Stories

Detail Return Return

前端數據拷貝簡史 - Stories Detail

本來是自己想了解下js中關於零拷貝的內容,順藤摸瓜瞭解了下相關歷史演進,便有了這篇文章。雖説是數據拷貝歷,但其中也夾雜了大量關於Ajax和SPA的歷史,也算是順着拷貝這條藤摸到的瓜,所以有點跑題。希望大家能開心吃瓜,如果有任何紕漏和補充,請在評論區暢所欲言,我們一起完善這段有趣的歷史。

一、為什麼我們需要拷貝?

小明已經有了一個羅技G102鼠標,但是他又買了一個,請問為什麼?答:因為怕第一個壞掉了(垃圾品控),或者…他想送給朋友。其實我們拷貝一份數據差不多也就這兩個原因。

拷貝主要有以下兩個用途:

  • 保護原始值:想在一個數據的基礎上做修改,但是又不想在他本體上動刀子,就可以複製一份出來。這樣可以避免意外的副作用,特別是在異步和多線程的情況下,要是大家都在原始數據上修改,最終會完全無法預測這個數據的變化過程。不可變性也是純函數的基石,對於函數式編程來説非常重要。
  • 跨區傳遞數據:瀏覽器特別是現代瀏覽器,為了安全等原因,對不同模塊和上下文會做內存隔離。比如窗口之間,worker線程之間,主線程和其它IO之間。相互隔離的內存區域是無法直接訪問對方的數據的。這時就需要調用系統提供的api,將數據拷貝過去。

image.png

由於原始類型的數據具有不可變性,生來什麼樣到死就是什麼樣,所以拷貝原始類型數據非常簡單粗暴,直接創建一個一模一樣的副本。包括進行賦值、傳參、運算等操作,都是進行的值拷貝。由於原始類型數據一般比較小,所以都放在棧中連續內存空間,因此拷貝速度非常快,不影響性能。

而JavaScript中的複雜數據拷貝,一直讓前端開發者們如鯁在喉。拿對象來説,一個對象可以包含各種原始類型數據,也能包含其它引用類型的數據。這就讓引用類型可以不停的套娃,對象套對象,對象套數組,數組套對象……無窮無盡。更可怕的是,引用類型的數據存儲在堆中,其內存空間並不是像原始類型數據那樣連續的!你不能像拷貝原始類型一樣,一體化注塑,直接複製整塊內存區域。你得像搜尋龍珠一樣集齊所有的內存碎片才能召喚出一樣的數據。還有js的原型鏈機制,引用類型上會有一大堆的隱式屬性;還有可能A引用B,B引用A這樣的循環引用。總之,拷貝複雜數據類型有一大堆的坑。

隨着各種網絡媒體的興盛,二進制數據處理也成了重要的一環,因此對二進制數據的拷貝,也是一個需要解決的問題。而且二進制數據不僅會在js的上下文之間拷貝,還會涉及操作系統層面的,比如GPU,各種IO設備等等。

其實值拷貝也不單純: 那些數字布爾值還好,頂天也就那麼大點,但是字符串可以是個無底洞啊,理論上你空間足夠,那字符串能一直長到長度突破數字的最大精度。難道一個超大的字符串,js引擎也要硬着頭皮每次操作都拷貝一份?實際上js引擎並沒有那麼老實,比如v8引擎中就有各種不同優化的字符串類型,會動態的控制是否只引用字符串,比如使用 “+” 來拼接字符串,只在必要的時候才創建新的內存區域。詳情可見:danbev的v8學習筆記。當然這些是js引擎底層偷偷做的優化,我們感知上依舊是每次創建新的不可變的字符串。

二、怎麼才算深拷貝?

上面説了,引用類型的內存零散的分佈在堆中,那如何才能集齊所有碎片克隆一個全新的數據呢?答案是遞歸,嵌套的對象,可以通過外層對象的引用,找到內層對象的引用,再根據內層對象的引用找到屬於它的原始數據,也能找到它更內層的對象的引用,如此往復,像傳教發展下線般一路找下去,直到沒有新的引用類型。

但是js中的引用類型不只包含我們顯示定義上去的數據,由於原型鏈機制,引用類型會繼承許多js內建的東西。那麼我們是否需要完整的把原型鏈也拷貝一份呢?通常來説不需要,我們看看MDN裏對深拷貝的定義

image.png

也就是説,我們不需要真的去複製原型鏈,只需要保證原型鏈的結構等價。原型鏈結構等價就是要保證兩個對象的繼承路徑一模一樣,因此允許共享同一個原型,畢竟對於相同的原型,繼承路徑肯定是一樣的。

我們來看一個拷貝案例

const a = {o: { data: 11}}
const b = {o: { data: 11}}

現在我説,b是a的拷貝。沒錯,匠人風格的純手工拷貝。但它確實符合深拷貝的定義,定義2的屬性名和順序肉眼可見的相同,然後我們先驗證定義的1和4:

// 返回false,滿足第一條不同對象
console.log(a === b);

// 返回true,畢竟字面量創建的對象都默認繼承自Object.prototype,保證了它們的原型鏈結構等價
console.log(Object.getPrototypeOf(a) === Object.getPrototypeOf(b));

第3條很有意思:它們的屬性的值是彼此的深拷貝。

這在語義上就是一個遞歸的定義,因此我們再手動遞歸驗證下b.o是a.o的深拷貝:

// 返回false
console.log(a.o === b.o);
// 返回true
console.log(Object.getPrototypeOf(a.o) === Object.getPrototypeOf(b.o));

總結,MDN定義的深拷貝有兩個關鍵點:

  1. 保證拷貝前後兩個對象結構一致,這一部分靠遞歸實現。
  2. 保證對應的引用類型原型鏈結構等價,這一般是先判斷引用類型的分類,是數組、對象、日期、Map、set、正則?然後調用js內建的構造函數new一個相同的容器,再複製數據。

當然,實際情況是靈活的,也許和定義略有不同。

首先是方法的拷貝:

想想我們拷貝的目的,其中之一就是創建一份獨立的數據,我們修改這個數據不影響原本的數據。這裏我們要建立一個數據(狀態)和行為分離的思想,行為就是js中的方法。拷貝方法是複雜且沒有必要的:

  • 因為方法通常不包含任何數據(狀態),而拷貝的核心恰恰是對數據的拷貝。
  • 可能依賴於創建時的詞法作用域。
  • this指向也可能被改變。
  • 難以序列化和反序列化,性能開銷大。
  • 安全問題。
  • ……

