【图文详解】200行JS代码,带你实现代码编译器(人人都能学会) 下

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 【图文详解】200行JS代码,带你实现代码编译器(人人都能学会) 下

词法分析器

词法分析器方法tokenizer 的主要任务:遍历整个原始代码字符串,将原始代码字符串转换为词法单元数组(tokens),并返回。

在遍历过程中,匹配每种字符并处理成词法单元压入词法单元数组,如当匹配到左括号( ( )时,将往词法单元数组(tokens)压入一个词法单元对象{type: 'paren', value:'('})。

网络异常,图片无法展示
|

// 词法分析器 参数:原始代码字符串 input
function tokenizer(input) {
  let current = 0;  // 当前解析的字符索引,作为游标
  let tokens = [];  // 初始化词法单元数组
  // 循环遍历原始代码字符串,读取词法单元数组
  while (current < input.length) {
    let char = input[current];
    // 匹配左括号,匹配成功则压入对象 {type: 'paren', value:'('}
    if (char === '(') {
      tokens.push({
        type: 'paren',
        value: '('
      });
      current++;
      continue; // 自增current,完成本次循环,进入下一个循环
    }
    // 匹配右括号,匹配成功则压入对象 {type: 'paren', value:')'}
    if (char === ')') {
      tokens.push({
        type: 'paren',
        value: ')'
      });
      current++;
      continue;
    }
    // 匹配空白字符,匹配成功则跳过
    // 使用 \s 匹配,包括空格、制表符、换页符、换行符、垂直制表符等
    let WHITESPACE = /\s/;
    if (WHITESPACE.test(char)) {
      current++;
      continue;
    }
    // 匹配数字字符,使用 [0-9]:匹配
    // 匹配成功则压入{type: 'number', value: value}
    // 如 (add 123 456) 中 123 和 456 为两个数值词法单元
    let NUMBERS = /[0-9]/;
    if (NUMBERS.test(char)) {
      let value = '';
      // 匹配连续数字,作为数值
      while (NUMBERS.test(char)) {
        value += char;
        char = input[++current];
      }
      tokens.push({ type: 'number', value });
      continue;
    }
    // 匹配形双引号包围的字符串
    // 匹配成功则压入 { type: 'string', value: value }
    // 如 (concat "foo" "bar") 中 "foo" 和 "bar" 为两个字符串词法单元
    if (char === '"') {
      let value = '';
      char = input[++current]; // 跳过左双引号
      // 获取两个双引号之间所有字符
      while (char !== '"') {
        value += char;
        char = input[++current];
      }
      char = input[++current];// 跳过右双引号
      tokens.push({ type: 'string', value });
      continue;
    }
    // 匹配函数名,要求只含大小写字母,使用 [a-z] 匹配 i 模式
    // 匹配成功则压入 { type: 'name', value: value }
    // 如 (add 2 4) 中 add 为一个名称词法单元
    let LETTERS = /[a-z]/i;
    if (LETTERS.test(char)) {
      let value = '';
      // 获取连续字符
      while (LETTERS.test(char)) {
        value += char;
        char = input[++current];
      }
      tokens.push({ type: 'name', value });
      continue;
    }
    // 当遇到无法识别的字符,抛出错误提示,并退出
    throw new TypeError('I dont know what this character is: ' + char);
  }
  // 词法分析器的最后返回词法单元数组
  return tokens;
}

语法分析器

语法分析器方法parser 的主要任务:将词法分析器返回的词法单元数组,转换为能够描述语法成分及其关系的中间形式(抽象语法树 AST)。

网络异常,图片无法展示
|

