前端知库

AST 在前端工程里有什么用?Babel、ESLint 和代码转换是怎样用它的?

AST 是把代码解析成的一棵树,每个节点表示一个语法结构。Babel 按“解析 → 遍历改写 → 生成代码”转换语法,ESLint 遍历 AST 找出有问题的写法,Prettier 按 AST 重新打印代码,jscodeshift 这类 codemod 工具批量改写代码。文中手写一个遍历器,演示只读检查和改写节点。

参考答案

简答

代码 const total = price * count 对工具来说只是一串字符,没法直接判断“这里声明了一个变量,值是一个乘法”。所以工具先把代码解析成一棵树,根节点是变量声明,下面挂着变量名 total 和乘法表达式,乘法下面再挂着 price 和 count。这棵树就是 AST,抽象语法树。

有了树,工具就能按节点类型精确地找代码、改代码,而不用拿正则去匹配字符串。字符串匹配分不清 count 是变量、是属性名还是写在注释里的字,AST 上每个节点都有明确的类型。

前端很多工具都建立在 AST 上。Babel 先把代码解析成 AST,由插件遍历并改写节点,再生成新代码,把新语法转成旧语法就是这样做的。ESLint 遍历 AST,在特定节点上检查写法并报告问题。Prettier 把代码解析成 AST 后丢掉原来的格式,按自己的规则重新打印。jscodeshift 这类 codemod 工具用来批量升级 API。

要注意,不同工具的 AST 格式不完全一样。ESLint 用的是 ESTree 格式,Babel 的 AST 基于 ESTree 但有自己的差异,写插件时要以对应工具的文档为准。另外 AST 一般不保留空格和格式,所以改写后的代码格式可能变化。

AST 长什么样

const total = price * count; 解析成 ESTree 格式的 AST,大致是下面这样(省略了位置信息)。可以把代码贴到 AST Explorer(astexplorer.net)上,选不同的解析器看完整的树。

JavaScript
{
  type: 'VariableDeclaration',
  kind: 'const',
  declarations: [
    {
      type: 'VariableDeclarator',
      id: { type: 'Identifier', name: 'total' },
      init: {
        type: 'BinaryExpression',
        operator: '*',
        left: { type: 'Identifier', name: 'price' },
        right: { type: 'Identifier', name: 'count' },
      },
    },
  ],
}

每个节点都有一个 type,说明它是什么语法结构。Identifier 是标识符,也就是变量名;BinaryExpression 是二元运算,left 和 right 是运算的两边。

工具是怎样遍历和改写 AST 的

Babel 和 ESLint 都用“访问者”的方式处理 AST:你提供一个对象,键是节点类型,值是函数;工具从根节点开始深度优先地走遍整棵树,每遇到一种类型的节点,就调用对应的函数。下面手写一个简化版,先像 ESLint 那样只读,再像 Babel 那样改写,最后把树拼回代码。

JavaScript
// const total = price * count; 简化后的 AST(字段名参照 ESTree)
const ast = {
  type: 'VariableDeclaration',
  kind: 'const',
  declarations: [
    {
      type: 'VariableDeclarator',
      id: { type: 'Identifier', name: 'total' },
      init: {
        type: 'BinaryExpression',
        operator: '*',
        left: { type: 'Identifier', name: 'price' },
        right: { type: 'Identifier', name: 'count' },
      },
    },
  ],
};

// 深度优先遍历:遇到哪种类型的节点,就调用 visitor 上同名的函数
function traverse(node, visitor) {
  if (!node || typeof node.type !== 'string') return;
  visitor[node.type]?.(node);
  for (const value of Object.values(node)) {
    if (Array.isArray(value)) value.forEach((child) => traverse(child, visitor));
    else if (value && typeof value === 'object') traverse(value, visitor);
  }
}

// 把 AST 重新拼回代码
function generate(node) {
  switch (node.type) {
    case 'VariableDeclaration':
      return `${node.kind} ${node.declarations.map(generate).join(', ')};`;
    case 'VariableDeclarator':
      return `${generate(node.id)} = ${generate(node.init)}`;
    case 'BinaryExpression':
      return `${generate(node.left)} ${node.operator} ${generate(node.right)}`;
    case 'Identifier':
      return node.name;
  }
}

