3、核心原理(二):AST遍历与节点操作、使用@babel/traverse修改AST、生成格式化后的代码

上一章我们聊了AST长什么样,怎么把它解析出来。但光看不动手,那叫观光,不叫开发。这一章我们真正开始“动刀子”——遍历AST、找到目标节点、修改它,最后再变回代码。

说白了,格式化工具的核心就三步:解析 → 遍历修改 → 生成。第二步是最考验功力的地方。我刚开始写格式化插件时,就栽在“改完AST不知道怎么变回代码”这个坑里,后来才发现,原来@babel/generator早就帮我们准备好了。

核心流程回顾:

源代码 → 解析器 → AST → 遍历器(traverse) → 修改后的AST → 生成器 → 格式化代码

源代码 解析器 AST 遍历器 生成器 @babel/parser @babel/traverse @babel/generator

3.1 遍历AST:从根节点开始“走一遍”

AST是一棵树,遍历就是把这棵树从头到尾走一遍。@babel/traverse用的是深度优先遍历,说白了就是“先钻到最深的叶子节点,再往回走”。

我个人习惯把遍历器想象成一个“探照灯”,它照到哪个节点,我们就处理哪个节点。你只需要告诉它:“嘿,遇到VariableDeclaration的时候,帮我做点事。”

import traverse from '@babel/traverse';

traverse(ast, {
  // 进入节点时触发
  enter(path) {
    console.log('进入节点:', path.node.type);
  },
  // 离开节点时触发
  exit(path) {
    console.log('离开节点:', path.node.type);
  }
});

这里有个关键概念——path。它不是节点本身,而是节点在树中的“位置信息”。path包含了当前节点、父节点、兄弟节点,还有一堆操作方法。我在项目中遇到过有人直接操作path.node,结果改完发现父节点没更新,代码生成出来乱七八糟。记住:永远通过path来操作节点

小技巧:path对象里有个path.parent可以拿到父节点,path.getSibling()可以拿到兄弟节点。调试时我经常用path.toString()看看当前节点对应的代码片段,非常方便。

3.2 节点操作:增删改查四板斧

遍历只是手段,修改才是目的。@babel/traverse提供了四类操作:查、改、增、删。我们一个个来看。

3.2.1 查:找到你要的节点

最常用的就是按类型查找。比如我想找到所有变量声明:

traverse(ast, {
  VariableDeclaration(path) {
    console.log('找到变量声明:', path.node.kind, path.node.declarations.length);
  }
});

还可以按条件过滤。比如只找const声明:

traverse(ast, {
  VariableDeclaration(path) {
    if (path.node.kind === 'const') {
      // 只处理const声明
    }
  }
});

嗯,这里要注意:遍历器是按节点类型匹配的,不是按节点名称。你写VariableDeclaration(path),它就会在所有VariableDeclaration节点上触发。如果你只想匹配某个特定变量名,得在回调里自己判断。

3.2.2 改:修改节点属性

改节点是最常见的操作。比如把所有的var改成let

traverse(ast, {
  VariableDeclaration(path) {
    if (path.node.kind === 'var') {
      path.node.kind = 'let';
    }
  }
});

就这么简单?对,就这么简单。但坑在后面——改完节点后,子节点可能也需要同步更新。我曾经在改函数参数时,只改了参数名没改引用,结果生成出来的代码里,函数体还在用旧参数名,直接报错。

避坑指南:修改节点后,记得检查所有引用该节点的地方是否也需要同步更新。特别是变量名、函数名这种有引用关系的属性。

3.2.3 增:插入新节点

插入节点稍微复杂一点,因为你要决定插在哪里。@babel/traverse提供了insertBeforeinsertAfter

traverse(ast, {
  ReturnStatement(path) {
    // 在return语句前插入一行console.log
    path.insertBefore(
      types.expressionStatement(
        types.callExpression(
          types.memberExpression(
            types.identifier('console'),
            types.identifier('log')
          ),
          [types.stringLiteral('before return')]
        )
      )
    );
  }
});

看着有点复杂对吧?因为你要用@babel/types来构建AST节点。我刚开始也觉得麻烦,后来习惯了反而觉得这样更安全——直接操作AST节点,比拼接字符串靠谱一万倍

3.2.4 删:移除节点

删除节点最简单,直接调用path.remove()

traverse(ast, {
  DebuggerStatement(path) {
    path.remove(); // 移除所有debugger语句
  }
});

但要注意:删除节点后,遍历器不会自动跳过子节点。如果你在enter里删了父节点,子节点的enter回调还会触发,这时候path.parent可能已经是null了。我的建议是:删除操作尽量在exit阶段做,或者用path.skip()跳过子节点。

3.3 实战:写一个简单的格式化规则

光说不练假把式。我们来写一个真实场景:把所有的==改成===。这是ESLint的eqeqeq规则的核心逻辑。

import traverse from '@babel/traverse';
import * as types from '@babel/types';

function eqeqeqFixer(ast) {
  traverse(ast, {
    BinaryExpression(path) {
      // 只处理 == 和 !=
      if (path.node.operator === '==' || path.node.operator === '!=') {
        const newOperator = path.node.operator === '==' ? '===' : '!==';
        
        // 创建新的BinaryExpression节点
        const newNode = types.binaryExpression(
          newOperator,
          path.node.left,
          path.node.right
        );
        
        // 替换当前节点
        path.replaceWith(newNode);
      }
    }
  });
  
  return ast;
}

你看,核心逻辑就十几行。但实际项目中要考虑的更多:要不要忽略== null的情况?要不要考虑类型注解?这些细节才是格式化工具的难点。

关键点总结:

  • 遍历器用enterexit两个阶段,exit阶段做删除更安全
  • path是操作的核心,不要直接操作path.node以外的属性
  • 增删改查四类操作各有适用场景,replaceWith是最常用的替换方法
  • 构建新节点用@babel/types,不要手动拼接字符串

3.4 生成格式化后的代码

AST改完了,怎么变回代码?用@babel/generator

import generate from '@babel/generator';

const result = generate(ast, {
  retainLines: false,    // 不保留原始行号
  compact: false,        // 不压缩
  concise: false,        // 不精简
  quotes: 'single',      // 使用单引号
}, code);  // 第三个参数是原始代码,用于保留注释位置

console.log(result.code);

这里有个细节:第三个参数传原始代码,生成器会尽量保留注释的位置。如果不传,注释可能会跑到奇怪的地方去。我记得有一次忘了传这个参数,结果所有注释都堆到了文件开头,被同事吐槽了好久。

生成器还支持很多配置项,比如缩进风格、行尾分号、引号类型等。这些配置就是格式化工具的“可选项”,用户可以根据喜好调整。

个人经验:生成代码后,最好再用prettiereslint --fix过一遍。因为@babel/generator生成的代码虽然语法正确,但缩进和换行可能不够“漂亮”。格式化工具嘛,最终还是要给人看的。

好了,这一章的内容就到这里。AST遍历和节点操作是格式化工具的核心能力,掌握了这些,你已经能写出一个能用的格式化插件了。下一章我们会聊更高级的话题——如何处理注释和源码位置信息,这些才是让格式化工具“好用”的关键。


公众号:蓝海资料掘金营,微信deep3321