前端知识体系总结-前端工程化(Babel篇)

Babel

手写一个简易编译器

Babel本质上就是一个编译器。把一种代码变成另一种代码。

我们将要实现一个最简单的Babel核心功能:将ES6的箭头函数转换为ES5的普通函数

我们不要去背那些复杂的概念,编译器的工作流程在任何语言里都是一样的,只有三个阶段:

  1. 解析(Parse):把代码字符串变成树结构(AST)。
  2. 转换(Transform):在树上修修补补,把"箭头函数节点"改成"普通函数节点"。
  3. 生成(Generate):把改好的树重新变回代码字符串。

一、为什么需要将代码解析为 AST

我们先看一个简单的代码:

js 复制代码
const add = (a, b) => a + b;

如果不生成AST,直接用正则替换,你可能会写出 code.replace('=>', 'function')。 但如果代码是这样的:

js 复制代码
const str = "这个箭头 => 是字符串不是代码";
const func = () => { return "=>"; };

正则就不管用了。它分不清哪个是语法,哪个是字符串内容。

只有通过某种方式把代码拆解成 树状结构 去进行表示,我们才能精准地知道每行代码的实际含义,比如这是一个变量声明,那是一个函数表达式。

这里我们使用 @babel/parser 来生成AST(因为手写词法分析器和语法分析器通过大量switch-case处理字符,逻辑虽简单但代码量太大,这里我们聚焦于核心的转换逻辑)。

二、AST长什么样

我们先看看上面那句 const add = (a, b) => a + b; 解析出来是什么东西。

js 复制代码
{
  "type": "VariableDeclaration", // 变量声明
  "kind": "const",
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": { "type": "Identifier", "name": "add" },
      "init": {
        "type": "ArrowFunctionExpression", // 重点在这里:箭头函数表达式
        "params": [
          { "type": "Identifier", "name": "a" },
          { "type": "Identifier", "name": "b" }
        ],
        "body": {
          "type": "BinaryExpression", // 二进制表达式 (a + b)
          "left": { "type": "Identifier", "name": "a" },
          "operator": "+",
          "right": { "type": "Identifier", "name": "b" }
        }
      }
    }
  ]
}

转换的目标很明确 :找到 ArrowFunctionExpression 类型的节点,把它替换成 FunctionExpression 类型的节点,同时处理一下函数体。

三、实现核心:遍历器(Traverser)

Babel最核心的部分不是解析,而是如何遍历这棵树。我们需要写一个函数,它能递归地访问树的每一个节点。当它遇到我们需要处理的节点时,调用我们提供的插件方法。 这是一个最基础的遍历器实现:

js 复制代码
function traverse(ast, visitor) {
  // 遍历数组类型的属性(比如 body 里的多行代码)
  function traverseArray(array, parent) {
    array.forEach(child => traverseNode(child, parent));
  }

  // 遍历单个节点
  function traverseNode(node, parent) {
    if (!node || typeof node !== 'object') return;

    // 1. 如果visitor里定义了当前节点类型的处理函数,就执行它
    // 比如 visitor.ArrowFunctionExpression(node, parent)
    const method = visitor[node.type];
    if (method) {
      method(node, parent);
    }

    // 2. 递归遍历当前节点的所有属性
    // 比如遍历 body, params, left, right 等属性
    Object.keys(node).forEach(key => {
      const child = node[key];
      if (Array.isArray(child)) {
        traverseArray(child, node);
      } else {
        traverseNode(child, node);
      }
    });
  }

  traverseNode(ast, null);
}

这段代码的逻辑是:从根节点开始,先检查有没有对应的插件函数要执行,执行完后,继续递归找它的子节点。只要树没走完,就一直递归下去。

四、实现插件:转换箭头函数

现在我们有了遍历器,就可以写"插件"了。插件就是定义由于怎么修改节点。 我们要把箭头函数:

js 复制代码
(a, b) => a + b

变成普通函数:

js 复制代码
function(a, b) { return a + b; }

转换逻辑的具体步骤:

  1. 找到 ArrowFunctionExpression 节点。
  2. 保留它的 params (参数)。
  3. 处理 body。箭头函数如果直接返回表达式(没有花括号),变成普通函数时需要加 { return ... }
  4. 把节点类型改为 FunctionExpression
