聊一聊 webpack 的打包优化实践
遇到了什么问题?
去年接触了公司内一个开发运行了两年多的项目,整体应用是基于 React 技术栈的,多个单页应用有构成了多页应用。可以理解为比较独立的子业务之间是 MPA 形式跳转,而子业务内部则是 SPA 形式。
项目的构建使用了 webpack,发现存在较大问题:
- 在生产环境上线编译大致需要 13 min+;
- 本地开发环境下,代码改动后的热更新(增量编译)需要大概 10~20s 的时间,使得开发体验很差。
相信这些问题在很多上线迭代了很长时间的、使用了 webpack 的团队中都会遇到,所以把自己的优化实践经历写出来,和大家交流下。我在优化的时候也参考了许多网络上介绍的优化手段,当然,有些具有不错效果,有些可能对我们来说不适用。这并不是一篇罗列各种 webpack 优化技巧的文章,除了优化实践,还会有一些期间的反思。
如何分析性能问题?
优化前自然需要找到问题点。
首先,你可以使用一些开源工具来分析。针对 webpack 性能分析的工具,用的很多的有 speed-measure-webpack-plugin,我们可以用它来统计各个 plugin 和 loader 的耗时。这部分上网络各类介绍 webpack 性能优化的文章都会提到,本文就不赘述了。
其次,需要结合你的业务来具体分析。例如,在最开头我提到,我们的项目虽然在子业务中是属于 SPA 形式的,但是整体上是 MPA 的方式,因此在项目打包时会有多个入口,针对每个入口 html-webpack-plugin 也会帮我们生成多个文件。因此,即使我只开发一个用户中心子业务,也会完整编译一次整体项目,显然是存在问题的。
最后,优化也会有一些通用的手段,或者说是业务“最佳实践经验”的总结。例如,保持升级到新版的 webpack(这里是指 major version),为 loader 提供 cache 等。
这大致就是性能优化前进行问题和现状分析的一些手段。总计来说就是:
- 通过自动化检测工具,来直观反映出系统的性能数据;
- 充分理解业务特点,结合实际业务情况分析问题;
- 查阅与了解一些业务“通用建议”,这些一般都具有一定普适性,可以进一步帮助你优化。
性能优化的实践与思考
一旦分析完目前项目中 webpack 的性能问题,就可以着手进行优化实践了。不过很多时候,性能问题的分析与处理是一个交错循环的过程。你在解决性能问题的同时,也可能发现新的性能优化点。
下面介绍一些我在项目中尝试的优化措施、优化效果和一些思考。
需要升级到最新的 webpack 么?
webpack 的一些版本迭代本身就会包含其自身的性能优化,所以升如果你的 major version 比较老了,那么升级到新版就会提高整体的性能。
例如我接触到该业务项目时,由于其此 2017 年开始就没有升级过 webpack,所以还是使用的 webpack v3。着手优化的第一件事就是将其升级到 webpack v4。
webpack v4 已经发布很长时间了(目前 webpack v5 的 beta 版都已经更新了很多次了),而项目中还在使用 webpack v3 算是一个遗留问题,有必要升级。关于 v3 升级到 v4 的指南网上很多,主要都是一些配置更新,像是代码压缩、commonChunks 更新到 splitChunks、使用 MiniCssExtractPlugin 等, 官方也提供了升级指导,我在这里就不赘述了,只是列几个项目中可能会用到的依赖项的版本更新吧:
- html-webpack-plugin >= 4.0.0(用于和 splitChunks 配合使用)
- eslint-loader >= 2.1.0)
- react-dev-utils >= 6.0.0(一些早期 create-react-app 创建的项目 eject 后需要升级这个)
- happypack >= 5.0.0
- file-loader >= 2.0.0
升级完成后的效果显著,根据多次对比实验的结果,会有 30%~40% 的性能提升。正如江湖中所说,很多时候各种优化手段带来的提升,不及升级一下 webpack。所以后续 webpack v5 稳定后大家跟上也是个不错的选择。因此,如果你项目中的 webpack 的主版本已经落后主流(稳定)的版本,建议优先进行升级。
使用 DLLPlugin 有收益么?
DLLPlugin 是借鉴 DLL 的思路,将一些更新频率极低的模块(例如 node_modules 中的各个包)单独存储到一个文件中,然后项目中通过生成的 manifest 与 DLL 产物将所需的模块打包进项目中。
我们也是使用业界常见的一种用法,对 node_modules 中代码运用的 DLLPlugin,不过在业务应用后提升并不明显。同时使用 DLL 还有一个需要解决的问题。
我们各类项目都是用云端编译的方式,只将源代码提交仓库,通过 CI 在编译集群中编译。任何产出的代码都不会提交仓库内。这样保证所有的编译环境、编译工具都是收敛统一的。由于 DLL 文件是编译产出的,这套理念之下,我们自然也倾向于不在本地构建出 DLL,而是云端编译。
另一方面,业务代码在提交到仓库进行云端编译时,就会需要去获取 DLL 文件加入到自己的编译流程中。这时候可能有这么几种获取 DLL 的方式:
- 开发者把之前云端编译好的 DLL 和放到仓库里一起提交。但这个里面有较大的维护成本,开发者需要知道 DLL 什么时候更新了,并且手动获取到最新的 DLL。不推荐。
- 自制一套 DLL 的发布、更新与拉取服务,然后在业务项目中声明所需的 DLL,在构建流程中集成 DLL 的拉取操作。如果要做的完善,会需要较大成本。
- 把 DLL 与 manifest 发布为一个 npm 包,业务代码通过 node_modules 方式引入。其实就是借用 npm 的能力实现了方法2中能力。是个不错的选择。
但是最终我并没有使用 DLLPlugin。一是因为上面提到的,在我们的业务中实际提升效果不明显;其二是引入后即使用方法3,也会增加复杂性,提高开发人员理解的门槛。正负向效果权衡后,并没有用。
所以这里也想说明, 一些大家推荐的方式,究竟有没有收益,仍然需要结合自己的项目来考量。利弊权衡,方得正道。
是否需要使用 Linter?
这似乎是一个开倒车的问题。在前端工程化不断完善的今天,Linter 被无数次证明其价值,为什么不使用呢?但结合本篇「webpack 打包优化」的主题,它其实是在问,我们需要在 webpack 中集成 Linter 么?
众所周知,我们在 webpack 中可以通过 eslint-loader 来集成 eslint。这么做的目标主要包括:
- 让开发人员能实时获知代码检查的结果,越早知道,越容易修复;
- 同时也是提供代码检查的规范,控制不良的编码进入仓库。
但在 webpack 中集成 Linter 并不一定是最佳解法。针对目的 1,我们完全可以在编辑器中加入相应的 Linter 规范。大多数情况下,团队内编码规范一旦确定,就不常更改。所以提供与编辑器集成的 Linter 对于完成目标 1 来说是更好的选择。而鉴于 vscode 的流程与插件体系的便利,提供一个包含你们团队 Linter 规则的 vscode 插件成本并不高,并且覆盖面广。
而针对目标 2,可以通过 git hook 的方式在来触发代码检查。最简单的方式可以通过 husky 来配置,让 eslint 在 git commit
或 git push
时触发。但是本地检查是可以人为注销的(例如为了绕过检查在提交时注释掉 hook)。所以更进一步,如果你们公司的基建能力够强,并且需要大范围推广代码规范,甚至可以考虑给 git 流程添加更严格的控制。让所有 push 的代码都只能先推入暂存分支,分支合入必须去代码管理平台操作,合入前服务端会 diff 文件并运行 Linter 进行检查。
此外,Linter 其实只需要检查变动的文件。由于集成在 webpack 中,所以它无法通过 git diff
来限制只检查变化的文件,全量编译时处理了很多不必要的文件。同时,eslint 和 webpack 的“标配” babel 一样,都要经过 source code -> AST -> source code 的过程,但两者 AST 又无法共享,本身也有效率的浪费。
既然会带来额外开销同时也不是最好的选择,那自然选择移除 webpack 中的 eslint 使用。但这并不代表不要 Linter 了。取而代之的是,会提供一个 vscode 插件来服务于开发阶段,同时在 CI/CD 中将 git 能力与 Linter 集成,做更有效的代码检查。
借着这个话题,想额外展开一句。一些小伙伴在提到前端构建、前端工程化时,眼光都落在 webpack 上。但它其实只是构建工具中的一种甚至只是一部分,对于工程化更是如此。没必要让 webpack 去承载所有工作。
使用 cache 香不香?
利用 cache 是各类优化的一个常见手段。
在 webpack v4 中可以使用 cache-loader 来提高 loader 处理的效率。更进一步的,可以使用 hard-source-webpack-plugin 来缓存编译的中间产物,大幅提升后续的编译效率。而 webpack v5 的一大亮点也是 加入了缓存能力,hard-source-webpack-plugin 久未维护估计也是因为其作者加入 webpack v5 的开发中。
同时提一句,hard-source-webpack-plugin 从 0.7.x 版本后,只支持 node 8+。
个人实践后,使用 cache-loader 与 hard-source-webpack-plugin 确实可以有效提升编译效率。尤其是 hard-source-webpack-plugin,使用后可以获取 60%~70% 的效率提升。不过引入该插件一定要非常谨慎,这个插件很“脆弱”,因为它使用 webpack 内部的数据结构,这些数据结构本身就是不准备开放给外部开发者和用户的,因此并没有很好的保障,很可能会因为 webpack 的迭代或引入一些插件导致出错。例如,我就遇到了一个与 DLL 一起使用后无法解决的 bug。
同时,如果你使用云端编译的方式,生产环境一般也是无法使用缓存的。如果是用编译集群,编译机的一般流程是会自动创建一个新的空目录,拉取最新代码,安装依赖,执行打包编译,将产物传到产品库中,最后清理文件。这个过程中资源都是全新的,不会存在缓存;而本次产出的缓存下次也不会用上。甚至可能会使用像 docker 这样的容器技术来创建编译环境。那么不仅资源是全新的,连环境也是全新的。
所以在这种主流的构建模式下,本地开发可以享受缓存,发布生产环境的流程是无法利用缓存的。同时,像 hard-source-webpack-plugin 这类的插件,我个人的信任度还是比较低,而其确实也容易出错,所以完全不推荐用于生产环境。个人建议是,如果想在生产环境中使用缓存,一定要慎重,确保缓存功能是“官方认证”且经历过考验的。因为这类功能很容易成为错误的来源。
针对上面提到的这种构建流程,当你决定了要在生产环境的编译中使用 cache 时,不论是在 v4 中使用 cache-loader 与 hard-source-webpack-plugin 这样的工具还是使用 v5 的缓存,你都需要一套保存与使用缓存的机制。这可能需要你和基建或工程效率团队的同学合作,增加相应的流程机制了。
基于上面的考虑,我还是选择只在开发环境支持 cache,同时提供一个开关,开发人员可以自由选择是否使用 cache;而在生产环境下完全不使用 cache。
最快的编译就是不编译
让你的仓库编译速度最快的方法就是把所有模块文件都删掉,变成一个空代码仓库,这样连一毫秒都不会花了。
当然这是一个玩笑。但它表达的观点是:只编译你所需要的东西,编译的越少,编译就越快。
我们的业务是一个在 MPA 中融合 SPA 的形态 —— 独立子业务间是 MPA 的形式,而子业务内则是 SPA 的模式。因此针对每个子业务的 SPA 在 webpack 中都会有一个对应的 entry。编译的过程就是将所有这些子业务的 entry 丢给 webpack 让它一起编译。由于子业务间的依赖较为复杂,个人中心的代码可能 import
了一个资源搜索业务中的模块,而 common 里的有些模块又“不够common”,所以当下又无法简单的拆分为不同仓库分开编译。开发人员的体验非常差,因为我只需要开发其中一小部分,却要整个仓库各类子业务都编译一遍。开发的启动和增量编译时间都很长。
你可能会觉得不合理,但业务业务是演进的,架构和技术也是演进的。并且问题已经出现,需要去解决,像是给行驶的车子换轮胎,你既不能要求停车,也不能坐视不管。通过良好的业务拆分与架构迁移也许可以解决这个问题,但目前时间与人力不允许,因此优先着眼编译的优化。
办法其实很简单。因为一般某个人一段时间的开发都是集中在一个子业务内,因此可以在开始开发时,让开发人员选择需要编译的子业务,webpack 只需要装载这一部分 entry 即可。具体可以通过使用 enquirer 列出 entry 选项让开发者选择,之后再将所选 entry 放入 webpack config 中启动。这个功能可以添加到你的 build 脚本之中。
实施时,业务总共有五个子业务,其中大部分的开发工作集中在一个较新的子业务中。通过只选择它,避免了编译另一个“沉重”的老业务模块,开发环境的启动耗时大幅缩减。
额外还要提一下,这个问题是架构模式导致的,这里的方法算是指标不治本。但有些时候,止疼药也得先吃着。而关于前端代码架构的拆分的讨论就不在本主题范围内了。
减少项目依赖的安装
本块针对的是编译集群里进行的生产编译的优化。我们的项目每次发布,都会在编译机上,拉取项目代码后,创建一个新的空环境,使用 npm i
安装一遍依赖(包括编译工具和源码依赖)。这其中的两块地方可能是不必要的:
- 一些项目依赖其实已经废弃了,不需要安装;
- 还有一些编译工具是长期稳定的,可以考虑做预装。
针对第一个问题,基本不会有太多争议,这类依赖应该从项目中移除。具体做法可以基于 babel 写一个简单的工具遍历 JavaScript 代码,收集其中所有对 node_modules 中各包的依赖,最终与 package.json 进行对比,标出所有没有用到的包。
针对第二个问题,一种做法是固定编译工具集。一般对于编译工具来说,团队内部会尽量统一与收敛,例如 webpack、Babel、PostCSS、Less 这些包长期会稳定使用某一个版本,因此可以考虑在编译机上预装这些依赖。尤其像 SASS 这种还需要 binding.node 的安装耗时更长。如果希望做环境隔离,还可以考虑通过虚拟机或容器的方式来交付你定制话的构建工具环境。
当然,对于第二个问题,也有同学会觉得,每次发布重新安装一遍编译工具集会更好,因为可以通过 npm 版本的语义来自动安装适合的版本,可以享受到一些补丁修复的好处。上面提到的预装,则是一种变相的所版本了。关于是否要锁版本这块其实也是仁者见仁智者见智。
我在实践中则是这块快都进行了优化。通过优化,在编译集群中进行生产环境编译的整体耗时减少了 10% 左右。
其他
还有一些其他的优化细节可以简单说一下:
- 开发环境可以将
devtool
设为eval
。 - 开发时如果没有需求,可以考虑将
pathinfo
设为false
。 - html-webpack-plugin 的缓存机制似乎仍然有些问题,如果追求极致的热更新(增量编译)速度,可以加入一些 魔改代码。由于热更新本来就分秒必争(是 0.1s,还是 1s,还是 10s 看到热更新效果,体验差距很大),因此该改动还是会带来一定的提升。
- happypack。happypack 号称会利用多线程加速 loader 处理,不过你可千万别期望开 4 个线程就会使 loader 耗时变为原先的 1/4。实践下来很多时候性能提升并不明显,在极好的机子(例如 32 core)上差距才会明显。同时,happypack 作者已经声明不再维护该仓库,如果有需求可以考虑用 thread-loader。所以我建议在自己的项目与构建环境中实测一下效果,再决定使不使用。
- 合理使用
noParse
来跳过一些代码模块,例如 jquery。 - 此外,resolve 也会有消耗,可以尽量避免一些后缀不全或路径查找。
效果
通过上面的一系列优化,
- 开发环境全量编译从耗时缩减 71%。如果只编译开发所需的单入口,耗时可下降 91%。
- 开发环境增量编译从原先的 20s 下降为 870ms 左右,耗时缩减 95%。
- 生产环境编译从原先的 13min+ 缩减为 5min,耗时降低 61 %。
最后
开篇就说到,这并不是一篇罗列各种 webpack 优化技巧的文章,更像是对于一次优化实践的总结与反思。
我的目的一直是提升 CI/CD 中「构建」这一环的效率和稳定性,同时提升本地开发体验,而项目在这一环节的核心工具就是 webpack。所以优化的中很多工作涉及到了这一块。但是,你完全没必要局限在 webpack 的优化上。正如文中关于 Linter 的那一节,并没有一门心思去想如何在 webpack 中加速 eslint,而是直接把它从 webpack 里去掉了。因为我们可以把它安排在其他地方,这样只需要检查更少的文件,做更少次的检查,就可以达到相同的目的,甚至更好的效果。
所以,优化 webpack 打包性能只是手段,不是目的。脚手架是因为其自身边界的原因,所以将很多工具都集中在了 webpack 身上,但你确完全不必,因为你面前的是星辰大海。如果你眼里只有 webpack,那你看什么都会是 loader 和 plugin。