🔥手把手带你实现MiniESLint:探秘 AST 的前端应用

1,165 阅读6分钟

如果你想了解 Javascript 的编译原理,那么你就得了解 AST(Abstract Syntax Tree),目前前端常用的一些插件或者工具,比如 JS 转译、代码压缩、CSS 预处理器、ESLintPrettier 等功能的实现,都是建立在 AST 的基础之上的。如果这篇文章对你有帮助,欢迎点赞关注😘~

什么是AST

定义: 在计算机科学中,抽象语法树是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。

从定义中我们只需要知道一件事就行,那就是 AST 是一种树形结构,并且是某种代码的一种抽象表示,BabeltscVue-cliEsLint等很多的工具和库的核心都是通过 AST 抽象语法树这个概念来实现对代码的检查、分析等操作的。

在线可视化网站:astexplorer.net/ ,利用这个网站我们可以很清晰的看到各种语言的 AST 结构。

estree

estree 就是es语法对应的标准 AST,作为一个前端也比较方便理解。我们以官方文档为例:github.com/estree/estr…

例如console.log('1')这行代码会被解析为:

{  
  "type""Program",  
  "start"0// 起始位置  
  "end"16// 结束位置,字符长度  
  "body": [  
    {  
      "type""ExpressionStatement"// 表达式语句  
      "start"0,  
      "end"16,  
      "expression": {  
        "type""CallExpression"// 函数方法调用式  
        "start"0,  
        "end"16,  
        "callee": {  
          "type""MemberExpression"// 成员表达式 console.log  
          "start"0,  
          "end"11,  
          "object": {  
            "type""Identifier"// 标识符,可以是表达式或者结构模式  
            "start"0,  
            "end"7,  
            "name""console"  
          },  
          "property": {  
            "type""Identifier",
            "start"8,  
            "end"11,  
            "name""log"  
          },  
          "computed"false// 成员表达式的计算结果,如果为 true 则是 console[log], false 则为 console.log  
          "optional"false  
        },  
        "arguments": [ // 参数  
          {  
            "type""Literal"// 文字标记,可以是表达式  
            "start"12,  
            "end"15,  
            "value""1",  
            "raw""'1'"  
          }  
        ],  
        "optional"false  
      }  
    }  
  ],  
  "sourceType""module"  
}

这里建议自己将本地的测试代码(例如一个简单的a+b函数)复制进上面提到的网站中,理解 estree 的各种节点类型。当然了,我们也不可能看一篇文章就记住那么多类型,只要心里有个大致的概念即可。

AST 的运用

将原代码转化为 AST,修改 AST,再重新转化为新代码就能完成代码转译。Babel 将最新语法的 JS 代码转化为 ES5 的原理就是这样的。

image.png Babel 操作 AST 会用到以下工具包:

  • @babel/parser 用于将代码转换为 AST
  • @babel/traverse 用于对 AST 的遍历,包括节点增删改查、作用域等处理
  • @babel/generator 用于将 AST 转换成代码
  • @babel/types 用于 AST 节点操作的 Lodash 式工具库,各节点构造、验证等

更多 api 详见 Babel手册

解析过程

源代码 → AST 的转换过程主要分为三步:

  • 词法分析:将代码字符串分割成 token 流

  • 语法分析:将 token 流转换成 AST

  • 遍历和修改:对 AST 进行遍历和修改,生成新的代码

让我们看一个简单的例子:

// 源代码
const message = 'Hello World';

// 转换成 AST 后的结构(简化版)
{
  "type": "VariableDeclaration",
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": {
        "type": "Identifier",
        "name": "message"
      },
      "init": {
        "type": "StringLiteral",
        "value": "Hello World"
      }
    }
  ],
  "kind": "const"
}

实战案例:实现一个简易ESLint

安装依赖

npm install @babel/parser @babel/traverse @babel/types --save-dev

目录结构如下:

image.png

编写代码

定义一个MiniEsLint类:

this.rules 定义了默认的规则配置:

  • 'no-console': true - 禁止使用 console
  • 'max-params': [true, 3] - 函数最多允许 3 个参数
  • ...rules - 使用展开运算符合并用户自定义规则

this.errors 初始化一个空数组,用于收集错误信息

class MiniESLint {
  constructor(rules = {}) {
    // 默认规则配置
    this.rules = {
      'no-console': true,  // 禁止使用 console
      'max-params': [true, 3],  // 函数参数最大数量
      ...rules
    };
    
    this.errors = [];
  }

  /**
   * 验证代码的主方法
   * @param {string} code - 要检查的代码字符串
   * @param {string} filename - 文件名,默认为 anonymous.js
   * @returns {Array} 返回错误信息数组
   */
  verify(code, filename = 'anonymous.js') {
    // 每次验证前清空错误数组
    this.errors = [];
    return this.errors;
  }

