在以前,为了减少 HTTP 请求,通常我们会把所有的代码都打包成一个单独的 JS 文件。但是如果这个 JS 文件的体积太大的话,就会让整个请求体体积过大,从而降低请求响应的速度,那就得不偿失了。
这时,我们不妨把所有代码分成一块一块,需要某块代码的时候再去加载它;还可以利用浏览器的缓存,下次用到它的时候,直接从缓存中读取。很显然,这种方式可以加快我们网页的加载速度。
所以说,Code Splitting 其实就是把代码分成很多很多块(chunk)。
怎么做
代码切割的主要方式有两种:
- 分离业务代码和第三方库(vendor)
- 按需加载(利用
import()
语法)
之所以把业务代码和第三方代码分离出来,是因为业务的需求是源源不断的,因此业务代码更新频率更高;相反,第三方库更新迭代相对较慢,有时还会锁版本,所以可以充分利用浏览器的缓存来加载这些第三方库。
而按需加载的使用场景,比如说「访问某个路由的时候再去加载相应的组件」,用户不一定一访问所有的路由,所以没必要把所有路由对应的组件都在开始的时候加载完毕。更典型的例子是「某些用户他们的权限只能访问特定的页面」,所以更没必要把他们没有权限的组件加载进来。
准备工作
我用 React 写了一个 demo ,他在页面输出一句 Hello world。
接下来,看看第一次打包情况:
可以看到,当前只有一个 chunk,也就是 app.js,它是一个 entry chunk。因为我们的 webpack 配置是这样子的:
// webpack.config.js
module.exports = {
entry: {
app: "../src/index.tsx", // entry chunk
},
};
app.js 包含了我们的第三方库 react 和 react-dom,以及我们的业务代码 src。
接下来我们把它们分离开来。
分离 Vendor
最简单的方法就是:增加一个 entry
// webpack.config.js
module.exports = {
entry: {
app: "../src/index.tsx",
vendor: ["react", "react-dom"],
},
};
来分析一下打包:
虽然 vendor.js 这个 chunk 包含了我们想要的 react 和 react-dom,但是 app.js 却没有忽略他们。
这是因为,每个 entry 都有自己的依赖,我们想要把 react 和 re-dom 等第三方依赖提取出来,就需要找出它们相同的依赖,就像这样:
如果想要提取公共模块的话,就需要用到 optimization.splitChunks。
optimization.splitChunks
现在我们修改 webpack 配置:
module.exports = {
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
vendors: { test: /[\\/]node_modules[\\/]/, priority: -10 },
},
},
},
};
其中 splitChunks 中的配置具体可以参考 webpack 官网 ,我这里采用的配置是所有针对所有模块进行拆分,同时将 node_modules 中的依赖放到 vendors.js 里面,你也可以进行修改,只对异步模块进行拆分。
我们看下打包的结果:
我们可以看到,app.js 里面的 react 和 react-dom 已经拆分到了 vendors 中。
Dynamic Import
由于产品经理加了新的需求,我们的 demo 新增了路由。
同时我们的打包:
我们新增的 react-router 自动打包到了 vendors 中,但是我们的主包 app.js 却将所有路由文件都打包到一个文件中,这不符合我们的按需加载的想法。
React.lazy()
webpack 可以针对两种语法进行拆分:
- ESM 的
import()
语法 webpack.ensure
我们使用 React 官方的 React.lazy
,它是基于 webpack.ensure
,我们修改路由配置:
import React, { FC, lazy } from "react";
import { Redirect, Route, Switch } from "react-router";
const Home = lazy(() => import("./home/home"));
const Person = lazy(() => import("./person/person"));
const School = lazy(() => import("./school/school"));
const Root: FC = () => {
return (
<Switch>
<Route path="/" exact render={() => <Redirect to="/home" />} />
<Route path="/home" component={Home} />
<Route path="/person" component={Person} />
<Route path="/school" component={School} />
</Switch>
);
};
export default Root;
在修改 webpack 配置文件
module.exports = {
output: {
path: "../dist",
filename: "[name].[chunkhash].bundle.js",
chunkFilename: "[name].[chunkhash].bundle.js",
},
};
为每一个 chunk 添加了 hash,利于以后做缓存。
如果你使用了 babel,需要安装 babel-plugin-syntax-dynamic-import 来解析 import()
语法,修改 .babelrc:
{
"plugins": ["syntax-dynamic-import"]
}
看一下打包情况:
可以看到,除了主包 app.js 以外,已经额外分离出了三个单独的 chunk,分别对应了我们的三个路由组件。
但是引发了额外的问题,那便是之前在主包已经拆分好的 vendor,在 chunk 中失效了,某一些依赖是多个 chunk 公用的,这时候这些依赖理应在 vendor.js 中,而不应该是每一个 chunk 都有自己的依赖。
但其实问题不大,原因在于 webpack 在抽取公用模块的时候,会对被抽取的模块大小进行判断,模式最小被抽取的大小是 30kb,当然我们修改已达到最小细粒度的复用,这完全靠调用方自己把控。
这里我们把最小大小修改为 0,即所有模块都会被抽取,我们看一下打包后的样子:
分离业务公共模块
不单只是第三方依赖,通常我们在写业务代码的时候,也会抽离一些代码放到公共模块中。
细心的读者应该可以看到上图 3,4,5 chunk 里面都包含了 Button,如果类似的公共组件一多起来,就会产生很多重复的代码,所以我们也应该将这些重复代码打包到一个公共的模块里面去。
实现方式和上面一致:
module.exports = {
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: 20,
minSize: 0,
},
default: { minChunks: 2, priority: 10, reuseExistingChunk: true },
},
},
},
};
这样,当 webpack 打包的时候,在所有异步 chunk 中引入次数大于等于 2 的模块,webpack 就会把它打包到 default.js chunk 中。(由于 demo 中我们的公用组件大小太小,所以我对公用 chunk 大小修改 0 以方便观察)。
最后我们打包的结果是:
Perfect,这就是我们想要的效果。
ps:由于有一个 chunk 太小导致图中没有显示出来,实际上图中一共有 6 个子 chunk。
总结
你的 Code Splitting = webpack bundle analyzer + optimization.splitChunks + 你的分析
我们做代码切割的目的,就是为了充分利用浏览器的缓存,以及首屏的极限优化达到按需加载的效果。