一文解决Babel 插件

420 阅读20分钟

Babel 是一个强大的 JavaScript 编译器,它允许开发者在项目中使用最新的 JavaScript 特性,并将其转换为向后兼容的版本,以便在当前和旧版浏览器或环境中运行。Babel 的核心功能之一就是其插件化的架构,开发者可以编写自定义插件来扩展 Babel 的功能,实现各种代码转换需求。

Babel 的工作流程

Babel 的转换过程主要分为三个阶段:

  1. 解析 (Parse) :

    • 输入: 源代码字符串。
    • 过程: Babel 使用解析器(默认为 @babel/parser,它是 babylon 的一个分支,而 babylon 又基于 acorn)将源代码字符串转换成一种叫做抽象语法树(Abstract Syntax Tree, AST)的中间表示形式。AST 是一种树形结构,它以结构化的方式表示代码的语法。树上的每个节点都代表代码中的一个构造,例如变量声明、函数调用、表达式等。
    • 输出: AST 对象。
  2. 转换 (Transform) :

    • 输入: 上一阶段生成的 AST。
    • 过程: 这是 Babel 插件工作的核心阶段。Babel 会遍历 AST,并在遍历过程中调用注册的插件。插件可以检查、修改、添加或删除 AST 节点,从而改变代码的结构和行为。多个插件会按照它们在 Babel 配置中声明的顺序依次执行。
    • 输出: 经过插件处理后的新的 AST。
  3. 生成 (Generate) :

    • 输入: 转换后的 AST。
    • 过程: Babel 使用代码生成器(默认为 @babel/generator)将修改后的 AST 转换回 JavaScript 代码字符串。在这个过程中,还可以生成 Source Map,用于在调试时将转换后的代码映射回原始代码。
    • 输出: 转换后的 JavaScript 代码字符串和可选的 Source Map。

你可以使用 AST Explorer 这个在线工具来查看不同代码片段对应的 AST 结构,这对于理解和编写 Babel 插件非常有帮助。

Babel 插件拿到的是什么,输出的是什么?

  • 拿到的是 (输入) :
    Babel 插件主要通过访问者模式 (Visitor Pattern) 来操作 AST。当 Babel 遍历 AST 时,如果遇到特定类型的节点,并且有插件注册了对该类型节点的访问者函数,那么这个函数就会被调用。
    访问者函数会接收两个主要的参数:

    1. path (类型为 NodePath): 这是一个非常重要的对象,它代表了 AST 中两个节点之间的链接。path 对象包含了当前节点 (path.node) 的信息,以及该节点在 AST 中的位置、作用域、父节点等上下文信息。更重要的是,path 对象提供了大量用于操作 AST 的方法,例如替换节点 (replaceWith)、删除节点 (remove)、在节点前后插入新节点 (insertBefore, insertAfter) 等。
    2. state: 这是一个可选的状态对象,它可以在遍历过程中传递数据。通常,插件的选项(通过 Babel 配置传入)会挂载到 state.opts 上。state 还可以用来在插件的不同访问者函数之间共享信息,或者在插件的 prepost 函数中使用。
  • 输出的是 (效果) :
    Babel 插件本身通常不直接“输出”一个值(除非是插件工厂函数返回插件对象)。它们通过修改传入的 path 对象所代表的 AST 结构来产生效果。这些修改会直接作用于 Babel 正在处理的 AST。当所有插件都执行完毕后,最终修改过的 AST 会被传递给代码生成阶段。

    所以,插件的“输出”是对 AST 的副作用修改

