基于 Webpack 的构建速度优化
背景
最近前后端项目放在一起使用 maven 打包,但是前端项目打包npm run build严重拖累整个项目打包效率,因此研究一下 vue-cli 构建优化。
项目技术栈:vue-cli 5、Webpack 5 、vue 2.6。
当前项目文件内容非常简单,只有一个页面及相应的业务文件,但是当前构建速度长达75s+,不过先不着急进行优化,先明确下几个问题以及对应的目标。
问题
- 当前构建为什么这么慢?
- 优化的目标是什么?
- 优化策略有哪些?
- 其他构建工具性能对比
原因分析及目标
当前项目文件非常简单,而构建速度已经惨不入目了,并且随着项目的增大,构建的效率会变得越来越慢。
Webpack 的构建流程,主要时间花费在递归遍历各个入口文件,并基于入口文件不断寻找依赖逐个编译再递归处理的过程,每次递归都需要经历 String->AST->String 的流程,然后通过不同的 loader 处理一些字符串 或者执行一些 JavaScript 脚本,由于 NodeJS 单线程的特性以及语言本身的效率限制,Webpack 构建慢一直成为它饱受诟病的原因。
潜在原因
- 项目打包体积是否过大,是否合理分配文件;
vue.config.js是否存在耗时配置或优化余地;- 未经过预处理或压缩的第三方库,导致
babel过多转译; - 未使用并行缓存
目标
- 打包体积尽可能小;
- 功能正常的情况下,尽可能优化构建速度
接下来根据优化方向一步步测试性能。
构建性能优化
查看dist文件
首先查看打包后的dist文件是否合理,是否存在文件过大的情况。

并无异常,chunk-elementUI和chunk-libs是执行分包策略后的产物。
一般情况下可以使用webpack-bundle-analyzer来分析项目的构建结果,以识别过大的模块、重复的依赖和不必要的代码,可自行尝试。
但是查看dist文件夹时,我发现输出了map文件。

该项目并不需要在生产环境中开启source-map,因此根据https://cli.vuejs.org/config/#productionsourcemap 关闭source-map。
productionSourceMap: falseSetting this to
falsecan speed up production builds if you don't need source maps for production.
去除后再次打包,减少了1-2s左右,收敛微弱,需要进一步优化。
合理配置路径解析
resolve: {
extensions: ['.js', '.vue', '.json', '.scss']
}webpack 会按照配置的扩展名顺序依次查找文件,将最常用的扩展名放在前面,可以减少不必要的查找,相较于json文件项目中.scss文件创建的更多,因此改良后的解析顺序为:
resolve: {
extensions: ['.js', '.vue', '.scss', '.json']
}再次打包,同样减少了1-2s左右,需要进一步通过查看耗时进行优化。
查看耗时
使用SpeedMeasurePlugin插件来看一下项目的构建过程中各个阶段的耗时情况。
speed-measure-webpack-plugin是一款统计 webpack 打包时间的插件,不仅可以分析总的打包时间,还能分析各阶段loader的耗时,并且可以输出一个文件用于永久化存储数据。
// 安装
npm install speed-measure-webpack-plugin --save-dev// 使用方式
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
module.exports = {
configureWebpack: smp.wrap({
// 在这里配置原本的Webpack配置
// 可以定义entry、output、module等配置
})
};
可以看出主要的loader耗时是babel-loader。
babel-loader
babel-loader 是具体负责转译 JavaScript 文件的加载器,它通过 Babel 来将现代 JavaScript 转换为旧版本浏览器支持的代码。但是当前项目中babel也处理了第三方库代码,因此尝试排除node-modules文件。
config.module
.rule('js')
.test(/\.js$/)
.exclude.add(/node_modules/)
.end()
.use('babel-loader')
.loader('babel-loader')此时babel-loader耗时已减少了一半。

注意,在很多优化策略中给出babel-loader需要再加上cache-loader处理进行缓存以及thread-loader并行处理,配置如下:
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-loader。 Migrate-from-v4。
继续查看文档,我发现caching和Parallelization功能已默认开启: 
transpileDependencies
在查询vue-cli文档时,我查看到一个和babel-loader相关的属性transpileDependencies: transpileDependencies
默认情况下 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。配置如下:
//配置
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缓存后冷启动耗时较长,但是未修改任何文件再次打包只需要5s。 
但是切记不要无脑使用fileSystem,因为可能会遇到缓存不一致、缓存文件过大、磁盘 I/O 性能影响、缓存管理复杂等问题,需要配合解决。本文重点在于提升构建速度,正常上生产可能会踩坑。
其他构建工具
基于Webpack的一些问题,如果实在没有优化空间,我们还可以在衡量可行性的情况下转换构建工具,比如Vite。 Vite有两个优点:
- 项目冷启动更快
- 热更新更快
主要介绍下Vite 我们先来看看 Webpack 与 Vite 的在构建上的区别。下图是 Webpack 的遍历递归收集依赖的过程: 
Webpack 启动时,从入口文件出发,调用所有配置的 Loader 对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理,一系列的递归操作非常耗时。 再看下Vite: 
Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了开发服务器启动时间。它快的核心在于两点:
使用
Go语言的依赖预构建:Vite将会使用esbuild进行预构建依赖。esbuild使用Go编写,并且比以JavaScript编写的打包器预构建依赖快 10-100 倍。依赖预构建主要做了什么呢?- 开发阶段中,
Vite的开发服务器将所有代码视为原生ES模块。因此,Vite必须先将作为CommonJS或UMD发布的依赖项转换为ESM - Vite 将有许多内部模块的
ESM依赖关系转换为单个模块,以提高后续页面加载性能。如果不编译,每个依赖包里面都可能含有多个其他的依赖,每个引入的依赖都会又一个请求,请求多了耗时就多;
- 开发阶段中,
按需编译返回:
Vite以 原生ESM方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:Vite只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。
总结
回答上面的几个问题
- 通过分析用时发现
babel-loader比较耗时; - Webpack 的构建流程主要时间花费在递归遍历各个入口文件,因此替换构建工具也是一个优化方向,比如
vite - 优化的难点主要在于对配置文件不太熟悉,不知道
vue-cli 5已做了哪些优化,也不知道Webpack可以做到哪些优化,这反而是需要积累的经验。如果是使用vue-cli创建的项目,优先查询vue-cli文档配置以及默认配置。