由於困難重重,所以大多數的深拷貝庫和api都放棄了方法的拷貝,默認認為方法不需要拷貝或由原型來管理。

其次是實用至上的拷貝哲學:

拷貝這份數據定是要拿來使用的,但通常情況下我們不會用到原本數據的全部,比如它原型繼承的一些雜七雜八的方法屬性,所以這些東西漏掉了似乎也沒什麼影響。因此我理解的深拷貝就是:遞歸的拷貝那些我們需要的東西。後面我們能看到,很多深拷貝方案其實都是殘缺的,但是不妨礙成為我們的好幫手。

三、上古時期:兼職的JSON和手搓的深拷貝

JSON起源

最早期瀏覽器和服務器之間傳輸複雜數據,使用的是XML,這種數據結構非常繁瑣。當時的js對象是有字面量寫法的,時任雅虎架構師的Douglas Crockford發現 eval() 可以直接將對象字面量字符串還原成數據:

// 服務端返回這樣的字符串
const dataString = '{"name":"John", "age":30, "cities":["NY","LA"]}';
// eval()函數可以將字符串當作js代碼執行,相當於一個小小的js解析器
// 這段字符串被當作 對象字面量語法,直接用這些數據創建了一個對象
const data = eval('(' + dataString + ')');  

這是個革命性的發現,意味着服務端可以返回js對象字面量這樣簡潔優美的數據形式,瀏覽器也可以快速進行解析。 eval()是一個非常危險的函數,它會無差別的執行任何js代碼,也包括惡意腳本,使用它很容易被跨站腳本攻擊(XSS)。因此Crockford從js支持的數據類型中選出最安全最實用的那些,也對比了其它編程語言支持的類型,得到一個安全子集,並起名為JavaScript Object Notation,簡稱JSON。由此,縱橫未來二十多年,還會繼續流行下去的明星數據交換格式誕生了。

2002年,Crockford創建了json.org網站,在上面發佈了自己對JSON概念的構思,將這個偉大的發明分享給了全世界。如下是JSON支持的類型:

image.png

JSON API的誕生

隨着web2.0時代的興起,簡潔實用的JSON一炮而紅。但JSON數據的解析一致依賴於 eval() 這個危險的函數,它經常被evil的人利用,用來執行惡意代碼。社區開發出各種方法來保證僅僅解析JSON數據,而不執行代碼。Crockford開發了一個名為 json2.js的庫來解決這個問題,這個庫提供了大家耳熟能詳的JSON.parse()函數,通過嚴格的詞法語法分析來合法的解析JSON數據。

2009年,ES5標準發佈,JSON.parse()JOSN.stringify()也響應開發者的需求,正式納入標準,隨後被各大瀏覽器實現。由於有瀏覽器的底層優化,所以性能比js庫版本更好,從此我們便告別了危險的 eval() 函數。

JSON兼職深拷貝

從前文的歷史可以看到,JSON本就脱胎於js對象,因此它被用於克隆對象也算是一種宿命。但這種技巧的侷限也來自JSON本身,它受安全子集的約束,因此只能識別JSON規範規定的那寥寥幾個基礎的數據類型。不管什麼複雜的數據結構,只要成功被JSON.stringify序列化,再用JSON.parse反序列化後,所有數據都會變成那幾種基礎的數據類型。更不用説實際複雜數據中,還有可能有很多無法被正確序列化的數據,還可能存在循環引用。JSON終究不是專業搞拷貝的,並沒有為這些拷貝中的特殊情況作準備,就像一個算命先生因為會算數,被村裏推舉當了會計,不過這帳算錯了,可不要推到JSON頭上。具體JSON拷貝有哪些侷限,問問萬能的AI,這裏不再贅述。

古時候的js通常只有一個主線程,因此拷貝通常也只發生在一個上下文內,數據的傳輸也主要面向網絡IO。古代程序員喜歡用JSON的序列化和反序列化來深度拷貝js對象(當然現代程序員也喜歡,夠用就用)。這其實是可以理解的,還記得我前面説過我對深拷貝的理解嗎?——遞歸的拷貝我們需要的數據。由於前端要處理的數據,大部分來自網絡傳輸,或者是為網絡傳輸準備的。而網絡數據的格式早已經是JSON的天下了,因此我們需要的數據往往是和JSON重合的!這也是使用JSON來深克隆數據的現實基礎,只要JSON還統治互聯網,那用JSON進行深克隆就會一直經久不衰。我們只有清楚的知道自己要拷貝的目標,才不會被JSON的侷限性影響。

深拷貝的各種庫實現

JSON到09年才成為事實標準,同年才首次加入主流瀏覽器,在這之前也存在深拷貝需求。並且雖然JSON進行簡單的深拷貝很方便,但是我們總會遇到更復雜的數據。因此深拷貝最正統的做法,依舊是開發者自己手寫深拷貝。

比如Date日期類型,日期數據繼承自 Date.prototype ,原型上有很多日期相關的方法。很明顯JSON中不存在Date這個類型,當我們 JSON.stringify(date) 的時候,會尋找date實例的 toJSON() 方法,而Date.pototype上正好有這個方法(實際上原生的也只有Date類型有這個方法),它返回一個 toISOString()返回的字符串,形如 YYYY-MM-DDTHH:mm:ss.sssZ 。到頭來,date對象變成了一個字符串,使用JSON.parse反序列化也會將其當成普通字符串處理,最終克隆出來的date變成了字符串。

這只是JSON解析對象限制的典型個例之一,JSON還沒法解析循環引用,只要遇到循環引用就會報錯。

總之,將數據先JSON.stringify再JSON.parse,這套連招與其説是拷貝,不如説是一個漏斗,將JSON解析合法的數據給篩選出來。

為了實現更完善的深拷貝,大家只能自己手搓,各種流行庫也實現了自己的深拷貝工具,比如jQuery、lodash。它們都是通過遞歸,或者迭代模擬的遞歸,逐漸構建拷貝對象。並且對特殊的js內建類型做判斷,比如這裏的Date,還有後來出現的Map、Set等作了判斷,使用內建的構造方法生成同種容器,再拷貝數據。也會使用類似下面的代碼,保證原型結構的等價:

// 用對象自己的原型,創建拷貝的對象
Object.create(Object.getPrototypeOf(src))

這些庫實現往往還給了開發者自定義的空間,比如lodash提供的 cloneDeepWith ,允許開發者自定義拷貝方式:

