Vite 设计与实现

第15章 SSR 与模块运行器

作者 杨艺韬 · 12,169 字

第15章 SSR 与模块运行器

开篇引言

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

Vite 的 SSR 方案经历了从 ssrLoadModule 到 Module Runner 的演进。早期的 ssrLoadModule 是一个相对简单的模块加载器,而 Vite 6+ 引入的 Module Runner 则是一个完整的模块执行运行时,支持 HMR、source map、循环依赖处理等高级特性。

本章将从 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 配置定义在 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[]
  }
}

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

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

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

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

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.1.5 _ssrConfigDefaultsObject.freeze 的意义

ssr/index.ts:60 的默认配置有一个容易忽略的细节:

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

Object.freeze 让这个对象变成真正的 immutable——后续所有 mergeWithDefaults(defaults, ssr ?? {}) 调用都不能修改它。为什么要这么谨慎?

因为 resolveSSROptions 会被多次调用(每个 DevEnvironment 一次、每个 build 一次)——如果 _ssrConfigDefaults 不 freeze、某次调用里有人通过 defaults.target = 'webworker' 修改了它、下次调用就受污染了。这在测试场景最明显:一个 test 意外改了默认值、下一个 test 莫名其妙地 target 错了、而问题难定位。

Object.freeze 在这里是全局静态配置对象的自我保护——用 JS 语言层的不变性把”这个对象不该被改”写进代码、而不是靠文档约定。性能上、freeze 对象的属性读取不变慢(现代 V8 对 frozen 对象有 fast-path 优化)、只是禁止了写——成本基本为零。

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

15.1.2 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.3 DevEnvironment 中的 SSR 集成

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

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

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

  transformRequest(url: string): Promise<TransformResult | null> {
    return transformRequest(this, url)
  }
}

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

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 决策流程

fetchModulessr/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 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}'.`)
  }

  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.1.5 unwrapId 在第四级前的神秘一步

fetchModule 的第 4 级分支(项目源码)一开头有一行(fetchModule.ts:81):

url = unwrapId(url)

它在做什么?unwrapId 定义在 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 的 key 是原始 id(带 \0)、不是 URL 形式。所以要先 unwrapId 把 URL 还原成 id、才能 ensureEntryFromUrl(url) 正确找到模块节点。

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

15.2.1.6 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 }
}

transformResultEnvironmentModuleNode 的一个字段——只在模块被 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.2 ESM vs CJS 类型判断

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

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

isFilePathESM 通过以下规则判断:

  • .mjs 文件 -> ESM
  • .cjs 文件 -> CJS
  • .js 文件 -> 查找最近的 package.jsontype 字段

这个类型信息传递给 Module Runner 后,Runner 会根据类型选择不同的导入方式:ESM 使用 import(),CJS 使用 require()(或兼容逻辑)。

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

fetchModule 末尾有一段处理 shebang 的代码(fetchModule.ts:106-107):

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