js 复制代码
const transformer = {
  ArrowFunctionExpression(node) {
    // 1. 修改节点类型
    node.type = 'FunctionExpression';
    
    // 2. 处理函数体
    // 如果原体不是块语句(比如是 x => x + 1 这种直接返回的)
    // 我们需要把它包装成 { return x + 1; }
    if (node.body.type !== 'BlockStatement') {
      node.body = {
        type: 'BlockStatement',
        body: [{
          type: 'ReturnStatement',
          argument: node.body
        }]
      };
    }
    
    // 普通函数通常不需要 generator 或 async 属性,除非原样保留
    node.expression = false; 
  }
};

这里我们直接修改了 node 对象。因为AST本质上就是对象引用,直接修改树上的属性,整棵树的结构就变了。

五、代码生成(Generator)

树修改完了,最后一步是把树变回字符串。 这一步通常很繁琐,因为要处理缩进、括号、分号。为了演示核心逻辑,我们手写一个极简版的生成器,只处理我们涉及到的几种节点。

js 复制代码
function generate(node) {
  switch (node.type) {
    case 'Program':
      return node.body.map(generate).join('\n');
      
    case 'VariableDeclaration':
      return `${node.kind} ${node.declarations.map(generate).join(', ')};`;
      
    case 'VariableDeclarator':
      return `${generate(node.id)} = ${generate(node.init)}`;
      
    case 'Identifier':
      return node.name;
      
    case 'FunctionExpression':
      // 组装函数字符串:function(参数) { 函数体 }
      const params = node.params.map(generate).join(', ');
      const body = generate(node.body);
      return `function(${params}) ${body}`;
      
    case 'BlockStatement':
      return `{\n${node.body.map(generate).join('\n')}\n}`;
      
    case 'ReturnStatement':
      return `return ${generate(node.argument)};`;
      
    case 'BinaryExpression':
      return `${generate(node.left)} ${node.operator} ${generate(node.right)}`;
      
    default:
      throw new Error(`Unknown node type: ${node.type}`);
  }
}

生成器逻辑 :递归地拼接字符串。遇到 BinaryExpression 就拼左右两边,遇到 FunctionExpression 就拼关键字和参数。

六、串联整个流程(Compiler)

最后,我们把解析、转换、生成串起来,就是一个迷你版的 Babel。

js 复制代码
const parser = require('@babel/parser'); // 借用parser,专注转换逻辑

function myBabelCompiler(code) {
  // 1. 解析 (Code -> AST)
  const ast = parser.parse(code);

  // 2. 转换 (AST -> New AST)
  // 传入我们的访问器对象
  traverse(ast, transformer);

  // 3. 生成 (New AST -> New Code)
  const output = generate(ast);

  return output;
}

// 测试
const sourceCode = "const add = (a, b) => a + b;";
const targetCode = myBabelCompiler(sourceCode);

console.log(targetCode);
// 输出结果:
// const add = function(a, b) {
// return a + b;
// };

总结

实现一个Babel,不要把问题想得太复杂,其实就是三个步骤:

  1. 对象化:代码是字符串,没法改,先变成对象(AST)。
  2. 递归:对象嵌套太深,必须用递归函数(Visitor)去一层层找。
  3. 还原 :改完对象属性后,按照语法规则把字符串拼回去。 真正的Babel虽然庞大,因为它要处理几百种语法节点,还要处理作用域(Scope)和引用关系,但核心骨架就是上面这几十行代码。当你写Babel插件时,你其实就是在写那个 transformer 对象里的函数。

Babel工程化配置与使用

刚才我们手写了一个微型编译器,搞懂了原理。但在实际工作中,我们不可能自己去写AST遍历器和生成器。我们直接使用Babel官方提供的工具链。

这里有一个非常反直觉的事实:Babel本身什么都不做

如果你只安装 @babel/core 然后运行它,你把 ES6 代码丢进去,出来的还是 ES6 代码。它只是把代码解析成AST,然后又打印出来,中间没有任何修改。它不知道你要干什么。