Babel 插件的核心 API 和概念

  1. 插件结构:
    一个 Babel 插件本质上是一个 JavaScript 函数,这个函数接收 Babel 的核心对象(通常命名为 babelapi)作为参数,并返回一个包含 visitor 对象的对象。

    export default function (babel) {
      // 可以通过 babel.types 或 api.types 访问 @babel/types
      const { types: t } = babel;
    
      return {
        // 可选的插件名称,用于调试和错误信息
        name: "my-custom-plugin",
    
        // 可选的 pre(state) 函数,在遍历 AST 之前执行
        pre(file) {
          // console.log("Plugin pre-execution for file:", file.opts.filename);
          // 可以在这里初始化一些插件级别的状态
          this.somePluginState = new Map();
        },
    
        // visitor 对象是插件的核心
        visitor: {
          // 键是 AST 节点的类型 (例如 'Identifier', 'BinaryExpression', 'FunctionDeclaration')
          // 值是处理该类型节点的函数或对象
          Identifier(path, state) {
            // 当 Babel 遍历到 Identifier 节点时,此函数会被调用
            // path: NodePath 对象,代表当前 Identifier 节点及其上下文
            // state: 插件的状态对象,包含插件选项 (state.opts)
            // console.log("Visiting Identifier:", path.node.name);
          },
    
          // 对于某些节点类型,可以提供 enter 和 exit 方法
          FunctionDeclaration: {
            enter(path, state) {
              // 在进入 FunctionDeclaration 节点时调用
              // console.log("Entering FunctionDeclaration:", path.node.id.name);
            },
            exit(path, state) {
              // 在退出 FunctionDeclaration 节点时调用 (所有子节点都已访问完毕)
              // console.log("Exiting FunctionDeclaration:", path.node.id.name);
            }
          },
    
          // 还可以使用 | 分隔多个节点类型,用同一个函数处理
          "BinaryExpression|LogicalExpression"(path, state) {
            // 处理二元表达式或逻辑表达式
          }
        },
    
        // 可选的 post(state) 函数,在遍历 AST 之后执行
        post(file) {
          // console.log("Plugin post-execution for file:", file.opts.filename);
          // 可以在这里进行一些清理工作
          // console.log("Plugin state:", this.somePluginState);
          // this.somePluginState.clear();
        },
    
        // 可选:如果插件需要处理继承的 visitor (不常用)
        // inherits: require("@babel/plugin-syntax-jsx"), // 例如
      };
    }
    
  2. visitor 对象:
    这是插件的核心。它的键是 AST 节点的类型名称(符合 ESTree 规范,Babel 在此基础上有所扩展,例如 JSX 相关的节点类型)。当 Babel 的 traverse 模块遍历 AST 时,遇到匹配类型的节点,就会调用相应的访问者函数。

  3. path (NodePath) 对象:
    NodePath 是理解 Babel 插件的关键。它不仅仅是对 AST 节点的简单包装,更提供了节点间的关系以及操作 AST 的丰富 API。

    • path.node: 当前访问的 AST 节点。

    • path.parent: 父 AST 节点。

    • path.parentPath: 父节点的 NodePath 对象。

    • path.scope: 当前节点所处的作用域信息,可以用来查找变量绑定、检查变量是否被引用等。

    • path.type: 当前节点的类型 (字符串,如 "Identifier")。

    • path.key: 当前节点在其父节点属性中的键名。

    • path.listKey: 如果当前节点是父节点某个数组属性的一员,此为该数组属性的键名。

    • path.inList: 布尔值,表示当前节点是否是列表的一部分。

    • path.get(key): 获取子路径。

    • path.isNodeType(type): 检查节点类型,如 path.isIdentifier()

    • path.findParent(callback): 向上查找符合条件的父路径。

    • 常用操作方法:

      • path.replaceWith(newNode): 用一个新节点替换当前节点。
      • path.replaceWithMultiple(nodesArray): 用多个新节点替换当前节点。
      • path.insertBefore(nodes): 在当前节点前插入一个或多个节点。
      • path.insertAfter(nodes): 在当前节点后插入一个或多个节点。
      • path.remove(): 删除当前节点。
      • path.skip(): 跳过当前节点的子节点的遍历。
      • path.stop(): 停止整个 AST 遍历。
  4. state 对象:

    • state.opts: 访问插件配置中传递的选项。例如,如果在 .babelrc.js 中配置插件:

      // .babelrc.js
      module.exports = {
        plugins: [
          ["./path/to/my-plugin.js", { option1: true, option2: "value" }]
        ]
      };
      

      在插件中可以通过 state.opts.option1state.opts.option2 来访问这些值。

    • state.file: 表示当前正在处理的文件,包含文件名等信息。

    • state.cwd: 当前工作目录。

    • 插件可以在 pre 函数中向 this (插件实例) 添加属性,这些属性在 visitorpost 函数中可以通过 this 访问,也可以通过 state 传递(但不推荐直接修改 state 对象本身,而是使用 this 来存储插件实例的状态)。

  5. @babel/types (通常别名为 ttypes) :
    这个模块提供了大量的工具函数,用于:

    • 创建 AST 节点: 例如 t.identifier("myVar") 创建一个标识符节点,t.binaryExpression("+", leftNode, rightNode) 创建一个二元表达式节点。
    • 检查节点类型: 例如 t.isIdentifier(node)t.isStringLiteral(node, { value: "hello" })
    • 断言节点类型: 例如 t.assertIdentifier(node),如果节点不是指定类型则抛出错误。
    • 其他辅助函数。

    在插件开头通常这样引入:

    export default function ({ types: t }) {
      // 现在可以使用 t.identifier, t.isBinaryExpression 等
      return {
        visitor: { /* ... */ }
      };
    }
    
  6. @babel/template:
    当需要创建复杂的 AST 节点结构时,手动使用 @babel/types 逐个创建节点会非常繁琐且容易出错。@babel/template 允许你用类似代码模板的字符串来生成 AST。

    • template(codeString, options): 返回一个函数,调用此函数并传入占位符的实际 AST 节点,即可生成对应的 AST 结构。
    import template from "@babel/template";
    import { types as t } from "@babel/core"; // 或者从插件参数中获取 types
    
    const buildTryCatch = template(`
      try {
        %%BODY%%
      } catch (%%ERROR_PARAM%%) {
        %%CATCH_HANDLER%%
      }
    `);
    
    // 在 visitor 中使用
    // const tryCatchAst = buildTryCatch({
    //   BODY: path.node.body, // 一个 BlockStatement
    //   ERROR_PARAM: t.identifier("e"),
    //   CATCH_HANDLER: t.blockStatement([
    //     t.expressionStatement(
    //       t.callExpression(
    //         t.memberExpression(t.identifier("console"), t.identifier("error")),
    //         [t.identifier("e")]
    //       )
    //     )
    //   ])
    // });
    // path.replaceWith(tryCatchAst);
    

    占位符可以是 %%NAME%% (大写,用于替换为 AST 节点) 或 $$NAME$$ (用于替换为字符串,生成标识符)。

详细代码讲解

下面我们将通过几个示例插件来详细讲解如何编写 Babel 插件。

准备工作

为了运行和测试这些插件,你需要安装 @babel/core。如果你想从命令行运行,还需要 @babel/cli

npm install --save-dev @babel/core
# 或者
yarn add --dev @babel/core

你可以创建一个 transform.js 文件来测试插件:

// transform.js
const babel = require('@babel/core');

function transformCode(code, plugins, presets = []) {
  const result = babel.transformSync(code, {
    plugins: plugins,
    presets: presets,
    configFile: false, // 忽略项目中的 babel 配置文件,以便独立测试
    babelrc: false,    // 同上
  });
  return result.code;
}

module.exports = { transformCode };

示例插件 1: 将 var 声明替换为 let

这个插件会遍历代码,找到所有的 var 变量声明,并将它们改为 let

// plugins/var-to-let-plugin.js

/**
 * Babel 插件:将 var 声明转换为 let 声明。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块,用于 AST节点的 TypeScript 定义和构建函数。
 * @returns {Object} Babel 插件对象,包含 visitor。
 */