// 语法分析器 参数:词法单元数组tokens
function parser(tokens) {
  let current = 0; // 设置当前解析的词法单元的索引,作为游标
  // 递归遍历(因为函数调用允许嵌套),将词法单元转成 LISP 的 AST 节点
  function walk() {
    // 获取当前索引下的词法单元 token
    let token = tokens[current]; 
    // 数值类型词法单元
    if (token.type === 'number') {
      current++; // 自增当前 current 值
      // 生成一个 AST节点 'NumberLiteral',表示数值字面量
      return {
        type: 'NumberLiteral',
        value: token.value,
      };
    }
    // 字符串类型词法单元
    if (token.type === 'string') {
      current++;
      // 生成一个 AST节点 'StringLiteral',表示字符串字面量
      return {
        type: 'StringLiteral',
        value: token.value,
      };
    }
    // 函数类型词法单元
    if (token.type === 'paren' && token.value === '(') {
      // 跳过左括号,获取下一个词法单元作为函数名
      token = tokens[++current];
      let node = {
        type: 'CallExpression',
        name: token.value,
        params: []
      };
      // 再次自增 current 变量,获取参数词法单元
      token = tokens[++current];
      // 遍历每个词法单元,获取函数参数,直到出现右括号")"
      while ((token.type !== 'paren') || (token.type === 'paren' && token.value !== ')')) {
        node.params.push(walk());
        token = tokens[current];
      }
      current++; // 跳过右括号
      return node;
    }
    // 无法识别的字符,抛出错误提示
    throw new TypeError(token.type);
  }
  // 初始化 AST 根节点
  let ast = {
    type: 'Program',
    body: [],
  };
  // 循环填充 ast.body
  while (current < tokens.length) {
    ast.body.push(walk());
  }
  // 最后返回ast
  return ast;
}

3.4 转换阶段

在转换阶段中,定义了转换器 transformer 函数,使用词法分析器返回的 LISP 的 AST 对象作为参数,将 AST 对象转换成一个新的 AST 对象。


为了方便代码组织,我们定义一个遍历器 traverser 方法,用来处理每一个节点的操作。

// 遍历器 参数:ast 和 visitor
function traverser(ast, visitor) {
  // 定义方法 traverseArray 
  // 用于遍历 AST节点数组,对数组中每个元素调用 traverseNode 方法。
  function traverseArray(array, parent) {
    array.forEach(child => {
      traverseNode(child, parent);
    });
  }
  // 定义方法 traverseNode
  // 用于处理每个 AST 节点,接受一个 node 和它的父节点 parent 作为参数
  function traverseNode(node, parent) {
    // 获取 visitor 上对应方法的对象
    let methods = visitor[node.type];
    // 获取 visitor 的 enter 方法,处理操作当前 node
    if (methods && methods.enter) {
      methods.enter(node, parent);
    }
    switch (node.type) {
      // 根节点
      case 'Program':
        traverseArray(node.body, node);
        break;
      // 函数调用
      case 'CallExpression':
        traverseArray(node.params, node);
        break;
      // 数值和字符串,忽略
      case 'NumberLiteral':
      case 'StringLiteral':
        break;
      // 当遇到无法识别的字符,抛出错误提示,并退出
      default:
        throw new TypeError(node.type);
    }
    if (methods && methods.exit) {
      methods.exit(node, parent);
    }
  }
  // 首次执行,开始遍历
  traverseNode(ast, null);
}

在看遍历器traverser 方法时,建议结合下面介绍的转换器transformer 方法阅读:

