Babel 是一个 source to source(源码到源码)的 JavaScript 编译器,简略来说,你为 Babel 供给一些 JavaScript 代码,Babel 能够更改这些代码,然后回来给你新生成的代码。Babel 主要用于将 ECMAScript 2015+ 代码转化为能够向后兼容的 JavaScript 版别。Babel 运用插件体系进行代码转化,因而任何人都能够为 babel 编写自己的转化插件,以支撑完结广泛的功用。
Babel 编译流程
Babel 的编译流程主要分为三个部分:解析(parse),转化(transform),生成(generate)。
code->AST->transformedAST->transformedcode
- 解析 Parse
将源码转化成笼统语法树(AST, Abstract Syntax Tree)。
比方:
functionsquare(n){
returnn*n;
}
以上的程序能够被转化成相似这样的笼统语法树:
-FunctionDeclaration:
-id:
-Identifier:
-name:square
-params[1]
-Identifier
-name:n
-body:
-BlockStatement
-body[1]
-ReturnStatement
-argument
-BinaryExpression
-operator:*
-left
-Identifier
-name:n
-right
-Identifier
-name:n
- 转化 Transform
转化阶段承受一个 AST 并遍历它,在遍历的进程中对树的节点进行增修正。这也是运转 Babel 插件的阶段。
- 生成 Generate
将经过一系列转化之后的 AST 转化成字符串办法的代码,一起还会创立 sourcemap。
你会用到的一些东西库
关于每一个阶段,Babel 都供给了一些东西库:
- Parse 阶段能够运用@babel/parser将源码转化成 AST。
- Transform 阶段能够运用 @babel/traverse 调用 visitor 函数遍历 AST,期间能够运用 @babel/types 创立 AST 和查看 AST 节点的类型,批量创立 AST 的场景下能够运用 @babel/template 半途还能够运用 @babel/code-frame 打印报错信息。
- Generate 阶段能够运用 @babel/generator 依据 AST 生成代码字符串和 sourcemap。
以上提及的包都是 @babel/core 的 dependencies,所以只需求安装 @babel/core 就能拜访到它们。
除了上面提到的东西库,以下东西库也比较常用:
- @babel/helper-plugin-utils:假如插件运用者的 Babel 版别没有您的插件所需的 API,它能给用户供给明确的错误信息。
- babel-plugin-tester:用于协助测验 Babel 插件的实用东西,一般配合 jest 运用。
本文不会深化讨论它们的详细用法,当你在编写插件的时分,能够依据功用需求找到它们,咱们后文也会涉及到部分用法。
知道 Babel 插件
接下来让咱们开端知道 Babel 插件吧。
babel 插件是一个简略的函数,它有必要回来一个匹配以下接口的目标。假如 Babel 发现不知道特点,它将抛出错误。
以下是一个简略的插件示例:
exportdefaultfunction(api,options,dirname){
return{
visitor:{
StringLiteral(path,state){},
}
};
};
Babel 插件承受 3 个参数:
- api:一个目标,包括了 types (@babel/types)、traverse (@babel/traverse)、template(@babel/template) 等实用办法,咱们能从这个目标中拜访到 @babel/core dependecies 中包括的办法。
- options:插件参数。
- dirname:目录名。
回来的目标有 name、manipulateOptions、pre、visitor、post、inherits 等特点:
- name:插件姓名。
- inherits:指定继承某个插件,经过 Object.assign 的办法,和当时插件的 options 兼并。
- visitor:指定 traverse 时调用的函数。
- pre 和 post 别离在遍历前后调用,能够做一些插件调用前后的逻辑,比方能够往 file(表明文件的目标,在插件里边经过 state.file 拿到)中放一些东西,在遍历的进程中取出来。
- manipulateOptions:用于修正 options,是在插件里边修正装备的办法。
咱们上面提到了一些生疏的概念:visitor、path、state,现在让咱们一起来知道它们:
- visitor 拜访者
这个姓名来源于设计模式中的拜访者模式(en.wikipedia.org/wiki/Visito… 简略的说它便是一个目标,指定了在遍历 AST 进程中,拜访指定节点时应该被调用的办法。
-
假如咱们有这样一段程序:
functionfoo(){ return'string' }
-
这段代码对应的 AST 如下:
-Program -FunctionDeclaration(body[0]) -Identifier(id) -BlockStatement(body) -ReturnStatement(body[0]) -StringLiteral(arugument)
-
当咱们对这颗 AST 进行深度优先遍历时,每次拜访 StringLiteral 都会调用 visitor.StringLiteral。
当 visitor.StringLiteral 是一个函数时,它将在向下遍历的进程中被调用(即进入阶段)。当 visitor.StringLiteral 是一个目标时({ enter(path, state) {}, exit(path, state) {} }
),visitor.StringLiteral.enter 将在向下遍历的进程中被调用(进入阶段),visitor.StringLiteral.exit 将在向上遍历的进程中被调用(退出阶段)。
- Path 途径
Path 用于表明两个节点之间衔接的目标,这是一个可操作和拜访的巨大可变目标。
Path 之间的关系如图所示:
除了能在 Path 目标上拜访到当时 AST 节点、父级 AST 节点、父级 Path 目标,还能拜访到增加、更新、移动和删除节点等其他办法,这些办法提高了咱们对 AST 增修正的效率。
- State 状况
在实践编写插件的进程中,某一类型节点的处理或许需求依赖其他类型节点的处理结果,但由于 visitor 特点之间互不关联,因而需求 state 协助咱们在不同的 visitor 之间传递状况。
一种处理办法是运用递归,并将状况往下层传递:
constanotherVisitor={
Identifier(path){
console.log(this.someParam)//=>'xxx'
}
};
constMyVisitor={
FunctionDeclaration(path,state){
//state.cwd:当时执行目录
//state.opts:插件options
//state.filename:当时文件名(绝对途径)
//state.file:BabelFile目标,包括当时整个ast,当时文件内容code,etc.
//state.key:当时插件姓名
path.traverse(anotherVisitor,{someParam:'xxx'});
}
};
另外一种传递状况的办法是将状况直接设置到 this 上,Babel 会给 visitor 上的每个办法绑定 this。在 Babel 插件中,this 一般会被用于传递状况:从 pre 到 visitor 再到 post。
exportdefaultfunction({types:t}){
return{
pre(state){
this.cache=newMap();
},
visitor:{
StringLiteral(path){
this.cache.set(path.node.value,1);
}
},
post(state){
console.log(this.cache);
}
};
}
常用的 API
Babel 没有完好的文档讲解一切的 api,因而下面会罗列一些或许还算常用的 api(并不是一切,主要是 path 和 types 上的办法或特点),咱们并不需求悉数背下来,在你需求用的时分,能找到对应的办法即可。
你能够经过 babel 的 typescript 类型界说找到以下罗列的特点和办法,还能够经过Babel Handbook找到它们的详细运用办法。
Babel Handbook:astexplorer.net/
- 查询
-
- path.node:拜访当时节点
- path.get():获取特点内部的 path
- path.inList:判别途径是否有同级节点
- path.key:获取途径所在容器的索引
- path.container:获取途径的容器(包括一切同级节点的数组)
- path.listKey:获取容器的key
- path.getSibling():取得同级途径
- path.findParent():关于每一个父途径调用 callback 并将其 NodePath 当作参数,当 callback 回来真值时,则将其 NodePath 回来
- path.find():与 path.findParent 的区别是,该办法会遍历当时节点
- 遍历
-
- path.stop():越过遍历当时途径的子途径
- path.skip():完全停止遍历
- 判别
-
- types.isXxx():查看节点的类型,如 types.isStringLiteral(path.node)
- path.isReferencedIdentifier():查看标识符(Identifier)是否被引证
- 增修正
-
- path.replaceWith():替换单个节点
- path.replaceWithMultiple():用多节点替换单节点
- path.replaceWithSourceString():用字符串源码替换节点
- path.insertBefore() / path.insertAfter():刺进兄弟节点
- path.get(‘listKey’).unshiftContainer() / path.get(‘listKey’).pushContainer():刺进一个节点到数组中,如 body
- path.remove():删除一个节点
- 效果域
-
- path.scope.hasBinding(): 从当时效果域开端向上查找变量
- path.scope.hasOwnBinding():仅在当时效果域中查找变量
- path.scope.generateUidIdentifier():生成一个仅有的标识符,不会与任何本地界说的变量相冲突
- path.scope.generateUidIdentifierBasedOnNode():依据某个节点创立仅有的标识符
- path.scope.rename():重命名绑定及其引证
AST Explorer
在 @babel/types 的类型界说中,能够找到一切 AST 节点类型。咱们不需求记住一切节点类型,社区内有一个 AST 可视化东西能够协助咱们剖析 AST:axtexplorer.net。
在这个网站的左侧,能够输入咱们想要剖析的代码,在右侧会主动生成对应的 AST。当咱们在左侧代码区域点击某一个节点,比方函数名 foo,右侧 AST 会主动跳转到对应的 Identifier AST 节点,并高亮展现。
咱们还能够修正要 parse 的言语、运用的 parser、parser 参数等。
自己完结一个插件吧
现在让咱们来完结一个简略的插件吧!以下是插件需求完结的功用:
- 将代码里重复的字符串字面量(StringLiteral)提升到顶层效果域。
- 承受一个参数 minCount,它是 number 类型,假如某个字符串字面量重复次数大于等于 minCount 的值,则将它提升到顶层效果域,否则不做任何处理。
因而,关于以下输入:
consts1="foo";
consts2="foo";
consts3="bar";
functionf1(){
consts4="baz";
if(true){
consts5="baz";
}
}
应该输出以下代码:
var_foo="foo",
_baz="baz";
consts1=_foo;
consts2=_foo;
consts3="bar";
functionf1(){
consts4=_baz;
if(true){
consts5=_baz;
}
}
经过 astexplorer.net/,咱们发现代码里的字符… AST 上对应的节点叫做 StringLiteral,假如想要拿到代码里一切的字符串而且计算每种字符串的数量,就需求遍历 StringLiteral 节点。
咱们需求一个目标用于存储一切 StringLiteral,key 是 StringLiteral 节点的 value 特点值,value 是一个数组,用于存储具有相同 path.node.value 的一切 path 目标,最终把这个目标存到 state 目标上,以便于在遍历结束时能计算相同字符串的重复次数,从而能够判别哪些节点需求被替换为一个标识符。
exportdefaultfunction(){
return{
visitor:{
StringLiteral(path,state){
state.stringPathMap=state.stringPathMap||{};
constnodes=state.stringPathMap[path.node.value]||[];
nodes.push(path);
state.stringPathMap[path.node.value]=nodes;
}
}
};
}
经过 astexplorer.net/ 咱们发现假如想要往顶层效果域中刺进一个变量,其实便是往 Program 节点的 body 上刺进 AST 节点。Program 节点也是 AST 的顶层节点,在遍历进程的退出阶段,Program 节点是最终一个被处理的,因而咱们需求做的工作是:依据搜集到的字符串字面量,别离创立一个坐落顶层效果域的变量,并将它们统一刺进到 Program 的 body 中,一起将代码中的字符串替换为对应的变量。
exportdefaultfunction(){
return{
visitor:{
StringLiteral(path,state){/**...*/},
Program:{
exit(path,state){
const{minCount=2}=state.opts||{};
for(const[string,paths]ofObject.entries(state.stringPathMap||{})){
if(paths.length<minCount){
continue;
}
constid=path.scope.generateUidIdentifier(string);
paths.forEach(p=>{
p.replaceWith(id);
});
path.scope.push({id,init:types.stringLiteral(string)});
}
},
},
}
};
}
完好代码
import{PluginPass,NodePath}from'@babel/core';
import{declare}from'@babel/helper-plugin-utils';
interfaceOptions{
/**
*当字符串字面量的重复次数大于或小于minCount,将会被提升到顶层效果域
*/
minCount?:number;
}
typeState=PluginPass&{
//以StringLiteral节点的value特点值为key,存放一切StringLiteral的Path目标
stringPathMap?:Record<string,NodePath[]>;
};
constHoistCommonString=declare<Options>(({assertVersion,types},options)=>{
//判别当时Babel版别是否为7
assertVersion(7);
return{
//插件姓名
name:'hoist-common-string',
visitor:{
StringLiteral(path,state:State){
//将一切StringLiteral节点对应的path目标搜集起来,存到state目标里,
//以便于在遍历结束时能计算相同字符串的重复次数
state.stringPathMap=state.stringPathMap||{};
constnodes=state.stringPathMap[path.node.value]||[];
nodes.push(path);
state.stringPathMap[path.node.value]=nodes;
},
Program:{
//将在遍历进程的退出阶段被调用
//Program节点是顶层AST节点,能够以为Program.exit是最终一个执行的visitor函数
exit(path,state:State){
//插件参数。还能够经过 state.opts 拿到插件参数
const{minCount=2}=options||{};
for(const[string,paths]ofObject.entries(state.stringPathMap||{})){
//关于重复次数少于minCount的Path,不做处理
if(paths.length<minCount){
continue;
}
//依据给定的字符串创立一个仅有的标识符
constid=path.scope.generateUidIdentifier(string);
//将一切相同的字符串字面量替换为上面生成的标识符
paths.forEach(p=>{
p.replaceWith(id);
});
//将标识符增加到顶层效果域中
path.scope.push({id,init:types.stringLiteral(string)});
}
},
},
},
};
});
测验插件
测验 Babel 插件有三种常用的办法:
- 测验转化后的 AST 结果,查看是否契合预期
- 测验转化后的代码字符串,查看是否契合预期(一般运用快照测验)
- 执行转化后的代码,查看执行结果是否契合预期
咱们一般运用第二种办法,配合babel-plugin-tester能够很好地协助咱们完结测验工作。配合 babel-plugin-tester,咱们能够比照输入输出的字符串、文件、快照。
importpluginTesterfrom'babel-plugin-tester';
importxxxPluginfrom'./xxxPlugin';
pluginTester({
plugin:xxxPlugin,
fixtures:path.join(__dirname,'__fixtures__'),
tests:{
//1.比照转化前后的字符串
//1.1输入输出完全一致时,能够简写
'doesnotchangecodewithnoidentifiers':'"hello";',
//1.2输入输出不一致
'changesthiscode':{
code:'varhello="hi";',
output:'varolleh="hi";',
},
//2.比照转化前后的文件
'usingfixturesfiles':{
fixture:'changed.js',
outputFixture:'changed-output.js',
},
//3.与上一次生成的快照做比照
'usingjestsnapshots':{
code:`
functionsayHi(person){
return'Hello'+person+'!'
}
`,
snapshot:true,
},
},
});
本文将以快照测验为例,以下是测验咱们插件的示例代码:
importpluginTesterfrom'babel-plugin-tester';
importHoistCommonStringfrom'../index';
pluginTester({
//插件
plugin:HoistCommonString,
//插件名,可选
pluginName:'hoist-common-string',
//插件参数,可选
pluginOptions:{
minCount:2,
},
tests:{
'usingjestsnapshots':{
//输入
code:`consts1="foo";
consts2="foo";
consts3="bar";
functionf1(){
consts4="baz";
if(true){
consts5="baz";
}
}`,
//运用快照测验
snapshot:true,
},
},
});
当咱们运转 jest 后(更多关于 jest 的介绍,能够查看jest 官方文档jestjs.io/docs/gettin… 会生成一个 snapshots 目录:
有了快照今后,每次迭代插件都能够跑一下单测以快速查看功用是否正常。快照的更新也很简略,只需求执行jest --updateSnapshot
。
运用插件
假如想要运用 Babel 插件,需求在装备文件里增加 plugins 选项,plugins 选项承受一个数组,值为字符串或许数组。以下是一些比方:
//.babelrc
{
"plugins":[
"babel-plugin-myPlugin1",
["babel-plugin-myPlugin2"],
["babel-plugin-myPlugin3",{/**插件options*/}],
"./node_modules/asdf/plugin"
]
}
Babel 对插件姓名的格局有必定的要求,比方最好包括 babel-plugin,假如不包括的话也会主动补充。以下是 Babel 插件姓名的主动补全规矩:
到这里,Babel 插件的学习就告一段落了,假如咱们想持续深化学习 Babel 插件,能够拜访Babel 的仓库(github.com/babel/babel… 这是一个 monorepo,里边包括了许多真实的插件,经过阅览这些插件,相信你必定能对 Babel 插件有更深化的理解!
参阅文档
Babel plugin handbook:github.com/jamiebuilds…
Babel 官方文档:babeljs.io/docs/en/
Babel 插件通关秘籍:/book/694611…
参加咱们
咱们来自字节跳动飞书商业应用研发部(Lark Business Applications),现在咱们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了工作区域。咱们重视的产品范畴主要在企业经营管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 范畴体系,也包括飞书批阅、OA、法务、财务、采购、差旅与报销等体系。欢迎各位参加咱们。
内推:欢迎扫码投递简历