1. 前言
关于 Babel,不知道你是不是跟我一样,对它的了解仅限于在脚手架的基础上加些个性化配置,比如支持 ES2015+ 等特殊语法,以便应用可在目标浏览器中正常运行,比如可选链。
直到有一天,我的微信小程序本地图片占用太多了,超过微信的代码包上传限制,需要把所有的本地图片都上传 OSS 云对象存储上,涉及到大量的本地图片路径转换为线上地址,人工替换工作量大,最终我通过手写一个 babel 自定义插件解决了这个问题。
2. 知识准备
2.1 知道 babel 是什么,有什么用
babel 是一个 JavaScript 编译器,负责源码到源码的编译,这种高级语言到高级语言的编译也叫转译。它最开始是用来解决 es6 转 es5的问题。后来随着发展,babel 越来越强大,还提供众多的模块用于不同形式的静态分析编译。
静态分析是在不需要执行代码的前提下对代码进行分析的处理过程 (]执行代码的同时进行代码分析即是动态分析)。
静态分析的目的是多种多样的, 它可用于语法检查,编译,代码高亮,代码转换,优化,压缩等等场景。
- 把
esnext新的语法、typescript、flow的语法转成目标环境可支持的实现,还可以针对浏览器不支持的 api 进行polyfill模拟实现。 - 一些特定用途的代码转换,比如转译工具
taro可转换react语法为微信小程序的语法。 - 网站支持自动国际化。
linter工具,对代码规范进行检查。- 压缩混淆工具,去掉死代码、变量名混淆等各种编译优化。
- api 文档自动生成文档,通过提取源码中的注释,生成文档。
type checker类型检查。javascript解释器。
2.2 理解 babel 抽象语法树 ast
跟直接使用正则表达式直接操作字符串不同,babel 整个处理过程中都在操作 ast。ast 到底是什么呢?
因为源码是一串按照指定的语法格式组织的字符串,为了让计算机认识源代码的语法结构,方便我们按照语法结构进行操作。我们需要把源代码转换成有依赖关系的抽象语法树数据结构 ast ,每个节点对象保存不同的数据。
之所以叫做抽象语法树,会把源码中一些无具体意义的分隔符如;,{ 等剔除。
babel 对源代码字符串进行词法分析、语法分析,最终生成一颗可操作的 ast 抽象语法树,这样对源代码的修改转换到对 ast 的增删改,然后再对 ast 处理输出目标字符串。
比如下面的代码
// before
var bar = [1, 2, 3];
// after
var bar = mori.vector(1, 2, 3);
生成的 ast 节点如下:
(图片来源:www.sitepoint.com/understandi…)
ast 是对源码的抽象,由标识符 Identifer、各种字面量 xxLiteral、各种语句 xxStatement,各种声明语句 xxDeclaration,各种表达式 xxExpression,以及 Class、Modules、File、Program、Directive、Comment 这些 ast 节点构成。
从关注字符串的不同,到我只需要知道前后代码对应 ast 树的区别,直接对源代码对应 ast 进行转换处理,最终生成目标代码。
对于代码对应的 ast,我们也不需要强制记下来,在开发过程中,我们可以随时在工具网站 astexpoler.net 去查。
2.3 babel 的编译流程和主要 API 包
babel 是源码到源码的转换,整体编译流程分为三步,先解析源码成 ast,然后插件更改 ast,最后由 babel输出代码:
• parse 解析:通过 parser 解析器进行词法分析和语法分析把源码转成抽象语法树 ast。
• transform 转换:接收 ast 并对其遍历,调用各种 transform 插件对 ast 进行增删改
• generate 生成:把转换后的 ast 转换成目标代码字符串,并生成源码映射 sourcemap。
从 github 中查看 babel 的整体功能,有以下模块:
• parse 阶段有 @babel/parser,功能是把源码转成 ast。
• transform 阶段有 @babel/traverse,可以遍历 ast,并调用 visitor 函数修改 ast。对于 ast节点的判断、创建、修改等,可以用 @babel/types 包,当需要批量创建 AST 的时候可以用 @babel/template 简化逻辑。
• generate 阶段有 @babel/generate,会把 ast 打印为目标代码字符串,同时生成 sourcemap。
• 使用 @babel/code-frame 包,用于中途遇到错误想打印代码位置的时候。
• 当需要应用 babel 预设和插件时,就需要使用 @babel/core,它会使用上面的包实现整体的功能。
举个例子,我想要给 console.log 打印函数额外添加行和列信息,应用上面的包如下:
// before
console.log(1);
// after
console.log("filename: (2, 4)", 1);
整体的代码结构是这样的
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const types = require('@babel/types');
const targetCalleeNameList = ['log'].map(item => `console.${item}`);
// 把目标代码解析为 ast
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous' // 根据内容是否有 import 和 export 来自动设置 module 还是 script
});
// 遍历 ast,这里可以对 ast 进行增删改
traverse(ast, {
CallExpression(path, state) { // 遍历函数调用 ast 节点时会回调
const calleeName = generate(path.node.callee).code;
if (targetCalleeNameList.includes(calleeName)) {
const { line, column } = path.node.loc.start;
path.node.arguments.unshift(
types.stringLiteral(`filename: (${line}, ${column})`)
);
}
}
});
// ast 代码转目标代码
const { code, map } = generate(ast);
console.log(code);
通过上面的代码,可以知道 parser、traverse、generator 各个包的作用。
3. 实现一个简单插件,理解编写规范
前面我们已经知道 babel 的编译流程,了解到 babel 插件是在 transform 阶段执行的。
接下来我们通过把上述的 console 函数插入参数转成插件,支持下面这样转换:
Before
console.log(1);
function func() {
console.info(2);
}
export default class Clazz {
say() {
console.debug(3);
}
render() {
return <div>{console.error(4)}</div>
}
}
After
console.log("filename: (1, 0)")
console.log(1);
function func() {
console.log("filename: (4, 4)")
console.info(2);
}
export default class Clazz {
say() {
console.log("filename: (9, 8)")
console.debug(3);
}
render() {
return <div>{[console.log("filename: (12, 21)"), console.error(4)]}</div>;
}
搭建主体流程代码如下,使用 @babel/core 的 transformFileSync 调用插件。
// index.js
const { transformFileSync } = require('@babel/core');
const path = require('path');
const insertParameterPlugin = require('./plugins/babel-plugin-parameters-insertinsert');
const { code } = transformFileSync(
path.join(__dirname, './sourceCode.js'),
{
plugins: [insertParameterPlugin], // 插件配置
parserOpts: {
sourceType: 'unambiguous',
plugins: ['jsx'] // 支持解析 jsx
}
});
3.1 插件编写规范 visitor
源码 parse 成 ast 之后,需要进行 ast 的遍历和增删改。babel 会递归遍历 ast,遍历过程中
会调用 ast 节点对应的 visitor 函数来实现 transform。
编写规范:导出一个函数,函数里返回一个对象,在对象里添加属性 vistior,visitor 里面声明对应的 ast 节点处理访问函数。由于 babel 在遍历 ast 是深度遍历,我们有两次机会访问节点,向下遍历时进入每个节点,向上遍历时我们退出每个节点。
// 返回一个函数,第一个参数为 api 参数,有 types、template 这些对应着对应的 npm 包 api。
module.exports = function(api) {
return {
visitor: {
Program: {
enter(path, state) {} // 程序进入时调用
}
// 每当遇到一个函数调用 ast 节点时调用
CallExpression(path, state) {}, // 默认进入
Identifier: {
enter() {
console.log("Entered!");
},
exit() {
console.log("Exited!");
}
}
}
}
}
visitor 即访问者,babel 这里运用到了 访问者设计模式。它的思想是当被操作的对象结构比较稳定,而操作对象的逻辑经常变化的时候,通过分离逻辑和对象结构,以便独立扩展。
对应到 babel 中,就是 ast 和 visitor 分离,在遍历 ast 的时候,调用注册的 visitor 来对其进行处理。
3.2 插件常用操作 API
上面已经知道 visitor 的编写规范,接下来了解每个 vistor 的参数 path 和 state。
3.2.1 path(路径)
抽象语法树ast 通常有很多节点,babel 提供 path 来进行节点操作以及访问关联的节点。
path 是一个对象,它表示两个节点之间的链接。它记录遍历路径的 api,记录了父子节点的引用以及对 ast 的增删改 api。
获取节点信息
- 获取当前节点:
path.node - 获取父节点:
path.parent - 获取父节点的 path:
path.parentPath - 获取 javaScript 中的作用域链信息:
path.scope path.hub可以通过path.hub.file拿到最外层 File 对象,path.hub.getScope拿到最外层作用域,path.hub.getCode拿到源码字符串
判断 AST 类型
path.isFunctionDeclaration()path.isTemplateLiteral()- …
增删改 AST 类型
- 插入节点:
path.insertBefore、path.insertAfter - 替换接:
path.replaceWith、path.replaceWithMultiple、replaceWithSourceString - 删除节点:
path.remove
在开发时我们可以通过 debugger 或者直接查看 babel 源码测试用例了解更多的操作 api。
3.2.2 state(状态)
可以从 state 中获取插件的配置项 opts 以及 file 对象:
state {
file
opts
}
比如自动化文档需求,下面摘取的一段代码,可以把数据存在 file 对象里:
const autoDocumentPlugin = declare((api, options, dirname) => {
return {
pre(file) {
file.set('docs', []);
},
visitor: {
FunctionDeclaration(path, state) {
const docs = state.file.get('docs');
docs.push({});
state.file.set('docs', docs);
}
},
post(file) {
// 读取存取的数据
const docs = file.get('docs');
const res = generate(docs, options.format); // 生成字符串
// 写到磁盘中
fse.writeFileSync(path.join(options.outputDir, 'docs' + res.ext), res.content);
}
}
});
我们也可以添加自定义的属性,以便在遍历过程中 ast 节点之间传递数据。
比如我想要在每个文件中引用某个模块,首先要找到模块标识符 jecyuId,把它存在 state 里,然后在每个 visitor 里都可以通过 state.jecyuId 找到这个,后面自定义插件会用到这种用法。
最终实现的 console 标记行列插件代码如下:
module.exports = function({types, template}) {
return {
visitor: {
CallExpression(path, state) { // state 可以读取插件 options 的配置
if (path.node.isNew) {
return;
}
const calleeName = generate(path.node.callee).code;
if (targeCalleeNameList.includes(calleeName)) {
const { line, column } = path.node.loc.start;
const newNode = template.expression(`console.log("filename: (${line}, ${column})")`)(); // 使用 template 模版 api 批量创建 AST 节点
newNode.isNew = true;
// 处理 JSX 里的 AST 节点
if (path.findParent(path => path.isJSXElement())) {
path.replaceWith(types.arrayExpression([newNode, path.node])); // // 替换 ast 节点
path.skip(); // 跳过新节点的遍历
} else {
path.insertBefore(newNode); // 直接在前面插入
}
}
}
}
}
}
4. 编写插件解决路径替换问题
让我们来解决文章开头提出的静态图片替换问题,需要把微信小程序代码包的本地图片上传到 OSS 上获得线上地址,然后把代码中引用本地图片,地址更改为线上地址,如下所示:
Before:
import SHIPPING_ICON from '../../../images/index/shipping-icon@2x.png';
const icon = <div src={require('../../images/modal-head@2x.png')} /div>;
After:
const SHIPPING_ICON = `${host}/freight-retail/ossimg/freight-weapp/images/index/shipping-icon@2x.png`;
const icon = <div src={`${host}/freight-retail/ossimg/freight-weapp/images/modal-head@2x.png`} /div>;
其中 host 变量是根据运行环境动态引入的测试环境或生产环境的域名地址,从外部模块 global-data 引入
// global-data.js
export const host = process.env.NODE_ENV === 'development' ? 'https://jecyu.sit.com' : 'https://jecyu.com'
4.1 思路分析
因为本地所有的图片都放在 images 下,通过子文件夹进行作用域分离,避免命名冲突,为方便替换,本地路径与上传到 OSS 桶里的文件夹是一致的。
另外,因为 OSS 有测试环境与正式环境的域名区别,所以替换的路径是带有 host 动态变量的。host 从哪里来,host 是声明在外部,每个更改路径的文件都需要显式引入,像这样:
import globalData from '@/store/global-data';
const { host } = globalData;
这里要处理的细节是,如果已引入 globalData模块的话,就判断是否引入 host 变量,没有的话要引入。
怎么解决呢?分为两个步骤:
- 收集要处理的文件
- 对每个文件执行处理规则
第一步,可以通过 node 脚本扫描获取所有文件 tsx、ts 文件
第二步,最粗暴的方式是采用正则表达式匹配,最原始的处理方式,需要对正则表达式较熟悉且维护麻烦。
另一种方式,借用 babel 处理 ast 解决,毕竟代码分析与编译是 babel 的强项。
-
判断文件是否有引入静态图片路径的,有则进行替换,并记录在
state中,用于后续判断是否需要引入global-data模块。 -
如果有
state.hasStaticImg,则判断是否已经引入global-data模块和host变量。
4.2 代码实现
在编写插件时,我们可以通过 astexplorer.net/ 输入要处理的源代码,分析代码处理前后对应的 ast 节点,再编码实现。
声明 ImportDeclaration 模块引入的 ast 节点回调,处理通过 import 引入图片的方式
// 针对 import 引入的图片替换处理
ImportDeclaration(curPath) {
const requireModulePath = curPath.get('source').node.value;
const specifierPath = curPath.get('specifiers.0');
if (specifierPath && specifierPath.isImportDefaultSpecifier()) {
const importModulePathList = requireModulePath.toString().split(path.sep); // path.sep 兼容 window 和 mac 的反斜杠
const ext = importModulePathList[importModulePathList.length - 1].split('.');
if (['png', 'jpg', 'jpeg', 'svg'].includes(ext[ext.length - 1])) {
state.hasStaticImg = true;
// 做替换
const imgPath = requireModulePath.replace(/(\.\.\/|\.\/)/g, '').replace(/\'/g, '');
const value = '`' + `\$\{host\}/freight-retail/ossimg/freight-weapp/${imgPath}` + '`';
const declarationAst = api.template.ast(`const ${specifierPath.toString()} = ${value};`);
curPath.replaceWith(declarationAst);
}
}
}
声明 CallExpression 调用函数节点的回调,处理通过 require 动态引入图片的方式:
CallExpression(curPath) {
const { callee, arguments: args } = curPath.node;
if (callee.name === 'require') {
if (args.length) {
const requireModulePath = args[0].value.toString();
const importModulePathList = requireModulePath.split(path.sep);
const ext = importModulePathList[importModulePathList.length - 1].split('.');
if (['png', 'jpg', 'jpeg', 'svg'].includes(ext[ext.length - 1])) {
state.hasStaticImg = true;
// 做替换
const imgPath = requireModulePath.replace(/(\.\.\/|\.\/)/g, '').replace(/\'/g, '');
const value = '`' + `\$\{host\}/freight-retail/ossimg/freight-weapp/${imgPath}` + '`';
const declarationAst = api.template.ast(`${value}`);
curPath.replaceWith(declarationAst);
}
}
}
}
通过前面的处理后,可以找到有静态图片的路径需要替换。如果有的话,就引入 global-data 模块以及 host 变量。
声明 ImportDeclaration 调用函数节点的回调,引入 global-data 模块。
引入模块需要判断 ImportDeclaration 是否包含了 global-data 模块,没有的话就用 @babel/helper-module-import 来引入。
path.traverse({
ImportDeclaration(curPath) {
const requireModulePath = curPath.get('source').node.value;
const importModulePathList = requireModulePath.toString().split('/');
const targetModule = importModulePathList[importModulePathList.length - 1];
if (targetModule === options.hostModule) { // options.hostModule 即插件配置的 'global-data'
state.globalModuePath = curPath;
const specifierPath = curPath.get('specifiers.0');
if (specifierPath.isImportDefaultSpecifier()) {// import globalData from 'global-data'
state.globalModueId = specifierPath.toString();
} else if (specifierPath.isImportSpecifier()){ // import { globalData } from 'global-data'
state.globalModueId = specifierPath.toString();
} else if (specifierPath.isImportNamespaceSecifier()) { // import * as globalData from 'global-data'
state.globalModueId = specifierPath.get('local').toString();
}
curPath.stop();
}
}
});
if (!state.globalModueId) {
state.globalModuePath = importModule.addDefault(path, '@/store/global-data', {
nameHint: path.scope.generateUid('globalData') // 生成唯一值
});
state.globalModueId = state.globalModuePath.name;
}
声明 VariableDeclarator 调用函数节点的回调,判断是否从globalData 中引入 host。
如果有引入 globalData,但没有引入 host,则追加到变量声明后面。
如果没有引入 globalData,则直接引入整个 host 声明语句。
path.traverse({
VariableDeclarator(curPath) {
if (curPath.node.init.name === state.globalModueId) {
state.hasDestructureGlobalData = true;
// 是否有 host
const propertiesPath = curPath.node.id.properties;
const hasHost = propertiesPath.find(property => property.key.name === 'host');
if (!hasHost) {
curPath.node.id.properties.push( // 追加 host 属性
api.types.objectProperty(
api.types.identifier('host'),
api.types.identifier('host'),
false,
true
));
}
curPath.stop();
}
}
});
if (!state.hasDestructureGlobalData) {
const ast = api.template.ast(`const { host } = ${state.globalModueId}`);
path.node.body.unshift(ast)
}
5. 总结
如何手写一个 babel 插件呢,在编写之前我们需要了解 babel 解决了什么问题,知道 ast 是什么、babel 的编译流程、插件在哪一步产生作用、以及插件的编写规范,在实现过程中我们可以查阅 ast 编译网站和使用 vscode 的 debugger 解决每一步可能遇到的问题。
下次你遇到一个 js 代码转换的问题,不仅仅可以用正则表达式处理,还可以通过手写一个 babel 插件解决。作为工程师,需要不断培养提效的思维,多了解业界的最佳做法。
本文涉及代码:github.com/jecyu/babel…
参考资料
- Babel 插件开发指南比官网详细,值得花时间翻阅。
- 《babel 插件通关秘籍掘金小册》 从实现 babel 插件到 babel 原理都有,干货足。
- babeljs.io/docs/en/usa…
- Understanding ASTs by Building Your Own Babel Plugin
- 深入Babel,这一篇就够了
- 访问者模式一篇就够了