从零实现一个 JavaScript 引擎

1. 前言:为什么前端工程师需要了解 JS 引擎

众所周知,JS 能在各种环境中运行,如浏览器、移动端、服务器、嵌入式设备,而 JS 引擎则是五花八门,如 V8、JS Core、Hermes、QuickJS。

引擎开发方主要应用场景特点
V8GoogleChrome、Node.js、DenoJIT 强大,性能高,体积大
JSCAppleSafari、Bun轻量、兼容性好
HermesMetaReact Native 移动端AOT 编译、启动快、省内存、包体小
QuickJSFabrice BellardIoT、嵌入式、工具小巧、无 JIT、学习好

以上 JS 引擎都是基于 C/C++ 实现的,一天晚上睡觉前突发奇想,为啥不可以通过 JS 语言本身来实现一个简单的 JS 引擎呢?基于此我开始并完成了该项目,并起了一个有意思的名字 js.js(项目地址见文末),顾名思义用 JS 来跑 JS。当然我并非是第一个产生该想法的人,网上搜查下会发现其他人已经实现了用 JS 写的 JS 引擎,如 JS-Interpreter

今天我们要做的是,回归 JS 引擎是如何一步步实现的,将大的遥不可及的任务拆分为小的可实现的单元。而通过这条路,我们可以明白如下系列问题的原因(这些问题我们会在文末一一解答):Babel 是如何将 ES6+ 代码转换为 ES5 的;Node/Deno/Bun 和 JS 引擎的区别是什么;为什么 Chrome 和 Safari 的 JS 兼容性不一致等问题。这也是我们从会用 JS 到真正理解 JS 的第一步。

2. 项目展示:先看看效果

在开始一步步实现之前,我们先看看当前 JS 引擎跑一段简单 JS 代码的效果。以下是 JS 代码片段:

// 创建引擎实例
import { SimpleJSEngine } from "../src/index.js";
const engine = new SimpleJSEngine();

// 执行基本运算
const result1 = engine.run(`
  let a = 10;
  let b = 20;
  let sum = a + b;
  console.log("计算结果:", sum);
  sum;
`);
console.log("返回值:", result1.result); // 30

// 执行函数定义和调用
const result2 = engine.run(`
  function fibonacci(n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
  }
  fibonacci(8);
`);
console.log("斐波那契数列第8项:", result2.result); // 21

让我们来运行以上代码 node intro.js,会在控制台得到如下输出:

计算结果: 30
返回值: 30
斐波那契数列第8项: 21

3. 项目概览:像搭积木一样实现 JS 引擎

实现一个 JS 引擎,这个任务看上去艰巨、遥不可及,实际上我们可以将其进行任务拆分,得到三个核心组件(词法分析器、语法分析器、解释器),然后像搭积木一样完成它。

graph LR
    Input["源代码"] --> Lexer["词法分析器<br/>Lexer"]
    Lexer --> |"Token 流"| Parser["语法分析器<br/>Parser"]
    Parser --> |"AST 树"| Interpreter["解释器<br/>Interpreter"]
    Interpreter --> Output["执行结果"]

值得一提的是,现代 JS 引擎比上述步骤更为复杂,可以理解为上述步骤的加强版,如 V8 包含了字节码和 JIT 等手段来使得 JS 代码快速运行。而本篇的引擎则不包含这些优化手段,更加专注于 JS 引擎的核心概念,避免学习路线过于陡峭。

4. 一步一步实现:任务拆分与实现

4.1 词法分析 Lexing

词法分析器的工作就像是把一篇文章拆分成单词。一段代码经过了词法分析器的处理,就得到了一系列的 token,供下一步语法分析器进行解析。我们以 let x = 1 + 2 * 3; 这个代码为例,看看词法分析后得到了什么:

"let x = 1 + 2 * 3;" 
    
[
  {type: 'LET', value: 'let', line: 1, column: 1},
  {type: 'IDENTIFIER', value: 'x', line: 1, column: 5},
  {type: 'ASSIGN', value: '=', line: 1, column: 7},
  {type: 'NUMBER', value: 1, line: 1, column: 9},
  {type: 'PLUS', value: '+', line: 1, column: 11},
  {type: 'NUMBER', value: 2, line: 1, column: 13},
  {type: 'MULTIPLY', value: '*', line: 1, column: 15},
  {type: 'NUMBER', value: 3, line: 1, column: 17},
  {type: 'SEMICOLON', value: ';', line: 1, column: 18}
]

可以看见我们得到了一系列的 token,而这些 token 有不同的种类,像 letx 显然不是同一种 token,前者是用于声明变量的关键字、后者是变量,所以它们的 type 是不同的。除了类型信息,还包含了 token 值以及位置信息。

了解了输入输出后,我们可以着手开始实现词法分析器了,以下是词法分析器的核心代码片段:

// 核心实现思路
export class Lexer {
  constructor(code) {
    this.code = code;
    this.position = 0;
    this.line = 1;
    this.column = 1;
  }

  // 主要方法:将字符流转换为 Token 流
  tokenize() {
    const tokens = [];
    while (!this.isAtEnd()) {
      const token = this.scanToken();
      if (token) tokens.push(token);
    }
    return tokens;
  }
}

