Webpack5+TypeScript:B端深度优化实践

前言

本项目为 B 端管理端部分中的主架构项目,整体应用是基于 React+TypeScript 技术栈,使用 Webpack 5 构建,通过微前端解决方案 QianKun 加载其他子项目,共同完成技术实现。

SAAS 端架构体系:

SAAS端架构

本项目已经过一次 webpack4->webpack5 的升级,相关的 webpack5 已配置并实施一些基础优化。但依旧存在一些待进一步优化的地方,如:

  • js/css polyfill 未引入/未生效
  • 构建耗时、开发重载耗时太长
  • 构建体积较大,首屏加载资源过多
  • 地图资源在构建中低概率出错
  • 其他配置优化等等

C 端考虑页面性能、用户体验,而 B 端项目更考虑降本增效、开发体验,从这些角度并结合上述存在的问题,开始了我们的优化之旅。

本文构建环境:MacBook Pro 16 2.6 GHz 六核 Intel Core i7

webpack5 相关补充配置

控制台输出

通过对 stats 的个性化配置,优化控制台的输出:

1
2
3
4
5
6
7
8
9
stats: {
chunks: false,
assetsSpace: 1,
moduleAssets: false,
modules: false,
builtAt: true,
timings: true,
hash: true,
},

status

资源模块

通过使用 asset 替换之前相关 loader 的功能(相关 loader 已停止更新):

  • asset/resource 将资源分割为单独的文件,并导出 url (file-loader)
  • asset/inline 将资源导出为 dataURL(url(data:)) 的形式 (url-loader)
  • asset/source 将资源导出为源码(source code)(raw-loader)
  • asset 自动选择导出为单独文件或者 dataURL 形式,默认为 8KB (url-loader limit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
output: {
assetModuleFilename: 'assets/[name].[contenthash:4][ext]',
},
module: {
rules: [
{
test: /\.(png|jpg|svg|jpeg|gif|woff|woff2|eot|ttf)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 4 * 1024,
},
},
},
],
},

编译优化

typescript 编译

优化前 typescript 使用自带的 tyepscript compiler(tsc) 编译,这也是之前业界最普遍的编译方案。通过配置 tsconfig.json 来指定如何编译,在 webpack 里通过 ts-loader 执行。编译到指定版本的目标 js 代码后,还需要通过 babel 再次编译加入支持更多特性的 polyfill(如 jsx)。这样整个项目编译链路为:

tsc编译

babel7 开始,支持 typescript 的编译,于是将编译工具由 ts-loader 转为 babel-loader,将编译链路缩短为:

babel编译

babel 的做法是:不做类型检查,并直接把类型信息去掉即可,所以编译速度会很快,除了 ts 历史遗留风格的 import/export 语法不支持之外,其他都可以支持。

tsc 的类型检查需要拿到整个工程的类型信息,需要做类型的 import、多个文件的 namespace、enum、interface 等的合并,而 babel 针对单文件编译,不会解析其他类型文件。所以无法跟 tsc 一样做类型检查。

编译性能对比

同一环境下,对比 tsc(ts-loader)tsc(ts-loader transpileOnly 省去类型检查和输出声明文件)、babel(babel-loader)三者构建数据:

构建耗时 产物体积
tsc 编译 64.49s 5.4MB
tsc 编译(transpileOnly) 51.63s 5.4MB
babel 编译 38.21s 4.4MB

babel 编译对比 tsc 编译总构建耗时下降到 38.21s,耗时缩减 40.75%;产物体积减少 1MB,体积缩减 18.51%;

之所以 tsc编译 体积更大,是因为无法指定目标运行浏览器版本,而全量 polyfill

typescript 最佳配置

对比 tsc(ts-loader) 进行 typescript 的检查和编译,babel 在编译上耗时更低,产物体积更小,并支持更多 es 特性。配合插件 fork-ts-checker-webpack-plugin (该插件读取 tsconfig.json 的配置)进行类型检查,可完全替代 ts-loader 编译检查方案。

并且优化前的 babel 配置未生效,故一并做了一些相关优化,优化后配置:

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
//...
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /\/node_modules/,
include: [resolve('../src')],
use: [
{
loader: 'babel-loader',
options: {
compact: true,
},
},
],
},
];
}
plugins: [new ForkTsCheckerWebpackPlugin()];
//...