  /**
   * 添加错误信息的辅助方法
   * @param {Object} error - 错误信息对象
   * @param {string} error.message - 错误描述
   * @param {number} error.line - 错误所在行
   * @param {number} error.column - 错误所在列
   * @param {string} error.filename - 文件名
   */
  addError({ message, line, column, filename }) {
    this.errors.push({
      message,  // 错误信息
      line,     // 行号
      column,   // 列号
      filename  // 文件名
    });
  }
}

module.exports = MiniESLint;

添加代码解析功能

主角登场!为我们的MiniEsLint类添加新装备:

  • @babel/parser(用于将代码转换为 AST)
  • @babel/traverse(用于遍历 AST)

添加 AST 解析功能:

// 1. 引入依赖
// @babel/parser 用于将代码转换为 AST
const parser = require('@babel/parser');
// @babel/traverse 用于遍历 AST
const traverse = require('@babel/traverse').default;

class MiniESLint {
  // 构造函数不变
  constructor(...}

  /**
   * 代码验证方法
   * @param {string} code - 要检查的代码
   * @param {string} filename - 文件名
   * @returns {Array} 错误信息数组
   */
  verify(code, filename = 'anonymous.js') {
    // 清空之前的错误
    this.errors = [];
    
    try {
      // 解析代码生成 AST
      const ast = parser.parse(code, {
        sourceType: 'module',  // 使用ES模式解析
        plugins: ['jsx'],  // 支持JSX语法
      });

      // 遍历 AST
      traverse(ast, {
        ...(暂时不添加新逻辑)
      });

      return this.errors;
    } catch (error) {
      console.error('代码解析错误:', error);
      return [{
        message: `代码解析错误: ${error.message}`,
        line: 0,
        column: 0,
        filename
      }];
    }
  }
}

关键部分说明:

  • parser.parse 将代码转换为 AST
  • traverse 用于遍历 AST 的每个节点且第二个参数是访问者对象,用于定义如何处理不同类型的节点

测试一下!

添加测试代码test.js:

const MiniESLint = require('./index.js');

// 创建实例
const linter = new MiniESLint();

// 测试语法错误的代码
const errorCode = `function test( {
  console.log('Missing parenthesis');
`;
const errors2 = linter.verify(errorCode, 'error.js');

image.png

可以发现,我们写的Eslint规则已经生效啦👏~

更进一步:实现no-console检查

在经过了上面的简易实战过后,我们继续优化一下我们当前的MiniESlint✨,来实现 console 的检查规则

但这次我们要来个新装备🔧 - @babel/types(用于 AST 节点类型判断)


当我们写 console.log('Hello') 时,它的 AST 结构是这样的:

{
  "type": "MemberExpression",
  "object": {
    "type": "Identifier",
    "name": "console"
  },
  "property": {
    "type": "Identifier",
    "name": "log"
  }
}

所以我们需要实现一个检查方法,大体如下:

checkConsole(path, filename) {
  const { node } = path;
  
  if (
    // 对象名是否为 "console"
    t.isIdentifier(node.object, { name: 'console' }) &&
    t.isIdentifier(node.property)
    // 是否访问了该对象的属性
  ) {
    this.addError({/*...*/});
  }
}
  • path 包含了当前节点及其上下文信息
  • node 是当前 AST 节点
  • t.isIdentifier() 用于检查节点是否为标识符

之前MiniEslint里的遍历方法大家还记得吗?要派上用场啦!

image.png

在遍历中使用检查方法:

traverse(ast, {
  MemberExpression: (path) => {
    if (this.rules['no-console']) {
      this.checkConsole(path, filename);
    }
  }
});
  • MemberExpression 匹配所有的成员访问表达式

  • 只有当 no-console 规则启用时才进行检查

测试一下:

const MiniESLint = require('./index.js');

const linter = new MiniESLint({
  'max-params': [true, 2]
});

const code = `
function test() {
  console.log('Debug info');
  console.error('Error info');
  console.warn('Warning info');
  return true;
}
`;

const errors = linter.verify(code, 'test.js');
console.log(errors);

image.png 成功实现!🎉

课后实践 & 总结

为了兼容低版本浏览器 我们也通常会使用 webpack 打包编译我们的代码将 ES6 语法降低版本,比如箭头函数变成普通函数。将 constlet 声明改成 var 等等,他都是通过 AST 来完成的,只不过实现的过程比较复杂,精致。不过也都是这三板斧:

  1. js 语法解析成 AST
  2. 修改 AST
  3. AST 转成 js 语法;

经过了上面你对于MiniEslint的实践,相信你对AST在代码解析和结构化的原理里有了一定的认识,如果你想要检验你的学习成果,不妨自己动手实现一个变量命名规范检查(例如让所有函数使用驼峰命名)的功能来加深理解!