0%

代码拆分和按需加载

按需加载和按需打包区分

从技术角度介绍按需加载概念前,我们需要先和另外一个概念:按需打包,进行区分。事实上,当前社区对于按需加载和按需打包并没有一个准确的命名伤的划分约定。因此从两者命名上,难以区分其实际含义。

其实,按需加载表示代码模块在交互需要时,动态引入;而按需打包针对第三方依赖库,及业务模块,只打包真正在运行时可能会需要的代码。

我们不妨先说明按需打包的概念和实施方法,目前按需打包一般通过两种方式进行:

  • 使用ES Module 支持的 Tree Shaking方案,在使用构建工具打包时,完成按需打包;
  • 使用以babel-plugin-import为主的Babel插件,实现自动按需打包;

**Tres Shaking实现按需打包

来看一个场景,假设业务中使用 antd 导出来的内容。假设应用中并没有使用 antd 提供的TimePicker组件,那么对于打包结果来说,无疑增加了代码体积。在这种情况下,如果组件库提供了ES Module版本,并开启 Tree Shaking,我们就可以通过“摇树”特性,将不会被使用的代码在构建阶段排除

Webpack 可以在 package.json 中设置sideEffects: false

学习编写Babel插件,实现按需打包

如果第三方库不支持 Tree Shaking,我们依然可以通过 Babel 插件,改变业务代码中对模块的引用路径来实现按需打包。

比如 babel-plugin-import 这个插件,它是 antd 团队推出的一个 Babel 插件,我们通过一个例子来理解它的原理,比如:

1
import {Button as Btn,Input,TimePicker,ConfigProvider,Haaaa} from 'antd'

这样代码就可以被编译为

1
2
3
4
import _ConfigProvider from "antd/lib/config-provider";
import _Button from "antd/lib/button";
import _Input from "antd/lib/input";
import _TimePicker from "antd/lib/time-picker";

编写一个类似的Babel插件也不是难事,Babel插件核心以来于对AST的解析和依赖。它本质上就是一个函数,在Babel 对 AST 语法树进行转换的过程中介入,通过相应的操作,最终让生成的结果发生改变

Babel已经内置了几个核心分析,操作AST的工具集,Babel插件通过观察者 + 访问者模式,对AST节点统一遍历,因此具备了酿好的拓展性和灵活性,比如这段代码:

1
import {Button as Btn, Input} from 'antd'

这样的代码,经过 Babel AST 分析后,得到结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
"type": "ImportDeclaration",
"specifiers": [
{
"type": "ImportSpecifier",
"imported": {
"type": "Identifier",
"loc": {
"identifierName": "Button"
},
"name": "Button"
},
"importKind": null,
"local": {
"type": "Identifier",
"loc": {
"identifierName": "Btn"
},
"name": "Btn"
}
},
{
"type": "ImportSpecifier",
"imported": {
"type": "Identifier",
"loc": {
"identifierName": "Input"
},
"name": "Input"
},
"importKind": null,
"local": {
"type": "Identifier",
"start": 23,
"end": 28,
"loc": {
"identifierName": "Input"
},
"name": "Input"
}
}
],
"importKind": "value",
"source": {
"type": "StringLiteral",
"value": "antd"
}
}

通过上述结构,我们很容易实现遍历 specifiers 属性

首先通过 buildExpressionHandler 方法对 import 路径进行改写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
buildExpressionHandler(node, props, path, state) {
// 获取文件
const file = (path && path.hub && path.hub.file) || (state && state.file);
const { types } = this;
const pluginState = 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); // eslint-disable-line
}
});
}

buildExpressionHandler 方法依赖 importMethod 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
importMethod(methodName, file, pluginState) {
if (!pluginState.selectedMethods[methodName]) {
const { style, libraryDirectory } = this;
// 获取执行方法名
const transformedMethodName = this.camel2UnderlineComponentName // eslint-disable-line
? transCamel(methodName, '_')
: this.camel2DashComponentName
? transCamel(methodName, '-')
: methodName;
// 获取相应路径
const path = winPath(
this.customName
? this.customName(transformedMethodName, file)
: join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line
);
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) {
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] };
}