要让它干活,必须明确告诉它:我要转换箭头函数,或者我要转换类(Class)。这些具体的转换功能,就是 Plugin(插件) ;而为了方便,把一堆常用的插件打包在一起,就是 Preset(预设)

一、基础配置:从零开始搭建

我们不讲虚的,直接看在一个空文件夹里怎么把 Babel 跑起来。

1. 初始化项目与安装核心库

你需要安装三个最基础的包:

  • @babel/core: 编译器核心,负责解析和生成。
  • @babel/cli: 命令行工具,让我们能在终端里运行 babel 命令。
  • @babel/preset-env: 这是一个智能预设,包含了所有现代 JS 语法的转换插件。
bash 复制代码
npm init -y
npm install --save-dev @babel/core @babel/cli @babel/preset-env

2. 编写配置文件

在项目根目录创建一个 babel.config.json 文件。这是控制 Babel 行为的大脑。最简单的配置只需要一行:告诉 Babel 使用 preset-env

json 复制代码
{
  "presets": ["@babel/preset-env"]
}

3. 运行测试

创建一个 src/index.js,写点 ES6 代码:

js 复制代码
const sayHello = () => console.log("Hello");

在终端运行编译命令:

bash 复制代码
npx babel src --out-dir dist

打开生成的 dist/index.js,你会发现箭头函数变成了 functionconst 变成了 var。这就是 preset-env 在起作用。它默认把所有新语法都转成了 ES5。

二、按需编译:Targets 的重要性

上面的默认配置有一个大问题:它太"笨"了。

它把所有代码都转成了 ES5,哪怕你只是跑在最新的 Chrome 浏览器上。现代浏览器原生支持 const 和箭头函数,强行转换只会让代码体积变大,运行变慢。

我们需要告诉 Babel 我们的代码要在什么环境下运行。

修改 babel.config.json

json 复制代码
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "88",
          "ie": "11"
        }
      }
    ]
  ]
}

这里我们配置了 targets。 如果你把 ie: "11" 去掉,只保留 chrome: "88",再次编译,你会发现 const 和箭头函数被保留了,没有被转换。

这是因为 Babel 查表发现 Chrome 88 原生支持这些语法,所以它直接跳过了转换步骤。这是 Babel 配置中最核心的优化点:只转换目标环境不支持的语法

三、处理API:Polyfill (垫片)

这是新手最容易混淆的地方。Babel 有两类转换:

  1. 语法转换 (Syntax Transform) :比如 => 转成 functionclass 转成 prototype。这是 preset-env 擅长的。
  2. API 添加 (Polyfill) :比如 Array.fromnew Promise()Map

如果你在代码里写 new Promise(),Babel 默认是不处理 的。因为从语法角度看,这就是创建了一个对象,语法没问题。但在 IE11 里运行会直接报错 Promise is not defined

我们需要引入 core-js 来实现这些缺少的 API。

不要全量引入,那样包会很大。我们要配置 Babel 自动按需引入。

首先安装 core-js:

bash 复制代码
npm install core-js

修改 babel.config.json,开启 useBuiltIns: "usage"

json 复制代码
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "58",
          "ie": "11"
        },
        "useBuiltIns": "usage", // 关键配置:按需引入
        "corejs": 3             // 指定 core-js 版本
      }
    ]
  ]
}

现在,如果在你的代码里写了 new Promise(),Babel 编译时会自动在文件头部加上一句: require("core-js/modules/es.promise.js")

如果你没用到 Promise,它就不加。这就是 usage 模式的威力。

四、在 Webpack 中集成

在实际开发中,我们很少直接运行 npx babel。通常是配合 Webpack 打包时自动转换。这需要用到 babel-loader

这是 Webpack 和 Babel 的连接桥梁。Webpack 负责读取文件,发现是 .js 后,交给 babel-loaderbabel-loader 调用 @babel/core 进行转换,转换完把代码还给 Webpack。

webpack.config.js 配置示例:

js 复制代码
module.exports = {
  mode: 'development',
  entry: './src/index.js',
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/, // 极其重要:千万别编译 node_modules,慢且容易出错
        use: {
          loader: 'babel-loader',
          // options 可以在这里写,也可以直接读取 babel.config.json
          // 推荐使用独立配置文件,更清晰
        }
      }
    ]
  }
};

