深度分享:從零實現一個 JS 引擎

背景

======

這是很久之前的一個念想,當時爲了加深自己對 js 的理解,明白 js 引擎是如何工作的。於是從上網找了一個 giao-js[1],感覺還不錯,因此想學習一下。

原文地址:https://juejin.cn/post/7205517870976270394?share_token=6a5d39fa-0b35-4e3c-8e93-0c9f11b2665d

JS 引擎

之前有篇文章理解 React 中 Fiber 架構 (一)[2] 中有講到瀏覽器進程如何渲染網頁和執行 js 代碼的,我們再複習一遍。

一個完整的 web 網頁在瀏覽器顯示和交互的進程(chrome 爲主),需要涉及到線程主要以下幾個部分:

針對 JS 引擎,官方的定義是:

JavaScript 引擎是一個專門處理 JavaScript 腳本的虛擬機,一般會附帶在網頁瀏覽器之中。—— JS 引擎 維基百科 [3]

因此,我們瞭解 JS 引擎在瀏覽器中的主要作用就,解析 JS 代碼,並運行代碼,那麼它是怎麼做到的呢?

如同我們人一樣去認識一門語言,電腦也一樣,當我們寫了一行代碼,JS 引擎要識別出來,它同樣去分析代碼,然後確定執行,主要有以下幾個步驟:

所以我們要模擬 JS 引擎要實現功能主要以下幾塊:

詞法分析

將源代碼分解並組織成一組有意義的單詞, 這一過程即爲詞法分析 (Token)。

詞法分析的工作就是 將一個長長的字符串識別出一個個的單詞,這一個個單詞就是 Token,具體實現效果如下:

const a = 1;

//  經過詞法分析會將上面拆分如下對象
[
  ("var""keyword"),
  ("a""identifier"),
  ("=""assignment"),
  ("1""literal"),
  (";""separator"),
];

如果用圖來顯示的話,它應該是這樣子的:

根據上面的結果,那麼詞法分析的實踐步驟應該如下:

利用Acron做詞法分析, 代碼如下:

const acron = require('acorn');

/**
 * 利用acorn庫進行詞法分析
 * @param {*} code 代碼
 * @param {*} ecmaVersion ECMAScript的標準版本 
 * @returns 
 */
const getToken = (code, ecmaVersion = '11') ={
    const tokenObj = acron.tokenizer(code, {
        ecmaVersion,
        locations: true
    });
    const tokens = [];
    let token = tokenObj.getToken();
    console.log(token)
    while (token.end !== token.start) {
        tokens.push(token);
        token = tokenObj.getToken();
    }
    return tokens;
}

getToken(`const a= 1+1;`); // 最終輸出Token數組

// 輸出如下對象
[ {
  "type"{ // 關鍵詞Token所屬類型
    "label""const",  // 解析到的關鍵詞所屬的類型 爲const
    "keyword""const", //  關鍵字
    ...
  },
  "value""const", // 解析到的 關鍵詞Token
  "start": 0, // 關鍵詞的開始位置
  "end": 5 // 關鍵詞的結束位置 下一個位置是空白符
}, ...]

語法解析

將詞法分析階段生成的 Token 轉換爲抽象語法樹 (Abstract Syntax Tree), 這一過程稱之爲語法解析 (Parsing)。

簡單的說,就是利用 Token 標識符去生成 AST 語法樹。

AST 語法樹

在語法解析前,我們需要對 AST 語法樹有一個認知,即是:什麼是 AST 語法樹?

抽象語法樹 (Abstract Syntax Tree),簡稱 AST,它是源代碼語法結構的一種抽象表示。它以樹狀的形式表現編程語言的語法結構,樹上的每個節點都表示源代碼中的一種結構。

用比較容易理解的話,用一個樹形數據結構去描述我們源代碼,從而能讓機器能更好識別我們所想要實現的功能。

目前市面上 Javascript 語言的 AST 語法樹的結構基本上都遵循 ESTree 語法樹規範 [4]。

這裏說明一下 ESTree 語法樹規範 [5] 的起源,能讓我們更容易理解語法解析的過程:

使用不同工具構建的抽象語法樹可能會有不同的結構,如果大家都遵從同樣的規範,那麼相關聯的生態鏈工具的開發會更爲輕鬆、明晰。很早之前,FireFox 瀏覽器所使用的 JavaScript 引擎 SpiderMonkey 曾經提供了一個 JavaScript API,使得開發者可以直接調用 SpiderMonkey 的 JavaScript 分析器。這個 API 所描述的 JavaScript 抽象語法樹格式漸漸流行起來,如今成爲 JavaScript AST 的通用描述。ESTree 語法樹規範 [6] 正是在此基礎上建立起來的,它現在是社區對 JavaScript 抽象語法樹構建時採用最廣泛的規則,可以認爲是社區推動的事實標準。衆多基礎設施開發者一起維護着這個規範,包括 Dave Herman(Mozilla 研究中心的首席研究員和策略總監)、 Nicholas C. Zakas(ESLint 的作者)、Ingvar Stepanyan(Acorn 的作者)、Mike Sherov 與 Ariya Hidayat(Esprima 的作者)以及 Babel.js 團隊等。

ESTree 語法樹規範 [7] 的初始版本是基於 ES5 的 [2],後續的 ES6/ES7/ES8 等版本的規範,都只針對新增語言特性提出。

ESTree 語法樹規範 [8] 基於 ECMAScript 標準去描述不同標準的 AST 樹結構,具體如下:

// 節點對象  下面這個版本屬於ES2015的規範
interface Node {
    type: string;
    loc: SourceLocation | null;
}