为什么不直接 replace(/^#!.*/, '') 删掉?——因为 source map 的行/列映射是按字符位置算的、把整行删掉会让所有后续行的位置全部前移、source map 就错位了。

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

这是每个编译器都会遇到的经典问题——移除语法无效但对工具链重要的内容时、要保留位置不变。Babel、TypeScript、swc 都用类似技巧。你会看到 s.overwrite(0, 19, ' ') 或者 code.replaceAt(0, 19, ' '.repeat(19))——本质都是”原地抹掉、保留长度”。

shebang 只会出现在可执行脚本的第一行(regex ^#!.*)——result.code[0] === '#' 做了 fast-reject:90% 的文件第一个字符不是 #、直接跳过 regex 测试。对 dev server 每次 transform 后的几千个模块都要过一遍这段代码——加个单字符前置判断能省不少 regex 启动开销。

15.2.3 缓存协商

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 不正确

ssrTransformssr/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 __ssr_import_0__ = await __vite_ssr_import__('./mod')"]
        B2["const __ssr_import_1__ = await __vite_ssr_import__('./lib')"]
        B3["const __ssr_import_2__ = await __vite_ssr_import__('./default')"]
        B4["const __ssr_import_3__ = await __vite_ssr_import__('./re')"]
        B5["const __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 进行更精确的加载优化。当 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

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

// export function foo() {} --> 保留函数声明,注册导出名
defineExport(node.declaration.id!.name)

// export const a = 1, b = 2 --> 提取所有声明名,逐一注册
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,将所有引用导入绑定的标识符替换为对应的属性访问:

walk(ast, {
  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 === '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 === 'PropertyDefinition' &&
               parentStack[1]?.type === 'ClassBody') {
      // 类字段初始化器中的引用需要提升为局部变量
      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 {
      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)
  },
})

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

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

为什么 deps 要在 transform 时收集、而不是在 Module Runner 里运行时识别?——因为 Vite 的 ModuleGraph 需要静态知道模块的依赖树——用来做 HMR 边界传播(某个模块变了、哪些模块受影响)、预加载提示生成、circular 早期诊断。如果等运行时再知道、就太晚了。

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

  • ModuleGraph 直接用 deps 建父子关系
  • Preload 插件可能也关心 dynamicDeps(动态导入的 chunk 可能需要预加载提示)
  • HMR 只关心 deps(动态导入一般不跟着 HMR)

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

15.3.4 作用域分析

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.5 方法调用的 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.5.5 onIdentifier 里的 isStaticProperty + shorthand 双重检查

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

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

为什么要检查 isNodeInPatternisInDestructuringAssignment——因为对象简写属性有两种语境

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

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

这就是双重检查的来源

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

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

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

15.3.6 JSON 模块的特殊处理

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

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

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 初始化

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) {
      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',
        )
      }
      this.transport.connect(createHMRHandlerForRunner(this))
    }

    // 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.2.5 runner.import(acceptedPath) 作为 HMR 回调的巧妙闭环

ModuleRunner 构造里初始化 HMRClient 时有一个精致的设计(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) 触发 cachedModulecachedRequest → 新的 fetch → evaluate 新代码
  4. 新 module 的 exports 通过 HMR accept callback 替换旧的、应用层看到更新

整个 HMR 刷新链路在 ModuleRunner 内部完成——不需要应用代码配合、不需要重启 Runner、不需要清理任何全局状态。被 HMR 影响的模块会在 cachedModuleinvalidate: true 分支、evaluatedModules 里的老节点被扔掉、新节点从 fetch 开始 rebuild。

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

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

15.4.3 模块加载流程

当调用 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.3.5 concurrentModuleNodePromises 的去重 Map

ModuleRunner 实例有一个字段(runner.ts:44):

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

这是一个按 URL 去重的并发请求 MapcachedModule(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 链不会触发。但在前端路由切换、多个 lazy chunk 同时加载的场景、经常有几十个模块同时请求相同的公共依赖、这个 Map 能避免几十次重复网络调用。

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

15.4.4 循环依赖处理

循环依赖是模块系统中最棘手的问题之一。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.4.5 debug?.('... takes over 2s to load') 的超时诊断

cachedRequestrunner.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 秒后如果模块还没加载完、自动打印调用栈——帮用户定位”为什么这个模块加载这么慢”。典型原因是:

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

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

try/finally 里的 if (debugTimer) clearTimeout(debugTimer) 保证无论模块加载成功还是失败、定时器都会被清除——避免模块已经 30 秒前加载完、定时器还错位地打印。这是 setTimeout + 清理的标准做法。

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

15.4.5 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 特性
  4. startOffsetAsyncFunction 构造器生成的函数声明占据额外的行,startOffset 记录了这个偏移量以便修正 source map

15.4.5.5 mod.promisemod.evaluated 的两阶段状态机

cachedRequest 里有一对看起来冗余的字段(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.promisemod.evaluated状态
undefinedfalse从未加载
非空false正在加载(directRequest 的 promise 尚未 resolve)
非空true加载完成

三种状态对应三种路径

  • 未加载 → 走 directRequest、setpromise、then mark 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 重跑、下次请求直接返回已有的 exports(可能是部分的)、或者 promise 里的异常。

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

15.4.5.7 getModuleInformation'cache' in fetchedModule 防污染检查

getModuleInformation 处理缓存命中分支时(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.6 directRequest 与运行时上下文

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

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 exports = await this.evaluator.runExternalModule(externalize)
    mod.exports = exports
    return exports
  }

  // 构建 import.meta 并注入 hot 属性
  const meta = await createImportMeta(modulePath)
  if (this.hmrClient) {
    Object.defineProperty(meta, 'hot', {
      enumerable: true,
      get: () => {
        hotContext ||= new HMRContext(this.hmrClient, mod.url)
        return hotContext
      },
    })
  }

  // 组装完整上下文
  const exports = Object.create(null)
  Object.defineProperty(exports, Symbol.toStringTag, {
    value: 'Module', enumerable: false, configurable: false,
  })
  mod.exports = exports

  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.6.5 ensureBuiltins 的一次性协商

ModuleRunner 有一对字段(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 函数就固定了——之后所有模块请求都本地判断是否是内置、不再走网络。这是一个启动期一次性开销换稳态零开销的设计——和 §8.5.0.5 讲的 vLLM bisect 查表思路同构。

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

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

15.5 SSR Manifest

15.5.1 预加载映射

SSR Manifest 插件(ssr/ssrManifestPlugin.ts)在构建时生成从模块 ID 到预加载资源的映射。框架利用这个映射在服务端渲染时注入正确的 <link> 预加载标签:

generateBundle(_options, bundle) {
  const ssrManifest = getSsrManifest(this)
  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))
        })
      }
    }
  }
  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。

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

