Vite内核解析-第15章 SSR 与模块运行器

《Vite 设计与实现》完整目录

第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 架构的设计与实现。

:::tip 本章要点

  • 理解 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 构建的一个关键决策点:

typescript 复制代码
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 函数合并:

typescript 复制代码
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 SSR 模块加载流水线

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

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

typescript 复制代码
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 能够通过传输层发起模块请求:

typescript 复制代码
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 模块加载的核心函数。它为每个模块请求做出"内部化还是外部化"的决策:

typescript 复制代码
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?
(非 file://)"} D -->|"是"| E["externalize (network)"] D -->|"否"| F{"裸模块标识符?
(不以 . / / 开头)"} F -->|"是"| G["tryNodeResolve"] F -->|"否"| H["transformRequest
(Vite 管线)"] G -->|"解析成功"| I["externalize
(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 ESM vs CJS 类型判断

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

typescript 复制代码
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.3 缓存协商

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

typescript 复制代码
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 函数负责生成转换后的代码:

typescript 复制代码
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 写在代码的哪个位置,它都会在模块执行前被处理。

阶段二:导出处理

typescript 复制代码
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

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

typescript 复制代码
// 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,将所有引用导入绑定的标识符替换为对应的属性访问:

typescript 复制代码
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.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 声明使用不同的作用域查找策略:

typescript 复制代码
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 绑定:

javascript 复制代码
// 原始代码
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.6 JSON 模块的特殊处理

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

typescript 复制代码
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
(模块缓存图)"] A --> C["NormalizedTransport
(通信层)"] A --> D["ESModulesEvaluator
(代码求值器)"] A --> E["HMRClient
(热更新客户端)"] 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 初始化

typescript 复制代码
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.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.4 循环依赖处理

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

typescript 复制代码
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
  }
}

三级检测的具体实现:

typescript 复制代码
// 第 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.5 ESModulesEvaluator

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

typescript 复制代码
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.6 directRequest 与运行时上下文

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

typescript 复制代码
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.5 SSR Manifest

15.5.1 预加载映射

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

typescript 复制代码
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"]
typescript 复制代码
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.6 Source Map 处理

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

typescript 复制代码
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.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 密封设计

typescript 复制代码
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 通道
(远程 Runner)"] B --> D["内存通道
(同进程 Runner)"] B --> E["HTTP 通道
(自定义部署)"] C --> F["DevServer"] D --> F E --> F

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 的多种部署场景。

相关推荐
杨艺韬4 小时前
Vite内核解析-第13章 Rolldown 构建引擎
agent
杨艺韬4 小时前
Vite内核解析-第18章 设计模式与架构决策
agent
杨艺韬4 小时前
Vite内核解析-第4章 插件系统与 Hook 机制
agent
杨艺韬4 小时前
Vite内核解析-第6章 模块图与依赖追踪
agent
杨艺韬4 小时前
Vite内核解析-第11章 HTML 转换与入口解析
agent
杨艺韬4 小时前
Vite内核解析-前言
agent
杨艺韬4 小时前
Vite内核解析-第7章 HMR 热更新
agent
杨艺韬4 小时前
Vite内核解析-第9章 JavaScript 与 TypeScript 转换
agent
杨艺韬4 小时前
Vite内核解析-第1章 为什么需要理解 Vite
agent