前端如何进行高效的分包
Issue 欢迎在 Gtihub Issue 中回答此问题: Issue 761
Author 回答者: shfshanyue
如何正确地进行分包
为什么需要分包?
为什么需要进行分包,一个大的 bundle.js
不好吗?
极其不建议,可从两方面进行考虑:
- 一行代码将导致整个
bundle.js
的缓存失效 - 一个页面仅仅需要
bundle.js
中 1/N 的代码,剩下代码属于其它页面,完全没有必要加载
如何更好的分包?
打包工具运行时
webpack(或其他构建工具) 运行时代码不容易变更,需要单独抽离出来,比如
webpack.runtime.js
。由于其体积小,必要时可注入index.html
中,减少 HTTP 请求数,优化关键请求路径
前端框架运行时
React(Vue) 运行时代码不容易变更,且每个组件都会依赖它,可单独抽离出来
framework.runtime.js
。请且注意,务必将 React 及其所有依赖(react-dom/object-assign)共同抽离出来,否则有可能造成性能损耗,见下示例
假设仅仅抽离 React 运行时(不包含其依赖)为单独 Chunk,且每个路由页面为单独 Chunk。某页面不依赖任何第三方库,则该页面会加载以下 Chunk
webpack.runtime.js
5KB ✅framework.runtime.js
30KB ✅page-a.chunk.js
50KB ✅vendor.chunk.js
50KB ❌ (因 webpack 依赖其object-assign
,而object-assign
将被打入共同依赖vendor.chunk.js
,因此此时它必回加载,但是该页面并不依赖任何第三方库,完全没有必要全部加载vendor.chunk.js
)
将 React 运行时及其所有依赖,共同打包,修复结果如下,拥有了更完美的打包方案。
webpack.runtime.js
5KB ✅framework.runtime.js
40KB ✅ (+10KB)page-a.chunk.js
50KB ✅
高频库
一个模块被 N(2个以上) 个 Chunk 引用,可称为公共模块,可把公共模块给抽离出来,形成 vendor.js
。
问:那如果一个模块被用了多次 (2次以上),但是该模块体积过大(1MB),每个页面都会加载它(但是无必要,因为不是每个页面都依赖它),导致性能变差,此时如何分包?
答:如果一个模块虽是公共模块,但是该模块体积过大,可直接 import()
引入,异步加载,单独分包,比如 echarts
等
问:如果公共模块数量多,导致 vendor.js 体积过大(1MB),每个页面都会加载它,导致性能变差,此时如何分包
答:有以下两个思路
- 思路一: 可对 vendor.js 改变策略,比如被引用了十次以上,被当做公共模块抽离成 verdor-A.js,五次的抽离为 vendor-B.js,两次的抽离为 vendor-C.js
- 思路二: 控制 vendor.js 的体积,当大于 100KB 时,再次进行分包,多分几个 vendor-XXX.js,但每个 vendor.js 都不超过 100KB
使用 webpack 分包
在 webpack 中可以使用 SplitChunksPlugin 进行分包,它需要满足三个条件:
- minChunks: 一个模块是否最少被 minChunks 个 chunk 所引用
- maxInitialRequests/maxAsyncRequests: 最多只能有 maxInitialRequests/maxAsyncRequests 个 chunk 需要同时加载 (如一个 Chunk 依赖 VendorChunk 才可正常工作,此时同时加载chunk数为 2)
- minSize/maxSize: chunk 的体积必须介于 (minSize, maxSize) 之间
以下是 next.js
的默认配置,可视作最佳实践
{
// Keep main and _app chunks unsplitted in webpack 5
// as we don't need a separate vendor chunk from that
// and all other chunk depend on them so there is no
// duplication that need to be pulled out.
chunks: (chunk) =>
!/^(polyfills|main|pages\/_app)$/.test(chunk.name) &&
!MIDDLEWARE_ROUTE.test(chunk.name),
cacheGroups: {
framework: {
chunks: (chunk: webpack.compilation.Chunk) =>
!chunk.name?.match(MIDDLEWARE_ROUTE),
name: 'framework',
test(module) {
const resource =
module.nameForCondition && module.nameForCondition()
if (!resource) {
return false
}
return topLevelFrameworkPaths.some((packagePath) =>
resource.startsWith(packagePath)
)
},
priority: 40,
// Don't let webpack eliminate this chunk (prevents this chunk from
// becoming a part of the commons chunk)
enforce: true,
},
lib: {
test(module: {
size: Function
nameForCondition: Function
}): boolean {
return (
module.size() > 160000 &&
/node_modules[/\\]/.test(module.nameForCondition() || '')
)
},
name(module: {
type: string
libIdent?: Function
updateHash: (hash: crypto.Hash) => void
}): string {
const hash = crypto.createHash('sha1')
if (isModuleCSS(module)) {
module.updateHash(hash)
} else {
if (!module.libIdent) {
throw new Error(
`Encountered unknown module type: ${module.type}. Please open an issue.`
)
}
hash.update(module.libIdent({ context: dir }))
}
return hash.digest('hex').substring(0, 8)
},
priority: 30,
minChunks: 1,
reuseExistingChunk: true,
},
commons: {
name: 'commons',
minChunks: totalPages,
priority: 20,
},
middleware: {
chunks: (chunk: webpack.compilation.Chunk) =>
chunk.name?.match(MIDDLEWARE_ROUTE),
filename: 'server/middleware-chunks/[name].js',
minChunks: 2,
enforce: true,
},
},
maxInitialRequests: 25,
minSize: 20000,
}