常用优化方案
1. 区分开发环境和生产环境
- 配置 Webpack 的
mode
选项可以使用相应模式的内置优化; development
:会将DefinePlugin
中process.env.NODE_ENV
的值设置为development
,为模块和 chunk 启用有效的名;production
:会将DefinePlugin
中process.env.NODE_ENV
的值设置为production
,为模块和 chunk 启用确定性的混淆名称,启用FlagDependencyUsagePlugin
和FlagIncludedChunksPlugin
,启用ModuleConcatenationPlugin
尝试进行模块合并,启用NoEmitOnErrorsPlugin
,启用TerserPlugin
进行代码压缩(如果配置了optimization.usedExports
还会进行 TreeShaking);
关于“环境变量”需要注意的问题
1. 为什么使用 cross-env 设置环境变量
有时候我们需要用 npm scripts 设置 Node.js 的环境变量,通常都会使用 cross-env 这个库。其实设置环境变量,在 MacOS 和 linux 系统直接通过 shell 命令就可以了,例如 PORT=8066
,但是 Win 设置的方式不太一样,所以 cross-env 实际上是实现了跨平台设置环境变量。
2. .env
文件是如何生效的
可以使用 dotenv
这个库,可以将 .env
文件下的内容加载到 Node.js 的 process.env
对象中,注意 key 和 value 都是 string 类型。
3. 前端项目的环境变量是如何生效的
前端项目的环境变量,实际上不是真正的环境变量,因为浏览器环境下是访问不到 process
对象的,需要通过 DefinePlugin
在打包构建的时候,将变量替换为对应的值。
注意这里有个坑,DefinePlugin
默认直接进行文本替换,如果想要替换为字符串字面量,则需要在字符串中再加一个引号,或者用 JSON.stringify
包裹:
new webpack.DefinePlugin({
__DEV__: "true", // 替换为布尔值
"process.env.NODE_ENV": JSON.stringify("development"), // 替换为字符串字面量
})
// 源码
if (__DEV__) {
// ...
}
if (process.env.NODE_ENV === "development") {
// ...
}
// 替换得到的结果
if (true) {
// ...
}
if ("development" === "development") {
// ...
}
2. 第三方库优化
- 选择支持 Tree-Shaking 的库,例如
lodash-es
- antd 组件库使用按需引入
- 减少尺寸。1)少用依赖,2)选择轻量级的依赖,比如用 day.js 代替 moment,用 zustand 代替 redux toolkit。
- 有一些库(例如前端框架的运行时代码)可以排除掉,不打包,使用外链 CDN 的方式引入
3. 慎用 source-map
source-map 是一种将经过编译、压缩、混淆的代码代码映射回源码的技术,它能够帮助开发者迅速定位到更有意义、更结构化的源码中,方便调试。不过,同样的 source-map 操作本身也有很大性能开销,建议读者根据实际场景慎重选择最合适的 source-map 方案。
- 开发环境使用
eval-cheap-module-source-map
,确保最佳编译速度; - 生产环境不开启或者使用
hidden-source-map
;
4. 启用 CSS、less、sass 模块化
css-loader
默认会为匹配/\.module\.\w+$/i
的文件启用 CSS module- 如果不需要 CSS module,设置
options.modules = false
会提升性能,因为避免了 CSS Modules 特性的解析
最近在研究 Webpack 配置,发现了一个细节。为什么 Webpack loader 配置都是用 "style-loader"
、require.resolve("style-loader")
等方式,而不是直接 require("style-loader")
。
在 Node 中 require()
实际上是一种 JIT 性质的加载,如果用 require()
加载比较昂贵,性能开销太大。实际上这是一种惰性加载方式,把 loader 模块代码加载推迟到需要调用该 loader 时进行,可以有效提升 Webpack 启动效率,同时如果不需要调用该 loader 则无需加载此 loader 相关模块代码。
Plugin 能否用该方式加载,不行因为 Webpack 整个编译流程都是靠 tapable
事件机制驱动的,必须在 Webpack 初始化阶段就提前埋入事件钩子。
5. 启用 ESLint
在过去我们通常需要使用 eslint-loader
,然而 2021 年的当下它已经被归档,取而代之的是 eslint-webpack-plugin
。
6. 静态资源部署到 CDN
生产环境的静态资源往往会上传到 CDN 上,在独立域名上维护。关于静态资源,可以分类成两部分:
/build
,此类文件在项目中使用 require/import 引用,会被 webpack 打包并加 hash 值,并通过 publicPath 修改资源地址。可以把此类文件上传至 CDN,并加上永久缓存/public
,此类文件在项目中直接引用根路径,直接打入镜像,如果上传至 CDN 可能增加复杂度 (批量修改 publicPath)
使用 output.publicPath
配置。
const CDN_HOST = process.env.CDN_HOST; // CDN 域名
const CDN_PATH = process.env.CDN_PATH; // CDN 路径
const ENV = process.env.ENV; // 当前的环境等等
const VERSION = process.env.VERSION; // 当前发布的版本
// 依据 ENV 等动态构造 publicPath
const getPublicPath = () => `${CDN_HOST}/${CDN_PATH}/${ENV}/`;
const publicPath = process.env.NODE_ENV === 'production' ? getPublicPath() : '.';
module.exports = {
output: {
filename: 'bundle.[name][contenthash:8].js',
publicPath,
},
}
打包产物上传 CDN 作用如下:
- CDN 可以实现多点负载均衡,用户就近访问,访问速度更快,大公司也无需搞一台超级带宽的存储服务器,只需使用多台正常带宽的 CDN 节点即可
- 减小 Docker 镜像体积,只需要包含 nginx、
index.html
以及public
目录下不打包的资源 - 便于版本管理,由于 JS、CSS 等资源被 Webpack 添加哈希(版本号),可提前将静态资源部署上线(只要
index.html
不更新,用户就无法访问最新的页面),也需要保留每个历史版本,并且能实现瞬间切换版本(只需要回滚 Docker 镜像中index.html
即可)
注意,第三方库配置 externals
之后就无法 Tree-Shaking,一般用于前端框架运行时代码(本身就没有太多可以 Tree-Shaking 的余地)。
如果 external antd(v5),还需要 external react、react-dom 和 dayjs。
常规的 external,不管资源用没用到都会插入 html,但是有些资源可能只在某个路由某个按钮点击后才会用到,需要以页面的加载速度和首屏时间为代价。解法推荐官方的 按需 externals:
1、配置 externals,比如 externals: { foo: ['script //cdn/foo.js', 'Foo'] }
2、无需处理 html
3、代码里 import 'foo'
时就不会编译 foo,而是从 //cdn/foo.js 按需引
官方文档参考:
https://webpack.js.org/configuration/externals/#externalstypescript
参考:
https://webpack.docschina.org/configuration/output/#outputpublicpath
此时通过一个脚本命令 npm run uploadOss
,来把静态资源上传至 CDN。nginx 只负责返回给用户 html 入口文件和 public
目录下的内容,其他静态资源走 CDN。
此外有一些自定义 CDN 的加载,可以使用 HTMLWebpackPlugin 暴露给模板变量渲染资源标签:
const CDN_CSS = [
'https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.0/theme-chalk/index.min.css'
];
const CDN_JS = [
'https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.min.js',
'https://cdn.bootcdn.net/ajax/libs/element-ui/2.6.0/index.js',
'https://cdn.bootcdn.net/ajax/libs/element-ui/2.6.0/locale/zh-CN.min.js',
'https://cdn.bootcdn.net/ajax/libs/echarts/5.1.2/echarts.min.js',
]
module.exports = {
configureWebpack: {
// 排除一些模块,不打包,而是通过外链 CDN 的方式引入
externals: {
vue: 'Vue',
'element-ui': 'ELEMENT',
echarts: 'echarts',
}
},
chainWebpack: config => {
config.plugin('html').tap(args => {
args[0].CDN_CSS = CDN_CSS;
args[0].CDN_JS = CDN_JS;
return args
})
}
// ...
}
在 html 模板中使用定义好的 CDN 变量:
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>web</title>
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<!-- 引入样式 -->
<% for(var css of htmlWebpackPlugin.options.CDN_CSS) { %>
<link rel="stylesheet" href="<%=css%>" >
<% } %>
<!-- 引入JS -->
<% for(var js of htmlWebpackPlugin.options.CDN_JS) { %>
<script src="<%=js%>"></script>
<% } %>
</head>
<body style="font-size:14px">
<div id="app"></div>
</body>
</html>
HtmlWebpackPlugin
功能非常强大。多入口分包场景下,对资源加载顺序有非常严格的要求,而 HtmlWebpackPlugin
提供的资源列表,本身就是按顺序排序的数组,可以完美解决该问题。除了根据给定的 HTML 模板,注入 scripts
标签之外,还可以用自定义模板(不一定是 HTML),拿到模板变量自行渲染:
module.exports = {
plugins: [
new HtmlWebpackPlugin({
// 自定义模板文件
template: path.join(workDir, "config/course-catalog.html"),
// 模板输出路径(相对于 dist 目录的路径)
// 可以直接一个文件名,也可以是一个路径
filename: "course-catalog/jcc.ftl",
// 默认会注入 scripts 标签,这里关闭默认行为
// 可以用 `htmlWebpackPlugin.files.js`、`htmlWebpackPlugin.files.css` 拿到资源列表进行渲染
inject: false,
// 当 `filename` 是路径的时候,默认会用 Webpack 的 `publicPath` 生成相对路径
// 这里覆盖 Webpack 默认的 `publicPath` 配置
publicPath: "",
// 暴露给模板的变量
templateParameters: {
cssTag: "<@css_combo [",
jsTag: "<@js_combo [",
closeTag: "] />",
},
// 默认会压缩 HTML,这里关闭 HTML 压缩
minify: false,
})
]
}
注意 HtmlWebpackPlugin
的模板语法和 EJS 非常像,但实际上用的是 lodash template:
7. 启用 Long Term Cache
使用 contenthash
时,往往会增加一个小模块后,整体文件的 hash
都发生变化,原因为 Webpack 的 module.id
默认基于解析顺序自增,从而引发缓存失效。Webpack4 中使用 HashedModuleIdsPlugin
来生成 hash 值作为模块 id,在 Webpack5 中已经不需要了,可通过设置 optimization.moduleIds
设置为 'deterministic'
,保证模块 id 不会随着解析顺序的变化而变化,生产环境默认开启。
使用 Webpack 给静态资源添加 hash
,对添加 hash
的资源设置永久缓存,可大幅提高网站的缓存能力,从而大幅提高网站的二次加载性能。通过在服务器端/网关端对资源设置以下 Response Header,进行强缓存一年时间,称为永久缓存,即 Long Term Cache
。
Cache-Control: public,max-age=31536000,immutable
假设有两个文件: index.js
和 lib.js
内容如下:
// index.js
import('./lib').then(o => console.log(o))
// lib.js
export const a = 3
由 Webpack 打包之后将会生成两个 chunk(一个是根据入口配置生成的 initial chunk,另一个是 async chunk),会生成两个单独的文件。假设 lib.js
文件内容发生变更,index.js
由于引用了 lib.js
,可能包含其文件名,那么它的 hash
是否会发生变动。
不一定。打包后的 index.js
中引用 lib 时并不会包含文件名,而是采用 chunkId 的形式,如果 chunkId 是固定的话,则不会发生变更。
// 打包前
import('./lib')
// 打包后,201 为固定的 chunkId (chunkIds = deterministic 时)
__webpack_require__.e(/* import() | lib */ 201)
在 webpack 中,通过 optimization.chunkIds
可设置确定的 chunId,来增强 Long Term Cache 能力。
{
optimization: {
chunkIds: 'deterministic'
}
}
参考:
8. 合理设置分包规则
业务工程构建的时候,应该合理设置分包规则。我们知道,Webpack 实际上已经内置了缓存组规则:
module.exports = {
// ...
optimization: {
splitChunks: {
// ...
cacheGroups: {
// 1. 第三方库单独合并到一个 chunk
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
reuseExistingChunk: true,
},
// 2. 被两个及以上 Async Chunk 引用的模块,单独拆分出一个 chunk
// 为啥是 Async Chunk?因为 `chunks` 默认就是 `chunks: "async"`
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
}
}
}
- 第三方库单独拆分出一个 chunk。由于第三方库一般体积较大、更新频率低,而业务代码体积小、更新频率非常高。将业务代码与第三方库拆分,可以避免修改业务改代码后,导致第三方库缓存失效,有利于提升缓存复用率
- 被两个及以上 Async Chunk 引用的模块,单独拆分出一个 chunk。此可以防止 Async Chunk 共享模块被重复打包(Initial Chunk 也可以抽离公共模块,但是需要手动配置
chunks: "all"
)
从以上配置可以看出 Webpack 拆包规则:
- 指定
minChunks: 2
:被两个及以上 Async Chunk 引用的模块,单独拆分出一个 chunk - 指定
test
:符合test
正则的模块会被单独合并到一个 chunk - 指定
test
同时指定一个函数类型name
:符合test
正则的模块会被划分到名为name
的 chunk(可以实现node_modules
每个包都打包成单独文件)
但是 Webpack 内置拆包规则,在某些场景下会有问题。例如:
- 业务组件库。实际上也属于业务代码,需要经常更新,但是由于通过 NPM 包形式,会被 Webpack 作为第三方库处理,与其他第三方库打包为一个 chunk;
- UI 组件库。例如 antd,由于 antd 本身体积较大,实际使用的时候,一般都是按需引入、按需打包,导致 antd 打包产物更新也比较频繁;
- CSS 样式打包。
MiniCssExtractPlugin
默认会为每个入口单独抽提 CSS,但是有时需要合并为一份文件,解决 code-split CSS 加载顺序不同造成样式不一致问题(比如多个页面引入 antd 组件库,导致样式冲突问题),推荐使用 CSS-in-JS,对 Code-Splitting 和 Tree-Shaking 都比较友好。
对于业务组件库、UI 组件库,都需要单独拆包进行缓存,提升缓存复用率(不建议与其他第三方库打包到一个 chunk)。对于 CSS 某些场景需要合并为一份文件,也可借助 cacheGroups
实现:
module.exports = {
// ...
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: "all",
cacheGroups: {
// 针对业务组件库的缓存组
commons: {
test: /[\\/]node_modules[\\/]@study[\\/]/,
name: 'commons',
chunks: 'all',
},
// 针对 antd 的缓存组,每个模块独立分包进行缓存
vendors: {
test: /[\\/]node_modules[\\/](antd|@ant-design|rc-.*?)[\\/]/,
priority: 10,
// 加这句可以避免异步 chunk 的 vendor 重复问题,比如 a 和 b 都依赖 moment,不加这句 moment 会被打两遍而不是被提取出来
chunks: 'all',
// 让每个依赖拥有单独的文件和 hash
// 注意需要对 PNPM 的路径特殊处理
name(module: any) {
// e.g. node_modules/.pnpm/lodash-es@4.17.21/node_modules/lodash-es
const path = module.context.replace(/.pnpm[\\/]/, '');
const match = path.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/);
if (!match) return 'npm.unknown';
const packageName = match[1];
return `npm.${packageName
.replace(/@/g, '_at_')
.replace(/\+/g, '_')}`;
},
},
// Extracting all CSS/less in a single file
styles: {
name: 'styles',
test: /\.(c|le)ss$/,
chunks: 'all',
enforce: true,
},
}
}
}
}
前端业务工程常用的分包策略:
- 针对
node_modules
资源- 可以将
node_modules
模块打包成单独文件(通过cacheGroups
实现),防止业务代码的变更影响 NPM 包缓存,同时建议通过maxSize
设定阈值,防止vendor
包体过大 - 更激进的,如果生产环境已经部署 HTTP2/3 一类高性能网络协议,甚至可以考虑将每一个 NPM 包都打包成单独文件
- 可以将
- 针对业务代码
- 设置
common
分组,通过minChunks
配置项将使用率较高的资源合并为 Common 资源 - 首屏用不上的代码,尽量以异步方式引入
- 设置
optimization.runtimeChunk
为true
,将运行时代码拆分为独立资源
- 设置
社区和我们在项目的实践过程中,发现有一些大家在用的拆包策略。
一、大 vendors 策略
{
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: 10,
name: 'vendors',
}
}
把所有依赖合到一起,绝对不会有重复。同时缺点是,1)单文件的尺寸过大,2)毫无缓存效率可言。
info - File sizes after gzip:
215.74 kB dist/vendors.js
17.67 kB (+17 B) dist/umi.js
581 B (-573 B) dist/p__foo.async.js
579 B (-574 B) dist/p__index.async.js
282 B dist/p__index.chunk.css
282 B dist/p__foo.chunk.css
二、一个依赖一个包策略
{
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: 10,
name(module) {
// 这里是简单示例,实际上还要针对 npm client 产物格式进行处理,比如 pnpm 和 cnpm 的命名方式就不同
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
return `npm.${packageName.replace('@', '')}`;
},
}
}
和策略 1 类似,不同的是把依赖按 package name + version 进行拆分,算是解了策略 1 的尺寸和缓存效率问题。但同时带来的潜在问题是,可能导致请求较多。我的理解是,对于非大型项目来说其实还好,因为,1)单个页面的请求不会包含非常多的依赖,2)基于 HTTP/2,几十个请求不算问题。但是,对于大型项目或巨型项目来说,需要考虑更合适的方案。
info - File sizes after gzip:
144.91 kB dist/npm.core-js3.22.4.js
42.91 kB dist/npm.react-dom18.1.0_react@18.1.0.js
22.07 kB (+4.4 kB) dist/umi.js
8.11 kB dist/npm.react-router6.3.0_react@18.1.0.js
7.26 kB dist/npm.regenerator-runtime0.13.9.js
6.87 kB dist/npm.babel+runtime@7.18.9.js
4.51 kB dist/npm.history5.3.0.js
1.15 kB (+573 B) dist/p__foo.async.js
1.15 kB (+574 B) dist/p__index.async.js
282 B dist/p__index.chunk.css
282 B dist/p__foo.chunk.css
三、最接近最佳实践的策略
在 2 的基础上,做一些更细致的拆分,目前已被应用到 next.js、gatsby 等大型框架里。他包含一些规则如下,
- 每个 page(路由)一个 chunk
- 新增 framework chunk 包含 react、react-dom、react-router 等不常变更的库
- 新增 lib chunk 包含 node_modules 下尺寸大于 160kb 的依赖
- 新增 common chunk 包含所有 page(路由)都有用到的 chunk
- 新增 shared chunk 包含被 2 个或以上页面用到的 chunk
这个策略在前面两个策略之间取了一些中间值,同时又能在缓存效率上有更好的利用。以下是示例代码,方便大家更好地理解这个策略。
{
default: false,
vendors: false,
framework: {
name: 'framework',
test: new RegExp(
`(?<!node_modules.*)[\\\\/]node_modules[\\\\/](${FRAMEWORK_BUNDLES.join(
`|`,
)})[\\\\/]`,
),
priority: 40,
enforce: true,
},
commons: {
name: 'commons',
minChunks: TOTAL_PAGE_LENGTH,
priority: 20,
},
lib: {
test(module) {
return (
module.size() > 160000 &&
/node_modules[/\\]/.test(module.identifier())
);
},
name(module) {
const rawRequest =
module.rawRequest &&
module.rawRequest.replace(/^@(\w+)[/\\]/, '$1-');
if (rawRequest) return `${rawRequest}-lib`;
const identifier = module.identifier();
const trimmedIdentifier = /(?:^|[/\\])node_modules[/\\](.*)/.exec(
identifier,
);
const processedIdentifier =
trimmedIdentifier &&
trimmedIdentifier[1].replace(/^@(\w+)[/\\]/, '$1-');
return `${processedIdentifier || identifier}-lib`;
},
priority: 30,
minChunks: 1,
reuseExistingChunk: true,
},
shared: {
name(module, chunks) {
const cryptoName = crypto
.createHash('sha1')
.update(
chunks.reduce((acc, chunk) => {
return acc + chunk.name;
}, ''),
)
.digest('base64')
.replace(/\//g, '');
return `shared-${cryptoName}`;
},
priority: 10,
minChunks: 2,
reuseExistingChunk: true,
},
},
最佳实践
Umi 层会通过配置 codeSplitting: {}
支持不同的策略,包含以上三种。
export default {
codeSplitting: {
// 推荐无脑选择 `granularChunks`(即策略三),其他策略可以遇到场景时按需配置。
jsStrategy: 'bigVendors' | 'depPerChunk' | 'granularChunks',
jsStrategyOptions: {},
cssStrategy: 'mergeAll',
cssStrategyOptions: {},
},
}
推荐看一下 UMI 的分包策略:
参考:
8. 清除无用 CSS
purgecss-webpack-plugin
可以实现 CSS 代码的 Tree-Shaking,单独提取 CSS 并清除用不到的 CSS
安装插件:
$ npm i -D purgecss-webpack-plugin
添加配置:
const PurgecssWebpackPlugin = require('purgecss-webpack-plugin')
const glob = require('glob'); // 文件匹配模式
function resolve(dir){
return path.join(__dirname, dir);
}
const PATHS = {
src: resolve('src')
}
module.exports = {
plugins:[
// ...
new PurgecssPlugin({
paths: glob.sync(`${PATHS.src}/**/*`, {nodir: true})
}),
]
}