本來是自己想了解下js中關於零拷貝的內容,順藤摸瓜瞭解了下相關歷史演進,便有了這篇文章。雖説是數據拷貝歷,但其中也夾雜了大量關於Ajax和SPA的歷史,也算是順着拷貝這條藤摸到的瓜,所以有點跑題。希望大家能開心吃瓜,如果有任何紕漏和補充,請在評論區暢所欲言,我們一起完善這段有趣的歷史。
一、為什麼我們需要拷貝?
小明已經有了一個羅技G102鼠標,但是他又買了一個,請問為什麼?答:因為怕第一個壞掉了(垃圾品控),或者…他想送給朋友。其實我們拷貝一份數據差不多也就這兩個原因。
拷貝主要有以下兩個用途:
- 保護原始值:想在一個數據的基礎上做修改,但是又不想在他本體上動刀子,就可以複製一份出來。這樣可以避免意外的副作用,特別是在異步和多線程的情況下,要是大家都在原始數據上修改,最終會完全無法預測這個數據的變化過程。不可變性也是純函數的基石,對於函數式編程來説非常重要。
- 跨區傳遞數據:瀏覽器特別是現代瀏覽器,為了安全等原因,對不同模塊和上下文會做內存隔離。比如窗口之間,worker線程之間,主線程和其它IO之間。相互隔離的內存區域是無法直接訪問對方的數據的。這時就需要調用系統提供的api,將數據拷貝過去。
由於原始類型的數據具有不可變性,生來什麼樣到死就是什麼樣,所以拷貝原始類型數據非常簡單粗暴,直接創建一個一模一樣的副本。包括進行賦值、傳參、運算等操作,都是進行的值拷貝。由於原始類型數據一般比較小,所以都放在棧中連續內存空間,因此拷貝速度非常快,不影響性能。
而JavaScript中的複雜數據拷貝,一直讓前端開發者們如鯁在喉。拿對象來説,一個對象可以包含各種原始類型數據,也能包含其它引用類型的數據。這就讓引用類型可以不停的套娃,對象套對象,對象套數組,數組套對象……無窮無盡。更可怕的是,引用類型的數據存儲在堆中,其內存空間並不是像原始類型數據那樣連續的!你不能像拷貝原始類型一樣,一體化注塑,直接複製整塊內存區域。你得像搜尋龍珠一樣集齊所有的內存碎片才能召喚出一樣的數據。還有js的原型鏈機制,引用類型上會有一大堆的隱式屬性;還有可能A引用B,B引用A這樣的循環引用。總之,拷貝複雜數據類型有一大堆的坑。
隨着各種網絡媒體的興盛,二進制數據處理也成了重要的一環,因此對二進制數據的拷貝,也是一個需要解決的問題。而且二進制數據不僅會在js的上下文之間拷貝,還會涉及操作系統層面的,比如GPU,各種IO設備等等。
其實值拷貝也不單純: 那些數字布爾值還好,頂天也就那麼大點,但是字符串可以是個無底洞啊,理論上你空間足夠,那字符串能一直長到長度突破數字的最大精度。難道一個超大的字符串,js引擎也要硬着頭皮每次操作都拷貝一份?實際上js引擎並沒有那麼老實,比如v8引擎中就有各種不同優化的字符串類型,會動態的控制是否只引用字符串,比如使用 “+” 來拼接字符串,只在必要的時候才創建新的內存區域。詳情可見:danbev的v8學習筆記。當然這些是js引擎底層偷偷做的優化,我們感知上依舊是每次創建新的不可變的字符串。
二、怎麼才算深拷貝?
上面説了,引用類型的內存零散的分佈在堆中,那如何才能集齊所有碎片克隆一個全新的數據呢?答案是遞歸,嵌套的對象,可以通過外層對象的引用,找到內層對象的引用,再根據內層對象的引用找到屬於它的原始數據,也能找到它更內層的對象的引用,如此往復,像傳教發展下線般一路找下去,直到沒有新的引用類型。
但是js中的引用類型不只包含我們顯示定義上去的數據,由於原型鏈機制,引用類型會繼承許多js內建的東西。那麼我們是否需要完整的把原型鏈也拷貝一份呢?通常來説不需要,我們看看MDN裏對深拷貝的定義:
也就是説,我們不需要真的去複製原型鏈,只需要保證原型鏈的結構等價。原型鏈結構等價就是要保證兩個對象的繼承路徑一模一樣,因此允許共享同一個原型,畢竟對於相同的原型,繼承路徑肯定是一樣的。
我們來看一個拷貝案例
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定義的深拷貝有兩個關鍵點:
- 保證拷貝前後兩個對象結構一致,這一部分靠遞歸實現。
- 保證對應的引用類型原型鏈結構等價,這一般是先判斷引用類型的分類,是數組、對象、日期、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支持的類型:
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技術傳播的更加廣泛。
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:
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保持大眾心中最初最完美的模樣。後來我們都知道了,搞瀏覽器的大人們表示 我全都要!
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層面的零拷貝方式其實更符合大家印象中的零拷貝,我們直接去搜零拷貝出來的多半也是這方面的知識。
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年才開始逐步的全面恢復。
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框架,下至瀏覽器內核,他們的倉庫都大門常開,各種標準和規範也是人人可查。這就給了我們從歷史和底層兩個方向深入學習的機會。
雖然這些東西好像對寫業務沒什麼太多幫助,不過個人感覺還是蠻有趣的,作為一個記憶力奇差的人,也是一個加深記憶的好方式。最後感謝大家的觀看,由於沒有多少實際開發經驗,因此文章中少不了各種疏漏,煩請各位大佬不吝賜教。
部分其它參考資源
- https://www.youtube.com/watch?v=lZW9zvdqnyw&list=WL&index=2 TPAC 2020的分組會議,討論cpu和gpu之間的內存複製問題對瀏覽器性能的影響
- https://github.com/danbev/learning-v8/tree/master 一個大佬的v8學習筆記
- https://developer.mozilla.org/zh-CN/ MDN,無需多言
- https://www.json.org/json-zh.html JSON最早的佈道網站
- https://caniuse.com/ 你可以在這裏搜索每個API的普及情況,能方便的查看最早支持某個API的是哪個瀏覽器
- https://matrixlogs.bakkot.com/ 一個歸檔網站,記錄了各種規範小組的羣聊記錄
- https://johnresig.com/ John Resig的博客,那個創造了jQuery的男人,有很多前端考古資料
- 影響全球的CPU漏洞深度解讀:熔斷與幽靈
- Fantastic Timers and Where to Find Them: High-Resolution Microarchitectural Attacks in JavaScript