1. 程式人生 > 實用技巧 >從JavaScript的執行原理談解析效率優化

從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;

語法對上下文是敏感的。解析器逐字檢查原始碼,並不知道某個程式碼塊是一個物件字面量。而左大括號不僅可以表明它是一個物件字面量,還可以表明它是一個解構物件或者箭頭函式。

資源搜尋網站大全 https://www.renrenfan.com.cn

不過,值得注意的是,JSON.parse同樣會阻塞主執行緒。對於超過 1MB 的檔案,可以使用FlatBuffers提高解析效率。

3. 最大化程式碼快取

最後,你可以通過完全規避解析來提高解析效率。對於服務端編譯來說,WebAssembly(WASM)是個不錯的選擇。然而,它沒辦法替代 JavaScript。對於 JS,更合適的方法是最大化程式碼快取。

值得注意的是,快取並不是任何時候都生效的。在執行結束之前編譯的任何程式碼都會被快取 —— 這意味著處理器、監聽器等不會被快取。為了最大化程式碼快取,你必須最大化執行結束之前編譯的程式碼數量。其中一個方法就是使用立即執行函式(IIFE)啟發式:解析器會通過啟發式的方法標識出這些 IIFE 函式,它們會在稍後立即被編譯。因此,使用啟發式的方法可以確保一個函式在指令碼執行結束之前被編譯。

此外,快取是基於單個指令碼執行的。這意味著更新指令碼將會使快取失效。V8 團隊建議可以分割指令碼或者合併指令碼,從而實現程式碼快取。但是,這兩個建議是互相矛盾的。你可以閱讀“JavaScript 開發中的程式碼快取”來了解更多程式碼快取相關的資訊。

結論

解析時間的優化涉及到工作執行緒的延遲解析以及通過最大化快取來避免完全解析。理解了 V8 的解析機制後,我們也能推斷出上面沒有提到的其它優化方法。

下面給出了更多瞭解解析機制的資源,這個機制通常來說同時適用於 V8 和 JavaScript 的解析。