Skip to content

基于 Webpack 的构建速度优化

背景

最近前后端项目放在一起使用 maven 打包,但是前端项目打包npm run build严重拖累整个项目打包效率,因此研究一下 vue-cli 构建优化。

项目技术栈:vue-cli 5Webpack 5vue 2.6

当前项目文件内容非常简单,只有一个页面及相应的业务文件,但是当前构建速度长达75s+,不过先不着急进行优化,先明确下几个问题以及对应的目标。

问题

  1. 当前构建为什么这么慢?
  2. 优化的目标是什么?
  3. 优化策略有哪些?
  4. 其他构建工具性能对比

原因分析及目标

当前项目文件非常简单,而构建速度已经惨不入目了,并且随着项目的增大,构建的效率会变得越来越慢。

Webpack 的构建流程,主要时间花费在递归遍历各个入口文件,并基于入口文件不断寻找依赖逐个编译再递归处理的过程,每次递归都需要经历 String->AST->String 的流程,然后通过不同的 loader 处理一些字符串 或者执行一些 JavaScript 脚本,由于 NodeJS 单线程的特性以及语言本身的效率限制,Webpack 构建慢一直成为它饱受诟病的原因。

潜在原因

  1. 项目打包体积是否过大,是否合理分配文件;
  2. vue.config.js是否存在耗时配置或优化余地;
  3. 未经过预处理或压缩的第三方库,导致babel过多转译;
  4. 未使用并行缓存

目标

  1. 打包体积尽可能小;
  2. 功能正常的情况下,尽可能优化构建速度

接下来根据优化方向一步步测试性能。

构建性能优化

查看dist文件

首先查看打包后的dist文件是否合理,是否存在文件过大的情况。

image.png

并无异常,chunk-elementUIchunk-libs是执行分包策略后的产物。

一般情况下可以使用webpack-bundle-analyzer来分析项目的构建结果,以识别过大的模块、重复的依赖和不必要的代码,可自行尝试。

但是查看dist文件夹时,我发现输出了map文件。

image.png

该项目并不需要在生产环境中开启source-map,因此根据https://cli.vuejs.org/config/#productionsourcemap 关闭source-map

bash
productionSourceMap: false

Setting this to false can speed up production builds if you don't need source maps for production.

去除后再次打包,减少了1-2s左右,收敛微弱,需要进一步优化。

合理配置路径解析

jsx
resolve: {
	extensions: ['.js', '.vue', '.json', '.scss']
}

webpack 会按照配置的扩展名顺序依次查找文件,将最常用的扩展名放在前面,可以减少不必要的查找,相较于json文件项目中.scss文件创建的更多,因此改良后的解析顺序为:

jsx
resolve: {
	extensions: ['.js', '.vue', '.scss', '.json']
}

再次打包,同样减少了1-2s左右,需要进一步通过查看耗时进行优化。

查看耗时

使用SpeedMeasurePlugin插件来看一下项目的构建过程中各个阶段的耗时情况。

speed-measure-webpack-plugin 是一款统计 webpack 打包时间的插件,不仅可以分析总的打包时间,还能分析各阶段loader的耗时,并且可以输出一个文件用于永久化存储数据。

jsx
// 安装
npm install speed-measure-webpack-plugin --save-dev
jsx
// 使用方式
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();

module.exports = {
  configureWebpack: smp.wrap({
    // 在这里配置原本的Webpack配置
    // 可以定义entry、output、module等配置
  })
};

image.png

可以看出主要的loader耗时是babel-loader

babel-loader

babel-loader 是具体负责转译 JavaScript 文件的加载器,它通过 Babel 来将现代 JavaScript 转换为旧版本浏览器支持的代码。但是当前项目中babel也处理了第三方库代码,因此尝试排除node-modules文件。

jsx
config.module
	.rule('js')
	.test(/\.js$/)
	.exclude.add(/node_modules/)
	.end()
	.use('babel-loader')
	.loader('babel-loader')

此时babel-loader耗时已减少了一半。

image.png

注意,在很多优化策略中给出babel-loader需要再加上cache-loader处理进行缓存以及thread-loader并行处理,配置如下:

jsx
const os = require('os')
...
config.module
	.rule('js')
	.test(/\.js$/)
	.exclude.add(/node_modules/)
	.end()
	.use('thread-loader')
	.loader('thread-loader')
	.options({
		workers: os.cpus().length - 1 // 用CPU核心数量减1的线程数
	})
	.use('babel-loader')
	.loader('babel-loader')
	.tap((options) => {
		 options.cacheDirectory = true
		 return options
	})