// 像 ESLint 规则一样只读不改:收集用到的变量名
const names = [];
traverse(ast, { Identifier: (node) => names.push(node.name) });
console.log(names.join(',')); // total,price,count

// 像 Babel 插件一样改写节点:把 const 换成 var,把 count 改名为 quantity
traverse(ast, {
  VariableDeclaration: (node) => { node.kind = 'var'; },
  Identifier: (node) => { if (node.name === 'count') node.name = 'quantity'; },
});
console.log(generate(ast)); // var total = price * quantity;

第一次遍历只读节点,按深度优先的顺序依次碰到 total、price、count。第二次遍历直接修改节点的属性,生成出来的代码就变了。

真实的工具比这复杂得多。Babel 传给访问函数的不是节点本身,而是 path,它记录了节点的父节点、所在位置,还提供替换、删除节点的方法;作用域分析也靠它,所以改名时能区分同名的不同变量。可是“解析成树 → 按类型访问节点 → 生成代码”这个流程是一样的。

Babel 怎样用 AST

Babel 的处理分三步:解析(parse)、转换(transform)、生成(generate)。@babel/parser 把源码解析成 AST,插件在转换阶段改写节点,@babel/generator 再把 AST 输出成代码和 Source Map。

一个 Babel 插件就是返回 visitor 对象的函数,结构和上面的例子很像:

JavaScript
// 把所有 == 换成 ===
module.exports = function () {
  return {
    visitor: {
      BinaryExpression(path) {
        if (path.node.operator === '==') path.node.operator = '===';
      },
    },
  };
};

把箭头函数转成普通函数、把可选链展开成条件判断,原理都是找到对应节点,换成旧语法的节点。语法转换和 polyfill 的区别见《Babel 是怎样把新语法转成旧语法的?语法转换和 polyfill 有什么区别?》。

ESLint 怎样用 AST

ESLint 默认用 Espree 解析代码,得到 ESTree 格式的 AST。一条规则是一个对象,meta 描述规则信息,create(context) 返回访问者对象,在节点上发现问题时调用 context.report()。

JavaScript
// 禁止使用 alert()
module.exports = {
  meta: {
    type: 'suggestion',
    messages: { noAlert: '不要使用 alert,请改用页面内提示' },
  },
  create(context) {
    return {
      CallExpression(node) {
        if (node.callee.type === 'Identifier' && node.callee.name === 'alert') {
          context.report({ node, messageId: 'noAlert' });
        }
      },
    };
  },
};

为什么不用正则搜 alert(?因为正则会把字符串 'alert(' 和注释里的字也当成调用,而 obj.alert() 的 callee 是 MemberExpression,按节点类型判断就不会误报。

和 Babel 不同,ESLint 规则一般只读不改。规则提供了 fixer 时,自动修复是按位置替换源码文本,而不是改 AST。

其他用到 AST 的地方

Prettier 把代码解析成 AST 后丢掉原有的格式,再按行宽等规则重新打印,所以它只改格式,不改代码的意思。

codemod 是批量改代码的脚本。比如一个库把 oldApi() 改名为 newApi(),项目里有几百处调用,可以用 jscodeshift 写一个转换脚本,按 AST 找到调用并替换,比手工改或全局替换文本更可靠。

此外,打包工具分析 import 和 export 做依赖图与 tree shaking、Vue 把模板编译成渲染函数、TypeScript 去掉类型注解,背后也都要先把代码解析成语法树。

常见追问

为什么 ESLint 和 Babel 不能共用同一份 AST?

两者的 AST 格式有差异。ESLint 规则按 ESTree 格式写,Babel 的 AST 在一些节点上和 ESTree 不同,比如字面量节点的划分。所以要让 ESLint 读懂 Babel 才支持的语法,要用 @babel/eslint-parser,由它把 Babel 的 AST 转成 ESLint 能用的格式。

改写 AST 以后,代码格式会不会乱?

可能会。AST 通常不保存空格、换行这类信息,生成代码时由生成器决定格式。Babel 的产物一般给机器运行,格式不重要;codemod 修改的是要提交的源码,jscodeshift 底层用的 recast 会尽量保留没改动部分的原始格式,改完后通常再跑一次 Prettier。