babel.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
module.exports = {
presets: [
[
'@babel/env',
{
targets: {
browsers: ['last 1 Chrome versions'],
},
useBuiltIns: 'usage',
corejs: 3,
},
],
'@babel/preset-react',
[
'@babel/preset-typescript',
{
isTSX: true,
allExtensions: true,
optimizeConstEnums: true,
},
],
],
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
[
'@babel/plugin-transform-runtime',
{
corejs: false,
regenerator: false,
},
],
],
};

以上配置方案参考:基于实践探寻 babel 7 最佳配置方案@babel/preset-typescriptbabel 编译 typescript 的插件。另强烈建议开启 optimizeConstEnums 配置:

optimizeConstEnums

css 编译

css polyfill

生产模式接入 postcsspostcss-preset-env 来添加 polyfill 处理 css 兼容问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const postcssPresetEnv = require('postcss-preset-env');
//...
use: [
//...
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [postcssPresetEnv({ browsers: 'last 2 versions' })],
},
},
},
//...
];
//...

css 压缩

配置 css-minimizer-webpack-plugin 压缩 css

1
2
3
4
5
6
7
8
9
10
11
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
//...
optimization: {
minimizer: [
//...
new CssMinimizerPlugin({
exclude: /\/node_modules\/@tencent\/tea-sr\/css\/tea-sr\.css/,
}),
];
}
//...

注意排除掉已压缩的库 css 文件,减少编译耗时。

增加热更新能力

优化前在开发环境下项目每一次修改代码保存编译后,浏览器都会全量刷新,故引入 React Fast Refresh 热替换方案:

1
npm install @pmmmwh/react-refresh-webpack-plugin react-refresh -D

webpack.dev.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
//...
module: {
rules: [
//...
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
plugins: ['react-refresh/babel'],
},
},
],
},
]
},
devServer: {
hot: true,
}
plugins: [
//...
new ReactRefreshWebpackPlugin({
overlay: false,
}),
],
//...

webpack 5 在开启 hot:true 时会自动添加 HotModuleReplacementPlugin,即无需重复引入。

优化后执行 webpack serve 效果如图:

react-refresh

修改代码保存后,但输入框内状态未重置,页面局部刷新,大幅提高开发效率和开发体验。

构建优化

cache 缓存构建

webpack 4 时代,我们通过以下方式缓存构建:

  • babel options: cacheDirectory
  • cache-loader
  • hard-source-webpack-plugin

随着 webpack5 cache 的到来,上述方案均可放弃,只需完成 cache 相关配置即可:

1
2
3
4
5
6
7
8
cache: {
type: 'filesystem',
cacheDirectory: resolve('../.webpack_build_cache'),
maxAge: 5 * 24 * 60 * 60 * 1000,
buildDependencies: {
config: [__filename],
},
},

另比较下 webpack5 cachecacheDirectory 的构建耗时数据(cache-loaderhard-source-webpack-plugin 同理,此处不在累述):

cache 耗时对比

可以看到仅仅设置 cache,对从二次构建开始的提升速度非常大,而在 webpack 5cacheDirectory 对编译提升的效果不明显。

DllPlugin

DllPlugin 原理则是事先把常用但又构建时间长的代码提前打包(例如 react、react-dom)为 dll(动态链接库)。后面再构建的时候就直接使用 dll,不再重复构建。这样可以减少构建耗时,提高编译速度。

webpack.dll.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const path = require('path');
const webpack = require('webpack');
const resolve = (...arg) => path.resolve(__dirname, ...arg);

const dllListMap = {
citycode: [resolve('../src/constant/city_code.json')],
andv: ['@antv/data-set', '@antv/g2'],
react: ['react', 'react-dom', 'react-router', 'react-router-dom'],
moment: ['moment'],
tencent: ['@tencent/aegis-web-sdk', '@tencent/sra', '@tencent/tea-component'],
};

module.exports = {
mode: 'production',
entry: dllListMap,
output: {
filename: 'dll.[name].js',
path: resolve('./dll'),
library: 'dll_[name]',
crossOriginLoading: 'anonymous',
},
module: {
rules: [
{
test: /\.(js|ts)x?$/,
exclude: /node_modules/,
include: path.resolve('./src'),
use: ['babel-loader'],
},
//...
],
},
plugins: [
//...
new webpack.DllPlugin({
context: resolve('..'),
path: resolve('./dll/manifest/[name].manifest.json'),
name: 'dll_[name]',
entryOnly: true,
}),
],
};