importMethod方法调用了@babel/helper-module-imports中的addSideEffect方法执行路径的转换操作。addSideEffect方法在源码中通过实例化一个 Import Injector,并调用实例方法完成了 AST 转换

“重新认识” dynamic import (动态导入)

静态导入的性能优劣

标准用法的 import 属于静态导入,它只支持一个字符串类型的 module specifier(模块路径声明),这样的特性会使所有被 import 的模块在加载时就被编译。从某些角度看,这种做法对于绝大多数场景来说性能是友好的,因为这意味着对工程代码的静态分析成为可能,进而使得类似 tree-shaking 的技术有了应用空间。

但是对于一些特殊场景,静态导入也可能成为性能的短板,比如,当我们需要:

  • 按需加载一个模块
  • 按运行事件选定一个模块

此时,dynamic import 就变得尤为重要。比如在浏览器,根据用户的系统语言选择加载不同的语言模块,根据用户的操作去加载不同的内容逻辑

MDN 文档中给出了 dynamic import 更具体的使用场景:

  • 静态导入的模块很明显降低了代码的加载速度且被使用的可能性很低,或者并不需要马上使用它
  • 静态导入的模块很明显占用了大量系统内存且被使用的可能性很低
  • 被导入的模块在加载时并不存在,需要异步获取
  • 导入模块的说明符,需要动态创建
  • 被导入的模块有副作用

dynamic import(动态导入)*

dynamicImport 函数实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const importModule = url => {
// 返回一个新的 Promise 实例
return new Promise((resolve, reject) => {
// 创建 script 标签
const script = document.createElement("script");

const tempGlobal = "__tempModuleLoadingVariable" + Math.random().toString(32).substring(2);

script.type = "module";
script.textContent = `import * as m from "${url}"; window.${tempGlobal} = m;`;
// load 回调
script.onload = () => {
resolve(window[tempGlobal]);
delete window[tempGlobal];
script.remove();
};
// error 回调
script.onerror = () => {
reject(new Error("Failed to load module script with URL " + url));
delete window[tempGlobal];
script.remove();
};
document.documentElement.appendChild(script);
});
}

这里,我们通过动态插入一个 script 标签实现对目标 script url 的加载,并通过将模块导出内容赋值给 window 对象。我们使用__tempModuleLoadingVariable” + Math.random().toString(32).substring(2) key保证模块导出对象的命名不会出现冲突

Webpack 赋能代码拆分和按需加载

总的来说,Webpack 提供了三种相关能力:

  • 通过入口配置分割代码
  • 动态导入支持
  • 通过 splitchunk 插件提取公共代码 (公共代码分割)

Webpack 对 dynamic import 能力支持

事实上,在 Webpack 早期版本中,提供了 require.ensure() 能力。请注意这是 Webpack 特有的实现:require.ensure() 能够将其参数对应的文件拆分到一个单独的 bundle 中,此 bundle 会被异步加载。

目前 require.ensure() 已经被符合 ES 规范的 dynamic import 取代。调用 import(),被请求的模块和它引用的所有子模块,会分离到一个单独的 chunk 中。值得学习的是,Webpack 对于 import() 的支持和处理非常“巧妙”,我们知道 ES 中关于 dynamic import 的规范,只接受一个参数,表示模块的路径:

import(${path}) -> Promise

但是 Webpack 是一个构建工具,Webpack 中对于 import() 的处理,可以通过注释接收一些特殊的参数,无须破坏 ES 对于 dynamic import 规定。比如:

1
2
3
4
5
import(
/* webpackChunkName: "chunk-name" */
/* webpackMode: "lazy" */
'module'
);