博客 数栈干货放送!babel-plugin-import最全源码详解(一)

数栈干货放送!babel-plugin-import最全源码详解(一)

   小美   发表于 2023-01-19 11:16  309  0

本文将带领大家解析babel-plugin-import 实现按需加载的完整流程,解开业界所认可 babel 插件的面纱。

首先供上babel-plugin-import插件

一、初见萌芽

首先 babel-plugin-import 是为了解决在打包过程中把项目中引用到的外部组件或功能库全量打包,从而导致编译结束后包容量过大的问题,如下图所示:

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user6/article/82741530ea191ab087204d27f25a94b2..jpg

babel-plugin-import 插件源码由两个文件构成

  • Index 文件即是插件入口初始化的文件,也是笔者在 Step1 中着重说明的文件
  • Plugin 文件包含了处理各种 AST 节点的方法集,以 Class 形式导出

先来到插件的入口文件 Index :

import Plugin from './Plugin';
export default function({ types }) {
let plugins = null;
/**
* Program 入口初始化插件 options 的数据结构
*/

const Program = {
enter(path, { opts = {} }) {
assert(opts.libraryName, 'libraryName should be provided');
plugins = [
new Plugin(
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);
},
exit() {
applyInstance('ProgramExit', arguments, this);
},
};
const ret = {
visitor: { Program }, // 对整棵AST树的入口进行初始化操作
};
return ret;
}

首先 Index 文件导入了 Plugin ,并且有一个默认导出函数,函数的参数是被解构出的名叫 types 的参数,它是从 babel 对象中被解构出来的,types 的全称是 @babel/types,用于处理 AST 节点的方法集。以这种方式引入后,我们不需要手动引入 @babel/types。 进入函数后可以看见观察者( visitor ) 中初始化了一个 AST 节点 Program,这里对 Program 节点的处理使用完整插件结构,有进入( enter )与离开( exit )事件,且需注意:

一般我们缩写的 Identifier() { ... } 是 Identifier: { enter() { ... } } 的简写形式。

这里可能有同学会问 Program 节点是什么?见下方 const a = 1 对应的 AST 树 ( 简略部分参数 )

{
"type": "File",
"loc": {
"start":... ,
"end": ...
},
"program": {
"type": "Program", // Program 所在位置
"sourceType": "module",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "NumericLiteral",
"value": 1
}
}
],
"kind": "const"
}
],
"directives": []
},
"comments": [],
"tokens": [
...
]
}

Program 相当于一个根节点,一个完整的源代码树。一般在进入该节点的时候进行初始化数据之类的操作,也可理解为该节点先于其他节点执行,同时也是最晚执行 exit 的节点,在 exit 时也可以做一些”善后“的工作。 既然 babel-plugin-import 的 Program 节点处写了完整的结构,必然在 exit 时也有非常必要的事情需要处理,关于 exit 具体是做什么的我们稍后进行讨论。 我们先看 enter ,这里首先用 enter 形参 state 结构出用户制定的插件参数,验证必填的 libraryName [库名称] 是否存在。Index 文件引入的 Plugin 是一个 class 结构,因此需要对 Plugin 进行实例化,并把插件的所有参数与 @babel/types 全部传进去,关于 Plugin 类会在下文中进行阐述。 接着调用了 applyInstance 函数:

export default function({ types }) {
let plugins = null;
/**
* 从类中继承方法并利用 apply 改变 this 指向,并传递 path , state 参数
*/

function applyInstance(method, args, context) {
for (const plugin of plugins) {
if (plugin[method]) {
plugin[method].apply(plugin, [...args, context]);
}
}
}
const Program = {
enter(path, { opts = {} }) {
...
applyInstance('ProgramEnter', arguments, this);
},
...
}
}

此函数的主要目的是继承 Plugin 类中的方法,且需要三个参数

  1. method(String):你需要从 Plugin 类中继承出来的方法名称
  2. args:(Arrray):[ Path, State ]
  3. PluginPass( Object):内容和 State 一致,确保传递内容为最新的 State

主要的目的是让 Program 的 enter 继承 Plugin 类的 ProgramEnter 方法,并且传递 path 与 state 形参至 ProgramEnter 。Program 的 exit 同理,继承的是 ProgramExit 方法。

现在进入 Plugin 类:

export default class Plugin {
constructor(
libraryName,
libraryDirectory,
style,
styleLibraryDirectory,
customStyleName,
camel2DashComponentName,
camel2UnderlineComponentName,
fileName,
customName,
transformToDefaultImport,
types, // babel-types
index = 0, // 标记符
) {
this.libraryName = libraryName; // 库名
this.libraryDirectory = typeof libraryDirectory === 'undefined' ? 'lib' : libraryDirectory; // 包路径
this.style = style || false; // 是否加载 style
this.styleLibraryDirectory = styleLibraryDirectory; // style 包路径
this.camel2DashComponentName = camel2DashComponentName || true; // 组件名是否转换以“-”链接的形式
this.transformToDefaultImport = transformToDefaultImport || true; // 处理默认导入
this.customName = normalizeCustomName(customName); // 处理转换结果的函数或路径
this.customStyleName = normalizeCustomName(customStyleName); // 处理转换结果的函数或路径
this.camel2UnderlineComponentName = camel2UnderlineComponentName; // 处理成类似 time_picker 的形式
this.fileName = fileName || ''; // 链接到具体的文件,例如 antd/lib/button/[abc.js]
this.types = types; // babel-types
this.pluginStateKey = `importPluginState${index}`;
}
...
}

在入口文件实例化 Plugin 已经把插件的参数通过 constructor 后被初始化完毕啦,除了 libraryName 以外其他所有的值均有相应默认值,值得注意的是参数列表中的 customeName 与 customStyleName 可以接收一个函数或者一个引入的路径,因此需要通过 normalizeCustomName 函数进行统一化处理。

function normalizeCustomName(originCustomName) {
if (typeof originCustomName === 'string') {
const customeNameExports = require(originCustomName);
return typeof customeNameExports === 'function'
? customeNameExports
: customeNameExports.default;// 如果customeNameExports不是函数就导入{default:func()}
}
return originCustomName;
}

此函数就是用来处理当参数是路径时,进行转换并取出相应的函数。如果处理后 customeNameExports 仍然不是函数就导入 customeNameExports.default ,这里牵扯到 export default 是语法糖的一个小知识点。

export default something() {}
// 等效于
function something() {}
export ( something as default )

回归代码,Step1 中入口文件 Program 的 Enter 继承了 Plugin 的 ProgramEnter 方法

export default class Plugin {
constructor(...) {...}

getPluginState(state) {
if (!state[this.pluginStateKey]) {
// eslint-disable-next-line no-param-reassign
state[this.pluginStateKey] = {}; // 初始化标示
}
return state[this.pluginStateKey]; // 返回标示
}
ProgramEnter(_, state) {
const pluginState = this.getPluginState(state);
pluginState.specified = Object.create(null); // 导入对象集合
pluginState.libraryObjs = Object.create(null); // 库对象集合 (非 module 导入的内容)
pluginState.selectedMethods = Object.create(null); // 存放经过 importMethod 之后的节点
pluginState.pathsToRemove = []; // 存储需要删除的节点
/**
* 初始化之后的 state
* state:{
* importPluginState「Number」: {
* specified:{},
* libraryObjs:{},
* select:{},
* pathToRemovw:[]
* },
* opts:{
* ...
* },
* ...
* }
*/

}
...
}

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

export default function({ types }) {
let plugins = null;
function applyInstance(method, args, context) {
...
}
const Program = {
...
}
const methods = [ // 注册 AST type 的数组
'ImportDeclaration'
]

const ret = {
visitor: { Program },
};

// 遍历数组,利用 applyInstance 继承相应方法
for (const method of methods) {
ret.visitor[method] = function() {
applyInstance(method, arguments, ret.visitor);
};
}

}

创建一个数组并将 ImportDeclaration 置入,经过遍历调用 applyInstance_ _和 Step1 介绍同理,执行完毕后 visitor 会变成如下结构

visitor: {
Program: { enter: [Function: enter], exit: [Function: exit] },
ImportDeclaration: [Function],
}

现在回归 Plugin,进入 ImportDeclaration

export default class Plugin {
constructor(...) {...}
ProgramEnter(_, state) { ... }

/**
* 主目标,收集依赖
*/

ImportDeclaration(path, state) {
const { node } = path;
// path 有可能被前一个实例删除
if (!node) return;
const {
source: { value }, // 获取 AST 中引入的库名
} = node;
const { libraryName, types } = this;
const pluginState = this.getPluginState(state); // 获取在 Program 处初始化的结构
if (value === libraryName) { // AST 库名与插件参数名是否一致,一致就进行依赖收集
node.specifiers.forEach(spec => {
if (types.isImportSpecifier(spec)) { // 不满足条件说明 import 是名称空间引入或默认引入
pluginState.specified[spec.local.name] = spec.imported.name;
// 保存为:{ 别名 : 组件名 } 结构
} else {
pluginState.libraryObjs[spec.local.name] = true;// 名称空间引入或默认引入的值设置为 true
}
});
pluginState.pathsToRemove.push(path); // 取值完毕的节点添加进预删除数组
}
}
...
}

