從JavaScript的運(yùn)行原理談解析效率優(yōu)化
編寫高效率的 JavaScript ,其中一個(gè)關(guān)鍵就是要理解它的工作原理。編寫高效代碼的方法數(shù)不勝數(shù),例如,你可以編寫對(duì)編譯器友好的 JavaScript 代碼,從而避免將一行簡(jiǎn)單代碼的運(yùn)行速度拖慢 7 倍。
本文我們會(huì)專注講解可以最小化 Javascript 代碼解析時(shí)間的優(yōu)化方法。我們進(jìn)一步縮小范圍,只討論 V8 這一驅(qū)動(dòng) Electron, Node.js 和 Google Chrome 的 JS 引擎。為了理解這些對(duì)解析友好的優(yōu)化方法,我們還得先討論 JavaScript 的解析過程,在深入理解代碼解析過程的基礎(chǔ)上,再對(duì)三個(gè)編寫更高速 JavaScript 的技巧進(jìn)行一一概述。
先簡(jiǎn)單回顧一下 JavaScript 執(zhí)行的三個(gè)階段。
- 從源代碼到語(yǔ)法樹 —— 解析器從源碼中生成一棵 抽象語(yǔ)法樹。
- 從語(yǔ)法樹到字節(jié)碼 —— V8 的解釋器 Ignition 從語(yǔ)法樹中生成字節(jié)碼(在 2017 年之前 并沒有該步驟,具體可以看 這篇文章)。
- 從字節(jié)碼到機(jī)器碼 —— V8 的編譯器 TurboFan 從字節(jié)碼中生成圖,用高度優(yōu)化的機(jī)器碼替代部分字節(jié)碼。
上述的第二和第三階段 涉及到了 JavaScript 的編譯。在這篇文章中,我們將重點(diǎn)介紹第一階段并解釋該階段對(duì)編寫高效 JavaScript 的影響。我們會(huì)按照從左到右、從上到下的順序介紹解析管道,該管道接受源代碼并生成一棵語(yǔ)法樹。
抽象語(yǔ)法樹(AST)。它是在解析器(圖中藍(lán)色部分)中創(chuàng)建的。
掃描器
源代碼首先被分解成 chunk,每個(gè) chunk 都可能采用不同的編碼,稍后會(huì)有一個(gè)字符流將所有 chunk 的編碼統(tǒng)一為 UTF-16。
在解析之前,掃描器會(huì)將 UTF-16 字符流分解成 token。token 是一段腳本中具有語(yǔ)義的最小單元。有不同類型的 token,包括空白符(用于 自動(dòng)插入分號(hào))、標(biāo)識(shí)符、關(guān)鍵字以及代理對(duì)(僅當(dāng)代理對(duì)無法被識(shí)別為其它東西時(shí)才會(huì)結(jié)合成標(biāo)識(shí)符)。這些 token 之后被送往預(yù)解析器中,接著再送往解析器。
預(yù)解析器
解析器的工作量是最少的,只要足夠跳過傳入的源代碼并進(jìn)行懶解析(而不是全解析)即可。預(yù)解析器確保輸入的源代碼包含有效語(yǔ)法,并生成足夠的信息來正確地編譯外部函數(shù)。這個(gè)準(zhǔn)備好的函數(shù)稍后將按需編譯。
解析
解析器接收到掃描器生成的 token 后,現(xiàn)在需要生成一個(gè)供編譯器使用的中間表示。
首先我們來討論解析樹。解析樹,或者說 具體語(yǔ)法樹(CST)將源語(yǔ)法表示為一棵樹。每個(gè)葉子節(jié)點(diǎn)都是一個(gè) token,而每個(gè)中間節(jié)點(diǎn)則表示一個(gè)語(yǔ)法規(guī)則。在英語(yǔ)里,語(yǔ)法規(guī)指的是名詞、主語(yǔ)等,而在編程里,語(yǔ)法規(guī)則指的是一個(gè)表達(dá)式。不過,解析樹的大小隨著程序大小會(huì)增長(zhǎng)得很快。
相反,抽象語(yǔ)法樹 要更加簡(jiǎn)潔。每個(gè)中間節(jié)點(diǎn)表示一個(gè)結(jié)構(gòu),比如一個(gè)減法運(yùn)算(-),并且這棵樹并沒有展示源代碼的所有細(xì)節(jié)。例如,由括號(hào)定義的分組是蘊(yùn)含在樹的結(jié)構(gòu)中的。另外,標(biāo)點(diǎn)符號(hào)、分隔符以及空白符都被省略了。你可以在 這里 了解更多 AST 和 CST 的區(qū)別。
接下來我們將重點(diǎn)放在 AST 上。以下面用 JavaScript 編寫的斐波那契程序?yàn)槔?nbsp;
- function fib(n) {
- if (n <= 1) return n;
- return fib(n-1) + fib(n-2);
- }
下面的 JSON 文件就是對(duì)應(yīng)的抽象語(yǔ)法
了。這是用 AST Explorer 生成的。(如果你不熟悉這個(gè),可以點(diǎn)擊這里來詳細(xì)了解 如何閱讀 JSON 格式的 AST)。
- {
- "type": "Program",
- "start": 0,
- "end": 73,
- "body": [
- {
- "type": "FunctionDeclaration",
- "start": 0,
- "end": 73,
- "id": {
- "type": "Identifier",
- "start": 9,
- "end": 12,
- "name": "fib"
- },
- "expression": false,
- "generator": false,
- "async": false,
- "params": [
- {
- "type": "Identifier",
- "start": 13,
- "end": 14,
- "name": "n"
- }
- ],
- "body": {
- "type": "BlockStatement",
- "start": 16,
- "end": 73,
- "body": [
- {
- "type": "IfStatement",
- "start": 20,
- "end": 41,
- "test": {
- "type": "BinaryExpression",
- "start": 24,
- "end": 30,
- "left": {
- "type": "Identifier",
- "start": 24,
- "end": 25,
- "name": "n"
- },
- "operator": "<=",
- "right": {
- "type": "Literal",
- "start": 29,
- "end": 30,
- "value": 1,
- "raw": "1"
- }
- },
- "consequent": {
- "type": "ReturnStatement",
- "start": 32,
- "end": 41,
- "argument": {
- "type": "Identifier",
- "start": 39,
- "end": 40,
- "name": "n"
- }
- },
- "alternate": null
- },
- {
- "type": "ReturnStatement",
- "start": 44,
- "end": 71,
- "argument": {
- "type": "BinaryExpression",
- "start": 51,
- "end": 70,
- "left": {
- "type": "CallExpression",
- "start": 51,
- "end": 59,
- "callee": {
- "type": "Identifier",
- "start": 51,
- "end": 54,
- "name": "fib"
- },
- "arguments": [
- {
- "type": "BinaryExpression",
- "start": 55,
- "end": 58,
- "left": {
- "type": "Identifier",
- "start": 55,
- "end": 56,
- "name": "n"
- },
- "operator": "-",
- "right": {
- "type": "Literal",
- "start": 57,
- "end": 58,
- "value": 1,
- "raw": "1"
- }
- }
- ]
- },
- "operator": "+",
- "right": {
- "type": "CallExpression",
- "start": 62,
- "end": 70,
- "callee": {
- "type": "Identifier",
- "start": 62,
- "end": 65,
- "name": "fib"
- },
- "arguments": [
- {
- "type": "BinaryExpression",
- "start": 66,
- "end": 69,
- "left": {
- "type": "Identifier",
- "start": 66,
- "end": 67,
- "name": "n"
- },
- "operator": "-",
- "right": {
- "type": "Literal",
- "start": 68,
- "end": 69,
- "value": 2,
- "raw": "2"
- }
- }
- ]
- }
- }
- }
- ]
- }
- }
- ],
- "sourceType": "module"
- }
- (來源:GitHub)
上面代碼的要點(diǎn)是,每個(gè)非葉子節(jié)點(diǎn)都是一個(gè)運(yùn)算符,而每個(gè)葉子節(jié)點(diǎn)都是操作數(shù)。這棵語(yǔ)法樹稍后將作為輸入傳給 JavaScript 接著要執(zhí)行的兩個(gè)階段。
三個(gè)技巧優(yōu)化你的 JavaScript
下面羅列的技巧清單中,我會(huì)省略那些已經(jīng)廣泛使用的技巧,例如縮減代碼來最大化信息密度,從而使掃描器更具有時(shí)效性。另外,我也會(huì)跳過那些適用范圍很小的建議,例如避免使用非 ASCII 字符。
提高解析性能的方法數(shù)不勝數(shù),讓我們著眼于其中適用范圍最廣泛的方法吧。
1.盡可能遵從工作線程
主線程被阻塞會(huì)導(dǎo)致用戶交互的延遲,所以應(yīng)該盡可能減少主線程上的工作。關(guān)鍵就是要識(shí)別并避免會(huì)導(dǎo)致主線程中某些任務(wù)長(zhǎng)時(shí)間運(yùn)行的解析行為。
這種啟發(fā)式超出了解析器的優(yōu)化范圍。例如,用戶控制的 JavaScript 代碼段可以使用 web workers 達(dá)到相同的效果。你可以閱讀 實(shí)時(shí)處理應(yīng)用 和 在 angular 中使用 web workers 來了解更多信息。
避免使用大量的內(nèi)聯(lián)腳本
內(nèi)聯(lián)腳本是在主線程中處理的,根據(jù)之前的說法,應(yīng)該盡量避免這樣做。事實(shí)上,除了異步和延遲加載之外,任何 JavaScript 的加載都會(huì)阻塞主線程。
避免嵌套外層函數(shù)
懶編譯也是發(fā)生在主線程上的。不過,如果處理得當(dāng)?shù)脑?,懶解析可以加快啟?dòng)速度。想要強(qiáng)制進(jìn)行全解析的話,可以使用諸如 optimize.js(已經(jīng)不維護(hù))這樣的工具來決定進(jìn)行全解析或者懶解析。
分解超過 100kB 的文件
將大文件分解成小文件以最大化并行腳本的加載速度。“2019 年 JavaScript 的性能開銷”一文比較了 Facebook 網(wǎng)站和 Reddit 網(wǎng)站的文件大小。前者通過在 300 多個(gè)請(qǐng)求中拆分大約 6MB 的 JavaScript ,成功將解析和編譯工作在主線程上的占比控制到 30%;相反,Reddit 的主線程上進(jìn)行解析和編譯工作的達(dá)到了將近 80%。
2. 使用 JSON 而不是對(duì)象字面量 —— 偶爾
在 JavaScript 中,解析 JSON 比解析對(duì)象字面量來得更加高效。 parsing benchmark 已經(jīng)證實(shí)了這一點(diǎn)。在不同的主流 JavaScript 執(zhí)行引擎中分別解析一個(gè) 8MB 大小的文件,前者的解析速度最高可以提升 2 倍。
2019 年谷歌開發(fā)者大會(huì) 也討論過 JSON 解析如此高效的兩個(gè)原因:
- JSON 是單字符串 token,而對(duì)象字面量可能包含大量的嵌套對(duì)象和 token;
- 語(yǔ)法對(duì)上下文是敏感的。解析器逐字檢查源代碼,并不知道某個(gè)代碼塊是一個(gè)對(duì)象字面量。而左大括號(hào)不僅可以表明它是一個(gè)對(duì)象字面量,還可以表明它是一個(gè)解構(gòu)對(duì)象或者箭頭函數(shù)。
不過,值得注意的是,JSON.parse 同樣會(huì)阻塞主線程。對(duì)于超過 1MB 的文件,可以使用 FlatBuffers 提高解析效率。
3. 最大化代碼緩存
最后,你可以通過完全規(guī)避解析來提高解析效率。對(duì)于服務(wù)端編譯來說, WebAssembly (WASM) 是個(gè)不錯(cuò)的選擇。然而,它沒辦法替代 JavaScript。對(duì)于 JS,更合適的方法是最大化代碼緩存。
值得注意的是,緩存并不是任何時(shí)候都生效的。在執(zhí)行結(jié)束之前編譯的任何代碼都會(huì)被緩存 —— 這意味著處理器、監(jiān)聽器等不會(huì)被緩存。為了最大化代碼緩存,你必須最大化執(zhí)行結(jié)束之前編譯的代碼數(shù)量。其中一個(gè)方法就是使用立即執(zhí)行函數(shù)(IIFE)啟發(fā)式:解析器會(huì)通過啟發(fā)式的方法標(biāo)識(shí)出這些 IIFE 函數(shù),它們會(huì)在稍后立即被編譯。因此,使用啟發(fā)式的方法可以確保一個(gè)函數(shù)在腳本執(zhí)行結(jié)束之前被編譯。
此外,緩存是基于單個(gè)腳本執(zhí)行的。這意味著更新腳本將會(huì)使緩存失效。V8 團(tuán)隊(duì)建議可以分割腳本或者合并腳本,從而實(shí)現(xiàn)代碼緩存。但是,這兩個(gè)建議是互相矛盾的。你可以閱讀“JavaScript 開發(fā)中的代碼緩存”來了解更多代碼緩存相關(guān)的信息。
結(jié)論
解析時(shí)間的優(yōu)化涉及到工作線程的延遲解析以及通過最大化緩存來避免完全解析。理解了 V8 的解析機(jī)制后,我們也能推斷出上面沒有提到的其它優(yōu)化方法。
下面給出了更多了解解析機(jī)制的資源,這個(gè)機(jī)制通常來說同時(shí)適用于 V8 和 JavaScript 的解析。
額外小貼士:理解 JavaScript 的錯(cuò)誤和性能是如何影響你的用戶的。
跟蹤生產(chǎn)過程中 JavaScript 的異?;蛘咤e(cuò)誤是很耗時(shí)的,而且也很令人傷腦筋。如果你有興趣監(jiān)控 JavaScript 的錯(cuò)誤和應(yīng)用性能是如何對(duì)用戶造成影響的,可以嘗試使用 LogRocket。
LogRocket 就像是為 web 應(yīng)用量身訂造的 DVR(錄像機(jī)),它可以確切地記錄你的網(wǎng)站上發(fā)生的所有事情。LogRocket 可以幫助你統(tǒng)計(jì)并報(bào)告錯(cuò)誤,以查看錯(cuò)誤發(fā)生的頻率以及它們對(duì)你的用戶群的影響程度。你可以輕松地重現(xiàn)錯(cuò)誤發(fā)生時(shí)特定的用戶會(huì)話,以查看是用戶的哪些操作導(dǎo)致了 bug。
LogRocket 可以記錄你的 app 上的請(qǐng)求和響應(yīng)(包含 header 和 body)以及用戶相關(guān)的上下文信息,從而窺探問題全貌。它也可以記錄頁(yè)面的 HTML 和 CSS,即使是面對(duì)最復(fù)雜的單頁(yè)面應(yīng)用,也可以重構(gòu)出像素完美級(jí)別的視頻。
如果你想提高你的 JavaScript 錯(cuò)誤監(jiān)控能力,LogRocket 是個(gè)不錯(cuò)的選擇。