Vite SSR 运行时如何处理循环导入:cyclic2 测试夹具全解析 📅 发布时间:2026/9/7 8:54:47 👁 浏览次数: Vite SSR 运行时如何处理循环导入cyclic2 测试夹具全解析【免费下载链接】viteNext generation frontend tooling. Its fast!项目地址: https://gitcode.com/GitHub_Trending/vi/viteVite 的 SSR 运行时基于ModuleRunner需要独立于 Node 原生 ESM 加载器执行服务端模块而循环导入circular import正是这类自定义模块求值器最容易踩坑的边界场景。本文以仓库中的 cyclic2 测试夹具文档 为骨架逐组解析 test1test9 的模块依赖图、源码与预期行为并结合 server-runtime.spec.ts 中的断言说明 Vite 运行时的容错策略与它在何处刻意偏离了 Node 原生 ESM 语义。读完后你将掌握如何构造与排查 SSR 模块执行器中的循环依赖问题以及如何区分“合法循环”与“会暴露为undefined的非法循环”。背景Vite SSR 运行时与 ModuleRunnerVite 的 SSR 模块执行能力由实验性 API createServerModuleRunner 提供它内部构造了一个ModuleRunner实例源码从vite/module-runner导入并挂接了 HMR 通道与 Node 的import.meta注入// packages/vite/src/node/ssr/runtime/serverModuleRunner.ts export function createServerModuleRunner( environment: DevEnvironment, options: ServerModuleRunnerOptions {}, ): ModuleRunner { const hmr createHMROptions(environment, options) return new ModuleRunner( { ...options, transport: createServerModuleRunnerTransport({ channel: environment.hot as NormalizedServerHotChannel, }), hmr, createImportMeta: createNodeImportMeta, sourcemapInterceptor: resolveSourceMapOptions(options), }, options.evaluator, ) }仓库中另有专门的变更文档 docs/changes/ssr-using-modulerunner.md 介绍在 SSR 场景使用 ModuleRunner 的方式。与浏览器不同Node 原生 ESM 对循环导入有严格的 TDZ暂时性死区语义而ModuleRunner作为独立的模块求值器必须自行实现循环导入的探测与绑定填充因此仓库用一组专门的夹具来固化其边界行为。除cyclic2外测试中还包含一条针对ModuleRunner.isCircularImport的用例spec L263-L273断言不会抛出maximum call stack错误从源码结构看运行时内部存在循环导入检测机制用于避免递归展开时的栈溢出。cyclic2 夹具的来历与目录结构cyclic2 的 README 说明了这批用例的来源test1test5 是“基于社区 issue #14048 评论中报告的循环导入案例”构造的test6 是另一组双模块互引场景。夹具目录结构如下每个子目录都是一组可独立执行的 ESM 模块package.json 声明了type: modulefixtures/cyclic2/ ├── README.md # 依赖关系图与来源说明 ├── package.json # { type: module } ├── test1/ test2/ test3/ test4/ # 合法循环四种导出写法 ├── test5/ # 非法循环导入顺序导致 TypeError ├── test6/ # 非法循环dep1 ⇔ dep2 互相引用 ├── test7/ # 混合 import 与 re-export 的循环 └── test9/ # default 导出在循环中的 getter 提升README 中为 test1test5 给出了如下 Mermaid 依赖图图中箭头描述index.js、dep1.js、dep2.js三个模块的引用关系下面按测试用例逐一展开。test1test4同一循环拓扑下的四种合法写法这四个用例的循环拓扑完全相同——index.js与dep2.js互为导入方dep2.js反向导入index.js中的dep1区别只在于index.js导出符号的写法。关键差异在index.js// test1/index.js —— 导入后重导出 import { dep1 } from ./dep1.js export { dep1 } import { dep2 } from ./dep2.js export { dep2 }// test2/index.js —— 直接 export ... from export { dep1 } from ./dep1.js export { dep2 } from ./dep2.js// test3/index.js —— 全部导入语句前置导出语句后置 import { dep1 } from ./dep1.js import { dep2 } from ./dep2.js export { dep1 } export { dep2 }// test4/index.js —— 通配符再导出 export * from ./dep1.js export * from ./dep2.js三组用例中循环的另一端dep2.js始终相同test1/dep2.jsimport { dep1 } from ./index.js export const dep2 { ok: dep1.ok }为什么这一圈是“合法”的执行顺序是index.js开始执行 → 遇到第一条导入test1 中为dep1test5 之前都是先触达dep1.js→dep1.js无依赖立即完成求值dep1 { ok: true }绑定就绪 → 继续执行时dep2.js才被求值此时它从处于初始化中途的index.js读取的dep1已经是可用值于是dep2 { ok: true }也能正常建立。也就是说虽然模块图上有环但环上的符号在被消费时恰好已完成初始化。这四组用例在 server-runtime.spec.ts L293-L308 中以参数化测试的形式统一断言it.for([ /fixtures/cyclic2/test1/index.js, /fixtures/cyclic2/test2/index.js, /fixtures/cyclic2/test3/index.js, /fixtures/cyclic2/test4/index.js, ])(cyclic %s, async (entry, { runner }) { const mod await runner.import(entry) expect({ ...mod }).toEqual({ dep1: { ok: true }, dep2: { ok: true }, }) })结论只要环上符号的消费时机晚于其初始化ModuleRunner能正确解析导入、直接再导出export ... from与通配再导出export *这四种形态下的循环依赖。test5导入顺序决定成败且错误形态与 Node 不同test5/index.js 与 test1 的差别仅在于导入顺序——先导入dep2后导入dep1import { dep2 } from ./dep2.js export { dep2 } import { dep1 } from ./dep1.js export { dep1 }此时执行链变成index.js开始 → 先求值dep2.js→dep2.js反查index.js的dep1绑定而该绑定此刻尚未初始化 → 在ModuleRunner的语义下dep1拿到的是undefined紧接着dep1.ok抛出TypeError。spec 中的断言L310-L319明确记录了两边运行时的差异it(cyclic invalid 1, async ({ runner }) { // Node also fails but with a different message // $ node packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic2/test5/index.js // ReferenceError: Cannot access dep1 before initialization await expect(() runner.import(/fixtures/cyclic2/test5/index.js), ).rejects.toMatchInlineSnapshot( [TypeError: Cannot read properties of undefined (reading ok)], ) })对比值得玩味Node 原生 ESM 会抛ReferenceError: Cannot access dep1 before initialization标准 TDZ 行为而 Vite 运行时把“未初始化绑定”降级为undefined最终表现为访问undefined.ok的TypeError。这意味着在 SSR 运行时中循环导入写错顺序不一定报 TDZ 错而可能是一个更难定位的undefined属性访问错误——这是排查服务端循环依赖时的重要线索。test6模块对互相引用undefined 回退的现状test6 来自 README 的第二张依赖图dep1.js与dep2.js互引index.js只导入dep1源码test6/dep1.js// dep1.js import { dep2 } from ./dep2.js export const dep1 dep1: dep2 // dep2.js import { dep1 } from ./dep1.js export const dep2 dep2: dep1两个模块在初始化阶段就互相读取对方无论谁先执行另一方都还是未初始化状态。spec 的注释L321-L332直言这“本应是一个错误但目前是undefined回退”it(cyclic invalid 2, async ({ runner }) { // It should be an error but currently undefined fallback. expect( await runner.import(/fixtures/cyclic2/test6/index.js), ).toMatchInlineSnapshot( { dep1: dep1: dep2: undefined, } , ) })即其中一个模块读到的对端绑定为undefined最终产出dep1: dep1: dep2: undefined。这条快照固化了当前实现的已知局限这类“真·互锁”循环不会抛错而是静默地产出含undefined的导出值开发者只能通过运行结果异常来发现。test7混合 import 与 re-export 的循环test7 复现的是 vitest 项目 issue #4143 评论中的一个真实案例见 test7/README.md循环链路为Ion.js → dom/index.js → dom/Blob.js → Ion.js// Ion.js export { IonTypes } from ./IonTypes.js // 再导出 import * as dom from ./dom/index.js // 命名空间导入 export { dom } // IonTypes.js export const IonTypes { BLOB: Blob } // dom/Blob.js —— 回指 Ion.js形成环 import { IonTypes } from ../Ion.js export const Blob IonTypes.BLOB // dom/index.js export { Blob } from ./Blob.js这个用例覆盖了“环上同时存在普通导入、命名空间导入和再导出”的组合形态。spec 断言L334-L346表明两条链都能拿到正确值const mod await runner.import(/fixtures/cyclic2/test7/Ion.js) expect(mod).toMatchInlineSnapshot( { IonTypes: { BLOB: Blob, }, dom: { Blob: Blob, }, } )由于IonTypes.js本身无依赖、先于环闭合完成求值dom/Blob.js回读Ion.js时其再导出的IonTypes已可用因此循环被正确解析。test9default 导出在循环中的 getter 提升test9 是 README 之外补充的边界用例考察 default 导出经过循环时的行为// index.js import dep from ./dep.js export default dep // dep.js import dep from ./index.js export default depspec 中该用例名为export default getter is hoistedL404-L416注释再次标出与 Node 的差异it(export default getter is hoisted, async ({ runner }) { // Node error is ReferenceError: Cannot access dep before initialization // It should be an error but currently undefined fallback. expect( await runner.import(/fixtures/cyclic2/test9/index.js), ).toMatchInlineSnapshot( { default: undefined, } , ) })default 导出在ModuleRunner中是以“getter 提升”的方式暴露的循环未闭合时读取到的是undefined而非 TDZ 错误。与 test5 的TypeError不同test9 甚至不抛错直接得到{ default: undefined }——这也是“应该报错但目前是 undefined 回退”的又一处现状记录。行为对照与实战启示把cyclic2全部用例的预期行为汇总如下依据 server-runtime.spec.ts 的断言用例拓扑ModuleRunner 行为Node 原生 ESM 行为test1test4index ⇔ dep2环上符号消费时已初始化正常dep1/dep2均为{ ok: true }正常test5同上但先导入环上未初始化侧抛TypeError: Cannot read properties of undefined (reading ok)抛ReferenceError: Cannot access dep1 before initializationtest6dep1 ⇔ dep2初始化期互读不抛错回退为dep1: dep2: undefined应报错注释表明此处是 undefined 回退的已知现状test7Ion → dom → Blob → Ion混合再导出正常各绑定值正确正常test9default 导出互指{ default: undefined }不抛错抛ReferenceError: Cannot access dep before initialization由此可以得到三条实用结论合法循环的判据是“消费时机”不是“有没有环”。test1test4 证明模块图存在环并不致命关键在于环上符号被读取时是否已完成初始化调整导入顺序如 test1 与 test5 的唯一差别就可能把可用代码变成运行期错误。SSR 运行时里的循环导入错误形态偏“软”。ModuleRunner对未初始化绑定采用undefined回退会把 Node 下响亮的 TDZReferenceError稀释成TypeError或静默的undefined值因此在服务端日志里看到属性访问undefined时应优先检查模块间是否存在循环依赖。测试快照是行为契约。这批夹具用toMatchInlineSnapshot固化了当前实现包括“本应报错但目前回退”的已知局限既是回归测试也是阅读ModuleRunner循环导入处理逻辑时最精确的行为说明书。如果你想在本地验证上述行为可在仓库根目录下用 vitest 运行 server-runtime.spec.ts例如pnpm exec vitest run packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts再对照 cyclic2 夹具目录 中的依赖图与各子目录源码逐条核对createServerModuleRunner的 HMR 与 source map 选项细节则见 serverModuleRunner.ts 的类型定义。【免费下载链接】viteNext generation frontend tooling. Its fast!项目地址: https://gitcode.com/GitHub_Trending/vi/vite创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考