// 自定義的拷貝方式
const customizer = (value) => {  
  // 如果值有自定義的 .clone 方法,就用它!  
  if (typeof value?.clone === 'function') {  
    return value.clone();  
  }  
  // 對於其他任何值,讓 Lodash 按默認方式處理  
  return undefined;  
}; 

const clonedData = cloneDeepWith(data, customizer);  

這又回到了前面説的,我們要拷貝的是我們需要的,而自定義的拷貝方式,就給了我們靈活篩選的空間。

四、HTML5時代:結構化克隆初具雛形

走出單一上下文

自1995年JavaScript誕生後,網頁從靜態走向動態,同時也給瀏覽器帶來了許多新功能。比如彈窗,我們可以用window.open方法打開一個獨立的新窗口。還有我們熟知的內聯框架(iframe),允許頁面內套一個頁面,它擁有自己獨立的文檔環境。

隨着開發者們對這些窗口的深度使用,窗口間通信的需求愈發廣泛,比如大型門户網站不同子域名間的通信、身份驗證窗口、同步支付窗口的狀態等等。由於那時候沒有一個規範的跨窗體數據傳輸方式,並且還有同源策略的阻礙,所以各種hack技巧百花齊放。比如修改document.domain、使用不會刷新頁面的hash,甚至將要傳輸的數據存到window.name裏面等等,甭管你廚子戲子痞子,只要能跑腿的,都被抓來報信了。

官方窗口通信問世

hack方法終究是旁門左道,各種安全問題、性能問題和限制層出不窮。因此WHATWG HTML5起草了官方的窗口間通信API。2005年Opera8率先實現了postMessage的原型,此事在這篇關於框架通信安全的論文裏亦有記載,但和現在的postMessage不同的是它存在於document對象上。2008年Firefox3正式實現了我們熟知的window.postMessage,隨後幾年遍普及到了所有的主流瀏覽器。

最早的postMessage只能傳輸文本數據,因此想要在窗口之間傳輸複雜數據,就需要進行序列化和反序列化。後面出現的JSON api和postMessage一拍即合,成了跨窗口傳遞數據的好兄弟,先用JSON.stringify序列化成字符串傳輸到另一個窗口,另一個窗口再用JSON.parse將其還原成數據。

早期的postMessage還存在各種安全問題,並且字符串能承載的信息終究有限,那些超出JSON處理範圍的數據,仍要依賴開發者手動處理,js急需一種原生的拷貝和傳輸對象的方式。

worker帶着結構化克隆橫空出世

實際上window.postMessage的使用者們並沒有被折磨太久,因為有其他地方更加渴求原生的深拷貝方法,所以HTML5標準早早的就在籌備這一方法。

日趨複雜的網頁建設需求,大規模計算的場景在網頁端越來越常見,而單線程的js在進行長時間計算時,會阻塞UI渲染,導致頁面卡頓。Web Worker便在這一背景下應運而生,前端走向了多線程時代,我們可以創建單獨的線程來承載繁重的計算任務,讓我們的主線程只管歲月靜好,安心渲染頁面。2009年,web worker在HTML5規範中正式被提出,同年便被主流瀏覽器實現。

大規模的運算任務往往伴隨着大規模的數據,比如解析大量的JSON數據,進行復雜的圖像算法。而將數據從主線程搬運到worker線程還用老一套的數據克隆方法就顯得繁瑣且不安全了。

由此,隨着worker一起發佈的還有結構化克隆算法,旨在安全的傳遞結構化數據。但是結構化克隆算法一開始並沒有暴露給開發者,而是作為瀏覽器內建的基礎設施,供其他功能模塊使用。

結構化克隆算法帶來新生態

結構化克隆算法總體也是採用遞歸+循環引用判斷的方式來進行深拷貝,由於它由底層實現,因此不需要在js層面對對象做序列化和反序列化。並且在c++層面做了許多的優化,讓它性能更優、安全性更好。

有了原生強大的深拷貝方法,那些急需這個功能的API便迫不及待擁抱上去。worker自誕生起便使用結構化克隆算法。worker也是使用一個叫postMessage的api傳遞數據,與window.postMessage不同,這個api底層調用結構化克隆方法來傳輸對象數據,免去了繁瑣的序列化和反序列化過程,直接一個方法搞定。

和worker與結構化克隆一同推出的還有indexDB,一個運行在瀏覽器中的關係型數據庫,其規範直接規定:任何由結構化克隆算法支持的對象都可以存儲

到2011年,Firefox引入快速發佈流程,一年之內從4.0升級到9.0。它在4.0版本率先實現了indexDB,並在6.0版本將結構化克隆率先用於window.postMessage。隨後postMessage的結構化克隆版本逐漸在所有的主流瀏覽器中普及,開發者們自此擺脱了傳遞數據前痛苦的序列化過程。

Ajax——微軟的烏龍球

結構化克隆算法還被用於存儲歷史狀態,這就涉及到ajax技術和SPA單頁應用的崛起,這部分歷史也蠻有趣,我決定跑個題講一講。

90s的早期網站,通過服務端腳本動態生成網站,每次請求都會導致頁面刷新,用户體驗非常差,這讓業界一直在摸索更優雅的局部刷新網頁的方式。早期人們通過插件的方式來解決這個問題,比如2000年左右微軟推出的ActiveX控件,也是一種瀏覽器插件。這讓網頁可以只有一個文檔,然後通過請求動態的更新內容,這時期便誕生了早期的SPA應用。

但微軟的outlook團隊希望在任何瀏覽器上可以直接使用outlook郵箱,就能達到和桌面應用差不多的體驗,而不是還得先下個插件。因此他們開發了一種不刷新頁面便發起http請求的技術,並命名為XMLHttpRequest,作為MSXML庫的一部分發布。為啥叫這個名字呢,僅僅是因為XML火,想蹭,實際上和XML沒啥關係,它是個通用的http請求工具(和JavaScript坐一桌很合適)。

1997年微軟便開發出了動態HTML也就是DHTML技術,以DOM為核心,讓js可以控制頁面元素,讓頁面可以根據用户的操作做出變化。XMLHttp的問世,意味着在不刷新頁面的情況下,請求網絡數據改變頁面內容成為可能,讓web應用也能擁有接近桌面端的體驗。

