babel-loader 如何工作? 什么是babel-loader插件? babel-loader插件可以干什么? 如何制作一个babel-loader插件?

本文会介绍比较基本的编译知识和babel-loader运作原理

babel-loader 是什么?

作为老一派的打包工具, babel-loader 想必大家已经非常熟悉了.它长这样子

js 复制代码
// webpack.config.js
module.exports = {
    // ...其他配置
    module: {
        rules: [
            {
                test: /\.js$/, // 匹配所有 .js 文件
                exclude: /node_modules/, // 排除 node_modules 目录
                use: 'babel-loader' // 使用 babel-loader 处理
            }
        ]
    }
};

babel-loader 如何工作

babel-loader 其实是会去调用 Babel 的核心库 @babel/core 来处理接收到的代码。Babel 首先使用解析器(如 @babel/parser)将 JavaScript 代码解析成抽象语法树(AST)。

AST 这里不做深入探讨, 简单的说AST 就是把代码中的每个语法元素(像变量声明、函数定义、表达式等)抽象成一种树状的数据结构,方便后续对代码进行分析和转换。

例如,对于代码 const message = 'Hello, World!';会被解析成包含 VariableDeclarationVariableDeclaratorIdentifierNumericLiteral 等节点的 AST。

js 复制代码
// 源代码
const message = 'Hello, World!';
// 对应的部分 AST 结构
{
    "type": "VariableDeclaration",
    "kind": "const",
    "declarations": [
        {
            "type": "VariableDeclarator",
            "id": {
                "type": "Identifier",
                "name": "message"
            },
            "init": {
                "type": "StringLiteral",
                "value": "Hello, World!"
            }
        }
    ]
}

AST 在线查看, 可以通过这个网址在线查看源码与AST的关系

Babel 通过插件(plugin)和预设(preset)来对 AST 节点进行增删改查, 这个预设可以理解成babel预制的插件, 本质上同插件无异; 比如: 使用 @babel/preset-env 预设可以将 ES6+ 代码转换为向后兼容的 JavaScript 代码,以适配不同的浏览器和环境。

这个对AST节点的修改过程也非常类似与对dom树节点修改的过程, 插件也是 Babel 转换的核心,所有真实的修改过程都发生在这里.

经过插件转换后,Babel 使用代码生成器(@babel/generator)将转换后的 AST 重新生成 JavaScript 代码。这个新生成的代码就是经过转换后的最终代码

基本流程如下:

  1. -> @babel/core
    接收处理文件
  2. -> @beble/parser
    将源文件转换成AST
  3. -> 插件翻译/修改AST
    通过插件对AST进行增删改查
  4. -> @babel/generator
    将修改后的AST在翻译为源码

可以看到 babel-loader 的工作流程其实就是帮助我们调用了babel核心库,

其实不依赖 webpack, babel也可以完成翻译任务,只不过我们要手动调整文件输入输出过程, 而webpack+babel-loader帮我们省略了这一套繁琐的过程.

更具体的可以参考 babel使用指南

什么是babel-loader插件? babel-loader插件可以干什么?

综上所述,理解了babel-loader的工作流程,这两个问题也就很好回答了,

babel-loader插件是一个针对AST节点开放入口,你可以非常便捷的在这里对AST节点进行增删改查, 它可以让我们聚焦文件编译时的核心工作,跳过其他许多繁琐的步骤.

用好babel-loader插件可以让我们更为轻松的去完成众多文件编译任务.

如何制作一个babel-loader插件?

babel插件的api很多, 刚开始接触也会觉得比较抽象,但是babel插件最核心的api其实只有三个

vistor 访问器

它定义了如何访问和修改 AST 节点, 访问器有点类似于webpack中的module.rules配置, 比如在webpack中配置了 { test: /.ts/} 那么编译器只会检查.ts文件, vistor访问器也一样, 你配置了 VariableDeclaration 访问器,所有的变量声明语句会进入这里, 配置了FunctionDeclaration 访问器,所有函数声明会进入这里

例如: function vistor(){}

js 复制代码
 visitor : {
  FunctionDeclaration(path) {
    // 这里的name就是 vistor
    const functionName = path.node.id.name;
    console.log('访问到函数声明:', functionName);
  },
};

以下是比较常见的vistor:

Identifier

作用:表示变量名、函数名、属性名等标识符。可以用于重命名变量、检查特定标识符等操作。

js 复制代码
const visitor = {
    Identifier(path) {
        if (path.node.name === 'oldVariable') {
            path.node.name = 'newVariable';
        }
    }
};

Literal

作用:表示各种字面量,如字符串、数字、布尔值、null 等。可以用于修改字面量的值。

js 复制代码
const visitor = {
    Literal(path) {
        if (typeof path.node.value === 'string') {
            path.node.value = path.node.value.toUpperCase();
        }
    }
};

VariableDeclaration

作用:表示变量声明语句,如 var、let 和 const 声明。可以用于修改变量声明的类型、添加或删除变量声明。

示例:

js 复制代码
const visitor = {
    VariableDeclaration(path) {
        if (path.node.kind === 'var') {
            path.node.kind = 'let';
        }
    }
};

FunctionDeclaration

作用:表示函数声明语句。可以用于修改函数名、参数、函数体等。

示例:

js 复制代码
const visitor = {
    FunctionDeclaration(path) {
        const newFunctionName = path.scope.generateUidIdentifier('newFunction');
        path.node.id = newFunctionName;
    }
};

BinaryExpression

作用:表示二元表达式,如 a + b、a * b 等。可以用于修改操作符或操作数。

示例:

js 复制代码
const visitor = {
    BinaryExpression(path) {
        if (path.node.operator === '+') {
            path.node.operator = '-';
        }
    }
};

CallExpression

作用:表示函数调用表达式,如 func()、obj.method() 等。可以用于修改调用的函数名、参数等。

示例:

js 复制代码
const visitor = {
    CallExpression(path) {
        if (path.node.callee.name === 'oldFunction') {
            path.node.callee.name = 'newFunction';
        }
    }
};

IfStatement

作用:表示 if 语句。可以用于修改条件表达式、if 块或 else 块的内容。

示例:

js 复制代码
const visitor = {
    IfStatement(path) {
        const newTest = t.booleanLiteral(true);
        path.node.test = newTest;
    }
};

ReturnStatement

作用:表示 return 语句。可以用于修改返回值。

示例:

js 复制代码
const visitor = {
    ReturnStatement(path) {
        const newReturnValue = t.numericLiteral(0);
        path.node.argument = newReturnValue;
    }
};

path

path是一个很重要的api了, 它用来检查节点路径信息和获取AST node信息等..., 这个可以理解相当于web开发中的window对象

1. 访问节点信息

  • 获取节点本身 :通过 path.node 可以直接访问当前遍历到的 AST 节点,进而获取节点的各种属性。
javascript 复制代码
module.exports = function(babel) {
    const { types: t } = babel;
    return {
        visitor: {
            Identifier(path) {
                const node = path.node;
                console.log('Identifier 节点名称:', node.name);
            }
        }
    };
};
  • 获取父节点 :使用 path.parent 可以获取当前节点的父节点,这在需要根据父节点信息来处理当前节点时非常有用。
javascript 复制代码
module.exports = function(babel) {
    const { types: t } = babel;
    return {
        visitor: {
            Identifier(path) {
                const parentNode = path.parent;
                if (t.isVariableDeclarator(parentNode)) {
                    console.log('当前 Identifier 是变量声明的一部分');
                }
            }
        }
    };
};

2. 节点操作

  • 替换节点path.replaceWith(newNode) 方法用于用一个新的节点替换当前节点。
javascript 复制代码
module.exports = function(babel) {
    const { types: t } = babel;
    return {
        visitor: {
            Identifier(path) {
                if (path.node.name === 'oldName') {
                    const newNode = t.identifier('newName');
                    path.replaceWith(newNode);
                }
            }
        }
    };
};
  • 删除节点path.remove() 方法可用于从 AST 中移除当前节点。
javascript 复制代码
module.exports = function(babel) {
    const { types: t } = babel;
    return {
        visitor: {
            // 假设要移除所有 console.log 调用
            CallExpression(path) {
                const callee = path.node.callee;
                if (t.isMemberExpression(callee) &&
                    t.isIdentifier(callee.object, { name: 'console' }) &&
                    t.isIdentifier(callee.property, { name: 'log' })
                ) {
                    path.remove();
                }
            }
        }
    };
};

3. 遍历控制

  • 继续遍历子节点path.traverse(visitor) 方法允许在当前节点的子树中继续遍历,使用自定义的访问器对象。
javascript 复制代码
module.exports = function(babel) {
    const { types: t } = babel;
    return {
        visitor: {
            FunctionDeclaration(path) {
                const customVisitor = {
                    Identifier(subPath) {
                        console.log('Function 内部的 Identifier:', subPath.node.name);
                    }
                };
                path.traverse(customVisitor);
            }
        }
    };
};
  • 跳过子节点遍历path.skip() 方法可以跳过当前节点的子节点遍历,直接进入下一个兄弟节点。
javascript 复制代码
module.exports = function(babel) {
    const { types: t } = babel;
    return {
        visitor: {
            ObjectExpression(path) {
                // 跳过 ObjectExpression 节点的子节点遍历
                path.skip();
            }
        }
    };
};

4. 作用域管理

  • 获取作用域path.scope 可以获取当前节点所在的作用域,作用域对象提供了许多方法用于管理变量和标识符。
javascript 复制代码
module.exports = function(babel) {
    const { types: t } = babel;
    return {
        visitor: {
            Identifier(path) {
                const scope = path.scope;
                const binding = scope.getBinding(path.node.name);
                if (binding) {
                    console.log('变量绑定信息:', binding);
                }
            }
        }
    };
};
  • 创建新的标识符path.scope.generateUidIdentifier(name) 方法用于生成一个唯一的标识符,避免命名冲突。