15.5.4 sortObjectKeys 的确定性输出

SSR Manifest 在 emit 前调用了 sortObjectKeys(ssrManifest)(line 847):

this.emitFile({
  fileName: '.vite/ssr-manifest.json',
  type: 'asset',
  source: JSON.stringify(sortObjectKeys(ssrManifest), undefined, 2),
})

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

JavaScript 对象的 key 顺序在大部分引擎里是插入顺序——但在 Vite 的 bundle 里、不同请求顺序会导致插入顺序不同、生成的 manifest 里 key 顺序就不一致——即使内容等价、文本也不同、MD5/SHA 变化、下游缓存失效。

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

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

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_REGEXPlastIndex = 0 重置

inlineSourceMap 里有一行细节(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 从上次匹配结束处开始)。如果上次 inlineSourceMap 调用里 test 返回 true 但没有 reset、下一次调用时 lastIndex 不是 0、test 从中间开始查、漏掉文件开头的匹配

这是 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 processImportanalyzeImportedModDifference 的 ESM/CJS 互通诊断

外部化模块有一个经常引起困惑的场景:用户 import { foo } from 'pkg'、pkg 是 CJS 包——CJS 的 module.exports = { foo } 在 ESM 里应该被看成默认导出、但用户想用具名解构。这种 ESM-from-CJS 互通是 Node.js 18+ 的功能、但兼容边界非常模糊

processImport 里调用 analyzeImportedModDifferencerunner.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(在 shared utils 里)比较”用户想要什么”和”模块实际提供什么”、在不匹配时抛带建议的错误——比如”你 import { foo } from 'pkg'、但 pkg 是 CJS 只导出了 default 对象、请改成 import pkg from 'pkg'; const { foo } = pkg”。

为什么需要这层诊断?——因为 Node.js 原生的错误信息是 SyntaxError: Named export 'foo' not found——看不出是 CJS/ESM 互通问题。用户花几小时查文档才能理解。vite 在 externalized 模块路径里加这一层、把诊断前移——用户直接看到”请换成 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.8.5 和其他章节的呼应

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

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

与第 8 章(vLLM ModelRunner)的呼应——pad_for_cudagraphbisect.bisect_leftensureBuiltins 的启动期一次性协商都是”启动时付一次代价、稳态零开销”的典型——运行时性能 vs 启动时性能的权衡被 vite 和 vllm 以类似方式处理。

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

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

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

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

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

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

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

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

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

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

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

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

analyzeImportedModDifference 前置诊断(§15.7.4)——ESM/CJS 互通问题在原生错误前被转译成可操作的建议。

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

读到这里再回看 15.1 节那张 SSR 加载流水线图——每个框里都藏着上面这些原则的具体应用。框架级代码的复杂性不在于”做了什么”、而在于”每一步都要考虑什么”——vite 的 SSR 模块不到 1500 行、承载了这么多细节、是克制与精巧的结合。

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