范文杰从接手慢工程的经历切入:面对自研框架或体量较大的旧项目,仅仅知道“构建慢”,不足以决定改什么。他把优化分成连续的三步——理解工作流程,找到时间花在哪里,再采用相应手段。
他举了两个经历:一个自研小程序预编译框架需要兼顾复杂功能与性能;另一些旧项目只有几万行代码,却可能从启动编译到完成等上七八分钟,甚至更久。第一次遇到时,他并不清楚 webpack 卡在何处。后来做过数次诊断,才形成这套先量后改的顺序。
这段分享来自深圳“前端的挑战与机遇”录播第 5 段,所属稿件发布于 2022 年 5 月 9 日。当时讨论的是 webpack 4/5 及相关工具,演示数字不构成通用性能保证。
核心流程:初始化、构建与生成
初始化阶段读取和合并配置,建立 Compiler 并安装插件,随后进入运行。分享没有把这里作为主要优化目标,因为在他观察的项目中,更明显的耗时出现在后面的模块处理和生成阶段。
构建阶段先定位文件、读取内容、运行 loader,再解析代码中的依赖,继续处理依赖模块。模块越多,文件访问、转换与解析工作就越多;某些转换过程还会重复经过字符串和语法树表示。最终建立的模块及其关系,为后续打包提供基础。
他顺着一个入口文件讲递归:解析 import 或 require,找到依赖文件,读取、运行 loader、生成语法树、继续找下一层依赖。完成后得到 module 与模块关系图。按他的个人测量,这部分在某个项目中占到构建时间的大约一半;他把文件 I/O、多次表示转换和耗时 loader 作为重点怀疑对象,而不是把“有很多文件”当作唯一解释。
生成阶段进一步组织 chunk,执行优化、压缩等操作,再输出文件。代码压缩和分包都可能需要明显时间。范文杰强调,要先知道工具在完成什么任务,才能理解某个配置为什么会耗时,而不是看到一次等待就认为工具在做无用功。
这一阶段会建立 chunk graph,决定入口、运行时和异步 chunk 怎样分组,再做诸如 tree shaking、模块合并与压缩等优化。范文杰坦言分包规则本身相当复杂;他在自己的测量中看到生成阶段也占了可观比例,并点名当时的 Terser 压缩和 SplitChunks 可能很耗时。压缩慢并不等于插件质量差,它所完成的工作也有计算成本。
Stats 帮助回答构建里到底有什么
第一条分析路线是导出构建统计信息,按需要启用 profiling。范文杰重点介绍了 assets、chunks 和 modules:它们分别帮助查看产物文件、组织后的代码块和具体模块,以及这些对象之间的关联。
他的阅读顺序是从输出文件向里追:一个 asset 对应哪些 chunk,每个 chunk 包含哪些 module;module 的 profile 再显示 resolve 和 build 等环节耗时。这样能回答“这个包为什么大”和“模块处理时间花在哪”两类问题。Stats 还会列出 webpack 版本与总时间,为前后两次构建比较提供上下文。
模块的 profiling 信息还能用于观察解析定位和构建等耗时。把这些信息联系起来,可以追查一个体积大或构建重的产物由哪些内容组成。不过,关系图和计时口径需要分别理解:共享模块、并行执行和重叠阶段存在时,简单相加不等于实际经过的总时间。
不同分析工具,回答不同问题
分享逐一展示了几类工具,并没有推荐只安装一种就完成全部诊断。
- webpack analyse 一类工具呈现模块与依赖关系,信息较多,但复杂工程里的阅读和使用体验也可能有负担。
- Statoscope帮助按入口和模块关系检查内容,查看是否有不必要或重复打包,并比较两次统计结果的变化。
- webpack-bundle-analyzer通过可视化面积展示模块与包体占比,便于发现较大的依赖,但体积大并不直接等于编译慢。
- speed-measure-webpack-plugin更关注插件与 loader 规则的运行耗时,可以补上另一类线索;它不负责解释全部产物体积分布。
- webpack-dashboard改善开发过程中的信息呈现,片中也提到其数据进一步利用上的局限。
现场比较也带着他的使用判断:官方分析器信息全面但当时体验有不稳定之处;Statoscope 的入口、模块关系和两份 Stats 的 diff 很适合看重复打包,却不回答具体耗时;bundle analyzer 能一眼看到某个 npm 包占了多少空间;speed-measure 能显示插件与规则消耗的时间,但不展示完整体积分布。选工具时先问自己要量“包里有什么”还是“哪一步等得久”,能少绕不少弯。
这些评价对应当时版本和范文杰的使用经验。核心方法是根据要回答的问题组合工具:产物包含什么、哪些转换耗时、改动前后发生什么变化,不应混成同一个指标。
Stats 之外,还能在 hooks 上记录阶段时间
Stats 未必直接提供想知道的所有内部阶段信息。例如某一步优化到底消耗多久,可能需要沿生命周期再定位。范文杰提出在合适的开始、结束节点记录时间,通过 hooks 观察特定工作段。
他举初始化阶段的开始与结束钩子为例:在两处各记时间戳,再计算差值。相同办法可以用于关注的模块构建或生成环节。不过钩子之间可能还有别的工作,所得数字首先是那段生命周期的耗时,不能未经核对便当成某一个插件的净执行时间。他自己为演示写的工具还只是原型,因此没有把它包装成现成产品推荐给听众。
这需要理解钩子的真实触发范围,不能仅凭名字就认定两点之间覆盖某个完整阶段。他展示了自己收集模块构建、生成与优化时间的工具原型,但明确说当时尚未完成,现场也没有提供一个已成熟可用的发布版本。
缓存:复用的是中间信息,不只是最终文件
优化措施首先是缓存。范文杰以 webpack 5 文件系统缓存为例,解释首次构建保存部分信息,后续符合复用条件时恢复它们,从而跳过重复工作。
他用 Three.js 做过个人测试,展示首次与后续构建之间的明显差异。这个例子说明缓存命中可能带来的收益,但不能把热缓存结果与另一工具的冷启动混在一起,也不能把示例秒数当成任意工程都能达到的成绩。
他展示的 webpack 5 配置是把缓存类型设为文件系统,初次构建时保存模块依赖等中间状态;后续条件未变时从快照恢复,跳过部分递归解析与转换。个人实验中,Three.js 的一次约 18 秒构建,在已有缓存的再次构建中降到约 0.8 秒。两个数字对应冷、热两种条件,展示的是缓存命中效果,不能用来宣传“webpack 构建只需 0.8 秒”。
随后他比较当时 webpack 4 生态中的做法:loader 结果缓存、保存更多构建状态的插件,以及 Babel 等工具各自的缓存。不同层级复用的范围不同,复杂度和兼容要求也不同。这里记录的是历史方案,旧插件是否仍维护、与所用版本是否兼容,需要另行判断,不能原样拼成今天的配置清单。
例如 cache-loader 主要复用一串 loader 的结果,HardSource 一类方案尝试保存更大范围的模块与解析状态,Babel 自己也能缓存转换结果。复用范围越大,恢复得越多,失效条件和上下文要求也越复杂。他把自己测得的 webpack 4 改善数字明确当作个人样本,不要求听众把几种缓存叠加后直接相加收益。
并行:先确定哪部分能够独立执行
另一类措施是多进程处理。片中分别讨论 loader 转换、多个独立 webpack 配置实例,以及压缩任务的并行。它们解决的是不同问题:多个配置各起进程,并不会自动让单个配置内部的每一步都变快。
他提到的 thread-loader 用于把适合的转换放到工作进程;有些 loader 依赖特定上下文或发出文件,不适合这样转移。另有工具能让多个 webpack 配置实例分别运行,这适合独立任务,却不改变单个实例的依赖递归。Terser 的并行选项则针对压缩本身。找到耗时段后再决定在哪一层并行,否则只会增加进程启动与通信成本。
范文杰也提醒 loader 兼容性。例如某些需要特定编译上下文或发出资源的操作,不能直接放到工作进程中。并行并非把所有规则套一层就完成,需要对照所用工具能力和具体转换任务。
少处理不需要的内容,把检查放到合适环节
通过 include、exclude 等规则缩小转换范围,可以减少重复处理;但不能简单认为所有第三方代码都不需要转换,仍要看目标环境。noParse 也依赖明确前提:确认相关内容无需继续进行相应依赖解析,不能为省时间盲目跳过。
TypeScript 可以把转译与类型检查拆开,通过独立过程继续检查,而不是为了快就不再检查。lint 同样可以在编辑器、提交或 CI 等环节安排,不必让每次本地构建承担全部检查工作;团队仍需有实际能执行的质量关口。
片中给的思路是 TypeScript 转译先采用 transpileOnly,再让独立进程负责类型检查;lint 可在 IDE、提交或持续集成阶段完成。这样缩短本地等待,不是撤销检查。source map 也按环境选择:开发需要快速定位,生产需要能追溯线上问题,但两边不必采用相同成本的生成方式。
source map 的生成策略和图片处理也有成本。范文杰建议按开发与生产需要选择适当方式,避免每轮构建重复做本可提前完成的资源处理。他还提醒关注旧依赖、运行环境与工具版本之间的限制。需要优化的是整体路径,不是只盯着一个插件。
延迟编译与项目拆分:减少一开始必须完成的工作
最后,分享回到构建工具的不同策略。除了算法和内部数据结构改进,webpack 5 当时的 Lazy Compilation 实验能力,尝试把部分编译延后到真正需要时;项目拆分与 Module Federation 等能力,则可以帮助开发者集中处理正在修改的部分。
范文杰把 webpack 5 的模块、chunk 关系结构调整也列入版本差异,并特别解释 Lazy Compilation:一开始不必编译尚未进入的异步入口,访问到对应功能时再做这部分工作。它优化的是“现在必须等什么”,不是把编译成本彻底取消。微前端或 Module Federation 则从项目边界着手,让本地开发时只处理相关子应用;是否收益明显,仍由实际依赖关系决定。
范文杰又以 Vite 的开发启动方式作对照:不在一开始完成整个应用的打包,按请求处理模块,可以改变启动等待的组成。这是在解释开发阶段为什么可能更快,不是说所有生产构建也采用同一流程,或某个工具在所有场景都优胜。
整场分享最终落在同一种排查习惯:分清冷启动与复用、体积与耗时、当前要做的工作与可以延后的工作,再衡量修改是否解决了真实瓶颈。该分段以附录内容结束,没有收录现场问答。