export default function ({ types: t }) {
  // 打印插件加载信息,有助于调试
  // console.log("var-to-let-plugin loaded");

  return {
    name: "var-to-let", // 插件名称,可选,但推荐

    visitor: {
      /**
       * 访问 VariableDeclaration (变量声明) 节点。
       * 例如:var a = 1; const b = 2; let c = 3;
       *
       * @param {NodePath} path - 当前 VariableDeclaration 节点的路径对象。
       * @param {Object} state - 插件状态对象,包含插件选项 (state.opts) 等。
       */
      VariableDeclaration(path, state) {
        // path.node 是当前的 AST 节点
        // console.log("Visiting VariableDeclaration:", path.node.kind);

        // 检查当前变量声明的类型是否为 'var'
        if (path.node.kind === "var") {
          // console.log(`Found 'var' declaration at line ${path.node.loc.start.line}, column ${path.node.loc.start.column}`);
          // console.log("Original AST node:", JSON.stringify(path.node, null, 2));

          // 直接修改节点的 kind 属性
          // 这是一个简单的修改,对于更复杂的转换,可能需要创建新节点并替换
          path.node.kind = "let";

          // console.log(`Changed 'var' to 'let'`);
          // console.log("Modified AST node:", JSON.stringify(path.node, null, 2));

          // 注意:这种直接修改是有效的,因为 Babel 的遍历器允许在遍历过程中修改 AST。
          // 修改后,后续的插件或代码生成阶段将使用这个修改后的节点。

          // 如果需要更复杂的操作,例如基于某些条件决定是否转换,
          // 或者转换成完全不同的结构,就需要使用 path.replaceWith() 等方法。

          // 示例:如果插件有选项控制是否转换
          // if (state.opts && state.opts.enableVarToLet === false) {
          //   console.log("var-to-let conversion is disabled by plugin options.");
          //   path.node.kind = "var"; // 恢复,或者一开始就不修改
          //   return; // 提前退出当前节点的处理
          // }
        }

        // 演示如何访问插件选项 (如果配置了)
        // if (state.opts.myCustomOption) {
        //   console.log("Plugin option myCustomOption:", state.opts.myCustomOption);
        // }
      },

      // 你可以添加其他访问者来处理不同类型的节点
      // 例如,记录所有函数名称
      // FunctionDeclaration(path) {
      //   if (path.node.id) {
      //     console.log("Found function:", path.node.id.name);
      //   }
      // }
    },

    // pre 和 post 函数示例
    pre(file) {
      // console.log(`[var-to-let-plugin] Starting transformation for: ${file.opts.filename || 'unknown file'}`);
      // this.declarationsChanged = 0; // 初始化插件实例的状态
    },

    post(file) {
      // console.log(`[var-to-let-plugin] Finished transformation for: ${file.opts.filename || 'unknown file'}`);
      // if (this.declarationsChanged > 0) {
      //   console.log(`[var-to-let-plugin] Total var declarations changed to let: ${this.declarationsChanged}`);
      // }
      // delete this.declarationsChanged; // 清理状态
    }
  };
}

// 为了在 Node.js 环境中直接 require 这个文件 (如果使用 ES Modules 语法 export default)
// 如果你的测试环境或 Babel 配置期望 CommonJS 模块,可以这样做:
// module.exports = function(babel) { /* ... plugin code ... */ };
// 或者在 package.json 中设置 "type": "module"

测试 var-to-let-plugin.js:

// test-var-to-let.js
const { transformCode } = require('./transform'); // 假设 transform.js 在同级目录
const varToLetPlugin = require('./plugins/var-to-let-plugin').default; // 注意 .default

const code = `
  var x = 10;
  function foo() {
    var y = 20;
    if (true) {
      var z = 30; // var 会被提升
    }
    console.log(y, z);
  }
  var a = 1, b = 2;
  const c = 3; // const 不应被修改
  let d = 4;   // let 不应被修改
`;

console.log("Original Code:\n", code);

const transformedCode = transformCode(code, [
  [varToLetPlugin, { /* 插件选项,这里为空 */ }]
]);

console.log("\nTransformed Code:\n", transformedCode);

/*
预期输出:
Original Code:
... (原始代码) ...

Transformed Code:
let x = 10;
function foo() {
  let y = 20;
  if (true) {
    let z = 30; // var 会被提升
  }
  console.log(y, z);
}
let a = 1, b = 2;
const c = 3; // const 不应被修改
let d = 4;   // let 不应被修改
*/

运行 node test-var-to-let.js 查看结果。

示例插件 2: 移除所有的 console.log 语句

这个插件会查找所有 console.log(...) 这样的调用表达式,并将它们从代码中移除。

// plugins/remove-console-log-plugin.js

/**
 * Babel 插件:移除所有的 console.log 调用。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块。
 * @returns {Object} Babel 插件对象。
 */
