关于我
前端工程化系列
前端工程化系列四:提升工具效率

前端工程化系列四:提升工具效率

大量使用自动化工具后,工具本身的性能反而可能成为阻塞开发的卡点。本文讲清为什么要持续优化工具执行效率,并给出收集性能数据、优化构建与 CI 的基础准则。

工程管理的重要原则之一就是自动化必然优于意识形态的规范约束,能用工具解决的问题尽量不要依赖人工,但大量使用自动化工具之后,工具本身的执行性能逐步降低反而很有可能变成阻塞开发效率的卡点之一。设想在一个超大规模的项目中,假如单次构建需要一个小时,单次 CI 需要 20-30 分钟,甚至单次 HMR 需要 3-5 分钟,那么开发者在编码的间隙中还需要花费许多时间等待工具完成工作,这不仅仅是时间问题,它会在持续打断编码动作,破坏开发者思维的连续性,降低思维本身的效率。

为此,工程管理者非常有必要持续投入一部分时间精力关注并优化工具的执行效率,这部分优化效果往往会在整个工程团队层面获得远超投入的长期效率收益。具体来说,每种工具都有其内在的性能优化逻辑,很难在一篇短短的文章里面完整罗列出来,不过有一些值得参考的基础抽象准则。

1. 收集性能数据

在着手执行具体的性能优化动作之前,建议前置思考几个问题:当前工程环境中的性能水平如何?那些环节已经成为性能卡点?这是非常重要且必须的步骤,工程师们往往更擅长于直接动手解决问题,但在没有论证、分析清楚问题的关键步骤之前就匆匆着手投入到具体实施中,往往只会过早陷入细节,大量精力投入到影响较小的方向上,最终效果只能停留在浅层,未涉及根本。