隨後幾年,主流瀏覽器也紛紛支持了XMLHttpRequest。由於種種原因,這項技術出現的前兩年並沒有濺起太大水花,而開發這門技術的outlook團隊,發現即使支持了局部刷新頁面,應用的體驗依舊和桌面端天差地別,這也和當時瀏覽器性能和網速的限制有關。這時的微軟也並沒有重視這門技術,一邊把java移植到IE瀏覽器中,試圖在瀏覽器上運行java小程序(沒錯,正統的java),結果得罪了太陽微系統公司,在微軟壟斷案的大背景下惹了一身官司;另一邊微軟轉頭搞起了自己的技術標準,開發XAML和Avalon,後者最終變成了WPF。

這次引領時代的機會落到了谷歌手中。2004年,谷歌推出的gmail大量使用了這種動態加載的技術,其接近桌面應用的絲滑體驗震驚世人,使其成為了SPA模式的里程碑產品。2005年穀歌繼續發力,推出了谷歌地圖,將這種技術的應用邊界繼續拓展,地圖塊動態的在頁面上加載,用户可以進行無限的滾動,這在那個時代看來簡直是進入了未來。谷歌地圖發佈到當年2月,科技作家Jesse James Garrett也被這兩個現象級應用震撼,在洗澡的時候靈感爆發並給這種技術起了個名字,隨後發表了一篇名為Ajax: A New Approach to Web Applications的文章,將這種技術命名為Ajax,並下了定義。如我們所知,Ajax就是Asynchronous JavaScript and XML的縮寫,這個縮寫恰到好處,大大提高了這項技術的傳播度,使得本就如日中天的Ajax技術傳播的更加廣泛。

image.png

Ajax讓web有了和桌面掰手腕的勇氣,web應用的易用性和功能性進入了黃金分割點。在隨後的日子裏,web不斷的蠶食桌面應用的市場。而桌面端正是微軟的主戰場,微軟一手締造了web應用的地基,卻讓別人築起了高樓。當然,這是多方面因素導致的,路線的錯誤、技術營銷的薄弱、對壟斷的過度自信等等。

谷歌的早期格言“Don’t be evil”的evil常常被認為是暗指微軟,谷歌主張基於標準來行動,以此與任何意識形態劃清界限,矛頭直指試圖圍繞Windows平台建立自己標準的微軟。這不禁讓人聯想到百度,百度的前瞻性始終讓我印象深刻,從搜索引擎,雲計算,到自動駕駛,再到AI大模型,百度無不走在最前列。但似乎最終這些關鍵詞和百度的聯繫都會漸漸隱去,一波又一波的浪潮之後,百度成了觥籌交錯的酒局裏喝可樂的那位,雖然還在桌上,但早已不是大家的焦點。

關於XMLHttp的歷史,可以參考XMLHttp的締造者Alex Hopmann的一篇文章The story of XMLHTTP。裏面記敍了XMLHttp的誕生過程,還有一些對微軟錯過這波浪潮的思考。另外還可以看看hacknews上這個討論XMLHttp創造者的帖子

不要刷新網頁!

迴歸主題,SPA應用越來越流行,單個文檔結合XMLHttp便可以滿足千變萬化的需求。新的痛點也隨之到來,為了不讓頁面刷新,網頁的url是不會改變的。由於網站只有一個url,所以url無法保存用户的狀態。比如你開了一個url為www.example.com漫畫網站,又打開了一卷漫畫看到第42頁,感覺很有意思想分享給好哥們。但是url只有孤零零的www.example.com,你把這個地址分享給好哥們後,還得給他説哪部漫畫的第42頁,他還得手動搜索這部漫畫再翻到第42頁。我相信你哥們會下樓旋三兩重慶小面,然後悠哉遊哉回家,敷衍的回你個——“真不戳👍🏽”。你以為他説的漫畫,其實他在説面。

我們現在已經習以為常,翻到網站的哪一頁,複製url再打開就是那一頁,甚至還能保留裏面的狀態(不知道現在從小玩手機長大的小朋友,還會不會複製鏈接的操作)。此外還有一個類似的問題,當我們點擊回退按鈕時,由於沒有存儲狀態,因此回退並不能返回上一個頁面,這就是著名的後退按鈕問題。

而這些狀態必然需要讓url來攜帶。那有什麼辦法可以讓url攜帶一串數據,又不讓頁面跳轉呢?通過前面的歷史,我們可以發現程序員們非常擅長物盡其用。這次被迫搞副業的,就是哈希。

Hash路由模式的由來

hash自上古時期便存在了,可以追溯到1994年的RFC1738,它在規範中被稱為Fragment Identifier(片段標識符)。“#” 符號是不安全字符,必須進行編碼。到了2005年的RFC3986,URL通用語法標準正式定義了Fragment Identifier。

簡單來説,hash就是用來做頁面內的導航的。url後面跟一個 # 號,# 號後面跟個id名,用這個url就可以跳轉到頁面內對應id元素的位置。以掘金為例,我們點擊旁邊的目錄,就會改變url的hash部分,跳轉到頁面對應的位置,掘金會自動在標題元素上加入heading-1之類的id:

image.png

hash確實是用來導航的,只不過是頁內導航,那它是怎麼做到兼職頁面之間的導航的呢?

我們在#後隨便輸入一個頁面內肯定沒有的id,敲擊回車,此時頁面沒有任何反應,不會滾動也不會跳轉。這不正好符合我們想要url攜帶信息,但是又不跳轉的需求麼?我們只需要監控url中hash部分的變化,就可以獲取對應的頁面狀態。

我們都知道監聽hashChange事件可以很方便的監聽hash的變化,但是這裏有個問題:SPA應用在2004年就開始大放異彩,而hashChange這個API實際上到了2009年才率先被IE8和Safari實現。這四年間人們要怎樣去監聽hash呢?

主流方法有兩個,一個是用setInterval定時器輪詢,不停監聽url的變化。由於每次改變hash也算是一次新導航,因此都會加入瀏覽器歷史記錄,也就解決了後退按鈕問題。jQuery的BBQ庫就是用這種方式進行實現的。:

In browsers that support it, the native HTML5 window.onhashchange event is used, otherwise a polling loop is initialized, running every…

還有一種稱為隱藏iframe的奇技淫巧,這個主要是用來處理後退按鈕問題的。大概流程就是創建一個隱藏的iframe,每次頁面狀態改變就改變iframe的url(通常不是hash,而是真的改變了文檔),以此觸發瀏覽器的導航歷史,解決了後退按鈕問題,一些狀態數據也可以存到iframe裏。當然主頁面的url是不會改變的,所以那時主流還是用定時器輪詢的hash方案。

history路由模式的由來

