随着项目的增大,webpack的打包速度已成前端工程师的“不可承受之重”。最近对团队内某项目的打包速度进行了一些优化,本文没有具体的配置教程,只提供一些优化思路,供启发和参考。
更换更快的打包工具
1. bundler:代表webpack、parcel
parcel和webpack主要区别
- parcel采用了类似于webpack中thread-loader的方式进行并行构建
- parcel内建了类似于dll的缓存策略(webpack5中也内置了缓存策略)
- parcel的HTML、JS 和 CSS 分别是通过 posthtml、babel 和 postcss处理的
更新webpack版本
webpack5内置了持久性缓存和跟好的缓存策略,Tree Shaking性能提升,摇掉更多无用代码。
尝试改善与网络平台的兼容性。
2. noBundler:代表snowpack、vite
主流的浏览器版本都支持直接使用 JavaScript Module。
HTTP/2可以合并请求来优化模块并发请求性能。
vite针对复杂的第三方库,会自动识别并提前打包缓存起来,避免过多http请求(类似于dll)
从webpack迁移到vite
项目里使用了create-react-app,内置了很多配置项(迁移成本高)
-
团队内部构建的SDK库由于打包后的语法不标准,webpack不报错,vite会报错(更改SDK)
项目里使用到了monaco-editor,官方提供的插件只有webpack版本,迁移到vite不好处理(团队内部写了rollup插件)
vite里less没有autoimport的配置(写死在less inject里)
vite变量形式和webpack不一样,webpack可以识别process.env.xxx,vite是import.meta.xxx(想的是开发用vite,build用webpack,需要兼容)
项目里用到的scoped-css-loader等loader没有vite版插件(类似于@vitejs/plugin-vue-jsx,写一个插件在vite里使用babel)
webpack可以很好的处理web和node共用变量(webpack可以转换cjs,vite需要通过optimize项配置)
由于webpack到vite的迁移成本比较高,vite build时的速度和webpack也差不多,使用hardsource等插件后webpack dev的速度也是可以接受的,决定还是在webpack体系下做优化
升级webpack
截止目前,cra正式版还停留在webpack4版本,好在alpha版升级到了webpack5,可以使用react-app-rewired start --scripts-version react-scripts
来指定react-scripts版本。实测升级到alpha版后速度更慢了,在优化了配置后速度也没有明显提升。
猜测原因是webpack5的缓存策略主要是dev的时候有用,本质上和hardsource没有太大区别,但首次生成缓存的速度比hardsource稍快,生产环境中一般会禁用或重新构建缓存。
替换babel
babel负责将js、ts、jsx等格式代码转换为js。相似功能的还有:tsc、esbuild、swc。
tsc在转换时默认会检查ts类型,插件环境没有babel好,一般都会使用babel。
babel不会检测类型,只负责把ts转换到js,速度会比tsc要快,插件生态支持的也好一些(react热更新、vue3jsx语法转换官方都只提供了babel的插件)注:swc与nextjs合作,内建了react热更新支持。esbuild调研后未发现支持,vite的react热更新插件还是使用了babel
esbuild、swc也不会检测类型,他们会用go和rust生成的二进制文件处理js或ts,速度比babel更快。如果没有使用babel插件,可以在webpack中直接用esbuild-loader或swc-loader替换babel,但插件生态几乎为零(减少了暴露给插件的API以提升速度,esbuild transformer无法使用插件)ps:Vercel团队最近吸纳了swc的作者,并在新版nextjs里提供了替换babel的选项,未来可期
用babel、esbuild和swc时,需要使用fork-ts-checker-webpack-plugin校验ts类型(cra默认启用)
一般在使用tsc打包的时候,也会关闭类型检查并使用fork-ts-checker-webpack-plugin检测类型以优化速度。
fork-ts-checker-webpack-plugin是一个webpack插件,它会在打包时fork出一个进程并行进行检查,可以更好的利用多核cpu的能力,过程中几乎不影响webpack主进程,故可以优化速度。
替换terser
terser负责压缩babel和webpack生成的产物,去掉无效代码,去掉日志输出代码,缩短变量名,生成source-map等,可以有效压缩体积
terser是js写的,压缩时的内存占用、cpu占用都很高,虽然有cache、多进程等选项,但提升并不理想
esbuild带有压缩的功能,使用esbuild替换terser做压缩,可以带来比较大的速度提升,但生成产物比terser压缩的大10%,在中后台项目且会拆分文件的场景下,文件尺寸不是痛点。
目前团队有测试和预上线环境,目前测试和沙箱环境也会压缩代码,可以在测试和沙箱构建时禁用压缩,经测试可以带来40%左右的速度提升
source-map
webpack提供了如下的source-map选项,
不同选项的构建速度和性能以及适用场景都有很大差别,在这里不详细叙述
团队测试和沙箱环境构建时可以不分离sourcemap(或者用cheap-eval-source-map映射到行)以提升速度。
-
用替代品生成sourcemap(用wasm-webpack-sources替换webpack-sources)
-
更新webpack-sources的版本,ps: nextjs团队在博客中提到,升级webpack-sources版本后,构建source-map的速度比不开启仅多花了11%左右的时间
延迟构建source-map,由于线上环境不会暴露source-map,可以先关闭source-map构建出一份产物到线上,然后打开source-map再在后台打包一次并将source-map上传到Sentry等监控平台和线上文件映射上。
使用多进程打包
happypack和thread-loader
- happypack已经很多年没有维护了,核心原理是启用多进程,多个loader并行处理文件,happypack开发人员建议,如果使用webpack4及以上更推荐使用thread-loader,thread-loader做的事情和happypack一样
-
thread-loader是webpack官方推荐的,原理是将loader放在单独的一个worker进程内处理,但实测下来babel-loader前放置thread-loader后的速度更慢了
开启thread-loader时,监测到有一瞬间fork出了很多node进程,但接下来就消失了。可能是因为以下限制(用了babel-plugin),实际thread-loader并没有启用成功
-
在看到thread-loader官方的用例后,推测也可能是由于项目是ts的,webpack需要递归的调用babel-loader来解析语法和生成依赖关系,进程间通信会消耗大量时间
使用缓存
cache-loader、开启loader自带的cache选项、dll、hardsource、webpack5
- cache-loader也是webpack官方推荐使用的,加在耗时较长的loader前面,在heavy loader执行前,对比要处理的文件和缓存文件的mtime,mtime没变的话直接取缓存文件,实测构建时间会变长。
loader: ['cache-loader', 'other-loader']
简单看了一下,cache-loader分两个阶段:pitch和loader
pitch阶段的处理流程是:cache-loader -> other-loader
loader阶段的处理流程是:other-loader -> cache-loader
pitch阶段根据当前正在处理的文件,读取.cache目录中对应的cache文件,对比mtime判断是否可以复用
loader阶段依赖pitch阶段的判断,如果pitch阶段判断当前文件的缓存失效了,loader阶段就要重新生成缓存。
- babel-loader自带了cache选项,但babel-loader的cache必须经过一次编译,才会将索引的文件与文件编译结果缓存在内存中。在后续的编译过程中,如果发现索引的文件已经缓存过了,才会直接引用已经编译缓存的结果。(还是会有编译的过程)
-
dll动态链接方案
可以将共用不经常改变的依赖(如react、react-dom、vue、antd、moment)打包成dll
webpack打包引入库时入口会被动态指向dll文件里,实测是有用的,但dll方案在18年左右被社区的脚手架抛弃,大概意思是使用dll会大量增加维护的成本,(我在使用时也遇到有些插件打成dll后报错),webpack4相比webpack3带来的打包速度提升使得dll有些得不偿失
hardsource和webpack5
hardsource和webpack5持久化缓存的方案类似,webpack5持久化缓存结果至硬盘上,第一次编译文件的时候,计算文件的hash。将编译结果与hash关联起来。第二次编译文件时,首先加载本地缓存结果,进入正常编译环节时,对编译的文件再次求hash,如果此hash在缓存库中已经存在了,那么将直接跳过编译环节,直接输出编译结果。
这两种方案都是dev的时候才会有用(记得官方有个issue说实验基于缓存build可能会有5%的概率出错),升级到cra5之后发现复用缓存的条件极其严格,每次重新build时都会重新构建缓存,hardsource首次构建的消耗时间比较大,webpack5由于的缓存是基于webpack4构建时的内存改造得来,首次构建带来的额外时间消耗并不大,二次构建hardsource和webpack5的速度相当。
external
- external之后会把webpack会把import语法转成访问全局变量,直接忽略语法解析,也不会调用loader。
external的主要问题是,有些库之间相互依赖,比如antd依赖moment和react,mobx-react-lite依赖react,也依赖了mobx,引入顺序和cdn质量需要额外花精力维护,很多库官方也没有提供umd的包,使用第三方的umd包可能会有问题。 - react中比较稳定的umd库有react react-dom antd moment,可以放心external掉
external可以配合Systemjs使用,systemjs支持直接引入esm而不必去找umd,webpack4也内建支持把libraryTarget改成SystemJs的形式,直接使用esm格式包时不需要type=module,也可以进行动态加载改造,微前端框架single-spa也使用这种方式进行依赖动态加载
<script type="systemjs-importmap">
{
imports": {
"react": "//gw.alipayobjects.com/os/lib/react/16.13.1/umd/react.production.min.js",
"react-dom": "//gw.alipayobjects.com/os/lib/react-dom/16.13.1/umd/react-dom.production.min.js",
"moment": "//cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.0/moment.min.js",
"antd": "//unpkg.com/antd@4.6.6/dist/antd.min.js",
"handsontable": "//cdn.jsdelivr.net/npm/handsontable@8.4.0/dist/handsontable.full.min.js",
"braft-editor": "//cdn.jsdelivr.net/npm/braft-editor@2.3.9/dist/index.min.js",
"lodash": "//cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js",
"mobx": "//cdnjs.cloudflare.com/ajax/libs/mobx/5.15.7/mobx.min.js",
"webpackBundle":"./output.bundle.js"
}
}
</script>
<script>
System.import('xxx').then((res) => {
System.import('webpackBundle');
})
</scirpt>
webpack5 Module Federation预编译node_modules
module federation 是 webpack5 提出的新特性,含义为模块联邦。主要是使用于微前端场景,可以在运行时动态引入子应用。
优化思路:利用 webpack5 的 module federation 特性,构建一个虚拟的 federation 应用,项目直接仅使用编译好的依赖,这样就可以直接减去热更新和启动时对依赖的重新编译。实际和dll的原理类似?但速度比dll要快很多
业内脚手架umi已在这方面做出了实践,但没有提供通用的webpack插件,可以期待未来社区的产出
硬件更换
由于JS的多线程能力不佳,webpack在打包时更吃CPU单核性能,多核性能几乎(在多核服务器上测试还没有笔记本块)没有用。
截止目前,苹果还没有发布第二代自研桌面处理器,(APPLE M1x在制程不变的情况下无法大幅提高主频,大概率会通过堆核心、增加GPU、增加总线带宽、提高内存频率等方式做优化,可以预见单核性能不会有类似Intel -> M1的巨大提升)基于的硬件选购建议是,M1 16g配置的Mac电脑在未来几年内都会是非常适合前端开发者使用的(传统x86芯片的电脑在不改变封装逻辑的前提下预计相同价位提升至M1的单核性能水平需要很长时间,基于JS的打包生态迁移到rust和go也需要很久)。
使用nice命令提高本机webpack进程优先级后速度也略有提升(需要root权限)
总结
基于上述调研和项目的业务场景,最终决定应用以下优化
- 用esbuild替换terser作压缩
- 测试和预上线环境禁用压缩并启用cheap-source-map
-
应用dll
以下是在本机的一些实验结果
优化后vs优化前生产环境构建提升了大约43%的时间,测试环境构建提升了大约58%的时间
一些困惑
- Module Federation为什么比dll快
- 能不能使用JS实现多线程打包