# Code Splitting 是什么以及为什么
在以前,为了减少 HTTP 请求,通常我们会把所有的代码都打包成一个单独的 JS 文件。但是如果这个 JS 文件的体积太大的话,就会让整个请求体体积过大,从而降低请求响应的速度,那就得不偿失了。
这时,我们不妨把所有代码分成一块一块,需要某块代码的时候再去加载它;还可以利用浏览器的缓存,下次用到它的时候,直接从缓存中读取。很显然,这种方式可以加快我们网页的加载速度。
所以说,Code Splitting 其实就是把代码分成很多很多块(chunk)。
# 怎么做
代码切割的主要方式有两种:
- 分离业务代码和第三方库(vendor)
- 按需加载(利用
import()
语法)
之所以把业务代码和第三方代码分离出来,是因为业务的需求是源源不断的,因此业务代码更新频率更高;相反,第三方库更新迭代相对较慢,有时还会锁版本,所以可以充分利用浏览器的缓存来加载这些第三方库。
而按需加载的使用场景,比如说「访问某个路由的时候再去加载相应的组件」,用户不一定一访问所有的路由,所以没必要把所有路由对应的组件都在开始的时候加载完毕。更典型的例子是「某些用户他们的权限只能访问特定的页面」,所以更没必要把他们没有权限的组件加载进来。
# 准备工作
我用 React 写了一个 demo (opens new window) ,他在页面输出一句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 官网 (opens new window) ,我这里采用的配置是所有针对所有模块进行拆分,同时将 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 (opens new window) 来解析 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 + 你的分析
我们做代码切割的目的,就是为了充分利用浏览器的缓存,以及首屏的极限优化达到按需加载的效果。