Webpack5+TypeScript:腾讯优码 SASS 端深度优化实践
腾讯优码全链路数字化解决方案,以“一物一码”为依托,从市场营销优化、商品防伪溯源、渠道终端管理、数据中台搭建等方向,驱动人、货、场的全链路融合,全面助力企业实现数字化升级。
前言
本项目为腾讯优码体系里的 SAAS
管理端部分中的主架构项目,整体应用是基于 React+TypeScript
技术栈,使用 Webpack 5
构建,通过微前端解决方案 QianKun
加载其他子项目,共同完成技术实现。
SAAS
端架构体系:
本项目已经过一次 webpack4->webpack5
的升级,相关的 webpack5
已配置并实施一些基础优化。但依旧存在一些待进一步优化的地方,如:
js/css polyfill
未引入/未生效- 构建耗时、开发重载耗时太长
- 构建体积较大,首屏加载资源过多
- 地图资源在构建中低概率出错
- 其他配置优化等等
C
端考虑页面性能、用户体验,而 B
端项目更考虑降本增效、开发体验,从这些角度并结合上述存在的问题,开始了我们的优化之旅。
本文构建环境:MacBook Pro 16 2.6 GHz 六核 Intel Core i7
webpack5 相关补充配置
控制台输出
通过对 stats
的个性化配置,优化控制台的输出:
stats: {
chunks: false,
assetsSpace: 1,
moduleAssets: false,
modules: false,
builtAt: true,
timings: true,
hash: true,
},
资源模块
通过使用 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
)
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
)。这样整个项目编译链路为:
在 babel7
开始,支持 typescript
的编译,于是将编译工具由 ts-loader
转为 babel-loader
,将编译链路缩短为:
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
:
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
:
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-typescript
为 babel
编译 typescript
的插件。另强烈建议开启 optimizeConstEnums
配置:
css 编译
css polyfill
生产模式接入 postcss
和 postcss-preset-env
来添加 polyfill
处理 css
兼容问题:
const postcssPresetEnv = require('postcss-preset-env');
//...
use: [
//...
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [postcssPresetEnv({ browsers: 'last 2 versions' })],
},
},
},
//...
];
//...
css 压缩
配置 css-minimizer-webpack-plugin
压缩 css
:
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
热替换方案:
npm install @pmmmwh/react-refresh-webpack-plugin react-refresh -D
webpack.dev.js
:
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
效果如图:
修改代码保存后,但输入框内状态未重置,页面局部刷新,大幅提高开发效率和开发体验。
构建优化
cache 缓存构建
在 webpack 4
时代,我们通过以下方式缓存构建:
babel options: cacheDirectory
cache-loader
hard-source-webpack-plugin
随着 webpack5 cache
的到来,上述方案均可放弃,只需完成 cache
相关配置即可:
cache: {
type: 'filesystem',
cacheDirectory: resolve('../.webpack_build_cache'),
maxAge: 5 * 24 * 60 * 60 * 1000,
buildDependencies: {
config: [__filename],
},
},
另比较下 webpack5 cache
和 cacheDirectory
的构建耗时数据(cache-loader
,hard-source-webpack-plugin
同理,此处不在累述):
可以看到仅仅设置 cache
,对从二次构建开始的提升速度非常大,而在 webpack 5
中 cacheDirectory
对编译提升的效果不明显。
DllPlugin
DllPlugin
原理则是事先把常用但又构建时间长的代码提前打包(例如 react、react-dom
)为 dll
(动态链接库)。后面再构建的时候就直接使用 dll
,不再重复构建。这样可以减少构建耗时,提高编译速度。
webpack.dll.js
:
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
:
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
耗时数据对比:
可以看出在 webpack 5
中,无论是 development
模式还是 production
模式,webpack 5
的优化性能足够,DllPlugin
实际效果都并不明显,并且会增加 dll
维护成本和开发人员的理解门槛,故放弃此方案。
thread-loader
放置在这个 thread-loader
之后的 loader
就会在一个单独的 worker
池(worker pool
)中运行,
use: [
'thread-loader',
{
loader: 'babel-loader',
options: {
compact: true,
},
},
],
thread-loader
耗时数据对比:
因本项目较小,并且 thread-loader
进程启动大概还需 600ms
,进程通信也有开销,故耗时优化效果不大,同样放弃此方案。
IgnorePlugin
IgnorePlugin
忽略第三方包指定目录,该目录内部不会被打包。
业务代码入口:
import moment from 'moment';
import 'moment/locale/zh-cn';
moment.locale('zh-cn');
webpack.config.js
:
plugins: [
//...
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/,
}),
];
对于 moment
第三方依赖库,该库主要是对时间进行格式化并且支持多个国家语言,所以可将其他语种忽略掉,只保留中文模块即可。
splitChunks+懒加载
启用懒加载机制按需加载资源,可提高首屏加载性能。
启用懒加载:
// import Resource from './pages/resource/App';
const Resource = React.lazy(() => import('./pages/resource/App'));
切割资源:
通过对业务代码的的模块相关引用分析,对不同业务模块进行各自的分割;并对第三方库的引用关系分析,将体积大的库和首页渲染不相关的库分割出来。
本项目切割思路:
- 将
css
资源单独分割为一个模块; - 将一些组件库各自分割为不同模块;
- 将非首页但体积较大的库单独分割出去;
- 用
vendor
模块兜底打包剩余node_modules
里的模块; - 分割非首页加载的业务代码里比较大的模块;
- 用
custom
兜底打包剩余业务代码;
配置如下:
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 拆包优化前后对比
优化前 | 优化后 | |
---|---|---|
总体积 | 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
异步加载的方式引入该模块:
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);
});
}
业务代码引入:
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%。
总结
本次优化如下:
- 优化控制台输出,去除冗余信息,减少视觉负担;
- 优化资源模块配置,去除
url-loader
等相关loader
依赖,使用内置asset
; - 摒弃
ts-loader
,使用babel-loader + fork-ts-checker-webpack-plugin
来编译ts
并类型检查,缩短编译链路加快编译速度; - 采用基于实践探寻 babel 7 最佳配置方案修复之前未引入
JS polyfill
的问题; - 引入
postcss
为css
添加polyfill
; - 配置
css-minimizer-webpack-plugin
压缩 css,优化减小资源体积; - 引入
React Fast Refresh
增加项目开发时热更新的能力,大幅提高开发效率和开发体验; - 通过对
DllPlugin
、thread-loader
、cacheDirectory
等缓存类方案对比,得出只需配置webpack cache
即可达到最优缓存效果; - 引入
IgnorePlugin
忽略依赖中部分无关模块,减少构建资源体积; - 通过合理配置
splitChunks
代码切割+懒加载,可以优化请求资源体积和首屏资源数,加快请求时间和首屏响应时间; - 通过异步
script
来加载体积巨大且更新频率极低的模块,使其脱离构建流程,加快构建速度;
优化不止步:
- 根据
agais
监控等数据进一步针对性优化; - 分包策略进一步优化;
es
导入tree sharing
(嵌套commonjs
无法优化);- 更多页面懒加载优化;
- 依赖包更新&分析优化引入;
- 统一
UI
库、react from
库等,减少同质库的引用; - 分离其他业务成独立微前端子项目;
noParse & purgecss-webpack-plugin
等;
优化初衷除了解决存在的问题,保证构建环节的稳定性之外,B
端项目更希望能同时提升本地的开发效率和开发体验,所以大部分优化都围绕 webpack
生态来进行,但如果有更好的方案(vite
?),完全可以抛弃现有的构建流程。所以优化 webpack
构建只是手段,不是目的。优化之路漫漫长远,webpack
生态带来丰富的功能,但放下 webpack
,我们的面前就是星辰大海。