项目背景
我们的系统(一个ToB的Web单页应用)前端单页应用经过多年的迭代,目前已经累积有大几十万行的业务代码,30+路由模块,整体的代码量和复杂度还是比较高的。
项目整体是基于Vue+TypeScirpt,而构建工具,由于最早项目是经由vue-cli初始化而来,所以自然而然使用的是Webpack。
我们知道,随着项目体量越来越大,我们在开发阶段将项目跑起来,也就是通过npmrunserve的单次冷启动时间,以及在项目发布时候的npmrunbuild的耗时都会越来越久。
因此,打包构建优化也是伴随项目的成长需要持续不断去做的事情。在早期,项目体量比较小的时,构建优化的效果可能还不太明显,而随着项目体量的增大,构建耗时逐渐增加,如何尽可能的降低构建时间,则显得越来越重要:
大项目通常是团队内多人协同开发,单次开发时的冷启动时间的降低,乘上人数及天数,经年累月节省下来的时间非常可观,能较大程度的提升开发效率、提升开发体验
大项目的发布构建的效率提升,能更好的保证项目发布、回滚等一系列操作的准确性、及时性
本文,就将详细介绍整个WMSFE项目,在随着项目体量不断增大的过程中,对整体的打包构建效率的优化之路。
瓶颈分析
再更具体一点,我们的项目最初是基于vue-cli4,当时其基于的是webpack4版本。如无特殊说明,下文的一些配置会基于webpack4展开。
工欲善其事必先利其器,解决问题前需要分析问题,要优化构建速度,首先得分析出Webpack构建编译我们的项目过程中,耗时所在,侧重点分布。
这里,我们使用的是SMP插件,统计各模块耗时数据。
speed-measure-webpack-plugin是一款统计webpack打包时间的插件,不仅可以分析总的打包时间,还能分析各阶段loader的耗时,并且可以输出一个文件用于永久化存储数据。
//安装npminstall--save-devspeed-measure-webpack-plugin
//使用方式constSpeedMeasurePlugin=require("speed-measure-webpack-plugin");constsmp=newSpeedMeasurePlugin();config.plugins.push(smp());
开发阶段构建耗时
对于npmrunserve,也就是开发阶段而言,在没有任何缓存的前提下,单次冷启动整个项目的时间达到了惊人的4min。
生产阶段构建耗时
而对于npmrunbuild,也就是实际线上生产环境的构建,看看总体的耗时:
因此,对于构建效率的优化可谓是势在必行。首先,我们需要明确,优化分为两个方向:
基于开发阶段npmrunserve的优化
在开发阶段,我们的核心目标是在保有项目所有功能的前提下,尽可能提高构建速度,保证开发时的效率,所以对于Live才需要的一些功能,譬如代码混淆压缩、图片压缩等功能是可以不开启的,并且在开发阶段,我们需要热更新。
基于生产阶段npmrunbuild的优化
而在生产打包阶段,尽管构建速度也非常重要,但是一些在开发时可有可无的功能必须加上,譬如代码压缩、图片压缩。因此,生产构建的目标是在于保证最终项目打包体积尽可能小,所需要的相关功能尽可能完善的前提下,同时保有较快的构建速度。
两者的目的不尽相同,因此一些构建优化手段可能仅在其中一个环节有效。
基于上述的一些分析,本文将从如下几个方面探讨对构建效率优化的探索:
基于Webpack的一些常见传统优化方式
分模块构建
基于Vite的构建工具切换
基于Es-build插件的构建效率优化
为什么这么慢?
那么,为什么随着项目的增大,构建的效率变得越来越慢了呢?
从上面两张截图不难看出,对于我们这样一个单页应用,构建过程中的大部分时间都消耗在编译JavaScript文件及CSS文件的各类Loader上。
本文不会详细描述Webpack的构建原理,我们只需要大致知道,Webpack的构建流程,主要时间花费在递归遍历各个入口文件,并基于入口文件不断寻找依赖逐个编译再递归处理的过程,每次递归都需要经历String-AST-String的流程,然后通过不同的loader处理一些字符串或者执行一些JavaScript脚本,由于NodeJS单线程的特性以及语言本身的效率限制,Webpack构建慢一直成为它饱受诟病的原因。
因此,基于上述Webpack构建的流程及提到的一些问题,整体的优化方向就变成了:
缓存
多进程
寻路优化
抽离拆分
构建工具替换
基于Webpack的传统优化方式
上面也说了,构建过程中的大部分时间都消耗在递归地去编译JavaScript及CSS的各类Loader上,并且会受限于NodeJS单线程的特性以及语言本身的效率限制。
如果不替换掉Webpack本身,语言本身(NodeJS)的执行效率是没法优化的,只能在其他几个点做文章。
因此在最早期,我们所做的都是一些比较常规的优化手段,这里简单介绍最为核心的几个:
缓存
多进程
寻址优化
缓存优化
其实对于vue-cli4而言,已经内置了一些缓存操作,譬如上图可见到loader的过程中,有使用cache-loader,所以我们并不需要再次添加到项目之中。
cache-loader:在一些性能开销较大的loader之前添加cache-loader,以便将结果缓存到磁盘里
那还有没有一些其他的缓存操作呢用上的呢?我们使用了一个HardSourceWebpackPlugin。
HardSourceWebpackPlugin
HardSourceWebpackPlugin:HardSourceWebpackPlugin为模块提供中间缓存,缓存默认存放的路径是node_modules/.cache/hard-source,配置了HardSourceWebpackPlugin之后,首次构建时间并没有太大的变化,但是第二次开始,构建时间将会大大的加快。
首先安装依赖:
npminstallhard-source-webpack-plugin-D
修改vue.config.js配置文件:
constHardSourceWebpackPlugin=require(hard-source-webpack-plugin);module.exports={...configureWebpack:(config)={//...config.plugins.push(newHardSourceWebpackPlugin());},...}
配置了HardSourceWebpackPlugin的首次构建时间,和预期的一样,并没有太大的变化,但是第二次构建从平均4min左右降到了平均20s左右,提升的幅度非常的夸张,当然,这个也因项目而异,但是整体而言,在不同项目中实测发现它都能比较大的提升开发时二次编译的效率。
设置babel-loader的cacheDirectory以及DLL
另外,在缓存方面我们的尝试有:
设置babel-loader的cacheDirectory
DLL
但是整体收效都不太大,可以简单讲讲。
打开babel-loader的cacheDirectory的配置,当有设置时,指定的目录将用来缓存loader的执行结果。之后的webpack构建,将会尝试读取缓存,来避免在每次执行时,可能产生的、高性能消耗的Babel重新编译过程。实际的操作步骤,你可以看看Webpack-babel-loader。
那么DLL又是什么呢?
DLL文件为动态链接库,在一个动态链接库中可以包含给其他模块调用的函数和数据。
为什么要用DLL?
原因在于包含大量复用模块的动态链接库只需要编译一次,在之后的构建过程中被动态链接库包含的模块将不会在重新编译,而是直接使用动态链接库中的代码。
由于动态链接库中大多数包含的是常用的第三方模块,例如Vue、React、React-dom,只要不升级这些模块的版本,动态链接库就不用重新编译。
DLL的配置非常繁琐,并且最终收效甚微,我们在过程中借助了autodll-webpack-plugin,感兴趣的可以自行尝试。值得一提的是,Vue-cli已经剔除了这个功能。
多进程
基于NodeJS单线程的特性,当有多个任务同时存在,它们也只能排队串行执行。
而如今大多数CPU都是多核的,因此我们可以借助一些工具,充分释放CPU在多核并发方面的优势,利用多核优势,多进程同时处理任务。
从上图中可以看到,VueCLi4中,其实已经内置了thread-loader。
thread-loader:把thread-loader放置在其它loader之前,那么放置在这个loader之后的loader就会在一个单独的worker池中运行。这样做的好处是把原本需要串行执行的任务并行执行。
那么,除了thread-loader,还有哪些可以考虑的方案呢?
HappyPack
HappyPack与thread-loader类似。
HappyPack可利用多进程对文件进行打包,将任务分解给多个子进程去并行执行,子进程处理完后,再把结果发送给主进程,达到并行打包的效果、HappyPack并不是所有的loader都支持,比如vue-loader就不支持。
可以通过LoaderCompatibilityList来查看支持的loaders。需要注意的是,创建子进程和主进程之间的通信是有开销的,当你的loader很慢的时候,可以加上happypack。否则,可能会编译的更慢。
当然,由于HappyPack作者对JavaScript的兴趣逐步丢失,维护变少,webpack4及之后都更推荐使用thread-loader。因此,这里没有实际结论给出。
上一次HappyPack更新已经是3年前
寻址优化
对于寻址优化,总体而言提升并不是很大。
它的核心即在于,合理设置loader的exclude和include属性。
通过配置loader的exclude选项,告诉对应的loader可以忽略某个目录
通过配置loader的include选项,告诉loader只需要处理指定的目录,loader处理的文件越少,执行速度就会更快
这肯定是有用的优化手段,只是对于一些大型项目而言,这类优化对整体构建时间的优化不会特别明显。
分模块构建
在上述的一些常规优化完成后。整体效果仍旧不是特别明显,因此,我们开始思考一些其它方向。
我们再来看看Webpack构建的整体流程:
上图是大致的webpack构建流程,简单介绍一下:
entry-option:读取webpack配置,调用newCompile(config)函数准备编译
run:开始编译
make:从入口开始分析依赖,对依赖模块进行build
before-resolve:对位置模块进行解析
build-module:开始构建模块
normal-module-loader:生成AST树
program:遍历AST树,遇到require语句收集依赖
seal:build完成开始优化
emit:输出dist目录
随着项目体量地不断增大,耗时大头消耗在第7步,递归遍历AST,解析require,如此反复直到遍历完整个项目。
而有意思的是,对于单次单个开发而言,极大概率只是基于这整个大项目的某一小个模块进行开发即可。
所以,如果我们可以在收集依赖的时候,跳过我们本次不需要的模块,或者可以自行选择,只构建必要的模块,那么整体的构建时间就可以大大减少。
这也就是我们要做的--分模块构建。
什么意思呢?举个栗子,假设我们的项目一共有6个大的路由模块A、B、C、D、E、F,当新需求只需要在A模块范围内进行优化新增,那么我们在开发阶段启动整个项目的时候,可以跳过B、C、D、E、F这5个模块,只构建A模块即可:
假设原本每个模块的构建平均耗时3s,原本18s的整体冷启动构建耗时就能下降到3s。
分模块构建打包的原理
Webpack是静态编译打包的,Webpack在收集依赖时会去分析代码中的require(import会被bebel编译成require)语句,然后递归的去收集依赖进行打包构建。
我们要做的,就是通过增加一些配置,简单改造下我们的现有代码,使得Webpack在初始化遍历整个路由模块收集依赖的时候,可以跳过我们不需要的模块。
再说得详细点,假设我们的路由大致代码如下:
importVuefromvue;importVueRouter,{Route}fromvue-router;//1.定义路由组件.//这里简化下模型,实际项目中肯定是一个一个的大路由模块,从其他文件导入constmoduleA={template:divAAAA/div}constmoduleB={template:divBBBB/div}constmoduleC={template:divCCCC/div}constmoduleD={template:divDDDD/div}constmoduleE={template:divEEEE/div}constmoduleF={template:divFFFF/div}//2.定义一些路由//每个路由都需要映射到一个组件。//我们后面再讨论嵌套路由。constroutesConfig=[{path:/A,