// 转化器,参数:ast
function transformer(ast) {
  // 创建 newAST,与之前 AST 类似,Program:作为新 AST 的根节点
  let newAst = {
    type: 'Program',
    body: [],
  };
  // 通过 _context 维护新旧 AST,注意 _context 是一个引用,从旧的 AST 到新的 AST。
  ast._context = newAst.body;
  // 通过遍历器遍历 处理旧的 AST
  traverser(ast, {
    // 数值,直接原样插入新AST,类型名称 NumberLiteral
    NumberLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'NumberLiteral',
          value: node.value,
        });
      },
    },
    // 字符串,直接原样插入新AST,类型名称 StringLiteral
    StringLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'StringLiteral',
          value: node.value,
        });
      },
    },
    // 函数调用
    CallExpression: {
      enter(node, parent) {
        // 创建不同的AST节点
        let expression = {
          type: 'CallExpression',
          callee: {
            type: 'Identifier',
            name: node.name,
          },
          arguments: [],
        };
        // 函数调用有子类,建立节点对应关系,供子节点使用
        node._context = expression.arguments;
        // 顶层函数调用算是语句,包装成特殊的AST节点
        if (parent.type !== 'CallExpression') {
          expression = {
            type: 'ExpressionStatement',
            expression: expression,
          };
        }
        parent._context.push(expression);
      },
    }
  });
  return newAst;
}

重要一点,这里通过 _context 引用来维护新旧 AST 对象,管理方便,避免污染旧 AST 对象。

3.5 代码生成

接下来到了最后一步,我们定义代码生成器codeGenerator 方法,通过递归,将新的 AST 对象代码转换成 JavaScript 可执行代码字符串。

// 代码生成器 参数:新 AST 对象
function codeGenerator(node) {
  switch (node.type) {
    // 遍历 body 属性中的节点,且递归调用 codeGenerator,按行输出结果
    case 'Program':
      return node.body.map(codeGenerator)
        .join('\n');
    // 表达式,处理表达式内容,并用分号结尾
    case 'ExpressionStatement':
      return (
        codeGenerator(node.expression) +
        ';'
      );
    // 函数调用,添加左右括号,参数用逗号隔开
    case 'CallExpression':
      return (
        codeGenerator(node.callee) +
        '(' +
        node.arguments.map(codeGenerator)
          .join(', ') +
        ')'
      );
    // 标识符,返回其 name
    case 'Identifier':
      return node.name;
    // 数值,返回其 value
    case 'NumberLiteral':
      return node.value;
    // 字符串,用双引号包裹再输出
    case 'StringLiteral':
      return '"' + node.value + '"';
    // 当遇到无法识别的字符,抛出错误提示,并退出
    default:
      throw new TypeError(node.type);
  }
}

3.6 编译器测试

截止上一步,我们完成简易编译器的代码开发。接下来通过前面原始需求的代码,测试编译器效果如何:

const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const source = "(add 2 (subtract 4 2))";
const target = compiler(source); // "add(2, (subtract(4, 2));"
const result = eval(target); // Ok result is 4

3.7 工作流程小结

总结 The Super Tiny Compiler 编译器整个工作流程:

1、input => tokenizer => tokens

2、tokens => parser => ast

3、ast => transformer => newAst

4、newAst => generator => output

其实多数编译器的工作流程都大致相同:

网络异常,图片无法展示
|

四、手写 Webpack 编译器

根据之前介绍的 The Super Tiny Compiler编译器核心工作流程,再来手写 Webpack 的编译器,会让你有种众享丝滑的感觉~

网络异常,图片无法展示
|

话说,有些面试官喜欢问这个呢。当然,手写一遍能让我们更了解 Webpack 的构建流程,这个章节我们简要介绍一下。

4.1 Webpack 构建流程分析

从启动构建到输出结果一系列过程:

  1. 初始化参数

解析 Webpack 配置参数,合并 Shell 传入和 webpack.config.js 文件配置的参数,形成最后的配置结果。

  1. 开始编译

上一步得到的参数初始化 compiler 对象,注册所有配置的插件,插件监听 Webpack 构建生命周期的事件节点,做出相应的反应,执行对象的 run 方法开始执行编译。

  1. 确定入口

从配置的 entry 入口,开始解析文件构建 AST 语法树,找出依赖,递归下去。

  1. 编译模块

递归中根据文件类型loader 配置,调用所有配置的 loader 对文件进行转换,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。

  1. 完成模块编译并输出

递归完事后,得到每个文件结果,包含每个模块以及他们之间的依赖关系,根据 entry 配置生成代码块 chunk

  1. 输出完成

