Farm 懒编译(Lazy Compilation)实战指南:让大型项目开发编译从分钟级降到秒级
Farm 懒编译(Lazy Compilation)实战指南:让大型项目开发编译从分钟级降到秒级
Farm 是一款用 Rust 编写的 Vite 兼容 Web 构建工具,其「懒编译(Lazy Compilation)」特性专门针对大型项目开发期的编译性能瓶颈设计:在开发模式下,Farm 默认只编译真正被执行的模块,动态导入(Dynamic Import)的模块会被推迟到运行时首次执行时才编译,从而极大缩短冷启动与热更新的编译时间。本文将基于 Farm 仓库的官方文档与源码实现,完整讲解懒编译的概念、配置方法、虚拟占位符模块的工作原理,并深入剖析其底层实现细节,帮助你在大型项目中正确、高效地启用这一能力。
什么是懒编译:按需加载与按需编译
当一个项目足够庞大时,一个常见的优化思路是「把大块代码拆成小块,按需加载」。在前端工程中,这一步通常通过动态导入(Dynamic Import)完成。Farm 官方文档给出的典型示例是配合 React.lazy 使用:
const page = React.lazy(() => import('./page')); // 延迟加载页面
但 Farm 的懒编译并不止步于「浏览器按需加载」这一层。默认情况下,在开发模式中,Farm 会把这些动态导入视为「应当延迟编译」的模块:在模块真正被执行之前,Farm 根本不会编译它,而只是为其生成一个轻量的虚拟占位符模块。也就是说:
- 浏览器层面:
./page只有在真正渲染时才会被加载; - 编译层面:
./page及其依赖树只有在真正渲染时才会被编译。
这两层叠加,使得大型项目开发时的首次编译与热更新不再需要全量构建,编译提速效果非常明显。
需要特别强调的是,懒编译对生产构建始终是禁用的(官方文档中的明确说明,详见 文档)。生产构建仍然需要全量编译与打包,以保证产物的完整性与代码分割的正确性。这一行为也在源码中得到印证:Config 的默认配置中 lazy_compilation: true(crates/core/src/config/mod.rs),而 配置文档 明确标注其默认值为「开发模式 true,构建模式 false」。
正确使用动态导入,是懒编译生效的前提
官方文档特别提示:能否让懒编译发挥最大价值,取决于动态导入是否用得正确。例如,某个页面有一个很大的依赖,但这个依赖在该页面渲染之前不会被使用,那么就应该确保这个大依赖是被动态导入的——这样它就不会在页面渲染前被编译。反之,如果在模块顶部用静态 import 引用了大依赖,那么该依赖会被视为模块图的一部分,懒编译将无法把它隔离在编译范围之外。
配置懒编译:一行配置开启/关闭
懒编译的开关是编译配置项 compilation.lazyCompilation。官方文档给出了最简配置:
import { defineConfig } from '@farmfe/core';
export default defineConfig({
compilation: {
lazyCompilation: true,
},
});
在实际项目中,compilation.lazyCompilation 接收一个布尔值,语义如下:
| 取值 | 行为 |
|---|---|
true(默认) |
开发模式下启用懒编译,动态导入的模块延迟到执行时才编译 |
false |
关闭懒编译,所有动态导入的模块在编译期正常编译 |
关于默认值有两个信息源可以交叉印证:Config 结构体的 Rust 默认实现中 lazy_compilation 默认为 true(crates/core/src/config/mod.rs),同时官方配置文档注明该选项「在开发模式默认 true,构建模式默认 false」。因此大多数场景下你甚至不需要显式配置;显式写出 true 的好处是让项目配置意图更清晰,也便于团队评审时一眼看到该特性处于开启状态。
仓库中的测试夹具也直接使用了这一配置:在 crates/compiler/tests/fixtures/runtime/bundle/lazy_compilation/config.json 中可以看到 "lazyCompilation": true 的测试配置,用于验证懒编译下的运行时打包行为。
懒编译如何工作:从动态导入到运行时补丁
官方文档以「三步走」的方式描述了懒编译的核心流程:分析动态导入 → 返回虚拟占位符模块 → 占位符执行时请求开发服务器编译并修补运行时。下面我们结合源码逐步拆解。
第一步:拦截动态导入,替换为虚拟模块
懒编译由一个独立的 Rust 插件 farmfe_plugin_lazy_compilation(crates/plugin_lazy_compilation/src/lib.rs)实现。该插件定义了虚拟模块的后缀:
pub const DYNAMIC_VIRTUAL_SUFFIX: &str = ".farm_dynamic_import_virtual_module";
插件在 resolve 阶段拦截所有 ResolveKind::DynamicImport 的解析请求(排除 external 模块),把原本要解析到的真实路径改写为「真实路径 + 虚拟后缀」的形式(见 lib.rs)。从源码结构可以看出,为了让这个虚拟模块的解析优先级高于其他所有插件,该插件将自身优先级设为 i32::MAX,即「最优先处理」——这是保证懒编译拦截不被打扰的关键设计。
随后在 load 阶段,当解析到带虚拟后缀的路径时,插件不再加载真实模块内容,而是把内嵌的运行时模板 dynamic_module.ts(crates/plugin_lazy_compilation/src/dynamic_module.ts)注入为模块内容,并替换其中的 MODULE_PATH、MODULE_ID、FARM_MODULE_SYSTEM、FARM_LAZY_COMPILE_SERVER_URL 等占位符(见 lib.rs)。也就是说,真正被编译进模块图的,是一段「请求延迟编译」的运行时逻辑,而非业务代码本身。
第二步:虚拟占位符模块的结构
官方文档给出了虚拟占位符模块的基本结构(为保持完整性,此处完整保留原文示例并补充说明):
// ... other actions
const compilingModules = FarmModuleSystem.compilingModules;
// 返回一个 promise,这个 promise 将在延迟编译完成后 resolve。
let promise = Promise.resolve();
// 模块已经在懒编译中
if (compilingModules.has(modulePath)) {
promise = promise.then(() => compilingModules.get(modulePath));
} else {
// 请求开发服务器进行懒编译
const url = '/__lazy_compile?paths=' + paths.join(',') + `&t=${Date.now()}`;
promise = import(url).then((module: any) => {
const result: LazyCompileResult = module.default;
// ...
});
// ... more actions
}
export const __farm_async = true;
export default promise;
对照仓库中的真实实现(dynamic_module.ts),可以看到这个示例高度还原了实际代码,核心逻辑包括:
FarmModuleSystem.compilingModules是一个Map<modulePath, Promise>,记录「正在懒编译的模块」,用于去重与并发控制;- 如果目标模块已在编译中,则直接复用其 Promise,避免重复请求;
- 否则将模块路径加入
FarmModuleSystem.lazyCompilingQueue队列,随后批量请求开发服务器的/__lazy_compile接口; - 请求 URL 携带
paths(逗号分隔的模块路径列表)与t=${Date.now()}(防止浏览器缓存); - 模块以
__farm_async = true标记为异步模块,default导出为一个 Promise,模块系统会等待该 Promise resolve 后才认为模块加载完成。
值得注意的是,真实实现中 URL 还包含一个细节:当 targetEnv 为 node 时会在请求中追加 &node=true,并且改用 Node 的 http 模块发起请求而非浏览器动态 import,这是对 Node 目标环境懒编译的专门适配(该能力在 CHANGELOG 中记录为「support lazy compilation when targeting node」)。
第三步:占位符执行,请求开发服务器编译
当占位符模块在运行时被执行时,它会向开发服务器发送 /__lazy_compile 请求。服务端由 Farm 的开发服务器中间件处理,其实现位于 packages/core/src/server/middlewares/lazyCompilation.ts:
- 解析请求参数
paths(逗号分隔的模块路径); - 调用
compiler.update()对这些模块执行增量编译,同步重新生成资源(该中间件在启动时会打印✨Lazy compiling与✓ Lazy compilation done ... in xxx ms的日志,方便观察懒编译行为); - 从编译结果中提取
dynamicResources(动态资源列表,每个资源带type: 0 | 1,分别表示 script 与 link)和dynamicModuleResourcesMap(模块 ID 到资源索引的映射); - 将这两部分数据作为响应体返回:浏览器环境返回
export default {...}形式的 JavaScript 模块,Node 环境则返回纯 JSON(同时携带Access-Control-Allow-Origin: *等跨域头,以兼容 WebView 等场景)。
第四步:运行时修补,完成模块注册
占位符拿到懒编译结果后,会把新编译出的动态资源补丁到 Farm 的运行时模块系统中。真实实现中通过 FarmModuleSystem.sd(dynamicResources, dynamicModuleResourcesMap) 完成资源注册,再通过 FarmModuleSystem.l(moduleId, true) 加载对应模块,并在模块执行完成后 resolve 之前挂起的 Promise(见 dynamic_module.ts)。
源码注释中有一段值得注意的说明:加载模块后 resolve 懒编译 Promise 的时机被刻意设计为「模块编译完成后即 resolve,而不是等模块执行完成」,其目的是避免出现「模块 A 等待模块 B 执行、而 B 又等待 A 执行」的死锁——仓库 CHANGELOG 中也记录了针对懒编译死锁问题的修复(CHANGELOG)。这从侧面说明:懒编译的队列与 Promise 管理是经过实际工程问题打磨的。
同时,浏览器环境下占位符执行时会在页面顶部显示一条「Compiling xxx + N modules...」的编译进度条,并在全部编译结束后自动移除;若不想显示该进度条,可以设置 window.__farm_hide_lazy_compile_progress = true(见 dynamic_module.ts)。
编译侧的其他配合细节
懒编译不只是运行时与服务器中间件的协作,编译器侧也有对应处理:
- 在模块创建阶段,所有以
.farm_dynamic_import_virtual_module结尾的虚拟模块会被标记为「可变模块」(不参与immutableModules的不可变判断),确保它们能被正确更新(crates/compiler/src/build/mod.rs); - 在持久缓存清理阶段,只有当模式为生产且未开启懒编译时,才会清理「模块图之外」的孤儿资源(crates/compiler/src/generate/finalize_resources.rs),避免开发模式下懒编译资源被误清理。
实战:在示例项目中观察懒编译
仓库提供了可直接运行的懒编译示例 examples/lazy-compilation,其中入口文件 src/index.ts 同时使用了静态导入与动态导入两种方式:
import { data } from './data';
import { Comp1 } from './comp1';
async function render() {
// ...
const comps = await Promise.all([
Comp1(),
import('./comp2').then((mod) => mod.Comp2()),
import('./comp3').then((mod) => mod.Comp3())
]);
// ...
}
其中 ./comp2、./comp3 是以动态导入方式引用的,正是懒编译的候选模块。运行示例时,开发服务器日志中会输出 ✨Lazy compiling 与 ✓ Lazy compilation done 的记录,你可以借此直观地观察到:只有当页面真正渲染并触发 import('./comp2') 时,comp2 及其依赖 data 才进入编译流程。
运行方式(示例的 package.json 中已定义脚本):
cd examples/lazy-compilation
pnpm install
pnpm start # farm start,启动开发服务器并观察懒编译日志
pnpm build # farm build,生产构建(懒编译始终禁用)
小结:懒编译的最佳实践
最后,结合官方文档与源码实现,总结使用懒编译的几个关键要点:
- 默认开启,按需关闭:开发模式下默认启用;仅在排查到与懒编译相关的异常、或希望获得更完整的编译日志时,才考虑通过
compilation.lazyCompilation: false显式关闭。 - 依赖正确使用动态导入:懒编译的价值建立在「按需加载」之上——页面级、路由级的业务模块请使用
import()/React.lazy()/Vue的异步组件等方式引入,让大依赖真正延迟到执行时编译。 - 生产构建不受影响:懒编译仅作用于开发模式,生产构建始终全量编译,无需在生产侧做额外处理。
- 关注运行时反馈:开发时留意页面顶部的懒编译进度提示与终端日志,可帮助你确认哪些模块被延迟编译、是否命中预期。
通过理解懒编译「虚拟占位符 + 开发服务器按需编译 + 运行时补丁」的完整链路,你可以在大型项目中更从容地规划模块拆分策略,把 Farm 开发编译的性能优势真正发挥出来。如需继续深入,建议阅读 懒编译官方文档、插件的完整 Rust 实现 lib.rs、运行时模板 dynamic_module.ts 以及服务器中间件 lazyCompilation.ts。