Farm 开发模式下的按需编译:深入 Lazy Compilation 机制与配置实践
Farm 开发模式下的按需编译:深入 Lazy Compilation 机制与配置实践
Lazy Compilation(按需编译)是 Farm 针对大型项目开发场景提供的一项核心优化能力:它只对真正被执行的动态导入模块进行编译,从而大幅缩短大型项目的启动与热更新编译时间。本文将以 lazy-compilation.md 文档为主线,结合 Farm 仓库中的 Rust 插件、运行时模板与开发服务器中间件源码,完整讲解该特性的原理、配置方式与实战用法。
大型项目为什么需要 Lazy Compilation
当项目体量变大时,把所有模块一次性编译完成会带来可观的启动成本。常规做法是把项目拆成多个小块、按需加载,这通常借助动态导入(dynamic import)实现:
const page = React.lazy(() => import('./page')); // lazy load page
在支持 lazy compilation 的构建工具中,这种动态导入的模块不必在编译启动阶段全部处理,而是等到模块真正在浏览器中被执行时才发起编译。Farm 的默认行为正是如此:
- 开发模式下,Farm 会惰性编译这些动态导入模块,只有当模块真正执行时才编译它;
- 按需编译可以显著加速大型项目的开发编译流程,减少首屏等待与文件监听风暴;
- 生产构建(production build)中 lazy compilation 始终处于禁用状态,保证产物完整、可预测。
从 normalize-config/index.ts 的配置归一化逻辑可以看到这条默认规则的实现:
if (isProduction) {
resolvedCompilation.lazyCompilation = false;
} else if (resolvedCompilation.lazyCompilation === undefined) {
resolvedCompilation.lazyCompilation ??= isDevelopment;
}
也就是说:生产模式强制关闭;未显式配置时,开发模式默认开启。
配置 Lazy Compilation
通过 compilation.lazyCompilation 即可开启或关闭该特性:
import { defineConfig } from '@farmfe/core';
export default defineConfig({
compilation: {
lazyCompilation: true,
},
});
配置类型在 packages/core/src/config/schema.ts 中被定义为可选布尔值:
lazyCompilation: z.boolean().optional(),
在 website/docs/config/compilation-options.md 的配置参考文档中也有明确说明:
default:
truein development mode,falsein build mode Whether to enable lazy compilation, configure to false to close.
与 treeShaking 的冲突约束
需要注意的是,lazyCompilation 与 treeShaking 不能同时开启。配置归一化阶段会做一次交叉校验(normalize-config/index.ts):
// lazyCompilation should be disabled in production mode
// so, it only happens in development mode
if (resolvedCompilation.treeShaking && resolvedCompilation.lazyCompilation) {
logger.error(
'treeShaking option is not supported in lazyCompilation mode, lazyCompilation will be disabled.'
);
resolvedCompilation.lazyCompilation = false;
}
如果同时开启两者,Farm 会打印错误日志并自动禁用 lazyCompilation(该行为对应 GitHub issue #962)。这是因为按需编译天然依赖“模块尚未编译”的状态,与静态树摇分析的语义相互冲突。
Lazy Compilation 的工作原理
整个机制横跨三个层面:Rust 编译期插件、运行时虚拟占位模块、开发服务器中间件。下面按一次动态导入的执行链路逐步拆解。
第一步:编译期将动态导入重写为虚拟模块
Farm 的核心编译器在解析阶段会先分析项目中所有的动态导入。其实现位于 Rust 插件 crates/plugin_lazy_compilation/src/lib.rs,插件名 FarmPluginLazyCompilation,且优先级被设置为 i32::MAX(源码 lib.rs#L28-L31),确保它比其他插件更早介入解析:
/// The lazy compilation plugin should take priority of all other plugins
fn priority(&self) -> i32 {
i32::MAX
}
在 resolve 钩子中(lib.rs#L114-L138),插件识别 ResolveKind::DynamicImport 且非 external 的依赖,把解析结果改写为追加了特殊后缀的虚拟模块路径:
// if the source is imported by dynamic import and it's not external source
if matches!(param.kind, ResolveKind::DynamicImport) && !is_external() {
...
resolved_path: format!("{}{}", resolve_result.resolved_path, DYNAMIC_VIRTUAL_SUFFIX),
}
其中虚拟模块后缀常量为 .farm_dynamic_import_virtual_module(lib.rs#L10)。也就是说,import('./page') 的模块 ./page 本身不会被立刻编译,取而代之的是编译一个轻量占位模块。
随后在 load 钩子中(lib.rs#L151-L183),插件把编译期注入的 dynamic_module.ts 模板代码作为该虚拟模块的内容返回,同时通过字符串替换注入四个关键值:
MODULE_PATH:虚拟模块的完整路径;MODULE_ID:编译产物中对应的模块 ID;FARM_MODULE_SYSTEM:运行时全局模块系统(FarmModuleSystem)的访问表达式,由get_farm_global_this与output.target_env计算得出;FARM_RUNTIME_TARGET_ENV:当前目标运行环境(浏览器或 Node),用于在运行时区分加载方式。
第二步:运行时虚拟占位模块的请求与合并
dynamic_module.ts 是占位模块的运行时模板,它被注入到浏览器后具备以下职责(源码 dynamic_module.ts):
- 在
FarmModuleSystem上维护三份共享状态:compilingModules(正在编译的模块 Promise 表)、lazyCompilingQueue(待编译队列)、lazyCompiling(是否正处于按需编译流程)。 - 占位模块被
import()执行时,先检查compilingModules.has(modulePath):- 若该模块已经在编译中,直接复用同一个 Promise,避免重复请求;
- 否则将其加入
lazyCompilingQueue,并组装请求 URL 向开发服务器发起编译请求。
- 请求 URL 形如:
const url = `${lazyCompilationPath}?paths=${encodeURIComponent(paths.join(','))}&t=${Date.now()}${
isNodeLazyCompile ? '&node=true' : ''
}`;
其中 t 为时间戳,用于绕过浏览器缓存;node=true 标记当前为 Node 运行环境(服务端渲染或 Node 应用场景)。
- 浏览器端通过
import(url)动态加载编译结果模块,Node 端则通过http模块发起请求(dynamic_module.ts#L33-L52)。 - 拿到结果后,调用
FarmModuleSystem.sd(dynamicResources, dynamicModuleResourcesMap)注册新生成的资源文件,再通过FarmModuleSystem.l(moduleId, true)加载模块、FarmModuleSystem.r(moduleId)读取模块导出,最终 resolve 占位模块的导出 Promise。 - 模板以
export const __farm_async = true; export default promise;结尾(dynamic_module.ts#L184-L185),把“编译完成”以异步模块的形式暴露给 Farm 运行时。
此外,浏览器环境下占位模块还会在页面顶部显示一个编译进度条(#farm-lazy-compiling,见 dynamic_module.ts#L58-L73),提示用户“正在编译哪些模块”;可通过设置 window.__farm_hide_lazy_compile_progress !== true 之外的值来隐藏它。源码注释还特别说明了按序 resolve 的设计考量(dynamic_module.ts#L157-L164):
// resolve the lazy compiling promise after the module is compiled instead of waiting for the module to be executed
// this is to avoid the case where the module is waiting for another lazy compiled module to be executed, causing a deadlock
即:占位模块在“编译完成”后就 resolve,而不是等模块执行完,避免多个按需模块相互等待造成死锁。
第三步:开发服务器增量编译与资源返回
占位模块请求的 URL 指向开发服务器上的 /__lazy_compile 端点,由中间件 packages/core/src/server/middlewares/lazyCompilation.ts 处理,并在 packages/core/src/server/index.ts 与 packages/core/src/server/index.ts#L600-L601 两处注册。该中间件的处理流程为:
- 解析查询参数
paths(逗号分隔的模块路径列表)与node(是否为 Node 环境),并打印日志✨Lazy compiling <paths>(lazyCompilation.ts#L48-L51)。 - 调用
compiler.update(paths, true, false, false)进行同步增量编译(lazyCompilation.ts#L57-L62),只编译请求的模块及其依赖,而不是全量重编译。 - 若为 Node 环境或配置了
server.writeToDisk,则把编译产物写盘(lazyCompilation.ts#L71-L73)。 - 通过
getDynamicResources从dynamicResourcesMap提取本次新增的dynamicResources(script/link 资源)与dynamicModuleResourcesMap(模块到资源的映射),组成本次编译结果。 - 浏览器端以
application/javascript返回export default <json>,Node 端以application/json直接返回 JSON(lazyCompilation.ts#L92-L107),并附带Access-Control-Allow-Origin: *等跨域头,以便在 webview(如 VS Code 扩展)等场景中也能跨源请求。
完成一次按需编译后,日志会输出 ✓ Lazy compilation done <paths> in <time>(lazyCompilation.ts#L75-L81),方便观察编译耗时。
第四步:服务端口同步
开发服务器端口可能动态变化(例如端口被占用后自动换端口),按需编译请求的地址必须同步更新。在 packages/core/src/server/index.ts#L352-L355 中可以看到,当端口变化时若启用了 lazy compilation,会调用 handleLazyCompilation(this.config, command) 同步注入到运行时模板中的服务器地址(即 dynamic_module.ts 中的 FARM_LAZY_COMPILE_SERVER_URL 占位符)。
一个可运行的完整示例
仓库中的 examples/lazy-compilation 目录提供了开箱即用的演示项目,其入口 src/index.ts 展示了动态导入的典型用法:
async function render() {
const root = document.getElementById('root');
// remove all children of root
root!.innerHTML = '';
const renderData = data();
const div = document.createElement('div');
div.id = 'root-comp';
div.innerText = renderData;
root?.appendChild(div);
const comps = await Promise.all([
Comp1(),
import('./comp2').then((mod) => mod.Comp2()),
import('./comp3').then((mod) => mod.Comp3())
]);
comps.forEach(async (comp) => {
root?.appendChild(await comp.render());
});
}
其中 Comp1 来自静态导入,而 comp2、comp3 通过动态导入按需加载;每个组件内部(如 comp1.ts)还会再次动态导入共享的 ./data 模块。启动方式为(见 package.json):
pnpm start # 等价于 farm start,开发模式,lazyCompilation 默认开启
pnpm build # 等价于 farm build,生产构建,lazyCompilation 强制关闭
pnpm preview # 等价于 farm preview
运行 farm start 后,打开页面观察控制台,可以直观看到:首次只编译入口相关的模块,当 comp2/comp3 真正渲染时才触发 /__lazy_compile 请求,并出现 ✨Lazy compiling 日志与页面顶部的编译进度提示。
使用 Lazy Compilation 的最佳实践
要充分发挥按需编译的效果,关键在于动态导入的使用位置是否合理。原文档特别强调:如果某个页面拥有体积庞大的依赖,但该依赖要到页面渲染时才会用到,就应当把它放进动态导入中,确保它在页面渲染前不会被编译。判断标准可以概括为:
- 依赖必须随使用时机切分:大依赖与使用它的页面/组件绑定,通过
import()延迟加载,而不是静态导入在入口中被一次性编译; - 避免在按需模块中静态导入重型公共依赖:按需模块内部的静态依赖也会随该模块一起在运行时编译,因此应把真正“重”的部分也动态化;
- 利用同一模块 Promise 复用:多个位置同时动态导入同一模块时,Farm 会复用
compilingModules中的 Promise,不会产生重复编译请求; - 与 treeShaking 互斥:开发模式开启 lazyCompilation 时不要同时开启 treeShaking,否则 lazyCompilation 会被自动禁用(见上文源码);
- 生产构建不受影响:lazyCompilation 只作用于开发模式,生产构建始终执行完整编译,无需为产物体积担忧。
小结
Lazy Compilation 是 Farm 开发体验的核心组件之一:Rust 插件负责把动态导入改写为虚拟占位模块,运行时模板负责排队、请求与合并编译结果,开发服务器中间件负责增量编译与资源返回,三层协作实现了“用到才编译”的按需构建。对于大型项目,合理组织动态导入并开启 compilation.lazyCompilation(开发模式默认开启),即可显著降低启动与热更新时的编译负担。
如需进一步深入,可继续阅读以下仓库文件:
- 特性文档:website/docs/features/lazy-compilation.md
- 配置参考:website/docs/config/compilation-options.md
- Rust 编译期插件实现:crates/plugin_lazy_compilation/src/lib.rs
- 运行时虚拟占位模块模板:crates/plugin_lazy_compilation/src/dynamic_module.ts
- 开发服务器按需编译中间件:packages/core/src/server/middlewares/lazyCompilation.ts
- 配置归一化与默认值逻辑:packages/core/src/config/normalize-config/index.ts
- 可运行示例:examples/lazy-compilation