javascript 复制代码
module.exports = function(babel) {
    const { types: t } = babel;
    return {
        visitor: {
            FunctionDeclaration(path) {
                const newId = path.scope.generateUidIdentifier('newFunction');
                path.node.id = newId;
            }
        }
    };
};

types

它主要用于创建、验证和操作抽象语法树(AST)节点, 也是使用非常频繁的api,相当于web开发中的document对象

创建 AST 节点

  • 创建标识符节点 :使用 t.identifier(name) 可以创建一个标识符节点,用于表示变量名、函数名等。
javascript 复制代码
const babel = require('@babel/core');
const t = babel.types;

// 创建一个名为 'message' 的标识符节点
const identifier = t.identifier('message');
  • 创建函数声明节点t.functionDeclaration(id, params, body) 可用于创建函数声明节点,其中 id 是函数名,params 是参数数组,body 是函数体。
javascript 复制代码
const id = t.identifier('add');
const param1 = t.identifier('a');
const param2 = t.identifier('b');
const params = [param1, param2];
const body = t.blockStatement([
    t.returnStatement(
        t.binaryExpression('+', param1, param2)
    )
]);
const functionDeclaration = t.functionDeclaration(id, params, body);

验证 AST 节点类型

  • 判断节点是否为标识符t.isIdentifier(node) 用于判断一个节点是否为标识符节点。
javascript 复制代码
const babel = require('@babel/core');
const t = babel.types;

const node = t.identifier('test');
if (t.isIdentifier(node)) {
    console.log('这是一个标识符节点');
}
  • 判断节点是否为函数声明t.isFunctionDeclaration(node) 可判断一个节点是否为函数声明节点。
javascript 复制代码
const id = t.identifier('multiply');
const param1 = t.identifier('x');
const param2 = t.identifier('y');
const params = [param1, param2];
const body = t.blockStatement([
    t.returnStatement(
        t.binaryExpression('*', param1, param2)
    )
]);
const functionNode = t.functionDeclaration(id, params, body);
if (t.isFunctionDeclaration(functionNode)) {
    console.log('这是一个函数声明节点');
}

操作 AST 节点

  • 修改标识符节点的名称 :可以直接修改标识符节点的 name 属性。
javascript 复制代码
const babel = require('@babel/core');
const t = babel.types;

const identifier = t.identifier('oldName');
identifier.name = 'newName';
  • 修改函数声明节点的参数 :可以修改函数声明节点的 params 属性。
javascript 复制代码
const id = t.identifier('subtract');
const param1 = t.identifier('m');
const param2 = t.identifier('n');
const params = [param1, param2];
const body = t.blockStatement([
    t.returnStatement(
        t.binaryExpression('-', param1, param2)
    )
]);
const functionNode = t.functionDeclaration(id, params, body);

// 添加一个新的参数
const newParam = t.identifier('c');
functionNode.params.push(newParam);

辅助生成复杂代码结构

  • 生成条件语句 :可以使用 t.ifStatement(test, consequent, alternate) 生成 if 语句。
javascript 复制代码
const test = t.binaryExpression('>', t.identifier('a'), t.identifier('b'));
const consequent = t.blockStatement([
    t.expressionStatement(
        t.callExpression(
            t.identifier('console.log'),
            [t.stringLiteral('a 大于 b')]
        )
    )
]);
const alternate = t.blockStatement([
    t.expressionStatement(
        t.callExpression(
            t.identifier('console.log'),
            [t.stringLiteral('a 小于等于 b')]
        )
    )
]);
const ifStatement = t.ifStatement(test, consequent, alternate);

如果需要更多的api查阅或者访问器的特性, 点击详细的官方文档

下面是一个最简单的插件演示:

js 复制代码
// myBabelPlugin.js
module.exports = function (babel) {
    // 从 babel 对象中解构出 types 模块,用于操作 AST 节点
    const { types: t } = babel;

    return {
        // visitor 对象定义了如何访问和修改 AST 节点
        visitor: {
            // 这里以 Identifier 节点为例,当遍历到 Identifier AST节点时会执行此函数
            Identifier(path) {
                // path 表示当前节点的路径,包含了节点的上下文信息
                if (path.node.name === 'oldIdentifier') {
                    // 如果节点的名称是 'oldIdentifier',则将其修改为 'newIdentifier'
                    path.node.name = 'newIdentifier';
                }
            }
        }
    };
};

在webpack中引入

js 复制代码
const path = require('path');
// 引入我们编写的插件
const myBabelPlugin = require('../my-babel-plugin/myBabelPlugin');

module.exports = {
    // 入口文件
    entry: './src/index.js',
    // 输出配置
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'bundle.js'
    },
    module: {
        rules: [
            {
                // 匹配 .js 文件
                test: /\.js$/,
                // 排除 node_modules 目录
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        // 使用我们编写的插件
                        plugins: [myBabelPlugin]
                    }
                }
            }
        ]
    }
};