export default function ({ types: t }) {
  // console.log("remove-console-log-plugin loaded");

  return {
    name: "remove-console-log",

    visitor: {
      /**
       * 访问 CallExpression (函数调用表达式) 节点。
       * 例如:func(a, b), obj.method(), console.log("hello")
       *
       * @param {NodePath} path - 当前 CallExpression 节点的路径对象。
       * @param {Object} state - 插件状态对象。
       */
      CallExpression(path, state) {
        const node = path.node;

        // 检查调用者 (callee) 是否是 console.log
        // console.log 的 AST 结构通常是:
        // {
        //   type: "CallExpression",
        //   callee: {
        //     type: "MemberExpression",
        //     object: { type: "Identifier", name: "console" },
        //     property: { type: "Identifier", name: "log" },
        //     computed: false
        //   },
        //   arguments: [ ... ]
        // }

        const callee = node.callee;

        // 方式一:直接检查属性 (不够健壮,如果 console 被重命名或赋值给其他变量则无效)
        // if (
        //   t.isMemberExpression(callee) &&
        //   t.isIdentifier(callee.object, { name: "console" }) &&
        //   t.isIdentifier(callee.property, { name: "log" })
        // ) {
        //   console.log(`Found console.log call at line ${node.loc.start.line}. Removing it.`);
        //   path.remove(); // 从 AST 中移除当前 CallExpression 节点
        // }

        // 方式二:使用 path.matchesPattern (更简洁,但要注意模式的精确性)
        // `path.matchesPattern("console.log")` 可以匹配 console.log()
        // `path.matchesPattern("console.error")` 可以匹配 console.error()
        if (path.get("callee").matchesPattern("console.log")) {
          // console.log(`Found console.log call via matchesPattern at line ${node.loc.start.line}. Removing it.`);
          // console.log("Original AST node for CallExpression:", JSON.stringify(node, null, 2));

          // 检查插件选项,例如,只有在特定模式下才移除
          const removeIfOnlySpecificArgument = state.opts.removeIfArgumentIs;
          if (removeIfOnlySpecificArgument) {
            if (node.arguments.length === 1 && t.isStringLiteral(node.arguments[0], { value: removeIfOnlySpecificArgument })) {
              // console.log(`Removing console.log with specific argument: "${removeIfOnlySpecificArgument}"`);
              path.remove();
            } else {
              // console.log(`Skipping console.log removal, argument does not match "${removeIfOnlySpecificArgument}".`);
            }
          } else {
            // 默认移除所有 console.log
            path.remove();
          }
        }

        // 也可以移除其他 console 方法,例如 console.error, console.warn
        // else if (path.get("callee").matchesPattern("console.error")) {
        //   console.log(`Found console.error call at line ${node.loc.start.line}. Removing it.`);
        //   path.remove();
        // }

        // 进阶:处理 console 被解构或重命名的情况
        // const calleePath = path.get("callee");
        // if (calleePath.isMemberExpression()) {
        //   const objectPath = calleePath.get("object");
        //   const propertyName = calleePath.node.property.name;
        //
        //   if (propertyName === 'log' || propertyName === 'warn' || propertyName === 'error') {
        //     const binding = objectPath.scope.getBinding(objectPath.node.name);
        //     // 检查 objectPath.node.name (例如 'console') 是否确实指向全局的 console 对象
        //     // 这比较复杂,因为 console 可能被局部变量覆盖
        //     // 一个简单(但不完全可靠)的检查是看它是否是全局绑定
        //     if (objectPath.isIdentifier({ name: "console" }) && (!binding || binding.path.scope.isGlobal)) {
        //       console.log(`Found console.${propertyName} call. Removing it.`);
        //       path.remove();
        //     }
        //   }
        // }
      },
    },
  };
}

测试 remove-console-log-plugin.js:

// test-remove-console-log.js
const { transformCode } = require('./transform');
const removeConsoleLogPlugin = require('./plugins/remove-console-log-plugin').default;

const code = `
  function greet(name) {
    console.log("Entering greet function"); // 将被移除
    const message = "Hello, " + name;
    console.log("Message:", message);       // 将被移除
    if (name === "error") {
      console.error("Error case detected!"); // 如果插件也处理 error,则移除
    }
    return message;
  }

  console.log("Script started"); // 将被移除
  greet("World");
  console.log("Script finished"); // 将被移除

  const myLogger = console;
  myLogger.log("Logged with myLogger"); // 这个默认不会被简单模式移除

  console.log("debug"); // 用于测试选项
`;

console.log("Original Code:\n", code);

// 测试1: 移除所有 console.log
const transformedCode1 = transformCode(code, [
  [removeConsoleLogPlugin]
]);
console.log("\nTransformed Code (all console.log removed):\n", transformedCode1);

// 测试2: 只移除 console.log("debug")
const transformedCode2 = transformCode(code, [
  [removeConsoleLogPlugin, { removeIfArgumentIs: "debug" }]
]);
console.log("\nTransformed Code (only console.log('debug') removed):\n", transformedCode2);


/*
预期输出 (transformedCode1):
function greet(name) {
  const message = "Hello, " + name;
  if (name === "error") {
    console.error("Error case detected!"); // 假设插件只移除 console.log
  }
  return message;
}
greet("World");

const myLogger = console;
myLogger.log("Logged with myLogger");

预期输出 (transformedCode2):
function greet(name) {
  console.log("Entering greet function");
  const message = "Hello, " + name;
  console.log("Message:", message);
  if (name === "error") {
    console.error("Error case detected!");
  }
  return message;
}
console.log("Script started");
greet("World");
console.log("Script finished");

const myLogger = console;
myLogger.log("Logged with myLogger");
*/

示例插件 3: 自动为函数体包裹 try...catch

这个插件会找到所有的函数声明和函数表达式,并将它们的主体代码块包裹在一个 try...catch 语句中,用于统一的错误处理。这是一个更复杂的例子,涉及到创建新的 AST 节点。

// plugins/auto-try-catch-plugin.js
import template from "@babel/template"; // 需要 npm install --save-dev @babel/template

/**
 * Babel 插件:自动为函数体包裹 try...catch 语句。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块。
 * @returns {Object} Babel 插件对象。
 */
