为什么要用 Babel
之前的 clean-log-loader 用正则删除 console.log,简单但脆弱:它分不清代码里的字符串和注释,也处理不好嵌套调用。要做到精确,就得理解代码结构——这正是 Babel 的强项。
Babel 把源码解析成 AST(抽象语法树),我们基于节点类型做增删改,再生成回代码。整个过程分三步:
源码 ──parse──▶ AST ──transform──▶ AST ──generate──▶ 源码
Loader 恰好处于「源码进、源码出」的位置,所以用 Babel 再合适不过。
核心 API
只需安装一个包:
npm install -D @babel/core
最常用的入口是 transformSync:
const babel = require('@babel/core');
const { code, map } = babel.transformSync(source, {
filename: 'a.js',
babelrc: false,
configFile: false,
sourceMaps: true,
plugins: [myPlugin],
});
plugins:插件数组,每个插件负责一种转换。babelrc/configFile:示例里关掉,避免读取外部配置导致行为不可控。sourceMaps:是否生成 sourcemap。- 返回值里
code是转换后的代码,map是源码映射。
编写插件:从节点类型说起
一个 Babel 插件就是一个返回 visitor 对象的函数:
module.exports = function myPlugin({ types: t }) {
return {
name: 'my-plugin',
visitor: {
// 节点类型: 处理函数
},
};
};
types(通常简写 t)是官方提供的工具集,用来判断和创建节点。
要删除 console.log(x),先看它对应的 AST 结构:
ExpressionStatement
└── CallExpression
├── callee: MemberExpression
│ ├── object: Identifier name="console"
│ └── property: Identifier name="log"
└── arguments: [...]
于是我们在 CallExpression 上做判断。
完整插件
module.exports = function removeConsoleLog({ types: t }) {
function isConsoleLog(callee) {
if (!t.isMemberExpression(callee)) return false;
if (!t.isIdentifier(callee.object, { name: 'console' })) return false;
if (t.isIdentifier(callee.property)) {
return callee.property.name === 'log';
}
if (t.isStringLiteral(callee.property)) {
return callee.property.value === 'log';
}
return false;
}
return {
name: 'remove-console-log',
visitor: {
CallExpression(path) {
if (!isConsoleLog(path.node.callee)) return;
const parent = path.parentPath;
if (parent.isExpressionStatement()) {
parent.remove();
} else {
path.replaceWith(t.identifier('undefined'));
}
},
},
};
};
逐段解读
-
path而非nodevisitor 回调拿到的是path,它描述节点在树中的位置及父子关系。path.node是节点本身,path.parentPath是父路径。 -
isXxx类型判断t.isMemberExpression、t.isIdentifier既能判类型,传第二个参数还能顺手校验字段,例如t.isIdentifier(obj, { name: 'console' })。 -
兼容两种写法
console.log的property是Identifier;console['log']的property是StringLiteral。两者都覆盖,避免漏网。 -
分场景删除
- 独立语句
console.log(x);:父节点是ExpressionStatement,直接parent.remove()。 - 嵌套表达式
const v = console.log(x);:不能删父节点,否则会留下const v = ;,所以把这次调用替换成undefined,得到const v = undefined;。
- 独立语句
-
replaceWith创建节点t.identifier('undefined')生成一个标识符节点,交给 Babel 生成代码。
接到 Loader 上
Loader 只负责调用 Babel 并把结果交出去:
const babel = require('@babel/core');
const removeConsoleLog = require('../babel-plugins/remove-console-log');
module.exports = function babelCleanLogLoader(source) {
const { code, map } = babel.transformSync(source, {
filename: this.resourcePath,
babelrc: false,
configFile: false,
sourceMaps: this.sourceMap,
plugins: [removeConsoleLog],
});
this.callback(null, code, map);
};
this.resourcePath作为filename,报错时能定位到具体文件。sourceMaps: this.sourceMap尊重 Webpack 的 sourcemap 配置。- 用
this.callback(null, code, map)同时返回代码和 sourcemap。
Webpack 配置:
{
test: /\.js$/i,
exclude: /node_modules/,
use: [
{
loader: path.resolve(__dirname, 'loaders/insert-comment-loader.js'),
options: { content: 'Generated by webpack-demo' },
},
path.resolve(__dirname, 'loaders/babel-clean-log-loader.js'),
],
}
效果对比
输入:
console.log('1 + 2 =', sum(1, 2));
const s = 'console.log 在字符串里';
console.warn('保留');
const v = console.log(foo());
// console.log('注释');
console['log']('computed');
输出:
const s = 'console.log 在字符串里';
console.warn('保留');
const v = undefined;
// console.log('注释');
字符串、注释、console.warn 全部原样保留——这是正则方案做不到的。注意 console['log'] 也被正确删除,而注释中的内容因为不在 AST 里,天然不会受影响。
注意事项
- Babel 只处理语法,不理解作用域:如果代码里有个局部变量也叫
console,插件无法区分。生产级插件会结合path.scope.getBinding('console')判断它是否是全局对象。 - 未使用的返回值会变成
undefined:这是嵌套场景的取舍,可根据需求改成void 0或保留。 - 性能:
transformSync是同步阻塞的,大项目可改用transformAsync并在 loader 里返回 Promise。 - 别重复造轮子:若只是做语法降级,直接配置
@babel/preset-env即可;写自定义插件是为了业务定制转换。
小结
用 Babel 写 loader 的收益,是能基于 AST 做精确、可靠的代码变换。掌握三步模型(parse / transform / generate)、visitor 与 path、以及 types 工具后,你就能实现删除日志、按需引入、自动埋点、i18n 提取等各类转换。