而要分析性能问题,最有力的依据必然是数据。普通 Web 应用有一整套关于性能的标准数据模型,有许多方法论、与工具帮助收集分析各种核心性能数据,但对于工程化工具而言则缺乏相关的标准化方案,所幸这类工具本质上依然是一些软件系统,因此可以通过扩展工具插件注入性能埋点、分析日志等方式监控工具的运行性能,之后搭配一些自研的程序将数据保存到持久存储环境中,后续即可据此分析工程环境的性能问题。常见的性能分析方法有:

  • 对于 Webpack,可开启 [stats](https://webpack.js.org/configuration/stats/) 配置项,之后将生成的 stats 数据上报到存储介质中;也可以借助一系列性能分析插件,例如 [webpack-bundle-analyzer](https://www.npmjs.com/package/webpack-bundle-analyzer)[rsdoctor](https://rsdoctor.dev/) 等实现类似效果;
  • 对于 ESLint,则可以开启 --debug 选项,获得完整的日志记录,之后分析性能情况;其次,也可以通过 TIMING 环境变量获得各个 rules 的执行效率:

  • 对于 Typescript,可使用 --generateTrace 选项生成完整的 Profile 信息,效果:

其他优秀开源工具一般也都有类似的开关或插件机制,读者可自行分析学习。这些方案与具体工具强相关,并无通用规则,实现与学习成本相对较高,也可以退而求其次选择另一种更通用但必然损失精度的方案:直接监听工程命令的执行时间,例如 Webpack 构建整体耗时、HMR 耗时、ESLint 执行整体耗时等等。

收集到数据之后,建议将其连同环境信息 —— CI、CD、本地环境等,一并上传保存到持久存储环境中,方便随时调取分析性能走势。

2. 减少计算空间

同等条件下计算空间越大,则时间消耗必然越大!例如使用 ESLint 检查 1000 行代码必然比 10000 行要快不少,这似乎是一句废话,但注意现实中大部分工程都遵循 2-8 定律,也就是 80% 的时间都花在开发 20% 的文件模块上,那么剩下没有发生变化的 80% 是不是可以直接跳过计算以节省性能呢?答案是肯定的,确实存在许多方式方法能有效避免重复计算,例如:

  • 在 Webpack 中可通过 [lazyCompilation](https://webpack.js.org/configuration/experiments/#experimentslazycompilation) 配置项跳过页面尚未使用的模块,也可以用 [cache](https://webpack.js.org/configuration/cache/) 项配置持久缓存避免重复构建并未发生变化的模块;类似的,Typescript、ESLint 等工具也都配备相应缓存能力,开启相关配置项即可;
  • 上述不同工具都有自己的缓存逻辑与资源复用规则,但通常都缺乏对跨环境协同的支持,单纯依靠工具无法将本地的缓存副本分享到 CI/CD 等环境中消费,本次 CI/CD 执行产生的缓存数据也无法延续到下次执行时继续消费,这就导致缓存的性能优化效果大打折扣。不幸的是社区目前并没有针对这类问题的通用解决方案,倒是有不少现代 Monorepo 方案提供了这类跨环境复用缓存能力,包括 RushNXTurboRepo 等,它们的解题思路是保存工程命令的执行结果,例如 package.json 中定义的 build 命令执行后若生成 dist,则将该目录压缩缓存到云环境,之后若文件 hash 与环境变量都完全相同则直接跳过命令执行而复用该产物副本,相关实现比较复杂后续单独开篇介绍;
  • 除缓存技术外,也可以设法将全量操作优化为仅针对变更代码的增量操作以节省执行性能,最直观的例子就是 lint-staged,借助这一工具能够在 Commit 代码时拼接出 eslint ./src/foo ./src/bar 形式的校验命令,配合 ESLint 的部分检测能力也就实现了增量检测功能。同样的,不同工具有自己特化的增量执行规则,无法一概而论,另外一种思路是借助现代 Monorepo 方案如 RushNX 等实现增量效果,以 Rush 为例,它的解题思路是计算本次变更的文件列表,以此推断出本次发生变化的 Packages,之后对这部分 Packages 及依赖了这些 Packages 的包 —— 也就是影响范围,执行工程命令(rush build --from @package/foo)从而实现增量效果。当然,这依然是一个非常复杂的命题,需要单独开篇讨论;

综上,目前流行较广泛的方案可大体总结为缓存、增量两类,许多优秀的工程化工具都提供了相关能力,只需正确设置即可开启使用,在此基础上也可以借助 Monorepo 方案实现更通用的缓存与增量执行效果,不一而足。

3. 使用更高性能版本

除了挖掘工具本身的性能极限之外,升级工具版本通常也能带来不错的性能收益,例如 Webpack5 引入文件持久缓存与 LazyCompilation 模式能有效减少构建任务量;Taiwind4 版本则将核心计算引擎迁移到 Rust 语言以获得更加计算性能;Typescript、ESLint 甚至包括 Node 引擎本身在最新版本中也都有不同程度的性能优化。此外,也可以考虑选用更高性能的替代工具,例如使用 RSPack 代替 Webpack,使用 OXLint 代替 ESLint,使用 SWC 或 ESBuild 代替 Babel 等。

相比于研究工具的底层原理与性能优化策略,直接升级版本通常能以更低的成本获得更大的性能收益,性价比非常高,但请务必注意,无论工具官方做出如何强有力的承诺,升级过程通常都并不容易,甚至可能伴随漫长的痛苦,并且版本跨度越大升级难度越高。

主因一方面是所谓的“代码变更必然引入风险”,底层工程化工具的版本变化也必然会影响顶层代码构建结果,并且这类工具作为工程最底层的基础设施,其风险影响面比其它具体代码模块变更要大的多,相应的回归测试成本也必然会高出许多,部分细微差异甚至很可能躲过单测、E2E 测试、回归测试等质检步骤最终意料之外地出现在最终用户面前。

另一方面,现实往往要远比工具维护团队所预想的复杂得多,当工程规模足够大时,总会出现一些意料之外的特性依赖,可能是直接调用了某些并不承诺稳定性的内部接口,可能依赖工具的输出日志,甚至可能依赖了工具内部某些潜在 Bug。此时即使只是 semver 中的 patch 更新都有可能使得工程环境崩溃,更悲观的情况可能引入了一些特别隐晦、难以察觉的偶现问题,致使工程整体始终存在隐患。因此,工程化工具的升级或替换并不是一件简单的事情。

但站在工程管理视角,我们绝不应该因噎废食,否则日积月累到不得不升级底层依赖时(参考:NPM 依赖管理的复杂性),巨大的版本变化更容易引入难以预料、难以修复的风险,优秀的工程团队对三方依赖更新应该始终抱有严谨而开放的态度,应该以尽可能低的成本完成版本与底层工具升级,幸运的是有不少策略能达成这一目标:

  • 完备的自动化测试:这是一个普适的定理,工程中自动化测试 —— 包括单测与 E2E 的覆盖面越高,理论上回归测试的边际成本越低,只要覆盖率足够高,在依赖版本升级场景中甚至可能完全无需人工干预即可实现完备的质量检测;

  • 制定升级方案:基于上述风险,工程团队应该谨慎看待每次版本变化,建议在着手改造代码前做好充足的调研论证与风险预案,制定详尽的升级方案,明确如下关键环节:

    • 风险预判:调研新旧版本之间的差异,据此初步判断升级的影响范围及可能出现的质量问题;
    • 渐进式升级:尽可能规避一把梭的全量升级方案,充分调研灵活设计渐进式升级、灰度方案,虽然这通常并不容易做到,但却能非常有效控制质量风险;
    • 线上观测:应针对版本变更制定线上环境观测方案,也就是在一些关键代码逻辑环节中补充有针对性的技术埋点,以便灰度或全量后能够及时观测线上的稳定性情况;
    • 回滚策略:应同步确定回滚方案,以防出现问题时能迅速回滚到稳定状态,确保万无一失;
    • 回归测试:基于上述信息确定回归测试范围,必要时可申请测试人力做好功能回归;
  • 频繁升级:只要工程存活的时间足够长,就总会出于性能、稳定性、安全性、功能等因素而不得不升级某些三方依赖,那么与其日积月累成巨大的版本变更,不如日常定期小步升级核心工具版本,将风险分摊到每次小规模更新中,尽量降低 Breaking Change 的概率,本质上这正是软件工程生命力的一种体现;

上述策略都有比较高的技术复杂度,特别在“升级方案”环节,需要扎实的技术功底、判断力、执行力等,以及敢于谨慎冒险的魄力,后续我会另开文章总结这些年在这方面的经验与心得。

On this page