但是亲测后效果并不明显,查询vue-cli V4升级到V5说明后才发现 vue-cli V5已经去除cache-loaderMigrate-from-v4

继续查看文档,我发现cachingParallelization功能已默认开启: image.png

transpileDependencies

在查询vue-cli文档时,我查看到一个和babel-loader相关的属性transpileDependenciestranspileDependencies

默认情况下 babel-loader 会忽略所有 node_modules 中的文件。你可以启用本选项,以避免构建后的代码中出现未转译的第三方依赖。 不过,对所有的依赖都进行转译可能会降低构建速度。如果对构建性能有所顾虑,你可以只转译部分特定的依赖:给本选项传一个数组,列出需要转译的第三方包包名或正则表达式即可。 检查vue.config.js文档后,果然设置成了true,暂时修改为false后再次构建:

那么transpileDependencies直接设置为false会有什么影响吗?怎么检查一些库需要经过babel转换呢? 这里就涉及到babel转译的细节了,另一篇文章将详细介绍。

文件系统缓存

除了优先查看Vue-cli配置,还应该查看更为直接的Webpack配置。Webpack 5 引入了内置的持久化缓存功能cache,能够直接在磁盘或内存中缓存编译结果。这比 cache-loader 更高效,因为 cache-loader 只能在特定的loader链中使用,而 Webpack 5 的缓存是全局的,能够覆盖整个构建过程,包括 loader、模块解析、以及其他构建步骤。其次需要注明的一点是社区提供 HardSourceWebpackPlugin 实现持久化缓存,Webpack 5是对这功能进行官方支持与优化,所以在一些解决方案中提到HardSourceWebpackPlugin,确认下是否低于Webpack 5。配置如下:

js
//配置
configureWebpack: smp.wrap({
  cache: {
      type: 'filesystem', // 使用文件系统缓存
      cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'), // 可选:自定义缓存目录
      buildDependencies: {
        config: [__filename], // 当配置文件改变时,缓存失效,webpack推荐写法
      },
    },
})

buildDependencies

cache.buildDependencies 是一个针对构建的额外代码依赖的数组对象。webpack 将使用这些项和所有依赖项的哈希值来使文件系统缓存失效。

默认是 webpack/lib 来获取 webpack 的所有依赖项。 推荐在 webpack 配置中设置 cache.buildDependencies.config: [__filename]来获取最新配置以及所有依赖项。

cacheDirectory

缓存目录,仅当 cache.type 被设置成 filesystem 才可用。

测试发现,使用filesystem缓存后冷启动耗时较长,但是未修改任何文件再次打包只需要5simage.png
但是切记不要无脑使用fileSystem,因为可能会遇到缓存不一致、缓存文件过大、磁盘 I/O 性能影响、缓存管理复杂等问题,需要配合解决。本文重点在于提升构建速度,正常上生产可能会踩坑。

其他构建工具

基于Webpack的一些问题,如果实在没有优化空间,我们还可以在衡量可行性的情况下转换构建工具,比如ViteVite有两个优点:

  1. 项目冷启动更快
  2. 热更新更快

主要介绍下Vite 我们先来看看 WebpackVite 的在构建上的区别。下图是 Webpack 的遍历递归收集依赖的过程:

Webpack 启动时,从入口文件出发,调用所有配置的 Loader 对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理,一系列的递归操作非常耗时。 再看下Vite
Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了开发服务器启动时间。它快的核心在于两点:

  1. 使用 Go 语言的依赖预构建:Vite 将会使用 esbuild 进行预构建依赖。esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。依赖预构建主要做了什么呢?

    • 开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为CommonJSUMD 发布的依赖项转换为 ESM
    • Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。如果不编译,每个依赖包里面都可能含有多个其他的依赖,每个引入的依赖都会又一个请求,请求多了耗时就多;
  2. 按需编译返回:Vite 以 原生 ESM 方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。

总结

回答上面的几个问题

  1. 通过分析用时发现 babel-loader 比较耗时;
  2. Webpack 的构建流程主要时间花费在递归遍历各个入口文件,因此替换构建工具也是一个优化方向,比如vite
  3. 优化的难点主要在于对配置文件不太熟悉,不知道vue-cli 5已做了哪些优化,也不知道Webpack可以做到哪些优化,这反而是需要积累的经验。如果是使用vue-cli创建的项目,优先查询vue-cli文档配置以及默认配置。