Vite 设计与实现

第15章 SSR 与模块运行器

作者 杨艺韬 · 13,278 字 · 发布于 · 更新于

开篇引言

服务端渲染(Server-Side Rendering, SSR)是现代 Web 框架的核心能力。它要求同一套源代码既能在浏览器中运行,又能在 Node.js(或其他服务端运行时)中执行。这给构建工具带来了独特的挑战:如何在服务端高效地加载和执行 ESM 模块?如何保持与开发时 HMR 的联动?如何为不同的运行环境提供差异化的模块解析策略?

Vite 的 SSR 方案经历了从 ssrLoadModule 到 Module Runner 的演进。早期的 ssrLoadModule 是一个相对简单的模块加载器,而 Vite 6 引入的 Module Runner 则是一个完整的模块执行运行时,支持 HMR、source map、循环依赖处理等高级特性。v8 里 ssrLoadModule 仍然保留、但内部已经是包着 ModuleRunner 的 SSRCompatModuleRunner(ssr/ssrModuleLoader.ts)——官方计划在未来的大版本弃用它、目前尚未标记 deprecated。

本章将从 ssr/ 目录和 module-runner/ 目录的源码出发,深入分析 Vite SSR 架构的设计与实现。

本章要点

  • 理解 Vite SSR 的整体架构与模块加载流程
  • 深入 ssrTransform 的 ESM 到运行时代码的转换机制
  • 掌握 Module Runner 的模块获取、求值与缓存策略
  • 分析 fetchModule 的外部化决策逻辑
  • 理解 SSR Manifest 在预加载优化中的作用
  • 对比传统 SSR 加载与 Module Runner 的架构差异

15.1 SSR 架构概览

15.1.1 SSR 配置体系

Vite 的 SSR 配置定义在 packages/vite/src/node/ssr/index.ts 中。该文件虽然简短,但其中的每个选项都对应着 SSR 构建的一个关键决策点:

export interface SSROptions {
  noExternal?: string | RegExp | (string | RegExp)[] | true
  external?: string[] | true
  target?: SSRTarget  // 'node' | 'webworker'
  optimizeDeps?: SsrDepOptimizationConfig
  resolve?: {
    conditions?: string[]
    externalConditions?: string[]
    mainFields?: string[]
  }
}

noExternal 和 external 是 SSR 配置中最关键的两个选项。它们控制了依赖的"外部化"策略:

  • 外部化 (external):依赖由 Node.js 原生加载器处理,不经过 Vite 转换。性能最优,但失去 Vite 插件的转换能力。
  • 内部化 (noExternal):依赖像项目源码一样经过 Vite 完整管线处理。适用于需要编译的依赖(如仅提供 ESM 的包、含 CSS 导入的组件库)。

target 选项决定了 SSR 产物的运行目标环境。当设为 'node' 时,package.json 的 browser 字段会被忽略;当设为 'webworker' 时(适用于 Cloudflare Workers 等环境),browser 字段会被尊重。

默认配置通过 resolveSSROptions 函数合并:

// packages/vite/src/node/ssr/index.ts:56
const _ssrConfigDefaults = Object.freeze({
  target: 'node',
  optimizeDeps: {},
} satisfies SSROptions)

export function resolveSSROptions(
  ssr: SSROptions | undefined,
  preserveSymlinks: boolean,
): ResolvedSSROptions {
  const defaults = mergeWithDefaults(_ssrConfigDefaults, {
    optimizeDeps: { esbuildOptions: { preserveSymlinks } },
  })
  return mergeWithDefaults(defaults, ssr ?? {})
}

15.1.2 _ssrConfigDefaults 被 Object.freeze 的意义

packages/vite/src/node/ssr/index.ts:56 的默认配置有一个容易忽略的细节:

const _ssrConfigDefaults = Object.freeze({
  target: 'node',
  optimizeDeps: {},
} satisfies SSROptions)

Object.freeze 让这个对象的顶层属性不可再写(freeze 是浅冻结,嵌套的 optimizeDeps: {} 本身并未被冻结)。为什么要这么谨慎?

因为它是模块级的共享单例:除了在 resolveSSROptions 里当默认值,它还以 ssrConfigDefaults 导出、挂在 config.ts 的 configDefaults.ssr 上(config.ts:866)。resolveSSROptions 在每次 resolveConfig(config.ts:1640)时都会被调用——dev server 重启、每次 build、测试里反复创建配置都算。mergeWithDefaults 本身会先 deepClone 默认值再合并(utils.ts:1232-1234),正常路径改不到它;freeze 防的是有代码绕过这层、直接写这个共享对象——比如 _ssrConfigDefaults.target = 'webworker'——那样之后每次调用都会受污染。这在测试场景最明显:一个 test 意外改了默认值、下一个 test 莫名其妙地 target 错了、而问题难定位。

Object.freeze 在这里是全局静态配置对象的自我保护——用 JS 语言层的不变性把"这个对象不该被改"写进代码、而不是靠文档约定。它只在模块加载时执行一次、只禁止写、不改变读的语义——成本可以忽略。

satisfies SSROptions 是 TypeScript 4.9 的类型操作符——它让 _ssrConfigDefaults 保持字面量的精确类型({ target: 'node'; optimizeDeps: {} })、同时验证它满足 SSROptions 的约束。如果用 as SSROptions 或 : SSROptions 就会让字面类型退化为 SSROptions、失去 narrow。satisfies 是零成本的类型校验。

15.1.3 SSR 模块加载流水线

从一个 SSR 框架的角度看,加载一个模块需要经过以下完整流程:

flowchart TB
    A["框架请求模块<br/>runner.import('/src/App.vue')"] --> B["DevEnvironment.fetchModule"]
    B --> C{"模块类型判断"}
    C -->|"内置模块 (node:fs)"| D["externalize<br/>type: 'builtin'"]
    C -->|"外部 URL (https://...)"| E["externalize<br/>type: 'network'"]
    C -->|"裸模块标识符 (lodash)"| F["tryNodeResolve 解析"]
    C -->|"项目源码 (./App.vue)"| G["transformRequest"]
    F --> H{"解析成功?"}
    H -->|"是"| I["externalize: file URL<br/>type: 'module' | 'commonjs'"]
    H -->|"否"| J["抛出 ERR_MODULE_NOT_FOUND"]
    G --> K["插件管线转换"]
    K --> L["ssrTransform"]
    L --> M["返回 FetchResult<br/>(code + sourceMap)"]
    D --> N["Module Runner 求值"]
    E --> N
    I --> N
    M --> N
    N --> O["返回模块 exports"]
    style G fill:#e3f2fd
    style L fill:#fff3e0
    style N fill:#e8f5e9

15.1.4 DevEnvironment 中的 SSR 集成

DevEnvironment(定义在 packages/vite/src/node/server/environment.ts)是 SSR 模块加载的服务端入口。它通过 fetchModule 方法桥接 Module Runner 和 Vite 的转换管线:

export class DevEnvironment extends BaseEnvironment {
  mode = 'dev' as const
  moduleGraph: EnvironmentModuleGraph

  // ... depsOptimizer、pluginContainer、hot、构造函数、reloadModule 等 ...

  fetchModule(
    id: string,
    importer?: string,
    options?: FetchFunctionOptions,
  ): Promise<FetchResult> {
    return fetchModule(this, id, importer, {
      ...this._remoteRunnerOptions,
      ...options,
    })
  }

  transformRequest(
    url: string,
    /** @internal */
    options?: TransformOptionsInternal,
  ): Promise<TransformResult | null> {
    return transformRequest(this, url, options)
  }
}

DevEnvironment 通过 HotChannel 暴露 fetchModule 调用接口,使远程 Module Runner 能够通过传输层发起模块请求:

// packages/vite/src/node/server/environment.ts:138
this.hot.setInvokeHandler({
  fetchModule: (id, importer, options) => {
    return this.fetchModule(id, importer, options)
  },
  getBuiltins: async () => {
    return this.config.resolve.builtins.map((builtin) =>
      typeof builtin === 'string'
        ? { type: 'string', value: builtin }
        : { type: 'RegExp', source: builtin.source, flags: builtin.flags },
    )
  },
})

15.2 fetchModule:外部化决策

15.2.1 决策流程

fetchModule(packages/vite/src/node/ssr/fetchModule.ts)是 SSR 模块加载的核心函数。它为每个模块请求做出"内部化还是外部化"的决策:

export async function fetchModule(
  environment: DevEnvironment,
  url: string,
  importer?: string,
  options: FetchModuleOptions = {},
): Promise<FetchResult> {
  // 决策 1:内置模块直接外部化
  if (
    url.startsWith('data:') ||
    isBuiltin(environment.config.resolve.builtins, url)
  ) {
    return { externalize: url, type: 'builtin' }
  }

  // 决策 2:外部 URL 直接外部化(file:// 除外)
  const isFileUrl = url.startsWith('file://')
  if (isExternalUrl(url) && !isFileUrl) {
    return { externalize: url, type: 'network' }
  }

  // 决策 3:裸模块标识符 -- Node 解析后外部化
  if (!isFileUrl && importer && url[0] !== '.' && url[0] !== '/') {
    const { externalConditions, dedupe, preserveSymlinks } =
      environment.config.resolve
    const resolved = tryNodeResolve(url, importer, {
      mainFields: ['main'],
      conditions: externalConditions,
      extensions: ['.js', '.cjs', '.json'],
      dedupe,
      preserveSymlinks,
      // ...
    })
    if (!resolved) {
      const err: any = new Error(
        `Cannot find module '${url}' imported from '${importer}'`,
      )
      err.code = 'ERR_MODULE_NOT_FOUND'
      throw err
    }
    const file = pathToFileURL(resolved.id).toString()
    const type = isFilePathESM(resolved.id, environment.config.packageCache)
      ? 'module'
      : 'commonjs'
    return { externalize: file, type }
  }

  // 决策 4:项目源码 -- Vite 管线转换
  url = unwrapId(url)
  const mod = await environment.moduleGraph.ensureEntryFromUrl(url)
  const cached = !!mod.transformResult

  if (options.cached && cached) {
    return { cache: true }  // 告知 Runner 使用本地缓存
  }

  let result = await environment.transformRequest(url)
  if (!result) {
    throw new Error(
      `[vite] transform failed for module '${url}'${
        importer ? ` imported from '${importer}'` : ''
      }.`,
    )
  }

  if (options.inlineSourceMap !== false) {
    result = inlineSourceMap(mod, result, options.startOffset)
  }

  // 移除 shebang
  if (result.code[0] === '#')
    result.code = result.code.replace(/^#!.*/, (s) => ' '.repeat(s.length))

  return {
    code: result.code,
    file: mod.file,
    id: mod.id!,
    url: mod.url,
    invalidate: !cached,
  }
}
graph TD
    A["模块请求 URL"] --> B{"data: 或 内置模块?"}
    B -->|"是"| C["externalize (builtin)"]
    B -->|"否"| D{"外部 URL?<br/>(非 file://)"}
    D -->|"是"| E["externalize (network)"]
    D -->|"否"| F{"裸模块标识符?<br/>(不以 . / / 开头)"}
    F -->|"是"| G["tryNodeResolve"]
    F -->|"否"| H["transformRequest<br/>(Vite 管线)"]
    G -->|"解析成功"| I["externalize<br/>(module/commonjs)"]
    G -->|"解析失败"| J["ERR_MODULE_NOT_FOUND"]
    style C fill:#e8f5e9
    style E fill:#e8f5e9
    style I fill:#e8f5e9
    style H fill:#fff3e0

15.2.2 unwrapId 在第四级前的神秘一步

fetchModule 的第 4 级分支(项目源码)一开头有一行(packages/vite/src/node/ssr/fetchModule.ts:81):

url = unwrapId(url)

它在做什么?unwrapId 定义在 packages/vite/src/shared/utils.ts、把 Vite 内部加过前缀的 url(比如 /@id/__x00__virtual:my-module)剥回到原始 id(\0virtual:my-module)。

为什么需要这步?——因为 Vite 的 PluginContainer 里某些虚拟模块会被加 \0 前缀(Rollup 约定的虚拟模块标记)、但 URL 里不能带 \0(URL 合法字符限制)、所以传到浏览器/Runner 时被编码成 __x00__ + /@id/ 前缀的组合。

到了 fetchModule 的项目源码分支、我们需要访问 environment.moduleGraph。moduleGraph 同时维护 urlToModuleMap 与 idToModuleMap 两张表(packages/vite/src/node/server/moduleGraph.ts:93-94),但 ensureEntryFromUrl 内部要先把 url 交给 resolveId 拿到 resolvedId、再查 idToModuleMap(packages/vite/src/node/server/moduleGraph.ts:343-379)——resolvedId 是带 \0 的原始形态。所以要先 unwrapId 把 URL 还原回去、才能 ensureEntryFromUrl(url) 正确找到模块节点。

为什么前三个分支不需要 unwrapId?因为前三个分支的分支条件(内置模块、外部 URL、裸标识符)天然排除了虚拟模块——虚拟模块的 URL 一定以 /@id/ 开头、不是 http(s)、不是内置、不是裸标识符、必走第 4 级。unwrapId 只在第 4 级需要、在其他分支就是多余的一行——vite 作者显式只在需要的地方做转换、保持每个分支的纯净。

15.2.3 mod.transformResult 作为缓存有效性标记

第 4 级分支有一段"已缓存确认"逻辑(line 83-89):

const mod = await environment.moduleGraph.ensureEntryFromUrl(url)
const cached = !!mod.transformResult

// if url is already cached, we can just confirm it's also cached on the server
if (options.cached && cached) {
  return { cache: true }
}

transformResult 是 EnvironmentModuleNode 的一个字段——只在模块被 transformRequest 过、且尚未被 invalidate 时非空。HMR 触发的 moduleGraph.invalidateModule(mod) 会清空 transformResult、cached 立刻变 false。

这就让 !!mod.transformResult 成为完美的缓存有效性信号——没有额外的版本号、没有 timestamp、没有 hash 对比、就一个布尔值判断。Runner 发请求时带 options.cached = true(我本地有这个模块的缓存)、服务端看 transformResult 还在就说"你缓存还有效"、否则重传 code。

这种设计的优雅在于——"有效性"不是一个独立的状态字段、而是"transform 产物是否还在"这一个自然事实的副产品。invalidate 时只要把 result 扔掉、所有相关判断自动失效、不用维护额外的版本计数器。

这和 LangGraph 专栏第 14 章讲的 Runtime 利用 execution_info is None 作为"任务未准备"的天然信号是同一种设计思想——用数据的存在与否代替显式状态标志、让代码更简洁、不变式更自然。

15.2.4 ESM vs CJS 类型判断

外部化时需要准确判断模块是 ESM 还是 CJS:

const type = isFilePathESM(resolved.id, environment.config.packageCache)
  ? 'module'
  : 'commonjs'

isFilePathESM 通过以下规则判断:

  • .mjs / .mts 文件 -> ESM
  • .cjs / .cts 文件 -> CJS
  • 其他(.js 等)-> 查找最近的 package.json 的 type 字段

这个类型信息传递给 Module Runner 后并不改变加载方式——外部化模块无论 type 是 'module' 还是 'commonjs',都走同一条原生 import()(packages/vite/src/module-runner/esmEvaluator.ts:47-49,调用点 packages/vite/src/module-runner/runner.ts:361-364)。type 唯一的去处是 packages/vite/src/module-runner/runner.ts:130-132:交给 analyzeImportedModDifference 做 ESM/CJS 互通诊断(§15.7.4 讲的就是这件事)。

15.2.5 Shebang 处理:#! 被替换成空格而不是删除

fetchModule 末尾有一段处理 shebang 的代码(packages/vite/src/node/ssr/fetchModule.ts:106-107):

// remove shebang
if (result.code[0] === '#')
  result.code = result.code.replace(/^#!.*/, (s) => ' '.repeat(s.length))

为什么不直接 replace(/^#!.*/, '') 删掉?——.* 不匹配换行,删成空串其实也不会让后续行前移;但第一行的列号、全文的字符偏移都会变。换成等长空格,任何按位置对应回源码的信息(source map、报错位置)都原样成立,不必去想"删掉之后哪些位置变了"。

换成 ' '.repeat(s.length) 就保留了原始字符长度——把 #!/usr/bin/env node(19 字符)替换成 19 个空格、文件总字符数不变、所有位置映射完全保留。浏览器/Node.js 执行空格行就是 no-op、等于 shebang 被"注释掉"了。

这是编译器里常见的问题——移除语法无效但对工具链重要的内容时、要保留位置不变。本质就是"原地抹掉、保留长度"。

shebang 只会出现在可执行脚本的第一行(regex ^#!.*)——result.code[0] === '#' 做了 fast-reject:绝大多数模块的第一个字符不是 #、直接跳过 regex。fetchModule 每返回一个源码模块都要过一遍这段代码——加个单字符前置判断就省掉了绝大多数 regex 调用。

15.2.6 缓存协商

fetchModule 支持一种客户端-服务端缓存协商机制:

if (options.cached && cached) {
  return { cache: true }
}

当 Module Runner 认为某个模块可能未变化时,它会设置 options.cached = true。服务端检查模块的 transformResult 是否仍然有效(未被 HMR 失效),如果有效则返回 { cache: true },告知 Runner 继续使用本地缓存。这避免了每次模块请求都传输完整的代码和 source map。

15.3 SSR Transform

15.3.1 转换的必要性

浏览器环境的 ESM 代码不能直接在 AsyncFunction 中执行,原因在于:

  1. import/export 语法在函数体内是语法错误
  2. import.meta 在非模块上下文中不可用
  3. 动态 import() 的基准 URL 不正确

ssrTransform(packages/vite/src/node/ssr/ssrTransform.ts)的任务就是将 ESM 语法转换为可在 AsyncFunction 中执行的等价代码,同时保持 ESM 的语义特性(如 live binding、提升行为等)。

15.3.2 运行时协议

转换后的代码通过一组运行时函数与 Module Runner 交互:

运行时函数 作用
__vite_ssr_import__(source) 替代 import 声明,返回模块 namespace
__vite_ssr_dynamic_import__(source) 替代 import() 表达式
__vite_ssr_exports__ 替代模块的 exports 对象
__vite_ssr_exportAll__(obj) 替代 export * from
__vite_ssr_exportName__(name, getter) 注册具名导出(带 getter 实现 live binding)
__vite_ssr_import_meta__ 替代 import.meta

15.3.3 转换规则详解

ssrTransformScript 分为三个主要阶段:

阶段一:导入处理

graph LR
    subgraph "输入 (ESM)"
        A1["import { foo } from './mod'"]
        A2["import * as ns from './lib'"]
        A3["import def from './default'"]
        A4["export { bar } from './re'"]
        A5["export * from './all'"]
    end
    subgraph "输出 (Runtime)"
        B1["const __vite_ssr_import_0__ = await __vite_ssr_import__('./mod')"]
        B2["const __vite_ssr_import_1__ = await __vite_ssr_import__('./lib')"]
        B3["const __vite_ssr_import_2__ = await __vite_ssr_import__('./default')"]
        B4["const __vite_ssr_import_3__ = await __vite_ssr_import__('./re')"]
        B5["const __vite_ssr_import_4__ = await __vite_ssr_import__('./all')"]
    end
    A1 --> B1
    A2 --> B2
    A3 --> B3
    A4 --> B4
    A5 --> B5

每个 import 声明被转换为对 __vite_ssr_import__ 的 await 调用。defineImport 函数负责生成转换后的代码:

function defineImport(index, importNode, metadata) {
  const source = importNode.source.value
  deps.add(source)

  // 精简 metadata -- 默认值不传递以减小体积
  const metadataArg =
    (metadata?.importedNames?.length ?? 0) > 0
      ? `, ${JSON.stringify(metadata)}`
      : ''

  const importId = `__vite_ssr_import_${uid++}__`
  const transformedImport = `const ${importId} = await ${ssrImportKey}(${
    JSON.stringify(source)
  }${metadataArg});\n`

  s.update(importNode.start, importNode.end, transformedImport)

  if (importNode.start === index) {
    hoistIndex = importNode.end  // 保持顺序
  } else {
    s.move(importNode.start, importNode.end, index)  // 提升到顶部
  }
  return importId
}

关键细节:metadata.importedNames 携带了导入的具体名称(如 ['foo']),Module Runner 用它检查外部化模块是否真的提供了这些导出(§15.7.4)。当 metadata 全为默认值时,省略参数以减小传输体积。

导入语句的提升(hoist)行为模拟了 ESM 规范中导入声明的提升语义 -- 无论 import 写在代码的哪个位置,它都会在模块执行前被处理。

阶段二:导出处理

function defineExport(name, local = name) {
  // 使用 getter 实现 live binding
  s.appendLeft(
    fileStartIndex,
    `${ssrExportNameKey}(${JSON.stringify(name)}, () => {
      try { return ${local} } catch {}
    });\n`,
  )
}

导出使用 Object.defineProperty 的 getter 形式注册,实现了 ESM 的 live binding 语义:当导出变量在源模块中被修改时,导入该变量的其他模块能够感知到变化。try/catch 包裹是为了处理循环依赖场景 -- 当被引用的变量尚未初始化时(处于 TDZ),优雅地返回 undefined 而非抛出 ReferenceError。

不同类型的导出有不同的处理:

// 四段分别摘自 packages/vite/src/node/ssr/ssrTransform.ts 的 :250、:252-258、
// :309-315 与 :326——它们在源码里分属不同分支,这里拼在一起对照看
// export function foo() {} --> 保留函数声明,注册导出名
defineExport(node.declaration.id!.name)

// export const a = 1, b = 2 --> 提取所有声明名,逐一注册
const declaration = node.declaration as ESTree.VariableDeclaration
for (const decl of declaration.declarations) {
  const names = extractNames(decl.id)
  for (const name of names) {
    defineExport(name)
  }
}

// export default expression --> 创建中间变量
const name = `__vite_ssr_export_default__`
s.update(node.start, node.start + 14, `const ${name} =`)
defineExport('default', name)

// export * from './foo' --> 整体导出
s.appendLeft(node.end, `${ssrExportAllKey}(${importId});\n`)

阶段三:引用重写

这是最复杂的阶段。转换需要遍历整个 AST,将所有引用导入绑定的标识符替换为对应的属性访问:

// packages/vite/src/node/ssr/ssrTransform.ts:332 起,onIdentifier 在 :348
walk(ast, {
  // onStatements(在语句之间补 ";")省略
  onIdentifier(id, parent, parentStack) {
    const binding = idToImportMap.get(id.name)
    if (!binding) return

    if (isStaticProperty(parent) && parent.shorthand) {
      // 对象简写属性: { foo } -> { foo: __import_x__.foo }
      if (!isNodeInPattern(parent) ||
          isInDestructuringAssignment(parent, parentStack)) {
        s.appendLeft(id.end, `: ${binding}`)
      }
    } else if ((parent.type === 'PropertyDefinition' &&
                parentStack[1]?.type === 'ClassBody') ||
               (parent.type === 'ClassDeclaration' && id === parent.superClass)) {
      // 类字段初始化器 / extends 子句中的引用需要提升为局部变量
      if (!declaredConst.has(id.name)) {
        declaredConst.add(id.name)
        const topNode = parentStack[parentStack.length - 2]
        s.prependRight(topNode.start, `const ${id.name} = ${binding};\n`)
      }
    } else if (parent.type === 'CallExpression') {
      // 方法调用: foo() -> (0, __import_x__.foo)()
      s.update(id.start, id.end, binding)
      s.prependRight(id.start, `(0,`)
      s.appendLeft(id.end, `)`)
    } else if (!(parent.type === 'ClassExpression' && id === parent.id)) {
      // 类表达式自身的名字不改写
      s.update(id.start, id.end, binding)
    }
  },
  onImportMeta(node) {
    s.update(node.start, node.end, ssrImportMetaKey)
  },
  onDynamicImport(node) {
    s.update(node.start, node.start + 6, ssrDynamicImportKey)
    // 只收集字面量 source
    if (node.type === 'ImportExpression' && node.source.type === 'Literal') {
      dynamicDeps.add(node.source.value as string)
    }
  },
})

15.3.4 deps.add(source) 的副作用收集

defineImport 里每次处理一个导入都会 deps.add(source)——deps 是一个 Set<string>、收集这个模块的所有静态依赖。转换函数最终返回 { code, map, deps, dynamicDeps, ssr: true }——deps 和 dynamicDeps 是 transform 的"副产物"。

这份 deps 在 v8.0.3 里是谁在消费?——严格说:Vite 自己的 EnvironmentModuleGraph 并不靠它建边——模块图的 importedModules/importers 是 importAnalysis 插件在 transform 阶段通过 updateModuleInfo 写入的、对 client 和 SSR 环境一视同仁。ssrTransform 返回的 deps/dynamicDeps 只是随 TransformResult 一起挂在 mod.transformResult 上(transformRequest.ts 里 TransformResult 的可选字段),留给调用 server.ssrTransform() 的外部工具(vitest、vite-node 一类)和测试断言使用。它之所以还在 transform 时顺手收集、是因为这一趟 AST 遍历本来就要碰每一个 import 节点——多一行 deps.add 几乎零成本。

dynamicDeps 为什么单独一个 Set?——因为动态 import 的依赖可能不在 import 时就确定(import(someVariable))、即使有静态分析也只能确定一部分。区分开来、让下游处理器根据需要用:

  • 只想知道「静态依赖」的消费者拿 deps 就够(Vite 自己的模块图不靠它、见上)
  • 关心懒加载边界的消费者(比如预加载提示)再看 dynamicDeps
  • 两份都只收字面量 source——import(someVariable) 这类非字面量动态导入不会出现在任何一份里

这种"transform 同时产出多种派生数据"的设计是 Vite 转换器共同模式——一次 AST 遍历、顺带收集多份信息、下游消费者各取所需、避免重复遍历。

15.3.5 作用域分析

walk 函数实现了完整的 JavaScript 作用域分析,确保只重写真正引用了导入绑定的标识符。它需要正确处理以下场景:

flowchart TB
    A["遍历 AST 节点"] --> B{"节点类型?"}
    B -->|"FunctionDeclaration"| C["将函数名注册到父级作用域"]
    B -->|"FunctionExpression (有名)"| D["将函数名注册到自身作用域"]
    B -->|"VariableDeclarator (var)"| E["注册到最近的函数级作用域"]
    B -->|"VariableDeclarator (let/const)"| F["注册到最近的块级作用域"]
    B -->|"ClassDeclaration"| G["将类名注册到父级作用域"]
    B -->|"CatchClause"| H["将参数注册到 catch 作用域"]
    B -->|"函数参数"| I["注册到函数自身作用域"]
    B -->|"Identifier"| J{"是否在当前作用域中?"}
    J -->|"是"| K["跳过 (被本地声明遮蔽)"]
    J -->|"否"| L{"是否引用导入绑定?"}
    L -->|"是"| M["重写为属性访问"]
    L -->|"否"| N["跳过"]

作用域信息存储在 WeakMap<Node, Set<string>> 中,var 声明和 let/const 声明使用不同的作用域查找策略:

function findParentScope(
  parentStack: ESTree.Node[],
  isVar = false,
): ESTree.Node | undefined {
  return parentStack.find(isVar ? isFunction : isBlock)
}

var 声明提升到函数级作用域,而 let/const 限定在块级作用域。这种区分确保了转换后代码的语义与原始 ESM 代码一致。

15.3.6 方法调用的 this 解绑

当导入的函数被作为方法调用时,需要特殊处理 this 绑定:

// 原始代码
import { foo } from './mod'
foo()

// 直接转换(错误)
__vite_ssr_import_0__.foo()
// 此时 foo 内的 this 指向 __vite_ssr_import_0__ 对象

// 正确转换
;(0, __vite_ssr_import_0__.foo)()
// 逗号表达式使 foo 成为独立值,this 变为 undefined (strict) 或 globalThis

(0, expr) 是一个经典的 JavaScript 技巧,广泛用于 Babel、TypeScript 等编译器中。逗号表达式的结果是最后一个操作数的值,但作为方法调用时,this 不再绑定到属性所在的对象。

15.3.7 onIdentifier 里的 isStaticProperty + shorthand 双重检查

§15.3.3 提到的 identifier 重写里、对象简写属性有一段细致的处理:

if (isStaticProperty(parent) && parent.shorthand) {
  if (!isNodeInPattern(parent) ||
      isInDestructuringAssignment(parent, parentStack)) {
    s.appendLeft(id.end, `: ${binding}`)
  }
}

为什么要检查 isNodeInPattern 和 isInDestructuringAssignment?——因为对象简写属性有两种语境:

  1. 对象字面量:const obj = { foo } 等价于 { foo: foo }——这里的 foo 如果是导入、要改写成 { foo: __import_x__.foo }
  2. 解构模式:const { foo } = someObj 等价于 const { foo: foo } = someObj——这里的 foo 是被赋值的变量声明、不是引用!不能改写、否则就把本地变量名改了。

但 ({ foo } = someObj) 里、{ foo } 又是"解构赋值"(给已有变量 foo 赋值)——这时 foo 是引用、要改写。

这就是双重检查的来源:

  • isNodeInPattern(parent) ——true 说明在解构模式里(const {} = ...)
  • isInDestructuringAssignment(...) ——true 说明是解构赋值(({} = ...)、已存在的变量)、不是解构声明(const {}、新建变量)

只有满足两种组合才改写:不在 pattern 里、或者在 pattern 里但是解构赋值。

这个逻辑错一点就会把解构声明的变量名也改了、产生语法错误或语义错误。vite 为了这一个语义细节、写了两个辅助函数、一个"或"条件——值得。这种对 JS 细节的精确建模是编译器级别代码的标志。

15.3.8 JSON 模块的特殊处理

对于 JSON 请求,ssrTransform 有一条快速路径:

async function ssrTransformJSON(code, inMap) {
  return {
    code: code.replace('export default', `${ssrModuleExportsKey}.default =`),
    map: inMap,
    deps: [],
    dynamicDeps: [],
    ssr: true,
  }
}

这条路径仅当 json.stringify === true 且未开启 json.namedExports 时启用(packages/vite/src/node/server/transformRequest.ts:420-425)。这种配置下 JSON 模块只有一个默认导出,不需要完整的 AST 分析,简单的字符串替换即可完成转换。

15.4 Module Runner

15.4.1 架构概览

Module Runner(module-runner/)是 Vite 的模块执行运行时。它运行在服务端或任何非浏览器环境中,负责加载和执行经过 ssrTransform 转换的代码。

graph TB
    subgraph "Module Runner 进程"
        A["ModuleRunner"] --> B["EvaluatedModules<br/>(模块缓存图)"]
        A --> C["NormalizedTransport<br/>(通信层)"]
        A --> D["ESModulesEvaluator<br/>(代码求值器)"]
        A --> E["HMRClient<br/>(热更新客户端)"]
    end
    subgraph "Vite Dev Server 进程"
        F["DevEnvironment"] --> G["PluginContainer"]
        F --> H["EnvironmentModuleGraph"]
        F --> I["fetchModule"]
    end
    C <-->|"invoke('fetchModule', [url, importer])"| I
    C <-->|"HMR 消息 (update/full-reload)"| F
    style A fill:#e8f5e9
    style F fill:#e3f2fd

15.4.2 ModuleRunner 初始化

// packages/vite/src/module-runner/runner.ts:38
export class ModuleRunner {
  public evaluatedModules: EvaluatedModules
  public hmrClient?: HMRClient
  private readonly transport: NormalizedModuleRunnerTransport

  constructor(
    public options: ModuleRunnerOptions,
    public evaluator: ModuleEvaluator = new ESModulesEvaluator(),
  ) {
    this.evaluatedModules = options.evaluatedModules ?? new EvaluatedModules()
    this.transport = normalizeModuleRunnerTransport(options.transport)

    // 初始化 HMR 客户端
    if (options.hmr !== false) {
      // :61-67——hmr 传 true 或没给 logger 就用默认 hmrLogger,logger 传 false 则静音
      const optionsHmr = options.hmr ?? true
      const resolvedHmrLogger: HMRLogger =
        optionsHmr === true || optionsHmr.logger === undefined
          ? hmrLogger
          : optionsHmr.logger === false
            ? silentConsole
            : optionsHmr.logger
      this.hmrClient = new HMRClient(
        resolvedHmrLogger,
        this.transport,
        ({ acceptedPath }) => this.import(acceptedPath),
      )
      if (!this.transport.connect) {
        throw new Error(
          'HMR is not supported by this runner transport, but `hmr` option was set to true',
        )
      }
      this.transport.connect(createHMRHandlerForRunner(this))
    } else {
      this.transport.connect?.()
    }

    // Source Map 支持
    if (options.sourcemapInterceptor !== false) {
      this.resetSourceMapSupport = enableSourceMapSupport(this)
    }
  }
}

初始化过程的三个关键组件:

  1. Transport:抽象的通信层,可以是 WebSocket、HTTP、或进程间通信
  2. HMRClient:与浏览器端的 HMR 客户端共享同一套 HMRClient 实现
  3. Source Map:通过拦截 Error.prepareStackTrace(Node.js)实现源码级的堆栈追踪

15.4.3 runner.import(acceptedPath) 作为 HMR 回调的巧妙闭环

ModuleRunner 构造里初始化 HMRClient 时有一个精致的设计(packages/vite/src/module-runner/runner.ts:68-72):

this.hmrClient = new HMRClient(
  resolvedHmrLogger,
  this.transport,
  ({ acceptedPath }) => this.import(acceptedPath),
)

HMRClient 的第三个参数是"模块变更后如何重新加载"的回调——ModuleRunner 传入了自己的 this.import!

这就形成一个闭环:

  1. 服务端检测到 my-module.ts 改了、发 HMR update 消息给 Runner
  2. HMRClient 接消息、找到 accept 该路径的 module、调用 ({ acceptedPath }) => this.import(acceptedPath)
  3. runner.import(acceptedPath) 触发 cachedModule → cachedRequest → 新的 fetch → evaluate 新代码
  4. 新 module 的 exports 通过 HMR accept callback 替换旧的、应用层看到更新

整个 HMR 刷新链路在 ModuleRunner 内部完成——不需要应用代码配合、不需要重启 Runner、不需要清理任何全局状态。被 HMR 影响的模块在 cachedModule → getModuleInformation 里拿到服务端回的 invalidate: true、evaluatedModules 里的同一个节点被 invalidateModule 清空 meta/promise/exports(packages/vite/src/module-runner/evaluatedModules.ts:89-100)、再按新代码重新求值。

这种"把自己的方法作为回调传给依赖组件"的模式是 JS 里依赖注入的常见姿态——ModuleRunner 不需要让 HMRClient 知道自己怎么重新加载模块、只要给一个 (event) => Promise<void> 就够了。反过来 HMRClient 也不必依赖 ModuleRunner 的具体类型——只依赖一个 callback 接口。

这种职责分离让 ModuleRunner 和 HMRClient 可以独立测试——HMRClient 的测试给它一个假的 callback 就能跑、不需要真的 ModuleRunner。这是典型的"回调作接口"的工程收益。

15.4.4 模块加载流程

当调用 runner.import(url) 时,触发一个多阶段的加载流程:

sequenceDiagram
    participant App as 应用代码
    participant Runner as ModuleRunner
    participant Cache as EvaluatedModules
    participant Transport as Transport
    participant Server as DevServer
    App->>Runner: import(url)
    Runner->>Runner: cachedModule(url)
    Runner->>Cache: getModuleByUrl(url)
    alt 缓存命中且有效
        Cache-->>Runner: 返回已缓存的 EvaluatedModuleNode
    else 需要从服务端获取
        Runner->>Transport: invoke('fetchModule', [url, importer, options])
        Transport->>Server: fetchModule(environment, url, importer)
        Server-->>Transport: FetchResult
        Transport-->>Runner: 模块信息
        Runner->>Cache: ensureModule(id, url)
    end
    Runner->>Runner: cachedRequest(url, mod, callstack)
    alt 已求值且无循环
        Runner-->>App: 返回 mod.exports
    else 需要求值
        Runner->>Runner: directRequest(url, mod, callstack)
        Note over Runner: 构建运行时上下文
        Runner->>Runner: evaluator.runInlinedModule(context, code)
        Runner-->>App: 返回 exports
    end

15.4.5 concurrentModuleNodePromises 的去重 Map

ModuleRunner 实例有一个字段(packages/vite/src/module-runner/runner.ts:44):

private readonly concurrentModuleNodePromises = new Map<
  string,
  Promise<EvaluatedModuleNode>
>()

这是一个按 URL 去重的并发请求 Map。cachedModule(line 229-244)的开头:

let cached = this.concurrentModuleNodePromises.get(url)
if (!cached) {
  const cachedModule = this.evaluatedModules.getModuleByUrl(url)
  cached = this.getModuleInformation(url, importer, cachedModule).finally(
    () => {
      this.concurrentModuleNodePromises.delete(url)
    },
  )
  this.concurrentModuleNodePromises.set(url, cached)
}

为什么需要这个 Map?——因为 getModuleInformation 内部会 transport.invoke('fetchModule', ...) 发网络请求、异步。如果在同一时刻有两个 import() 都请求同一个 url(比如多个模块同时依赖 react)、两次进入 cachedModule、两次都发 fetch 请求——浪费了一次网络往返。

用 Map 去重后:第一个请求塞进 Map、第二个请求来看到已经有 pending promise、直接 await 同一个 promise——两边最终拿到同一个模块节点。

.finally(() => delete ...) 确保 promise 结算后从 Map 里清理——下次同 url 再请求时走正常流程(那时候模块已经进 evaluatedModules 缓存、走 cache 分支)。

这个去重只在"同一个 url 同一瞬间多个请求"的并发场景生效——普通的串行 import 链不会触发。但服务端并发 import 多个模块(比如 Promise.all 同时加载几个页面组件)时、它们常常同时请求相同的公共依赖、这个 Map 让每个 url 只发一次 fetch。

和 LangGraph 专栏第 15 章讲的 AsyncBatchedBaseStore 的"跨 future 去重"是同一种模式——把并发请求合并成一次底层操作、省下的是网络/磁盘的实际开销。

15.4.6 循环依赖处理

循环依赖是模块系统中最棘手的问题之一。Module Runner 实现了三级检测策略:

private async cachedRequest(url, mod, callstack = [], metadata) {
  const moduleId = mod.meta!.id
  const { importers } = mod
  const importee = callstack[callstack.length - 1]
  if (importee) importers.add(importee)

  // 快速路径:已完全求值的模块不会死锁
  if (mod.evaluated && mod.promise) {
    return this.processImport(await mod.promise, mod.meta!, metadata)
  }

  // 三级循环检测
  if (
    callstack.includes(moduleId) ||          // 1. 调用栈直接检测
    this.isCircularModule(mod) ||            // 2. 直接循环检测
    this.isCircularImport(importers, moduleId) // 3. 传递性循环检测
  ) {
    if (mod.exports)
      return this.processImport(mod.exports, mod.meta!, metadata)
  }

  // 正常求值
  try {
    if (mod.promise) return this.processImport(await mod.promise, mod.meta!, metadata)
    const promise = this.directRequest(url, mod, callstack)
    mod.promise = promise
    mod.evaluated = false
    return this.processImport(await promise, mod.meta!, metadata)
  } finally {
    mod.evaluated = true
  }
}

三级检测的具体实现:

// 第 1 级:直接循环 -- A -> B -> A
// callstack.includes(moduleId) 即可检测

// 第 2 级:模块级循环 -- 模块的导入者同时也是它的依赖
private isCircularModule(mod: EvaluatedModuleNode) {
  for (const importedFile of mod.imports) {
    if (mod.importers.has(importedFile)) return true
  }
  return false
}

// 第 3 级:传递性循环 -- A -> B -> C -> A
private isCircularImport(importers, moduleUrl, visited = new Set()) {
  for (const importer of importers) {
    if (visited.has(importer)) continue
    visited.add(importer)
    if (importer === moduleUrl) return true
    const mod = this.evaluatedModules.getModuleById(importer)
    if (mod?.importers.size &&
        this.isCircularImport(mod.importers, moduleUrl, visited)) {
      return true
    }
  }
  return false
}

当检测到循环时,返回目标模块当前已有的 exports(可能尚未完全初始化)。这与 Node.js 的 CJS 模块加载器行为一致:循环依赖中,后加载的模块拿到的是部分初始化的 exports 对象。

15.4.7 debug?.('... takes over 2s to load') 的超时诊断

cachedRequest(packages/vite/src/module-runner/runner.ts:199-212)有一段只在 debug 模式开启时才启动的长时间加载告警:

let debugTimer: any
if (this.debug) {
  debugTimer = setTimeout(() => {
    const getStack = () =>
      `stack:\n${[...callstack, moduleId]
        .reverse()
        .map((p) => `  - ${p}`)
        .join('\n')}`

    this.debug!(
      `[module runner] module ${moduleId} takes over 2s to load.\n${getStack()}`,
    )
  }, 2000)
}

2 秒后如果模块还没加载完、自动打印调用栈——帮用户定位"为什么这个模块加载这么慢"。典型原因是:

  • 循环依赖触发了异常深的递归链
  • 某个模块的 transformRequest 卡在慢插件里(比如错误的 lint-plugin 对每个模块都跑一遍)
  • 网络请求超时(Runner 和 DevServer 不在同一进程时)

这种时间驱动的诊断比被动等用户报问题友好得多——开了 debug 就立刻在日志里看到"module X 加载慢"的警告、可以沿着 stack 往上追。

try/finally 里的 if (debugTimer) clearTimeout(debugTimer) 保证无论模块加载成功还是失败、定时器都会被清除——避免模块早已加载完、定时器到点还误报。这是 setTimeout + 清理的标准做法。

debug 关闭时完全零开销——if (this.debug) 判断一次就跳过、不设 timer、不创建闭包、不调用 setTimeout。这体现了 vite 对"观测工具要么免费、要么不用"的追求。

15.4.8 ESModulesEvaluator

ESModulesEvaluator 是默认的模块求值器,使用 AsyncFunction 构造器执行代码:

export class ESModulesEvaluator implements ModuleEvaluator {
  public readonly startOffset: number =
    getAsyncFunctionDeclarationPaddingLineCount()

  async runInlinedModule(context, code): Promise<any> {
    const initModule = new AsyncFunction(
      ssrModuleExportsKey,    // __vite_ssr_exports__
      ssrImportMetaKey,       // __vite_ssr_import_meta__
      ssrImportKey,           // __vite_ssr_import__
      ssrDynamicImportKey,    // __vite_ssr_dynamic_import__
      ssrExportAllKey,        // __vite_ssr_exportAll__
      ssrExportNameKey,       // __vite_ssr_exportName__
      '"use strict";' + code,
    )

    await initModule(
      context[ssrModuleExportsKey],
      context[ssrImportMetaKey],
      context[ssrImportKey],
      context[ssrDynamicImportKey],
      context[ssrExportAllKey],
      context[ssrExportNameKey],
    )

    Object.seal(context[ssrModuleExportsKey])
  }

  runExternalModule(filepath: string): Promise<any> {
    return import(filepath)
  }
}

设计要点:

  1. AsyncFunction 而非 vm 模块:vm.Module 是 Node.js 特有的 API,而 AsyncFunction 在 Deno、Bun、Cloudflare Workers 等运行时中都可用
  2. "use strict" 前缀:ESM 规范要求模块始终在严格模式下执行
  3. Object.seal 密封 exports:模拟 ESM namespace 求值后不能再增删导出的特性(见 §15.7.2)
  4. startOffset:AsyncFunction 构造器生成的函数声明占据额外的行,startOffset 记录了这个偏移量以便修正 source map

15.4.9 mod.promise 与 mod.evaluated 的两阶段状态机

cachedRequest 里有一对看起来冗余的字段(packages/vite/src/module-runner/runner.ts:186, 214-222):

// fast path: already evaluated modules can't deadlock
if (mod.evaluated && mod.promise) {
  return this.processImport(await mod.promise, meta, metadata)
}
// ... circular check ...

try {
  // cached module (in-progress, not yet evaluated)
  if (mod.promise)
    return this.processImport(await mod.promise, meta, metadata)

  const promise = this.directRequest(url, mod, callstack)
  mod.promise = promise
  mod.evaluated = false
  return this.processImport(await promise, meta, metadata)
} finally {
  mod.evaluated = true
  if (debugTimer) clearTimeout(debugTimer)
}

两个字段配合表达了三种状态:

mod.promise mod.evaluated 状态
undefined false 从未加载
非空 false 正在加载(directRequest 的 promise 尚未 resolve)
非空 true 加载完成

三种状态对应三种路径:

  • 未加载 → 走 directRequest、设置 mod.promise、结束后标记 evaluated
  • 正在加载(循环依赖常出现)→ await mod.promise 等别人把它搞完
  • 已加载 → await mod.promise(promise 已 resolved、立即返回)

evaluated 字段是"安全快速通道"的开关——fast path 里直接拿 mod.promise 就返回、跳过循环检测。为什么 evaluated=true 就可以跳过循环检测?——因为 evaluated=true 意味着模块执行已经完成、所有 exports 都已稳定——它不可能是循环的一部分(循环发生时执行还没完、evaluated 还是 false)。

finally 里的 mod.evaluated = true 保证即使 directRequest 抛异常、evaluated 也被设成 true——这可能看起来反直觉(异常的模块 evaluated=true?)、但设计是:异常后不要再让它走 directRequest 重跑、下次请求走 fast path await mod.promise、直接重新抛出同一个异常(直到该模块被 invalidate)。

这种"状态字段 + promise 引用"的组合是 JS 里构造异步状态机的标准姿态——状态用 bool 或 enum、值用 promise——和 React 的 Suspense、Vite 自己的 transformRequest 都是同样模式。

15.4.10 getModuleInformation 的 'cache' in fetchedModule 防污染检查

getModuleInformation 处理缓存命中分支时(packages/vite/src/module-runner/runner.ts:306-312)有一道防御性校验:

if ('cache' in fetchedModule) {
  if (!cachedModule || !cachedModule.meta) {
    throw new Error(
      `Module "${url}" was mistakenly invalidated during fetch phase.`,
    )
  }
  return cachedModule
}

场景:Runner 发请求时带 options.cached = true("我本地有缓存、你确认下还有效吗")、服务端回 { cache: true }("有效、用你本地的")——但这个答复只在Runner 本地真的还有缓存时才合理。

如果在发送请求的同一瞬间、HMR 把 cachedModule 给 invalidate 了、本地缓存已经没了、服务端却还回了 { cache: true }——Runner 如果直接返回 undefined 就会让下游拿到 meta 字段不存在的模块、引发莫名其妙的错误。

抛 Module "..." was mistakenly invalidated during fetch phase.——在这里fail loudly、让用户知道是竞态 invalidation问题、而不是让错误在别处以模糊的形式爆出。

这种"服务端答复和客户端状态不一致就 throw"的做法是分布式协调里的常见防御——不相信远端对本地状态的推断、本地自己最权威。只有在本地状态和远端假设同时成立才走快速路径、否则就报错把问题抛出来。

这个错误在实际中很少触发——一般不会有人在 fetch 进行中对同一个模块做 HMR 失效——但一旦发生、有明确的错误总比 silent 数据错乱强一百倍。

15.4.11 directRequest 与运行时上下文

directRequest 方法为每个模块构建完整的执行上下文:

// packages/vite/src/module-runner/runner.ts:334(源码签名多行带类型;debug 日志省略)
protected async directRequest(url, mod, _callstack) {
  const fetchResult = mod.meta!
  const moduleId = fetchResult.id
  const callstack = [..._callstack, moduleId]

  // 构建 __vite_ssr_import__ 函数
  const request = async (dep, metadata) => {
    const importer = ('file' in fetchResult && fetchResult.file) || moduleId
    const depMod = await this.cachedModule(dep, importer)
    depMod.importers.add(moduleId)
    mod.imports.add(depMod.id)
    return this.cachedRequest(dep, depMod, callstack, metadata)
  }

  // 构建 __vite_ssr_dynamic_import__ 函数
  const dynamicRequest = async (dep) => {
    dep = String(dep)
    if (dep[0] === '.') {
      dep = posixResolve(posixDirname(url), dep)
    }
    return request(dep, { isDynamicImport: true })
  }

  // 外部化模块使用原生 import
  if ('externalize' in fetchResult) {
    const { externalize } = fetchResult
    const exports = await this.evaluator.runExternalModule(externalize)
    mod.exports = exports
    return exports
  }

  const { code, file } = fetchResult
  // ...(code == null 时抛 Failed to load 错误,省略)

  // 构建 import.meta
  const createImportMeta =
    this.options.createImportMeta ?? createDefaultImportMeta
  const modulePath = cleanUrl(file || moduleId)
  const meta = await createImportMeta(modulePath)

  // 先建好 exports 并挂到 mod 上
  const exports = Object.create(null)
  Object.defineProperty(exports, Symbol.toStringTag, {
    value: 'Module', enumerable: false, configurable: false,
  })
  mod.exports = exports

  // 注入 hot 属性(懒创建 HMRContext)
  let hotContext
  if (this.hmrClient) {
    Object.defineProperty(meta, 'hot', {
      enumerable: true,
      get: () => {
        if (!this.hmrClient) {
          throw new Error(`[module runner] HMR client was closed.`)
        }
        hotContext ||= new HMRContext(this.hmrClient, mod.url)
        return hotContext
      },
      set: (value) => {
        hotContext = value
      },
    })
  }

  // 组装完整上下文
  const context = {
    [ssrImportKey]: request,
    [ssrDynamicImportKey]: dynamicRequest,
    [ssrModuleExportsKey]: exports,
    [ssrExportAllKey]: (obj) => exportAll(exports, obj),
    [ssrExportNameKey]: (name, getter) =>
      Object.defineProperty(exports, name, {
        enumerable: true, configurable: true, get: getter,
      }),
    [ssrImportMetaKey]: meta,
  }

  await this.evaluator.runInlinedModule(context, code, mod)
  return exports
}

注意 exports 对象在模块执行前就被赋给 mod.exports,这使得循环依赖中的部分导出能被其他模块访问到。

15.4.12 ensureBuiltins 的一次性协商

ModuleRunner 有一对字段(packages/vite/src/module-runner/runner.ts:48-49):

private isBuiltin?: (id: string) => boolean
private builtinsPromise?: Promise<void>

ensureBuiltins 在第一次 getModuleInformation 时被调用(line 249-275),内部向服务端发一次 invoke('getBuiltins', [])、拿到服务端认为哪些 id 是内置模块的列表(比如 ['node:fs', /^node:/, ...]),然后编译成一个 isBuiltin(id): boolean 函数缓存下来。

为什么要跨进程协商?——因为 Runner 可能和 DevServer 不在同一进程、甚至不在同一台机器。Runner 端不知道"当前的服务端认为 node:xxx 这些前缀都是内置"——这个列表还可以被插件通过 config.resolve.builtins 扩展(比如某个插件声明 @cloudflare/workers-types/... 也是内置)。

一次协商后 isBuiltin 函数就固定了——之后所有模块请求都本地判断是否是内置、不再走网络。这是一个启动期一次性开销换稳态零开销的设计——和 《vLLM 推理内核深度解析》 第 8 章 §8.5 讲的 pad_for_cudagraph 预铺平查表思路同构。

builtinsPromise 作为 "正在协商" 的占位符——多个并发请求第一次触发 ensureBuiltins 时、第一个发起请求、后来的都 await builtinsPromise——避免了多次冗余的 invoke('getBuiltins') 调用。这又是一次§15.4.5 "用 promise Map 去重"的同样思想——并发请求合并为一次底层操作。

这个设计还有一个隐藏的前向兼容价值——将来 vite 想支持更多内置模块、只要改服务端配置、Runner 端零改动。如果把内置列表硬编码在 Runner 代码里、每次扩展都要升级两端。

15.5 SSR Manifest

15.5.1 预加载映射

SSR Manifest 插件(packages/vite/src/node/ssr/ssrManifestPlugin.ts)在构建时生成从模块 ID 到预加载资源的映射。框架利用这个映射在服务端渲染时注入正确的 <link> 预加载标签(下面是示意、非逐字:源码里 fileName 是 typeof config.build.ssrManifest === 'string' ? config.build.ssrManifest : '.vite/ssr-manifest.json' 的三元,见 packages/vite/src/node/ssr/ssrManifestPlugin.ts:106-113):

generateBundle(_options, bundle) {
  const config = this.environment.config
  const ssrManifest = getSsrManifest(this)
  const { base } = config
  for (const file in bundle) {
    const chunk = bundle[file]
    if (chunk.type === 'chunk') {
      for (const id in chunk.modules) {
        const normalizedId = normalizePath(relative(config.root, id))
        const mappedChunks =
          ssrManifest[normalizedId] ?? (ssrManifest[normalizedId] = [])
        if (!chunk.isEntry) {
          mappedChunks.push(joinUrlSegments(base, chunk.fileName))
          chunk.viteMetadata!.importedCss.forEach((file) => {
            mappedChunks.push(joinUrlSegments(base, file))
          })
        }
        chunk.viteMetadata!.importedAssets.forEach((file) => {
          mappedChunks.push(joinUrlSegments(base, file))
        })
      }
      // ...(含 __vitePreload 的 chunk 还要追踪动态导入的 CSS,见 §15.5.2)
    }
  }
  this.emitFile({
    fileName: '.vite/ssr-manifest.json',
    type: 'asset',
    source: JSON.stringify(sortObjectKeys(ssrManifest), undefined, 2),
  })
}

15.5.2 动态导入的 CSS 追踪

SSR Manifest 还需要处理动态导入 chunk 中 __vitePreload 引用的 CSS 依赖。插件通过解析 chunk 代码中的导入语句,递归追踪所有关联的 CSS 文件:

flowchart TB
    A["检测 chunk 中的 __vitePreload"] --> B["es-module-lexer 解析动态导入"]
    B --> C["获取每个导入的 URL"]
    C --> D["解析为 bundle 中的文件名"]
    D --> E["递归追踪 importedCss"]
    E --> F{"还有子导入?"}
    F -->|"是"| E
    F -->|"否"| G["收集所有 CSS deps"]
    G --> H["写入 ssrManifest"]
const addDeps = (filename: string) => {
  if (filename === ownerFilename) return  // 避免自引用
  if (analyzed.has(filename)) return      // 避免重复
  analyzed.add(filename)
  const chunk = bundle[filename] as OutputChunk | undefined
  if (chunk) {
    chunk.viteMetadata!.importedCss.forEach((file) => {
      deps.push(joinUrlSegments(base, file))
    })
    chunk.imports.forEach(addDeps)  // 递归追踪
  }
}

这种递归追踪确保了即使 CSS 依赖嵌套在多层 chunk 导入链中,也能被正确地包含在 SSR Manifest 中。

15.5.3 joinUrlSegments 而不是 path.join

SSR Manifest 插件里反复用 joinUrlSegments(base, chunk.fileName):

mappedChunks.push(joinUrlSegments(base, chunk.fileName))

为什么不用 Node.js 的 path.join?——因为 path.join 是文件路径的操作(用平台 separator),而我们这里拼的是URL。在 Windows 上、path.join('/assets/', 'x.js') 返回 '\\assets\\x.js'——反斜杠绝对不能出现在 URL 里。

joinUrlSegments 统一用正斜杠、同时处理"base 末尾斜杠 + segment 开头斜杠"的重复斜杠问题——joinUrlSegments('/base/', '/x.js') === '/base/x.js'(不是 //x.js)。

看起来是个小工具函数、但代表了 vite 对 "路径 vs URL"严格区分的工程态度——两者在概念上不同(路径是文件系统、URL 是 Web 资源),API 也要分开。这种区分在做 SSR Manifest 时尤其重要——manifest 要在服务端生成、但消费它的是浏览器的 preload 标签、必须是 URL。

这和第 9 章讲的 Oxc ext → lang 兜底映射、第 10 章讲的 CSS URL 改写其实是同一种设计哲学——不混用语义相近但规则不同的字符串、每种用途有自己的工具函数。

15.5.4 sortObjectKeys 的确定性输出

SSR Manifest 在 emit 前调用了 sortObjectKeys(ssrManifest)(packages/vite/src/node/ssr/ssrManifestPlugin.ts:106-113;注意 fileName 在源码里是个三元——build.ssrManifest 传字符串时该字符串直接作为输出文件名,只有传 true 才落到 .vite/ssr-manifest.json,下面两处代码块为读起来方便只保留了默认分支):

this.emitFile({
  fileName: '.vite/ssr-manifest.json', // 简化:源码为三元,此处只保留默认分支
  type: 'asset',
  source: JSON.stringify(sortObjectKeys(ssrManifest), undefined, 2),
})

为什么要排序?——因为 build 要产生确定性输出(reproducible builds)。同一份源代码构建两次、产物应该字节级一致——便于做 CI 缓存、Docker layer 缓存、内容哈希稳定等。

JavaScript 对象字符串 key 的枚举顺序按规范是插入顺序(整数形式的 key 除外)——但在 Vite 的 bundle 里、不同请求顺序会导致插入顺序不同、生成的 manifest 里 key 顺序就不一致——即使内容等价、文本也不同、MD5/SHA 变化、下游缓存失效。

排序解决这个问题——按字母序排 key、无论插入顺序如何、最终文本一定一致。这是对 build 系统"纯函数性"的追求——相同输入永远得到相同输出。

很多前端构建工具(webpack 的 manifest 插件、parcel 的报告输出)都有类似逻辑——这是生产级构建工具的基本素养。用户从来不会注意到这一点、但构建缓存莫名频繁失效时、背后常常是这种"微小但决定性"的排序失误。

15.6 Source Map 处理

fetchModule 中的 inlineSourceMap 函数将 source map 内联到模块代码中:

function inlineSourceMap(mod, result, startOffset) {
  const map = result.map
  let code = result.code

  if (!map || !('version' in map) ||
      code.includes(MODULE_RUNNER_SOURCEMAPPING_SOURCE))
    return result

  // 移除其他 source map(只保留 Vite 的)
  if (OTHER_SOURCE_MAP_REGEXP.test(code))
    code = code.replace(OTHER_SOURCE_MAP_REGEXP, '')

  // 补偿 AsyncFunction 引入的行偏移
  const sourceMap = startOffset
    ? Object.assign({}, map, {
        mappings: ';'.repeat(startOffset) + map.mappings,
      })
    : map

  result.code = `${code.trimEnd()}\n` +
    `//# sourceURL=${mod.id}\n` +
    `${MODULE_RUNNER_SOURCEMAPPING_SOURCE}\n` +
    `//# ${SOURCEMAPPING_URL}=${genSourceMapUrl(sourceMap)}\n`

  return result
}

startOffset 的作用至关重要。ESModulesEvaluator 使用 new AsyncFunction(params, code) 创建函数,这个函数声明本身会占据一些行(如 async function anonymous(param1, param2, ...) {)。Source map 的行映射必须加上这个偏移量才能正确指向源代码。

15.6.1 OTHER_SOURCE_MAP_REGEXP 的 lastIndex = 0 重置

inlineSourceMap 里有一行细节(packages/vite/src/node/ssr/fetchModule.ts:139-140):

const OTHER_SOURCE_MAP_REGEXP = new RegExp(
  `//# ${SOURCEMAPPING_URL}=data:application/json[^,]+base64,([A-Za-z0-9+/=]+)$`,
  'gm',
)

function inlineSourceMap(...) {
  // ...
  OTHER_SOURCE_MAP_REGEXP.lastIndex = 0
  if (OTHER_SOURCE_MAP_REGEXP.test(code))
    code = code.replace(OTHER_SOURCE_MAP_REGEXP, '')

OTHER_SOURCE_MAP_REGEXP.lastIndex = 0 这一行是一道保险——因为 regex 有 g flag(global)、test() 命中时会推进 lastIndex(下次 test 从上次匹配结束处开始)。如果某次调用留下了非 0 的 lastIndex、下一次调用的 test 就会从中间开始查、漏掉前面的匹配。在现有代码里、test 命中后紧跟的 replace 会把 lastIndex 归零、test 失败也会自动归零——显式 reset 让正确性不再依赖"test 之后必然 replace"这个隐含前提。

这是 JavaScript RegExp 的一个经典坑——g flag 让 test/exec 有状态、不 reset 就可能跨调用污染。vite 的 fetchModule 每个模块都跑一次、regex 是模块级 const、每次进来先手动 lastIndex = 0 最稳妥。

为什么不去掉 g flag?——因为后面还要 code.replace(OTHER_SOURCE_MAP_REGEXP, '')——replace 需要 g 才会替换所有匹配(否则只替换第一个)。折中方案是:保留 g flag、在需要的地方显式 reset。

很多 JS 开发者栽在这个坑上——最常见的症状是"我的 regex 在单测里没问题、上了 prod 偶尔失效"。vite 在 production 代码里严格 reset、是成熟工程代码的标志。

15.6.2 trimEnd + newline 的 EOF 规范化

最后一段拼 source map URL 的代码(line 148-150):

result.code = `${code.trimEnd()}\n//# sourceURL=${
  mod.id
}\n${MODULE_RUNNER_SOURCEMAPPING_SOURCE}\n//# ${SOURCEMAPPING_URL}=${genSourceMapUrl(sourceMap)}\n`

code.trimEnd() 把原代码末尾的换行/空格吃掉、然后 \n 加一个干净的换行——统一了"原代码有没有 trailing newline"的情况。

接着三行 metadata 每行都用 \n 分隔、末尾也有 \n——整个输出保证以单个 \n 结尾。

为什么这么讲究?——因为 source map URL 标记必须在自己独立的行上(//# sourceMappingURL=...)、如果原代码末尾是 something\n\n\n、直接 code + '\n//# ...' 就会是 something\n\n\n\n//# ...——多余的空行本身不是问题、但让 code view 看起来乱。

trimEnd() + 单个 \n 实现了输出一致性——无论原代码的 EOF 什么样、最终产物都是"代码最后非空行 + 换行 + 3 行 metadata"。这是一种对文本输出的"规范化最终一公里"处理。

15.7 设计决策分析

15.7.1 为什么不直接用 Node.js ESM Loader

Node.js 提供了自定义 ESM Loader(--loader / --import)的能力,但 Vite 选择实现独立的 Module Runner,原因包括:

  1. HMR 需求:Node.js ESM 规范不允许模块被重新求值。Module Runner 通过 invalidateModule + 重新执行实现热更新
  2. 插件集成:Vue SFC、TypeScript 等需要 Vite 插件管线处理,Node.js Loader 难以完整集成
  3. 跨运行时:AsyncFunction 方案在 Node.js、Deno、Bun、Cloudflare Workers 中均可工作
  4. 精确控制:Module Runner 拥有完整的模块图,能实现精确的缓存失效和循环依赖处理
  5. Source Map:通过内联和行偏移补偿提供准确的错误定位

15.7.2 exports 密封设计

Object.seal(context[ssrModuleExportsKey])

Object.seal 在模块求值后禁止添加新属性,模拟了 ESM namespace 的静态特性。但已有属性的 getter 仍可返回变化后的值,保持了 live binding 语义。这是一个巧妙的平衡:防止意外的属性添加,同时允许导出值的合法变化。

15.7.3 传输层抽象

Module Runner 通过 NormalizedModuleRunnerTransport 抽象通信层,使得它不依赖于特定的 IPC 机制。同一个 Module Runner 可以通过 WebSocket、HTTP、或内存通道与 DevServer 通信。这种抽象使得 Module Runner 可以运行在与 DevServer 相同或不同的进程中。

graph LR
    A["ModuleRunner"] --> B["NormalizedTransport"]
    B --> C["WebSocket 通道<br/>(远程 Runner)"]
    B --> D["内存通道<br/>(同进程 Runner)"]
    B --> E["HTTP 通道<br/>(自定义部署)"]
    C --> F["DevServer"]
    D --> F
    E --> F

15.7.4 processImport 和 analyzeImportedModDifference 的 ESM/CJS 互通诊断

外部化模块有一个经常引起困惑的场景:用户 import { foo } from 'pkg'、pkg 是 CJS 包——CJS 的 module.exports = { foo } 在 ESM 里应该被看成默认导出、但用户想用具名解构。Node.js 对这种 ESM-from-CJS 具名导入只靠 cjs-module-lexer 静态分析有限支持、兼容边界非常模糊。

processImport 里调用 analyzeImportedModDifference(packages/vite/src/module-runner/runner.ts:122-134):

private processImport(
  exports: Record<string, any>,
  fetchResult: ResolvedResult,
  metadata?: SSRImportMetadata,
) {
  if (!('externalize' in fetchResult)) {
    return exports
  }
  const { url, type } = fetchResult
  if (type !== 'module' && type !== 'commonjs') return exports
  analyzeImportedModDifference(exports, url, type, metadata)
  return exports
}

analyzeImportedModDifference(在 packages/vite/src/shared/ssrTransform.ts 里)比较"用户想要什么"(metadata.importedNames)和"模块实际提供什么"、在不匹配时抛 SyntaxError:ESM 包缺导出时报 does not provide an export named 'foo';CJS 包则抛一段带建议的错误——"Named export 'foo' not found……CommonJS modules can always be imported via the default export"、并给出 import pkg from 'pkg'; const {foo} = pkg; 的写法。

为什么需要这层诊断?——因为 ssrTransform 把静态 import 改成了 __vite_ssr_import__ 调用、外部化模块最终走的是动态 import():缺失的具名导出不会像顶层静态 import 那样在链接阶段报错、只会静默地得到 undefined。源码注释说得很直白——"since we transform as dynamic imports, we need to emulate the error manually",CJS 那段文案更是注明 "Copied from Node.js"。vite 在 externalized 模块路径里补上这一层、让 dev 下的报错与生产环境 Node.js 的行为一致——用户直接看到"请换成 default import"。

这是开发体验(DX)优化的典范——在用户踩坑之前、把原因和解法告诉他。工具的价值不只是跑起来、还包括在跑不起来的时候告诉你怎么修。

type === 'module' || type === 'commonjs' 两个分支走同一个分析函数——分析函数内部根据 type 切换判断逻辑。这种"分支变量、路径统一"的设计避免了两份几乎相同的代码、让维护更简单。

15.8 小结

本章深入分析了 Vite SSR 架构的核心组件:

  • fetchModule 实现了精确的外部化决策逻辑,通过四级判断(内置模块、外部 URL、裸模块、项目源码)确定每个模块的最优加载路径。缓存协商机制避免了不必要的代码传输。
  • SSR Transform 将 ESM 语法转换为可在 AsyncFunction 中执行的运行时代码,实现了完整的作用域分析、导入提升、导出 live binding、this 解绑等语义保持。三阶段处理(导入、导出、引用重写)的设计清晰而高效。
  • Module Runner 是一个完整的模块执行运行时,通过 Transport 抽象层与 DevServer 通信。它实现了三级循环依赖检测、EvaluatedModules 缓存图、HMR 热更新支持以及精确的 source map 行偏移补偿。
  • SSR Manifest 在构建时生成模块到资源的映射,包括递归追踪动态导入链中的 CSS 依赖,使 SSR 框架能够注入完整的预加载标签。

Vite SSR 架构的核心设计哲学是"选择性介入":项目源码经过完整管线处理以获得 HMR 和转换支持,node_modules 依赖尽可能外部化以获得最优性能。Module Runner 的跨运行时设计(基于 AsyncFunction 而非 vm)和传输层抽象,使得 Vite 的 SSR 方案能够适应从传统 Node.js 到 Edge Runtime 的多种部署场景。

15.9 和其他章节的呼应

与第 10 章(CSS 插件)的呼应——joinUrlSegments 在 SSR Manifest 里拼资源 URL、第 10 章讲的 cssUrlRE 在 import 时改写 URL——两者共享"URL 和文件路径严格分开"的工程语言。

与 LangGraph 专栏第 14 章(Runtime)的呼应——mod.transformResult 作为缓存有效性的天然信号、LangGraph 的 execution_info is None 作为任务未准备的信号——都是"用数据存在性代替独立状态字段"的同样设计哲学、跨语言跨框架出现。

与 vLLM 专栏第 8 章(ModelRunner)的呼应——pad_for_cudagraph 在初始化时预铺平的查表和 ensureBuiltins 的启动期一次性协商都是"启动时付一次代价、稳态零开销"的典型——运行时性能 vs 启动时性能的权衡被 vite 和 vllm 以类似方式处理。

与 hyper/tower 专栏第 8 章(Filter/Steer)的呼应——vite 的 fetchModule 4 级分支决策(builtin/network/bareId/project)和 Steer 的 Picker 模式本质一致——按运行时信息选择完全不同的代码路径。那一章和本章对比看能发现同样的设计在前端和后端都是好模式。

15.10 源码细节汇总的十点设计原则

把本章所有源码级观察提炼成 vite SSR 的十条设计原则:

① Object.freeze 静态配置(§15.1.2)——防止运行期意外修改默认值污染其他调用。

② unwrapId 虚拟模块还原(§15.2.2)——URL 层和 moduleGraph 层的 id 形态不同、按需转换。

③ mod.transformResult 作为缓存有效性的天然信号(§15.2.3)——不维护独立的版本号、利用数据存在性表示状态。

④ shebang 替换成等长空格(§15.2.5)——保留字符位置让 source map 不错位。

⑤ concurrentModuleNodePromises 去重 Map(§15.4.5)——并发请求合并为一次底层 fetch。

⑥ debug?.(...) 2s 超时诊断(§15.4.7)——开则警告、关则零开销。

⑦ mod.promise + mod.evaluated 两阶段状态机(§15.4.9)——异步模块的三状态表达。

⑧ ensureBuiltins 启动期一次性协商(§15.4.12)——跨进程信息在启动时锁定、稳态零网络。

⑨ sortObjectKeys 确定性构建输出(§15.5.4)——按字母序排 manifest key、保证字节级可复现。

⑩ analyzeImportedModDifference 前置诊断(§15.7.4)——动态导入下本会静默变成 undefined 的缺失导出、被还原成与 Node.js 一致、带解法建议的报错。

这十条原则跨越"配置设计、缓存策略、错误处理、并发协调、构建可复现"五个维度——其中不少在其他成熟 JS 工具链(webpack、parcel、turbopack 等)里也能找到类似做法——但 vite 把它们组合在一套相对小巧的 SSR 架构里、让服务端渲染既快又好调试。

读到这里再回看 15.1 节那张 SSR 加载流水线图——每个框里都藏着上面这些原则的具体应用。框架级代码的复杂性不在于"做了什么"、而在于"每一步都要考虑什么"——vite 的 node/ssr/ 目录约 1600 行(v8.0.3 实测 1570 行,含 ssr/runtime/;加上 module-runner/ 的 1749 行也才 3319 行)、承载了这么多细节、是克制与精巧的结合。

下一章进入 Environment API——你会发现本章的 DevEnvironment 其实就是 Environment 的一个具体实现、本章讲的所有 SSR 逻辑都是"一个 Environment 所承担的职责"的集合。把本章和下一章连起来看、就能理解 vite 6+ 架构里"多环境运行时"的完整图景——为什么要让 client / ssr / worker 各有一个独立 Environment、为什么不能简单地把 SSR 当成 client 的"一个特殊模式"。整个架构的优雅就在这种"抽象分层精确对应业务本质"的设计里——看似相同的操作(加载模块)在不同环境下需要截然不同的规则、而抽象层让这些规则能分别表达、又不重复。