Skip to main content

常用优化方案

1. 区分开发环境和生产环境

  • 配置 Webpack 的 mode 选项可以使用相应模式的内置优化;
  • development:会将 DefinePluginprocess.env.NODE_ENV 的值设置为 development,为模块和 chunk 启用有效的名;
  • production:会将 DefinePluginprocess.env.NODE_ENV 的值设置为 production,为模块和 chunk 启用确定性的混淆名称,启用 FlagDependencyUsagePluginFlagIncludedChunksPlugin,启用 ModuleConcatenationPlugin 尝试进行模块合并,启用 NoEmitOnErrorsPlugin,启用 TerserPlugin 进行代码压缩(如果配置了 optimization.usedExports 还会进行 TreeShaking);
tip

关于“环境变量”需要注意的问题

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 包裹:

webpack.config.js
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 特性的解析
tip

最近在研究 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,
},
}
tip

打包产物上传 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

参考:

2021 年当我们聊前端部署时,我们在聊什么

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>
tip

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:

https://lodash.com/docs/4.17.15#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.jslib.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'
}
}

参考:

什么是 Long Term Cache

缓存 - Webpack 中文文档

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"
tip

从以上配置可以看出 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 实现:

webpack.config.js
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.runtimeChunktrue,将运行时代码拆分为独立资源
tip

社区和我们在项目的实践过程中,发现有一些大家在用的拆包策略。

一、大 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 的分包策略:

https://umijs.org/docs/api/config#codesplitting

https://github.com/umijs/umi/blob/master/packages/preset-umi/src/features/codeSplitting/codeSplitting.ts

参考:

Webpack5 核心原理与应用实践

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})
}),
]
}