【譯】從JavaScript的運行原理談解析效率優化

How JavaScript Works: Optimizing for Parsing Efficiency

編寫高效率的 JavaScript ,其中一個關鍵就是要理解它的工做原理。編寫高效代碼的方法數不勝數,例如,你能夠編寫對編譯器友好的 JavaScript 代碼,從而避免將一行簡單代碼的運行速度拖慢 7 倍javascript

本文咱們會專一講解能夠最小化 Javascript 代碼解析時間的優化方法。咱們進一步縮小範圍,只討論 V8 這一驅動 ElectronNode.jsGoogle Chrome 的 JS 引擎。爲了理解這些對解析友好的優化方法,咱們還得先討論 JavaScript 的解析過程,在深刻理解代碼解析過程的基礎上,再對三個編寫更高速 JavaScript 的技巧進行一一律述。html

先簡單回顧一下 JavaScript 執行的三個階段。java

  1. 從源代碼到語法樹 —— 解析器從源碼中生成一棵 抽象語法樹
  2. 從語法樹到字節碼 —— V8 的解釋器 Ignition 從語法樹中生成字節碼(在 2017 年以前 並無該步驟,具體能夠看 這篇文章)。
  3. 從字節碼到機器碼 —— V8 的編譯器 TurboFan 從字節碼中生成圖,用高度優化的機器碼替代部分字節碼。

上述的第二和第三階段 涉及到了 JavaScript 的編譯。在這篇文章中,咱們將重點介紹第一階段並解釋該階段對編寫高效 JavaScript 的影響。咱們會按照從左到右、從上到下的順序介紹解析管道,該管道接受源代碼並生成一棵語法樹。node

Abstract Syntax Tree (AST) for JavaScript Parsing

抽象語法樹(AST)。它是在解析器(圖中藍色部分)中建立的。android

掃描器

源代碼首先被分解成 chunk,每一個 chunk 均可能採用不一樣的編碼,稍後會有一個字符流將全部 chunk 的編碼統一爲 UTF-16。git

在解析以前,掃描器會將 UTF-16 字符流分解成 token。token 是一段腳本中具備語義的最小單元。有不一樣類型的 token,包括空白符(用於 自動插入分號)、標識符、關鍵字以及代理對(僅當代理對沒法被識別爲其它東西時纔會結合成標識符)。這些 token 以後被送往預解析器中,接着再送往解析器。github

預解析器

解析器的工做量是最少的,只要足夠跳過傳入的源代碼並進行懶解析(而不是全解析)便可。預解析器確保輸入的源代碼包含有效語法,並生成足夠的信息來正確地編譯外部函數。這個準備好的函數稍後將按需編譯。web

解析

解析器接收到掃描器生成的 token 後,如今須要生成一個供編譯器使用的中間表示。chrome

首先咱們來討論解析樹。解析樹,或者說 具體語法樹(CST)將源語法表示爲一棵樹。每一個葉子節點都是一個 token,而每一箇中間節點則表示一個語法規則。在英語裏,語法規指的是名詞、主語等,而在編程裏,語法規則指的是一個表達式。不過,解析樹的大小隨着程序大小會增加得很快。typescript

相反,抽象語法樹 要更加簡潔。每一箇中間節點表示一個結構,好比一個減法運算(-),而且這棵樹並無展現源代碼的全部細節。例如,由括號定義的分組是蘊含在樹的結構中的。另外,標點符號、分隔符以及空白符都被省略了。你能夠在 這裏 瞭解更多 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 解析如此高效的兩個緣由:

  1. JSON 是單字符串 token,而對象字面量可能包含大量的嵌套對象和 token;
  2. 語法對上下文是敏感的。解析器逐字檢查源代碼,並不知道某個代碼塊是一個對象字面量。而左大括號不只能夠代表它是一個對象字面量,還能夠代表它是一個解構對象或者箭頭函數。

不過,值得注意的是,JSON.parse 一樣會阻塞主線程。對於超過 1MB 的文件,可使用 FlatBuffers 提升解析效率

3. 最大化代碼緩存

最後,你能夠經過徹底規避解析來提升解析效率。對於服務端編譯來講, WebAssembly (WASM) 是個不錯的選擇。然而,它沒辦法替代 JavaScript。對於 JS,更合適的方法是最大化代碼緩存。

值得注意的是,緩存並非任什麼時候候都生效的。在執行結束以前編譯的任何代碼都會被緩存 —— 這意味着處理器、監聽器等不會被緩存。爲了最大化代碼緩存,你必須最大化執行結束以前編譯的代碼數量。其中一個方法就是使用當即執行函數(IIFE)啓發式:解析器會經過啓發式的方法標識出這些 IIFE 函數,它們會在稍後當即被編譯。所以,使用啓發式的方法能夠確保一個函數在腳本執行結束以前被編譯。

此外,緩存是基於單個腳本執行的。這意味着更新腳本將會使緩存失效。V8 團隊建議能夠分割腳本或者合併腳本,從而實現代碼緩存。可是,這兩個建議是互相矛盾的。你能夠閱讀「JavaScript 開發中的代碼緩存」來了解更多代碼緩存相關的信息。

結論

解析時間的優化涉及到工做線程的延遲解析以及經過最大化緩存來避免徹底解析。理解了 V8 的解析機制後,咱們也能推斷出上面沒有提到的其它優化方法。

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

額外小貼士:理解 JavaScript 的錯誤和性能是如何影響你的用戶的。

跟蹤生產過程當中 JavaScript 的異常或者錯誤是很耗時的,並且也很使人傷腦筋。若是你有興趣監控 JavaScript 的錯誤和應用性能是如何對用戶形成影響的,能夠嘗試使用 LogRocket

LogRocket Dashboard Free Trial Banner

LogRocket 就像是爲 web 應用量身訂造的 DVR(錄像機),它能夠確切地記錄你的網站上發生的全部事情。LogRocket 能夠幫助你統計並報告錯誤,以查看錯誤發生的頻率以及它們對你的用戶羣的影響程度。你能夠輕鬆地重現錯誤發生時特定的用戶會話,以查看是用戶的哪些操做致使了 bug。

LogRocket 能夠記錄你的 app 上的請求和響應(包含 header 和 body)以及用戶相關的上下文信息,從而窺探問題全貌。它也能夠記錄頁面的 HTML 和 CSS,即便是面對最複雜的單頁面應用,也能夠重構出像素完美級別的視頻。

若是你想提升你的 JavaScript 錯誤監控能力,LogRocket 是個不錯的選擇

相關文章
相關標籤/搜索