数栈干货分享!babel-plugin-import最全源码详解
本文将带领大家解析babel-plugin-import 实现按需加载的完整流程,解开业界所认可 babel 插件的面纱。
首先供上babel-plugin-import插件
一、初见萌芽
首先 babel-plugin-import
是为了解决在打包过程中把项目中引用到的外部组件或功能库全量打包,从而导致编译结束后包容量过大的问题,如下图所示:
babel-plugin-import
插件源码由两个文件构成
- Index 文件即是插件入口初始化的文件,也是笔者在 Step1 中着重说明的文件
- Plugin 文件包含了处理各种 AST 节点的方法集,以 Class 形式导出
先来到插件的入口文件 Index :
首先 Index 文件导入了 Plugin ,并且有一个默认导出函数,函数的参数是被解构出的名叫 types 的参数,它是从 babel 对象中被解构出来的,types 的全称是 @babel/types
,用于处理 AST 节点的方法集。以这种方式引入后,我们不需要手动引入 @babel/types
。 进入函数后可以看见观察者( visitor )
中初始化了一个 AST 节点 Program
,这里对 Program
节点的处理使用完整插件结构,有进入( enter )与离开( exit )事件,且需注意:
一般我们缩写的 Identifier() { ... } 是 Identifier: { enter() { ... } } 的简写形式。
这里可能有同学会问 Program
节点是什么?见下方 const a = 1 对应的 AST 树 ( 简略部分参数 )
Program 相当于一个根节点,一个完整的源代码树。一般在进入该节点的时候进行初始化数据之类的操作,也可理解为该节点先于其他节点执行,同时也是最晚执行 exit 的节点,在 exit 时也可以做一些”善后“的工作。 既然 babel-plugin-import
的 Program
节点处写了完整的结构,必然在 exit 时也有非常必要的事情需要处理,关于 exit 具体是做什么的我们稍后进行讨论。 我们先看 enter ,这里首先用 enter 形参 state 结构出用户制定的插件参数,验证必填的 libraryName
[库名称] 是否存在。Index 文件引入的 Plugin 是一个 class 结构,因此需要对 Plugin 进行实例化,并把插件的所有参数与 @babel/types
全部传进去,关于 Plugin 类会在下文中进行阐述。 接着调用了 applyInstance
函数:
此函数的主要目的是继承 Plugin 类中的方法,且需要三个参数
- method(String):你需要从 Plugin 类中继承出来的方法名称
- args:(Arrray):[ Path, State ]
- PluginPass( Object):内容和 State 一致,确保传递内容为最新的 State
主要的目的是让 Program
的 enter 继承 Plugin 类的 ProgramEnter
方法,并且传递 path 与 state 形参至 ProgramEnter
。Program
的 exit 同理,继承的是 ProgramExit
方法。
现在进入 Plugin 类:
在入口文件实例化 Plugin 已经把插件的参数通过 constructor
后被初始化完毕啦,除了 libraryName
以外其他所有的值均有相应默认值,值得注意的是参数列表中的 customeName 与 customStyleName 可以接收一个函数或者一个引入的路径,因此需要通过 normalizeCustomName
函数进行统一化处理。
此函数就是用来处理当参数是路径时,进行转换并取出相应的函数。如果处理后 customeNameExports
仍然不是函数就导入 customeNameExports.default
,这里牵扯到 export default 是语法糖的一个小知识点。
回归代码,Step1 中入口文件 Program
的 Enter 继承了 Plugin 的 ProgramEnter
方法
ProgramEnter
中通过 getPluginState
**初始化 state 结构中的 importPluginState
对象,getPluginState
函数在后续操作中出现非常频繁,读者在此需要留意此函数的作用,后文不再对此进行赘述。 但是为什么需要初始化这么一个结构呢?这就牵扯到插件的思路。正像开篇流程图所述的那样 ,babel-plugin-import
具体实现按需加载思路如下:经过 import 节点后收集节点数据,然后从所有可能引用到 import 绑定的节点处执行按需加载转换方法。state 是一个引用类型,对其进行操作会影响到后续节点的 state 初始值,因此用 Program 节点,在 enter 的时候就初始化这个收集依赖的对象,方便后续操作。负责初始化 state 节点结构与取数据的方法正是 getPluginState
。 这个思路很重要,并且贯穿后面所有的代码与目的,请读者务必理解再往下阅读。
二、惟恍惟惚
借由 Step1,现在已经了解到插件以 Program
为出发点继承了 ProgramEnter
并且初始化了 Plugin 依赖,如果读者还有尚未梳理清楚的部分,请回到 Step1 仔细消化下内容再继续阅读。 首先,我们再回到外围的 Index 文件,之前只在观察者模式中注册了 Program
的节点,没有其他 AST 节点入口,因此至少还需注入 import 语句的 AST 节点类型 ImportDeclaration
创建一个数组并将 ImportDeclaration
置入,经过遍历调用 applyInstance
_ _和 Step1 介绍同理,执行完毕后 visitor 会变成如下结构
现在回归 Plugin,进入 ImportDeclaration
ImportDeclaration
会对 import 中的依赖字段进行收集,如果是名称空间引入或者是默认引入就设置为 { 别名 :true },解构导入就设置为 { 别名 :组件名 } 。getPluginState
方法在 Step1 中已经进行过说明。关于 import 的 AST 节点结构 用 babel-plugin 实现按需加载 中有详细说明,本文不再赘述。执行完毕后 pluginState 结构如下
这下 state.importPluginState
结构已经收集到了后续帮助节点进行转换的所有依赖信息。 目前已经万事俱备,只欠东风。东风是啥?是能让转换 import 工作开始的 action。在 用 babel-plugin 实现按需加载 中收集到依赖的同时也进行了节点转换与删除旧节点。一切工作都在 ImportDeclaration
节点中发生。而 babel-plugin-import
的思路是寻找一切可能引用到 Import 的 AST 节点,对他们全部进行处理。有部分读者也许会直接想到去转换引用了 import 绑定的 JSX 节点,但是转换 JSX 节点的意义不大,因为可能引用到 import 绑定的 AST 节点类型 ( type ) 已经够多了,所有应尽可能的缩小需要转换的 AST 节点类型范围。而且 babel 的其他插件会将我们的 JSX 节点进行转换成其他 AST type,因此能不考虑 JSX 类型的 AST 树,可以等其他 babel 插件转换后再进行替换工作。其实下一步可以开始的入口有很多,但还是从咱最熟悉的 React.createElement 开始。
JSX 转换后 AST 类型为 CallExpression
(函数执行表达式),结构如下所示,熟悉结构后能方便各位同学对之后步骤有更深入的理解。
因此我们进入 CallExpression 节点处,继续转换流程。
可以看见源码调用了importMethod
两次,此函数的作用是触发 import 转换成按需加载模式的 action,并返回一个全新的 AST 节点。因为 import 被转换后,之前我们人工引入的组件名称会和转换后的名称不一样,因此 importMethod
需要把转换后的新名字(一个 AST 结构)返回到我们对应 AST 节点的对应位置上,替换掉老组件名。函数源码稍后会进行详细分析。 回到一开始的问题,为什么 CallExpression
需要调用 importMethod
函数?因为这两处表示的意义是不同的,CallExpression
节点的情况有两种:
- 刚才已经分析过了,这第一种情况是 JSX 代码经过转换后的 React.createElement
- 我们使用函数调用一类的操作代码的 AST 也同样是
CallExpression
类型,例如:
因此在 CallExpression
中首先会判断 node.callee 值是否是 Identifier
,如果正确则是所述的第二种情况,直接进行转换。若否,则是 React.createElement 形式,遍历 React.createElement 的三个参数取出 name,再判断 name 是否是先前 state.pluginState 收集的 import 的 name,最后检查 name 的作用域情况,以及追溯 name 的绑定是否是一个 import 语句。这些判断条件都是为了避免错误的修改函数原本的语义,防止错误修改因闭包等特性的块级作用域中有相同名称的变量。如果上述条件均满足那它肯定是需要处理的 import 引用了。让其继续进入importMethod
转换函数,importMethod
需要传递三个参数:组件名,File(path.sub.file),pluginState
进入函数后,先别着急看代码,注意这里引入了两个包:path.join 和 @babel/helper-module-imports ,引入 join 是为了处理按需加载路径快捷拼接的需求,至于 import 语句转换,肯定需要产生全新的 import AST 节点实现按需加载,最后再把老的 import 语句删除。而新的 import 节点使用 babel 官方维护的 @babel/helper-module-imports
生成。现在继续流程,首先无视一开始的 if 条件语句,稍后会做说明。再捋一捋 import 处理函数中需要处理的几个环节:
- 对引入的组件名称进行修改,默认转换以“-”拼接单词的形式,例如:DatePicker 转换为 date-picker,处理转换的函数是 transCamel。
转换到组件所在的具体路径,如果插件用户给定了自定义路径就使用 customName 进行处理,babel-plugin-import
为什么不提供对象的形式作为参数?因为 customName 修改是以 transformedMethodName 值作为基础并将其传递给插件使用者,如此设计就可以更精确的匹配到需要按需加载的路径。处理这些动作的函数是 withPath,withPath 主要兼容 Linux 操作系统,将 Windows 文件系统支持的 '\' 统一转换为 '/'。
对 transformToDefaultImport 进行判断,此选项默认为 true,转换后的 AST 节点是默认导出的形式,如果不想要默认导出可以将 transformToDefaultImport 设置为 false,之后便利用 @babel/helper-module-imports
生成新的 import 节点,最后**函数的返回值就是新 import 节点的 default Identifier,替换掉调用 importMethod 函数的节点,从而把所有引用旧 import 绑定的节点替换成最新生成的 import AST 的节点。
最后,根据用户是否开启 style 按需引入与 customStyleName 是否有 style 路径额外处理,以及 styleLibraryDirectory(style 包路径)等参数处理或生成对应的 css 按需加载节点。
到目前为止一条最基本的转换线路已经转换完毕了,相信大家也已经了解了按需加载的基本转换流程,回到 importMethod 函数一开始的if 判断语句,这与我们将在 step3 中的任务息息相关。现在就让我们一起进入 step3。
三、了如指掌
在 step3 中会进行按需加载转换最后的两个步骤:
- 引入 import 绑定的引用肯定不止 JSX 语法,还有其他诸如,三元表达式,类的继承,运算,判断语句,返回语法等等类型,我们都得对他们进行处理,确保所有的引用都绑定到最新的 import,这也会导致importMethod 函数被重新调用,但我们肯定不希望 import 函数被引用了 n 次,生成 n 个新的 import 语句,因此才会有先前的判断语句。
- 一开始进入
ImportDeclaration
收集信息的时候我们只是对其进行了依赖收集工作,并没有删除节点。并且我们尚未补充 Program 节点 exit 所做的 action
接下来将以此列举需要处理的所有 AST 节点,并且会给每一个节点对应的接口(Interface)与例子(不关注语义):
MemberExpression
MemberExpression(属性成员表达式),接口如下
如果插件的选项中没有关闭 transformToDefaultImport ,这里会调用 importMethod 方法并返回@babel/helper-module-imports
给予的新节点值。否则会判断当前值是否是收集到 import 信息中的一部分以及是否是文件作用域下的全局变量,通过获取作用域查看其父节点的类型是否是 File,即可避免错误的替换其他同名变量,比如闭包场景。
VariableDeclarator
VariableDeclarator(变量声明),非常方便理解处理场景,主要处理 const/let/var 声明语句
本例中出现 buildDeclaratorHandler 方法,主要确保传递的属性是基础的 Identifier 类型且是 import 绑定的引用后便进入 importMethod 进行转换后返回新节点覆盖原属性。
ArrayExpression
ArrayExpression(数组表达式),接口如下所示
本例的处理和刚才的其他节点不太一样,因为数组的 Element 本身就是一个数组形式,并且我们需要转换的引用都是数组元素,因此这里传递的 props 就是类似 [0, 1, 2, 3] 的纯数组,方便后续从 elements 中进行取数据。这里进行具体转换的方法是 buildExpressionHandler,在后续的 AST 节点处理中将会频繁出现
首先对 props 进行遍历,同样确保传递的属性是基础的 Identifier
类型且是 import 绑定的引用后便进入 importMethod 进行转换,和之前的 buildDeclaratorHandler 方法差不多,只是 props 是数组形式
LogicalExpression
LogicalExpression(逻辑运算符表达式)
主要取出逻辑运算符表达式的左右两边的变量,并使用 buildExpressionHandler 方法进行转换
ConditionalExpression
ConditionalExpression(条件运算符)
主要取出类似三元表达式的元素,同用 buildExpressionHandler 方法进行转换。
IfStatement
IfStatement(if 语句)
这个节点相对比较特殊,但笔者不明白为什么要调用两次 buildExpressionHandler ,因为笔者所想到的可能性,都有其他的 AST 入口可以处理。望知晓的读者可进行科普。
ExpressionStatement
ExpressionStatement(表达式语句)
ReturnStatement
ReturnStatement(return 语句)
ExportDefaultDeclaration
ExportDefaultDeclaration(导出默认模块)
BinaryExpression
BinaryExpression(二元操作符表达式)
NewExpression
NewExpression(new 表达式)
ClassDeclaration
ClassDeclaration(类声明)
Property
Property(对象的属性值)
处理完 AST 节点后,删除掉原本的 import 导入,由于我们已经把旧 import 的 path 保存在 pluginState.pathsToRemove 中,最佳的删除的时机便是 ProgramExit
,使用 path.remove() 删除。
恭喜各位坚持看到现在的读者,已经到最后一步啦,把我们所处理的所有 AST 节点类型注册到观察者中
到此已经完整分析完 babel-plugin-import
的整个流程,读者可以重新捋一捋处理按需加载的整个处理思路,其实抛去细节,主体逻辑还是比较简单明了的。
四、一些思考
笔者在进行源码与单元测试的阅读后,发现插件并没有对 Switch 节点进行转换,遂向官方仓库提了 PR,目前已经被合入 master 分支,读者有任何想法,欢迎在评论区畅所欲言。 笔者主要补了 SwitchStatement
,SwitchCase
与两个 AST 节点处理。
SwitchStatement
SwitchCase
五、小小总结
这是笔者第一次写源码解析的文章,也因笔者能力有限,如果有些逻辑阐述的不够清晰,或者在解读过程中有错误的,欢迎读者在评论区给出建议或进行纠错。
现在 babel 其实也出了一些 API 可以更加简化 babel-plugin-import 的代码或者逻辑,例如:path.replaceWithMultiple ,但源码中一些看似多余的逻辑一定是有对应的场景,所以才会被加以保留。
此插件经受住了时间的考验,同时对有需要开发 babel-plugin 的读者来说,也是一个非常好的事例。不仅如此,对于功能的边缘化处理以及操作系统的兼容等细节都有做完善的处理。
如果仅仅需要使用babel-plugin-import ,此文展示了一些在 babel-plugin-import 文档中未暴露的API,也可以帮助插件使用者实现更多扩展功能,因此笔者推出了此文,希望能帮助到各位同学。
本文首发于:数栈研习社
数栈是云原生—站式数据中台PaaS,我们在github上有一个有趣的开源项目:FlinkX。FlinkX是一个基于Flink的批流统一的数据同步工具,既可以采集静态的数据,比如MySQL,HDFS等,也可以采集实时变化的数据,比如MySQL binlog,Kafka等,是全域、异构、批流一体的数据同步引擎,大家如果有兴趣,欢迎来github社区找我们玩~