由於SPA應用越來越流行,hash始終不是專為SPA而生的,傳統方案有諸多性能和功能侷限。因此開發者們非常需要一種原生的解決SPA路由的方案。

現在有兩種方案,第一種就是讓hash轉正,設計一個原生的監聽hash變化的機制。第二種就是設計一種url路徑變化,也能控制它不刷新頁面的方式,讓url保持大眾心中最初最完美的模樣。後來我們都知道了,搞瀏覽器的大人們表示 我全都要!

image.png

hash轉正後就是 hashChange事件,而後者就是2008年加入HTML5規範的 History API。由於這幾年來hash模式在SPA領域已經深入碼心,無論是從兼容性、web社區漸進增強的哲學角度,都應該將其保留下來。所以現在的一些路由庫做兼容性處理時,會把hash路由從hashChange模式退回到定時器模式。

History API是一種面向未來的模式,美觀的URL更符合我們的直覺,沒有醜陋的#符號(或者説不會和作為頁面錨點的#符號糾纏不清)。它很像是一種對隱藏iframe方案的原生改良,通過history.pushState()添加歷史,在不觸發重加載的情況下修改url。由於當時的主流依舊是hash模式,所以瀏覽器廠商們優先在2008年開始支持hashChange事件,到了2010年才開始着手實現history API。

然而History API有自己的特殊能力和特有的缺陷,所以它和hash模式不能完全互替,這也是經典面試題之——請説説hash路由和history路由的區別。

需要服務端的配合:由於SPA應用只有一個html文件,所以只更新url在運行中沒有問題,但是一旦刷新當前頁面,就會觸發http請求。由於瀏覽器發起http請求,是不會攜帶#後的hash部分的,所以hash模式不管在哪個路由下發起http請求都是一樣的,都能請求到同一個html文件。但是history模式不一樣,不同路由的url,會發起不同路徑的http請求,會向服務端請求不同路徑下的html文檔,如果不加配置就會找不到對應的文檔,也就返回404了。從這方面來説,hash模式是純前端的路由,稍微方便一點。

修改URL的時候可以添加數據:history.pushState()history.replaceState() 的第一個參數,都可以傳入一個數據。這個數據會保存到導航歷史記錄中,當我們從其它歷史記錄導航回來,可以通過 popstate 事件取回這個數據。而這個數據是存儲在瀏覽器的session history中,其內部就用到了結構化克隆算法。包了這麼大盤餃子,就為了這點醋,彎彎繞繞我們終於回到了主線。也就是説,導航記錄中可以存儲的數據,就是結構化克隆算法支持的數據。結構化克隆算法到了2009年才橫空出世,是構成history API的重要基礎,我猜想這也是History API比hashChange事件晚支持兩年的一個原因。

五、結構化克隆算法的第一次大升級

2009年結構化克隆算法剛誕生時,只能克隆一些常見數據類型,比如基本數據類型、對象和數組。

2010年:支持稀疏表

JavaScript中的數組和我們上課時學習的數組很不一樣,如果你是計算機相關專業或是學習過c、java等語言,一定知道通常説的數組有以下特點:

  • 連續的內存
  • 固定的長度
  • 相同的數據類型

但我們知道,JavaScript中的數組可以加入不同的數據類型,還能動態的進行擴容。難道Javascript之父Brendan Eich 上課也在划水?大家應該都聽説過Eich 十日造js的傳説,js最初的使命是作為一種膠水語言來粘合網頁元素和java小程序(1995年網景公司和太陽微系統公司合作,準備在自家瀏覽器中加入java applet,JavaScript這個名字也是這個背景下誕生的)。這種語言需要足夠的靈活、動態和方便,還要基於原型和支持函數式編程,時間緊任務重,所以Eich最終多方權衡下,選擇用哈希表來作為數組的基礎。

哈希表(hash table),也就是散列表,是一種基於key來尋找數據的數據結構。也就是説js中的數組是根據索引這個key來找對應的value,這些value不必連續,可能分散在內存的各處。而散列表也給稀疏列表奠定了基礎:數組的索引只是key的話,我們是可以不定義這些key的,也就是説索引可以不存在,這就是所謂的空洞(hole),這種遍佈空洞的哈希表就是稀疏表。(稀疏表的空洞表示這個位置真的什麼都沒有,如果有索引並且值是undefined,實際上也算是有值的)。

const arr = [1,,2,3]
console.log( 1 in arr ) // false
// 在arr中找不到 1 這個索引,就形成了一個空洞
// forEach、for in 等遍歷方式會跳過空洞

最早的結構化克隆算法對稀疏表支持不力,主要表現為會把空洞填充為undefined。從而稀疏數組就變成了密集數組,一方面丟失了稀疏數組的性能優勢,另一方面克隆後數據結構被破壞,可能影響程序穩定。

因此2010年這個問題被修復,序列化時能精確的區分空洞和undefined。

我們之前説過現代瀏覽器並不老實,底層做了很多優化,數組的實現並不是完全的哈希表。畢竟在不同數據規模下,哈希表和傳統數組的性能有所差別。以v8引擎為例,採取了快慢數組的方式,動態的選擇兩種模式,推薦這篇文章:探究JS V8引擎下的“數組”底層實現

2011年:真正支持複雜對象

前面説過,深拷貝的一個重點就是拷貝js內建的對象,需要使用內建方法重新new一個新容器,以此保證原型鏈一致性。而那個時代用的最多的內建對象便是Date和正則表達式,可最初的結構化克隆算法不支持,這在2011年得到了解決。

這一年還有一件大事,由於webgl需要頻繁操作大規模的數據,js急需一種高效處理二進制數據的方式,ArrayBuffer和TypedArray便應運而生。至此,二進制數據也正式加入了JavaScript大家庭,所以結構化克隆算法也支持了ArrayBuffer和TypedArray。

有了這些升級,我們就可以在worker線程間通過postMeassage傳遞二進制數據,讓多個線程處理webgl中用的大量數據提高性能。也可以將二進制數據存入indexDB、導航記錄中…

總之結構化克隆之後的升級之路,都是為了匹配Javascript升級路上不斷增多的數據類型。

真假拷貝:可轉移對象和零拷貝

這裏也是本篇文章誕生的緣由,之前搞threejs用到了可轉移對象,以此為切入點深入,調查背後的歷史,便有了這篇文章。

