写你的第一个 Babel 插件:import 改写实战

本节目标

Babel 插件的本质非常简单:

module.exports = function myPlugin() {
  return {
    visitor: {
      // 当遍历到某种节点时,Babel 调用这里的函数
      SomeNodeType(path) { /* 用 path 改树 */ }
    }
  }
}

这次我们改 ImportDeclaration( import 声明)节点:当来源是 'antd' 时,把每个具名导入 { Button, Input } 拆成独立的默认导入 import Button from 'antd/lib/button'。这正是 antd 按需加载的经典写法。

动手:完整插件 + 运行

// ===== 文件 1:plugin.cjs(我们的插件)=====
// 运行环境:Node.js 18+,需安装: npm install @babel/core @babel/types
// 说明:插件本身只是“返回 visitor 的函数”,不依赖运行环境

module.exports = function babelPluginImportToLib() {
  const t = require('@babel/types')
  return {
    visitor: {
      ImportDeclaration(path) {
        const source = path.node.source.value
        if (source !== 'antd') return            // 只处理来自 antd 的导入

        const newNodes = path.node.specifiers
          .filter((s) => s.type === 'ImportSpecifier') // 具名导入 { X }
          .map((s) => {
            const exported = s.imported.name       // 导出的名字,如 Button
            const local = s.local.name             // 本地绑定的名字
            return t.importDeclaration(
              [t.importDefaultSpecifier(t.identifier(local))],
              t.stringLiteral('antd/lib/' + exported.toLowerCase())
            )
          })

        if (newNodes.length > 0) {
          // 用多个新节点替换原来的单条导入
          path.replaceWithMultiple(newNodes)
        }
      }
    }
  }
}
// ===== 文件 2:run.cjs(用插件转换一段代码)=====
// 运行环境:Node.js 18+,需安装: npm install @babel/core
// 运行方式:把 plugin.cjs 和 run.cjs 放同一目录,终端执行  node run.cjs

const babel = require('@babel/core')
const myPlugin = require('./plugin.cjs')

const code = "import { Button, Input } from 'antd'"
const result = babel.transformSync(code, { plugins: [myPlugin] })

console.log('转换前:', code)
console.log('转换后:', result.code)
// 转换后:
// import Button from "antd/lib/button";
// import Input from "antd/lib/input";

名词解释

插件(Plugin):在 Babel 语境下,插件就是“一个返回 { visitor } 的函数”。Babel 在遍历 AST 时,遇到你在 visitor 里声明的节点类型,就调用对应的处理函数。换句话说,插件 = 你告诉 Babel“遇到某类节点时该做什么”的一段逻辑。

ImportDeclaration(导入声明节点):AST 中表示一条 import ... from ... 语句的节点。它的 source.value 是模块路径(如 'antd'),specifiers 是这条语句导入的具体绑定(默认导入 import x、具名导入 import { x })。改按需加载,本质上就是重写这个节点。

课后练习

练习 1:本节的插件把 import { Button } from 'antd' 转换成了什么?请写出转换后的完整代码。

答案:import Button from "antd/lib/button";。逻辑是:每个具名导入 { Button } 被拆成一条独立的默认导入,模块路径被小写化为 antd/lib/button(Button → button)。

练习 2:如果源码是 import React from 'react',本节的插件会改动它吗?为什么?

答案:不会,有两道关卡把它挡住了:① if (source !== 'antd') return 直接跳过非 antd 的导入;② 它是默认导入不是具名导入,specifiers.filter(s => s.type === 'ImportSpecifier') 会把它过滤掉,于是 newNodes 为空,不会触发 replaceWithMultiple。

本节小结(观点与完整描述)

本节你写出了第一个“能落地”的 Babel 插件,也戳破了插件神秘感:插件 = 一个返回 { visitor } 的函数,你写的就是“遇到某类节点时做什么”。真正干活的三件套是 path.replaceWith / replaceWithMultiple / remove 配合 t.xxx() 造节点;而 babel.transformSync(code, { plugins: [你的插件] }) 则让你在普通 Node 脚本里离线跑通整条流水线。值得点破的是——你每天用的 babel-plugin-import、React 的 JSX 转换、TypeScript 去类型,底层全是同一套机制,区别只在 visitor 里改了什么。理解了“插件就是 visitor 函数”,你就从“用别人的插件”跨进了“写自己的插件”。