输出所有的 chunk 到文件系统。


注意:在构建生命周期中有一系列插件在做合适的时机做合适事情,比如 UglifyPlugin 会在 loader 转换递归完对结果使用 UglifyJs 压缩覆盖之前的结果

网络异常,图片无法展示
|

4.2 代码实现

手写 Webpack 需要实现以下三个核心方法:

  • createAssets : 收集和处理文件的代码;
  • createGraph :根据入口文件,返回所有文件依赖图;
  • bundle : 根据依赖图整个代码并输出;

1. createAssets

function createAssets(filename){
    const content = fs.readFileSync(filename, "utf-8"); // 根据文件名读取文件内容
    // 将读取到的代码内容,转换为 AST
    const ast = parser.parse(content, {
        sourceType: "module" // 指定源码类型
    })
    const dependencies = []; // 用于收集文件依赖的路径
    // 通过 traverse 提供的操作 AST 的方法,获取每个节点的依赖路径
    traverse(ast, {
        ImportDeclaration: ({node}) => {
            dependencies.push(node.source.value);
        }
    });
    // 通过 AST 将 ES6 代码转换成 ES5 代码
    const { code } = babel.transformFromAstSync(ast, null, {
        presets: ["@babel/preset-env"]
    });
    let id = moduleId++;
    return {
        id,
        filename,
        code,
        dependencies
    }
}

2. createGraph

function createGraph(entry) {
    const mainAsset = createAssets(entry); // 获取入口文件下的内容
    const queue = [mainAsset];
    for(const asset of queue){
        const dirname = path.dirname(asset.filename);
        asset.mapping = {};
        asset.dependencies.forEach(relativePath => {
            const absolutePath = path.join(dirname, relativePath); // 转换文件路径为绝对路径
            const child = createAssets(absolutePath);
            asset.mapping[relativePath] = child.id;
            queue.push(child); // 递归去遍历所有子节点的文件
        })
    }
    return queue;
}

3. bunlde

function bundle(graph) {
    let modules = "";
    graph.forEach(item => {
        modules += `
            ${item.id}: [
                function (require, module, exports){
                    ${item.code}
                },
                ${JSON.stringify(item.mapping)}
            ],
        `
    })
    return `
        (function(modules){
            function require(id){
                const [fn, mapping] = modules[id];
                function localRequire(relativePath){
                    return require(mapping[relativePath]);
                }
                const module = {
                    exports: {}
                }
                fn(localRequire, module, module.exports);
                return module.exports;
            }
            require(0);
        })({${modules}})
    `
}

五、总结

本文从编译器概念和基本工作流程开始介绍,然后通过 The Super Tiny Compiler 译器源码,详细介绍核心工作流程实现,包括词法分析器语法分析器遍历器转换器的基本实现,最后通过代码生成器,将各个阶段代码结合起来,实现了这个号称可能是有史以来最小的编译器。

本文也简要介绍了手写 Webpack 的实现,需要读者自行完善和深入哟! 是不是觉得很神奇~

网络异常,图片无法展示
|

当然通过本文学习,也仅仅是编译器相关知识的边山一脚,要学的知识还有非常多,不过好的开头,更能促进我们学习动力。加油!


最后,文中介绍到的代码,我存放在 Github 上:

  1. [learning]the-super-tiny-compiler.js
  2. [writing]webpack-compiler.js

六、参考资料

  1. 《The Super Tiny Compiler》
  2. 《有史以来最小的编译器源码解析》
  3. 《Angular 2 JIT vs AOT》