之前的結構化克隆算法,真的是老老實實在克隆。比如我們使用threejs,希望新開一個worker線程來處理一些紋理數據,從主線程中將紋理數據傳輸過去,就得把這份數據拷貝一份。這是物理上的主線程一份,worker線程一份,都在各自線程獨立的內存空間中。然而現實情況中這些紋理、圖像的二進制數據體積可能是很大的,這一拷貝操作會相當耗時,可以從谷歌的測試中看出,直接拷貝32MB的數據耗時可高達數百毫秒。

線程間的零拷貝

為了解決這一問題,可轉移對象應運而生,ArrayBuffer被升級為了可轉移對象。主線程和worker線程都在同一進程中,所以使用的是同一片虛擬內存空間,而線程之間的內存隔離是js引擎來主持的,相當於js引擎給這塊內存裏面的數據都頒發一個身份證,標記你屬於哪個線程的,只有那個線程能用你。而可轉移對象的原理,就是把這個數據的身份證給換了,也就是把所有權給移交了,數據本身還是躺在那片內存裏。當然,所有權移交後,原本的線程就失去了對這個數據的訪問權。

我們可以用如下方式使用可轉移對象模式:

const buffer = new ArrayBuffer(1024) 
const worker = new worker('xxx.js')
 // 第三個參數就是可轉移對象列表,代表buffer這兒數據會被作為可轉移對象的方式傳輸
worker.postMessage(buffer, '*', [buffer]) 

console.log(buffer.byteLength); // 現在原本的數據就失效了

這就叫零拷貝,並沒有在物理上覆制一份,而是轉移所有權。(有點像引用類型的賦值)

進程間的零拷貝

除了線程間通信,開發中還會設計跨窗口通信,而由於瀏覽器的站點隔離策略,不同窗口可能跑在不同的進程中。而不同進程間的內存隔離是操作系統層面的,相當嚴格,瀏覽器是沒有操作的權限的,一個進程是絕對不允許訪問另一個進程的數據的。

跨窗口通信的API也是postMessage 同樣也支持可轉移對象,那既然這麼進程間的內存隔離這麼嚴格,要怎麼才能做到零拷貝呢?

事實上無法完全的做到零拷貝,瀏覽器會調用操作系統的API創造一片共享內存,共享內存是操作系統提供的一種可以讓不同進程共享數據的機制,先把要轉移的數據物理複製到共享內存中,再進行所有權的分配。只要數據不是原本就在共享內存中,至少得進行一次物理拷貝。

再回頭看看非可轉移對象模式在這種情況下的表現。進程間傳遞消息靠的的是IPC通信,如果你學過操作系統課程,應該會教你用管道來進行各種進程間的操作。以chrome為例,chromium團隊將這個操作系統能力封裝成了一個叫mojo的框架,用於更方便的進行進程間通信。非可轉移對象的拷貝方法要進行數據傳輸,靠的就是IPC(當然可轉移對象模式也需要IPC進行協調)。數據會先被拷貝到一個發送緩衝區,再發送到IPC緩衝區,再發送到讀取緩衝區,最後進入目標進程,其中每一步都涉及真實的物理拷貝,因此開銷巨大。當然這是一個簡化的過程,實際過程複雜的多,我也沒有能力完全搞懂。

這種OS層面的零拷貝方式其實更符合大家印象中的零拷貝,我們直接去搜零拷貝出來的多半也是這方面的知識。

image.png

2011年上線ArrayBuffer後,很快大家就發現了性能問題,可轉移對象在2012年就得到了支持,也算響應的非常迅速了。

六、2012往後:不斷進化的結構化克隆算法

隨着js的發展,越來越多的新成員加入js大家庭,也隨着大家的實踐,社區對結構化克隆算法也有了更多的期盼。結構化克隆算法就這樣不斷的被完善、被擴充,下面就簡要的概述一下:

支持getter/setter

2012年有一個很重要的更新,增加了對getter/setter的支持。以往的結構化克隆遇到getter屬性,會忽略或者拋錯,總之無法處理。而很多內建對象的對外暴露的字段,都是getter屬性,機構化克隆後會造成相關字段丟失。特別是2012年普及開來的Map和Set,它們的size屬性都是隻讀的getter。更新後的結構化克隆算法遇到getter屬性會先調用一下,獲取到它的值再克隆到新的數據中去。

Map和Set加入js了,getter也支持了,自然也支持克隆Map和Set了。還增加了對Error、Blob、ImageData等類型數據的支持。

命途多舛的SharedArrayBuffer

2016年-2017年,主流瀏覽器廠商是實現了SharedArrayBuffer,用於多個線程間共享內存。結構化克隆算法也增加了對SharedArrayBuffer的支持,SharedArrayBuffer不是一個可轉移對象,因此經過結構化克隆後,目標線程中會有一個新的SharedArrayBuffer對象。你可能會問,內存不是共享的嗎,為啥又真的複製了一份?你可以把SharedArrayBuffer對象看做是一個入口,我們複製的只是這個入口,兩份SharedArrayBuffer對象指向的其實是同一塊內存區域。

SharedArrayBuffer的發展也是一波三折,2018年1月研究人員發現了Spectre和Meltdown CPU漏洞SharedArrayBuffer成為了這些攻擊的理想工具。因此各大瀏覽器緊急下線了SharedArrayBuffer,直到同年年中才恢復了部分功能,直到2020年才開始逐步的全面恢復。

image.png

Spectre和Meltdowm漏洞:簡單來説就是CPU廠商為了優化性能,開發出了分支預測功能,會提前執行分支中的內容並將數據準備到高速緩存中,若真的執行了這個分支就可以直接取緩存,若沒有執行這個分支,那麼扔掉即可。這個提前執行並沒有考慮程序的內存邊界,因為CPU認為程序真正執行到這裏自會判斷,要是越界了自會終止。並且若沒有執行預測的分支,只會清理寄存器中的數據,而不會清理緩存的數據。這恰恰給了黑客機會,惡意程序可以誘導分支預測器緩存一個越界的數據,在真正執行時又不執行這個分支,而是用合法代碼讀取緩存中的值。那要怎麼知道哪個值是緩存的值呢,普通值讀取在內存中,而緩存值在更快的高速緩存中。這就可以用到基於時間的側信道攻擊,我們計算哪個值訪問的時間比別人快,就可以確定它是緩存的值。下面是網友對這個過程一個形象的描述:

