Skip to content

Webpack5+TypeScript: 腾讯优码 SASS 端深度优化实践 ​

作者:korey zhao

腾讯优码全链路数字化解决方案,以“一物一码”为依托,从市场营销优化、商品防伪溯源、渠道终端管理、数据中台搭建等方向,驱动人、货、场的全链路融合,全面助力企业实现数字化升级。

前言 ​

本项目为腾讯优码体系里的 SAAS 管理端部分中的主架构项目,整体应用是基于 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 的个性化配置,优化控制台的输出:

js
  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)
js
  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.49s5.4MB
tsc 编译(transpileOnly)51.63s5.4MB
babel 编译38.21s4.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:

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:

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 配置:

optimizeConstEnums

css 编译 ​

css polyfill ​

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

js
const postcssPresetEnv = require('postcss-preset-env');
//...
use: [
  //...
  {
    loader: 'postcss-loader',
    options: {
      postcssOptions: {
        plugins: [postcssPresetEnv({ browsers: 'last 2 versions' })],
      },
    },
  },
  //...
];
//...

css 压缩 ​

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

js
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 热替换方案:

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

webpack.dev.js:

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 效果如图:

react-refresh

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

构建优化 ​

cache 缓存构建 ​

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

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

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

js
  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 耗时对比

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

DllPlugin ​

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

webpack.dll.js:

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:

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 耗时数据对比:

dllplugin 耗时对比

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

thread-loader ​

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

js
 use: [
        'thread-loader',
        {
          loader: 'babel-loader',
          options: {
            compact: true,
          },
        },
      ],

thread-loader 耗时数据对比:

thread-loader 耗时对比

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

IgnorePlugin ​

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

业务代码入口:

js
import moment from 'moment';
import 'moment/locale/zh-cn';
moment.locale('zh-cn');

webpack.config.js:

js
plugins: [
  //...
  new webpack.IgnorePlugin({
    resourceRegExp: /^\.\/locale$/,
    contextRegExp: /moment$/,
  }),
];

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

splitChunks+懒加载 ​

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

启用懒加载:

js
// 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 兜底打包剩余业务代码;

配置如下:

js
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 MB4.2 MB
gzip 体积2.61 MB1.14 MB
最大子包体积1.75 MB747.44 KB
子包名语义化否是
拆包精细度低高

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

异步 script ​

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

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);
  });
}

业务代码引入:

js
useEffect(() => {
  (async () => {
    try {
      if (window.chinaGeojson) {
        return;
      }
      await addScript(DATA_MAP_CDN);
    } catch {
      console.log('地图资源加载失败');
    }
  })();
}, []);

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

优化前后数据对比 ​

构建耗时对比 ​

构建耗时对比

耗时对比优化前(dev)优化后(dev)优化前(prod)优化后(prod)
第一次62.8121.3288.137.95
第二次7.911.0479.2917.79
第三次5.440.6979.315.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. 引入 postcss 为 css 添加 polyfill;
  6. 配置 css-minimizer-webpack-plugin 压缩 css,优化减小资源体积;
  7. 引入 React Fast Refresh 增加项目开发时热更新的能力,大幅提高开发效率和开发体验;
  8. 通过对 DllPlugin、thread-loader、cacheDirectory 等缓存类方案对比,得出只需配置 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,我们的面前就是星辰大海。

参考 ​