前言
使用 React 技术栈的同学,都有接触过 antd、material-ui 等 UI 组件库。
早期(编译工具不支持 tree shaking 的时期)为了实现按需引入功能,我们会通过 babel-plugin-import 插件来优化我们的项目打包体积,做到只打包我们项目中所用到的模块(如 UI 组件库中的组件)。
现在新版的 antd 和 material-ui 等都提供了 ES modules 产物资源,而打包工具如:Webpack、Rollup 等在打包层面也支持了 tree shaking,使得我们不需要额外配置 babel-plugin-import 也能实现按需引入,这得益于 tree shaking 策略。
虽然现代框架技术都已支持 tree shaking,但总归在一些老的项目所用到的老技术是无法支持 tree shaking 的,这就需要本文的主角 babel-plugin-import 来完成这件事情。
介绍
我们知道,代码的转换需要交给 Babel 来处理,而 Babel 的代码转换离不开 Babel 插件(或预设)。
babel-plugin-import 是 Babel 的模块化导入插件,支持对 antd、material-ui、loadsh 等依赖包的模块进行按需引入。
这里以 antd UI 组件库举例,我们在 .babelrc 配置文件中配置 babel-plugin-import 插件如下:
{
...
"plugins": [
...
["import", { "libraryName": "antd", style: "css" }]
]
}
经过打包编译后,require 的路径可以具体到模块的所在路径:
import { Button } from 'antd';
ReactDOM.render(xxxx);
↓ ↓ ↓ ↓ ↓ ↓
var _button = require('antd/lib/button');
require('antd/lib/button/style/css');
ReactDOM.render(<_button>xxxx);
这就是按需引入,不再是导入整个依赖包,而是通过 修改路径 具体到依赖包下的指定模块。
使用
1、安装
npm install babel-plugin-import --save-dev
2、在 babel 配置文件 .babelrc or webpack babel-loader 中配置
{
plugins: [
["import", {
"libraryName": "antd", // 指定导入包的名称
"libraryDirectory": "lib", // 指定模块的存放目录
style: "css", // 导入 css 样式
}]
]
}
上面是 babel@7+ 版本配置方式,如果是 babel@6 版本,可以直接配置成对象形式:
{
plugins: [
{ "libraryName": "antd", "libraryDirectory": "lib", "style": true }
]
}
3、配置多个包按需引入
有时候项目中需要对多个依赖包做按需引入处理,在 plugins 集合中配置多个 import 即可。
{
"plugins": [
["import", { "libraryName": "antd", "libraryDirectory": "lib"}, "antd"],
["import", { "libraryName": "antd-mobile", "libraryDirectory": "lib"}, "antd-mobile"]
]
}
原理分析和实现
下面我们分析一下 babel-plugin-import 源码中的具体实现。
1、分析 import 语句 AST 语法树
babel 的编译工作离不开 AST语法树 的转换,假如我们有这样一行模块导入代码:
import { Button, Input } from 'antd';
转换为 AST 语法树后结构如下:(语法转换可以在这里尝试:astexplorer.net)
{
"type": "Program",
"body": [
{
"type": "ImportDeclaration",
"specifiers": [
{
"type": "ImportSpecifier",
"imported": {
"type": "Identifier",
"name": "Button"
},
"local": {
"type": "Identifier",
"name": "Button"
}
},
{
"type": "ImportSpecifier",
"imported": {
"type": "Identifier",
"name": "Input"
},
"local": {
"type": "Identifier",
"name": "Input"
}
}
],
"source": {
"type": "Literal",
"value": "antd",
}
}
],
"sourceType": "module"
}
以上 JSON 数据中,我们关注以下几个跟 import 导入有关的信息:
source.value:antd,即组件库的名称;specifiers.imported.name:Button,导入的组件;specifiers.local.name: Button,导入的组件,对应到 as 起别名的情况;
这里我们只需先对 AST语法树 结构有个印象,后面我们会用到,下面开始编写插件。
2、插件入口文件
babel-plugin-import 入口文件在 src/index.js中,
- 首先,插件是一个
函数,接收babel对象作为入参,这里用到了babel.types来修改 AST 语法节点; - 每个插件都需要返回一个
访问者(visitor)作为插件的逻辑处理池;在 visitor 中可以定义 enter、exit,以及特定 AST 节点类型的 函数 进行处理;
import Plugin from './Plugin';
// babel plugin 是一个函数,接收 babel 作为入参,用到的对象为 babel.types 来修改 AST 语法
export default function ({ types }) {
let plugins = null;
// 当 babel 遍历语法树遇到 method 节点类型时,调用 Plugin 实例上的相应方法去处理
function applyInstance(method, args, context) {
for (const plugin of plugins) {
if (plugin[method]) {
plugin[method].apply(plugin, [...args, context]);
}
}
}
// 定义程序
const Program = {
// Babel 为了提供更灵活的配置方式,将访问阶段分为 enter 和 exit。
// enter 代表程序进入这个节点,也就是入栈,exit 表示程序退出节点。
enter(path, { opts = {} }) {
// Init plugin instances once.
if (!plugins) {
if (Array.isArray(opts)) {
// 如果配置了多个 `import` 插件,每个插件都会创建一个 `Plugin` 示例
plugins = opts.map(({ libraryName, libraryDirectory, style }, index) => {
return new Plugin(
libraryName,
libraryDirectory,
style,
types,
index,
);
},
);
} else {
plugins = [
new Plugin(
opts.libraryName,
opts.libraryDirectory,
opts.style,
types,
),
];
}
}
applyInstance('ProgramEnter', arguments, this);
},
exit() {
applyInstance('ProgramExit', arguments, this);
},
};
const ret = {
visitor: { Program },
};
// 需要处理的 AST Node 节点类型名称。比如 ImportDeclaration 处理 import 语句
const methods = [
'ImportDeclaration',
'CallExpression',
];
for (const method of methods) {
ret.visitor[method] = function () {
// method 的具体实现由 Plugin 实例提供
applyInstance(method, arguments, ret.visitor);
};
}
return ret;
}
从入口文件中可以看到,通过 new Plugin 创建了插件实例,核心的按需引入逻辑都在 Plugin 中。
3、Plugin 插件实例
babel-plugin-import 的核心实现都在 Plugin 中,具体工作如下:
- 收集
import语句{ xxx }中的模块名称; - 分析模块在文件中导入后,是否被
call使用到; - 如果有被使用到,改写
import语句,并根据插件的配置,使得path具体到模块的所在目录。
主要方法概览如下:
// Plugin.js
import { join } from 'path';
import { addSideEffect, addDefault, addNamed } from '@babel/helper-module-imports';
function transCamel(_str, symbol) {
const str = _str[0].toLowerCase() + _str.substr(1);
return str.replace(/([A-Z])/g, $1 => `${symbol}${$1.toLowerCase()}`);
}
function winPath(path) {
return path.replace(/\\/g, '/');
}
export default class Plugin {
constructor(
libraryName, // 需要使用按需加载的包名
libraryDirectory = 'lib', // 按需加载的目录
style = false, // 是否加载样式
types, // babel-type 工具函数
index = 0,
) {
this.libraryName = libraryName;
this.libraryDirectory = libraryDirectory;
this.style = style;
this.types = types;
this.pluginStateKey = `importPluginState${index}`;
}
// 获取内部状态,收集依赖,state 指向 plugin.visitor
getPluginState(state) {
if (!state[this.pluginStateKey]) {
state[this.pluginStateKey] = {};
}
return state[this.pluginStateKey];
}
// 生成按需引入 import 语句(核心代码)
importMethod(methodName, file, pluginState) {
...
}
ProgramEnter(path, state) {
// 初始化插件实例的 state 对象
const pluginState = this.getPluginState(state);
pluginState.specified = Object.create(null);
pluginState.libraryObjs = Object.create(null);
pluginState.selectedMethods = Object.create(null);
pluginState.pathsToRemove = [];
}
ProgramExit(path, state) {
// 删除旧的 import
this.getPluginState(state).pathsToRemove.forEach(p => !p.removed && p.remove());
}
// import 语句的处理方法,收集 import { xxx } 中的模块
ImportDeclaration(path, state) {
...
}
// import 的模块被使用时的处理方法。上面收集了依赖之后,得判断这些 import 的变量是否被用到
// 比如使用方式为:React.createElement(Button, null, "Hello"); 可将这行代码转换为 AST 节点树结合更容易理解 CallExpression 做的事情
CallExpression(path, state) {
...
}
}
-
首先是实例的属性初始化,将插件的配置信息
options绑定到插件实例上; -
接着在
visitor.enter阶段调用 ProgramEnter 方法初始化 Plugin 实例的 state 对象;
pluginState.specified:import 导入的模块名,如:Button;pluginState.selectedMethods:记录路径已经修改过的模块名;pluginState.pathsToRemove:存储 import xxx from 'antd' 源代码,用于在所有模块的路径改写成具体路径后,删除原始 import 语句。
ProgramEnter(path, state) {
// 初始化插件实例的 state 对象
const pluginState = this.getPluginState(state);
pluginState.specified = Object.create(null);
pluginState.libraryObjs = Object.create(null);
pluginState.selectedMethods = Object.create(null);
pluginState.pathsToRemove = [];
}
- 收集导入的模块
分析每一条import语句,如果导入的包名和配置的plugin.libraryName一致,则收集导入的模块名称。
// import 语句的处理方法,收集 import { xxx } 中的模块
ImportDeclaration(path, state) {
const { node } = path;
if (!node) return;
// import 语句中 from 的包名,如这里的 antd
const { value } = node.source;
// 在插件 options 上配置的包名
const { libraryName } = this;
const { types } = this;
const pluginState = this.getPluginState(state);
// 如果 import 的包名和插件中配置的包名一致,开启工作
if (value === libraryName) {
// 遍历 import antd 下面的模块(如:Button)
node.specifiers.forEach(spec => {
// 判断类型是否为 ImportSpecifier (大括号 {} import 模块形式)
if (types.isImportSpecifier(spec)) {
// 收集依赖。也就是 pluginState.specified.Button = Button
// local.name 是 `导入` 进来的别名,比如 import { Button as MyButton } from 'antd' 的 MyButton
// imported.name 是包 antd 真实 `导出` 的变量名
pluginState.specified[spec.local.name] = spec.imported.name;
} else {
pluginState.libraryObjs[spec.local.name] = true;
}
});
// 收集旧的依赖
pluginState.pathsToRemove.push(path);
}
}
- 查找模块是否被使用
调用 CallExpression 分析被使用到的模块名,调用importMethod方法改写import路径.
// import 的模块被使用时的处理方法。上面收集了依赖之后,得判断这些 import 的变量是否被用到
// 比如使用方式为:React.createElement(Button, null, "Hello");
// 可将上面这行代码转换为 AST 节点树结合更容易理解 CallExpression 做的事情
CallExpression(path, state) {
const { node } = path;
const file = (path && path.hub && path.hub.file) || (state && state.file);
// 方法调用者的 name
const { name } = node.callee;
const { types } = this;
const pluginState = this.getPluginState(state);
// 情况一:如果方法调用者是 Identifier 类型(标识符),如:Button() 或直接访问访问 Button
if (types.isIdentifier(node.callee)) {
if (pluginState.specified[name]) {
node.callee = this.importMethod(pluginState.specified[name], file, pluginState);
}
}
// 情况二:参数形式,如 React.createElement(Button, null, "Hello"),会将 Button 作为第一个参数
node.arguments = node.arguments.map(arg => {
const { name: argName } = arg;
if (
pluginState.specified[argName] &&
path.scope.hasBinding(argName) && // 检查当前作用域内是否存在 Button 变量
path.scope.getBinding(argName).path.type === 'ImportSpecifier' // 并且变量通过 import 方式创建
) {
return this.importMethod(pluginState.specified[argName], file, pluginState);
}
return arg;
});
}
- 改写模块导入路径(实现按需引入)
importMethod是 babel-plugin-import 的核心方法,import 模块的路径改写在这里处理。
// 生成按需引入 import 语句(核心代码)
importMethod(methodName, file, pluginState) {
if (!pluginState.selectedMethods[methodName]) {
const { style, libraryDirectory } = this;
// camel2DashComponentName 为 true,会转换成小写字母,并且使用 - 作为连接符
const transformedMethodName = this.camel2DashComponentName ? transCamel(methodName, '-') : methodName;
// 兼容 windows 路径
// // path.join('antd', 'lib', 'button') == 'antd/lib/button'
const path = winPath(
this.customName
? this.customName(transformedMethodName, file)
: join(this.libraryName, libraryDirectory, transformedMethodName)
);
// 根据是否有导出 default 来判断使用哪种方法来生成 import 语句,默认为 true
// addDefault(path, 'antd/lib/button', { nameHint: 'button' })
// addNamed(path, 'button', 'antd/lib/button')
pluginState.selectedMethods[methodName] = this.transformToDefaultImport
? addDefault(file.path, path, { nameHint: methodName })
: addNamed(file.path, methodName, path);
// 根据不同配置 import 样式
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);
}
}
}
return { ...pluginState.selectedMethods[methodName] };
}
可以看到,这里将插件指定的 包名、模块目录名、模块名 进行拼接,得到最终的导入路径,如:antd/lib/button
const path = join(this.libraryName, libraryDirectory, transformedMethodName);
而模块的style 样式文件导入规则如下:
if (style === true) {
addSideEffect(file.path, `${path}/style`);
} else if (style === 'css') {
addSideEffect(file.path, `${path}/style/css`);
}
- addSideEffect, addDefault 和 addNamed 是
@babel/helper-module-imports的三个方法,作用都是创建一个import语句方法,生成表现如下:
addSideEffect(filePath, 'sourcePath');
↓ ↓ ↓ ↓ ↓ ↓
import "sourcePath";
addDefault(filePath, 'sourcePath', { nameHint: "hintedName" });
↓ ↓ ↓ ↓ ↓ ↓
import hintedName from "sourcePath";
addNamed(filePath, 'named', 'sourcePath');
↓ ↓ ↓ ↓ ↓ ↓
import named from "sourcePath";
最后
感谢阅读。