Farm 开发模式下的按需编译:深入 Lazy Compilation 机制与配置实践

原创2026-10-13 19:59:231,419 阅读
文章标签:前端构建构建工具开发工具

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: true in development mode, false in 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):

  1. 在 FarmModuleSystem 上维护三份共享状态:compilingModules(正在编译的模块 Promise 表)、lazyCompilingQueue(待编译队列)、lazyCompiling(是否正处于按需编译流程)。
  2. 占位模块被 import() 执行时,先检查 compilingModules.has(modulePath):
    • 若该模块已经在编译中,直接复用同一个 Promise,避免重复请求;
    • 否则将其加入 lazyCompilingQueue,并组装请求 URL 向开发服务器发起编译请求。
  3. 请求 URL 形如:
const url = `${lazyCompilationPath}?paths=${encodeURIComponent(paths.join(','))}&t=${Date.now()}${
  isNodeLazyCompile ? '&node=true' : ''
}`;

其中 t 为时间戳,用于绕过浏览器缓存;node=true 标记当前为 Node 运行环境(服务端渲染或 Node 应用场景)。

  1. 浏览器端通过 import(url) 动态加载编译结果模块,Node 端则通过 http 模块发起请求(dynamic_module.ts#L33-L52)。
  2. 拿到结果后,调用 FarmModuleSystem.sd(dynamicResources, dynamicModuleResourcesMap) 注册新生成的资源文件,再通过 FarmModuleSystem.l(moduleId, true) 加载模块、FarmModuleSystem.r(moduleId) 读取模块导出,最终 resolve 占位模块的导出 Promise。
  3. 模板以 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 两处注册。该中间件的处理流程为:

  1. 解析查询参数 paths(逗号分隔的模块路径列表)与 node(是否为 Node 环境),并打印日志 ✨Lazy compiling <paths>(lazyCompilation.ts#L48-L51)。
  2. 调用 compiler.update(paths, true, false, false) 进行同步增量编译(lazyCompilation.ts#L57-L62),只编译请求的模块及其依赖,而不是全量重编译。
  3. 若为 Node 环境或配置了 server.writeToDisk,则把编译产物写盘(lazyCompilation.ts#L71-L73)。
  4. 通过 getDynamicResources 从 dynamicResourcesMap 提取本次新增的 dynamicResources(script/link 资源)与 dynamicModuleResourcesMap(模块到资源的映射),组成本次编译结果。
  5. 浏览器端以 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(开发模式默认开启),即可显著降低启动与热更新时的编译负担。

如需进一步深入,可继续阅读以下仓库文件:

登录后查看全文
farm