Skip to content

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

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 框架的角度看,加载一个模块需要经过以下完整流程:

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

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 分为三个主要阶段:

阶段一:导入处理

基于 VitePress 构建