ImportDeclaration 会对 import 中的依赖字段进行收集,如果是名称空间引入或者是默认引入就设置为 { 别名 :true },解构导入就设置为 { 别名 :组件名 } 。getPluginState 方法在 Step1 中已经进行过说明。关于 import 的 AST 节点结构 用 babel-plugin 实现按需加载 中有详细说明,本文不再赘述。执行完毕后 pluginState 结构如下

// 例: import { Input, Button as Btn } from 'antd'

{
...
importPluginState0: {
specified: {
Btn : 'Button',
Input : 'Input'
},
pathToRemove: {
[NodePath]
}
...
}
...
}

这下 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 开始。

class Hello extends React.Component {
render() {
return <div>Hello</div>
}
}

// 转换后

class Hello extends React.Component {
render(){
return React.createElement("div",null,"Hello")
}
}

JSX 转换后 AST 类型为 CallExpression(函数执行表达式),结构如下所示,熟悉结构后能方便各位同学对之后步骤有更深入的理解。

{
"type": "File",
"program": {
"type": "Program",
"body": [
{
"type": "ClassDeclaration",
"body": {
"type": "ClassBody",
"body": [
{
"type": "ClassMethod",
"body": {
"type": "BlockStatement",
"body": [
{
"type": "ReturnStatement",
"argument": {
"type": "CallExpression", // 这里是处理的起点
"callee": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"identifierName": "React"
},
"name": "React"
},
"property": {
"type": "Identifier",
"loc": {
"identifierName": "createElement"
},
"name": "createElement"
}
},
"arguments": [
{
"type": "StringLiteral",
"extra": {
"rawValue": "div",
"raw": "\"div\""
},
"value": "div"
},
{
"type": "NullLiteral"
},
{
"type": "StringLiteral",
"extra": {
"rawValue": "Hello",
"raw": "\"Hello\""
},
"value": "Hello"
}
]
}
],
"directives": []
}
}
]
}
}
]
}
}

因此我们进入 CallExpression 节点处,继续转换流程。

export default class Plugin {
constructor(...) {...}
ProgramEnter(_, state) { ... }

ImportDeclaration(path, state) { ... }

CallExpression(path, state) {
const { node } = path;
const file = path?.hub?.file || state?.file;
const { name } = node.callee;
const { types } = this;
const pluginState = this.getPluginState(state);
// 处理一般的调用表达式
if (types.isIdentifier(node.callee)) {
if (pluginState.specified[name]) {
node.callee = this.importMethod(pluginState.specified[name], file, pluginState);
}
}
// 处理React.createElement
node.arguments = node.arguments.map(arg => {
const { name: argName } = arg;
// 判断作用域的绑定是否为import
if (
pluginState.specified[argName] &&
path.scope.hasBinding(argName) &&
types.isImportSpecifier(path.scope.getBinding(argName).path)
) {
return this.importMethod(pluginState.specified[argName], file, pluginState); // 替换了引用,help/import插件返回节点类型与名称
}
return arg;
});
}
...
}

可以看见源码调用了importMethod 两次,此函数的作用是触发 import 转换成按需加载模式的 action,并返回一个全新的 AST 节点。因为 import 被转换后,之前我们人工引入的组件名称会和转换后的名称不一样,因此 importMethod 需要把转换后的新名字(一个 AST 结构)返回到我们对应 AST 节点的对应位置上,替换掉老组件名。函数源码稍后会进行详细分析。 回到一开始的问题,为什么 CallExpression 需要调用 importMethod 函数?因为这两处表示的意义是不同的,CallExpression 节点的情况有两种:

  1. 刚才已经分析过了,这第一种情况是 JSX 代码经过转换后的 React.createElement
  2. 我们使用函数调用一类的操作代码的 AST 也同样是 CallExpression 类型,例如:
import lodash from 'lodash'

lodash(some values)

因此在 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

import { join } from 'path';
import { addSideEffect, addDefault, addNamed } from '@babel/helper-module-imports';

