Appearance
第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[]
}
}noExternal 和 external 是 SSR 配置中最关键的两个选项。它们控制了依赖的"外部化"策略:
- 外部化 (external):依赖由 Node.js 原生加载器处理,不经过 Vite 转换。性能最优,但失去 Vite 插件的转换能力。
- 内部化 (noExternal):依赖像项目源码一样经过 Vite 完整管线处理。适用于需要编译的依赖(如仅提供 ESM 的包、含 CSS 导入的组件库)。
target 选项决定了 SSR 产物的运行目标环境。当设为 'node' 时,package.json 的 browser 字段会被忽略;当设为 '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 决策流程
fetchModule(ssr/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.json的type字段
这个类型信息传递给 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 中执行,原因在于:
import/export语法在函数体内是语法错误import.meta在非模块上下文中不可用- 动态
import()的基准 URL 不正确
ssrTransform(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 分为三个主要阶段:
阶段一:导入处理