在這篇文章中,咱們將經過 JS 構建咱們本身的 JS 解釋器,用 JS 寫 JS,這聽起來很奇怪,儘管如此,這樣作咱們將更熟悉 JS,也能夠學習 JS 引擎是如何工做的!
解釋器是在運行時運行的語言求值器,它動態地執行程序的源代碼。
解釋器解析源代碼,從源代碼生成 AST(抽象語法樹),遍歷 AST 並逐個計算它們。
將源代碼分解並組織成一組有意義的單詞,這一過程即爲詞法分析(Token)。
在英語中,當咱們遇到這樣一個語句時:javascript
Javascript is the best language in the world
咱們會下意識地把句子分解成一個個單詞:java
+----------------------------------------------------------+ | Javascript | is | the | best | language | in |the |world | +----------------------------------------------------------+
這是分析和理解句子的第一階段。node
詞法分析是由詞法分析器完成的,詞法分析器會掃描(scanning)代碼,提取詞法單元。git
var a = 1; [ ("var": "keyword"), ("a": "identifier"), ("=": "assignment"), ("1": "literal"), (";": "separator"), ];
詞法分析器將代碼分解成 Token 後,會將 Token 傳遞給解析器進行解析,咱們來看下解析階段是如何工做的。github
將詞法分析階段生成的 Token 轉換爲抽象語法樹(Abstract Syntax Tree),這一過程稱之爲語法解析(Parsing)。
在英語中,Javascript is the best language 被分解爲如下單詞:web
+------------------------------------------+ | Javascript | is | the | best | language | +------------------------------------------+
這樣咱們就能夠挑選單詞並造成語法結構:正則表達式
"Javascript": Subject "is the best language": Predicate "language": Object
Javascript 在語法中是一個主語名詞,其他的是一個沒有什麼意義的句子叫作謂語,language 是動做的接受者,也就是賓語。結構是這樣的:express
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" } ], }
解釋器將遍歷 AST 並計算每一個節點。- 求值階段
1 + 2 | | v +---+ +---+ | 1 | | 2 | +---+ +---+ \ / \ / \ / +---+ | + | +---+ { lhs: 1, op: '+'. rhs: 2 }
解釋器解析 Ast,獲得 LHS 節點,接着收集到操做符(operator)節點+,+操做符表示須要進行一次加法操做,它必須有第二個節點來進行加法操做.接着他收集到 RHS 節點。它收集到了有價值的信息並執行加法獲得告終果,3。babel
{ 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~
A tiny, fast JavaScript parser, written completely in JavaScript. 一個徹底使用 javascript 實現的,小型且快速的 javascript 解析器
本次實踐咱們將使用 acorn.js ,它會幫咱們進行詞法分析,語法解析並轉換爲抽象語法樹。
Webpack/Rollup/Babel(@babel/parser) 等第三方庫也是使用 acorn.js 做爲本身 Parser 的基礎庫。(站在巨人的肩膀上啊!)
最開始 Mozilla JS Parser API 是 Mozilla 工程師在 Firefox 中建立的 SpiderMonkey 引擎輸出 JavaScript AST 的規範文檔,文檔所描述的格式被用做操做 JAvaScript 源代碼的通用語言。
隨着 JavaScript 的發展,更多新的語法被加入,爲了幫助發展這種格式以跟上 JavaScript 語言的發展。The ESTree Spec 就誕生了,做爲參與構建和使用這些工具的人員的社區標準。
acorn.js parse 返回值符合 ESTree spec 描述的 AST 對象,這裏咱們使用@types/estree 作類型定義。
號稱使人愉快的 JavaScript 測試...咱們使用它來進行單元測試.
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 加法的解釋。首先咱們經過AST explorer,看看 1+1 這段代碼轉換後的 AST 結構。
咱們能夠看到這段代碼中存在 4 種節點類型,下面咱們簡單的介紹一下它們:
根節點,即表明一整顆抽象語法樹,body 屬性是一個數組,包含了多個 Statement 節點。
interface Program { type: "Program"; sourceType: "script" | "module"; body: Array<Directive | Statement | ModuleDeclaration>; comments?: Array<Comment>; }
表達式語句節點,expression 屬性指向一個表達式節點對象
interface ExpressionStatement { type: "ExpressionStatement"; expression: Expression; }
二元運算表達式節點,left 和 right 表示運算符左右的兩個表達式,operator 表示一個二元運算符。
本節實現的重點,簡單理解,咱們只要拿到 operator 操做符的類型並實現,而後對 left,right 值進行求值便可。
interface BinaryExpression { type: "BinaryExpression"; operator: BinaryOperator; left: Expression; right: Expression; }
字面量,這裏不是指 [] 或者 {} 這些,而是自己語義就表明了一個值的字面量,如 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;
就這樣,普通的二元運算就搞定啦!!!
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;
以上就是變量對象,做用域及做用域鏈的基礎實現了,接下來咱們就能夠定義及訪問變量了。
從語法樹中咱們能夠看到三個陌生的節點類型,來看看它們分別表明什麼意思:
變量聲明,kind 屬性表示是什麼類型的聲明,由於 ES6 引入了 const/let。
declarations 表示聲明的多個描述,由於咱們能夠這樣:let a = 1, b = 2;。
interface VariableDeclaration { type: "VariableDeclaration"; declarations: Array<VariableDeclarator>; kind: "var" | "let" | "const"; }
變量聲明的描述,id 表示變量名稱節點,init 表示初始值的表達式,能夠爲 null。
interface VariableDeclarator { type: "VariableDeclarator"; id: Pattern; init?: Expression | null; }
顧名思義,標識符節點,咱們寫 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;
咱們先來看看 module.exports = 6 對應的 AST。
從語法樹中咱們又看到兩個陌生的節點類型,來看看它們分別表明什麼意思:
賦值表達式節點,operator 屬性表示一個賦值運算符,left 和 right 是賦值運算符左右的表達式。
interface AssignmentExpression { type: "AssignmentExpression"; operator: AssignmentOperator; left: Pattern | MemberExpression; right: Expression; }
成員表達式節點,即表示引用對象成員的語句,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); }); }
var result = 0; for (var i = 0; i < 5; i++) { result += 2; } module.exports = result;
到這一彈你們都發現了,不一樣的語法其實對應的就是不一樣的樹節點,咱們只要實現對應的節點函數便可.咱們先來看看這幾個陌生節點的含義.
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; }
update 運算表達式節點,即 ++/--,和一元運算符相似,只是 operator 指向的節點對象類型不一樣,這裏是 update 運算符。
interface UpdateExpression { type: "UpdateExpression"; operator: UpdateOperator; argument: Expression; prefix: boolean; }
塊語句節點,舉個例子: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