export default class Plugin {
constructor(...) {...}
ProgramEnter(_, state) { ... }
ImportDeclaration(path, state) { ... }
CallExpression(path, state) { ... }

// 组件原始名称 , sub.file , 导入依赖项
importMethod(methodName, file, pluginState) {
if (!pluginState.selectedMethods[methodName]) {
const { style, libraryDirectory } = this;
const transformedMethodName = this.camel2UnderlineComponentName // 根据参数转换组件名称
? transCamel(methodName, '_')
: this.camel2DashComponentName
? transCamel(methodName, '-')
: methodName;
/**
* 转换路径,优先按照用户定义的customName进行转换,如果没有提供就按照常规拼接路径
*/

const path = winPath(
this.customName
? this.customName(transformedMethodName, file)
: join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line
);
/**
* 根据是否是默认引入对最终路径做处理,并没有对namespace做处理
*/

pluginState.selectedMethods[methodName] = this.transformToDefaultImport // eslint-disable-line
? addDefault(file.path, path, { nameHint: methodName })
: addNamed(file.path, methodName, path);
if (this.customStyleName) { // 根据用户指定的路径引入样式文件
const stylePath = winPath(this.customStyleName(transformedMethodName));
addSideEffect(file.path, `${stylePath}`);
} else if (this.styleLibraryDirectory) { // 根据用户指定的样式目录引入样式文件
const stylePath = winPath(
join(this.libraryName, this.styleLibraryDirectory, transformedMethodName, this.fileName),
);
addSideEffect(file.path, `${stylePath}`);
} else if (style === true) { // 引入 scss/less
addSideEffect(file.path, `${path}/style`);
} else if (style === 'css') { // 引入 css
addSideEffect(file.path, `${path}/style/css`);
} else if (typeof style === 'function') { // 若是函数,根据返回值生成引入
const stylePath = style(path, file);
if (stylePath) {
addSideEffect(file.path, stylePath);
}
}
}
return { ...pluginState.selectedMethods[methodName] };
}
...
}

进入函数后,先别着急看代码,注意这里引入了两个包:path.join 和 @babel/helper-module-imports ,引入 join 是为了处理按需加载路径快捷拼接的需求,至于 import 语句转换,肯定需要产生全新的 import AST 节点实现按需加载,最后再把老的 import 语句删除。而新的 import 节点使用 babel 官方维护的 @babel/helper-module-imports 生成。现在继续流程,首先无视一开始的 if 条件语句,稍后会做说明。再捋一捋 import 处理函数中需要处理的几个环节:

  • 对引入的组件名称进行修改,默认转换以“-”拼接单词的形式,例如:DatePicker 转换为 date-picker,处理转换的函数是 transCamel。
function transCamel(_str, symbol) {
const str = _str[0].toLowerCase() + _str.substr(1); // 先转换成小驼峰,以便正则获取完整单词
return str.replace(/([A-Z])/g, $1 => `${symbol}${$1.toLowerCase()}`);
// 例 datePicker,正则抓取到P后,在它前面加上指定的symbol符号
}

转换到组件所在的具体路径,如果插件用户给定了自定义路径就使用 customName 进行处理,babel-plugin-import 为什么不提供对象的形式作为参数?因为 customName 修改是以 transformedMethodName 值作为基础并将其传递给插件使用者,如此设计就可以更精确的匹配到需要按需加载的路径。处理这些动作的函数是 withPath,withPath 主要兼容 Linux 操作系统,将 Windows 文件系统支持的 '\' 统一转换为 '/'。

function winPath(path) {
return path.replace(/\\/g, '/');
// 兼容路径: windows默认使用‘\’,也支持‘/’,但linux不支持‘\’,遂统一转换成‘/’
}

对 transformToDefaultImport 进行判断,此选项默认为 true,转换后的 AST 节点是默认导出的形式,如果不想要默认导出可以将 transformToDefaultImport 设置为 false,之后便利用 @babel/helper-module-imports 生成新的 import 节点,最后**函数的返回值就是新 import 节点的 default Identifier,替换掉调用 importMethod 函数的节点,从而把所有引用旧 import 绑定的节点替换成最新生成的 import AST 的节点。

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user6/article/ed5c66ef04a6e5a9c3b1d3c8d5ed391f..jpg

最后,根据用户是否开启 style 按需引入与 customStyleName 是否有 style 路径额外处理,以及 styleLibraryDirectory(style 包路径)等参数处理或生成对应的 css 按需加载节点。

到目前为止一条最基本的转换线路已经转换完毕了,相信大家也已经了解了按需加载的基本转换流程,回到 importMethod 函数一开始的if 判断语句,这与我们将在 step3 中的任务息息相关。现在就让我们一起进入 step3。



想了解或咨询更多有关袋鼠云大数据产品、行业解决方案、客户案例的朋友,浏览袋鼠云官网:https://www.dtstack.com/?src=bbs

同时,欢迎对大数据开源项目有兴趣的同学加入「袋鼠云开源框架钉钉技术群」,交流最新开源技术信息,群号码:30537511,项目地址:https://github.com/DTStack


0条评论
社区公告
  • 大数据领域最专业的产品&技术交流社区,专注于探讨与分享大数据领域有趣又火热的信息,专业又专注的数据人园地

最新活动更多
微信扫码获取数字化转型资料
钉钉扫码加入技术交流群