我们的 js 都是打包输出到一个文件中的,当内容越来越多的时候,会导致单个文件体积十分巨大,所以我们就需要对代码进行分割,将一个巨大的文件,分割成多个中小型文件,然后可以按需加载或并行加载这些文件
代码分离是 webpack 中最引人注目的特性之一。此特性能够把代码分离到不同的 bundle 中,然后可以按需加载或并行加载这些文件。代码分离可以用于获取更小的bundle,以及控制资源加载优先级,如果使用合理,会极大缩减加载时间。
常用的代码分离方法有三种:
- 入口起点:使用 entry 配置手动地分离代码。
- 动态导入:通过模块的内联函数调用来分离代码
- 防止重复:使用 SplitChunksPlugin 去重和分离 chunk。
一、多入口
npm init 初始化一个项目,新建 src/index.js、src/other.js、index.html,创建 webpack.config.js,写入如下配置,并在 package.json 中添加开发模式打包的脚本
module.export = {
entry: {
index: "./src/index.js",
other: "./src/other.js"
},
output: {
filename: "[name].js"
},
plugins: [new HTMLWebpackPlugin({
template: "index.html"
})]
}
// package.json
"script": {
"build": "webpack --mode development"
}
执行 npm run build
,会发现输出了两个 js 文件,并且 html 中自动帮我们引入了这两个文件
问题:
查看打包生成的 index.js 跟 other.js 文件,会发现它们有大量重复的内容
这些都是 webpack 生成的 runtime
代码,由于这两个文件同时在 index.html 里面引入,因此 runtime 代码被引入了两次,我们可以添加如下配置将 runtime 代码分离出来
runtime的配置:
module.export = {
optimization: {
runtimeChunk: "single" // 它其实是下面这种写法的简写
/* runtimeChunk: {
name: "runtime"
} */
}
}
打包后会发现多了一个叫 runtime.js 的文件,index.js 与 other.js 中重复的 runtime 代码都被抽离到了这个文件中,index.html 也同时引入了这三个文件
对于单页面,应避免使用多入口,可以使用单入口多文件,像下面这样
entry: {
index: ["./src/index.js", "./src/other.js"]
}
二、 防止重复
(1)配置 entry 提取公用依赖
webpack.config.js
module.exports = {
entry: {
index: {
import: './src/index.js', // 启动时需加载的模块
dependOn: 'shared', // 当前入口所依赖的入口
},
another: {
import: './src/other.js',
dependOn: 'shared',
},
shared: 'lodash' // 当上面两个模块有lodash这个模块时,就提取出来并命名为shared chunk
},
output: {
filename: '[name].bundle.js', // 对应多个出口文件名
path: path.resolve(__dirname, './dist'),
},
}
执行webpack
命令,可以看到打包结果
已经提取出来shared.bundle.js,
即为提取打包了lodash公用模块
index.bundle.js other.bundle.js
体积也变小
查看dist/index.html
可以看到三个文件都被加载了
(2)SplitChunksPlugin
SplitChunksPlugin 能自动的帮助我们做公共模块的抽离
webpack 中 splitChunks 的默认配置
// webpack.config.js
optimization: {
splitChunks: {
chunks: 'async', // 动态导入的模块其依赖会根据规则分离
minSize: 30000, // 文件体积要大于 30k
minChunks: 1, // 文件至少被 1 个chunk 引用
maxAsyncRequests: 5, // 动态导入文件最大并发请求数为 5
maxInitialRequests: 3, // 入口文件最大并发请求数为 3
automaticNameDelimiter: '~', // 文件名中的分隔符
name: true, // 自动命名
cacheGroups: {
vendors: { // 分离第三方库
test: /[\\/]node_modules[\\/]/,
priority: -10 // 权重
},
default: { // 分离公共的文件
minChunks: 2, // 文件至少被 2 个 chunk 引用
priority: -20,
reuseExistingChunk: true // 复用存在的 chunk
}
}
}
}
chunks
该参数有四种取值
async
:动态导入的文件其静态依赖会根据规则分离initial
:入口文件的静态依赖会根据规则分离all
:所有的都会根据规则分离chunk => Boolean
:返回 true 表示根据规则分离,false 则不分离
更多配置查看: SplitChunksPlugin
三、 动态导入
当涉及到动态代码拆分时,webpack 提供了两个类似的技术。
-
第一种,也是推荐选择的方式是,使用符合 ECMAScript 提案 的 import() 语法 来实现动态导入。
-
第二种,则是 webpack 的遗留功能,使用 webpack 特定的 require.ensure 。
这里让我们尝试使用第一种方式,使用 import() 来进行动态导入
我们分别在 index.html、other.js、index.js
中添加如下代码
// index.html body 中添加
<button id="btn">点我</button>
// other.js
console.log("我被加载了")
// index.js
let btn = document.querySelector("#btn");
btn.addEventListener("click", () => {
// import() 返回的是一个 promise,在 then() 方法中执行导入后的操作,也可以使用 async/await
import("./other").then(res => {
console.log(res);
});
});
打包后在浏览器中打开 index.html
可以看到,打包文件多生成了一个叫 0.js 的文件,并且 html 中并没有引入该文件,点击按钮再看看会发生什么
生成了一个 script 标签,并加载了 0.js 文件,这就是动态导入。对于这个文件名称,不是很直观,我们使用魔法注释修改一下
import(/* webpackChunkName: "other" */ "./other").then(res => {
console.log(res);
});
最终打包生成的文件名就叫 other.js,如果希望加上 hash 值,可以在配置文件里添加一个参数
output: {
filename: "[name]-[chunkhash:10].js", // 入口文件打包生成的文件名
chunkFilename: "[name]-[chunkhash:10].js" // 动态模块打包生成的文件名,name 默认为数字,如果使用了魔法注释则为魔法注释的名字
}
这样一来,又引发了一个新的问题,从下图可以看出,虽然 other.js 的内容没有被直接打包进 main.js,但是 main.js 中保存着 other.js 的文件名,当 other.js 内容发生变化时,其文件名也会变化,导致 main.js 跟着变化,那么之前的缓存将会失效
我们可以使用上面提到的 runtimeChunk 将引用的代码抽离到一个单独的文件中。
optimization: {
runtimeChunk: {
name: entrypoint => `runtime-${entrypoint.name}`
}
}
执行打包,随意修改 other.js 中的内容,再次打包,两次生成的文件如下
可以看到,main.js 的 hash 值并没有变化,而抽离出来的文件用户也访问不到,因此不会影响缓存
四、懒加载
懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。
math.js
export function add (x, y) {
return x + y
}
export function reduce (x, y) {
return x - y
}
src/index.js
const button = document.createElement('button')
button.textContent = '点击执行加法运算'
button.addEventListener('click', () => {
// 魔法注释 webpackChunkName 修改懒加载打包文件名
// 即使不使用 webpackChunkName,webpack 5 也会自动在 development 模式下分配有意义的文件名。
import(/* webpackChunkName: 'math' */ './math.js').then(({ add }) => {
console.log(add(4, 5))
})
})
document.body.appendChild(button)
效果:
点击按钮后才加载math.bundle.js并执行了函数打印输出结果
问题:
这样做可能会让用户的交互长时间没有响应的。原因就是待到交互时才进行模块的加载,可能时间会比较长。由此,我们引入prefetch,即预取。
五、预获取、预加载
Webpack v4.6.0+ 增加了对预获取和预加载的支持。
在声明 import 时,使用下面这些内置指令,可以让 webpack 输出 “resource hint(资源提示)”,来告知浏览器:
-
prefetch(预获取):将来某些导航下可能需要的资源(当页面所有内容都加载完毕后,在网络空闲的时候,加载资源)
-
preload(预加载):当前导航下可能需要资源
prefetch
src/index.js
const button = document.createElement('button')
button.textContent = '点击执行加法运算'
button.addEventListener('click', () => {
// webpackPrefetch: true 在动态引入时开始预获取
import(/* webpackChunkName: 'math', webpackPrefetch: true */ './math.js').then(({ add }) => {
console.log(add(4, 5))
})
})
document.body.appendChild(button)
效果
可以看到math.bundle.js已经预先获取了
加载完成之后,浏览器又会去自动加载
preload
preload和prefetch在用法上相差不大,效果上的差别如下(引自官方文档):
- preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
- preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。
- preload chunk 会在父 chunk 中立即请求,用于当下时刻。prefetch chunk 会用于未来的某个时刻。
浏览器支持程度不同。