webpack.dev.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const dllConfig = () => {
const plugins = [];
const dllFiles = fs.readdirSync(resolve('./dll'));
dllFiles.forEach((item) => {
if (/^dll\..*\.js$/.test(item)) {
plugins.push(
new AddAssetHtmlPlugin({
filepath: resolve(`./dll/${item}`),
outputPath: `./dll`,
publicPath: '/dll/',
})
);
return;
}
if (item === 'manifest' && fs.lstatSync(resolve(`./dll/${item}`)).isDirectory()) {
fs.readdirSync(resolve(`./dll/${item}`)).forEach((json) => {
plugins.push(
new webpack.DllReferencePlugin({
context: resolve(''),
manifest: resolve(`./dll/manifest/${json}`),
})
);
});
}
});
return plugins;
};
//...
plugins: [
//...
...dllConfig(),
],

dllplugin 耗时数据对比:

dllplugin 耗时对比

可以看出在 webpack 5 中,无论是 development 模式还是 production 模式,webpack 5 的优化性能足够,DllPlugin 实际效果都并不明显,并且会增加 dll 维护成本和开发人员的理解门槛,故放弃此方案。

thread-loader

放置在这个 thread-loader 之后的 loader 就会在一个单独的 worker 池(worker pool)中运行,

1
2
3
4
5
6
7
8
9
use: [
'thread-loader',
{
loader: 'babel-loader',
options: {
compact: true,
},
},
],

thread-loader 耗时数据对比:

thread-loader 耗时对比

因本项目较小,并且 thread-loader 进程启动大概还需 600ms,进程通信也有开销,故耗时优化效果不大,同样放弃此方案。

IgnorePlugin

IgnorePlugin 忽略第三方包指定目录,该目录内部不会被打包。

业务代码入口:

1
2
3
import moment from 'moment';
import 'moment/locale/zh-cn';
moment.locale('zh-cn');

webpack.config.js

1
2
3
4
5
6
7
plugins: [
//...
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/,
}),
];

对于 moment 第三方依赖库,该库主要是对时间进行格式化并且支持多个国家语言,所以可将其他语种忽略掉,只保留中文模块即可。

splitChunks+懒加载

启用懒加载机制按需加载资源,可提高首屏加载性能。

启用懒加载:

1
2
// import Resource from './pages/resource/App';
const Resource = React.lazy(() => import('./pages/resource/App'));

切割资源:

通过对业务代码的的模块相关引用分析,对不同业务模块进行各自的分割;并对第三方库的引用关系分析,将体积大的库和首页渲染不相关的库分割出来。

本项目切割思路:

  1. css 资源单独分割为一个模块;
  2. 将一些组件库各自分割为不同模块;
  3. 将非首页但体积较大的库单独分割出去;
  4. vendor 模块兜底打包剩余 node_modules 里的模块;
  5. 分割非首页加载的业务代码里比较大的模块;
  6. custom 兜底打包剩余业务代码;

配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
splitChunks: {
chunks: 'all',
automaticNameDelimiter: '.',
name(module, chunks, cacheGroupKey) {
return `${cacheGroupKey}`;
},
cacheGroups: {
styles: {
type: 'css/mini-extract',
maxSize: 400000,
minSize: 100000,
enforce: true,
priority: 100,
},
'tencent-tea': {
test: /[\\/]node_modules\/@tencent\/tea-component[\\/]/,
maxSize: 600000,
minSize: 400000,
priority: 100,
},
'tencent-sra': {
test: /[\\/]node_modules\/@tencent\/sra[\\/]/,
priority: 100,
},
//...其他库
vendors: {
test: /[\\/]node_modules[\\/]/,
maxSize: 700000,
minSize: 500000,
priority: 80,
},
'custom-a': {
test: /[\\/]src\/pages\/custom\/a[\\/]/,
priority: 70,
},
//...其他业务代码
custom: {
priority: 20,
},
},
},

splitChunks 拆包优化前后对比

splitChunks 拆包对比

优化前 优化后
总体积 8.91 MB 4.2 MB
gzip 体积 2.61 MB 1.14 MB
最大子包体积 1.75 MB 747.44 KB
子包名语义化
拆包精细度

