打包体积和性能优化的手段
打包性能分析
使用 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-db
npx 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,
}
}
}
}
}