深度分享:從零實現一個 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 爲主),需要涉及到線程主要以下幾個部分:
-
GUI 渲染線程
,負責渲染瀏覽器界面 HTML 元素, 當界面需要重繪 (Repaint) 或由於某種操作引發迴流 (reflow) 時, 該線程就會執行。 -
JavaScript引擎線程
,JS 內核,負責處理 Javascript 腳本程序。一直等待着任務隊列中任務的到來,然後解析 Javascript 腳本,運行代碼。 -
定時觸發器線程
,定時器 setInterval 與 setTimeout 所在線程,爲什麼要單獨弄個線程處理定時器?是因爲 JavaScript 引擎是單線程的, 如果處於阻塞線程狀態就會影響記計時的準確 -
事件觸發線程
,用來控制事件輪詢,JS 引擎自己忙不過來,需要瀏覽器另開線程協助 -
異步http請求線程
,在XMLHttpRequest
或fetch
在連接後是通過瀏覽器新開一個線程請求, 將檢測到狀態變更時,如果設置有回調函數,異步線程就產生狀態變更事件放到 JavaScript 引擎的處理隊列中等待處理。這裏需要注意XMLHttpRequest
和fetch
的區別,fetch
是 w3c 標準化後一個專門提供給開發調用發起 http 的 API 接口,XMLHttpRequest 是一個非標準化的 Http 請求對象,主要是可以發起 http 請求獲取 XML 數據。
針對 JS 引擎,官方的定義是:
JavaScript 引擎是一個專門處理 JavaScript 腳本的虛擬機,一般會附帶在網頁瀏覽器之中。—— JS 引擎 維基百科 [3]
因此,我們瞭解 JS 引擎在瀏覽器中的主要作用就,解析 JS 代碼,並運行代碼,那麼它是怎麼做到的呢?
如同我們人一樣去認識一門語言,電腦也一樣,當我們寫了一行代碼,JS 引擎要識別出來,它同樣去分析代碼,然後確定執行,主要有以下幾個步驟:
-
詞法分析,主要是
分詞(tokenize)
,將 JS 代碼比較關鍵詞(如:function、const、let 等),拆出來放到解析器裏 -
語法分析,主要解析(parse),主要用了
預解析器
和解析器
: -
預解析器
會判斷哪些代碼需要立即執行,哪些代碼不需要立即執行,需要立即執行的代碼纔會放到解析器裏去解析 -
解析器
,從詞法分析獲取關鍵詞做標記,將代碼生成一個抽象語法樹,也叫 AST 語法樹 -
生成 AST 語法樹,AST 語法樹由
解析器
生成後,將會傳遞給到解釋器
-
生成字節碼,主要由
解釋器
將 AST 語法樹編譯成字節碼 -
執行代碼,將字節碼轉成機器代碼,以更快的速度在電腦中執行
所以我們要模擬 JS 引擎要實現功能主要以下幾塊:
-
分詞器
,將 JS 關鍵詞進行標記 -
解析器
,生成 AST 語法樹 -
解釋器
,執行 AST 語法樹
詞法分析
將源代碼分解並組織成一組有意義的單詞, 這一過程即爲詞法分析 (Token)。
詞法分析的工作就是 將一個長長的字符串識別出一個個的單詞,這一個個單詞就是 Token,具體實現效果如下:
const a = 1;
// 經過詞法分析會將上面拆分如下對象
[
("var": "keyword"),
("a": "identifier"),
("=": "assignment"),
("1": "literal"),
(";": "separator"),
];
如果用圖來顯示的話,它應該是這樣子的:
根據上面的結果,那麼詞法分析的實踐步驟應該如下:
-
先分詞,分詞的邏輯使用正則表達式
-
先判斷是否爲關鍵詞,如:運算符 (+-*/=)、聲明符(var、const、function) 等
-
如果是則執行拆詞
-
接着遇到空格也拆詞
-
遇到換行符或; 也拆詞
-
... 還有符合條件判斷也拆詞
-
最終會獲取到一個數組,["var", "a", "=", "1", ";"]
-
再判斷該詞屬於哪個類型,如:var 屬於 keyword 關鍵字。
利用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 規範有了解,它分別定義不同類型節點的數據結構,拿幾種常見的做一下介紹,具體如下所示:
-
Program
,一個完整的程序源碼樹,就是樹的跟節點,因此也屬於Node
類型 -
Node
,語法樹的基礎節點 -
Declaration
聲明節點 -
Function
,函數聲明或表達式,繼承節點Node
-
Statement
,代碼內容,標識任何聲明,繼承節點Node
-
Expression
, 表達式,標識任何聲明,繼承節點Node
-
Pattern
,解構綁定和賦值節點,繼承節點Node
-
Identifier
,標識符,如:變量名、函數名 -
Literal
, 字面量,對應 JavaScript,就是基本值,例如布爾值 true、數字 200、字符串 "this is a string"
一個 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 節點每個類型做區分判斷,主要有以下幾種:
-
變量
-
作用域以及作用域鏈
-
上下文 This 指向
-
條件判斷
-
For 循環,其中的 break 和 continue
-
函數部分 Function
-
生成器 Generator
-
異步 Async
因此我們需要幾個類去保存相關的值:
-
Scope,作用域類,保存作用域內的值以及作用域鏈(當前作用域可以找到父級作用域鏈)
-
Visitor,AST 樹 Node 節點處理類,裏面有函數
visitNode(node, scope)
,用來處理對應 node 類型,其中 VISITOR 是所有類型函數的 Map 對象,用來快速查詢 -
Variable,變量存儲類,用來存儲變量類型和值
參考代碼:
/**
* 遍歷AST語法樹,並執行對應的處理函數
* @param {*} node
* @param {*} scope
*/
visitNode(node, scope) {
const { type } = node;
if (VISITOR[type]) {
return VISITOR[type]({ node, scope, context: this });
}
return undefined;
}
變量和作用域
在 JavaScript 中,對變量的聲明通常是綁定在作用域中的,而作用域分爲以下幾種:
-
全局作用域,全局作用域中僅存在一處,即爲最上級的環境。
-
函數作用域,函數存在並執行時,內部存儲函數作用域
-
塊級作用域,每隔 block 塊 {} 都可產生作用域,如 if for while 等
我們舉個例子,以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 是可以改變的,如:
-
call()
bind()
apply()
等函數,當執行到相關函數的時候,需要將傳遞進來 scope 的替換成當前的 scope -
ES6 中的箭頭函數等,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]
總結
本文涉及的東西有點多,花了好幾天時間才弄明白,因此有些知識點在這裏做一下小總結:
-
JS 引擎是有三部分組成的,分別是:
詞法分析
,語法解析
和解釋器
-
詞法解析和語法解析,最終的目標是生成符合 ESTree 規範的
AST語法樹
-
解釋器的作用就是依據
AST語法樹
去執行相關邏輯,輸出所需要的最終結果 -
比較重要的部分在於變量、作用域和作用域鏈的實現
-
其他部分則是依據對應
ECMAScript 規範
實現對應邏輯皆可
ECMAScript 規範
其實,JS 解釋器實現起來不難,就是需要對 JS 執行邏輯有完整的認識,不僅僅只是上面幾個部分,但是基本上 AST 語法樹都已經包括在裏面了,所以這個時候需要你對 ECMAScript 規範有一定了解才能完整實現解釋器。
所以這裏貼一個 ECMAScript 規範鏈接,作爲後續完整解釋器的擴展。
使用 ECMAScript 指代由 Ecma International Technical Committee 39 負責編撰的 ECMAScript Language Specification,而使用 JavaScript 來指代我們日常使用的那個常見編程語言。
我們可以在 tc39.es/ecma262[10] 獲取到最新的 ECMAScript 規範
如何閱讀規範呢?
-
慣例與基礎:如在 ECMAScript 中 Number 的定義是什麼,亦或者 throw a TypeError exception 語句代表什麼含義;
-
語言語法產生式:如如何寫一個符合規範的 for-in 循環;
-
語言靜態語義:如一個 VariableDeclaration 如何確定一個變量聲明;
-
語言運行時語義:如一個 for-in 循環的執行例程的定義;
-
APIs:如 String.prototype.substring 等內置對象的方法例程定義。
能做什麼
有了我們自己的 JS 引擎後我們能做些什麼,其實目前市面上已經有很多應用場景,比如:
-
Babel[11],最常見 JavaScript 編譯器,能夠將 js 代碼編譯成我們想要的任一版本的 ECMAscript 標準,基於 acorn.js 優化
-
ESlint[12],最常用的代碼質量掃描工具,能找到代碼不符合規範的地方,基於 Espree.js 進行分析
-
pretiier[13], 最常用的代碼格式工具,能幫忙把代碼格式優化
-
js 沙箱安全機制,之前寫過一篇文件低代碼系列——js 沙箱設計 [14] 裏提到過,想要完整動態執行 js 代碼,最好的方式是擁有自己的 js 解釋器
參考資料
-
編譯原理實戰一:如何用 JS 實現一個詞法分析器?[15]
-
giao-js github 源碼 [16]
-
分享一篇可視化的 JS 引擎執行流程 [17]
-
Acron 一個小巧又快速的 js 解析器 [18]
-
The ESTree Spec(ES 語法樹規範)[19]
-
astexplorer 在線語法解析器 [20]
-
ECMAScript 規範閱讀導引 Part 1[21]
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/8SoBYlUIRJE7DFZBFqCcCQ