export default function ({ types: t }) {
  // console.log("auto-try-catch-plugin loaded");

  // 使用 @babel/template 来构建 try...catch 结构
  // %%BODY%% 是一个占位符,将被实际的函数体替换
  // %%ERROR_IDENTIFIER%% 是错误对象的标识符
  // %%CATCH_HANDLER_BODY%% 是 catch 块内部的语句
  const buildTryCatch = template.statements(`
    try {
      %%BODY%%
    } catch (%%ERROR_IDENTIFIER%%) {
      %%CATCH_HANDLER_BODY%%
    }
  `);

  // 默认的 catch 处理逻辑:console.error(e)
  const defaultCatchHandler = template.statement(`
    console.error(%%ERROR_IDENTIFIER%%);
  `);

  // 如果希望 catch 块更复杂,可以定义更复杂的模板或手动构建
  // const complexCatchHandler = template.statements(`
  //   console.error("An error occurred in function:", %%FUNCTION_NAME%%);
  //   console.error(%%ERROR_IDENTIFIER%%.message);
  //   reportErrorToServer(%%ERROR_IDENTIFIER%%);
  // `);


  return {
    name: "auto-try-catch",

    visitor: {
      /**
       * 访问函数声明、函数表达式和箭头函数表达式。
       * 'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'
       * 也可以分开写:
       * FunctionDeclaration(path, state) { /* ... */ },
       * FunctionExpression(path, state) { /* ... */ },
       * ArrowFunctionExpression(path, state) { /* ... */ },
       */
      'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression': function (path, state) {
        const node = path.node;

        // 0. 检查插件选项,例如是否启用,或者特定的函数名才处理
        if (state.opts.disable === true) {
          // console.log("Auto try-catch is disabled by plugin options.");
          return;
        }

        const onlyWrapNamedFunctions = state.opts.onlyWrapFunctionsNamed;
        if (onlyWrapNamedFunctions && Array.isArray(onlyWrapNamedFunctions)) {
          let functionName = "";
          if (t.isFunctionDeclaration(node) && node.id) {
            functionName = node.id.name;
          } else if (t.isFunctionExpression(node) && node.id) {
            functionName = node.id.name;
          } else if (t.isArrowFunctionExpression(node) && path.parentPath.isVariableDeclarator() && t.isIdentifier(path.parentPath.node.id)) {
            // const myFunction = () => { ... }
            functionName = path.parentPath.node.id.name;
          }
          // console.log("Checking function name:", functionName);
          if (!onlyWrapNamedFunctions.includes(functionName)) {
            // console.log(`Skipping function ${functionName || '(anonymous)'} as it's not in the whitelist.`);
            return;
          }
        }


        // 1. 获取函数体 (BlockStatement)
        let body = node.body;

        // 2. 如果是箭头函数且函数体不是块语句 (例如: const add = (a, b) => a + b;),
        //    需要先将其转换为块语句。
        if (!t.isBlockStatement(body)) {
          // 例如: () => expr  变成  () => { return expr; }
          // console.log("Arrow function with expression body found. Converting to block statement.");
          const expression = body;
          body = t.blockStatement([t.returnStatement(expression)]);
          // 更新节点,确保后续操作基于新的块语句体
          node.body = body;
        }

        // 3. 检查函数体是否为空或者已经是一个 try...catch 包裹的结构 (避免重复包裹)
        if (body.body && body.body.length === 0) {
          // console.log("Function body is empty. Skipping try-catch wrapping.");
          return; // 空函数体,无需包裹
        }
        if (body.body && body.body.length === 1 && t.isTryStatement(body.body[0])) {
          // console.log("Function body is already a try statement. Skipping.");
          return; // 已经有 try 语句了,不再包裹
        }

        // console.log(`Wrapping function ${node.id ? node.id.name : (path.parentPath.isVariableDeclarator() && path.parentPath.node.id ? path.parentPath.node.id.name : '(anonymous)')} with try...catch.`);
        // console.log("Original function body AST:", JSON.stringify(body, null, 2));

        // 4. 定义错误标识符和 catch 处理逻辑
        const errorIdentifier = path.scope.generateUidIdentifier("e"); // 生成一个唯一ID,避免冲突

        let catchHandlerBody;
        if (state.opts.customCatchHandler && typeof state.opts.customCatchHandler === 'string') {
          // 允许用户通过选项传入自定义的 catch 处理代码字符串
          // 注意:这种方式直接执行用户传入的字符串作为代码模板,需要谨慎处理安全问题。
          // 更安全的方式是让用户传入一个函数,该函数返回 AST 节点数组。
          try {
            const customHandlerTemplate = template.statements(state.opts.customCatchHandler);
            catchHandlerBody = customHandlerTemplate({
              ERROR_IDENTIFIER: errorIdentifier,
              // 可以传递更多上下文给模板,例如函数名
              // FUNCTION_NAME: t.stringLiteral(node.id ? node.id.name : 'anonymous')
            });
          } catch (err) {
            console.warn(`[auto-try-catch-plugin] Failed to parse customCatchHandler: ${err.message}. Falling back to default.`);
            catchHandlerBody = defaultCatchHandler({ ERROR_IDENTIFIER: errorIdentifier });
          }
        } else {
          catchHandlerBody = defaultCatchHandler({ ERROR_IDENTIFIER: errorIdentifier });
        }


        // 5. 使用 template 构建新的 try...catch 结构
        // 注意:template.statements 返回的是一个 AST 节点数组
        // 而函数体 node.body 需要的是一个 BlockStatement
        // 所以我们将 template 返回的数组作为新 BlockStatement 的 body
        const tryCatchAstNodes = buildTryCatch({
          BODY: body.body, // 直接传递原函数体的语句数组
          ERROR_IDENTIFIER: errorIdentifier,
          CATCH_HANDLER_BODY: catchHandlerBody,
        });

        // 6. 将原函数体替换为新的包含 try...catch 的块语句
        node.body = t.blockStatement(tryCatchAstNodes);

        // console.log("New function body AST with try-catch:", JSON.stringify(node.body, null, 2));

        // 如果函数是异步函数 (async function)
        // 它的返回值会被隐式包裹在 Promise.resolve() 中。
        // 如果 try 块中的代码抛出错误,且没有 return 语句,
        // catch 块执行后,函数会隐式返回 undefined (被 Promise.resolve(undefined) 包裹)。
        // 如果 catch 块中 rethrow 了错误,或者抛出了新错误,则 Promise 会 reject。
        // 这里的简单包裹对于异步函数的错误捕获是有效的。
        // 如果需要更复杂的异步错误处理(例如,确保 Promise总是 resolve 或 reject 特定值),
        // catch 块的逻辑会更复杂。

        // 标记路径已更改,有助于 Babel 进行优化 (某些情况下)
        // path.stop(); // 如果你确定这个节点处理完后不需要再访问其子节点或同级其他访问器
      }
    }
  };
}

测试 auto-try-catch-plugin.js:

// test-auto-try-catch.js
const { transformCode } = require('./transform');
const autoTryCatchPlugin = require('./plugins/auto-try-catch-plugin').default;

const code = `
  function syncFunction(a, b) {
    console.log("syncFunction called");
    if (a < 0) {
      throw new Error("Negative input for 'a'");
    }
    return a + b;
  }

  const arrowFunc = (x) => {
    if (x === 0) throw new Error("Zero division");
    return 100 / x;
  };

  const arrowExprFunc = (y) => y * y;

  async function asyncFunction(name) {
    console.log("asyncFunction called");
    if (!name) {
      throw new Error("Name is required");
    }
    await new Promise(resolve => setTimeout(resolve, 10));
    return `Hello, ${name}`;
  }

  // 已经有 try-catch 的函数,不应该被再次包裹
  function alreadyWrapped() {
    try {
      console.log("Already wrapped");
    } catch (e) {
      // ignore
    }
  }

  // 空函数
  function emptyFunction() {}

  // 测试插件选项
  function processData(data) {
    if (!data) throw new Error("No data");
    return data.toUpperCase();
  }
  function ignoreThis(data) {
    return data;
  }
`;