只要项目根目录下有 babel.config.jsonbabel-loader 会自动读取它,不需要重复配置。

总结 Babel 使用的核心逻辑

  1. Babel 核心只是空壳,必须通过配置文件告诉它用什么插件。
  2. Preset-env 是万能钥匙 ,它根据 targets 决定要转换哪些语法,避免过度编译。
  3. 语法 != API=> 是语法,Promise 是 API。处理 API 需要配置 core-jsuseBuiltIns: "usage"
  4. exclude node_modules。在使用 Webpack 时,永远记得排除 node_modules,第三方包通常已经是编译好的,重复编译纯属浪费时间。

手写 Babel 插件:复刻 babel-plugin-import

我们要写一个真的能用的、在生产环境中极其常见的插件。

很多 UI 组件库(比如 Ant Design)或者工具库(比如 Lodash),都有一个痛点:文件太大。

当你写下这行代码时:

js 复制代码
import { Button, Alert } from 'antd';

在没有优化的情况下,Webpack 会把整个 antd 库(几百个组件)全打包进去,哪怕你只用了两个组件。我们要写的插件,就是要把上面那一行代码,在编译时自动转换成:

js 复制代码
import Button from 'antd/lib/button';
import Alert from 'antd/lib/alert';

这样就能按需加载,体积瞬间变小。这个插件逻辑非常经典,涉及了节点查找节点替换多节点生成这几个 Babel 插件最核心的操作。

一、准备工作

写插件的第一步永远不是写代码,而是对比 AST。我们要搞清楚,处理前的 AST 长什么样,处理后长什么样。

处理前 (import { Button } from 'antd'): 它是一个 ImportDeclaration 节点。

  • source: 值是 'antd'
  • specifiers: 这是一个数组。里面有一个 ImportSpecifier,它的 imported 属性是 Button(引入的名字),local 属性也是 Button(本地使用的名字)。

处理后 (import Button from 'antd/lib/button'): 变成了两个(或多个)ImportDeclaration 节点。

  • 每个节点都是 ImportDefaultSpecifier(注意这里变成了默认导入,因为具体的组件文件通常是 export default)。
  • source: 值变成了 'antd/lib/button'

处理方案:

  1. 监听 :专门盯着 ImportDeclaration 类型的节点。
  2. 检查 :看它的来源库是不是我们要优化的库(比如 'antd')。
  3. 提取 :如果是,就把里面的 ButtonAlert 这些名字取出来。
  4. 构造:用这些名字生成新的 import 语句。
  5. 替换:用新生成的数组,替换掉原来那一个老节点。

二、开始编写插件代码

创建一个 my-import-plugin.js 文件。

Babel 插件的标准写法是一个函数,它接受一个 babel 对象作为参数。我们需要从这个对象里拿出 types,这是 Babel 提供的节点构造工厂。你可以把它想象成乐高积木的模具,用来生成新的 AST 节点。

js 复制代码
module.exports = function(babel) {
  const { types: t } = babel; // 这是我们的工厂

  return {
    visitor: {
      // 我们只关心 import 语句
      ImportDeclaration(path, state) {
        const { node } = path;

        // 1. 检查:如果引入的库不是 'antd',直接跳过,不做处理
        // state.opts 是我们在配置文件里传给插件的参数
        // 这样插件就不仅仅能处理 antd,也能处理 lodash 等其他库
        const libraryName = state.opts.libraryName || 'antd';
        if (node.source.value !== libraryName) {
          return;
        }

        // 2. 检查:如果是默认导入 (import Antd from 'antd'),不仅没法按需加载,还说明用户可能真想引入全量
        // 我们只处理 { Button } 这种命名导入 (ImportSpecifier)
        if (!t.isImportSpecifier(node.specifiers[0])) {
          return;
        }

        // 3. 核心逻辑:遍历原来的 specifiers,生成新的 import 节点数组
        const newImports = node.specifiers.map(specifier => {
          // specifier.imported.name 是 "Button"
          // specifier.local.name 是我们代码里用的变量名 (通常也是 "Button")
          const componentName = specifier.imported.name;
          const localName = specifier.local.name;

          // 构造新的路径: 'antd/lib/button'
          // 这里简单的转成小写,实际工程中可能需要驼峰转连字符
          const newPath = `${libraryName}/lib/${componentName.toLowerCase()}`;

          // 使用 Babel 的 types 工具创建新节点
          // 生成: import localName from 'newPath'
          return t.importDeclaration(
            [t.importDefaultSpecifier(t.identifier(localName))],
            t.stringLiteral(newPath)
          );
        });

        // 4. 替换:用新的节点数组替换原来的一个节点
        // replaceWithMultiple 专门用来把一个节点变成一堆节点
        path.replaceWithMultiple(newImports);
      }
    }
  };
};