4.2 语法分析 Parsing

接下来便是进行语法分析,核心是构建 AST。

抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。AST 在前端工程化领域是非常重要的,如 Babel、ESLint、CSS 预处理器都建立在 AST 的处理之上。

之前的步骤中从 let x = 1 + 2 * 3; 得到了 token 流,将其输入到语法分析器中,以构建 AST,如下所示:

Token   AST 
{
  type: 'VariableDeclaration',
  kind: 'let',
  declarations: [{
    type: 'VariableDeclarator',
    id: {type: 'Identifier', name: 'x'},
    init: {
      type: 'BinaryExpression',
      operator: '+',
      left: {type: 'Literal', value: 1},
      right: {
        type: 'BinaryExpression',  // 🔍 乘法在更深层,优先级更高
        operator: '*',
        left: {type: 'Literal', value: 2},
        right: {type: 'Literal', value: 3}
      }
    }
  }]
}

语法分析器使用递归下降算法,将平坦的 Token 流转换为层次化的 AST:

/** 
 * 从低到高优先级的递归调用
 * parseExpression()           // 入口
 *   ↓
 * parseAssignment()          // 赋值 = (优先级最低)
 *  ↓  
 * parseLogicalOr()           // 逻辑或 ||
 *  ↓
 * ...
 *  ↓
 * parsePrimary()             // 最高优先级:数字、标识符、括号
 */

// 递归下降解析示例
parseExpression() {
  return this.parseAssignmentExpression();
}

parseAssignmentExpression() {
  const left = this.parseLogicalOrExpression();

  if (this.match(TokenType.ASSIGN)) {
    const operator = this.getCurrentToken().value;
    this.advance();
    const right = this.parseAssignmentExpression();
    // 生成树节点
    return new AST.AssignmentExpression(operator, left, right);
  }

  return left;
}

可以看到,我们递归的调用链的优先即是从低到高的,这样可以保证位于调用链更深的为更高优先级的,由此高优先级可以被优先处理。递归下降算法也是编译原理中的经典技巧~

📊 注意 AST 中的优先级体现

4.3 解释 Interpreting

解释器通过访问者模式遍历 AST 并执行。

什么是访问者模式?

访问者模式将操作数据结构分离:

// AST 节点:纯数据结构
class BinaryExpression {
  constructor(operator, left, right) {
    this.type = 'BinaryExpression';  // 节点类型标识
    this.operator = operator;        // 操作符
    this.left = left;               // 左操作数
    this.right = right;             // 右操作数
  }
}

// 解释器:访问者,定义操作
class Interpreter {
  evaluate(node) {
    switch (node.type) {           // 根据类型分发
      case 'BinaryExpression':
        return this.evaluateBinaryExpression(node);
      case 'Literal':
        return node.value;
      // ...更多节点类型
    }
  }
}

访问者模式的优势:

执行过程可视化

AST   执行步骤分解
1. 访问 VariableDeclaration 节点
2. 访问右侧的 BinaryExpression (+)
3. 计算左操作数: 1
4. 计算右操作数 BinaryExpression (*)
   - 计算 2 * 3 = 6
5. 计算加法: 1 + 6 = 7
6. 在环境中创建变量 x,值为 7

项目中的实际实现:

// 解释器的核心分发方法
evaluate(node) {
  if (!node) return undefined;

  switch (node.type) {
    case 'Program':
      return this.evaluateProgram(node);
    case 'Literal':
      return node.value;                    // 直接返回字面量值
    case 'Identifier':
      return this.environment.get(node.name); // 从环境中获取变量
    case 'BinaryExpression':
      return this.evaluateBinaryExpression(node);
    case 'VariableDeclaration':
      return this.evaluateVariableDeclaration(node);
    case 'CallExpression':
      return this.evaluateCallExpression(node);
    // ... 支持多种节点类型
  }
}

// 具体的访问方法示例
evaluateBinaryExpression(node) {
  const left = this.evaluate(node.left);   // 递归计算左操作数
  const right = this.evaluate(node.right); // 递归计算右操作数
  
  switch (node.operator) {
    case '+': return left + right;
    case '*': return left * right;
    case '==': return left == right;
    // ... 更多操作符
  }
}

关键技术实现:

5. 彩蛋:项目资源与互动

项目统计数据

维度数据说明
🚀 代码规模~1,500 行纯 JavaScript 实现,零外部依赖
🎯 支持特性15+ 核心特性变量声明、函数、循环、闭包、对象等
⚡ 执行方式树遍历解释适合学习和原型验证,非生产优化
📦 技术栈ES6+ 模块使用 class、import/export 等现代语法
🧪 测试覆盖20+ 演示用例从基础运算到复杂递归,全面验证
🎓 学习价值编译原理入门词法分析 → 语法分析 → 解释执行

支持的 JavaScript 特性:

仓库地址js.js,欢迎大家 star~ ⭐

这篇文章只是 JavaScript 引擎实现的入门文章,主要介绍了核心概念和整体架构。我后续会推出后续的文章,对各节进行展开讲讲。如果你希望看到这个系列继续下去,请在评论区留言或给项目点个 star 进行支持!

致永不磨灭的激情和灵感