console.log("Original Code:\n", code);

// 测试1: 默认行为
const transformedCode1 = transformCode(code, [
  [autoTryCatchPlugin]
]);
console.log("\nTransformed Code (default catch handler):\n", transformedCode1);

// 测试2: 使用自定义 catch handler (字符串模板)
const customHandler = `
  console.warn("Custom error handler caught:", %%ERROR_IDENTIFIER%%.message);
  Sentry.captureException(%%ERROR_IDENTIFIER%%);
`;
const transformedCode2 = transformCode(code, [
  [autoTryCatchPlugin, { customCatchHandler: customHandler }]
]);
console.log("\nTransformed Code (custom catch handler):\n", transformedCode2);

// 测试3: 只包裹特定名称的函数
const transformedCode3 = transformCode(code, [
  [autoTryCatchPlugin, { onlyWrapFunctionsNamed: ["processData", "syncFunction"] }]
]);
console.log("\nTransformed Code (only 'processData' and 'syncFunction' wrapped):\n", transformedCode3);


/*
预期输出 (transformedCode1 会比较长,这里只展示 syncFunction 的大致结构):
function syncFunction(a, b) {
  try {
    console.log("syncFunction called");
    if (a < 0) {
      throw new Error("Negative input for 'a'");
    }
    return a + b;
  } catch (_e) { // _e 是生成的唯一ID
    console.error(_e);
  }
}

const arrowFunc = x => {
  try {
    if (x === 0) throw new Error("Zero division");
    return 100 / x;
  } catch (_e2) {
    console.error(_e2);
  }
};

const arrowExprFunc = y => {
  try {
    return y * y;
  } catch (_e3) {
    console.error(_e3);
  }
};

async function asyncFunction(name) {
  try {
    console.log("asyncFunction called");
    if (!name) {
      throw new Error("Name is required");
    }
    await new Promise(resolve => setTimeout(resolve, 10));
    return `Hello, ${name}`;
  } catch (_e4) {
    console.error(_e4);
  }
}

function alreadyWrapped() { // 不变
  try {
    console.log("Already wrapped");
  } catch (e) {
    // ignore
  }
}

function emptyFunction() {} // 不变

// processData 会被包裹, ignoreThis 不会 (在 transformedCode3 中)
*/

这个 auto-try-catch-plugin 示例展示了:

  • 使用 @babel/template 创建复杂的 AST 结构。
  • 处理不同类型的函数(声明、表达式、箭头函数)。
  • 转换箭头函数的表达式体为块语句体。
  • 使用 path.scope.generateUidIdentifier 生成唯一的变量名以避免冲突。
  • 通过插件选项 state.opts 自定义插件行为。
  • 避免重复处理(例如已经有 try...catch 的函数)。

示例插件 4: 简单的国际化 (i18n) 文本替换

这个插件演示如何读取插件选项(一个字典),并替换代码中特定的函数调用(例如 __('greeting'))为其在字典中对应的值。

// plugins/i18n-plugin.js

/**
 * Babel 插件:简单的国际化文本替换。
 * 替换形如 __('key') 或 i18n('key', 'default value') 的调用。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块。
 * @returns {Object} Babel 插件对象。
 */
export default function ({ types: t }) {
  // console.log("i18n-plugin loaded");

  let translations = {}; // 用于存储从选项加载的翻译
  let defaultLocale = 'en';
  let translationFunctionName = '__'; // 默认的翻译函数名

  return {
    name: "simple-i18n",

    // pre 函数在遍历前执行,适合用来处理插件选项
    pre(state) {
      // console.log("i18n-plugin: pre() hook");
      // console.log("Plugin options received:", JSON.stringify(this.opts, null, 2)); // this.opts 是 state.opts 的别名

      if (this.opts.translations) {
        translations = this.opts.translations;
      } else {
        console.warn("[i18n-plugin] 'translations' option not provided. Plugin may not work as expected.");
        translations = {};
      }

      if (this.opts.defaultLocale) {
        defaultLocale = this.opts.defaultLocale;
      }

      if (this.opts.functionName) {
        translationFunctionName = this.opts.functionName;
      }
      // console.log(`[i18n-plugin] Using function name: '${translationFunctionName}', default locale: '${defaultLocale}'`);
      // console.log("[i18n-plugin] Loaded translations:", JSON.stringify(translations, null, 2));
    },

    visitor: {
      /**
       * 访问 CallExpression (函数调用表达式) 节点。
       * @param {NodePath} path - 当前 CallExpression 节点的路径对象。
       * @param {Object} state - 插件状态对象 (这里我们主要用 pre 中设置好的 this.opts)。
       */
      CallExpression(path, state) {
        const node = path.node;
        const callee = node.callee;

        // 检查是否是我们定义的翻译函数调用,例如 __() 或 i18n()
        if (t.isIdentifier(callee, { name: translationFunctionName })) {
          // console.log(`Found translation function call: ${translationFunctionName}() at line ${node.loc.start.line}`);

          if (node.arguments.length === 0) {
            // console.warn(`[i18n-plugin] Call to ${translationFunctionName}() with no arguments. Skipping.`);
            return;
          }

          const firstArg = node.arguments[0];
          if (!t.isStringLiteral(firstArg)) {
            // console.warn(`[i18n-plugin] First argument to ${translationFunctionName}() must be a string literal (the key). Skipping.`);
            return;
          }

          const translationKey = firstArg.value;
          // console.log(`Translation key: "${translationKey}"`);

          let translatedString = null;

          // 尝试从当前语言的翻译中获取
          if (translations[defaultLocale] && translations[defaultLocale].hasOwnProperty(translationKey)) {
            translatedString = translations[defaultLocale][translationKey];
          } else if (translations.hasOwnProperty(translationKey)) {
            // 如果顶层直接是键值对 (没有按语言组织)
            // 或者作为一种回退机制,如果特定语言没有,尝试从通用翻译中找
            // 这种结构通常不推荐,最好按语言组织
            // console.log(`[i18n-plugin] Key "${translationKey}" not found in locale "${defaultLocale}", trying root.`);
            // translatedString = translations[translationKey];
          }


          // 如果找不到翻译,并且提供了默认值参数
          if (translatedString === null || typeof translatedString !== 'string') {
            if (node.arguments.length > 1 && t.isStringLiteral(node.arguments[1])) {
              const defaultValue = node.arguments[1].value;
              // console.log(`[i18n-plugin] Key "${translationKey}" not found for locale "${defaultLocale}". Using provided default value: "${defaultValue}"`);
              translatedString = defaultValue;
            } else {
              // console.warn(`[i18n-plugin] Key "${translationKey}" not found for locale "${defaultLocale}" and no default value provided. Replacing with key itself or an indicator.`);
              // 如果没有翻译也没有默认值,可以选择替换为 key 本身,或者一个标记,或者抛出错误
              // 这里我们替换为 "KEY_NOT_FOUND:key"
              translatedString = state.opts.missingKeyPrefix ? `${state.opts.missingKeyPrefix}${translationKey}` : `KEY_NOT_FOUND:${translationKey}`;
            }
          }

          if (typeof translatedString === 'string') {
            // console.log(`Replacing call with string literal: "${translatedString}"`);
            // 用翻译后的字符串字面量替换整个函数调用表达式
            path.replaceWith(t.stringLiteral(translatedString));
          } else {
            // console.warn(`[i18n-plugin] Could not resolve translation for key "${translationKey}". Original call remains.`);
          }
        }
      }
    },

    post(state) {
      // console.log("i18n-plugin: post() hook");
      // 清理,虽然在这个简单例子中非必需,但好习惯
      translations = {};
      defaultLocale = 'en';
      translationFunctionName = '__';
    }
  };
}

