前端构建效率优化之路

网站建设3年前发布
60 0 0

2023030522095726afd64077d6eb568188110ee284ea32560457696,我们的系统(一个 ToB 的 Web 单页应用)经过多年的迭代,目前已经累积有大几十万行的业务代码,30+ 路由模块,整体的代码量和复杂度还是比较高的。,项目整体是基于 Vue + TypeScirpt,而构建工具,由于最早项目是经由 vue-cli 初始化而来,所以自然而然使用的是 Webpack。,我们知道,随着项目体量越来越大,我们在开发阶段将项目跑起来,也就是通过 npm run serve 的单次冷启动时间,以及在项目发布时候的 npm run build 的耗时都会越来越久。,因此,打包构建优化也是伴随项目的成长需要持续不断去做的事情。在早期,项目体量比较小的时,构建优化的效果可能还不太明显,而随着项目体量的增大,构建耗时逐渐增加,如何尽可能的降低构建时间,则显得越来越重要:,本文,就将详细介绍整个我们项目,在随着项目体量不断增大的过程中,对整体的打包构建效率的优化之路。,再更具体一点,我们的项目最初是基于 vue-cli 4,当时其基于的是 webpack4 版本。如无特殊说明,下文的一些配置会基于 webpack4 展开。,工欲善其事必先利其器,解决问题前需要分析问题,要优化构建速度,首先得分析出 Webpack 构建编译我们的项目过程中,耗时所在,侧重点分布。,这里,我们使用的是 SMP 插件,统计各模块耗时数据。,对于 npm run serve,也就是开发阶段而言,在没有任何缓存的前提下,单次冷启动整个项目的时间达到了惊人的 4 min。,2023030522095862f0aeb4755d6e1ad1059370a1a7ef44a5fd19371,而对于 npm run build,也就是实际线上生产环境的构建,看看总体的耗时:,2023030610192954a46ef227b82d72c2455130ae8bd6e5b2f1b6278,因此,对于构建效率的优化可谓是势在必行。首先,我们需要明确,优化分为两个方向:,在开发阶段,我们的核心目标是在保有项目所有功能的前提下,尽可能提高构建速度,保证开发时的效率,所以对于 Live 才需要的一些功能,譬如代码混淆压缩、图片压缩等功能是可以不开启的,并且在开发阶段,我们需要热更新。,而在生产打包阶段,尽管构建速度也非常重要,但是一些在开发时可有可无的功能必须加上,譬如代码压缩、图片压缩。因此,生产构建的目标是在于保证最终项目打包体积尽可能小,所需要的相关功能尽可能完善的前提下,同时保有较快的构建速度。,两者的目的不尽相同,因此一些构建优化手段可能仅在其中一个环节有效。,基于上述的一些分析,本文将从如下几个方面探讨对构建效率优化的探索:,那么,为什么随着项目的增大,构建的效率变得越来越慢了呢?,从上面两张截图不难看出,对于我们这样一个单页应用,构建过程中的大部分时间都消耗在编译 JavaScript 文件及 CSS 文件的各类  Loader 上。,本文不会详细描述 Webpack 的构建原理,我们只需要大致知道,Webpack 的构建流程,主要时间花费在递归遍历各个入口文件,并基于入口文件不断寻找依赖逐个编译再递归处理的过程,每次递归都需要经历 String->AST->String 的流程,然后通过不同的 loader 处理一些字符串或者执行一些 JavaScript 脚本,由于 NodeJS 单线程的特性以及语言本身的效率限制,Webpack 构建慢一直成为它饱受诟病的原因。,因此,基于上述 Webpack 构建的流程及提到的一些问题,整体的优化方向就变成了:,上面也说了,构建过程中的大部分时间都消耗在递归地去编译 JavaScript 及 CSS 的各类  Loader 上,并且会受限于 NodeJS 单线程的特性以及语言本身的效率限制。,如果不替换掉 Webpack 本身,语言本身(NodeJS)的执行效率是没法优化的,只能在其他几个点做文章。,因此在最早期,我们所做的都是一些比较常规的优化手段,这里简单介绍最为核心的几个:,其实对于 vue-cli 4 而言,已经内置了一些缓存操作,譬如上图可见到 loader 的过程中,有使用 cache-loader,所以我们并不需要再次添加到项目之中。,那还有没有一些其他的缓存操作呢用上的呢?我们使用了一个 HardSourceWebpackPlugin 。,首先安装依赖:,修改 vue.config.js 配置文件:,配置了 HardSourceWebpackPlugin 的首次构建时间,和预期的一样,并没有太大的变化,但是第二次构建从平均 4min 左右降到了平均 20s 左右,提升的幅度非常的夸张,当然,这个也因项目而异,但是整体而言,在不同项目中实测发现它都能比较大的提升开发时二次编译的效率。,另外,在缓存方面我们的尝试有:,但是整体收效都不太大,可以简单讲讲。,打开 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 在多核并发方面的优势,利用多核优势,多进程同时处理任务。,从上图中可以看到,Vue CLi4 中,其实已经内置了 thread-loader。,那么,除了 thread-loader,还有哪些可以考虑的方案呢?,HappyPack 与 thread-loader 类似。,HappyPack 可利用多进程对文件进行打包, 将任务分解给多个子进程去并行执行,子进程处理完后,再把结果发送给主进程,达到并行打包的效、HappyPack 并是所有的 loader 都支持, 比如 vue-loader 就不支持。,可以通过 Loader Compatibility List 来查看支持的 loaders。需要注意的是,创建子进程和主进程之间的通信是有开销的,当你的 loader 很慢的时候,可以加上 happypack。否则,可能会编译的更慢。,当然,由于 HappyPack 作者对 JavaScript 的兴趣逐步丢失,维护变少,webpack4 及之后都更推荐使用 thread-loader。因此,这里没有实际结论给出。,2023030522292347b4fdc146657446302314555641adc40574fd185,对于寻址优化,总体而言提升并不是很大。,它的核心即在于,合理设置 loader 的 exclude 和 include 属性。,这肯定是有用的优化手段,只是对于一些大型项目而言,这类优化对整体构建时间的优化不会特别明显。,在上述的一些常规优化完成后。整体效果仍旧不是特别明显,因此,我们开始思考一些其它方向。,我们再来看看 Webpack 构建的整体流程:,202303052210011123373081936fe4f4b58667b3307256939a8b248,上图是大致的 webpack 构建流程,简单介绍一下:,随着项目体量地不断增大,耗时大头消耗在第 7 步,递归遍历 AST,解析 require,如此反复直到遍历完整个项目。,而有意思的是,对于单次单个开发而言,极大概率只是基于这整个大项目的某一小个模块进行开发即可。,所以,如果我们可以在收集依赖的时候,跳过我们本次不需要的模块,或者可以自行选择,只构建必要的模块,那么整体的构建时间就可以大大减少。,这也就是我们要做的 -- 分模块构建。,什么意思呢?举个栗子,假设我们的项目一共有 6 个大的路由模块 A、B、C、D、E、F,当新需求只需要在 A 模块范围内进行优化新增,那么我们在开发阶段启动整个项目的时候,可以跳过 B、C、D、E、F 这 5 个模块,只构建 A 模块即可:,20230305221001e1b06f887a8699b9a884966ab8e3ea26de9d6290320230305221001177378682760c250b3b209d021e2e25a483ae1514,假设原本每个模块的构建平均耗时 3s,原本 18s 的整体冷启动构建耗时就能下降到 3s。,Webpack 是静态编译打包的,Webpack 在收集依赖时会去分析代码中的 require(import 会被 bebel 编译成 require) 语句,然后递归的去收集依赖进行打包构建。,我们要做的,就是通过增加一些配置,简单改造下我们的现有代码,使得 Webpack 在初始化遍历整个路由模块收集依赖的时候,可以跳过我们不需要的模块。,再说得详细点,假设我们的路由大致代码如下:,我们要做的,就是每次启动项目时,可以通过一个前置命令行脚本,收集本次需要启动的模块,按需生成需要的 routesConfig 即可。,我们尝试了:,最终选择了使用 NormalModuleReplacementPlugin 插件进行文件替换的方式,原因在于它对整个项目的侵入性非常小,只需要添加前置脚本及修改 Webpack 配置,无需改变任何路由文件代码。总结而言,该方案的两点优势在于:,利用 NormalModuleReplacementPlugin 插件,可以不修改原来的路由配置文件,在编译阶段根据配置生成一个新的路由配置文件然后去使用它,这样做的好处在于对整个源码没有侵入性。,NormalModuleReplacementPlugin 插件的作用在于,将目标源文件的内容替换为我们自己的内容。,我们简单修改 Webpack 配置,如果当前是开发环境,利用该插件,将原本的 config.ts 文件,替换为另外一份,代码如下:,上面的代码功能是将实际使用的 config.ts 替换为自定义配置的 dev.routerConfig.ts 文件,那么 dev.routerConfig.ts 文件的内容又是如何产生的呢,其实就是借助了 inquirer 与 EJS 模板引擎,通过一个交互式的命令行问答,选取需要的模块,基于选择的内容,动态的生成新的 dev.routerConfig.ts 代码,这里直接上代码。,改造一下我们的启动脚本,在执行 vue-cli-service serve 前,先跑一段我们的前置脚本:,而 dev-server.js 所需要做的事,就是通过 inquirer 实现一个交互式命令,用户选择本次需要启动的模块列表,通过 ejs 生成一份新的 dev.routerConfig.ts 文件。,模板代码的简单示意:,dev-server.js 的核心在于启动一个 inquirer 交互命令行服务,让用户选择需要构建的模块,类似于这样:,202303052229253702af6680023c82be55584b261cf181311c5189820230305223538328d5a509af0923f1686133e37135b69002fa4518,模板代码示意 router.config.template.ejs 是 EJS 模板文件,chooseModules 是我们在终端输入时,获取到的用户选择的模块集合数组,根据这个列表,我们去生成新的 routesConfig 文件。,这样,我们就实现了分模块构建,按需进行依赖收集。以我们的项目为例,我们的整个项目大概有 20 个不同的模块,几十万行代码:,实际效果大致如下,无需启动所有模块,只启动我们选中的模块进行对应的开发即可:,20230305221004b3f1b6879aa08bf93d3384c6edcd58a9d060a367820230305221005d9aafef639ff7fdaf1b395b73e07955edefada572,这样,如果单次开发只涉及固定的模块,单次项目冷启动的时间,可以从原本的 4min+ 下降到 18s 左右,而有缓存状态下二次构建 1 个模块,仅仅需要 4.5s,属于一个比较大的提升。,受限于 Webpack 所使用的语言的性能瓶颈,要追求更快的构建性能,我们不可避免的需要把目光放在其他构建工具上。这里,我们的目光聚焦在了 Vite 与 esbuild 上。,Vite,一个基于浏览器原生 ES 模块的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。,当然,由于 Vite 本身特性的限制,目前只适用于在开发阶段替代 Webpack。,我们都知道 Vite 非常快,它主要快在什么地方?,那么是什么让它这么快?,我们先来看看 Webpack 与 Vite 的在构建上的区别。下图是 Webpack 的遍历递归收集依赖的过程:,20230305222925c78c8cb880375da42cb360c3920bedebb1c9f05142023030522292887af0cb42702d38496b884eb2c18cbf0d21d9b200,上文我们也讲了,Webpack 启动时,从入口文件出发,调用所有配置的 Loader 对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。,这一过程是非常非常耗时的,再看看 Vite:,20230305222927c2b417a287deba7263a24557af7380dad4aa5c7382023030522100758843473656b00c7bd4588a9159a5b1ecd78cc857,Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了开发服务器启动时间。它快的核心在于两点:,使用 Vite 的另外一个大的好处在于,它的热更新也是非常迅速的。,我们首先来看看 Webpack 的热更新机制:,202303052210075658566259072e6fffb266eebeb01ebe90746f93020230305221008796f13548e74d7b49d67399a0543244d295c86914,一些名词解释:,Webpack 热更新的大致原理是,文件经过 Webpack-complier 编译好后传输给 HMR Server,HMR Server 知道哪个资源 (模块) 发生了改变,并通知 HMR Runtime 有哪些变化,HMR Runtime 就会更新我们的代码,这样浏览器就会更新并且不需要刷新。,而 Webpack 热更新机制主要耗时点在于,Webpack 的热更新会以当前修改的文件为入口重新 build 打包,所有涉及到的依赖也都会被重新加载一次。,而  Vite 号称 热更新的速度不会随着模块增多而变慢。它的主要优化点在哪呢?,Vite 实现热更新的方式与 Webpack 大同小异,也通过创建 WebSocket 建立浏览器与服务器建立通信,通过监听文件的改变向客户端发出消息,客户端对应不同的文件进行不同的操作的更新。,Vite 通过 chokidar 来监听文件系统的变更,只用对发生变更的模块重新加载,只需要精确的使相关模块与其临近的 HMR 边界连接失效即可,这样 HMR 更新速度就不会因为应用体积的增加而变慢而 Webpack 还要经历一次打包构建。所以 HMR 场景下,Vite 表现也要好于 Webpack。,通过不同的消息触发一些事件。做到浏览器端的即时热模块更换(热更新)。通过不同事件,触发更细粒度的更新(目前只有 Vue 和 JS,Vue 文件又包含了 template、script、style 的改动),做到只更新必须的文件,而不是全量进行更新。在些事件分别是:,本文不会在 Vite 原理上做太多深入,感兴趣的可以通过官方文档了解更多 -- Vite 官方文档 -- 为什么选 Vite。,基于 Vite 的改造,相当于在开发阶段替换掉 Webpack,下文主要讲讲我们在替换过程中遇到的一些问题。,基于 Vue-cli 4 的 Vue2 项目改造,大致只需要:,当以命令行方式运行 npm run vite时,Vite 会自动解析项目根目录下名为 vite.config.js 的文件,读取相应配置。而对于 vite.config.js 的配置,整体而言比较简单:,当然,对于项目的源码,可能需要一定的改造,下面是我们遇到的一些小问题:,解决完上述的一些问题后,我们成功地将开发时基于 Webpack 的构建打包迁移到了 Vite,效果也非常惊人,全模块构建耗时只有 2.6s:,20230305222928256b518634022e74c6b803ffa4d8c68a9a351d21420230305221010a56ec4b0621a9472aff482d0840ff174f68987867,至此,开发阶段的构建耗时从原本的 4.5min 优化到了 2.6s:,好,上述我们基本已经完成了整个开发阶段的构建优化。下一步是优化生产构建。,我们的生产发布是基于 GitLab 及 Jenkins 的完整 CI/CD 流。,在优化之前,看看我们的整个项目线上发布的耗时:,2023030522101085079f751373893576d46187c6b784040920c113820230305222929d85054432b9c2648e5f00026edca71e9b99606279,可以看到,生产环境构建时间较长, build 平均耗时约 9 分钟,整体发布构建时长在 15 分钟左右,整体构建环节耗时过长, 效率低下,严重影响测试以及回滚 。,好,那我们看看,整个构建流程,都需要做什么事情:,202303052229290711b4d45132cd14355491b4982929cc5464169202023030522101587b481220bb05c10f6d7636bbfb19333b45a3b934,其中, Build base 和 Build Region 阶段存在较大优化空间。,Build base 阶段的优化,涉及到环境准备,镜像拉取,依赖的安装。前端能发挥的空间不大,这一块主要和 SRE 团队沟通,共同进行优化,可以做的有增加缓存处理、外挂文件系统、将依赖写进容器等方式。,我们的优化,主要关注 Build Region 阶段,也就是核心关注如何减少 npm run build 的时间。,文章开头有贴过 npm run build 的耗时分析,简单再贴下:,20230305221015742dcb836daed86a2fd90066f14bee2e43d539542202303052229336125b2149352fa602a57526529b208ee44f41d165,一般而言, 代码编译时间和代码规模正相关。,根据以往优化经验,代码静态检查可能会占据比较多时间,目光锁定在 eslint-loader 上。,在生产构建阶段,eslint 提示信息价值不大,考虑在 build 阶段去除,步骤前置。,同时,我们了解到,可以通过 esbuild-loader 插件去替代非常耗时的 babel-loader、ts-loader 等 loader。,因此,我们的整体优化方向就是:,优化前后流程对比:,2023030522101769f786523bdbd9811f1410a8110556198293a160620230305222936d1f1e8180897451f7a382294bb2883072140d8144,这个上面说了,还是比较好理解的,在生产构建阶段,eslint 提示信息价值不大,考虑在 build 阶段去除,步骤前置。,比如在 git commit 的时候利用 lint-staged 及 git hook 做检查, 或者利用 CI 在 git merge 的时候加一条流水线任务,专门做静态检查。,我们两种方式都有做,简单给出接入 Gitlab CI 的代码:,通过 .gitlab-ci.yml 配置文件,指定固定的时机进行 lint 指令,前置步骤。,这里,我们主要借助了 esbuild-loader。,上面其实我们也有提到 esbuild,Vite 使用 esbuild 进行预构建依赖。这里我们借助的是 esbuild-loader,它把 esbuild 的能力包装成 Webpack 的 loader 来实现 Javascript、TypeScript、CSS 等资源的编译。以及提供更快的资源压缩方案。,接入起来也非常简单。我们的项目是基于 Vue CLi 的,主要修改 vue.config.js,改造如下:,移除 ESLint,以及接入 esbuild-loader 这一番组合拳打完,本地单次构建可以优化到 90 秒。,再看看线上的 Jenkins 构建耗时,也有了一个非常明显的提升:,2023030522102046697d5566eaff057f33284d1c197413bba504365,整体而言,上述优化完成后,对整个项目的打包构建效率是有着一个比较大的提升的,但是这并非已经做到了最好。,看看我们旁边兄弟组的 Live 构建耗时:,2023030522293791d069b78da80eb2241177c6dd03b1f68cbdaf885,在项目体量差不多的情况下,他们的生产构建耗时(npm run build)在 2 分钟出头,细究其原因在于:,是的,后续我们还有许多可以尝试的方向,譬如我们正在做的一些尝试有:,同时,我们也必须看到,前端技术日新月异,各种构建工具目不暇给。前端从最早期的刀耕火种,到逐步向工程化迈进,到如今的泛前端工程化囊括的各式各样的标准、规范、各种提效的工具。构建效率优化可能会处于一种一直在路上的状态。当然,这里不一定有最佳实践,只有最适合我们项目的实践,需要我们不断地去摸索尝试。

© 版权声明

相关文章