Rolldown Lazy Compilation 设计解析:基于 /@vite/lazy 的动态导入按需编译机制
Rolldown Lazy Compilation 设计解析基于 /vite/lazy 的动态导入按需编译机制【免费下载链接】rolldownFast Rust bundler for JavaScript/TypeScript with Rollup-compatible API.项目地址: https://gitcode.com/GitHub_Trending/ro/rolldownRolldown 的 Lazy Compilation懒编译是一种开发期优化动态导入import()背后的模块不再随入口打包而是等浏览器在运行时真正请求时才即时编译并返回。本文以 design.md 为主体结合 implementation.md 与crates/rolldown_plugin_lazy_compilation/下的源码系统讲解它的启用方式、作用范围、编译粒度、代理模块双状态模型、rolldown:exports契约以及 Dev Server 集成细节。读完你既能快速在 dev 模式下开启该特性也能理解/vite/lazy请求背后的完整调用链与安全设计。什么是 Lazy CompilationLazy compilation 是一种开发优化手段把动态导入模块的编译工作推迟到运行时真正被请求时才执行。其目标有三更快的冷启动—— 启动时只编译入口及其同步依赖按需编译——import()背后的代码在浏览器执行到该语句时才即时编译just-in-time对用户透明—— 不需要修改业务代码import(./foo)依旧开箱即用。它复用了 HMR 的运行时与渲染路径来产出模块输出因此与 HMR 模块系统天然配套属于 dev/HMR 体系内的一项独立特性。启用方式opt-in 的 devMode 配置Lazy compilation 是**主动选择opt-in**的特性且必须嵌套在 dev 模式内启用export default { experimental: { devMode: { lazy: true }, }, };单独的devMode只会开启 dev/HMR 机制即HmrPluginlazy: true才会额外把LazyCompilationPlugin插入到用户插件之前。内部插件的注册逻辑位于 apply_inner_plugins.rs当options.experimental.dev_mode存在时先压入HmrPlugin当dev_mode.lazy Some(true)时再创建LazyCompilationPlugin并压入第 41–48 行。这些内置插件总是被前置到用户插件之前再通过PluginHookMeta机制控制钩子最终执行顺序用户几乎感知不到它们的存在。插件通过context()方法暴露共享的lazy_entries/fetched_entries集合封装为LazyCompilationContext并交给DevEngine使引擎能在每次懒编译前调用mark_as_fetched。rolldown-vite 的 bundled dev 模式默认开启lazy: true。仓库里的真实示例可见 examples/lazy-compilation/dev.config.mjs它同时打开了devMode.lazy与incrementalBuildimport { defineDevConfig } from rolldown/test-dev-server; export default defineDevConfig({ build: { input: ./src/entry-a.js, output: { strictExecutionOrder: true, }, experimental: { devMode: { lazy: true, }, incrementalBuild: true, }, treeshake: false, }, });作用范围只针对动态导入Lazy compilation 有明确的边界仅动态导入import()—— 静态导入永远被立即编译不参与懒编译独立特性—— 复用 HMR 运行时/渲染路径产出模块输出/vite/lazy请求本身不会触发 HMR 更新。但一旦被 fetch懒模块就变成普通的被监视watched图内模块之后的编辑会走标准的按客户端 HMR 管线详见 implementation.md Editing a fetched lazy module模块类型盲区module-type-blind边界——resolve_id会代理每一个动态导入不做扩展名或模块类型过滤因此真实目标直到第一次/vite/lazy请求才被加载。编译单元内的一切都必须能渲染成 ECMAScript AST由此带来几类边界情况CSSRolldown 已移除 CSS 打包能力#4271懒编译会把这个硬错误从服务启动时推迟到第一次/lazy请求HTTP 500可在消费方await import()处以可捕获的 rejection 形式收到JSON / text / base64 / dataurl目前在懒 chunk 内是坏的——它们的导出在链接阶段合成而懒渲染路径会跳过该步骤导致首次加载时注册为空导出需要等一次重建 页面刷新后才正常详见 implementation.md Known Limitations二进制资源只有当某个插件在load钩子中把二进制转为 JS 时才能工作例如 dev server 的 Vite 风格 asset 插件编译期发射的字节通过onAdditionalAssets回调交付#9815。编译单元包含懒模块的所有静态依赖new URL(...)引用也按静态依赖处理。编译粒度懒边界Lazy Boundary当一个懒模块被请求时只编译该模块 它的同步依赖并减去请求方客户端已经执行过的模块通过executed_modules做按客户端剪枝懒模块内部的嵌套动态导入不会编译它们会成为新的懒边界这就在每个动态导入处天然形成了一层懒边界。Entry ├── sync-dep-1 (compiled immediately) ├── sync-dep-2 (compiled immediately) └── import(./lazy-a) ← lazy boundary ├── sync-dep-3 (compiled when lazy-a is requested) ├── sync-dep-4 (compiled when lazy-a is requested) └── import(./lazy-b) ← another lazy boundary (NOT compiled yet)在渲染出的懒 chunk或 HMR patch内部嵌套的import()指向另一个懒代理时HMR finalizer 会把它重写为先去 fetch/vite/lazy?...再通过loadExports(stableProxyId)读取代理注册的导出——因为 partial bundle 没有单独打包的代理 chunk如果不这样读取代理的顶层导出就会丢失详见 implementation.md Lazy chunk rendering。核心设计决策1. 透明的用户体验用户不需要改动任何代码。import(./module)直接可用插件自动重写动态导入、自动解包代理导出。LazyCompilationPlugin只注册四个钩子HookUsage::BuildStart | ResolveId | Load | TransformAst见 lazy_compilation_plugin.rs其中build_start只负责捕获cwd供后续计算稳定 ID 使用。2.rolldown:exports契约代理模块导出一个特殊命名导出rolldown:exports——它是一个Promise解析为真实模块的导出若真实模块初始化时抛错它会reject这正是初始化错误能在消费方await import()处被捕获的原因#9981。Rolldown 的transform_ast钩子会自动把动态导入包上一层解包助手// User code (unchanged) const mod await import(./lazy.js); // Transformed by lazy compilation plugin const mod await import(./lazy.js).then(__unwrap_lazy_compilation_entry);助手函数会在至少有一个动态导入被包裹的模块中注入且插入在指令序言directive prologue如use strict之后以保留其语义function __unwrap_lazy_compilation_entry(m) { var e m[rolldown:exports]; return e ? e : m; }实际 AST 构建逻辑在 runtime_injector.rsLazyCompilationRuntimeInjector遍历表达式把每个ImportExpression重写为import(...).then(__unwrap_lazy_compilation_entry)并用transformed_count记录是否注入助手create_unwrap_lazy_compilation_entry_helper按function声明逐节点构造出上面的函数体参数m、var e m[rolldown:exports];、return e ? e : m;。该变换对所有动态导入都安全懒代理返回 promise非懒模块则原样透传e为空时助手直接返回模块命名空间代理模块自身被豁免ID 含?rolldown-lazy1的模块不会被transform_ast处理因此 stub 模板里的import(/vite/lazy?...)和 fetched 模板里的import($MODULE_ID)永远不会被再次包裹见transform_ast开头的if args.id.contains(?rolldown-lazy1)早退。3. 代理模块的两种状态代理模块有两个状态决定LazyCompilationPlugin的load钩子返回什么内容未 fetch初始状态返回stub 模板proxy-module-template.js它通过/vite/lazy端点获取真实代码const lazyExports (async () { // Remove the cache of the current module from the runtimes module map. // This module with key $STABLE_PROXY_MODULE_ID is swapped in the lazy loaded chunk again with the real module. delete __rolldown_runtime__.modules[$STABLE_PROXY_MODULE_ID]; // Dev server will intercept this import and serve the actual module code. // We send the proxy module ID (with ?rolldown-lazy1) so the server can mark it as fetched. await import( /* vite-ignore */ /vite/lazy?id${encodeURIComponent($PROXY_MODULE_ID)}clientId${__rolldown_runtime__.clientId} ); // Loading the chunk re-registers this proxy id, exposing the real modules // initializer as its own rolldown:exports promise. Await that promise (dont // just hand back the namespace) so an error thrown while the real module // initializes rejects lazyExports too, surfacing at the consumers // await import(...) (catchable) instead of escaping as an unhandled rejection. return await __rolldown_runtime__.loadExports($STABLE_PROXY_MODULE_ID)[rolldown:exports]; })(); export { lazyExports as rolldown:exports };这里包含三步(1) 驱逐代理过期的运行时注册让懒 chunk 能用同一个稳定代理 ID重新注册真实初始化器当前实现已演化为调用__rolldown_runtime__.removeModuleCache($STABLE_PROXY_MODULE_ID)语义相同(2) fetch 懒 chunk(3) 通过重新注册后的代理自身的rolldown:exportspromise解析——这是一个两层的 promise 链其 rejection 语义使得初始化错误在消费方处可捕获。已 fetch首次请求之后返回fetched 模板proxy-module-template-fetched.js它直接导入真实模块const lazyExports (async () { await import($MODULE_ID); return __rolldown_runtime__.loadExports($STABLE_MODULE_ID); })(); export { lazyExports as rolldown:exports };动态导入的返回值namespace被有意丢弃导出改为按稳定 ID 从运行时注册表读取因为当一个共享懒模块落入公共 chunk 时chunk 级重命名可能压缩导出名直接取 namespace 会得到undefined#9132。其中$MODULE_ID是绝对路径仅用于解析$STABLE_MODULE_ID是相对 cwd 的稳定 ID。状态转换由LazyCompilationContext.mark_as_fetched()管理lazy_compilation_plugin.rsDevEngine 在每次懒编译之前调用它见 dev_engine.rs。两个模板共使用四个占位符每个都被替换为 serde_json 引号包裹的 JS 字符串字面量因此 Windows 反斜杠路径也能正确转义#9102占位符值使用位置$PROXY_MODULE_ID绝对路径 ?rolldown-lazy1stub ——/vite/lazy?id请求参数$STABLE_PROXY_MODULE_ID稳定 ID ?rolldown-lazy1stub —— 模块表 delete loadExports$MODULE_ID绝对路径去掉 queryfetched ——import($MODULE_ID)$STABLE_MODULE_ID稳定 IDfetched ——loadExports($STABLE_MODULE_ID)render_proxy_template按长的先替换的顺序处理且$MODULE_ID最后替换因为其他三个占位符名都包含MODULE_ID子串lazy_compilation_plugin.rs。配套的单元测试覆盖了 Windows 与 Unix 路径的转义正确性同文件tests模块。4. Dev Server 集成Dev server 处理/vite/lazy?id...clientId...请求的完整流程收到携带代理模块 ID绝对路径 ?rolldown-lazy1与客户端 UUID 的请求调用DevEngine.compileEntry(moduleId, clientId)TS 侧/DevEngine::compile_lazy_entryRust 侧DevEngine 查询该客户端的executed_modules并把代理标记为已 fetch安全门该 ID 只是构建缓存的查找键——不在模块图中的 ID 会被以Lazy entry module not found in cache拒绝绝不从文件系统解析因此恶意请求无法打包任意文件类似 Vite 的server.fs.strict由测试钉死见 dev-lazy-compile.test.ts#9969。注意顺序DevEngine::compile_lazy_entry会在该校验之前无条件调用mark_as_fetched因此未知 ID 仍会进入fetched_entries无害但排查问题时值得了解从代理模块做部分扫描ScanMode::Partial——插件返回 fetched 模板其import($MODULE_ID)触发真实模块的编译编译期间发射的资源通过onAdditionalAssets回调在代码返回之前交付保证 chunk 执行时资源已可服务#9815修复 vitejs/vite#22596直接返回编译后的 JSContent-Type: application/javascript浏览器以 ES module 方式加载编译失败则应答 HTTP 500通知协调器触发一次后台重建使后续页面加载直接拿到 fetched 模板无需再发/lazy请求。在resolve_id钩子中插件处理两个关键分支lazy_compilation_plugin.rs已知代理 ID 的再解析任何导入类型例如 dev server 把 stub ID 当作入口来服务懒编译请求若 specifier 以?rolldown-lazy1结尾且在lazy_entries中则解析为其自身未知代理 ID 则继续不可解析对应 #9969 安全门动态导入且导入方是已 fetch 的代理返回Ok(None)跳过代理创建让import($MODULE_ID)正常解析到真实模块——否则会为同一模块再创建一个代理造成无限递归Issue 3 的经验教训其余动态导入通过ctx.resolveskip_self: true、转发custom解析原 ID 后追加?rolldown-lazy1。追加是幂等的#9439ctx.resolve可能重入其他插件的 resolve 钩子如别名插件若解析结果已带标记则直接复用避免双重后缀导致 stub 模板中的运行时失效键与代理 ID 失配回归问题 vitejs/vite#22454。load钩子则只服务lazy_entries中存在的代理 ID其余?rolldown-lazy1ID 一律放行返回Ok(None)且对代理 ID 会跳过用户侧所有构建钩子resolve_id、load、transform、transform_ast、module_parsed使用户插件只能看到真实模块。数据生命周期懒编译涉及两个作用域的数据Session 作用域跨所有浏览器标签页共享贯穿 dev server 整个生命周期数据说明Module Graph所有已解析、已编译的模块lazy_entries解析过程中发现的所有代理模块 ID 集合fetched_entries已通过/vite/lazy请求 fetch 过的代理模块集合Build Output磁盘/内存中的打包 JS 文件Watched Files被监视变更的文件关键行为一旦某个懒模块被任一客户端 fetch之后所有客户端拿到的都是 fetched 模板直接导入真实模块。懒编译完成后构建输出会被刷新因此后续页面加载无需/lazy请求即可拿到 fetched 模板。Client 作用域每个浏览器标签页独立用clientId标识数据说明clientId浏览器标签页的唯一标识executed_modules浏览器实际执行过的模块用于 HMR 边界计算与懒 patch 剪枝会话生命周期clientId → ClientSession { executed_modules }存于 DevEngine 的SharedClients在收到该 clientId 的首条hmr:module-registered消息时隐式创建websocket 断开时经removeClient移除executed_modules是只增不减的稳定 ID 集合且包含形如src/foo.js?rolldown-lazy1的代理 ID——懒 chunk 会用稳定 ID 重新注册代理特殊 clientIdrolldown-tests被视为已执行一切Rust 层测试绕过按客户端门控只有浏览器 E2E playground 走executed_modules剪枝路径clientId由 HMR 运行时在初始化时通过crypto.randomUUID()生成追加到 websocket URL 与每个/vite/lazy请求的clientId参数中它在懒编译中的唯一作用是按客户端剪枝没有路由语义——编译结果在 HTTP 响应中同步返回未知clientId静默退化为空执行集返回完整依赖闭包。Fetched 与 Executed 的区分这是两个作用域下的不同概念Fetched会话级浏览器对该代理模块发过/lazy请求服务端已编译真实模块及其依赖所有客户端此后都拿 fetched 模板Executed客户端级浏览器真正执行过该模块代码用于剪枝某个客户端的懒 patch 并门控 HMR 传播。两者可以不同步客户端 A fetch 了某模块客户端 B 可能还没导航到对应路由。当已 fetch 的懒模块被编辑时各客户端结果不同执行过它的客户端收到真正的Patch若无 HMR 边界接受变更则FullReload从未执行过的客户端收到一个内容为空但非Noop的Patch代码只是__rolldown_runtime__.applyUpdates([]);。构建输出刷新后台重建懒编译成功后DevEngine::compile_lazy_entry的成功分支依次做两件事dev_engine.rsif result.is_ok() { // 1. 交付编译期间发射的资源在代码返回前 if let Some(on_additional_assets) ... { ... } // 2. 排队后台重建 self.notify_module_changed(proxy_module_id); }协调器收到ModuleChanged携带含?rolldown-lazy1的原始代理 ID后先调用update_watch_paths()——否则懒编译过程中发现的新监视文件会在重建任务启动时被丢弃这一步正是让之后编辑懒模块能触发重建的关键排队一个Rebuild任务把代理 ID 当作变更文件并把输出标记为 stale重建把构建输出中的 stub 换成 fetched 模板之后的页面加载直接拿到它无需/lazy请求。原始代理 ID 被刻意不规范化部分重建时它解析回自身resolver 保留 query、字符串匹配增量缓存中代理模块的键、并强制代理的load钩子重跑——这次返回 fetched 模板。若规范化为真实模块 ID则会失效错误的模块并留下缓存的 stub 代理。成功的后台重建对已连接客户端是静默的输出原地替换、不发送任何 websocket 消息正在运行的页面继续使用/lazy返回的代码只有已有FullReload挂起或服务器正从先前广播的构建错误中恢复时才触发 reload。Rebuild任务从不产生 HMR 更新只与其他Rebuild合并因此?rolldown-lazy1伪路径永远不会泄漏进 HMR 更新计算——不过插件会通过watch_change钩子观察到它一次。失败路径懒编译失败时两个步骤都不执行——不排队重建、stub 模板留在构建输出中但代理仍保持已 fetch 标记。若后台重建本身失败消费方会缓存错误、向所有客户端广播错误浮层并取消挂起的全量 reload确保页面不会重载到损坏的 bundle 上#9903。错误处理当前错误契约不再是早期 POC 阶段Err 或 panic 都可以未知模块 ID→Err(Lazy entry module not found in cache. module_id...)hmr_stage.rs 的compile_lazy_entry此处即 #9969 安全门napi 绑定层将其包装为带Failed to compile lazy entry: ...前缀的 rejected promisedev-server middleware 应答 HTTP 500缺失id/clientId参数时放行给next()成功时设置Content-Type: application/javascript初始化错误可捕获#9981懒模块初始化抛错会使重新注册的代理的rolldown:exportspromise reject进而 reject stub 的lazyExports最终在消费方await import(...)处抛错——try/catch生效无处理程序时恰好触发一次unhandledrejection。冷路径首次/lazy编译与热路径重建刷新后的 fetched 代理都有对应 spec 钉死运行时loadExports未命中不抛错——仅告警并返回{}唯一遗留的 panic在任何 bundle 构建之前调用compile_lazy_entry。已知限制共享模块去重当多个懒入口共享公共依赖时两层机制协同防止重复执行Entry ├── import(./lazy-a) ← lazy boundary │ └── shared.js (sync dep) └── import(./lazy-b) ← lazy boundary └── shared.js (sync dep)服务端剪枝收集懒 patch 的同步依赖时跳过其稳定 ID 在请求方客户端executed_modules中的模块由hmr:module-registered填充运行时去重标志懒 chunk 以dedup_module_initializer: true渲染给每个模块包装器追加第三个真值参数——createEsmInitializer(stableId, factory, 1)/createCjsInitializer(...)——运行时在 ID 已注册时跳过 factory。服务端仍存在竞态窗口两次/lazy请求快速连发、首个 patch 的hmr:module-registered尚未到达时会产生重叠 chunkhmr_stage.rs中有 TODO但运行时去重标志使其无害shared.js同时出现在两个 chunk 中却只执行一次。HMR patch 刻意省略去重标志dedup_module_initializer: falsepatch 的意义就在于重新执行模块体并发布新导出去重会静默丢弃更新。代码注释将该标志标记为在运行时 dispose/re-execute API 就绪前的临时方案。链接期合成导出JSON、text、base64、dataurl导出在链接阶段合成的模块JSON/text/base64/dataurl在懒 chunk 与 HMR patch 内都是坏的它们被扫描为裸表达式语句ExportsKind::Noneexport default仅由链接阶段的generate_lazy_export物化而懒/HMR 渲染路径从不运行它渲染的是纯扫描期 AST 克隆。懒 chunk 以registerModule(id)注册它们运行时填充为{ exports: {} }——因此导入方在首次懒加载时看到空导出后台重建 页面刷新后完整构建应用了变换同一导入才正常。目前尚无 playground fixture 覆盖此场景。CSSRolldown 已移除 CSS 打包#4271且懒边界创建时不加载目标——所以import(./style.css)能构建成功硬错误Bundling CSS is no longer supported被推迟到第一次/lazy请求HTTP 500消费方await import()处收到可捕获的 rejection。二进制资源Rolldown 核心没有内置资源处理默认module_types映射之外的扩展名会被按 UTF-8 读取并当作 JS 解析因此懒子树内静态导入的二进制文件会在请求时导致懒编译失败。懒子树内的资源导入只有在插件于load钩子中将其转为 JS 时才可用dev server 移植的vite:asset插件正是如此。Sourcemaps懒 chunk 只有sourcemap: inline可用。/lazy负载在整条链路上HmrStage→DevEngine→ napi → middleware都只是一个普通String没有独立 map 文件的字段用file/true时代码会带上//# sourceMappingURLlazy_compile_{n}.js.map注释但 map 资源在服务端被丢弃注释悬空hidden则静默丢弃 map。相比之下HMR patch 通过HmrPatch { sourcemap, sourcemap_filename }携带 mapdev server 从内存文件存储同时提供 patch 与 map——因此同一份sourcemap: file配置对 HMR 编辑有效、对懒 chunk 静默失效。该路径目前无测试覆盖。测试覆盖与验证E2E playground 位于packages/test-dev-server/tests/playground/lazy-compilation/一个统一 dev server 配置experimental.devMode.lazy: true 一个别名插件各场景独立目录、由main.js按需导入确保每个 spec 都拿到全新首次 fetchSpec钉死的行为basic懒模块以两个独立 JS 请求到达代理 chunk 真实 chunk见 basic.spec.tsaliased-import别名重入下幂等的代理 ID 创建vite#22454emitted-asset懒编译期间发射的资源在首次加载即可服务vite#22596lazy-init-error初始化错误可用 try/catch 捕获——冷、热两条路径#9975/#9981lazy-init-error-unhandled无处理程序时恰好一次unhandledrejection——冷、热路径nested-dynamic-import懒 chunk 内嵌套的懒import()在首次点击即可解析shared-module共享 chunk 中的导出名保持#9132 fetch 后的 watch/自动 reload多个 spec 使用retry: 0因为这些 bug 只会在全新服务器的首次交互中复现。单元测试 dev-lazy-compile.test.ts 钉死了未知 ID 拒绝行为#9969先执行完整构建填充缓存再调用engine.compileEntry(/does/not/exist.js?rolldown-lazy1, some-client)断言抛出Lazy entry module not found in cache。关键源码索引便于后续深入阅读的源码路径核心插件lazy_compilation_plugin.rsresolve_id/load/transform_ast、LazyCompilationContext、render_proxy_templateAST 注入器runtime_injector.rs包裹动态导入、生成__unwrap_lazy_compilation_entry双状态模板proxy-module-template.js 与 proxy-module-template-fetched.js内置插件注册apply_inner_plugins.rsexperimental.dev_mode.lazy true时注册Dev Enginedev_engine.rscompile_lazy_entryexecuted_modules 查询、mark-as-fetched、资源交付、notify_module_changedHMR/构建hmr_stage.rscompile_lazy_entry缓存门控、部分扫描、按客户端依赖收集、chunk 渲染以及crates/rolldown/src/hmr/下的 finalizer 与 utils示例examples/lazy-compilation/dev.config.mjs 与examples/lazy-compilation/src/下的入口、异步依赖与共享模块。结语Rolldown 的懒编译把编译什么从构建期决策推迟到浏览器运行期以代理模块 双状态模板 rolldown:exportspromise 契约这一套组合拳实现了完全透明的按需编译用户代码零改动嵌套动态导入自动形成新的懒边界共享模块通过服务端剪枝与运行时去重标志保证只执行一次而/vite/lazy请求只做缓存查找、绝不读文件系统的安全设计则堵住了任意文件打包的漏洞。开发者在 dev 模式下只需experimental: { devMode: { lazy: true } }一行配置即可获得更快的冷启动体验——同时需要留意 JSON/CSS/二进制资源与 sourcemap 在懒路径下的已知边界。【免费下载链接】rolldownFast Rust bundler for JavaScript/TypeScript with Rollup-compatible API.项目地址: https://gitcode.com/GitHub_Trending/ro/rolldown创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考