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