extend interface Program {
    sourceType: "script" | "module";
    body: [ Statement | ImportOrExportDeclaration ];
}

interface IfStatement <: Statement { // <: 標識前者是後者的子集 即是繼承的關係
    type: "IfStatement";
    test: Expression;
    consequent: Statement;
    alternate: Statement | null;
}

因此瞭解 JS 的 AST 語法樹結構,需要對 ESTree 規範有了解,它分別定義不同類型節點的數據結構,拿幾種常見的做一下介紹,具體如下所示:

一個 AST 語法的組成結構大概如下:

Program
|-- body: Node[] // 代碼主體
|     |-- Function // 函數聲明
|     |-- Statement // 代碼內容
|     |-- Declaration // 變量聲明
|     |-- Expression // 表達式
|          |-- Literal
|          |-- Identifier

還需要解答一個問題,就是在 AST 語法樹中,如何判斷一個節點的完整性呢?

按照 ESTree 的規範:遇到一個空節點(比如:換行 / 分號 / 結構體結束符}]等),則拆成一個完整的節點。

實現原理

弄明白 AST 語法樹的數據結構,接下來就是如何將之前詞法分析的 Token 數組解析成語法樹結構,解析流程圖 (Acron.js 實現) 如下:

利用Acron做語法解析, 代碼如下:

const code = `function sum(a, b){return a+b;}; const a = sum(1, 2);`

const acron = require('acorn');

console.log(acron.parse(code))

最終得到結構如下:

Node {
  type: 'Program',
  start: 0,
  end: 53,
  body:
   [ Node {
       type: 'FunctionDeclaration',
       start: 0,
       end: 31,
       id: [Node],
       expression: false,
       generator: false,
       async: false,
       params: [Array],
       body: [Node] },
     Node { type: 'EmptyStatement', start: 31, end: 32 },
     Node {
       type: 'VariableDeclaration',
       start: 33,
       end: 53,
       declarations: [Array],
       kind: 'const' } ],
  sourceType: 'script' }

解釋器

解釋器,就是遍歷 AST 語法樹,然後根據 Node 節點類型,去執行或計算每個節點。

這裏實現一個 JS 解釋器,需要對 AST 語法樹 Node 節點每個類型做區分判斷,主要有以下幾種:

因此我們需要幾個類去保存相關的值:

參考代碼:

/**
 * 遍歷AST語法樹,並執行對應的處理函數
 * @param {*} node 
 * @param {*} scope 
 */
visitNode(node, scope) {
      const { type } = node;
    if (VISITOR[type]) {
        return VISITOR[type]({ node, scope, context: this });
    }
    return undefined;
}

變量和作用域

在 JavaScript 中,對變量的聲明通常是綁定在作用域中的,而作用域分爲以下幾種:

我們舉個例子,以var a = 1;爲例,我們需要需要哪些代碼,才能實現從 AST 樹解析,將變量a被聲明在全局變量作用域中,具體步驟如下圖:

這裏實現一個作用域 Scope 類,參考代碼如下:

class Scope {
  /**
     * 
     * @param {*} type 
     * @param {*} parent 
     */
    constructor(type, parent) {
        this.parent = parent || null; // 父級作用域
        this.type = type; // 作用域類型 Global, Function, Block
        this.targetScope = new Map();  // 當前作用域
    }

    /**
     * 變量聲明方法,變量已定義則拋出語法錯誤異常
     * @param {*} kind 變量類型
     * @param {*} rawName  變量名
     * @param {*} value 變量值
     * @returns 
     */
    declare(kind, rawName, value) {
        this.targetScope.set(rawName, value);
    }
}

上下文 This

上下文的 this 對象其實指的就是當前作用域,然而我們瞭解過 JS 中的 this 是可以改變的,如:

這些都需要在解析代碼的時候注意的邏輯問題。

其他類型解釋

條件判斷

IfStatement,裏面有屬性: test爲判斷條件,consequent爲條件成立時執行的語句,alternate爲條件不成立時執行的語句,參考代碼如下:

// visitNode會執行AST語法樹節點函數
const { test, consequent, alternate } = node;
const testValue = visitNode(test, scope);
if (testValue) {
    if(consequent){
       visitNode(consequent, scope);
    }
} else {
    if(alternate){
       visitNode(alternate, scope);
    }
}

其他部分邏輯就不會在這裏一一描述,具體 Node 類型都有自己的判斷邏輯,因此想要了解完整邏輯,可以到完整源碼裏查看,註解都十分清晰。

完整源碼地址在:github.com/qiubohong/q…[9]

總結

======

本文涉及的東西有點多,花了好幾天時間才弄明白,因此有些知識點在這裏做一下小總結:

ECMAScript 規範

其實,JS 解釋器實現起來不難,就是需要對 JS 執行邏輯有完整的認識,不僅僅只是上面幾個部分,但是基本上 AST 語法樹都已經包括在裏面了,所以這個時候需要你對 ECMAScript 規範有一定了解才能完整實現解釋器。

所以這裏貼一個 ECMAScript 規範鏈接,作爲後續完整解釋器的擴展。

使用 ECMAScript 指代由 Ecma International Technical Committee 39 負責編撰的 ECMAScript Language Specification,而使用 JavaScript 來指代我們日常使用的那個常見編程語言。

我們可以在 tc39.es/ecma262[10] 獲取到最新的 ECMAScript 規範

如何閱讀規範呢?

能做什麼

有了我們自己的 JS 引擎後我們能做些什麼,其實目前市面上已經有很多應用場景,比如:

參考資料

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/8SoBYlUIRJE7DFZBFqCcCQ