前言
在這篇文章中,我們將通過 JS 構建我們自己的 JS 直譯器,用 JS 寫 JS,這聽起來很奇怪,儘管如此,這樣做我們將更熟悉 JS,也可以學習 JS 引擎是如何工作的!
什麼是直譯器 (Interpreter) ?
直譯器是在執行時執行的語言求值器,它動態地執行程式的原始碼。
直譯器解析原始碼,從原始碼生成 AST(抽象語法樹),遍歷 AST 並逐個計算它們。
直譯器 (Interpreter) 工作原理
- 詞法分析 (Tokenization)
- 語法解析 (Parsing)
- 求值 (Evaluating)
詞法分析 (Tokenization)
將原始碼分解並組織成一組有意義的單詞,這一過程即為詞法分析(Token)。
在英語中,當我們遇到這樣一個語句時:
Javascript is the best language in the world
我們會下意識地把句子分解成一個個單詞:
+----------------------------------------------------------+
| Javascript | is | the | best | language | in |the |world |
+----------------------------------------------------------+
這是分析和理解句子的第一階段。
詞法分析是由詞法分析器完成的,詞法分析器會掃描(scanning)程式碼,提取詞法單元。
var a = 1;
[
("var": "keyword"),
("a": "identifier"),
("=": "assignment"),
("1": "literal"),
(";": "separator"),
];
詞法分析器將程式碼分解成 Token 後,會將 Token 傳遞給解析器進行解析,我們來看下解析階段是如何工作的。
語法解析 (Parsing)
將詞法分析階段生成的 Token 轉換為抽象語法樹(Abstract Syntax Tree),這一過程稱之為語法解析(Parsing)。
在英語中,Javascript is the best language 被分解為以下單詞:
+------------------------------------------+
| Javascript | is | the | best | language |
+------------------------------------------+
這樣我們就可以挑選單詞並形成語法結構:
"Javascript": Subject
"is the best language": Predicate
"language": Object
Javascript 在語法中是一個主語名詞,其餘的是一個沒有什麼意義的句子叫做謂語,language 是動作的接受者,也就是賓語。結構是這樣的:
Subject(Noun) -> Predicate -> Object
語法解析是由語法解析器完成的,它會將上一步生成的 Token,根據語法規則,轉為抽象語法樹(AST)。
{
type: "Program",
body: [
{
type: "VariableDeclaration",
declarations: [
{
type: "VariableDeclarator",
id: {
type: "Identifier",
name: "sum"
},
init: {
type: "Literal",
value: 30,
raw: "30"
}
}
],
kind: "var"
}
],
}
求值階段 (Evaluating)
直譯器將遍歷 AST 並計算每個節點。- 求值階段
1 + 2
|
|
v
+---+ +---+
| 1 | | 2 |
+---+ +---+
\ /
\ /
\ /
+---+
| + |
+---+
{
lhs: 1,
op: '+'.
rhs: 2
}
直譯器解析 Ast,得到 LHS 節點,接著收集到操作符(operator)節點+,+操作符表示需要進行一次加法操作,它必須有第二個節點來進行加法操作.接著他收集到 RHS 節點。它收集到了有價值的資訊並執行加法得到了結果,3。
{
type: "Program",
body: [
{
type: "ExpressionStatement",
expression: {
type: "BinaryExpression",
left: {
type: "Literal",
value: 1,
raw: "1"
},
operator: "+",
right: {
type: "Literal",
value: 2,
raw: "2"
}
}
}
],
}
實踐
前面我們已經介紹瞭直譯器的工作原理,接下來我們來動動手鬆鬆筋骨吧,實現一個 Mini Js Interpreter~
實踐準備
- Acorn.js
A tiny, fast JavaScript parser, written completely in JavaScript. 一個完全使用 javascript 實現的,小型且快速的 javascript 解析器
本次實踐我們將使用 acorn.js ,它會幫我們進行詞法分析,語法解析並轉換為抽象語法樹。
Webpack/Rollup/Babel(@babel/parser) 等第三方庫也是使用 acorn.js 作為自己 Parser 的基礎庫。(站在巨人的肩膀上啊!)
- The Estree Spec
最開始 Mozilla JS Parser API 是 Mozilla 工程師在 Firefox 中建立的 SpiderMonkey 引擎輸出 JavaScript AST 的規範文件,文件所描述的格式被用作操作 JAvaScript 原始碼的通用語言。
隨著 JavaScript 的發展,更多新的語法被加入,為了幫助發展這種格式以跟上 JavaScript 語言的發展。The ESTree Spec 就誕生了,作為參與構建和使用這些工具的人員的社群標準。
acorn.js parse 返回值符合 ESTree spec 描述的 AST 物件,這裡我們使用@types/estree 做型別定義。
- Jest
號稱令人愉快的 JavaScript 測試...我們使用它來進行單元測試.
- Rollup
Rollup 是一個 JavaScript 模組打包器,我們使用它來打包,以 UMD 規範對外暴露模組。
專案初始化
// visitor.ts 建立一個Visitor類,並提供一個方法操作ES節點。
import * as ESTree from "estree";
class Visitor {
visitNode(node: ESTree.Node) {
// ...
}
}
export default Visitor;
// interpreter.ts 建立一個Interpreter類,用於執行ES節點樹。
// 建立一個Visitor例項,並使用該例項來執行ESTree節點
import Visitor from "./visitor";
import * as ESTree from "estree";
class Interpreter {
private visitor: Visitor;
constructor(visitor: Visitor) {
this.visitor = visitor;
}
interpret(node: ESTree.Node) {
this.visitor.visitNode(node);
}
}
export default Interpreter;
// vm.ts 對外暴露run方法,並使用acorn code->ast後,交給Interpreter例項進行解釋。
const acorn = require("acorn");
import Visitor from "./visitor";
import Interpreter from "./interpreter";
const jsInterpreter = new Interpreter(new Visitor());
export function run(code: string) {
const root = acorn.parse(code, {
ecmaVersion: 8,
sourceType: "script",
});
return jsInterpreter.interpret(root);
}
實踐第 1 彈: 1+1= ?
我們這節來實現 1+1 加法的解釋。首先我們通過AST explorer,看看 1+1 這段程式碼轉換後的 AST 結構。
我們可以看到這段程式碼中存在 4 種節點型別,下面我們簡單的介紹一下它們:
Program
根節點,即代表一整顆抽象語法樹,body 屬性是一個陣列,包含了多個 Statement 節點。
interface Program {
type: "Program";
sourceType: "script" | "module";
body: Array<Directive | Statement | ModuleDeclaration>;
comments?: Array<Comment>;
}
ExpressionStatement
表示式語句節點,expression 屬性指向一個表示式節點物件
interface ExpressionStatement {
type: "ExpressionStatement";
expression: Expression;
}
BinaryExpression
二元運算表示式節點,left 和 right 表示運算子左右的兩個表示式,operator 表示一個二元運算子。
本節實現的重點,簡單理解,我們只要拿到 operator 操作符的型別並實現,然後對 left,right 值進行求值即可。
interface BinaryExpression {
type: "BinaryExpression";
operator: BinaryOperator;
left: Expression;
right: Expression;
}
Literal
字面量,這裡不是指 [] 或者 {} 這些,而是本身語義就代表了一個值的字面量,如 1,“hello”, true 這些,還有正規表示式,如 /\d?/。
type Literal = SimpleLiteral | RegExpLiteral;
interface SimpleLiteral {
type: "Literal";
value: string | boolean | number | null;
raw?: string;
}
interface RegExpLiteral {
type: "Literal";
value?: RegExp | null;
regex: {
pattern: string;
flags: string;
};
raw?: string;
}
廢話少說,開擼!!!
// standard/es5.ts 實現以上節點方法
import Scope from "../scope";
import * as ESTree from "estree";
import { AstPath } from "../types/index";
const es5 = {
// 根節點的處理很簡單,我們只要對它的body屬性進行遍歷,然後訪問該節點即可。
Program(node: ESTree.Program) {
node.body.forEach((bodyNode) => this.visitNode(bodyNode));
},
// 表示式語句節點的處理,同樣訪問expression 屬性即可。
ExpressionStatement(node: ESTree.ExpressionStatement>) {
return this.visitNode(node.expression);
},
// 字面量節點處理直接求值,這裡對正規表示式型別進行了特殊處理,其他型別直接返回value值即可。
Literal(node: ESTree.Literal>) {
if ((<ESTree.RegExpLiteral>node).regex) {
const { pattern, flags } = (<ESTree.RegExpLiteral>node).regex;
return new RegExp(pattern, flags);
} else return node.value;
},
// 二元運算表示式節點處理
// 對left/node兩個節點(Literal)進行求值,然後實現operator型別運算,返回結果。
BinaryExpression(node: ESTree.BinaryExpression>) {
const leftNode = this.visitNode(node.left);
const operator = node.operator;
const rightNode = this.visitNode(node.right);
return {
"+": (l, r) => l + r,
"-": (l, r) => l - r,
"*": (l, r) => l * r,
"/": (l, r) => l / r,
"%": (l, r) => l % r,
"<": (l, r) => l < r,
">": (l, r) => l > r,
"<=": (l, r) => l <= r,
">=": (l, r) => l >= r,
"==": (l, r) => l == r,
"===": (l, r) => l === r,
"!=": (l, r) => l != r,
"!==": (l, r) => l !== r,
}[operator](leftNode, rightNode);
},
};
export default es5;
// visitor.ts
import Scope from "./scope";
import * as ESTree from "estree";
import es5 from "./standard/es5";
const VISITOR = {
...es5,
};
class Visitor {
// 實現訪問節點方法,通過節點型別訪問對應的節點方法
visitNode(node: ESTree.Node) {
return {
visitNode: this.visitNode,
...VISITOR,
}[node.type](node);
}
}
export default Visitor;
就這樣,普通的二元運算就搞定啦!!!
實踐第 2 彈: 怎麼找到變數?
Javascript 的作用域與作用域鏈的概念想必大家都很熟悉了,這裡就不再囉嗦了~
是的,我們需要通過實現作用域來訪問變數,實現作用域鏈來搜尋識別符號。
在這之前,我們先實現 Variable 類,實現變數的存取方法。
// variable.ts
export enum Kind {
var = "var",
let = "let",
const = "const",
}
export type KindType = "var" | "let" | "const";
export class Variable {
private _value: any;
constructor(public kind: Kind, val: any) {
this._value = val;
}
get value() {
return this._value;
}
set value(val: any) {
this._value = val;
}
}
import { Variable, Kind, KindType } from "./variable";
class Scope {
// 父作用域
private parent: Scope | null;
// 當前作用域
private targetScope: { [key: string]: any };
constructor(public readonly type, parent?: Scope) {
this.parent = parent || null;
this.targetScope = new Map();
}
// 是否已定義
private hasDefinition(rawName: string): boolean {
return Boolean(this.search(rawName));
}
// var型別變數定義
public defineVar(rawName: string, value: any) {
let scope: Scope = this;
// 如果不是全域性作用域且不是函式作用域,找到全域性作用域,儲存變數
// 這裡就是我們常說的Hoisting (變數提升)
while (scope.parent && scope.type !== "function") {
scope = scope.parent;
}
// 儲存變數
scope.targetScope.set(rawName, new Variable(Kind.var, value));
}
// let型別變數定義
public defineLet(rawName: string, value: any) {
this.targetScope.set(rawName, new Variable(Kind.let, value));
}
// const型別變數定義
public defineConst(rawName: string, value: any) {
this.targetScope.set(rawName, new Variable(Kind.const, value));
}
// 作用域鏈實現,向上查詢識別符號
public search(rawName: string): Variable | null {
if (this.targetScope.get(rawName)) {
return this.targetScope.get(rawName);
} else if (this.parent) {
return this.parent.search(rawName);
} else {
return null;
}
}
// 變數宣告方法,變數已定義則丟擲語法錯誤異常
public declare(kind: Kind | KindType, rawName: string, value: any) {
if (this.hasDefinition(rawName)) {
console.error(
`Uncaught SyntaxError: Identifier '${rawName}' has already been declared`
);
return true;
}
return {
[Kind.var]: () => this.defineVar(rawName, value),
[Kind.let]: () => this.defineLet(rawName, value),
[Kind.const]: () => this.defineConst(rawName, value),
}[kind]();
}
}
export default Scope;
以上就是變數物件,作用域及作用域鏈的基礎實現了,接下來我們就可以定義及訪問變數了。
實踐第 3 彈: var age = 18
從語法樹中我們可以看到三個陌生的節點型別,來看看它們分別代表什麼意思:
VariableDeclaration
變數宣告,kind 屬性表示是什麼型別的宣告,因為 ES6 引入了 const/let。
declarations 表示宣告的多個描述,因為我們可以這樣:let a = 1, b = 2;。
interface VariableDeclaration {
type: "VariableDeclaration";
declarations: Array<VariableDeclarator>;
kind: "var" | "let" | "const";
}
VariableDeclarator
變數宣告的描述,id 表示變數名稱節點,init 表示初始值的表示式,可以為 null。
interface VariableDeclarator {
type: "VariableDeclarator";
id: Pattern;
init?: Expression | null;
}
Identifier
顧名思義,識別符號節點,我們寫 JS 時定義的變數名,函式名,屬性名,都歸為識別符號。
interface Identifier {
type: "Identifier";
name: string;
}
瞭解了對應節點的含義後,我們來進行實現:
// standard/es5.ts 實現以上節點方法
import Scope from "../scope";
import * as ESTree from "estree";
type AstPath<T> = {
node: T;
scope: Scope;
};
const es5 = {
// ...
// 這裡我們定義了astPath,新增了scope作用域引數
VariableDeclaration(astPath: AstPath<ESTree.VariableDeclaration>) {
const { node, scope } = astPath;
const { declarations, kind } = node;
// 上面提到,生宣告可能存在多個描述(let a = 1, b = 2;),所以我們這裡對它進行遍歷:
// 這裡遍歷出來的每個item是VariableDeclarator節點
declarations.forEach((declar) => {
const { id, init } = <ESTree.VariableDeclarator>declar;
// 變數名稱節點,這裡拿到的是age
const key = (<ESTree.Identifier>id).name;
// 判斷變數是否進行了初始化 ? 查詢init節點值(Literal型別直接返回值:18) : 置為undefined;
const value = init ? this.visitNode(init, scope) : undefined;
// 根據不同的kind(var/const/let)宣告進行定義,即var age = 18
scope.declare(kind, key, value);
});
},
// 識別符號節點,我們只要通過訪問作用域,訪問該值即可。
Identifier(astPath: AstPath<ESTree.Identifier>) {
const { node, scope } = astPath;
const name = node.name;
// walk identifier
// 這個例子中查詢的是age變數
const variable = scope.search(name);
// 返回的是定義的變數物件(age)的值,即18
if (variable) return variable.value;
},
};
export default es5;
實踐第 4 彈: module.exports = 6
我們先來看看 module.exports = 6 對應的 AST。
從語法樹中我們又看到兩個陌生的節點型別,來看看它們分別代表什麼意思:
AssignmentExpression
賦值表示式節點,operator 屬性表示一個賦值運算子,left 和 right 是賦值運算子左右的表示式。
interface AssignmentExpression {
type: "AssignmentExpression";
operator: AssignmentOperator;
left: Pattern | MemberExpression;
right: Expression;
}
MemberExpression
成員表示式節點,即表示引用物件成員的語句,object 是引用物件的表示式節點,property 是表示屬性名稱,computed 如果為 false,是表示 . 來引用成員,property 應該為一個 Identifier 節點,如果 computed 屬性為 true,則是 [] 來進行引用,即 property 是一個 Expression 節點,名稱是表示式的結果值。
interface MemberExpression {
type: "MemberExpression";
object: Expression | Super;
property: Expression;
computed: boolean;
optional: boolean;
}
我們先來定義 module.exports 變數。
import Scope from "./scope";
import Visitor from "./visitor";
import * as ESTree from "estree";
class Interpreter {
private scope: Scope;
private visitor: Visitor;
constructor(visitor: Visitor) {
this.visitor = visitor;
}
interpret(node: ESTree.Node) {
this.createScope();
this.visitor.visitNode(node, this.scope);
return this.exportResult();
}
createScope() {
// 建立全域性作用域
this.scope = new Scope("root");
// 定義module.exports
const $exports = {};
const $module = { exports: $exports };
this.scope.defineConst("module", $module);
this.scope.defineVar("exports", $exports);
}
// 模擬commonjs,對外暴露結果
exportResult() {
// 查詢module變數
const moduleExport = this.scope.search("module");
// 返回module.exports值
return moduleExport ? moduleExport.value.exports : null;
}
}
export default Interpreter;
ok,下面我們來實現以上節點函式~
// standard/es5.ts 實現以上節點方法
import Scope from "../scope";
import * as ESTree from "estree";
type AstPath<T> = {
node: T;
scope: Scope;
};
const es5 = {
// ...
// 這裡我們定義了astPath,新增了scope作用域引數
MemberExpression(astPath: AstPath<ESTree.MemberExpression>) {
const { node, scope } = astPath;
const { object, property, computed } = node;
// property 是表示屬性名稱,computed 如果為 false,property 應該為一個 Identifier 節點,如果 computed 屬性為 true,即 property 是一個 Expression 節點
// 這裡我們拿到的是exports這個key值,即屬性名稱
const prop = computed
? this.visitNode(property, scope)
: (<ESTree.Identifier>property).name;
// object 表示物件,這裡為module,對module進行節點訪問
const obj = this.visitNode(object, scope);
// 訪問module.exports值
return obj[prop];
},
// 賦值表示式節點
(astPath: AstPath<ESTree.>) {
const { node, scope } = astPath;
const { left, operator, right } = node;
let assignVar;
// LHS 處理
if (left.type === "Identifier") {
// 識別符號型別 直接查詢
const value = scope.search(left.name);
assignVar = value;
} else if (left.type === "MemberExpression") {
// 成員表示式型別,處理方式跟上面差不多,不同的是這邊需要自定義一個變數物件的實現
const { object, property, computed } = left;
const obj = this.visitNode(object, scope);
const key = computed
? this.visitNode(property, scope)
: (<ESTree.Identifier>property).name;
assignVar = {
get value() {
return obj[key];
},
set value(v) {
obj[key] = v;
},
};
}
// RHS
// 不同操作符處理,查詢到right節點值,對left節點進行賦值。
return {
"=": (v) => {
assignVar.value = v;
return v;
},
"+=": (v) => {
const value = assignVar.value;
assignVar.value = v + value;
return assignVar.value;
},
"-=": (v) => {
const value = assignVar.value;
assignVar.value = value - v;
return assignVar.value;
},
"*=": (v) => {
const value = assignVar.value;
assignVar.value = v * value;
return assignVar.value;
},
"/=": (v) => {
const value = assignVar.value;
assignVar.value = value / v;
return assignVar.value;
},
"%=": (v) => {
const value = assignVar.value;
assignVar.value = value % v;
return assignVar.value;
},
}[operator](this.visitNode(right, scope));
},
};
export default es5;
ok,實現完畢,是時候驗證一波了,上 jest 大法。
// __test__/es5.test.ts
import { run } from "../src/vm";
describe("giao-js es5", () => {
test("assign", () => {
expect(
run(`
module.exports = 6;
`)
).toBe(6);
});
}
實踐第 5 彈: for 迴圈
var result = 0;
for (var i = 0; i < 5; i++) {
result += 2;
}
module.exports = result;
到這一彈大家都發現了,不同的語法其實對應的就是不同的樹節點,我們只要實現對應的節點函式即可.我們先來看看這幾個陌生節點的含義.
ForStatement
for 迴圈語句節點,屬性 init/test/update 分別表示了 for 語句括號中的三個表示式,初始化值,迴圈判斷條件,每次迴圈執行的變數更新語句(init 可以是變數宣告或者表示式)。
這三個屬性都可以為 null,即 for(;;){}。
body 屬性用以表示要迴圈執行的語句。
interface ForStatement {
type: "ForStatement";
init?: VariableDeclaration | Expression | null;
test?: Expression | null;
update?: Expression | null;
body: Statement;
}
UpdateExpression
update 運算表示式節點,即 ++/--,和一元運算子類似,只是 operator 指向的節點物件型別不同,這裡是 update 運算子。
interface UpdateExpression {
type: "UpdateExpression";
operator: UpdateOperator;
argument: Expression;
prefix: boolean;
}
BlockStatement
塊語句節點,舉個例子:if (...) { // 這裡是塊語句的內容 },塊裡邊可以包含多個其他的語句,所以有一個 body 屬性,是一個陣列,表示了塊裡邊的多個語句。
interface BlockStatement {
0;
type: "BlockStatement";
body: Array<Statement>;
innerComments?: Array<Comment>;
}
廢話少說,盤它!!!
// standard/es5.ts 實現以上節點方法
import Scope from "../scope";
import * as ESTree from "estree";
type AstPath<T> = {
node: T;
scope: Scope;
};
const es5 = {
// ...
// for 迴圈語句節點
ForStatement(astPath: AstPath<ESTree.ForStatement>) {
const { node, scope } = astPath;
const { init, test, update, body } = node;
// 這裡需要注意的是需要模擬建立一個塊級作用域
// 前面Scope類實現,var宣告在塊作用域中會被提升,const/let不會
const forScope = new Scope("block", scope);
for (
// 初始化值
// VariableDeclaration
init ? this.visitNode(init, forScope) : null;
// 迴圈判斷條件(BinaryExpression)
// 二元運算表示式,之前已實現,這裡不再細說
test ? this.visitNode(test, forScope) : true;
// 變數更新語句(UpdateExpression)
update ? this.visitNode(update, forScope) : null
) {
// BlockStatement
this.visitNode(body, forScope);
}
},
// update 運算表示式節點
// update 運算表示式節點,即 ++/--,和一元運算子類似,只是 operator 指向的節點物件型別不同,這裡是 update 運算子。
UpdateExpression(astPath: AstPath<ESTree.UpdateExpression>) {
const { node, scope } = astPath;
// update 運算子,值為 ++ 或 --,配合 update 表示式節點的 prefix 屬性來表示前後。
const { prefix, argument, operator } = node;
let updateVar;
// 這裡需要考慮引數型別還有一種情況是成員表示式節點
// 例: for (var query={count:0}; query.count < 8; query.count++)
// LHS查詢
if (argument.type === "Identifier") {
// 識別符號型別 直接查詢
const value = scope.search(argument.name);
updateVar = value;
} else if (argument.type === "MemberExpression") {
// 成員表示式的實現在前面實現過,這裡不再細說,一樣的套路~
const { object, property, computed } = argument;
const obj = this.visitNode(object, scope);
const key = computed
? this.visitNode(property, scope)
: (<ESTree.Identifier>property).name;
updateVar = {
get value() {
return obj[key];
},
set value(v) {
obj[key] = v;
},
};
}
return {
"++": (v) => {
const result = v.value;
v.value = result + 1;
// preifx? ++i: i++;
return prefix ? v.value : result;
},
"--": (v) => {
const result = v.value;
v.value = result - 1;
// preifx? --i: i--;
return prefix ? v.value : result;
},
}[operator](updateVar);
},
// 塊語句節點
// 塊語句的實現很簡單,模擬建立一個塊作用域,然後遍歷body屬性進行訪問即可。
BlockStatement(astPath: AstPath<ESTree.BlockStatement>) {
const { node, scope } = astPath;
const blockScope = new Scope("block", scope);
const { body } = node;
body.forEach((bodyNode) => {
this.visitNode(bodyNode, blockScope);
});
},
};
export default es5;
上 jest 大法驗證一哈~
test("test for loop", () => {
expect(
run(`
var result = 0;
for (var i = 0; i < 5; i++) {
result += 2;
}
module.exports = result;
`)
).toBe(10);
});
你以為這樣就結束了嗎? 有沒有想到還有什麼情況沒處理? for 迴圈的中斷語句呢?
var result = 0;
for (var i = 0; i < 5; i++) {
result += 2;
break; // break,continue,return
}
module.exports = result;
感興趣的小夥伴可以自己動手試試,或者戳原始碼地址
結語
giao-js目前只實現了幾個語法,本文只是提供一個思路。
有興趣的同學可以檢視完整程式碼。
覺得有幫助到你的話,點個 star 支援下作者 ❤️ ~
參考
Build a JS Interpreter in JavaScript Using Acorn as a Parser