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,这一篇就够了
- 访问者模式一篇就够了