测试 i18n-plugin.js:

// test-i18n-plugin.js
const { transformCode } = require('./transform');
const i18nPlugin = require('./plugins/i18n-plugin').default;

const code = `
  const greeting = __("greeting.hello");
  const farewell = __("farewell", "Goodbye for now!");
  const missing = __("missing.key");
  const customName = i18n("custom.message");

  function showMessages() {
    console.log(greeting);
    console.log(farewell);
    console.log(missing);
    console.log(customName);
    console.log(__("inline.usage", "Inline default"));
  }
`;

const pluginOptions1 = {
  functionName: "__", // 明确指定函数名
  defaultLocale: "en_US",
  translations: {
    "en_US": {
      "greeting.hello": "Hello, World!",
      "farewell": "Farewell, Friend!",
      "inline.usage": "Used inline"
      // "missing.key" is intentionally missing
    },
    "es_ES": {
      "greeting.hello": "¡Hola, Mundo!"
    }
  },
  missingKeyPrefix: "[MISSING] " // 自定义未找到键的前缀
};

const pluginOptions2 = {
  functionName: "i18n", // 改变翻译函数名
  defaultLocale: "fr_FR",
  translations: {
    "fr_FR": {
      "custom.message": "Message personnalisé"
    }
  }
};


console.log("Original Code:\n", code);

// 测试1: 使用 __ 和 en_US 翻译
const transformedCode1 = transformCode(code, [
  [i18nPlugin, pluginOptions1]
]);
console.log("\nTransformed Code (en_US with __):\n", transformedCode1);

// 测试2: 使用 i18n 和 fr_FR 翻译 (只影响 i18n() 调用)
// 注意:由于 Babel 插件是按顺序应用的,如果想让两个不同配置的 i18n 插件都生效,
// 需要分别应用它们,或者让一个插件能够处理多种配置。
// 这里我们假设只应用一个,所以 __() 调用在这次转换中不会被 pluginOptions2 处理。
const transformedCode2 = transformCode(code, [
  [i18nPlugin, pluginOptions2] // 这个配置只会处理 i18n()
]);
console.log("\nTransformed Code (fr_FR with i18n, __ calls untouched by this specific plugin instance):\n", transformedCode2);

// 更实际的场景是,你可能只配置一次插件,或者链式调用:
const transformedCodeCombined = transformCode(code, [
  [i18nPlugin, pluginOptions1], // 先处理 __
  [i18nPlugin, pluginOptions2]  // 再处理 i18n
]);
console.log("\nTransformed Code (Combined - __ then i18n):\n", transformedCodeCombined);


/*
预期输出 (transformedCode1):
const greeting = "Hello, World!";
const farewell = "Farewell, Friend!";
const missing = "[MISSING] missing.key";
const customName = i18n("custom.message"); // 不会被 pluginOptions1 处理

function showMessages() {
  console.log(greeting);
  console.log(farewell);
  console.log(missing);
  console.log(customName);
  console.log("Used inline");
}

预期输出 (transformedCode2):
const greeting = __("greeting.hello"); // 不会被 pluginOptions2 处理
const farewell = __("farewell", "Goodbye for now!"); // 不会被 pluginOptions2 处理
const missing = __("missing.key"); // 不会被 pluginOptions2 处理
const customName = "Message personnalisé";

function showMessages() {
  console.log(greeting);
  console.log(farewell);
  console.log(missing);
  console.log(customName);
  console.log(__("inline.usage", "Inline default")); // 不会被 pluginOptions2 处理
}

预期输出 (transformedCodeCombined):
const greeting = "Hello, World!";
const farewell = "Farewell, Friend!";
const missing = "[MISSING] missing.key";
const customName = "Message personnalisé";

function showMessages() {
  console.log(greeting);
  console.log(farewell);
  console.log(missing);
  console.log(customName);
  console.log("Used inline");
}
*/

这个 i18n-plugin 示例展示了:

  • pre 钩子中处理和准备插件选项。
  • 根据插件选项动态改变插件的行为(如翻译函数名、区域设置)。
  • 从选项中加载数据(翻译字典)。
  • 替换函数调用节点为字符串字面量节点。
  • 处理参数和提供回退机制(默认值、未找到键的标记)。
  • post 钩子中进行清理(可选)。

