打包体积和性能优化的手段
打包性能分析
使用 speed-measure-webpack-plugin 可评估每个 loader/plugin 的执行耗时
更快的 js loader:swc
当 loader 进行编译时的 AST 操作均为 CPU 密集型任务,使用 Javascript 性能低下,此时可采用高性能语言 rust 编写的 swc。
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules)/,
use: {
loader: 'swc-loader'
}
}
]
}module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules)/,
use: {
loader: 'swc-loader'
}
}
]
}开启持久化缓存
webpack5 内置了关于缓存的插件,可通过 cache 字段配置开启。
它将 Module、Chunk、ModuleChunk 等信息序列化到磁盘中,二次构建避免重复编译计算,编译速度得到很大提升。
{
cache: {
type: 'filesystem'
}
}{
cache: {
type: 'filesystem'
}
}如对一个 JS 文件配置了 eslint、typescript、babel 等 loader,他将有可能执行五次编译,被五次解析为 AST
acorn: 用以依赖分析,解析为acorn的 ASTeslint-parser: 用以 lint,解析为espree的 ASTtypescript: 用以 ts,解析为typescript的 ASTbabel: 用以转化为低版本,解析为@babel/parser的 ASTterser: 用以压缩混淆,解析为acorn的 AST
而当开启了持久化缓存功能,最耗时的 AST 解析将能够从磁盘的缓存中获取,再次编译时无需再次进行解析 AST。
得益于持久化缓存,二次编译甚至可得到与 Unbundle 的 vite 等相近的开发体验
::: 在 webpack4 中使用的是 cache-loader :::
开启多进程
thread-loader 为官方推荐的开启多进程的 loader,可对 babel 解析 AST 时开启多线程处理,提升编译的性能。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: 'thread-loader',
options: {
workers: 8
}
},
'babel-loader'
]
}
]
}
}module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: 'thread-loader',
options: {
workers: 8
}
},
'babel-loader'
]
}
]
}
}::: 在 webpack4 中使用的是 happypack 插件 :::
打包体积分析
可以使用webpack-bundle-analyzer分析打包后各模块的体积。
在查看页面中,有三个体积选项:
stat: 每个模块的原始体积parsed: 每个模块经 webpack 打包处理之后的体积,比如 terser 等做了压缩,便会体现在上边gzip: 经 gzip 压缩后的体积
JS 代码压缩
目前通常使用swc和terser来进行压缩,通过 AST 分析来生成一棵体积更小的 AST,他们拥有相同的 API。
常见方案如下
去除多余的字符、空格、换行、注释
压缩变量名、函数名、属性名
解析程序逻辑,合并声明及布尔简化
解析程序逻辑,编译预计算
Tree Shaking
基于 esm 进行静态分析,通过 AST 将没有用到的函数进行移除,减少打包体积。
垫片体积控制
垫片的作用
由于垫片的存在,打包后体积便会增加,所需支持的浏览器版本越高,垫片越少,体积就会越小。
babel 在 @babel/preset-env 中使用 core-js 作为垫片
postcss 使用 autoprefixer 作为垫片
core-js已经集成到了babel/swc之中
通过配置,babel编译代码后将会自动包含所需的polyfill
关于前端打包体积与垫片关系,我们有以下几点共识:
由于低浏览器版本的存在,垫片是必不可少的
垫片越少,则打包体积越小
浏览器版本越新,则垫片越少
垫片体积优化
那在前端工程化实践中,当我们确认了浏览器版本号,那么它的垫片体积就会确认。
假设项目只需要支持最新的两个谷歌浏览器。那么关于 browserslist 的查询,可以写作 last 2 Chrome versions。
browserslist依赖caniuse-lite的数据库,因此需要自己经常更新
npx browserslist@latest --update-dbnpx browserslist@latest --update-db该命令将会对caniuse-lite进行升级,可体现在lock文件中
常用的查询语法
用户份额
> 5%: 在全球用户份额大于 5% 的浏览器 > 5% in CN: 在中国用户份额大于 5% 的浏览器
根据最新浏览器版本
last 2 versions: 所有浏览器的最新两个版本 last 2 Chrome versions: Chrome: 浏览器的最新两个版本
不再维护的浏览器
dead: 官方不在维护已过两年,比如 IE10
浏览器版本号
Chrome > 90: Chrome: 大于 90 版本号的浏览器
分包
为什么需要进行分包,而不是使用一个大的bundle.js包
主要从 2 方面考虑
一行代码的改动将使整个 bundle.js 的缓存失效
每次页面仅需要 bundle.js 中的部分代码,因此没有必要都加载进来
如何更好地分包
可以从以下几块内容去做拆分
- 打包工具运行时
webpack 运行时代码不容易变更,可以单独抽离出来,比如webpack.runtime.js。甚至可以注入到index.html中,减少 http 请求数。
- 前端框架运行时
例如 Vue、React 的运行时代码,可以单独抽离出来framework.runtime.js。但是需要把框架和它的依赖共同抽离,否则它的依赖也会打到其它页面分包造成不必要的性能损耗。
最终结果如下
webpack.runtime.js 5KB ✅
framework.runtime.js 40KB ✅ (+10KB)
page-a.chunk.js 50KB ✅
- 高频库
1 个模块被 2 个以上的 chunk 使用,可以认为是公共模块,可以抽离出来形成 vendor.js。
问题 1:假如一个模块体积很大(超过 1MB),例如 echarts,不是每个页面都依赖它,该如何解决?
可以在需要使用它的页面,通过import()引入,通过异步加载单独分包。
问题 2:如果公共模块的数量很多,导致 vendor.js的体积很大(超过 1MB),怎么处理?**
思路一:可以对vender.js改变策略,按照被引入的频次进一步拆包
思路二:根据vender.js的体积进行分包,把大于100KB的包拆分成几个小包
webpack 的分包实现
可以使用SplitChunksPlugin进行分包
示例如下:
// webpack.config.js
{
"optimization": {
"splitChunks": {
chunks: (chunk) => {
return !/^(polyfills|main|pages\/_app)$/.test(chunk.name) &&
!MIDDLEWARE_ROUTE.test(chunk.name), // 对页面进行分包
}
cacheGroup: {
framework: {
chunks: (chunk: webpack.compilation.Chunk) => !chunk.name?.match(MIDDLEWARE_ROUTE),
name: "framework"
},
commons: {
name: 'commons',
minChunks: totalPages,
priority: 20,
}
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)
},
},
middleware: {
chunks: (chunk: webpack.compilation.Chunk) =>
chunk.name?.match(MIDDLEWARE_ROUTE),
filename: 'server/middleware-chunks/[name].js',
minChunks: 2,
enforce: true,
}
}
}
}
}// webpack.config.js
{
"optimization": {
"splitChunks": {
chunks: (chunk) => {
return !/^(polyfills|main|pages\/_app)$/.test(chunk.name) &&
!MIDDLEWARE_ROUTE.test(chunk.name), // 对页面进行分包
}
cacheGroup: {
framework: {
chunks: (chunk: webpack.compilation.Chunk) => !chunk.name?.match(MIDDLEWARE_ROUTE),
name: "framework"
},
commons: {
name: 'commons',
minChunks: totalPages,
priority: 20,
}
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)
},
},
middleware: {
chunks: (chunk: webpack.compilation.Chunk) =>
chunk.name?.match(MIDDLEWARE_ROUTE),
filename: 'server/middleware-chunks/[name].js',
minChunks: 2,
enforce: true,
}
}
}
}
}