|
从babel plugin插件入门到babel plugin import 源码解析
从babel plugin插件入门到babel plugin import 源码解析
钟泽方
贝壳产品技术
贝壳产品技术 “贝壳产品技术公众号”作为贝壳官方产品技术号,致力打造贝壳产品、技术干货分享平台,面向互联网/O2O开发/产品从业者,每周推送优质产品技术文章、技术沙龙活动及招聘信息等。欢迎大家关注我们。 242篇内容
2021年09月08日 19:32
babel 简介01编译原理js 作为解释型语言,编译的过程其实也就是 language-to-language,例如:ES6 语法转换成 ES5(@babel/preset-env),React jsx 转换成函数调用形式(React.createElement,@babel/preset-react),让一些语法糖或者更先进的语法可以在低版本的浏览器上跑起来。因此,前端所谓的编译也只是用到了编译原理的一些前端操作: 词法解析和语法解析。Babel 就是这么一个编译器。02Babel 架构Babel 分为三个处理步骤:解析(parse),转换(transform),生成(generate)。其中,解析指的是词法解析,转换指的是使用语法解析器生成 AST,生成指的是使用第二步生成的 AST 重新生成代码。Babel 插件的工作就是在转换阶段介入的。Babel 是一个微内核架构,大部分工作是由插件来完成的。03Preset 和 Plugin语言转换的工作其实就是由 Plugin 完成的,事实上,Babel 的 Preset 就是 Plugin 的集合,例如 @babel/preset-react,它返回的其实是一个元素为 Babel 插件的数组:babel 插件简介01访问者模式Babel 在转换阶段会遍历每一个 AST 结点,并将每一个结点都传递给插件,插件根据自己的需要选择合适的切入点进行操作,我们每进入一个节点,实际上是说我们在访问它们,设计模式里有一个访问者模式的概念。这个模式的基本想法如下:首先我们拥有一个由许多对象构成的对象结构,这些对象的类都拥有一个accept方法用来接受访问者对象;访问者是一个接口,它拥有一个visit方法,这个方法对访问到的对象结构中不同类型的元素作出不同的反应;在对象结构的一次访问过程中,我们遍历整个对象结构,对每一个元素都实施accept方法,在每一个元素的accept方法中回调访问者的visit方法,从而使访问者得以处理对象结构的每一个元素。我们可以针对对象结构设计不同的实在的访问者类来完成不同的操作。——摘自维基百科下面我们来模拟一个访问者模式://AcceptorsstartclassHead{constructor(){this.name='head';}accept(visitor){visitor.visitHead&visitor.visitHead(this);}}classFoot{constructor(){this.name='foot';}accept(visitor){visitor.visitFoot&visitor.visitFoot(this);}}classFur{constructor(){this.name='fur';}accept(visitor){visitor.visitFur&visitor.visitFur(this);}}classCat{constructor(){this.elements=[newHead(),newFoot(),newFur()];}accept(visitor){for(letelementofthis.elements){element.accept(visitor);}}}//Acceptorsend//VisitorsstartclassHuman{constructor(name){this.name=name;}visitHead(element){console.log(`${this.name}kissedandvisit${element.name}`);}visitFoot(element){console.log(`${this.name}shookandvisit${element.name}`);}visitFur(element){console.log(`${this.name}touchedandvisit${element.name}`);}}//Visitorsendconstcat=newCat();constlucy=newHuman('lucy');constjohn=newHuman('john');cat.accept(lucy);cat.accept(john);运行结果:02Babel 插件结构Babel 插件最外层是一个CommonJs导出一个函数,这个函数再返回一个访问者对象,我们先定义一个最简单的访问者对象://可以直接定义对象及其成员方法constMyVisitor={Identifier(){console.log("Visitingidentifier.")}}//也可以先定义一个对象,再给对象添加方法constMyVisitor={}MyVisitor.Identifier=function(){console.log("Visitingidentifier.")}真实的插件的访问者对象是另外一个对象的 value,它的 key 是 visitor,然后我们将插件补充完整:module.exports=function(){return{visitor:{Identifier(){console.log("Visitingidentifier.")}}}}这就是一个完整的 Babel 插件,它在每次进入一个标识符(Identifier)的时候会打印一个字符串。Babel 遍历到节点的时候,会有两个生命周期,一个是进入(enter),另一个是离开(exit)上述写法是下面代码的简写:module.exports=function(){return{visitor:{Identifier:{enter(){console.log("Visitingidentifier.")}}}}}完整的生命周期应该如下:module.exports=function(){return{visitor:{Identifier:{enter(){console.log("Visitingidentifier.")},exit(){console.log("Leavingidentifier.")}}}}}03插件调试这里介绍一种使用VsCode调试Node程序的方式。//首先我们创建一个工程mkdirbabel-plugin-showcdbabel-plugin-show//创建一个源码文件夹mkdirbabel-project//创建一个babel插件文件夹mkdirbabel-plugin-mytest目前我们的工程文件结构是这样的;下面把我们上面介绍的插件写到插件文件夹里:cdbabel-plugin-mytestvimindex.js//这里做一些功能的增强,可以把标识符反转module.exports=function(){return{visitor:{Identifier(path){constname=path.node.namepath.node.name=name.split('').reverse().join()}}}}然后在源码文件夹里进行初始化和写代码:cd..cdbabel-project//初始化包管理工具yarninit-y//安装babel-cli和babel-coreyarnadd@babel/cli@babel/core-D//新增babel配置vimbabel.config.js//指定使用我们上面写的插件//创建一个文件,写入源码vimindex.js//写入如下代码letjava=46letbeike='beike'functionbarz(){}最后我们添加一个 vscode 配置文件来告诉它如何来启动这个项目:cd..mkdir.vscodetouchlaunch.json通过 vscode 来打开launch.json,选择需要添加的配置:我们就会得到一个配置框架,然后将里面的内容修改成我们需要的样子,我们这里修改了 program、cwd、args,相信大家一眼就能看出这些字段代表的意义,这里不再赘述:接下来我们点击启动按钮:我们打开源码和目标代码,可以看到目标代码里的标识符(Identifier)都被反转了,同时可以在下面调试控制台看到启动04APIBabel 其实是一组模块的集合,这里解释一些主要的模块。babylonbabylon 是 Babel 的解析器,可以将源码解析成 AST。constbabylon=require('babylon');constcode=`functionsquare(n){returnn*n}`;//输出AST对象console.log(babylon.parse(code));babel-traversebabel-traverse 模块遍历 AST,同时维护了整棵树的状态,并且负责替换、移除和添加节点。constbabylon=require('babylon');consttraverse=require('babel-traverse').defaultconstcode=`functionsquare(n){returnn*n}`;constast=babylon.parse(code)traverse(ast,{enter(path){if(path.node.type==="Identifier"&path.node.name==="n"){console.log('called...')}}});babel-typesBabel Types模块是一个用于 AST 节点的 Lodash 式工具库(译注:Lodash 是一个 JavaScript 函数工具库,提供了基于函数式编程风格的众多工具函数), 它包含了构造、验证以及变换 AST 节点的方法。该工具库包含考虑周到的工具方法,对编写处理AST逻辑非常有用。constbabylon=require('babylon');consttraverse=require('babel-traverse').default;constt=require('babel-types');constcode=`functionsquare(n){returnn*n}`;constast=babylon.parse(code);traverse(ast,{enter(path){if(t.isIdentifier(path.node,{name:'n'})){console.log('called...')}},});05其它需要了解的概念pathAST 是由一个个节点构成的,然后只有 AST 节点信息(下面称为 node)我们仍然难以操作 AST,path 是一种增强的 node 数据结构,它可以表示出节点之前的关联关系(例如:parent、parentPath、parentKey),同时还会自带一些操作函数(例如:traverse),我们定义的 visitors 接受的参数实际上就是 path 而非 node。Bindings当编写一个转换时,必须小心作用域,确保改变代码不会影响源码的功能。这里第 2 行就是一个 binding,第 4 和第 7 行都是这个 binding 的引用。functionscopeOnce(){varref="Thisisabinding";ref;//ThisisareferencetoabindingfunctionscopeTwo(){ref;//Thisisareferencetoabindingfromalowerscope}}babel-plugin-import 源码解析01Entry(src/index.js)首先看下面的代码,插件返回了一个对象,这个对象有一个Program元素,代表在 Program 这个节点的访问者,这个节点是整个程序的入口,AST 的根节点,在这个节点可以做一些初始化和释放资源的操作:constret={visitor:{Program},};returnretProgram 的具体实现如下所示,定义了一个 plugins 来管理不同 options 下的多个 Plugin 实例(真正实现功能的代码,下面会分析 Plugin 源码),options 可以是一个对象,也可以是一个数组。letplugins=null;constProgram={enter(path,{opts={}}){//Initplugininstancesonce.if(!plugins){if(Array.isArray(opts)){plugins=opts.map(({libraryName,libraryDirectory,style,styleLibraryDirectory,customStyleName,camel2DashComponentName,camel2UnderlineComponentName,fileName,customName,transformToDefaultImport,},index,)=>{assert(libraryName,'libraryNameshouldbeprovided');returnnewPlugin(libraryName,libraryDirectory,style,styleLibraryDirectory,customStyleName,camel2DashComponentName,camel2UnderlineComponentName,fileName,customName,transformToDefaultImport,types,index,);},);}else{assert(opts.libraryName,'libraryNameshouldbeprovided');plugins=[newPlugin(opts.libraryName,opts.libraryDirectory,opts.style,opts.styleLibraryDirectory,opts.customStyleName,opts.camel2DashComponentName,opts.camel2UnderlineComponentName,opts.fileName,opts.customName,opts.transformToDefaultImport,types,),];}}applyInstance('ProgramEnter',arguments,this);//eslint-disable-line},exit(){applyInstance('ProgramExit',arguments,this);//eslint-disable-line},};其中有一个工具函数 applyInstance,它接受一个方法名,调用函数和上下文,它实现的是将外层 visitors 拿到的参数传给所有 plugin 对应的 visitors 调用一遍。functionapplyInstance(method,args,context){//eslint-disable-next-lineno-restricted-syntaxfor(constpluginofplugins){if(plugin[method]){plugin[method].apply(plugin,[...args,context]);}}}然后向 ret.visitor 填充 Plugin 构造函数实现的方法:constmethods=['ImportDeclaration','CallExpression','MemberExpression','Property','VariableDeclarator','ArrayExpression','LogicalExpression','ConditionalExpression','IfStatement','ExpressionStatement','ReturnStatement','ExportDefaultDeclaration','BinaryExpression','NewExpression','ClassDeclaration','SwitchStatement','SwitchCase',];for(constmethodofmethods){ret.visitor[method]=function(){//eslint-disable-lineapplyInstance(method,arguments,ret.visitor);//eslint-disable-line};}02Plugin(src/Plugin.js)我们先看看它的大致结构,红色框内是 visitors,黄色框里是几个内部函数,最后还有一个 constructor 来保存 options 传入的信息。接下来我们按照程序运行的顺序来分析代码。constructor构造函数保存 options 传入的值,注意下面有一个 pluginStateKey,每一个 options 都有一个自己独立的 key 值,这个 key 在下面的 getPluginState 中会用到。constructor(libraryName,libraryDirectory,style,styleLibraryDirectory,customStyleName,camel2DashComponentName,camel2UnderlineComponentName,fileName,customName,transformToDefaultImport,types,index=0,){this.libraryName=libraryName;this.libraryDirectory=typeoflibraryDirectory==='undefined''lib':libraryDirectory;this.camel2DashComponentName=typeofcamel2DashComponentName==='undefined'true:camel2DashComponentName;this.camel2UnderlineComponentName=camel2UnderlineComponentName;this.style=style||false;this.styleLibraryDirectory=styleLibraryDirectory;this.customStyleName=normalizeCustomName(customStyleName);this.fileName=fileName||'';this.customName=normalizeCustomName(customName);this.transformToDefaultImport=typeoftransformToDefaultImport==='undefined'true:transformToDefaultImport;this.types=types;this.pluginStateKey=`importPluginState${index}`;}getPluginState在全局 state 里保存当前 option 的上下文,具体的做法是在实例中保存一个 key 值,然后在全局 state 使用这个 key 值保存一个对象,里面的内容例如:需要替换的库名。getPluginState(state){if(!state[this.pluginStateKey]){state[this.pluginStateKey]={};//eslint-disable-line}returnstate[this.pluginStateKey];}这个状态的结构在 ProgramEnter 里构建出来:ProgramEnterProgramEnter(path,state){constpluginState=this.getPluginState(state);pluginState.specified=Object.create(null);pluginState.libraryObjs=Object.create(null);pluginState.selectedMethods=Object.create(null);pluginState.pathsToRemove=[];}上面两个方法即为一个 Plugin 实例的初始化过程,接下来我们沿着程序的执行路径来分析剩下的代码。首先看一下我们针对 babel-plugin-import 的配置,options 只是一个对象,是一个最简单的配置,指定了 libraryName 和 style:{"plugins":[["../babel-plugin-import/lib/index",{"libraryName":"antd","style":true}]]}我们的代码是从 import 一个库开始的,所以我们先写一行代码,import 一个 antd 组件,此时,Plugin 的 ImportDeclaration 会被调用:import{Button,TableHeader}from'antd'ImportDeclaration在这个 visitor 里通过 path 把当前语句里的 source 拆解出来,如果 source 匹配上了我们在 options 里配置的 libraryName,则将语句里的 specifiers 拆解出来保存在 pluginState 里。第 12-20 行是在全局 state 里将 specifiers 缓存下来,在这个例子中我们只需要关注 14-16行,16-19 行在下面 全量引用 中进行分析,第 21 行是将当前 import 语句的 path 保存下来,以便在生命周期结束前将这行代码删除。ImportDeclaration(path,state){const{node}=path;//pathmayberemovedbyprevinstances.if(!node)return;const{value}=node.source;const{libraryName}=this;const{types}=this;constpluginState=this.getPluginState(state);if(value===libraryName){node.specifiers.forEach(spec=>{//import{Button}from'antd'if(types.isImportSpecifier(spec)){pluginState.specified[spec.local.name]=spec.imported.name;}else{//importAntfrom'antd'pluginState.libraryObjs[spec.local.name]=true;}});pluginState.pathsToRemove.push(path);}}这是当前 path.node.source 的结构:这是当前 path.node.specifier 其中一个成员的结构:执行完成后,pluginState 的结构如下:接下来我们尝试在 jsx 语法里使用这两个组件,同时,babelrc 需要适配 jsx 语法,配置一个 preset-react:{"plugins":[["../babel-plugin-import/lib/index",{"libraryName":"antd","style":true}]],"presets":["@babel/preset-react"]}代码执行到第 6 行时,此时 CallExpression visitor 会被调用:import{Button,TableHeader}from'antd';constComponent=()=>{return(TextOk);};CallExpression为什么会调用这个 visitor 因为 jsx 语法转换成了函数调用的方式,即:Text=>React.createElement(TableHeader,{type:'header',},'Text')第 2-6 行也是一个拆解 path 的过程,注意其中的 types 其实就是上面提到的 babel-types 工具,当前代码会在第 8 行判非,不会走到 8-12 行,14-25 行遍历函数的参数,15 行将参数名取出,然后在 17 行判断当前参数是否在全局 state 里注册过,即这个参数名是否跟 import 进来的某个参数名一致,第 18 行判断这个参数名是否引用一个 binding,第 19 行判断这个参数的绑定类型是否是一个 ImportSpecifier,如果判断为真,则调用 importMethod。node.arguments 结构如下:CallExpression(path,state){const{node}=path;constfile=(path&path.hub&path.hub.file)||(state&state.file);const{name}=node.callee;const{types}=this;constpluginState=this.getPluginState(state);if(types.isIdentifier(node.callee)){if(pluginState.specified[name]){node.callee=this.importMethod(pluginState.specified[name],file,pluginState);}}node.arguments=node.arguments.map(arg=>{const{name:argName}=arg;if(pluginState.specified[argName]&path.scope.hasBinding(argName)&path.scope.getBinding(argName).path.type==='ImportSpecifier'){returnthis.importMethod(pluginState.specified[argName],file,pluginState);}returnarg;});}importMethodimportMethod 是一个内部函数,它的功能是根据 methodName 来给文件添加 import 绝对路径的语句。第 3-9 行是根据 options 和 methodName(如:TableHeader) 计算出这个组件在包中的绝对路径,经过转换后的值如下:transformedMethodName:table-headerpath:antd/lib/table-header第 14-15 行很关键,这里开始添加 import 代码了,根据配置 transformToDefaultImport 来选择不同的转换方式,addDefault 和 addName 引用自@babel/helper-module-imports,如果调用的是 addDefault,那么就会给这个文件添加一句 import 语句,例如在本例中,会给文件添加一句:import _TableHeader from 'antd/lib/table-header';第 17-33 行是添加引用样式的 import 语句,针对我们的配置,生效的是第 25-27 行,同样,addSideEffect 也是引用自 @babel/helper-module-imports,在本例中,调用 addEffect,会给文件添加一句:import _TableHeader from 'antd/lib/table-header';importMethod(methodName,file,pluginState){if(!pluginState.selectedMethods[methodName]){const{style,libraryDirectory}=this;consttransformedMethodName=this.camel2UnderlineComponentNametransCamel(methodName,'_'):this.camel2DashComponentNametransCamel(methodName,'-'):methodName;constpath=winPath(this.customNamethis.customName(transformedMethodName,file):join(this.libraryName,libraryDirectory,transformedMethodName,this.fileName),);pluginState.selectedMethods[methodName]=this.transformToDefaultImportaddDefault(file.path,path,{nameHint:methodName}):addNamed(file.path,methodName,path);if(this.customStyleName){conststylePath=winPath(this.customStyleName(transformedMethodName));addSideEffect(file.path,`${stylePath}`);}elseif(this.styleLibraryDirectory){conststylePath=winPath(join(this.libraryName,this.styleLibraryDirectory,transformedMethodName,this.fileName),);addSideEffect(file.path,`${stylePath}`);}elseif(style===true){addSideEffect(file.path,`${path}/style`);}elseif(style==='css'){addSideEffect(file.path,`${path}/style/css`);}elseif(typeofstyle==='function'){conststylePath=style(path,file);if(stylePath){addSideEffect(file.path,stylePath);}}}return{...pluginState.selectedMethods[methodName]};}ProgramExit最后会在这个函数里把原始代码里的 import 语句删除:ProgramExit(path,state){this.getPluginState(state).pathsToRemove.forEach(p=>!p.removed&p.remove());}全量引用回到 ImportDeclaration 方法的第 16-19 行,当遇到一个全量引用语句时会走到 16 行的分支里:importAntdfrom'antd';...ImportDeclaration(path,state){const{node}=path;//pathmayberemovedbyprevinstances.if(!node)return;const{value}=node.source;const{libraryName}=this;const{types}=this;constpluginState=this.getPluginState(state);if(value===libraryName){node.specifiers.forEach(spec=>{//import{Button}from'antd'if(types.isImportSpecifier(spec)){pluginState.specified[spec.local.name]=spec.imported.name;}else{//importAntfrom'antd'pluginState.libraryObjs[spec.local.name]=true;}});//收集当前需要删除的节点,后续会用新结点替换pluginState.pathsToRemove.push(path);}}16 行 else 分支执行完成以后,可以看到是把这条 import 语句的变量名保存在了 libraryObjs 里。下面模拟一下使用全量引用的方式调用组件:importAntdfrom'antd';constAntButton=Antd.ButtonBabel 解析到第 2 行时,会触发 MemberExpression visitor。node.object.name 即:Antd。第 9 行判断当对象名跟存储下来的 libraryObjs 匹配时,调用 path.replaceWith 函数,它可以将 Antd.Button 转换成 _Button。在 replaceWith 函数生效前,调用了 importMethod 函数,将 import 语句也替换掉了。11-16 行匹配的是另外一种语法,原理跟第 10 行基本相同:import{defaultasAntd}from'antd';constAntButton=Antd.ButtonMemberExpression(path,state){const{node}=path;constfile=(path&path.hub&path.hub.file)||(state&state.file);constpluginState=this.getPluginState(state);//multipleinstancecheck.if(!node.object||!node.object.name)return;if(pluginState.libraryObjs[node.object.name]){path.replaceWith(this.importMethod(node.property.name,file,pluginState));}elseif(pluginState.specified[node.object.name]&path.scope.hasBinding(node.object.name)){const{scope}=path.scope.getBinding(node.object.name);//globalvariableinfilescopeif(scope.path.parent.type==='File'){node.object=this.importMethod(pluginState.specified[node.object.name],file,pluginState);}}}声明与表达式下面模拟一下定义的处理方式。假设有如下语法:import{Button}from'antd';letobj={AntButton:Button,};console.log(obj)Property visitoer 会被触发。Property对于声明语法,只是简单地调用了 buildDeclaratorHandler 。Property(path,state){const{node}=path;this.buildDeclaratorHandler(node,'value',path,state);}buildDeclaratorHandlerbuildDeclaratorHandler(node,prop,path,state){constfile=(path&path.hub&path.hub.file)||(state&state.file);const{types}=this;constpluginState=this.getPluginState(state);constcheckScope=targetNode=>pluginState.specified[targetNode.name]&path.scope.hasBinding(targetNode.name)&path.scope.getBinding(targetNode.name).path.type==='ImportSpecifier';if(types.isIdentifier(node[prop])&checkScope(node[prop])){node[prop]=this.importMethod(pluginState.specified[node[prop].name],file,pluginState);//eslint-disable-line}elseif(types.isSequenceExpression(node[prop])){node[prop].expressions.forEach((expressionNode,index)=>{if(types.isIdentifier(expressionNode)&checkScope(expressionNode)){node[prop].expressions[index]=this.importMethod(pluginState.specified[expressionNode.name],file,pluginState,);//eslint-disable-line}});}}6-9 是检查一个 node 是否是从指定 library import 出来的一个组件。11 行判断当前 node 的一个指定 prop 是否是从指定 library import 出来的一个组件,如果是,则将这个 prop 通过调用 importMethod 替换成新的语句,同时替换上面的 import 语句。在本例中,就是把 { AntButton: Button } 转换成 { AntButton: _Button }。第13行处理的是下面这种语法:import{Button,Table}from'antd';letcomp=(Button,Table);16-21 行将括号里的变量逐一分析并替换成新的字符串。ArrayExpressionArrayExpression(path,state){const{node}=path;constprops=node.elements.map((_,index)=>index);this.buildExpressionHandler(node.elements,props,path,state);}处理数组表达式,代码也很简单,仅仅是调用了一下 buildExpressionHandler。表达式通常会伴随着计算,一个表达式的运算分量有可能是一个或多个,可能是左值和右值,也可能是一个序列,所以参数中的 props 是一个字符串数组,指定需要检查的节点的 key 值。以 ArrayExpression 为例,考虑下面的语法:import{Button,Table}from'antd';letcomp=[Button,Table];ArrayExpression 在分析第三行的时候,运算分量就是一个序列,它的 key 值对应 index,所以传给 buildExpressionHandler 的 props 就是以 index 为元素的数组。buildExpressionHandlerbuildExpressionHandler(node,props,path,state){constfile=(path&path.hub&path.hub.file)||(state&state.file);const{types}=this;constpluginState=this.getPluginState(state);props.forEach(prop=>{if(!types.isIdentifier(node[prop]))return;if(pluginState.specified[node[prop].name]&types.isImportSpecifier(path.scope.getBinding(node[prop].name).path)){node[prop]=this.importMethod(pluginState.specified[node[prop].name],file,pluginState);}});}第 5 行是通过 props 来遍历 node 中需要检查的运算分量,第 6-9 行检查该运算分量是不是一个标识符,如果是再继续检查这个标识符的作用域,最后在第 11 行完成替换。其它语法剩下的语法就是枚举可能出现标识符的语法,分别调用 buildExpressionHandler 和 buildDeclaratorHandler 来进行节点的替换,原理跟上面的举例相同,这里不再赘述。总结babel-import-plugin 的流程是先在 import 语法 visitor 中解析特定的 libraryName,并将它 import 进来的所有成员的标识符都缓存下来,然后枚举可能出现这些标识符的语法解析它们包含的标识符,去跟缓存下来的标识符进行匹配,一旦匹配上就用新的格式来替换这个标识符,同时根据标识符的名字和包名生成从绝对路径 import 的语句,来将原始的 import 语句替换掉。
预览时标签不可点
FE33大前端69FE · 目录#FE上一篇初识WebAssembly下一篇ES2021新特性关闭更多小程序广告搜索「undefined」网络结果
|
|