前言
在h5首屏优化的时候,代码分割(code spitting)是一个常用的优化手段。一个页面可能会有很多功能不需要在首屏展示,有些甚至直到页面关闭都不会被用户使用到。所以我们希望页面首次渲染的时候只加载最核心的js文件,其他的模块等到有需要的时候再加载,即按需加载。
实现动态导入
使用import函数
webpack提供了几种方式帮我们实现代码分割,其中一种方式是使用import函数来动态导入模块。
假设我们有如下模块:
// src/utils.js
const utils = 'utils模块'
export default utils
复制代码
在b模块引用:
// src/b.js
import('./utils').then(data => {
console.log(data);
});
const b = 'b模块';
export default b;
复制代码
a模块引用了b模块和utils模块:
// src/a.js
import('./utils').then((data) => {
console.log(data);
});
import('./b').then((data) => {
console.log(data);
});
const a = 'a模块';
export default a;
复制代码
入口文件:
// index.js
import('./src/a').then((data) => {
console.log(data);
});
复制代码
webpack配置:
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
entry: './index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
};
复制代码
执行构建命令后,结果正如我们预期,输出了4个js文件。通过import函数导入的模块都被打包成独立的js文件。在实际使用中,我们会通过触发某些事件,再去执行import函数,这时就会在html文档上插入一个script标签,加载我们需要的模块。
使用babel/plugin-syntax-dynamic-import插件
通常我们会在项目中使用babel来编译代码,这时需要确保babel能正确解析import语法。所以需要在项目中引入
babel/plugin-syntax-dynamic-import
插件:
{
"plugins": ["@babel/plugin-syntax-dynamic-import"]
}
复制代码
实际应用
在实际应用中,其实我们很少会直接使用import函数来动态导入一个模块。这是因为在所有导入该模块的地方,如果有一处忘记使用import函数,它就会被打包到最终的bundle里面,这样我们优化的目的就达不到了。此外,我们还需要判断这个模块是否真的需要分割出来,因为建立一个http请求也需要一定的开销。import函数使用不当不仅达不到性能优化的目的,可能还会降低页面的性能。
webpack4以后内置了SplitChunksPlugin插件,通过配置optimization.splitChunk,可以定好代码分割的策略,webpack就会按照这个策略对代码进行分割。关于splitChunk的使用,以后有空再写一篇文章来讨论,这里不做展开。
vue-router和react-router官方都提供了路由懒加载的方案。使用vue-cli和vue-router的时候,路由懒加载是开箱即用的:
// 定义异步组件
const Foo = () => import('./Foo.vue')
// 在路由配置中什么都不需要改变,只需要像往常一样使用 Foo
const router = new VueRouter({
routes: [{ path: '/foo', component: Foo }]
})
复制代码
更详细的文档可以查看vue-router路由懒加载
使用react-router有不同的方案可以选择:
- 使用 React.lazy;
- 使用 loadable-components.
官方文档提供了很详细的使用指南
webpack打包后的代码如何实现模块化
webpack打包输出的代码是一个自执行函数,通过全局变量modules,installedModules和函数__webpack_require__实现模块化。
查看打包后的bundle.js,为了查看整体结构,我把代码折叠起来:
自执行函数和eval有点难看,我们可以把代码结构整理成这样:
function main(modules) {
function webpackJsonpCallback(data){
//...
}
var installedModules = {};
function __webpack_require__(moduleId) {
// ...
}
}
var indexModule = {
'./index.js': function(module, exports, __webpack_require__) {
__webpack_require__.e(1).then(
__webpack_require__.bind(null, "./src/a.js")
).then(data => {
console.log(data)
})
}
}
main(indexModule)
复制代码
下面我们分析一下模块的加载,为了宏观把握代码结构,这里我们会忽略一些执行细节,只讲主要流程。
webpack用一个全局对象modules来存放所有的模块,key和value对应moduleId和模块函数,从入口模块./index.js
开始加载其他模块。__webpack_require__.e
动态插入script标签,在chunk加载完成后会把chunk里面所有的模块和对应的模块函数加入到modules对象里面,__webpack_require__
负责执行模块函数。在这个实例中,__webpack_require__.e(1)
加载了1.bundle.js
, 完成后modules会变成这样:
{
'./index.js': function(module, exports, __webpack_require__) {
__webpack_require__.e(1).then(
__webpack_require__.bind(null, "./src/a.js")
).then(data => {
console.log(data)
})
},
'./src/a.js': function(module, __webpack_exports__, __webpack_require__) {
__webpack_require__.r(__webpack_exports__);
__webpack_require__.e(0)
.then(__webpack_require__.bind(null, "./src/utils.js"))
.then((data) => { console.log(data);});
__webpack_require__.e(2)
.then(__webpack_require__.bind(null, "./src/b.js"))
.then((data) => {console.log(data);});
const a = 'a模块';
__webpack_exports__["default"] = (a);
}
}
复制代码
代码通过__webpack_require__
来引用模块./src/a.js
,判断到installedModules
里面没有这个模块,就会去执行对应的模块加载函数,调用__webpack_require__.e
动态加载./src/utils.js
和./src/b.js
。
因为a模块的导出值不依赖于其他模块,所以可以看到它的模块执行函数没有等异步模块加载完,直接赋值:__webpack_exports__["default"] = (a)
模块加载函数执行完了,会把结果缓存到installedModules
中,然后返回导出值。下一次有其他地方加载a模块,就直接从installedModules
拿,不需要执行模块加载函数了。
上面讲的,正是__webpack_require__
函数的源码:
function __webpack_require__(moduleId) {
if(installedModules[moduleId]) {
// 有缓存,直接返回模块导出值
return installedModules[moduleId].exports;
}
// 构造一个新模块
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// 执行模块加载函数。前面分析过,module.exports会在模块执行函数中赋值
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 模块加载完成标志
module.l = true;
// 返回导出值
return module.exports;
}
复制代码
动态导入(dynamic import)的原理
上一小节分析webpack的模块化方案时,已经有提到动态加载是通过__webpack_require__.e
来实现的,与之相关的还有一个全局变量installedChunks
和挂载在window对象上的window["webpackJsonp"]
。
在分析源码前先思考一个问题,通过动态插入script标签来实现动态加载,如何解决这样一个问题:
多次引用同一个模块,只生成一个script标签
在我们的实例中,a模块和b模块都引用了utils.js,而a模块又引用了b模块。utils模块被引用了两次,我们希望只生成一个script标签。且模块加载完成后,能够通知到所有正在加载utils的模块。
全局变量installedChunks用来缓存异步加载的chunks,形式如下:
{
chunkId: installedChunkData
}
复制代码
installedChunkData有四种可能的值:
installedChunkData | meaning |
---|---|
undefined | chunk not loaded |
null | chunk preloaded/prefetched |
resolve, reject, promise | chunk loading |
0 | chunk loaded |
首次加载到一个chunk的时候:
- installedChunkData = [resolve, reject, promise],script onloaded之后installedChunkData = undefined;
- 添加script标签,异步js加载完成后开始执行,首先会执行一个全局定义的webpackJsonpCallback函数,调用installedChunkData的resolve方法,然后installedChunkData = 0。
经过上面的分析之后,我们就可以回答前面的问题了:
__webpack_require__.e
加载chunk的时候,会判断installedChunkData的值,如果值为0就不会去加载了。所以加载同一个chunk不会多次添加script标签。- 如果script还没loaded,
__webpack_require__.e
就会返回首次加载时创建的promise对象,因为所有地方引用的都是同一个promise,所以只要有一个地方调用了resolve方法,promise状态就会改变了。