一名常客經常點飯館的炒飯 以後常客來的時候廚師想都不想就直接給做炒飯
如果常客變了口味,廚師頂多不把炒飯給常客,就擱置在一邊 然後接下來有個壞人,説隨便點個吃的能飽流行,然後廚師把擱置的炒飯給他。
壞人得到了信息: 常客喜歡吃炒飯。
那如何治本呢?讓廚師把擱置的炒飯扔掉。 那就浪費了做炒飯的時間了,這個期間做了毫無意義的事情。
那廚師以後不要自作主張做炒飯了行不?當然可以,那廚師以後就得等常客點菜,假如一個這是個大飯館,而且廚師只有一個,顧客還很多,那在常客想好要吃啥的時候,後面的顧客一直等。
也就是cpu性能降低,所以這個預測執行,只要是個現代cpu都是標配

從上面描述可以看出,要進行Spectre和Meltdown攻擊,需要對時間精度的要求很高,因為CPU高速緩存的存取都是納秒級別的。瀏覽器原本有個能精準計時的API叫perform.now(),在漏洞爆出後精度被緊急改為5微秒甚至100微秒。同時SharedArrayBuffer也可以構建納秒級別的計時器,其原理就是創建一塊共享內存,讓一個worker線程在裏面瘋狂計數。這個計數操作由於直接操作共享內存,所以速度非常快,可以達到納秒級別,主線程只需要讀取這個共享內存的計數,就可以獲得當前的精準時刻。(當然其中有很多精度校準的細節)

修復:為了應對這兩個漏洞,瀏覽器廠商在底層數據訪問和SharedArrayBuffer上做了很多優化:

  • v8加入了JIT毒化,破壞內存訪問時間的精度
  • 從CPU調度到操作系統層面來干擾時間精度
  • 內存層面緩存污染、訪問模式隨機化
  • 2020年後,解決方案標準化,SharedArrayBuffer只能在跨源隔離條件下使用,具體就是使用 'Cross-Origin-Opener-Policy': 'same-origin''Cross-Origin-Embedder-Policy': 'require-corp' 兩個響應頭

更高性能的Canvas

canvas是瀏覽器提供的繪圖API,可以高性能的實現複雜繪圖。為了提高Canvas的渲染性能,也是為它量身打造了一些數據類型,這些數據類型也被加入到結構化克隆算法的菜單中。由於我的canvas使用經驗不多,這裏作簡單的介紹。

ImageData:實際上結構化克隆誕生之初就支持了這種數據。如果你接觸過一些圖形學知識,應該能瞭解到,圖像其實就是一個描述了每個像素色彩的矩陣。而做圖像處理,就是對這個矩陣做一系列的數學變換。ImageData就是描述了這樣一個矩陣的ArrayBuffer,可以通過 ctx.getImageData() 從canvas元素中取得這個矩陣,也可以通過 ctx.putImageData() 將一個矩陣數據賦予canvas元素。

值得注意的是,ImageData本身並非可轉移對象,它的data屬性是一個Uint8ClampedArray,這是能作為可轉移對象的。

ImageBitmap:ImageData數據是用來給CPU操作的,如果用GPU繪製,需要讀取到GPU中。隨着GPU的普及,我們需要一個更高效渲染圖像的方式。因此在2015到2016年,ImageBitmap被納入HTML標準並被瀏覽器廠商實現。ImageBitmap持有一個對位圖的引用,可以直接傳遞到GPU中渲染。ImageBitmap最大的特徵是不可變,創建了就不能修改數據了,因此我們也不能對圖像做各種數學變換。ImageBitmap還是可轉移對象,因此在多線程情況下,有非常好的性能。比起ImageData只能從Canvas上下文獲取,ImageBitmap可以從多種源中獲取,比如:

  • HTMLImageElement
  • HTMLVideoElement
  • HTMLCanvasElement
  • ImageData
  • Blob
  • 其他ImageBitmap對象

總的來説,ImageBitmap提供了一種高性能的位圖繪製方式。

關於上面兩者的區別,可以看這個stackoverflow的討論

OffscreenCanvas:我們知道操作Canvas需要獲取Canvas元素的上下文,通過這個上下文來進行一系列的繪製動作和數據處理,最終渲染到頁面上。雖然最終都需要在主線程中進行渲染,但數據處理和繪製動作的設定為何不放到一個新的線程中呢,等一切準備好再送回主線程渲染不就好了嗎? OffscreenCanvas應運而生,顧名思義,離屏的Canvas,環境中不需要有Canvas元素就能進行繪製。我們可以通過如下方式創建一個離屏Canvas,並送到worker線程。這裏就用到了postMessage和可轉移對象,當然和結構化克隆算法脱不開關係。

const canvas = document.getElementById('myCanvas');
const offscreenCanvas = canvas.transferControlToOffscreen();

const worker = new Worker('canvas-worker.js');
worker.postMessage({ 
    canvas: offscreenCanvas,
    width: 800,
    height: 600
}, [offscreenCanvas]);

OffscreenCanvas在2016年左右加入規範,2017年後逐步被瀏覽器廠商實現,其中safari近幾年才完整的支持。

像ImageBitmap和OffscreenCanvas這種新興的API,基本誕生之初就考慮到了結構化克隆算法的支持,因此只要出來就加入了結構化克隆的菜單。

七、舊時王謝堂前燕:structuredClone API問世

2021年以前的時代,結構化克隆算法一直是個內部API,只有“內部人”才可以使用,比如在誰用postMessage API的時候,瀏覽器內部會自動調用結構化克隆算法來進行深拷貝,開發者是完全沒有感知的。開發者要深拷貝自己的對象,還是得用前面説過的老法子。

深拷貝這麼常用的功能,明明有這麼高效的解決方案,居然藏着掖着不拿出來,屬實説不過去。實際上這幾年來,社區就已經對暴露這個API有山呼海嘯的需求了:https://github.com/whatwg/html/issues/793

終於到2021年,structuredClone()被正式添加到HTML標準中並完善,到2022年,主流瀏覽器基本都實現了這個API。由於是瀏覽器底層的實現,所以比純JS方案的性能要優異很多。關於structuredClone() API的設計有兩個有趣的地方:

採用同步設計

關於這個API是設計成同步還是異步,在上面那個issue中也有討論,這裏又充分體現出了web技術中權衡的藝術。

支持異步設計的一派可以看做是完美主義者,他們認為希望這個API能響應異步設計的哲學,就和fetch之類的API一樣,避免克隆大數據阻塞主線程,還能降低內存峯值。

支持同步設計的一派則是實用主義至上,異步雖然能解決很多的性能問題,但是大大增加了代碼的複雜度。並且就現實來講,處理大數據的克隆總是少數情況,一般需要處理的數據都能在瞬間完成克隆。就算需要克隆大型數據,也有替代方案,原本worker線程間通信的postMessage API 不就天然實現了異步克隆嗎。或者可以將大數據分片再加入事件循環中逐步克隆。

