前言
在使用 vant、element-ui、ant-design 等 UI 组件库时候会用到按需加载,通过 [babel-plugin-import](https://github.com/ant-design/babel-plugin-import) 插件可以快速配置好自动按需加载组件,还可以通过直接手动引入对应组件和样式文件的方式来实现。同时,在开发中使用 webpack 构建项目时也常使用懒加载技术,本文所述的组件库动态加载和 webpack 构建项目的懒加载是不同的。本文将以 babel-plugin-import 插件为主,讲解组件库按需加载方案的实现原理。
对比 webpack 懒加载
组件库按需加载: 组件库以组件为基本单位产出 js、css、less 文件,借助插件或者部分引入的写法,使得项目代码或 babel 编译后的代码中只包含使用到的组件的 js、css、less 等。
webpack 懒加载: webpack 将源码中的 import、require 引入的文件编译之后再根据动态加载语法配置(通常以页面路由为基本单位)将较大的代码拆分并构建出较小的 chunk 包,应用在运行时执行到相应业务逻辑时才去加载执行对应 chunk 代码。 webpack 懒加载主要发生在下图的 JS 拆分出不同的 Chunk 这一过程中。
可见,两者的差别主要在于:
- 两者执行时机不同,组件库按需加载是在源码编写阶段或者 babel 编译 js 阶段,而 webpack 懒加载则是在构建生成打包产物时,组件库按需加载在前,webpack 懒加载在后;
- 两者原理不同,组件库按需加载是在源码阶段就去掉了无关代码,而 webpack 懒加载则是将经过 tree-shaking 优化过后的大文件包进行拆分在适当的运行时进行按需加载。
为何需要组件库按需加载
组件库按需加载主要目的就是为了减少项目构建打包产物的大小,提高项目线上首屏渲染速度,减少白屏时间,减少流量消耗。
一般组件库会提供一种引入全部组件和 css 文件的写法,例如:
import Vue from 'vue';
import Vant from 'vant';
import 'vant/lib/index.css';
Vue.use(Vant);
这种写法经过 webpack 构建之后会将组件库产出的 vant.min.js、index.css 引入并打包至构建产物中,而引入的 vant.min.js 文件是包含组件库全部组件的 js 部分,index.css 包含全部组件的 css 部分。因此,这会导致构建打包产物增大。
组件库动态加载用法
Vant 官方文档中推荐使用如下两种方式让 Vant 组件库支持按需加载。
方式一:手动加载
手动引入需要使用到的组件以及其对应的样式文件即可,在 webpack 构件时组件库中其他未被引入的文件不会被打包。
import Button from 'vant/lib/button';
import 'vant/lib/button/style';
方式二:自动加载
安装 babel-plugin-import 插件
npm i babel-plugin-import -D
修改 babel 插件配置
module.exports = {
plugins: [
['import', {
libraryName: 'vant',
libraryDirectory: 'es',
style: true
}, 'vant']
]
};
在项目代码中按需引入要用到的组件
import { Button } from 'vant';
Vue.use(Button);
组件库按需加载的本质
从上文中手动配置按需加载需要用到的组件中就可以看出,所谓的按需加载就如字面意思一样,指的就是按需引入需要的组件,用专业术语来讲就是:在代码中手动引入需要用到的组件。组件其实就是对一堆 js、css 以及 less 等文件的总称,所以上文中需要手动引入组件对应的样式文件 vant/lib/button/style 。
即,本质就是对源代码进行如下转换
import { Button } from 'vant';
转换为
import "vant/es/button/style";
import _Button from "vant/es/button";
可以想到,如果每次需要用到新的组件都像这样时都同时手动引入 js、css 或 less 文件岂不是很麻烦,所以为了免去引入写法的繁杂,产生了两种方案:引入全部组件和使用插件自动引入。使用插件自动引入就是插件帮我们把引入组件的写法进行了转换,最后转换成了上文中手动加载方式的写法。
babel-plugin-import 插件
上文已经介绍了使用插件自动按需加载的本质,下面开始进一步地分析该插件底层是如何实现的。
核心原理
该插件核心逻辑可以概括用图示如下
关键过程包括:词法、语法分析,AST 转换,代码生成。
1. 词法、语法分析
@babel/core (v7 版本之后)读取项目的源码内容,进行词法、语法分析,然后得到抽象语法树 AST,词法、语法分析过程这里不赘述,抽象语法树 AST 可以简单的理解为是一种树状结构的数据用于描述源码的内容。下图为一段 import 导入语句对应的 AST 结构,一个 AST 由多个节点组成,基本的节点结构类似于如下:
{
"type": "ImportDeclaration",
"start": 0,
"end": 29,
...
}
源代码和 AST 对应关系见下图:
2. AST 转换
上文中提到组件按需加载的本质是对源码的 import 导入写法进行转换,babel-plugin-import 插件也是如此,而转换代码这一过程是在 AST 层面进行并非直接操作源代码。
以下面组件引入的代码为例:
import { Button } from 'vant'
对应的 AST 为(点击查看完整 AST)
转换后代码为:
import "vant/es/button/style";
import _Button from "vant/es/button";
对应 AST 为(点击查看完整 AST)
转换的过程中对 AST 进行了变更:新增加了对样式文件的导入声明——第一个
ImportDeclaration 节点,对第二个 ImportDeclaration 节点的变量描述符 Identifier 进行了更名等。
3. 代码生成
对 AST 树进行了转换之后需要根据转后的 AST 生成代码,这一过程由 @babel/generator 完成,过程就是深度优先遍历整个 AST,然后构建可以表示转换后代码的字符串。
根据这个 AST 构建出如下代码:
import "vant/es/button/style";
import _Button from "vant/es/button";
以上用例 demo 可以在这里 github.com/JohnieXu/de… 查看。
浅析插件原理
插件配置
babel.config.js、.babelrc 配置文件的 plugins 选项告诉 babel 在编译 js 文件时候需要使用 babel-plugin-import 插件进行处理,同时配置文件中还指定了插件的参数,参数主要包括:
{
"libraryName": "vant", // 组件库名称,对应 import 语法中的包名
"libraryDirectory": "lib", // 编译之后各个组件单元所在文件夹名称
"style": true, // 是否引入组件对应样式文件,也可以传入 less 来引入 less 文件
"styleLibraryDirectory": "", // 编译之后引入的组件样式文件所在文件夹名称
"camel2DashComponentName": false, // 是否将驼峰命名的导入变量转换为对应的横线连接命名的文件名
"customName": (name, file) => { return `vant/lib/${name}` }, // 自定义编译之后引入的组件名
"customStyleName": (name, file) => { return `vant/lib/css/${name}` }, // 自定义编译之后引入样式文件的名称
}
更多配置参数详见 github.com/ant-design/…
插件入口
一个 babel 插件其实就是一个返回了 Visitor 对象的函数,其大致结构如下
export default function({ types: t }) {
return {
visitor: {
Identifier(path, state) {},
ASTNodeTypeHere(path, state) {}
}
};
};
之所以叫做 Visitor 是因为这里采用了一种访问者模式,Visitor 对象上配置的所有成员方法、成员对象都是用于 babel 处理每个 node 节点时的钩子函数。babel-plugin-import 插件中使用到的钩子函数如下所示,后面注释有各个钩子的执行时机。
const methods = [
'ImportDeclaration', // import 导入声明
'CallExpression', // 函数调用
'MemberExpression',
'Property',
'VariableDeclarator',
'ArrayExpression',
'LogicalExpression',
'ConditionalExpression',
'IfStatement',
'ExpressionStatement',
'ReturnStatement',
'ExportDefaultDeclaration',
'BinaryExpression',
'NewExpression',
'ClassDeclaration',
'SwitchStatement',
'SwitchCase',
];
babel-plugin-import 插件的 Visitor 对象上还配置了 Program 钩子,其结构如下:
const Program = {
enter(path, options) {
// ...
},
exit() {
// ...
}
}
Program 钩子是在 babel 处理一个独立文件(或者叫做模块更合适,node 规范定义一个文件就是一个模块)时执行。这里 enter、exit 是钩子函数另一种写法,分别对应进入、退出钩子,每个钩子函数都可以分别指定进入和退出时指定的钩子函数,不按此方式具体指定则默认为 enter 钩子。
这里 Program 在 enter 时执行的逻辑具体为:
- 根据插件接受到的配置参数初始化插件 Plugin 数组
- 遍历插件 Plugin 数组,依次执行各个插件的初始化方法
ProgramEnter
这里提到的插件 Plugin 是指的 babel-plugin-import 独立封装的一个类,主要用来定义各个钩子函数的执行逻辑,与 babel 插件的 Visitor 对象对接,区别在于这里的 Plugin 里面封装了一些可复用的工具方法。其核心方法如下:
转换导入语法
转换导入语法需要识别 ES6 模块规范的默认导入、部分导入以及整体导入等语法,主要逻辑包括鉴别是否是部分导入,只有部分导入才表示导入具体组件,转换导入变量名等。
import { Button } from 'vant'
console.log(Button) // 1. 部分导入
import Vant from 'vant'
console.log(Vant.Dialog, Vant.Toast, Vant.Cell) // 2. 默认导入
import * as V from 'vant'
console.log(V.Dialog, V.Toast) // 3. 全部导入
上面代码经过转后的代码如下,默认导入、整体导入等导入语句被去掉,转换成了对应组件的部分导入 ,例如: import Vant from 'vant'。
import "vant/es/log/style";
import _log from "vant/es/log";
import "vant/es/cell/style";
import _Cell from "vant/es/cell";
import "vant/es/toast/style";
import _Toast from "vant/es/toast";
import "vant/es/dialog/style";
import _Dialog from "vant/es/dialog";
import "vant/es/button/style";
import _Button from "vant/es/button";
console.log(_Button); // 1. 部分导入
console.log(_Dialog, _Toast, _Cell); // 2. 默认导入
console.log(_Dialog, _Toast); // 3. 全部导入
处理逻辑如下:
ImportDeclaration钩子中将部分导入、默认导入和整体导入的语句记录到插件全局状态对象上,同时将节点的 path 对象记录至插件全局状态对象上;- 插件全局状态对象上存储的 path 对象会在
Program退出时遍历执行remove方法,从而移除了所有原始的导入语句; - 在
MemberExpression、CallExpression、buildExpressionHandler、buildDeclaratorHandler等钩子函数中执行importMethod函数; importMethod函数会根据插件的配置参数计算出真实文件导入路径、是否导入样式文件、样式文件名、是否转换默认导入等配置,从而使用addSideEffect方法添加对应的部分导入语句。
增加样式导入
在上述第 4 步中 importMethod 方法会根据计算出的参数配置添加对样式文件的导入,其源码实现逻辑如下:
// Plugin.js importMethod方法部分逻辑
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) {
addSideEffect(file.path, `${path}/style`);
} else if (style === 'css') {
addSideEffect(file.path, `${path}/style/css`);
} else if (typeof style === 'function') {
const stylePath = style(path, file);
if (stylePath) {
addSideEffect(file.path, stylePath);
}
}
核心还是使用 @babel/helper-module-imports 提供的 addSideEffect 方法添加对样式文件的导入。
转换变量引用
在上述转换导入语法步骤中,不管是否配置 transformToDefaultImport 来处理 export default ,都会重命名导入模块的变量描述符 Identifier,因此引用了的导入组件对应的变量都得进行转换。
涉及到 Identifier 的钩子主要有:
Property
VariableDeclarator
ArrayExpression
LogicalExpression
ConditionalExpression
IfStatement
ExpressionStatement
ReturnStatement
ExportDefaultDeclaration
BinaryExpression
NewExpression
SwitchStatement
SwitchCase
ClassDeclaration
上述不同的钩子函数中 path.node 对应的属性稍有不同,但是最终都需要转换 Identifier 的 name 属性。
因此,转换变量引用主要逻辑为:
- 以钩子函数为入口,根据不同的节点类型取找到不同节点与变量相关的属性;
- 校验变量的
name是否存在于插件全局状态的specfied中,即变量是否是导入组件指向的变量; - 通过
path.scope.hasBinding、path.scope.getBinding排除掉掉其他作用域的变量; - 借助
importMethod方法计算转换后模块对应的变量名然后修改节点对应的变量命。
总结
经过上文的讲解,我们清楚了在使用常见 UI 组件库时引入组件库的其中方式以及区别,也了解了如何配置组件库的按需加载,最后还分析了 babel-plugin-import 插件的实现原理,知道了所谓的组件库按需加载是在 babel 编译 js 阶段进行了代码转换。
上文的讲解对 babel 插件原理、AST 抽象语法树的介绍并不充分,主要考虑到这些并非本文的重点,要想更详细了解的话强烈建议前往阅读参考资料里给出的文章。
参考资料
- 在线查看 AST 工具:astexplorer.net/
- 深入 babel,这一篇就够了 juejin.cn/post/684490…
- 一口(很长的)气了解 babel juejin.cn/post/684490…
- babel 插件手册 github.com/jamiebuilds…
- ECMAScript 6 入门-Module 的语法 es6.ruanyifeng.com/#docs/modul…
写在最后
既然看到这里了不妨点个赞鼓励下作者呗 :)
作者博客:blog.lessing.online/
公众号:【迪诺笔记】
Github:github.com/johniexu