目录
相关文章
|
26天前
|
JavaScript
短小精悍的js代码
【10月更文挑战第17天】
120 58
|
1月前
|
JavaScript 前端开发 开发者
如何在 Visual Studio Code (VSCode) 中使用 ESLint 和 Prettier 来检查代码规范并自动格式化 Vue.js 代码。
【10月更文挑战第7天】随着前端开发技术的快速发展,代码规范和格式化工具变得尤为重要。本文介绍了如何在 Visual Studio Code (VSCode) 中使用 ESLint 和 Prettier 来检查代码规范并自动格式化 Vue.js 代码。通过安装和配置这两个工具,可以确保代码风格一致,提升团队协作效率和代码质量。
226 2
|
14天前
|
JavaScript
原生js炫酷随机抽奖中奖效果代码
原生js随机抽奖是一个炫酷的根据数据随机抽奖的代码,该网页可进行随机抽取一个数据,页面动画高科技、炫酷感觉的随机抽奖效果,简单好用,欢迎下载!
31 3
原生js炫酷随机抽奖中奖效果代码
|
18天前
|
JavaScript 前端开发 开发者
如何在 Visual Studio Code (VSCode) 中使用 ESLint 和 Prettier 检查代码规范并自动格式化 Vue.js 代码,包括安装插件、配置 ESLint 和 Prettier 以及 VSCode 设置的具体步骤
随着前端开发技术的快速发展,代码规范和格式化工具变得尤为重要。本文介绍了如何在 Visual Studio Code (VSCode) 中使用 ESLint 和 Prettier 检查代码规范并自动格式化 Vue.js 代码,包括安装插件、配置 ESLint 和 Prettier 以及 VSCode 设置的具体步骤。通过这些工具,可以显著提升编码效率和代码质量。
181 4
|
20天前
|
JSON 移动开发 数据格式
html5+css3+js移动端带歌词音乐播放器代码
音乐播放器特效是一款html5+css3+js制作的手机移动端音乐播放器代码,带歌词显示。包括支持单曲循环,歌词显示,歌曲搜索,音量控制,列表循环等功能。利用json获取音乐歌单和歌词,基于html5 audio属性手机音乐播放器代码。
72 6
|
16天前
|
JavaScript 前端开发 开发者
如何在 Visual Studio Code (VSCode) 中使用 ESLint 和 Prettier 检查代码规范并自动格式化 Vue.js 代码
随着前端开发技术的快速发展,代码规范和格式化工具变得尤为重要。本文介绍如何在 Visual Studio Code (VSCode) 中使用 ESLint 和 Prettier 检查代码规范并自动格式化 Vue.js 代码。通过安装和配置这些工具,可以确保代码风格一致,提高代码质量和可读性。
47 1
|
1月前
|
JavaScript 前端开发 开发者
如何在 VSCode 中使用 ESLint 和 Prettier 检查并自动格式化 Vue.js 代码,提升团队协作效率和代码质量。
【10月更文挑战第9天】随着前端开发技术的发展,代码规范和格式化工具变得至关重要。本文介绍如何在 VSCode 中使用 ESLint 和 Prettier 检查并自动格式化 Vue.js 代码,提升团队协作效率和代码质量。通过安装插件、配置 ESLint 和 Prettier,以及设置 VSCode,实现代码实时检查和格式化,确保代码风格一致。
29 2
|
1月前
|
JavaScript 前端开发 开发者
如何在 Visual Studio Code (VSCode) 中使用 ESLint 和 Prettier 检查并自动格式化 Vue.js 代码,提升代码质量和团队协作效率。
【10月更文挑战第8天】本文介绍了如何在 Visual Studio Code (VSCode) 中使用 ESLint 和 Prettier 检查并自动格式化 Vue.js 代码,提升代码质量和团队协作效率。通过安装 VSCode 插件、配置 ESLint 和 Prettier,实现代码规范检查和自动格式化,确保代码风格一致,提高可读性和维护性。
70 2
|
1月前
|
自然语言处理 JavaScript 前端开发
深入理解JavaScript中的闭包:原理、应用与代码演示
【10月更文挑战第12天】深入理解JavaScript中的闭包:原理、应用与代码演示