AST Explorer 的使用

强烈推荐使用 AST Explorer (astexplorer.net)

  1. 选择 JavaScript 作为语言。
  2. 选择 @babel/parser 作为解析器。
  3. 选择 "Transform" -> "Babel Plugin" (或 v7/v8)。
  4. 在左上角面板输入你的源代码。
  5. 在右上角面板可以看到生成的 AST 结构。点击代码中的部分会高亮对应的 AST 节点,反之亦然。
  6. 在左下角面板可以编写你的 Babel 插件代码。
  7. 在右下角面板可以看到插件转换后的代码。

这对于理解特定代码结构对应的 AST 节点类型、属性以及试验插件逻辑非常有帮助。

Babel 插件的测试

虽然上面的例子中我们用了简单的 transformCode 函数来手动测试,但在实际项目中,建议使用更专业的测试框架,如 Jest。

你可以使用 @babel/coretransformtransformSync 方法在测试用例中运行你的插件,并断言输出是否符合预期。

一个简单的 Jest 测试用例可能如下:

// my-plugin.test.js
import pluginTester from 'babel-plugin-tester'; // 一个流行的测试工具 npm install --save-dev babel-plugin-tester
import myCustomPlugin from '../plugins/my-custom-plugin'; // 你的插件

pluginTester({
  plugin: myCustomPlugin,
  pluginName: 'my-custom-plugin', // 与插件中的 name 对应
  // fixture: path.join(__dirname, 'fixtures', 'my-test-case', 'code.js'), // 从文件加载测试用例
  // outputFixture: path.join(__dirname, 'fixtures', 'my-test-case', 'output.js'),
  tests: [
    {
      title: 'should replace var with let',
      code: 'var x = 1;',
      output: 'let x = 1;',
      pluginOptions: { /* 插件选项 */ }
    },
    {
      title: 'should remove console.log',
      code: 'console.log("hello"); var a = 1;',
      output: 'var a = 1;', // 假设这是另一个插件或组合
      // 如果是测试 remove-console-log-plugin
      // plugin: require('../plugins/remove-console-log-plugin').default,
      // output: 'var a = 1;',
    },
    // 更多测试用例
    {
      title: 'should not change const',
      code: 'const y = 2;',
      output: 'const y = 2;', // 或者 snapshot: true
    },
    {
      title: 'error case',
      code: 'var 123invalid = "test";', // 无效代码
      error: /SyntaxError/ // 或特定的错误信息/类型
    }
  ],
});

babel-plugin-tester 提供了很好的结构来组织测试用例,包括快照测试、错误测试等。

其他相关知识

  1. 插件顺序:

    • Babel 插件的执行顺序很重要。在 Babel 配置中,插件按数组顺序从前到后执行。
    • Preset(预设,插件的集合)中的插件会在自定义插件之前执行。Preset 的顺序是反向的(从后到前)。
    • 如果一个插件的转换依赖于另一个插件的结果,你需要确保它们的顺序正确。
  2. Preset (预设) :

    • Preset 是一组预先配置好的 Babel 插件和/或选项。例如 @babel/preset-env 可以根据你指定的目标环境自动确定需要的插件和 polyfill。
    • @babel/preset-react 包含了转换 JSX 的插件。
    • @babel/preset-typescript 包含了转换 TypeScript 的插件。
    • 你可以创建自己的 Preset。
  3. 宏 (Macros - babel-plugin-macros) :

    • 宏允许你在构建时执行代码生成,并且不需要用户在 Babel 配置中添加插件,只需导入宏即可。它们提供了更零配置的插件体验。例如 styled-components/macro
  4. 性能注意事项:

    • 避免在访问者函数中进行昂贵的操作,特别是那些会被频繁调用的节点类型(如 Identifier)。
    • 如果可能,在 pre 函数中进行一次性的计算或设置。
    • 合理使用 path.skip() 来跳过不需要处理的子树。
    • 缓存计算结果(如果适用)。
    • Babel 内部有一些优化,但插件的写法仍然对性能有影响。
  5. 作用域 (Scope) :

    • path.scope 对象非常有用,它提供了关于当前节点作用域内变量绑定、引用等信息。
    • scope.hasBinding("varName"): 检查变量是否在此作用域或父作用域中声明。
    • scope.getBinding("varName"): 获取变量的绑定信息 (Binding 对象),包含声明节点、引用等。
    • scope.generateUidIdentifier("prefix"): 生成一个在当前作用域中唯一的标识符,避免命名冲突。
    • 理解作用域对于进行安全的变量重命名、注入新变量等操作至关重要。
  6. 与打包工具 (Webpack, Rollup) 的集成:

    • Babel 通常作为这些打包工具的一个加载器 (loader) 或插件来使用(例如 babel-loader for Webpack)。打包工具负责读取文件,然后将文件内容传递给 Babel 进行转换。
  7. Source Maps:

    • Babel 可以生成 Source Map,将转换后的代码映射回原始代码,这对于调试非常重要。代码生成阶段 (@babel/generator) 负责此事,通常可以通过 Babel 的选项来控制 Source Map 的生成。
  8. Helper 函数:

    • 某些转换(例如类、异步函数)可能需要一些辅助函数(helpers)来模拟新特性。Babel 可以将这些 helpers 内联到每个需要它们的文件中,或者通过 @babel/plugin-transform-runtime@babel/runtime 将它们提取为共享模块,以减小代码体积。

总结

编写 Babel 插件是一个强大但复杂的过程。核心在于理解 AST 的结构、掌握 NodePath API 以及如何使用 @babel/types@babel/template 来操纵 AST。

  • 流程: Parse -> Transform (插件工作区) -> Generate。
  • 输入: 插件通过 visitor 模式接收 path (NodePath) 和 state
  • 输出: 插件通过修改 path 对应的 AST 节点来产生效果。
  • 关键工具: NodePath API, @babel/types, @babel/template
  • 实践: 多使用 AST Explorer,多写测试。