最終瀏覽器廠商也是走了實用主義路線,保證功能和易用性的平衡。

不支持原型鏈克隆

之前我們看到很多深度克隆的庫,支持自定義的克隆規則,讓我們可以為自定義的對象綁定自定義的原型鏈,從而達到全方位的原型鏈一致性。但是structureClone卻不支持自定義擴展,甚至嚴格禁止原型鏈的克隆。這就意味着,我們自己定義的一些類的實例,無法保證原型鏈一致性!

瀏覽器為什麼要在這裏和我們使絆子,其實也很好理解,最主要的就是安全問題。structureClone本身是用於給 postMeassage、indexDB等API提供底層支持的,而暴露出的structureClone() API也是共用這個底層能力。而這些API通常需要跨realm傳輸數據(realm可以簡單理解為js上下文,但指代更廣)。我們設想一下,如果structureClone 支持自定義克隆規則,或者複製自定義原型鏈,意味着什麼:

我們在另一個realm裏,比如另一個worker線程中接受到了主線程過來的數據,其中就包含自定義的類實例。克隆時要想重建這些實例,就得調用它們的構造方法,這便是最危險的地方,得執行一個函數。我之前就解釋過為什麼要禁止克隆函數,因為這裏面可以傳遞惡意代碼,更別説這裏直接就執行了。並且要從底層實現這個功能,肯定要加非常多的限制,讓系統變得非常臃腫。因此直接禁止是一個比較划算的選擇。

開發者要是需要克隆自定義的實例,可以使用structureCloneAPI後自己手動重建這些實例。

結構化克隆支持的列表

下面讓AI總結了下結構化克隆算法目前支持的和不支持的數據類型,以供大家參考

支持的數據類型

類型分類 具體類型 説明
基本類型 undefined 原始值
null 原始值
Boolean 原始布爾值和Boolean對象
Number 原始數字值和Number對象
BigInt 原始BigInt值和BigInt對象
String 原始字符串值和String對象
內置對象 Date 日期對象
RegExp 正則表達式對象
集合類型 Array 數組(包括稀疏數組)
Object 普通對象
Map Map集合對象
Set Set集合對象
二進制數據 ArrayBuffer 數組緩衝區
SharedArrayBuffer 共享數組緩衝區
Int8Array 8位有符號整型數組
Uint8Array 8位無符號整型數組
Uint8ClampedArray 8位無符號夾緊整型數組
Int16Array 16位有符號整型數組
Uint16Array 16位無符號整型數組
Int32Array 32位有符號整型數組
Uint32Array 32位無符號整型數組
Float32Array 32位浮點數組
Float64Array 64位浮點數組
BigInt64Array 64位有符號BigInt數組
BigUint64Array 64位無符號BigInt數組
DataView 數據視圖對象
錯誤對象 Error 標準錯誤對象
EvalError Eval錯誤對象
RangeError 範圍錯誤對象
ReferenceError 引用錯誤對象
SyntaxError 語法錯誤對象
TypeError 類型錯誤對象
URIError URI錯誤對象
Web APIs File 文件對象
FileList 文件列表對象
Blob 二進制大對象
ImageData 圖像數據對象
CryptoKey 加密密鑰對象

不支持的數據類型

類型分類 具體類型 錯誤類型 説明
函數 Function DataCloneError 所有函數類型都不支持
AsyncFunction DataCloneError 異步函數
GeneratorFunction DataCloneError 生成器函數
DOM相關 DOM節點 DataCloneError 所有DOM元素
HTMLElement DataCloneError HTML元素
Event DataCloneError 事件對象
高級對象 Symbol DataCloneError 符號類型
WeakMap DataCloneError 弱映射對象
WeakSet DataCloneError 弱集合對象
Promise DataCloneError 承諾對象
Proxy DataCloneError 代理對象
特殊對象 原型鏈 被忽略 克隆後丟失原型鏈
類實例 變為普通對象 失去類的方法和原型
已分離的緩衝區 DataCloneError 已被轉移的ArrayBuffer

後記

斷斷續續寫了好久,終於完成了。學習前端也剛好一年半了,學習的時候一直有種掣肘難行的感覺,無數的API學了忘、忘了學,最後記住的也只有常用的那寥寥數個。每一句代碼都好像是熟悉的陌生人,我寫下它、使用它、運行它,但似乎總是隔着一層紗,讓我有一種奇妙的疏離感。尤其是背八股文的時候,總覺得隔靴搔癢,那些答案往往都點到為止。直到有天看了一本書,叫做《前端跨界開發指南》,開頭講模塊化的時候,講了在那個沒有現代模塊化方案的年代,前輩們如何用盡已有的資源,開發出各種奇技淫巧來實現模塊化,這時我才明白了import這短短六個字母的分量。

我似乎理解了這其中的隔閡,前端是一個歷史包袱非常重的領域,我們現在用的很多API、工具、甚至代碼約定,都有着複雜的歷史變遷,規範和社區訴求你來我往,螺旋上升。我們用一個API時,它為什麼這麼用、為什麼是這種寫法、為什麼是這種方式運作,背後可能經歷了無數的拉扯。而瞭解背後的歷史,能讓我們知道來龍去脈,知道解決了什麼問題,能讓我們對代碼有更多的共鳴。

另一方面前端的環境非常隔離,瀏覽器幾乎完全隔離了開發者和操作系統的直接交互,工具封裝程度很高(似乎現代化的工具都這樣)。因此我們很難看到背後真正的運作過程,很多教程都是依據現象總結的經驗性理論,工作中夠用,但是感覺不真實。

好在前端方面的歷史雖然散亂,但都在互聯網上留了痕,各種issue和規範小組的討論存檔,都能在互聯網的大海中撈到。前端也是開源最盛行的領域,上至UI框架,下至瀏覽器內核,他們的倉庫都大門常開,各種標準和規範也是人人可查。這就給了我們從歷史和底層兩個方向深入學習的機會。

雖然這些東西好像對寫業務沒什麼太多幫助,不過個人感覺還是蠻有趣的,作為一個記憶力奇差的人,也是一個加深記憶的好方式。最後感謝大家的觀看,由於沒有多少實際開發經驗,因此文章中少不了各種疏漏,煩請各位大佬不吝賜教。

部分其它參考資源

Add a new Comments

Some HTML is okay.