本项目启用了 HTTP2,利用 HTTP2 的多路复用,可在同一 TCP 连接同时发出多个 HTTP 请求,故无需刻意合并资源。

异步 script

将一些更新频率极低、体积较大、使用场景单一的独立模块独立打包后放至 CDN,然后在业务里通过 js 异步加载的方式引入该模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
export function addScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.addEventListener('load', () => {
resolve();
});
script.addEventListener('error', (e: ErrorEvent) => {
reject(e);
});
document.body.appendChild(script);
});
}

业务代码引入:

1
2
3
4
5
6
7
8
9
10
11
12
useEffect(() => {
(async () => {
try {
if (window.chinaGeojson) {
return;
}
await addScript(DATA_MAP_CDN);
} catch {
console.log('地图资源加载失败');
}
})();
}, []);

此方案应用于地图数据相关资源模块(chinaGeojson),解决了地图频繁构建并低概率出错的问题,并缩减了项目构建体积和构建耗时。

优化前后数据对比

构建耗时对比

构建耗时对比

耗时对比 优化前(dev) 优化后(dev) 优化前(prod) 优化后(prod)
第一次 62.81 21.32 88.1 37.95
第二次 7.91 1.04 79.29 17.79
第三次 5.44 0.69 79.3 15.93
  • 启动开发环境耗时由 62.81s 缩减为 21.32s,耗时降低 66.05%;
  • 开发环境修改代码增量编译从原先的 7.91s 缩减为 1.04s,耗时缩减 86.9%;
  • 生产环境编译从原先的 88.1s 缩减为 37.95s,耗时降低 57.43%。经过多次编译后耗时降低 79.9%;

优化前 dev 重编译耗时大幅降低是因为开启了 cache 的缘故。

构建体积对比

构建体积对比

优化后产物体积减少 4826KB,体积缩减 49.93%。

首屏资源加载对比

首屏资源加载对比

加载资源数 加载资源总体积
优化前 16 个 1.1MB(transferred)
优化后 11 个 669KB(transferred)

首屏加载资源数减少 5 个,加载资源体积减少 457KB,缩减 40.6%。

总结

本次优化如下:

  1. 优化控制台输出,去除冗余信息,减少视觉负担;
  2. 优化资源模块配置,去除 url-loader 等相关 loader 依赖,使用内置 asset;
  3. 摒弃 ts-loader,使用 babel-loader + fork-ts-checker-webpack-plugin 来编译ts并类型检查,缩短编译链路加快编译速度;
  4. 采用基于实践探寻 babel 7 最佳配置方案修复之前未引入 JS polyfill 的问题;
  5. 引入 postcsscss 添加 polyfill
  6. 配置 css-minimizer-webpack-plugin 压缩 css,优化减小资源体积;
  7. 引入 React Fast Refresh 增加项目开发时热更新的能力,大幅提高开发效率和开发体验;
  8. 通过对 DllPluginthread-loadercacheDirectory 等缓存类方案对比,得出只需配置 webpack cache 即可达到最优缓存效果;
  9. 引入 IgnorePlugin 忽略依赖中部分无关模块,减少构建资源体积;
  10. 通过合理配置 splitChunks 代码切割+懒加载,可以优化请求资源体积和首屏资源数,加快请求时间和首屏响应时间;
  11. 通过异步 script 来加载体积巨大且更新频率极低的模块,使其脱离构建流程,加快构建速度;

优化不止步:

  • 根据 agais 监控等数据进一步针对性优化;
  • 分包策略进一步优化;
  • es 导入 tree sharing(嵌套 commonjs 无法优化);
  • 更多页面懒加载优化;
  • 依赖包更新&分析优化引入;
  • 统一 UI 库、react from 库等,减少同质库的引用;
  • 分离其他业务成独立微前端子项目;
  • noParse & purgecss-webpack-plugin 等;

优化初衷除了解决存在的问题,保证构建环节的稳定性之外,B 端项目更希望能同时提升本地的开发效率和开发体验,所以大部分优化都围绕 webpack 生态来进行,但如果有更好的方案(vite?),完全可以抛弃现有的构建流程。所以优化 webpack 构建只是手段,不是目的。优化之路漫漫长远,webpack 生态带来丰富的功能,但放下 webpack,我们的面前就是星辰大海。

参考

  • webpack
  • webpack 5 新特性
  • 深入研究:HTTP2 的真正性能到底如何