这段代码虽然短,但它展示了 Babel 插件最核心的逻辑:Path(路径)操作path 对象非常强大,它不只是当前节点,还包含了父节点、兄弟节点的信息,以及最重要的操作方法(比如 replaceWithMultiple, remove, insertBefore)。

三、调试与运行

插件写好了,怎么用呢?我们不需要把它发布到 npm,直接在本地引用测试。

在项目根目录下创建一个 .babelrc 或者 babel.config.json,配置上我们刚写的插件:

json 复制代码
{
  "presets": ["@babel/preset-env"],
  "plugins": [
    [
      "./my-import-plugin.js", 
      {
        "libraryName": "antd" 
      }
    ]
  ]
}

这里我们用了相对路径 ./my-import-plugin.js,并且传入了参数 libraryName: "antd"

验证效果

创建一个 test.js

js 复制代码
import { Button, Modal } from 'antd';
console.log(Button, Modal);

然后运行 Babel 编译(假设你已经安装了 @babel/cli):

bash 复制代码
npx babel test.js

你的控制台输出应该会变成这样:

js 复制代码
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
console.log(Button, Modal);

四、进阶思考:为什么说这有难度?

刚才的代码是一个"乞丐版"实现。在真实场景中,情况会复杂得多,这也是为什么 babel-plugin-import 源码有几百行的原因。

1. 样式的处理 真正的按需加载,不仅仅是加载 JS,还要加载对应的 CSS。 你需要不仅生成 import Button from ...,还要顺便生成 import 'antd/lib/button/style/css'。这需要在 map 循环里多生成一个 importDeclaration 节点。

2. 作用域冲突 如果你在代码里已经定义了一个叫 Button 的变量,然后再 import { Button } from 'antd',Babel 插件如果不小心处理,可能会导致变量名冲突。虽然在这个场景下概率不大,但写通用插件时,通常需要用 path.scope.generateUidIdentifier 来生成唯一的变量名。

3. 路径转换规则 我们只用了简单的 .toLowerCase()。但有的组件叫 DatePicker,文件路径可能是 date-picker。这时候就需要引入更复杂的命名转换算法(Kebab Case)。

总结

写好一个 Babel 插件,其实就是三个步骤的循环:

  1. 看 AST :用 AST Explorer 这种在线工具,把你的源代码放进去,看它是怎么被解析的。
  2. 造节点 :利用 babel.types (t) 构建你想要的新结构。
  3. 换节点 :利用 path 提供的 API,把旧的换成新的。

当你掌握了 visitor 模式和 types 构建器,你就掌握了修改 JavaScript 语言本身的权力。

相关推荐
GISer_Jing1 小时前
基于 OpenClaw 构建 博客自动撰写 Agent
前端·aigc·ai写作
潜水豆1 小时前
基于cursor 的自用专家系统v0.2
前端
Ryan今天学习了吗1 小时前
前端知识体系总结-前端工程化(Vite篇)
前端·面试·前端工程化
Neon12041 小时前
WKWebView 中 iframe 无法监听原生 JSBridge 回调的完整分析
前端
用户8168694747252 小时前
Chrome 插件开发入门
前端
_Eleven2 小时前
前端布局指南
前端·css
一枚前端小姐姐2 小时前
Vue3 + Vite 从零搭建项目,超详细入门指南
前端·vue.js
小李独爱秋2 小时前
模拟面试:简述一下MySQL数据库的备份方式。
数据库·mysql·面试·职场和发展·数据备份
一只叫煤球的猫2 小时前
别再把 Lambda 当匿名类:这 9 类坑你一定踩过
java·后端·面试