Vue 3 核心源码深度剖析系列(v3.5.44)-第 16 章 SSR 编译内幕:compiler-ssr 与 SSR 代码生成
本章对应
packages/compiler-ssr/src/:index.ts(94 行,编译入口)、ssrCodegenTransform.ts(251 行,IR→push 语句的核心)、runtimeHelpers.ts(55 行,helper 符号表)、errors.ts(43 行,错误码)、以及transforms/下的ssrTransformElement.ts(493 行)、ssrTransformComponent.ts(386 行)、ssrTransformSlotOutlet.ts(96 行)、ssrTransformTeleport.ts(71 行)、ssrTransformSuspense.ts(83 行)、ssrTransformTransition.ts(39 行)、ssrTransformTransitionGroup.ts(118 行)、ssrVIf.ts(78 行)、ssrVFor.ts(53 行)、ssrVModel.ts(201 行)、ssrVShow.ts(35 行)、ssrInjectFallthroughAttrs.ts(92 行)、ssrInjectCssVars.ts(67 行)。交叉引用packages/compiler-core/src/compile.ts的getBaseTransformPreset(32–63)与baseCompile(67–122)、packages/compiler-core/src/codegen.ts的ssrHelpers导入生成(456–464、515–522)、packages/compiler-core/src/options.ts的ssr/inSSR/ssrCssVars选项(179–195、296)、packages/server-renderer/src/render.ts的PushFn与createBuffer(45–92)与ssrRender调用点(208–218)、packages/server-renderer/src/internal.ts(21 行,helper 导出表)、以及helpers/目录下的ssrRenderAttrs.ts、ssrRenderComponent.ts、ssrRenderSlot.ts、ssrRenderList.ts、ssrInterpolate.ts、ssrRenderTeleport.ts、ssrRenderSuspense.ts、ssrVModelHelpers.ts、ssrGetDirectiveProps.ts。所有行号对应vuejs/core@3.5.44。读完本章你将能回答:为什么
@vue/compiler-ssr没有自己的compile管线、只能"寄生"在compiler-dom的transform之上?ssrRender(_ctx, _push, _parent, _attrs)四个参数分别是什么、为什么比客户端render(_ctx, _cache)多了两个?_push到底是同步还是异步、它push 的到底是什么?一次 SSR 渲染为什么会产生一棵SSRBuffer树而不是一个字符串?ssrCodegenTransform这个"第二趟 pass"为什么必须存在——IR 里已有VNodeCall,为什么不直接生成?pushStringPart与pushStatement的区别是什么、什么内容会被"粘"进同一个模板字面量、什么内容必须"切断"?ssrRenderAttrs为什么要一次把整个属性对象序列化成字符串,而客户端patchProps却要逐属性 patch?ssrTransformElement里那三个_temp0临时变量分别在解决<textarea v-bind>、<input v-bind v-model>、自定义指令textContent/innerHTML的什么问题?<slot>为什么编译成ssrRenderSlot(_ctx.$slots, ...)而不是 vnode 子节点?Teleport 的__teleportBuffers是怎么在流式渲染里把内容"搬"到目标位置、又为什么必须用注释锚点占位?v-model在 SSR 下为什么是"把 model 值渲染进value/checked属性",而不是绑定事件?为什么 SSR 编译把hoistStatic、cacheHandlers、transformText全部关掉——这些优化在服务端还有意义吗?
导语:为什么需要一门"专门生成字符串"的编译器
第 15 章我们把服务端渲染(server-renderer)的运行时讲完了:renderToString 创建一棵 SSRBuffer 树,递归地把组件渲染成字符串片段,最后 unrollBuffer 拼成最终 HTML。但第 15 章刻意留了一个悬而未决的问题——那些字符串片段是从哪来的? 答案就是本章的主题:@vue/compiler-ssr。
要理解 compiler-ssr,先要建立一个反直觉的事实:它在源码结构上不是一门"独立的编译器",而是 compiler-core / compiler-dom transform 管线的一组合集(preset)。 packages/compiler-ssr/src/index.ts 里的 compile 函数(index.ts:32)甚至没有自己调用 baseParse 之外的任何解析、也没有自己调用 getBaseTransformPreset ——它把 compiler-dom 的 baseParse、transform、generate 原样拿来用,只是换掉了 nodeTransforms / directiveTransforms 两张表,并在标准 transform 之后额外插入了一个"第二趟 pass"(ssrCodegenTransform)。
这个设计带来一个非常关键的后果:SSR 编译与 CSR 编译共享同一个 parser、同一份 AST 数据结构、同一套 transform 上下文(scope、bindingMetadata、helpers、components、directives 追踪),却在 codegen 阶段彻底分道扬镳。 客户端产物是一棵 vnode(render 返回 vnode 树);SSR 产物是一串拼接好的字符串(ssrRender 往 _push 里塞字符串)。
为什么不能在 generate 里加个分支就完事?因为客户端 IR 的核心是 VNodeCall(第 7 章),它假设"每个元素/组件最终会变成一个 createVNode 调用"。而 SSR 的核心诉求恰好相反:能把多个相邻的静态/动态片段合并进同一个模板字面量就不要拆开,能一次 _push 搞定就绝不分两次。 于是 compiler-ssr 在 transform 结末了之后,又加了一趟专门的 pass,把已经生成的 VNodeCall / IfNode / ForNode / SlotOutletNode 重新翻译成一棵由 _push(...)、if、for、模板字面量组成的新 JS AST,再交给共享的 generate 输出代码。这就是 ssrCodegenTransform.ts 存在的全部理由。
本章的核心张力可以概括成一句话:
客户端编译器优化的是"vnode 树的复用与更新";SSR 编译器优化的是"输出字符流的连续性与最少 helper 调用"。前者面向后续 diff,后者面向一次性序列化——两者的最优解天然不同。
围绕这句话,本章会把每一个 transforms/*.ts 文件走读一遍,并回答一个反复出现的问题:什么时候该把内容"粘"成字符串,什么时候必须调用一个 ssrRenderXxx helper? 判据并不复杂——凡是有"运行时才能确定的结构"(组件、插槽、Teleport、动态属性集合、v-for/v-if 的分支)就必须调用 helper 或产生控制流语句;凡是在编译期结构已定、只是"值"不确定的内容(静态文本、插值),就可以安全地拼进模板字面量。
本章结构是"定位 → 产物形态 → 核心 pass → 逐 transform → 错误/差异 → 实战":16.1 讲 compiler-ssr 的定位与入口;16.2 讲产物形态与 _push 调用约定;16.3 逐段走读 ssrCodegenTransform;16.4 给出 helper 映射全表;16.5–16.8 逐个走读 element / component / slot / Teleport·v-model·v-show 的 transform;16.9 讲错误码;16.10 给出 SSR 与 CSR 的差异总表;16.11 用一个真实模板产出两份编译产物并逐段对照;16.12 收口。
需要提前记住一个判断:SSR 编译产物的"形状"(模板字面量 + 少量 helper 调用 + 少量 if/for)几乎完全由 ssrCodegenTransform 里 pushStringPart / pushStatement 这对孪生函数决定。 理解了这对函数"何时合并、何时切断"的规则,本章后面所有 transform 的行为都只是这条规则的应用。
16.1 compiler-ssr 的定位:compiler-core 的 transform 插件,而非独立编译器
16.1.1 一个被反复误解的包
打开 packages/compiler-ssr/package.json,你会看到它的依赖只有 @vue/shared 和 @vue/compiler-dom。注意——没有 @vue/compiler-core 的直接依赖、更没有自己的 parser。 所有解析能力都从 compiler-dom 转手而来。这个依赖拓扑本身就是一句设计声明:
compiler-ssr是compiler-dom的一个下游消费者,它复用compiler-dom的全部基础设施(parser、AST、transform、codegen),只替换"与代码生成相关的两张插件表 + 一个后处理 pass"。
这也解释了为什么它没有被拆成 compiler-core 的一层:SSR 编译必须继承 DOM 特化知识(isVoidTag、isCustomElement、propsToAttrMap、isSSRSafeAttrName、isBooleanAttr、SVG/MathML 命名空间等)。如果 SSR 编译器挂在 compiler-core 上,它就得把这些 DOM 知识重新抄一遍。挂在 compiler-dom 上则天然共享。
16.1.2 compile() 入口逐行走读
packages/compiler-ssr/src/index.ts:32-94 是整章的入口,全文仅 60 余行,却把设计意图表达得淋漓尽致:
// packages/compiler-ssr/src/index.ts:32-49
export function compile(
source: string | RootNode,
options: CompilerOptions = {},
): CodegenResult {
options = {
...options,
...parserOptions,
ssr: true,
inSSR: true,
scopeId: options.mode === 'function' ? null : options.scopeId,
// always prefix since compiler-ssr doesn't have size concern
prefixIdentifiers: true,
// disable optimizations that are unnecessary for ssr
cacheHandlers: false,
hoistStatic: false,
}
const ast = typeof source === 'string' ? baseParse(source, options) : source
// Save raw options for AST. This is needed when performing sub-transforms
// on slot vnode branches.
rawOptionsMap.set(ast, options)
...
}
逐行拆解这段"选项强制":
...parserOptions合并:parserOptions来自compiler-dom,是把isVoidTag/isNativeTag/isPreTag等 parser 钩子接进来的载体。这一步让 SSR 解析与 DOM 解析在"哪些标签是 void、哪些是自定义元素"上保持完全一致——否则 SSR 会给<input>生成一个错误的</input>闭合标签。ssr: true、inSSR: true:这两个布尔位是贯穿整条管线的"模式开关"。options.ts:179-195对它们的注释写得极清楚:ssr用来声明"当前在生成 SSR render 函数",inSSR用来声明"当前处在 SSR 编译上下文中"。二者的差别在于subTransform(16.6 会讲)——slot 的 vnode 回退分支需要临时把ssr关掉(ssr: false)但保留inSSR: true,从而让这段回退代码按客户端规则生成,却又知道"我仍处在 SSR 编译里、不要做客户端专有的预归一化"。scopeId的条件清空:当mode === 'function'(非 SFC 模块模式)时,scopeId被强制置为null。原因是 scoped style 的data-v-xxx注入依赖"每个组件的 scopeId",而 function 模式通常是编译一个孤立的模板字符串、没有 SFC 语境。prefixIdentifiers: true恒真:注释直白——compiler-ssr doesn't have size concern。SSR 产物在服务端执行,包体积无关紧要,因此永远开启标识符前缀(_ctx.foo),从而规避with(_ctx)这种无法静态分析的写法,也让 helper/scope 追踪更精确。cacheHandlers: false、hoistStatic: false:这两行是理解 SSR 与 CSR 优化哲学差异的钥匙。 事件处理器缓存(_cache[0] || ...)是为"避免每次渲染重建函数"服务的,而 SSR 每个请求只渲染一次、且根本不渲染事件监听器(见 16.5),缓存毫无意义;静态提升(把静态 vnode 提升到 render 函数外)是为"跨次渲染复用 vnode"服务的,SSR 同样每个请求只渲染一次,提升反而增加服务端常驻内存。第 7 章讲过的两大优化,在 SSR 语境下被主动关闭。
16.1.3 被强制的编译选项一览
| 选项 | 客户端默认 | SSR 强制值 | 理由 |
|---|---|---|---|
ssr | false | true | 声明生成 ssrRender 而非 render;影响 codegen 前缀与 helper 归属 |
inSSR | false | true | 标记处于 SSR 编译上下文;禁止对 props 做客户端预归一化(transformElement.ts:776 的 !context.inSSR 分支) |
prefixIdentifiers | 依模式 | true | 服务端无体积顾虑,永远前缀化,避免 with |
cacheHandlers | 可选 | false | SSR 一次性渲染,事件处理器不缓存(且 SSR 不渲染事件) |
hoistStatic | 可选 | false | 跨次复用无意义,反而增加服务端常驻内存 |
scopeId(function 模式) | 传入值 | null | function 模式无 SFC 语境,无法注入 data-v-xxx |
optimizeImports | 可选 | 不强制 | 由使用者决定;SSR helper 从 vue/server-renderer 导入 |
这张表回答了一个常见困惑:"为什么我用 @vue/compiler-ssr 编译出来的代码里没有 _cache、没有 hoisted 常量?"——因为它们被入口函数强制关掉了。
16.1.4 nodeTransforms / directiveTransforms 清单与顺序
transform 调用(index.ts:55-87)是本章最重要的"目录"。它把标准 preset 完全替换为 SSR preset:
// packages/compiler-ssr/src/index.ts:55-87(节选)
transform(ast, {
...options,
hoistStatic: false,
nodeTransforms: [
transformVBindShorthand,
ssrTransformIf,
ssrTransformFor,
trackVForSlotScopes,
transformExpression,
ssrTransformSlotOutlet,
ssrInjectFallthroughAttrs,
ssrInjectCssVars,
ssrTransformElement,
ssrTransformComponent,
trackSlotScopes,
transformStyle,
...(options.nodeTransforms || []), // user transforms
],
directiveTransforms: {
// reusing core v-bind
bind: transformBind,
on: transformOn,
// model and show have dedicated SSR handling
model: ssrTransformModel,
show: ssrTransformShow,
// the following are ignored during SSR
cloak: noopDirectiveTransform,
once: noopDirectiveTransform,
memo: noopDirectiveTransform,
...(options.directiveTransforms || {}), // user transforms
},
})
逐个说明"为什么换":
nodeTransforms(node 级结构变换):
transformVBindShorthand:compiler-dom提供的:[key]简写归一化,原样保留。ssrTransformIf/ssrTransformFor:替换transformIf/transformFor。它们同样基于createStructuralDirectiveTransform+processIf/processFor(见ssrVIf.ts:19、ssrVFor.ts:18),因此第一趟生成的结构与客户端完全一致(IfNode/ForNode),差别只在第二趟的处理函数(ssrProcessIf/ssrProcessFor)。trackVForSlotScopes/transformExpression/trackSlotScopes:原样沿用的核心 transform。scope 追踪与标识符前缀化逻辑与客户端完全共享——这意味着<div v-for="i in list">{{ i }}</div>在 SSR 下同样能正确识别i是循环变量而非_ctx.i。ssrTransformSlotOutlet:替换transformSlotOutlet。第一趟构造ssrCodegenNode(一个_ssrRenderSlot(...)调用表达式),第二趟补全 fallback 与 scopeId。ssrInjectFallthroughAttrs/ssrInjectCssVars:SSR 独有。前者把_attrs(第 4 个形参)注入到单一根节点;后者把 SFC<style vars>的 CSS 变量注入到元素。ssrTransformElement/ssrTransformComponent:替换transformElement。这是本章篇幅最大的两个 transform,分别负责"元素 → 开标签字面量"和"组件 →ssrRenderComponent/ssrRenderVNode调用"。transformStyle:compiler-dom的 style 归一化,原样保留(但 SSR 下它把静态 style 也转成动态以进入 merge 逻辑)。
directiveTransforms(指令级):
bind→ 复用transformBind:v-bind产出的{key, value}属性对在 SSR 里同样可用,只是后续被序列化而非 patch。on→ 复用transformOn:注意,v-on的 transform 仍被注册,但buildProps(ssr=true)会跳过所有事件属性(transformElement.ts:593的if (isVOn && ssr) continue)。保留它只是为了组件上仍要传事件监听器(16.6)。model→ssrTransformModel:专用,把 v-model 编译成value/checked属性渲染(16.8)。show→ssrTransformShow:专用,把v-show编译成style: cond ? null : { display: 'none' }(16.8)。cloak/once/memo→noopDirectiveTransform:在 SSR 下全部变成空操作。v-once/v-memo是渲染缓存优化,SSR 一次性渲染无意义;v-cloak是客户端"未编译前隐藏"的标记,服务端不需要。
一个隐藏的顺序约束值得点出:ssrTransformElement 排在 transformStyle 之前,但 ssrTransformElement 返回的是"退出阶段的回调"(后序变换)。 这意味着元素的 props 在处理时,transformStyle 已经跑完并把 style 归一化了。这个"进入/退出阶段"(第 6 章)的时序是理解所有 return function xxxPostTransform...() 写法的前提。
16.1.5 「两趟 transform」:IR 复用 + SSR 重写
compile 的最后三步(index.ts:89-93)揭示了整个架构:
// packages/compiler-ssr/src/index.ts:89-93
// traverse the template AST and convert into SSR codegen AST
// by replacing ast.codegenNode.
ssrCodegenTransform(ast, options)
return generate(ast, options)
整个过程可以画成两张图的叠加:第一趟 transform(共享)把模板 AST 加工成带 codegenNode 的 IR;第二趟(ssrCodegenTransform)把根节点的 codegenNode 换成一棵全新的 BlockStatement。
flowchart TD
A["template 字符串"] --> B["baseParse (compiler-dom)"]
B --> C["模板 AST (RootNode)"]
C --> D["transform (SSR preset, 第一趟)"]
D --> D1["IfNode / ForNode / ElementNode.ssrCodegenNode / ComponentNode.ssrCodegenNode 等"]
D1 --> E["ssrCodegenTransform (第二趟)"]
E --> E1["遍历 AST,processChildren→pushStringPart/pushStatement"]
E1 --> E2["ast.codegenNode = createBlockStatement(context.body)"]
E2 --> F["generate (共享)
function/module preamble + 输出语句"]
F --> G["ssrRender 源码字符串"]
关键在第 89–91 行的注释——"by replacing ast.codegenNode"。第一趟产生的 IR 并没有被抛弃,而是被第二趟当作"素材"读取:ssrProcessElement 读 node.ssrCodegenNode.elements、ssrProcessComponent 读 node.ssrCodegenNode.arguments、ssrProcessSlotOutlet 读 node.ssrCodegenNode.arguments。这解释了为什么每个 SSR transform 都既要"建 ssrCodegenNode"(第一趟,退出阶段)又要"提供 ssrProcessXxx 函数"(第二趟)——它们是同一份数据的写者与读者。
16.1.6 包依赖关系与产物归属
ssrCodegenTransform 末尾(ssrCodegenTransform.ts:66-75)做了一件容易被忽略的收尾工作:
// packages/compiler-ssr/src/ssrCodegenTransform.ts:66-75
// Finalize helpers.
// We need to separate helpers imported from 'vue' vs. '@vue/server-renderer'
ast.ssrHelpers = Array.from(
new Set([
...Array.from(ast.helpers).filter(h => h in ssrHelpers),
...context.helpers,
]),
)
ast.helpers = new Set(Array.from(ast.helpers).filter(h => !(h in ssrHelpers)))
这两行把 helper 分成两拨:来自 vue 的(_createVNode、_mergeProps、_resolveComponent、_withCtx 等,进 ast.helpers)与来自 vue/server-renderer 的(_ssrInterpolate、_ssrRenderAttrs 等,进 ast.ssrHelpers)。codegen.ts:456-464 会为后者生成 const { ... } = require("vue/server-renderer"),codegen.ts:515-522 会为模块模式生成 import { ... } from "vue/server-renderer"。这解释了一个观察:为什么 SSR 产物总是有两行"导入"——它的运行期依赖横跨 vue 与 vue/server-renderer 两个包。
runtimeHelpers.ts:53-54 的注释与 registerRuntimeHelpers(ssrHelpers) 则是"跨包契约"的锚点:
// packages/compiler-ssr/src/runtimeHelpers.ts:53-54
// Note: these are helpers imported from @vue/server-renderer
// make sure the names match!
registerRuntimeHelpers(ssrHelpers)
它把符号(Symbol('ssrInterpolate'))与字符串名('ssrInterpolate')注册进全局映射,使 codegen 的 helper() 能把符号翻译成 _ssrInterpolate。符号到名字的映射一旦与 server-renderer/src/internal.ts 的导出名不一致,产物就会引用到运行时并不存在的函数。 这就是 internal.ts 把导出写成一长串显式 export { ... } 的原因。
16.2 SSR 编译产物形态:ssrRender 与 _push 的调用约定
16.2.1 ssrRender 的函数签名
先看一个最小的真实产物。对模板 foo {{ bar }} baz 调用 compile,得到(以下快照取自 packages/compiler-ssr/__tests__/ssrText.spec.ts:44-52,为 3.5.44 真实输出):
const { ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer")
return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(`foo ${_ssrInterpolate(_ctx.bar)} baz`)
}
对比客户端 render(第 7 章),签名差异一目了然:
客户端 render | 服务端 ssrRender | |
|---|---|---|
| 第 1 参 | _ctx(组件代理) | _ctx(同) |
| 第 2 参 | _cache(渲染缓存数组) | _push(输出回调) |
| 第 3 参 | 无 | _parent(父组件实例) |
| 第 4 参 | 无 | _attrs(透传属性对象) |
| 返回值 | vnode / vnode 树 | undefined(输出以副作用形式发生) |
| 副作用 | 无 | 往 _push 写字符串/子 buffer |
这四个形参不是随便定的。_push 存在是因为 SSR 的输出是"流式副作用"而非"返回值"——组件把内容"推"进父级提供的缓冲区,天然支持流式(边渲染边发送)。_parent 存在是因为服务端渲染组件时需要父实例来解析 appContext(全局组件、指令、provides)、以及计 scoped slotId。_attrs 存在是因为 Vue 的属性透传语义:父组件写在子组件标签上的、子组件未声明的 props 会落到根元素上。
server-renderer/src/render.ts:208-218 展示了这四个参数的真实来源:
// packages/server-renderer/src/render.ts:208-218
ssrRender(
instance.proxy, // _ctx
push, // _push
instance, // _parent
attrs, // _attrs
// compiler-optimized bindings
instance.props,
instance.setupState,
instance.data,
instance.ctx,
)
注意后四个参数(props/setupState/data/ctx)——它们是 inline: true 模式(<script setup> 无 with、无 _ctx 代理)时的额外绑定,编译器产物在 inline 模式下会用它们做标识符直接前缀(第 9 章)。默认(非 inline)模式下前四个才是主力。
16.2.2 _push 是什么:PushFn 与 SSRBuffer
_push 的类型在 server-renderer/src/render.ts:45-47 定义:
// packages/server-renderer/src/render.ts:45-47
export type SSRBuffer = SSRBufferItem[] & { hasAsync?: boolean }
export type SSRBufferItem = string | SSRBuffer | Promise<SSRBuffer>
export type PushFn = (item: SSRBufferItem) => void
一个 _push 接收的不是"最终字符串",而是 SSRBufferItem——它可以是字符串、可以是一个子 buffer(数组)、也可以是一个 Promise。createBuffer(render.ts:69-92)负责把连续的字符串就地拼接(appendable 快速路径),并在遇到子 buffer/Promise 时把 hasAsync 标到当前 buffer 上:
// packages/server-renderer/src/render.ts:77-90
push(item: SSRBufferItem): void {
const isStringItem = isString(item)
if (appendable && isStringItem) {
buffer[buffer.length - 1] += item as string
return
}
buffer.push(item)
appendable = isStringItem
if (isPromise(item) || (isArray(item) && item.hasAsync)) {
buffer.hasAsync = true
}
},
这解释了为什么编译产物里的 _push 调用既是"拼接"又是"分片":同一个组件内的连续字符串会被 createBuffer 合成一个元素(减少数组项),而每次调用 _ssrRenderComponent(会返回新的子 buffer)则必然引入一个数组项。
graph TD
R["根组件 push"] --> S1["'<div>hello'"]
S1 --> SUB["子 buffer: ssrRenderComponent 返回"]
SUB --> S2["'<span>world'"]
SUB --> S3["Promise (async setup)"]
R --> S4["'</div>'"]
note["hasAsync 决定 unrollBuffer 是否需要 await<br/>同步 buffer 走 unrollBufferSync 快路径"]
设计动机:如果 _push 只接收字符串,那么组件边界处就必须"先渲染完子树、拿到字符串、再 push",会退化成递归字符串拼接、无法流式。用 buffer 树 + hasAsync 位,renderToString 就能在"无异步"时走纯同步展开(renderToString.ts:56-68 的 unrollBufferSync),只在真正遇到异步 setup 或 Teleport 时才 await。这是服务和端性能账本上最划算的一笔。
16.2.3 helper 的导入:ssrHelpers 与 vue/server-renderer
SSR 产物的 helper 分两类,其导入形式由 codegen 决定。function 模式下(codegen.ts:456-464):
const { ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer")
const { resolveComponent: _resolveComponent, mergeProps: _mergeProps } = require("vue")
模块模式下(codegen.ts:515-522):
import { ssrRenderAttrs as _ssrRenderAttrs, ssrInterpolate as _ssrInterpolate } from "vue/server-renderer"
import { resolveComponent as _resolveComponent } from "vue"
注意两点:
- helper 的字符串名由
runtimeHelpers.ts的ssrHelpers决定(16.4 全表),符号SSR_INTERPOLATE会被codegen翻译成_ssrInterpolate。 ssrRuntimeModuleName默认是'vue/server-renderer'(codegen.ts:152),可以在CompilerOptions.ssrRuntimeModuleName里覆盖(options.ts:337-340)——这是给自定义构建/子路径导出的扩展点。
server-renderer/src/internal.ts 是"名字对应"的另一端,它把 helper 的运行期实现集中导出:
// packages/server-renderer/src/internal.ts:2-17(节选)
export { renderVNode as ssrRenderVNode } from './render'
export { ssrRenderComponent } from './helpers/ssrRenderComponent'
export { ssrRenderSlot, ssrRenderSlotInner } from './helpers/ssrRenderSlot'
export { ssrRenderTeleport } from './helpers/ssrRenderTeleport'
export { ssrRenderClass, ssrRenderStyle, ssrRenderAttrs, ssrRenderAttr, ssrRenderDynamicAttr } from './helpers/ssrRenderAttrs'
export { ssrInterpolate } from './helpers/ssrInterpolate'
export { ssrRenderList } from './helpers/ssrRenderList'
export { ssrRenderSuspense } from './helpers/ssrRenderSuspense'
export { ssrGetDirectiveProps } from './helpers/ssrGetDirectiveProps'
export { includeBooleanAttr as ssrIncludeBooleanAttr } from '@vue/shared'
这张表与 runtimeHelpers.ts 的 ssrHelpers 必须严格一一对应,否则产物就会引用不存在的函数。这是编译器与运行时之间最脆弱、也最核心的一份"接口契约"。
16.2.4 客户端 render 产物的对照
同一个模板 foo {{ bar }} baz 在客户端(compiler-dom,模块模式)会产出:
import { toDisplayString as _toDisplayString, createTextVNode as _createTextVNode } from "vue"
export function render(_ctx, _cache) {
return _createTextVNode(_toDisplayString(_ctx.bar))
}
差异不只在拼接与 vnode:
- SSR 把周围静态文本 "foo "、" baz" 直接写进模板字面量,而客户端
transformText会把它们与插值合成一个_createTextVNode(甚至进一步toDisplayString(a) + " foo ")。SSR 不启用transformText(见 16.3.6),所以文本与插值是分开的片段。 - 客户端产物的核心是
createVNode/createTextVNode的嵌套调用,SSR 产物的核心是_push+ 模板字面量。 - 客户端需要在运行时对 vnode 做 diff,所以保留结构化信息(type、props、children);SSR 不需要 diff,结构化信息全部在编译期"拍平"为字符串。
16.2.5 「字符串拼接」为何比 vnode 更适合 SSR
这是本章最该讲清的设计动机。客户端之所以用 vnode,是因为它需要一次"编译产物 → 可 diff 的中间表示":DOM 已经存在,后续更新必须能回答"哪些节点变了"。SSR 完全不需要这个过程——它只要最终 HTML 字符串,且每个请求只算一次。如果 SSR 也走 vnode 路径(实际上 Vue 提供了 renderVNode 这条回退路,见 16.6),则服务端要为每个节点创建 vnode 对象、递归 patch、再序列化——纯属浪费。
字符串拼接的三个直接收益:
- 零中间对象。
_push('<div>hello')直接进 buffer,不创建 vnode,不分配 children 数组。 - 把"合并"下推到编译期。相邻静态内容在产物里就是一个字面量里的连续字符,运行期不需要"合并子 vnode children"。
- 流式友好。字符串可以边产生边 flush 给 HTTP 响应;vnode 树必须整棵建完才能序列化。
代价也很明确:产物对"值"的每次变化都重建。 但 SSR 每个请求就渲染一次,这个代价天然被摊掉。这就是"为什么第 7 章的静态提升在 SSR 被关掉"的同一个道理——优化的目标函数不同,最优解不同。
16.2.6 产物的可读性:格式化与 source map
真实产物在 __tests__ 快照里是可读的(${...} 换行缩进),但落到 Function() 执行时格式无关。server-renderer/src/helpers/ssrCompile.ts:86-92 用 Function('require', code)(fakeRequire) 把产物字符串编译成函数,其中 fakeRequire 只在 'vue' 与 'vue/server-renderer' 两个 id 上返回模块:
// packages/server-renderer/src/helpers/ssrCompile.ts:86-92
const { code } = compile(template, finalCompilerOptions)
const requireMap = {
vue: Vue,
'vue/server-renderer': helpers,
}
const fakeRequire = (id: 'vue' | 'vue/server-renderer') => requireMap[id]
return (compileCache[cacheKey] = Function('require', code)(fakeRequire))
这段"运行时即时编译"只存在于 CJS 构建(ssrCompile.ts:27 的 if (!__CJS__) throw)——ESM 构建不支持 on-the-fly 编译,必须预编译。这也解释了为什么 ssrCompile 里有一个 compileCache:同一模板字符串 + 同一选项的产物被缓存复用。
16.3 ssrCodegenTransform 逐段走读:把 IR 翻译成 push 语句
16.3.1 为什么必须存在"第二趟"
第一趟 transform 结束后,根节点上挂着的是一个 codegenNode——通常是 VNodeCall(CREATE_VNODE 调用),children 里嵌套着 IfNode/ForNode/SlotOutletNode/CompoundExpression 等。这是一棵"客户端优先"的 IR。 如果直接交给 generate,产物必然是一堆 createVNode(...)——那不是 SSR 想要的。
ssrCodegenTransform.ts:32-36 的注释点明了动机:
// packages/compiler-ssr/src/ssrCodegenTransform.ts:32-36
// Because SSR codegen output is completely different from client-side output
// (e.g. multiple elements can be concatenated into a single template literal
// instead of each getting a corresponding call), we need to apply an extra
// transform pass to convert the template AST into a fresh JS AST before
// passing it to codegen.
注意关键词组:multiple elements can be concatenated into a single template literal instead of each getting a corresponding call. 客户端 IR 的粒度是"一个元素 = 一个 createVNode 调用";SSR 的粒度是"一串连续内容 = 一个模板字面量"。从"每节点一调用"到"连续片段一拼接",粒度发生了变化,这不是 generate 层面加分支能解决的,必须在 AST 层面重写。
第二趟的产物是一棵全新的 JS AST:根是一个 BlockStatement,body 里是 CallExpression(_push(...))与 IfStatement 的混合。它替代了 ast.codegenNode,然后共享的 generate 把这棵 JS AST 打印成源码。
16.3.2 SSRTransformContext 数据结构
第二趟的状态载体是 SSRTransformContext(ssrCodegenTransform.ts:78-88):
// packages/compiler-ssr/src/ssrCodegenTransform.ts:78-88
export interface SSRTransformContext {
root: RootNode
options: CompilerOptions
body: (JSChildNode | IfStatement)[]
helpers: Set<symbol>
withSlotScopeId: boolean
onError: (error: CompilerError) => void
helper<T extends symbol>(name: T): T
pushStringPart(part: TemplateLiteral['elements'][0]): void
pushStatement(statement: IfStatement | CallExpression): void
}
逐个字段:
body:输出的语句列表。注意它在 context 创建时是空的、跨 context 不共享(createSSRTransformContext里const body = [],ssrCodegenTransform.ts:96)。而helpers却是"可继承"的(下文 16.3.5)。helpers: Set<symbol>:本 context 收集到的 helper 符号集合。它会在父子 context 间共享同一个 Set 引用,因此子 context 调用的 helper 会自动出现在根集合里。withSlotScopeId:当前是否处于某个被ssrRenderSlot调用的 slot 函数体中。置真后,元素会额外 push_scopeId、ssrRenderSlot会接收 scopeId 参数——这是 scoped slot 的data-v-xxx能正确落到 slot 内容上的机制。helper(name):把符号加进helpers并原样返回符号。它既是"声明"又是"取用"——调用context.helper(SSR_INTERPOLATE)的同时就登记了依赖。pushStringPart/pushStatement:核心输出 API,见下。
createSSRTransformContext(ssrCodegenTransform.ts:90-135)的闭包状态里还有一个极其重要的隐藏变量:
// packages/compiler-ssr/src/ssrCodegenTransform.ts:96-97
const body: BlockStatement['body'] = []
let currentString: TemplateLiteral | null = null
currentString 是"当前正在累积的模板字面量",它不属于公开接口、只服务于 pushStringPart 的内部合并逻辑。
16.3.3 pushStringPart 与 pushStatement:合并与切断
这两个函数是整章的心脏,它们的实现短到可以背下来:
// packages/compiler-ssr/src/ssrCodegenTransform.ts:114-133
pushStringPart(part) {
if (!currentString) {
const currentCall = createCallExpression(`_push`)
body.push(currentCall)
currentString = createTemplateLiteral([])
currentCall.arguments.push(currentString)
}
const bufferedElements = currentString.elements
const lastItem = bufferedElements[bufferedElements.length - 1]
if (isString(part) && isString(lastItem)) {
bufferedElements[bufferedElements.length - 1] += part
} else {
bufferedElements.push(part)
}
},
pushStatement(statement) {
// close current string
currentString = null
body.push(statement)
},
pushStringPart(part) 的三条规则:
- 懒创建:若
currentString为空,新建一个_push(...)调用表达式 push 进 body,并创建空的TemplateLiteral作为它的第一个实参。每次"全新一段字符串"都会对应一个新的_push调用。 - 相邻字符串合并:若
part是字符串、且当前字面量最后一项也是字符串,则直接字符串拼接(+=)——例如<div>的<与标签名、或开标签与静态文本。这保证了_push(\hello`)里不会出现'' + 'hello'` 这种运行期相加的残骸。 - 非字符串则作为字面量的
${...}项插入:CallExpression(如_ssrInterpolate(...))作为 expression 元素,出现在产物的${ ... }里。
pushStatement(statement) 只有一条规则:
- 先切断
currentString(置 null),再把语句追加进 body。 这个"切断"动作是产物形态的开关:一旦遇到if、for、ssrRenderComponent、ssrRenderSlot这类"会打断字符串连续性"的结构,当前累积的模板字面量就被"封口",下一段字符串必须从新的_push开始。
用一句话概括:pushStringPart 是"可合并的软内容",pushStatement 是"必须切断的硬边界"。
看一个真实对照。模板 <div v-if="foo"></div> 的产物(ssrVIf.spec.ts:5-15):
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(`<div${_ssrRenderAttrs(_attrs)}></div>`)
} else {
_push(`<!---->`)
}
}
ssrProcessIf 先 context.pushStatement(ifStatement)(切断),然后在每个 branch 的子 context 里 processChildren——子 context 各自累积各自的 currentString,于是两个 branch 各自产生一个独立的 _push。
再看 v-for(ssrVFor.spec.ts:5-15):
return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(`<!--[-->`)
_ssrRenderList(_ctx.list, (i) => {
_push(`<div></div>`)
})
_push(`<!--]-->`)
}
ssrProcessFor 先 push 字符串 <!--[-->,再 pushStatement(renderList 调用),再 push 字符串 <!--]-->。三次"字符串 → 切断 → 字符串",正好对应三段独立的 _push。
16.3.4 processChildren:按节点类型分派
processChildren(ssrCodegenTransform.ts:154-240)是第二趟的总调度器。签名有个 Container 接口(只有 children: TemplateChildNode[]),所以它既能处理 Root,也能处理 Element、IfBranch、slot 等任何有 children 的容器:
// packages/compiler-ssr/src/ssrCodegenTransform.ts:150-160
interface Container {
children: TemplateChildNode[]
}
export function processChildren(
parent: Container,
context: SSRTransformContext,
asFragment = false,
disableNestedFragments = false,
disableComment = false,
): void {
if (asFragment) {
context.pushStringPart(`<!--[-->`)
}
...
四个参数:
asFragment:是否给这组 children 包上<!--[-->...<!--]-->的 fragment 注释包裹。SSR 用这两个注释在 HTML 里"标记"一个 Fragment 边界——这既是给 hydration(第 15 章)用的对齐锚点,也是unrollBuffer里hasAsync结构的标记。disableNestedFragments:给内层容器用。TransitionGroup 会把子内容摊平成单层 fragment,因此要禁止内层再包 fragment(16.6)。disableComment:抑制v-if为假时补的<!---->注释(TransitionGroup 运行时过滤注释,hydration 时不能出现,见ssrTransformTransitionGroup.ts:90-96)。
分派表(ssrCodegenTransform.ts:165-235)按 child.type 展开,再按 tagType 细分:
| 节点 | 处理 | 产出 |
|---|---|---|
ELEMENT / ElementTypes.ELEMENT | ssrProcessElement | 开标签字面量 + children + 闭标签 |
ELEMENT / COMPONENT | ssrProcessComponent | _push(_ssrRenderComponent(...)) 或 _ssrRenderVNode(...) |
ELEMENT / SLOT | ssrProcessSlotOutlet | _ssrRenderSlot(...) 语句 |
ELEMENT / TEMPLATE | 空(// TODO) | template 本身不产出,由结构指令处理 |
TEXT | pushStringPart(escapeHtml(content)) | 转义后拼进字符串 |
COMMENT | pushStringPart('<!--...-->')(除非 disableComment) | 原样拼进字符串 |
INTERPOLATION | pushStringPart(_ssrInterpolate(content)) | 模板字面量里的 ${...} |
IF | ssrProcessIf | if/else if/else 语句 |
FOR | ssrProcessFor | _ssrRenderList(...) 语句 |
IF_BRANCH | 空(由 ssrProcessIf 处理) | — |
TEXT_CALL / COMPOUND_EXPRESSION | 空 | 不会出现,因为 SSR 不用 transformText |
| 其它 | onError(X_SSR_INVALID_AST_NODE) + 穷尽性检查 | 报错 |
最后那个 const exhaustiveCheck: never = child; return exhaustiveCheck(ssrCodegenTransform.ts:190、233)是穷尽性检查的编译期保证:如果 TemplateChildNode 将来新增了类型而这里没处理,TypeScript 会报错。这是一种把"漏处理"从运行期 bug 提前到编译期错误的防御性写法,值得在自己的编译器里模仿。
16.3.5 processChildrenAsStatement 与 createChildContext
processChildrenAsStatement(ssrCodegenTransform.ts:242-251)是"开子 context、处理、把 body 收成一个 BlockStatement"的便捷封装:
// packages/compiler-ssr/src/ssrCodegenTransform.ts:242-251
export function processChildrenAsStatement(
parent: Container,
parentContext: SSRTransformContext,
asFragment = false,
withSlotScopeId: boolean = parentContext.withSlotScopeId,
): BlockStatement {
const childContext = createChildContext(parentContext, withSlotScopeId)
processChildren(parent, childContext, asFragment)
return createBlockStatement(childContext.body)
}
而 createChildContext(ssrCodegenTransform.ts:137-148)的关键与 body 恰好相反——它把 parent.helpers 这个 Set 的同一个引用传给子 context:
// packages/compiler-ssr/src/ssrCodegenTransform.ts:141-147
return createSSRTransformContext(
parent.root,
parent.options,
parent.helpers, // 同一个 Set 引用,子→父自动汇合
withSlotScopeId,
)
这是很有意的一条设计:body 隔离(每个控制流分支有自己的语句列表),helpers 共享(任意深度的子 context 用到的 helper 都会汇总到根)。 它让 ssrCodegenTransform 末尾只需读一次 context.helpers 就能收集全部 helper,无需递归回传。
16.3.6 processText / 插值:SSR 不用 transformText
processChildren 对 TEXT 的处理是 pushStringPart(escapeHtml(child.content))(ssrCodegenTransform.ts:195)。注意 escapeHtml 在编译期就调用了——静态文本的 HTML 实体在产物里就是已转义的字面量,运行期不再重复转义(对比 _ssrInterpolate 必须在运行期对动态值转义)。
对 INTERPOLATION 的处理(ssrCodegenTransform.ts:204-209):
case NodeTypes.INTERPOLATION:
context.pushStringPart(
createCallExpression(context.helper(SSR_INTERPOLATE), [
child.content,
]),
)
break
它把插值内容包进 _ssrInterpolate(...),作为字符串字面量的 ${...} 项。
为什么 SSR 下 TEXT_CALL/COMPOUND_EXPRESSION 是 no-op? 因为这两个类型只在 transformText 存在时才会作为模板子节点出现(compiler-dom 的 transformText 会把相邻的 文本+插值 合并成一个 TEXT_CALL 节点)。而 SSR preset 里没有 transformText(对比 compile.ts:55 的客户端 preset 末尾有 transformText)。ssrCodegenTransform.ts:220-224 的注释说得直白:
// these two types can never appear as template child node since
// `transformText` is not used during SSR compile.
这就是为什么同样是 {{ a }} b,客户端产物会把它们合成一个 toDisplayString(a) + " b",而 SSR 产物是 ${_ssrInterpolate(a)} b 两段。SSR 不需要 transformText,因为"把相邻内容拼成一段字符串"在 SSR 里本来就是 pushStringPart 的天职——它在输出阶段天然完成了这件事,不需要额外的 AST 合并节点。
16.3.7 ssrProcessIf:把 v-if 变成 if/else 语句
ssrProcessIf(ssrVIf.ts:26-64)把 IfNode 的每个 branch 翻译成一个 IfStatement:
// packages/compiler-ssr/src/transforms/ssrVIf.ts:32-63(节选)
const [rootBranch] = node.branches
const ifStatement = createIfStatement(
rootBranch.condition!,
processIfBranch(rootBranch, context, disableNestedFragments),
)
context.pushStatement(ifStatement)
let currentIf = ifStatement
for (let i = 1; i < node.branches.length; i++) {
...
if (branch.condition) {
// else-if
currentIf = currentIf.alternate = createIfStatement(branch.condition, branchBlockStatement)
} else {
// else
currentIf.alternate = branchBlockStatement
}
}
if (!currentIf.alternate && !disableComment) {
currentIf.alternate = createBlockStatement([
createCallExpression(`_push`, ['`<!---->`']),
])
}
三个要点:
- 第一个 branch 是
if,其后有 condition 的 branch 是else if(挂成上一个的alternate),无 condition 的是else。 这利用了IfStatement.alternate的链式结构。 - **若最终没有
else且未禁用注释,补一个else { _push(\<!---->`) }**。**这个<!---->是 SSR 的关键约定:v-if 为假时不能"什么都不输出",否则 hydration 时客户端找不到节点来对齐。** 这与客户端createCommentVNode('v-if')` 是同一目的、不同实现。 processIfBranch(ssrVIf.ts:66-77)决定是否包 fragment:若 branch 只有一个元素子节点,不包;若是单个ForNode,也不包(优化掉嵌套 fragment);否则包<!--[-->...<!--]-->。这条规则直接决定了产物里 fragment 注释的出现位置。
16.3.8 ssrProcessFor:把 v-for 变成 ssrRenderList 调用
ssrProcessFor(ssrVFor.ts:23-52)构造一个循环函数,并交给 _ssrRenderList:
// packages/compiler-ssr/src/transforms/ssrVFor.ts:31-48
const renderLoop = createFunctionExpression(
createForLoopParams(node.parseResult),
)
renderLoop.body = processChildrenAsStatement(node, context, needFragmentWrapper)
// v-for always renders a fragment unless explicitly disabled
if (!disableNestedFragments) {
context.pushStringPart(`<!--[-->`)
}
context.pushStatement(
createCallExpression(context.helper(SSR_RENDER_LIST), [
node.source,
renderLoop,
]),
)
if (!disableNestedFragments) {
context.pushStringPart(`<!--]-->`)
}
要点:
- 循环变量通过
createForLoopParams提取,保证(item, index)形参与模板一致。 _ssrRenderList(source, renderLoop)是"运行期遍历"——因为source可能是数组、字符串、数字范围、对象、迭代器(见 16.4.4)。- fragment 包裹
<!--[-->/<!--]-->默认总是加,即使循环体只有单个元素——这与 v-if 的"单元素不包"规则不同。原因:v-for 的每个迭代都是一个独立片段,在 DOM 里无法用一个元素表示多次重复,必须用注释界定整组。needFragmentWrapper只影响循环体内部是否再包一层。
16.3.9 ssrCssVars 的注入
ssrCodegenTransform 开头有一段"特事特办"(ssrCodegenTransform.ts:47-59),处理 SFC <style vars>:
// packages/compiler-ssr/src/ssrCodegenTransform.ts:47-59
if (options.ssrCssVars) {
const cssContext = createTransformContext(createRoot([]), options)
const varsExp = processExpression(
createSimpleExpression(options.ssrCssVars, false),
cssContext,
)
context.body.push(
createCompoundExpression([`const _cssVars = { style: `, varsExp, `}`]),
)
Array.from(cssContext.helpers.keys()).forEach(helper => {
ast.helpers.add(helper)
})
}
它的产物就是 ssrInjectCssVars.spec.ts:14-16 里那行:
const _cssVars = { style: { color: _ctx.color }}
_push(`<div${_ssrRenderAttrs(_mergeProps(_attrs, _cssVars))}></div>`)
注意两点设计:其一,_cssVars 只在渲染函数开头算一次(而非内联到每个元素),避免同一变量的表达式被重复求值;其二,ssrInjectCssVars(第一趟)只是往元素上注入一个 v-bind="_cssVars"(ssrInjectCssVars.ts:58-66),真正的 const _cssVars = ... 声明由第二趟塞进 body 顶部。两趟分工在这里体现得最清楚:第一趟声明"这个元素要吃 _cssVars",第二趟负责"声明 _cssVars 是什么"。
16.3.10 收尾:替换 codegenNode 并分离 helper
ssrCodegenTransform.ts:61-76 是收尾:
// packages/compiler-ssr/src/ssrCodegenTransform.ts:61-75
const isFragment =
ast.children.length > 1 && ast.children.some(c => !isText(c))
processChildren(ast, context, isFragment)
ast.codegenNode = createBlockStatement(context.body)
ast.ssrHelpers = Array.from(new Set([
...Array.from(ast.helpers).filter(h => h in ssrHelpers),
...context.helpers,
]))
ast.helpers = new Set(Array.from(ast.helpers).filter(h => !(h in ssrHelpers)))
三点:
- 根是否是 fragment 由"子节点多于一个且至少一个非文本"决定,多根模板因此被
<!--[-->...<!--]-->包裹(对应ssrInjectCssVars.spec.ts:31-35的多根产物)。 ast.codegenNode = createBlockStatement(context.body)—— 用一棵全新的语句块替换掉第一趟留下的VNodeCall。从这一刻起,generate看到的不再是 vnode IR,而是纯 JS 语句。- helper 分离:
ast.ssrHelpers收集所有在ssrHelpers表里注册过的符号(包括第一趟组件分支里可能直接产生并塞进ast.helpers的),ast.helpers只剩下vue包的 helper。这就是"为什么有的产物在vue里导入、有的在vue/server-renderer里导入"的最终裁决处。
16.3.11 判据总结:什么时候 push 字符串、什么时候用 helper
把本节所有例子归纳成一张判据表:
| 内容 | 编译期可定结构? | 处理 | 运行期产出 |
|---|---|---|---|
| 静态文本 / 注释 | 是 | pushStringPart(字符串) | 直接进模板字面量 |
| 静态属性 | 是 | pushStringPart(字符串) | 直接进开标签字面量 |
| 插值 | 结构定、值不定 | pushStringPart(_ssrInterpolate(exp)) | 字面量里的 ${...} |
| 动态 class/style(无 v-bind) | 结构定、值不定 | pushStringPart(_ssrRenderClass/Style(exp)) | 字面量里的 ${...} |
| 动态属性(静态键,安全名) | 结构定、值不定 | pushStringPart(_ssrRenderAttr(key,val)) | 字面量里的 ${...} |
| 动态属性(动态键/需整对象) | 否 | pushStringPart(_ssrRenderAttrs(obj)) | 字面量里的 ${...} |
| v-if / v-else | 否(控制流) | pushStatement(ifStatement) | if/else 语句 |
| v-for | 否(控制流) | pushStatement(ssrRenderList(...)) | 循环语句 |
| 组件 | 否(运行时解析) | pushStatement(_ssrRenderComponent(...)) | 语句 |
| 动态组件 | 否 | pushStatement(_ssrRenderVNode(...)) | 语句 |
<slot> | 否 | pushStatement(_ssrRenderSlot(...)) | 语句 |
<teleport> | 否 | pushStatement(_ssrRenderTeleport(...)) | 语句 |
<Suspense> | 否 | pushStatement(_ssrRenderSuspense(...)) | 语句 |
判据的本质:"结构(标签序列、分支、迭代次数、组件身份)能否在编译期完全确定"。 能确定 → 拼字符串;不能确定 → 要么进 ${...} 表达式(值级不确定),要么产生语句(结构级不确定)。
16.4 runtimeHelpers 映射表:符号 → 名字 → 运行期实现
16.4.1 符号表全文
runtimeHelpers.ts:3-51 定义了 19 个符号与名字的映射。它们全部注册进 compiler-core 的全局 helper 表,使 codegen 能把符号翻译成 _xxx。完整对照(编译期符号名 = 运行期导出名):
| 符号(编译期) | 运行期名 | 运行期实现位置 |
|---|---|---|
SSR_INTERPOLATE | ssrInterpolate | helpers/ssrInterpolate.ts:3 |
SSR_RENDER_VNODE | ssrRenderVNode | render.ts:241(renderVNode 的别名) |
SSR_RENDER_COMPONENT | ssrRenderComponent | helpers/ssrRenderComponent.ts:10 |
SSR_RENDER_SLOT | ssrRenderSlot | helpers/ssrRenderSlot.ts:24 |
SSR_RENDER_SLOT_INNER | ssrRenderSlotInner | helpers/ssrRenderSlot.ts:48 |
SSR_RENDER_CLASS | ssrRenderClass | helpers/ssrRenderAttrs.ts:100 |
SSR_RENDER_STYLE | ssrRenderStyle | helpers/ssrRenderAttrs.ts:104 |
SSR_RENDER_ATTRS | ssrRenderAttrs | helpers/ssrRenderAttrs.ts:27 |
SSR_RENDER_ATTR | ssrRenderAttr | helpers/ssrRenderAttrs.ts:93 |
SSR_RENDER_DYNAMIC_ATTR | ssrRenderDynamicAttr | helpers/ssrRenderAttrs.ts:63 |
SSR_RENDER_LIST | ssrRenderList | helpers/ssrRenderList.ts:4 |
SSR_INCLUDE_BOOLEAN_ATTR | ssrIncludeBooleanAttr | @vue/shared 的 includeBooleanAttr |
SSR_LOOSE_EQUAL | ssrLooseEqual | helpers/ssrVModelHelpers.ts:4 |
SSR_LOOSE_CONTAIN | ssrLooseContain | helpers/ssrVModelHelpers.ts:6 |
SSR_RENDER_DYNAMIC_MODEL | ssrRenderDynamicModel | helpers/ssrVModelHelpers.ts:11 |
SSR_GET_DYNAMIC_MODEL_PROPS | ssrGetDynamicModelProps | helpers/ssrVModelHelpers.ts:30 |
SSR_RENDER_TELEPORT | ssrRenderTeleport | helpers/ssrRenderTeleport.ts:10 |
SSR_RENDER_SUSPENSE | ssrRenderSuspense | helpers/ssrRenderSuspense.ts:6 |
SSR_GET_DIRECTIVE_PROPS | ssrGetDirectiveProps | helpers/ssrGetDirectiveProps.ts:7 |
16.4.2 ssrInterpolate:唯一被塞进 ${...} 的默认 helper
// packages/server-renderer/src/helpers/ssrInterpolate.ts:1-5
import { escapeHtml, toDisplayString } from '@vue/shared'
export function ssrInterpolate(value: unknown): string {
return escapeHtml(toDisplayString(value))
}
语义:toDisplayString(客户端文本渲染也用它,第 7 章)负责把任意值转成"人可读字符串"(null/undefined → ''、对象 → JSON、数组 → 拼接等),escapeHtml 负责 HTML 转义。编译期对应:每次 INTERPOLATION 都产生一个 _ssrInterpolate 调用。与客户端对比:客户端 _toDisplayString 不转义(因为 vnode 的文本节点由 DOM API 处理),SSR 必须转义(因为直出 HTML)。
16.4.3 ssrRenderAttrs / Attr / DynamicAttr / Class / Style
ssrRenderAttrs(ssrRenderAttrs.ts:27-60)是"整体序列化属性对象"的入口:
// packages/server-renderer/src/helpers/ssrRenderAttrs.ts:27-60(节选)
export function ssrRenderAttrs(props: Record<string, unknown>, tag?: string): string {
let ret = ''
for (let key in props) {
if (
shouldIgnoreProp(key) ||
isOn(key) ||
(tag === 'textarea' && key === 'value') ||
key.startsWith('.')
) {
continue
}
const value = props[key]
if (key.startsWith('^')) key = key.slice(1)
if (key === 'class') {
ret += ` class="${ssrRenderClass(value)}"`
} else if (key === 'style') {
ret += ` style="${ssrRenderStyle(value)}"`
} else if (key === 'className') {
if (value != null) ret += ` class="${escapeHtml(String(value))}"`
} else {
ret += ssrRenderDynamicAttr(key, value, tag)
}
}
return ret
}
它一次性过滤并序列化整个对象,几个细节值得记住:
shouldIgnoreProp(ssrRenderAttrs.ts:23-25)是,key,ref,innerHTML,textContent,ref_key,ref_for的makeMap——key/ref是 vnode 语义、innerHTML/textContent是运行时 DOM 属性,都不该出现在 HTML 里。isOn(key)跳过事件(onClick、onUpdate:modelValue等)——服务端无事件。tag === 'textarea' && key === 'value'跳过:<textarea>的 value 要作为子文本渲染,不是属性(见 16.5.6)。key.startsWith('.')跳过:.xxx是"强制作为 property"的标记(第 8 章),SSR 不渲染 property。key.startsWith('^')则剥掉^前缀渲染——^是"强制作为 attribute"的标记。
ssrRenderAttr(key, value)(ssrRenderAttrs.ts:93-98)是静态键的快速路径:键已在编译期确定,只需转义值:
// packages/server-renderer/src/helpers/ssrRenderAttrs.ts:93-98
export function ssrRenderAttr(key: string, value: unknown): string {
if (!isRenderableAttrValue(value)) return ``
return ` ${key}="${escapeHtml(value)}"`
}
ssrRenderDynamicAttr(key, value, tag)(ssrRenderAttrs.ts:63-89)是动态键/未知键路径:需要在运行期判断布尔属性、检查属性名安全性、决定是否驼峰转短横线:
// packages/server-renderer/src/helpers/ssrRenderAttrs.ts:71-88(节选)
const attrKey =
tag && (tag.indexOf('-') > 0 || isSVGTag(tag))
? key // preserve raw name on custom elements and svg
: propsToAttrMap[key] || key.toLowerCase()
if (
isBooleanAttr(attrKey) ||
(attrKey === 'hidden' && (typeof value === 'boolean' || typeof value === 'number'))
) {
return includeBooleanAttr(value) ? ` ${attrKey}` : ``
} else if (isSSRSafeAttrName(attrKey)) {
return value === '' ? ` ${attrKey}` : ` ${attrKey}="${escapeHtml(value)}"`
} else {
console.warn(`[@vue/server-renderer] Skipped rendering unsafe attribute name: ${attrKey}`)
return ``
}
ssrRenderClass / ssrRenderStyle(ssrRenderAttrs.ts:100-113):分别用 normalizeClass/normalizeStyle + stringifyStyle 归一化后转义。编译期对应:静态键的 class/style 绑定(16.5)。
| 编译期 helper | 编译期何时用 | 运行期做什么 |
|---|---|---|
_ssrRenderAttrs(obj, tag?) | 有 v-bind="obj"、动态键、自定义指令 | 遍历对象、过滤 key/ref/事件、序列化 |
_ssrRenderAttr("id", val) | 静态键、安全属性名、非 class/style/布尔 | id="escaped" |
_ssrRenderDynamicAttr(key, val, tag?) | 静态键但为 hidden、或动态键 | 运行期判定布尔属性/安全名 |
_ssrRenderClass(val) | 静态键 class 且非合并场景 | escapeHtml(normalizeClass(val)) |
_ssrRenderStyle(val) | 静态键 style 且非合并场景 | escapeHtml(stringifyStyle(normalizeStyle(val))) |
16.4.4 ssrRenderList:五种可迭代源的统一
// packages/server-renderer/src/helpers/ssrRenderList.ts:4-37(节选)
export function ssrRenderList(source: unknown, renderItem) {
if (isArray(source) || isString(source)) {
for (let i = 0, l = source.length; i < l; i++) renderItem(source[i], i)
} else if (typeof source === 'number') {
if (!Number.isInteger(source) || source < 0) { /* warn */ return }
for (let i = 0; i < source; i++) renderItem(i + 1, i)
} else if (isObject(source)) {
if (source[Symbol.iterator]) {
let i = 0
for (const item of source) renderItem(item, i++)
} else {
const keys = Object.keys(source)
for (let i = 0, l = keys.length; i < l; i++) renderItem(source[keys[i]], keys[i], i)
}
}
}
它覆盖:数组、字符串、数字范围(1-based:renderItem(i+1, i))、可迭代对象(Map/Set/生成器)、普通对象(按 Object.keys,回调收 (值, 键, 序号))。 这与客户端 renderList(第 12 章)的语义保持一致——编译器只把遍历逻辑委托给 helper,五种源的分派发生在运行期。
16.4.5 ssrRenderComponent / ssrRenderVNode
// packages/server-renderer/src/helpers/ssrRenderComponent.ts:10-22
export function ssrRenderComponent(comp, props = null, children = null, parentComponent = null, slotScopeId?) {
return renderComponentVNode(
createVNode(comp, props, children),
parentComponent,
slotScopeId,
)
}
ssrRenderComponent 内部还是创建了一个 vnode——因为服务端渲染组件需要一个"可递归的组件描述",renderComponentVNode(render.ts:94)会为它创建组件实例、跑 setup、再递归渲染子树。这是"字符串拼接"与"组件递归"两套机制的接缝:组件边界必须是 helper(因为它要在运行期决定身份、跑生命周期),组件内部则尽量拼字符串。
ssrRenderVNode(render.ts:241 的 renderVNode)用于动态组件:<component :is="x"> 用 resolveDynamicComponent 的结果可能解析成一个字符串(原生标签)或一个组件,因此必须走完整的 vnode 渲染路径(ssrTransformComponent.ts:179-194 生成的 _ssrRenderVNode(_push, _createVNode(...), _parent))。
16.4.6 ssrRenderSlot / ssrRenderSlotInner
// packages/server-renderer/src/helpers/ssrRenderSlot.ts:24-46(节选)
export function ssrRenderSlot(slots, slotName, slotProps, fallbackRenderFn, push, parentComponent, slotScopeId?) {
push(`<!--[-->`)
ssrRenderSlotInner(slots, slotName, slotProps, fallbackRenderFn, push, parentComponent, slotScopeId)
push(`<!--]-->`)
}
ssrRenderSlot 用 <!--[-->...<!--]--> 包裹 slot 内容("template-compiled slots are always rendered as fragments")。ssrRenderSlotInner(ssrRenderSlot.ts:48-136)干三件事:
- 调用 slot 函数——若它是 SSR 编译产物(形参含
_push),会往一个临时slotBuffer里 push;若它是 vnode 分支(返回数组),则走renderVNodeChildren。 - 空内容用 fallback 兜底:SSR 分支下若
slotBuffer里全是注释,则视为空、调fallbackRenderFn(ssrRenderSlot.ts:90-104)。 - Transition 场景避免二次 fragment:
#9933的处理(ssrRenderSlot.ts:106-128)——若首尾恰好是<!--[-->/<!--]-->且当前是 transition 上下文,则剥掉它们再逐项 push。这与SSR_RENDER_SLOT_INNER的选择(16.7)配套。
16.4.7 ssrRenderSuspense
// packages/server-renderer/src/helpers/ssrRenderSuspense.ts:6-12
export function ssrRenderSuspense(push, { default: renderContent }) {
if (renderContent) {
renderContent()
} else {
push(`<!---->`)
}
}
注意签名是 ssrRenderSuspense(_push, slots) 而非 _push(ssrRenderSuspense(...))(ssrRenderSuspense.ts:3-5 的注释明确说明)。这个约定极其重要:Suspense 必须同步——若把它的返回值包进 _push,任何返回的 Promise 都会被静默丢弃。所以编译器对 Suspense 走 pushStatement(helper(...)) 而非 pushStringPart。在 SSR 里 Suspense 只渲染 default(或 fallback),不等待异步组件。
16.4.8 ssrIncludeBooleanAttr 与 v-model helpers
ssrIncludeBooleanAttr 直接复用 @vue/shared 的 includeBooleanAttr——它实现"布尔属性"的 HTML 语义:值为真时输出属性名(如 checked)、为假时输出空串。编译期用于 _ssrIncludeBooleanAttr(val) ? " checked" : ""(16.5.5)。
ssrVModelHelpers.ts 四个 helper:
// packages/server-renderer/src/helpers/ssrVModelHelpers.ts:11-45(节选)
export function ssrRenderDynamicModel(type, model, value) {
switch (type) {
case 'radio': return looseEqual(model, value) ? ' checked' : ''
case 'checkbox': return (isArray(model) ? ssrLooseContain(model, value) : model) ? ' checked' : ''
default: return ssrRenderAttr('value', model)
}
}
export function ssrGetDynamicModelProps(existingProps = {}, model) {
const { type, value } = existingProps
switch (type) {
case 'radio': return looseEqual(model, value) ? { checked: true } : null
case 'checkbox': return (isArray(model) ? ssrLooseContain(model, value) : model) ? { checked: true } : null
default: return { value: model }
}
}
ssrLooseEqual = looseEqual、ssrLooseContain(arr, v) = looseIndexOf(arr, v) > -1。"loose" 指 looseEqual 的宽松比较语义('1' == 1 为真),与客户端 v-model 的匹配逻辑完全一致——这保证 SSR 直出的 checked/selected 状态与客户端首次渲染完全对齐,避免 hydration mismatch。
16.4.9 ssrRenderTeleport
// packages/server-renderer/src/helpers/ssrRenderTeleport.ts:10-49(节选)
export function ssrRenderTeleport(parentPush, contentRenderFn, target, disabled, parentComponent) {
parentPush('<!--teleport start-->')
const context = parentComponent.appContext.provides[ssrContextKey]
const teleportBuffers = context.__teleportBuffers || (context.__teleportBuffers = {})
const targetBuffer = teleportBuffers[target] || (teleportBuffers[target] = [])
const bufferIndex = targetBuffer.length
let teleportContent
if (disabled) {
contentRenderFn(parentPush)
teleportContent = `<!--teleport start anchor--><!--teleport anchor-->`
} else {
const { getBuffer, push } = createBuffer()
push(`<!--teleport start anchor-->`)
contentRenderFn(push)
push(`<!--teleport anchor-->`)
teleportContent = getBuffer()
}
targetBuffer.splice(bufferIndex, 0, teleportContent)
...
parentPush('<!--teleport end-->')
}
语义:Teleport 内容不在原位置渲染,而是塞进 context.__teleportBuffers[target],标准输出位置只留 <!--teleport start-->...<!--teleport end--> 三个注释锚点。renderToString.ts:140-151 的 resolveTeleports 在渲染结束后把这批 buffer 展开写进 context.teleports[target]。详见 16.8。
16.4.10 ssrGetDirectiveProps
// packages/server-renderer/src/helpers/ssrGetDirectiveProps.ts:7-29(节选)
export function ssrGetDirectiveProps(instance, dir, value?, arg?, modifiers = {}) {
if (typeof dir !== 'function' && dir.getSSRProps) {
return dir.getSSRProps({ dir, instance: ..., value, oldValue: undefined, arg, modifiers }, null) || {}
}
return {}
}
自定义指令在 SSR 下没有生命周期(mounted/updated 都不跑),唯一能参与渲染的接口是 getSSRProps——它返回一个属性对象,被合并进元素的最终 props。没有实现 getSSRProps 的自定义指令在 SSR 下被彻底忽略。
16.5 ssrTransformElement:元素在 SSR 下的编译
16.5.1 两阶段结构
ssrTransformElement(ssrTransformElement.ts:68-377)由一个"守护判断 + 退出回调"构成:
// packages/compiler-ssr/src/transforms/ssrTransformElement.ts:68-76
export const ssrTransformElement: NodeTransform = (node, context) => {
if (
node.type !== NodeTypes.ELEMENT ||
node.tagType !== ElementTypes.ELEMENT
) {
return
}
return function ssrPostTransformElement() {
...
它只处理 tagType === ElementTypes.ELEMENT(原生元素),把组件、slot 交给下游 transform。返回的 ssrPostTransformElement 是退出阶段回调(第 6 章)——这保证它看到的 children 表达式、props 都已经过 transformExpression/transformStyle 处理。
它做的核心工作是构造开标签的 TemplateLiteral['elements'] 数组(openTag)并挂到 node.ssrCodegenNode(ssrTransformElement.ts:79、375),供第二趟的 ssrProcessElement(ssrTransformElement.ts:465-492)读取。闭包里的两个辅助模块级状态:
rawChildrenMap(ssrTransformElement.ts:63-66):一个WeakMap<PlainElementNode, TemplateLiteral['elements'][0]>,存"被指令覆盖的子内容"(v-html/v-text/动态<textarea>value),第一趟写、第二趟读。needTagForRuntime(ssrTransformElement.ts:81-82):当标签是textarea或含-(自定义元素)时,需要把标签名透传给运行期(ssrRenderAttrs/ssrRenderDynamicAttr的第二个参数)。
16.5.2 开标签的构造:从一个字符串起步
开标签的构建是一个"累积字符串数组"的过程:
// packages/compiler-ssr/src/transforms/ssrTransformElement.ts:79
const openTag: TemplateLiteral['elements'] = [`<${node.tag}`]
之后每遇到一个属性/指令,就往 openTag push 字符串或表达式。最终 node.ssrCodegenNode = createTemplateLiteral(openTag)(ssrTransformElement.ts:375)。第二趟 ssrProcessElement(ssrTransformElement.ts:465-492)把它逐项 pushStringPart:
// packages/compiler-ssr/src/transforms/ssrTransformElement.ts:465-492
export function ssrProcessElement(node, context): void {
const isVoidTag = context.options.isVoidTag || NO
const elementsToAdd = node.ssrCodegenNode!.elements
for (let j = 0; j < elementsToAdd.length; j++) {
context.pushStringPart(elementsToAdd[j])
}
if (context.withSlotScopeId) {
context.pushStringPart(createSimpleExpression(`_scopeId`, false))
}
context.pushStringPart(`>`)
const rawChildren = rawChildrenMap.get(node)
if (rawChildren) {
context.pushStringPart(rawChildren)
} else if (node.children.length) {
processChildren(node, context)
}
if (!isVoidTag(node.tag)) {
context.pushStringPart(`</${node.tag}>`)
}
}
注意 isVoidTag(node.tag):<input>、<br>、<img> 等 void 元素不 push 闭标签。这与 CSR 里 createElementVNode 依赖运行期不同,SSR 在编译期就知道——因为 isVoidTag 来自 compiler-dom 的 parserOptions。
16.5.3 needMergeProps 的分流
元素属性处理有一条重要的"分流线"(ssrTransformElement.ts:84-103):
// packages/compiler-ssr/src/transforms/ssrTransformElement.ts:84-103(节选)
const hasDynamicVBind = hasDynamicKeyVBind(node)
const hasCustomDir = node.props.some(
p => p.type === NodeTypes.DIRECTIVE && !isBuiltInDirective(p.name),
)
// v-show has a higher priority in ssr
const vShowPropIndex = node.props.findIndex(
i => i.type === NodeTypes.DIRECTIVE && i.name === 'show',
)
if (vShowPropIndex !== -1) {
const vShowProp = node.props[vShowPropIndex]
node.props.splice(vShowPropIndex, 1)
node.props.push(vShowProp)
}
const needMergeProps = hasDynamicVBind || hasCustomDir
needMergeProps 为真当且仅当:存在无参 v-bind(v-bind="obj" / v-bind:[key])或存在自定义指令。 这两种情况都可能"运行时才决定覆盖哪些静态属性",因此必须退化成"把所有属性合成一个对象、整体交给 ssrRenderAttrs"。反之(只有静态属性和 :key=value 这类静态键动态值),走逐条序列化的快路径。
一个易错点被这段的开头解决了:v-show 被识别为最高优先级。 代码把它 splice 出来、push 到末尾,确保它在后续 buildProps 或逐条处理时最后生效——因为 v-show 会改写 style,若它排在静态 style 之前会被后者覆盖。这是一个非常典型的"指令优先级"实现技巧:靠调整 props 顺序来实现"最后写的赢"。
16.5.4 静态与动态属性:逐条序列化 + 真值表
逐条处理循环(ssrTransformElement.ts:220-363)先跳过 <input> 的 true-value/false-value(ssrTransformElement.ts:223,它们对 SSR 无意义),然后按属性类型分支。
指令属性(NodeTypes.DIRECTIVE) 先处理"子内容覆盖"类指令,再处理一般指令:
v-html:把(exp) ?? ''存进rawChildrenMap(ssrTransformElement.ts:228-232)——v-html的原始 HTML 直接作为子内容输出,不再转义(这是v-html的语义)。v-text:把节点 children 替换成一个插值createInterpolation(prop.exp)(ssrTransformElement.ts:233-234)——于是后续按普通插值处理。v-slot出现在元素上:报X_V_SLOT_MISPLACED(ssrTransformElement.ts:235-238)。<textarea :value>:单独处理(见 16.5.6)。- 一般指令(且非
needMergeProps、非v-on):调用directiveTransforms[name],取{ props, ssrTagParts }(ssrTransformElement.ts:243-254)。动态 path 上的ssrTagParts(如ssrRenderDynamicModel)被 push 进 openTag。
得到 {key, value} 属性对后,逐个 key 判断(ssrTransformElement.ts:255-341):
| 条件 | 处理 | 产物片段 |
|---|---|---|
key 是 key/ref | continue(跳过) | 无 |
key === 'class' | openTag.push(' class="', dynamicClassBinding = _ssrRenderClass(value), '"') | class="${_ssrRenderClass(...)}" |
key === 'style' | 合并或新建 _ssrRenderStyle(value) | style="${_ssrRenderStyle(...)}" |
| 属性名是布尔属性 | _ssrIncludeBooleanAttr(value) ? " name" : "" | ${(...) ? " checked" : ""} |
attrName === 'hidden' | _ssrRenderDynamicAttr(key, value) | ${_ssrRenderDynamicAttr("hidden", v)} |
isSSRSafeAttrName(attrName) | _ssrRenderAttr(key, value) | ${_ssrRenderAttr("id", v)} |
| 否则 | 报 X_SSR_UNSAFE_ATTR_NAME | 编译错误 |
| 动态 key | _ssrRenderDynamicAttr(key, value[, tag]) | ${_ssrRenderDynamicAttr(k, v)} |
一段真实产物(ssrElement.spec.ts:176-206):
// <input type="checkbox" :checked="checked">
_push(`<input type="checkbox"${(_ssrIncludeBooleanAttr(_ctx.checked)) ? " checked" : ""}>`)
// <div :id="id" class="bar">
_push(`<div${_ssrRenderAttr("id", _ctx.id)} class="bar"></div>`)
attrName 的归一化很关键(ssrTransformElement.ts:287-290):自定义元素保留原始名,否则 propsToAttrMap[attrName] || attrName.toLowerCase()——这就是 :viewBox → viewBox(SVG 保留)、:className → 保留(有 map)、:someProp → someprop 的来历。
16.5.5 布尔属性与 hidden
布尔属性(isBooleanAttr,来自 @vue/shared)的 SSR 语义是"有属性名即真":checked、disabled、selected、multiple 等。因此编译成:
(_ssrIncludeBooleanAttr(value)) ? " checked" : ""
这保证值为 false 时完全不输出属性(而不是 checked="false",那在 HTML 里仍被解析为真)。hidden 是个"半布尔"特例——它既可能是布尔(hidden)、也可能是字符串(hidden="until-found"),因此走 _ssrRenderDynamicAttr(运行期判断类型),产物见 ssrElement.spec.ts:194-206:
_push(`<div><span${_ssrRenderDynamicAttr("hidden", false)}></span><span${_ssrRenderDynamicAttr("hidden", 'until-found')}></span></div>`)
安全属性名 isSSRSafeAttrName 是 SSR 独有的防注入闸门:属性名会拼进 HTML,若名字里含 >/"/空格等,可能被用来构造注入。untrusted 的动态键必须先过这道闸门,失败则编译报错(静态键)或运行期 warn(动态键)。
16.5.6 三个 _temp 特例
ssrTransformElement 里有三处生成临时变量 _tempN 的分支,各自解决一个"属性与子内容互相影响"的难题。
特例一:<textarea v-bind="obj"> + 子内容(ssrTransformElement.ts:119-155)。 <textarea> 的值既可能来自 value 属性(应作为子文本渲染),也可能来自 v-bind="obj" 的对象(运行期才知道有没有 value)。于是:
const tempId = `_temp${context.temps++}`
propsExp.arguments = [
createAssignmentExpression(createSimpleExpression(tempId, false), mergedProps),
]
rawChildrenMap.set(node, createCallExpression(context.helper(SSR_INTERPOLATE), [
createConditionalExpression(
createSimpleExpression(`"value" in ${tempId}`, false),
createSimpleExpression(`${tempId}.value`, false),
createSimpleExpression(existingText ? existingText.content : ``, true),
false,
),
]))
产物(ssrElement.spec.ts:56-72):
_push(`<textarea${_ssrRenderAttrs(_temp0 = _mergeProps(_ctx.obj, _attrs), "textarea")}>${_ssrInterpolate(("value" in _temp0) ? _temp0.value : "fallback")}</textarea>`)
核心洞察:合并后的 props 被赋值给 _temp0,既用于属性序列化(_ssrRenderAttrs(_temp0, "textarea"),注意 ssrRenderAttrs 会跳过 textarea 的 value 键),又用于子内容判定("value" in _temp0)。同一个对象被"复用"两次,避免重复计算 mergeProps。 第二个参数 "textarea" 正是 needTagForRuntime 的作用。
特例二:<input v-bind="obj" v-model>(ssrTransformElement.ts:156-180)。 需要"在不含 v-model 的 props 基础上,叠加 v-model 该加的 value/checked":
propsExp.arguments = [
createSequenceExpression([
createAssignmentExpression(tempExp, mergedProps),
createCallExpression(context.helper(MERGE_PROPS), [
tempExp,
createCallExpression(context.helper(SSR_GET_DYNAMIC_MODEL_PROPS), [tempExp, vModel.exp!]),
]),
]),
]
产物(ssrVModel.spec.ts:445-478):
_push(`<div${_ssrRenderAttrs(_attrs)}><input${_ssrRenderAttrs((_temp0 = _ctx.obj, _mergeProps(_temp0, _ssrGetDynamicModelProps(_temp0, _ctx.foo))))}></div>`)
这里用了一个逗号序列表达式 (_temp0 = _ctx.obj, _mergeProps(_temp0, ...)):先赋值、再算 merge。 ssrGetDynamicModelProps 从 _temp0 里读 type/value 来决定该补 checked 还是 value(16.4.8)。
特例三:自定义指令 + 空子节点(ssrTransformElement.ts:181-203)。 若自定义指令的 getSSRProps 可能返回 textContent/innerHTML,则需要把它们作为子内容渲染:
rawChildrenMap.set(node, createConditionalExpression(
createSimpleExpression(`"textContent" in ${tempId}`, false),
createCallExpression(context.helper(SSR_INTERPOLATE), [
createSimpleExpression(`${tempId}.textContent`, false),
]),
createSimpleExpression(`${tempId}.innerHTML ?? ''`, false),
false,
))
产物(ssrElement.spec.ts:310-317):
_push(`<div${_ssrRenderAttrs(_temp0 = _ssrGetDirectiveProps(_ctx, _directive_xxx, _ctx.z, "x", { y: true }))}>${("textContent" in _temp0) ? _ssrInterpolate(_temp0.textContent) : _temp0.innerHTML ?? ''}</div>`)
三个 _temp 特例的共同模式:当"属性"与"子内容"共享同一份运行期数据时,先把它绑到临时变量,再两处引用。 这是编译器处理"数据依赖交织"的通用手段。
16.5.7 class/style 的合并
动态与静态 class 共存时要合并(ssrTransformElement.ts:354-369):静态 class 记进 staticClassBinding,动态 class 记进 dynamicClassBinding;若两者都有,则 mergeCall(dynamicClassBinding, staticClassBinding)(把静态 class 作为数组元素塞进 _ssrRenderClass 的实参),并用 removeStaticBinding(openTag, 'class') 把之前 push 的静态 class="foo" 字符串删掉:
// packages/compiler-ssr/src/transforms/ssrTransformElement.ts:442-453
function removeStaticBinding(tag, binding) {
const regExp = new RegExp(`^ ${binding}=".+"$`)
const i = tag.findIndex(e => typeof e === 'string' && regExp.test(e))
if (i > -1) tag.splice(i, 1)
}
产物(ssrElement.spec.ts:140-156):
// <div class="foo" :class="bar"></div>
_push(`<div class="${_ssrRenderClass([_ctx.bar, "foo"])}"></div>`)
注意顺序:[_ctx.bar, "foo"]——动态在前、静态在后。 因为 normalizeClass 拼接时后者覆盖前者,而"动态 :class 应覆盖静态 class"是 Vue 的既定语义。style 的合并同理(mergeCall 追加到 dynamicStyleBinding.arguments[0] 数组)。
16.5.8 为什么属性整体序列化成字符串——与 patchProps 对比
现在回答本章开头那个问题:为什么 SSR 把属性整体序列化成字符串,而客户端 patchProps 逐属性 patch?
| 维度 | 客户端 patchProp/patchProps(第 17 章) | SSR ssrRenderAttrs |
|---|---|---|
| 目标 | 一个已存在的 DOM 元素 | 一段 HTML 字符串 |
| 操作粒度 | 逐属性(el.setAttribute/classList/style.setProperty) | 整体(拼字符串) |
| 是否需要 diff | 需要(对比新旧 props) | 不需要(一次性) |
| class/style | 特化快速路径 | 归一化后拼 class="..." |
| 事件 | addEventListener | 直接丢弃 |
| 输出形式 | DOM 副作用 | 字符串 |
根本差异是"目标介质"不同: DOM 是"有状态的、可增量修改的对象",属性必须逐条设置(因为你要保留未变更的属性);HTML 是"一次性的字符流",所有属性必须在同一时刻全部序列化完。因此客户端必须"逐属性"(因为它要最小化修改),服务端必须"整体序列化"(因为它要一次成串)。 这不是优化取舍,而是介质决定的必然。
16.6 ssrTransformComponent:组件在 SSR 下的编译
16.6.1 两阶段 + 双分支的复杂结构
组件的 SSR 编译是整个 compiler-ssr 里最复杂的部分,因为它要同时解决两件事:产出 SSR 版本(_ssrRenderComponent),又要为"运行时才决定用 vnode 分支"的场景保留一份客户端版本。 这是通过"三次遍历"协作完成的:
- 第一趟进入阶段(
ssrTransformComponent.ts:91-122):判断组件类型、resolveComponentType、识别是否内置组件。 - 第一趟退出阶段(
ssrTransformComponent.ts:124-201):用克隆节点 + 客户端 transform 生成 vnode 回退分支;用 SSR slot builder 生成_ssrRenderComponent调用。 - 第二趟(
ssrProcessComponent,ssrTransformComponent.ts:204-269):补全 slot 函数体、决定 push 方式。
16.6.2 内置组件的分派
进入阶段先做内置组件识别(ssrTransformComponent.ts:99-113):
// packages/compiler-ssr/src/transforms/ssrTransformComponent.ts:99-113(节选)
const component = resolveComponentType(node, context, true /* ssr */)
const isDynamicComponent =
isObject(component) && component.callee === RESOLVE_DYNAMIC_COMPONENT
componentTypeMap.set(node, component)
if (isSymbol(component)) {
if (component === SUSPENSE) {
return ssrTransformSuspense(node, context)
} else if (component === TRANSITION_GROUP) {
return ssrTransformTransitionGroup(node, context)
} else if (component === TRANSITION) {
return ssrTransformTransition(node, context)
}
return // other built-in components: fallthrough
}
resolveComponentType(node, context, true) 的第三个参数 ssr: true 很关键——它让内置组件(Transition/KeepAlive/Teleport/Suspense/TransitionGroup)不注册进 context.helpers(transformElement.ts:289-291 的 if (!ssr) context.helper(builtIn)),因为它们在 SSR 有专门处理、不需要运行期导入。
分派结果:
| 内置组件 | 处理 | SSR 行为 |
|---|---|---|
<Suspense> | ssrTransformSuspense + ssrProcessSuspense | _ssrRenderSuspense(_push, slots),只渲染 default |
<TransitionGroup> | ssrTransformTransitionGroup + ssrProcessTransitionGroup | 渲染成带 tag 的元素(默认无 tag → fragment),子内容摊平 |
<Transition> | ssrTransformTransition + ssrProcessTransition | 直接渲染子内容(可选 <template> 包裹) |
<KeepAlive> | fallthrough | 无 SSR 语义,直接渲染其子节点 |
<Teleport> | fallthrough(在第二趟 ssrProcessTeleport 处理) | _ssrRenderTeleport |
isSymbol(component) 这个判断本身就是 resolveComponentType 的产物——内置组件被解析成符号(SUSPENSE/TELEPORT 等),普通组件被解析成字符串("Foo")或 resolveComponent 调用表达式。
16.6.3 组件 props 的构建:事件仍要传
退出阶段构建 props(ssrTransformComponent.ts:137-151):
// packages/compiler-ssr/src/transforms/ssrTransformComponent.ts:137-151(节选)
let propsExp: string | JSChildNode = `null`
if (node.props.length) {
// note we are not passing ssr: true here because for components, v-on
// handlers should still be passed
const { props, directives } = buildProps(
node, context, undefined, true, isDynamicComponent,
)
if (props || directives.length) {
propsExp = buildSSRProps(props, directives, context)
}
}
注意注释与调用:buildProps 这里不传 ssr: true。 因为组件的 props 是在服务端也"完整传递"给子组件的——子组件可能需要 onXxx 事件监听器(虽然服务端不触发,但某些库逻辑会用)、也可能需要透传给深层。这与元素不同:元素的 v-on 在 SSR 完全丢弃(transformElement.ts:593),而组件的事件保留。
产物(ssrComponent.spec.ts:21-32):
// <foo @click="bar" />
_push(_ssrRenderComponent(_component_foo, _mergeProps({ onClick: _ctx.bar }, _attrs), null, _parent))
buildSSRProps(ssrTransformElement.ts:379-407)负责把 props 与 getSSRProps 结果合并:
// packages/compiler-ssr/src/transforms/ssrTransformElement.ts:379-407(节选)
export function buildSSRProps(props, directives, context): JSChildNode {
let mergePropsArgs: JSChildNode[] = []
if (props) {
if (props.type === NodeTypes.JS_CALL_EXPRESSION) {
mergePropsArgs = props.arguments as JSChildNode[] // 已是 mergeProps 调用
} else {
mergePropsArgs.push(props)
}
}
if (directives.length) {
for (const dir of directives) {
mergePropsArgs.push(
createCallExpression(context.helper(SSR_GET_DIRECTIVE_PROPS), [
`_ctx`, ...buildDirectiveArgs(dir, context).elements,
] as JSChildNode[]),
)
}
}
return mergePropsArgs.length > 1
? createCallExpression(context.helper(MERGE_PROPS), mergePropsArgs)
: mergePropsArgs[0]
}
这段是"元素"与"组件"共用的 props 合并逻辑——元素在有 v-bind="obj"/自定义指令时也调它(16.5.3)。产物见 ssrElement.spec.ts:376-411 的自定义指令合并。
16.6.4 静态组件 vs 动态组件
退出阶段最后一步决定最终 helper(ssrTransformComponent.ts:179-200):
// packages/compiler-ssr/src/transforms/ssrTransformComponent.ts:179-200
if (typeof component !== 'string') {
// dynamic component that resolved to a resolveDynamicComponent call
// expression - since the resolved result may be a plain element (string)
// or a VNode, handle it with `renderVNode`.
node.ssrCodegenNode = createCallExpression(
context.helper(SSR_RENDER_VNODE),
[`_push`, createCallExpression(context.helper(CREATE_VNODE), [component, propsExp, slots]), `_parent`],
)
} else {
node.ssrCodegenNode = createCallExpression(
context.helper(SSR_RENDER_COMPONENT),
[component, propsExp, slots, `_parent`],
)
}
component是字符串 →_ssrRenderComponent:走"组件递归"路径(内部仍createVNode但专为 SSR 优化)。component不是字符串(resolveDynamicComponent调用表达式)→_ssrRenderVNode:因为动态组件可能解析成原生标签(字符串)或组件,必须走完整 vnode 渲染路径。
注意 _ssrRenderVNode 的第一个参数是 _push——它是"语句式"helper:直接接收 push 函数、把结果写进去;而 _ssrRenderComponent 是"表达式式"helper:返回子 buffer,由外层 _push(...) 包起来。这个差异在 ssrProcessComponent 里体现(ssrTransformComponent.ts:258-267):
if (typeof component === 'string') {
context.pushStatement(createCallExpression(`_push`, [node.ssrCodegenNode]))
} else {
context.pushStatement(node.ssrCodegenNode) // _ssrRenderVNode 本身就含 _push
}
16.6.5 slot 函数的双分支
这是组件 SSR 编译最精妙的部分。第一趟退出阶段用两种 slot builder 各生成一份 slot:
// packages/compiler-ssr/src/transforms/ssrTransformComponent.ts:153-177(节选)
const wipEntries: WIPSlotEntry[] = []
wipMap.set(node, wipEntries)
const buildSSRSlotFn: SlotFnBuilder = (props, _vForExp, children, loc) => {
const param0 = (props && stringifyExpression(props)) || `_`
const fn = createFunctionExpression(
[param0, `_push`, `_parent`, `_scopeId`],
undefined, true /* newline */, true /* isSlot */, loc,
)
wipEntries.push({ type: WIP_SLOT, fn, children, vnodeBranch: vnodeBranches[wipEntries.length] })
return fn
}
const slots = node.children.length
? buildSlots(node, context, buildSSRSlotFn).slots
: `null`
SSR slot 函数的形参是 (props, _push, _parent, _scopeId)——多了 _push 和 _scopeId。 而 vnodeBranches 是之前用克隆节点经客户端 transform 生成的(ssrTransformComponent.ts:128-135):
// packages/compiler-ssr/src/transforms/ssrTransformComponent.ts:128-135
if (clonedNode.children.length) {
buildSlots(clonedNode, context, (props, vFor, children) => {
vnodeBranches.push(createVNodeSlotBranch(props, vFor, children, context))
return createFunctionExpression(undefined)
})
}
于是每个 slot 都有两份实现,第二趟把它们合进同一个函数体(ssrTransformComponent.ts:241-250):
fn.body = createIfStatement(
createSimpleExpression(`_push`, false),
processChildrenAsStatement(wipEntries[i], context, false, true /* withSlotScopeId */),
vnodeBranch,
)
产物(ssrComponent.spec.ts:57-80)把这点体现得淋漓尽致:
default: _withCtx((_, _push, _parent, _scopeId) => {
if (_push) {
_push(`hello<div${_scopeId}></div>`) // SSR 分支
} else {
return [ _createTextVNode("hello"), _createVNode("div") ] // vnode 回退分支
}
}),
_: 1 /* STABLE */
判据 if (_push) 很巧妙: slot 被 ssrRenderSlot 调用(传了 _push)走 SSR 分支,被 vnode 渲染路径调用(未传 _push)走 vnode 回退分支,实现"一次编译、两种调用方式",使 SSR 编译产物被 createVNode 的 children 复用时也能工作。SSR 分支里元素带 ${_scopeId} 正是 withSlotScopeId 的效果。
16.6.6 subTransform / createVNodeSlotBranch / rawOptionsMap
createVNodeSlotBranch(ssrTransformComponent.ts:284-335)和 subTransform(ssrTransformComponent.ts:337-372)实现了"在 SSR 编译内部跑一遍客户端 transform":
// packages/compiler-ssr/src/transforms/ssrTransformComponent.ts:293-304(节选)
const subOptions = {
...rawOptions,
nodeTransforms: [...vnodeNodeTransforms, ...(rawOptions.nodeTransforms || [])],
directiveTransforms: {
...vnodeDirectiveTransforms,
...(rawOptions.directiveTransforms || {}),
},
}
要点:
rawOptionsMap(ssrTransformComponent.ts:271-274)保存"原始选项"(index.ts:53处rawOptionsMap.set(ast, options))。因为subTransform要构造不含 SSR preset 的客户端管线,而context.options已被 SSR 改写,故取原始选项再换上vnodeNodeTransforms/vnodeDirectiveTransforms(ssrTransformComponent.ts:276-282,即getBaseTransformPreset(true) + DOMNodeTransforms)。subTransform强制childContext.ssr = false(ssrTransformComponent.ts:346),让这段回退代码按客户端规则生成;但保留inSSR(继承自 options),使得buildProps的预归一化仍被跳过(transformElement.ts:776的!context.inSSR)。subTransform还继承了父 context 的scopes/identifiers(ssrTransformComponent.ts:348-350),保证 slot props(如v-slot="{ msg }"里的msg)在嵌套子模板里被正确识别。- helper 与 components/directives 会 merge 回父 context(
ssrTransformComponent.ts:354-367)。注意 helper 是"计数相加"而非去重覆盖——因为context.helpers可能记录"同一 helper 出现次数"。 - imports/hoists 不 merge(
ssrTransformComponent.ts:368-371的注释):imports(asset url)只应与客户端分支一致、hoists 在客户端分支里被禁用。
16.6.7 ssrProcessComponent 的 fallthrough 与 slot 收尾
第二趟 ssrProcessComponent(ssrTransformComponent.ts:204-269)先判断 node.ssrCodegenNode 是否存在:
- 不存在 → 说明这是"内置组件 fallthrough"(
Teleport/Suspense/TransitionGroup/Transition/KeepAlive),按类型再分派(ssrTransformComponent.ts:210-231)。Transition/KeepAlive直接processChildren渲染其子内容。特别注意#5352的处理(ssrTransformComponent.ts:224-226):
// #5352: if is at root level of a slot, push an empty string.
if ((parent as WIPSlotEntry).type === WIP_SLOT) {
context.pushStringPart(``)
}
当"全注释内容"的组件位于 slot 根时,SSR 会 push 一个空字符串:ssrRenderSlotInner 会把"只含注释"的 slot 视为空而渲染 fallback,push 空串可"骗过"该判定(ssrRenderSlot.ts:94-99)。产物里呈现为 _push(\`)`。
- 存在 → 补全 slot 函数体(
ssrTransformComponent.ts:234-251)、处理 scopeId(ssrTransformComponent.ts:254-256,若withSlotScopeId则给_ssrRenderComponent追加_scopeId参数),最后按 16.6.4 的方式 pushStatement。
16.6.8 Transition / TransitionGroup / Suspense / KeepAlive 的 SSR 行为
<Transition>(ssrTransformTransition.ts):SSR 完全没有过渡动画——直接渲染子内容。ssrTransformTransition 只在第一趟记录是否有 appear 属性(ssrTransformTransition.ts:14-22),第二趟若 appear 为真则用 <template> 包裹内容(ssrTransformTransition.ts:24-38),并过滤掉注释子节点(#5351)。产物(ssrComponent.spec.ts:350-358):
// <transition><div/></transition>
_push(`<div${_ssrRenderAttrs(_attrs)}></div>`)
<TransitionGroup>(ssrTransformTransitionGroup.ts):SSR 把内容渲染成一个普通元素(若有 tag + props,则用 _ssrRenderAttrs 序列化),并把子内容摊平成单层 fragment(disableNestedFragments: true),同时禁止补空注释(disableComment: true,因为运行时 TransitionGroup 会过滤注释、hydration 不能看到它们,#6715)。产物见 ssrTransformTransitionGroup.ts:60-117 的分支:有 tag → 生成带标签的元素;无 tag → 纯 fragment。
<Suspense>(ssrTransformSuspense.ts):第一趟用 buildSlots 生成 slots,第二趟填函数体,最后 _ssrRenderSuspense(_push, slotsExp)(77-82)。其 slot 函数不是普通 slot(isSlot:false,47 行)。产物(ssrSuspense.spec.ts、ssrInjectCssVars.spec.ts:98-113):
_ssrRenderSuspense(_push, {
fallback: () => { _push(`<div${_ssrRenderAttrs(_cssVars)}>fallback</div>`) },
default: () => { _push(`<div${_ssrRenderAttrs(_cssVars)}>ok</div>`) },
_: 1 /* STABLE */
})
注意 Suspense 的 slots 仍会被注册 _: 1(STABLE),说明它是通过标准 slots 机制传递的。SSR 下 Suspense 不等待异步组件——_asyncComponent 的 __asyncResolved 在服务端若不就绪,就直接渲染 fallback(或空)。
<KeepAlive>:没有 SSR 语义,直接 fallthrough 渲染子内容。产物(ssrComponent.spec.ts:361-372):
// <keep-alive><foo/></keep-alive>
_push(_ssrRenderComponent(_component_foo, _attrs, null, _parent))
<Teleport>:在内置分派里走 fallthrough,由第二趟 ssrProcessTeleport 处理(16.8)。
16.6.9 ssrInjectFallthroughAttrs:把 _attrs 注入根节点
组件 _attrs 的注入逻辑独立在 ssrInjectFallthroughAttrs.ts。它做两件事:识别根节点、往根元素 push 一个 v-bind="_attrs":
// packages/compiler-ssr/src/transforms/ssrInjectFallthroughAttrs.ts:76-91(节选)
function injectFallthroughAttrs(node: RootNode | TemplateChildNode) {
if (
node.type === NodeTypes.ELEMENT &&
(node.tagType === ElementTypes.ELEMENT || node.tagType === ElementTypes.COMPONENT) &&
!findDir(node, 'for')
) {
node.props.push({
type: NodeTypes.DIRECTIVE, name: 'bind', arg: undefined,
exp: createSimpleExpression(`_attrs`, false), modifiers: [], loc: locStub,
})
}
}
_attrs 被标记为已知标识符(ssrInjectFallthroughAttrs.ts:24-26),避免被前缀成 _ctx._attrs。!findDir(node, 'for'):带 v-for 的根不注入 _attrs——多个根元素不知该放谁身上,是有意的保守取舍。判断"哪个是根"的逻辑(ssrInjectFallthroughAttrs.ts:28-73)要处理单个根、被 Transition/KeepAlive 包裹的单个根、根级 v-if/v-else 链的每个分支,目标都是找到"唯一会被渲染的根节点"。产物里 ${_ssrRenderAttrs(_attrs)}、${_ssrRenderAttrs(_mergeProps({...}, _attrs))} 就是它的功劳。
16.7 ssrTransformSlotOutlet:<slot> 如何变成一次函数调用
<slot> 在客户端渲染里是"占位符"——它自己不是元素,而是"在这里插入父组件传下来的 slot 内容"。在 SSR 里,这个"插入"被编译成一次性函数调用 ssrRenderSlot(...)。本节把 ssrTransformSlotOutlet.ts(两趟共两段代码)+ 运行期 ssrRenderSlot.ts 串起来讲清楚。
16.7.1 第一趟:processSlotOutlet + args 数组
第一趟 transform 的骨架极短(packages/compiler-ssr/src/transforms/ssrTransformSlotOutlet.ts:20-71):
export const ssrTransformSlotOutlet: NodeTransform = (node, context) => {
if (isSlotOutlet(node)) {
const { slotName, slotProps } = processSlotOutlet(node, context)
const args = [
`_ctx.$slots`, // 0: slots 池
slotName, // 1: 插槽名("default" / 动态表达式)
slotProps || `{}`, // 2: 传给 slot 的 props
`null`, // 3: fallback 占位,第二趟替换
`_push`, // 4: 渲染函数
`_parent`, // 5: 父组件实例
]
// ...
node.ssrCodegenNode = createCallExpression(context.helper(method), args)
}
}
三个 AST 级细节值得展开:
processSlotOutlet来自compiler-dom(ssrTransformSlotOutlet.ts:11)。它负责把<slot name="foo" :msg="x">解析成slotName("default"或动态表达式)+slotProps(一个对象表达式)。SSR 完全复用客户端的槽解析逻辑,只替换后续的"如何渲染"。 这就是 16.1 说的"compiler-ssr 是插件、不是独立编译器"的又一例证。args[0]是_ctx.$slots——注意它不是_ctx.$slots.default这样的静态取值,而是把整个 slots 对象传给运行期,由运行期用slotName去索引。这样动态插槽名(<slot :name="n">)在编译期不需要被解析,只需把表达式_ctx.n当参数传下去。args[3]先写死null,第二趟如果有 fallback 内容才替换成一个函数。这是"两趟策略"在参数级别的最小体现:第一趟先把调用形态搭好,第二趟才填实。
16.7.2 SSR_RENDER_SLOT vs SSR_RENDER_SLOT_INNER:transition 内的单 slot
第一趟最绕的一段是"选哪个 helper"(ssrTransformSlotOutlet.ts:39-67):
let method = SSR_RENDER_SLOT
// #3989, #9933
let parent = context.parent!
if (parent) {
const children = parent.children
// #10743 <slot v-if> in <Transition>
if (parent.type === NodeTypes.IF_BRANCH) {
parent = context.grandParent!
}
let componentType
if (
parent.type === NodeTypes.ELEMENT &&
parent.tagType === ElementTypes.COMPONENT &&
((componentType = resolveComponentType(parent, context, true)) === TRANSITION ||
componentType === TRANSITION_GROUP) &&
children.filter(c => c.type === NodeTypes.ELEMENT).length === 1
) {
method = SSR_RENDER_SLOT_INNER
if (!(context.scopeId && context.slotted !== false)) {
args.push('null')
}
args.push('true')
}
}
设计动机(#3989/#9933): <Transition> / <TransitionGroup> 在运行期会把它的 slot 解包成 vnode 而不是 fragment。如果 <slot> 在 <transition> 内被当成普通 slot 渲染,ssrRenderSlot 会强行包一层 <!--[--> ... <!--]--> fragment,这与"transition 内应解包"的语义冲突,hydration 时对不上。
所以判据是:父节点是 Transition/TransitionGroup 组件、且元素子节点恰好只有 1 个(即这个 <slot> 是唯一元素) → 改用 SSR_RENDER_SLOT_INNER(运行期不包 fragment,见 16.7.5),并在参数尾部追加一个 true(对应 transition 形参)。
三个边界:
#10743:<slot v-if>在<Transition>内时,<slot>的父是IF_BRANCH(v-if 分支节点)而非<transition>本身,故需parent = context.grandParent!上跳一层再判。- 参数位对齐:若当前模板用了
:slotted(context.scopeId && context.slotted !== false),会额外 push`"${scopeId}-s"`(slotScopeId);走SSR_RENDER_SLOT_INNER时若无该 scopeId 则补null占位,再 push'true'。ssrRenderSlotInner签名为(slots, name, props, fallback, push, parent, slotScopeId, transition),两段参数必须严格对齐,极易踩坑。 resolveComponentType(parent, context, true)的isExplicit=true表示"必须是显式组件名、不经resolveComponent",用来判定父是否为内置Transition/TransitionGroup。
产物形如(ssrSlotOutlet.spec.ts):
// <slot/>(普通)
_ssrRenderSlot(_ctx.$slots, "default", {}, null, _push, _parent)
// <slot/> 在 <transition> 内(唯一元素)
_ssrRenderSlotInner(_ctx.$slots, "default", {}, null, _push, _parent, null, true)
16.7.3 -s 后缀:slot scope id 的双重身份
args 里那个 `"${scopeId}-s"` 是 slot scope id。理解它要回到 SFC scoped CSS 的双 ID 机制:
- 普通元素带
data-v-xxxx(模板作用域 id); - 从父组件"传进来"的 slot 内容,同时携带父组件的模板 scope id 和它自身所在的子组件 scope id。子组件渲染
<slot>时,给传进来的内容补上"子组件 scope id 的-s变体"(`${scopeId}-s`),客户端 CSS 选择器形如.child[data-v-child-s] .foo。 - 在客户端,这个
-s由renderSlot逻辑在运行时拼接(setCurrentRenderingInstance期间);在 SSR,因为<slot>编译成了ssrRenderSlot(...)调用,就必须在编译期把这个-s字符串当作实参显式传下去。
这也解释了 16.6.5 里 withSlotScopeId 的作用:当 SSR process 阶段处于"正在处理某个 slot 函数体"时,所有子元素都要额外拼 ${_scopeId};而 <slot> 自己则要传 ${slotScopeId} + _scopeId。 见 16.7.4。
16.7.4 第二趟 ssrProcessSlotOutlet:fallback 与 scopeId 转发
ssrProcessSlotOutlet(ssrTransformSlotOutlet.ts:73-96)只做两件事:
export function ssrProcessSlotOutlet(node, context) {
const renderCall = node.ssrCodegenNode!
// has fallback content
if (node.children.length) {
const fallbackRenderFn = createFunctionExpression([])
fallbackRenderFn.body = processChildrenAsStatement(node, context)
renderCall.arguments[3] = fallbackRenderFn // 替换占位的 null
}
// Forwarded <slot/>. Merge slot scope ids
if (context.withSlotScopeId) {
const slotScopeId = renderCall.arguments[6]
renderCall.arguments[6] = slotScopeId
? `${slotScopeId as string} + _scopeId`
: `_scopeId`
}
context.pushStatement(node.ssrCodegenNode!)
}
要点:
- fallback 是"编译期就能定的子内容",因此它被编译成一个独立函数(
createFunctionExpression([])),函数体是"渲染<slot>的子节点",并作为第 4 个参数fallbackRenderFn传入。运行期只有在"slot 为空"时才调用它(16.7.5)。注意它不是(props, _push, _parent, _scopeId)形态的 slot 函数——它就是() => { _push(...) }闭包,作用域沿用外面的_push。产物:
// <slot><div>fallback</div></slot>
_ssrRenderSlot(_ctx.$slots, "default", {}, () => {
_push(`<div>fallback</div>`)
}, _push, _parent)
- slotScopeId 参数(index 6)在
withSlotScopeId时被"改写":原本可能是"xxxxx-s"(静态字符串)或null,被重写成`"xxxxx-s" + _scopeId`或`_scopeId`。这是字符串拼接,发生在编译产物的源码里——因为运行期需要"父模板 id + 子模板 id"的组合字符串。注意_scopeId是 slot 函数的形参(16.6.5:SSR slot 函数签名(props, _push, _parent, _scopeId)),所以这段改写只有在这个<slot>确实位于某个 slot 函数体内时才成立(由context.withSlotScopeId保证)。 - 最后
pushStatement:因为这是一次函数调用(有副作用、有边界),必须用硬边界切断currentString(16.3.2)。因此<slot>的前后字符串无法被合并。
16.7.5 运行期 ssrRenderSlot / ssrRenderSlotInner:注释判定与 buffer
运行期实现(packages/server-renderer/src/helpers/ssrRenderSlot.ts)是本节的"另一半真相"。
ssrRenderSlot(ssrRenderSlot.ts:24-46)永远包 fragment:
export function ssrRenderSlot(slots, slotName, slotProps, fallbackRenderFn, push, parentComponent, slotScopeId) {
// template-compiled slots are always rendered as fragments
push(`<!--[-->`)
ssrRenderSlotInner(slots, slotName, slotProps, fallbackRenderFn, push, parentComponent, slotScopeId)
push(`<!--]-->`)
}
为什么必须包 <!--[-->/<!--]-->? 因为一个 <slot> 在 DOM 里对应"任意数量的兄弟节点"(0 到 N 个),客户端 hydration 需要一个锚点对来界定这段区间——否则无法知道"slot 内容到哪里结束"。这与 16.3.5 里 v-for/fragment 的注释锚点是同一套机制。
ssrRenderSlotInner(ssrRenderSlot.ts:48-136)的逻辑树:
flowchart TD
A[slotFn] -->|有| D["slotFn(props||{}, bufferedPush, parent)"]
D -->|返回 Array| F1["renderVNodeChildren(vnode 分支)"]
D -->|否则| G{"slotBuffer 全为注释?"}
G -->|是| C
G -->|否| I["剥掉首尾 fragment 注释后逐条 push"]
A -->|无| C{fallback?}
C -->|有| C1[渲染 fallback]
C -->|无且 transition| C3["push('
关键设计点:
bufferedPush(ssrRenderSlot.ts:60-63):slot 函数的_push不是直通的外层_push,而是先写进一个局部数组slotBuffer。 为什么要缓冲?因为运行期必须先判断"这个 slot 是不是空的",再决定渲染它还是渲染 fallback。若直接写进外层 buffer 就无法撤销。缓冲让"渲染内容"与"是否采用"分离。isEmptySlot的判定(ssrRenderSlot.ts:88-100):遍历slotBuffer,只要有一条isComment为假,就是"非空"。isComment(ssrRenderSlot.ts:138-145)用正则/^<!--[\s\S]*-->$/判断"整条是不是一条 HTML 注释",并且对<!---->/<!--[-->/<!--]-->(长度 ≤ 8)直接返回真。这意味着"条件渲染出一个假分支<!---->"在 slot 里被视为空——于是会渲染 fallback。 这正是 16.6.7 里#5352要"push 空串骗过空判定"的原因。transition为真时isEmptySlot = false被强制(ssrRenderSlot.ts:91-92):因为 transition 内的 slot 不应该被当成"空"来处理(它是被解包的 vnode)。同时尾部还有"若 buffer 首尾恰好是 fragment 注释就剥掉"的特殊处理(ssrRenderSlot.ts:111-128,#9933),避免重复包 fragment。isArray(ret)分支(72-86):slot 函数返回数组时(说明它是未编译为 SSR 形态的客户端 vnode slot),走ensureValidVNode+renderVNodeChildren把 vnode 塞进 SSR 管线——这是"SSR slot 与 vnode slot 可混用"的兼容设计。第 5 点细节:传给 slot 函数的 scopeId 前带一个空格(ssrRenderSlot.ts:70),故产物里元素写成<div${_scopeId}>。
16.7.6 具名 / 动态 / scoped slot 的产物差异
| 模板 | 产物关键片段 | 说明 |
|---|---|---|
<slot/> | _ssrRenderSlot(_ctx.$slots, "default", {}, null, _push, _parent) | 默认插槽,无 fallback |
<slot name="header"/> | _ssrRenderSlot(_ctx.$slots, "header", {}, null, _push, _parent) | 具名插槽是静态字符串参数 |
<slot :name="n"/> | _ssrRenderSlot(_ctx.$slots, _ctx.n, {}, null, _push, _parent) | 动态插槽名是表达式,transformExpression 会前缀 _ctx. |
<slot :msg="x"/> | _ssrRenderSlot(_ctx.$slots, "default", { msg: _ctx.x }, null, _push, _parent) | slotProps 变对象表达式(由 processSlotOutlet 生成) |
<slot><b>fb</b></slot> | 第 4 参变成 () => { _push(\fb`) }` | fallback 是闭包 |
注意 <slot v-if>: 它会被 ssrTransformIf 包成 if/else,<slot> 的调用落在真分支里;同时 16.7.2 的 #10743 分支专门处理"<slot v-if> 在 <Transition> 内"(父是 IF_BRANCH)。产物:
if (_ctx.ok) {
_ssrRenderSlotInner(_ctx.$slots, "default", {}, null, _push, _parent, null, true)
} else {
_push(`<!---->`)
}
16.7.7 易错点清单
slotProps可能 nullish:编译期slotProps || '{}'兜底,运行期ssrRenderSlotInner再slotProps || {}归一(ssrRenderSlot.ts:66-67,注释称"保持与renderSlot契约一致")——因为<slot v-bind="maybeNull">运行期确实可能为空。- fallback 用的是外层
_push,不是bufferedPush,故 fallback 一旦被选中就立即写入父 buffer,无需再缓冲。 v-slot="{ msg }"里的msg属特殊作用域,由trackSlotScopes(16.6.2)处理,不能误加_ctx.;而 slotProps 表达式里来自当前模板作用域的变量则由transformExpression正确前缀。
16.8 ssrTransformTeleport / ssrVModel / ssrVShow
这三个 transform 恰好代表 SSR 编译的三种典型改写手法:Teleport = "延迟渲染 + 收集"、v-model = "把语义编译成条件表达式"、v-show = "编译成 style 属性"。逐个拆。
16.8.1 Teleport 的第一趟:只登记,不渲染
<Teleport> 是内置组件,走 16.6.7 的"ssrCodegenNode 不存在 → 内置 fallthrough"路径。第一趟 ssrTransformComponent 里对 Teleport 的处理几乎什么都不做——它只被登记进 teleports 集合(由 ssrCodegenTransform 收尾时汇总):
// packages/compiler-ssr/src/ssrCodegenTransform.ts:61-76(节选)
if (teleports.size) {
// ...
ast.codegenNode = createBlockStatement(context.body)
// ... 生成 resolveTeleports 相关代码
}
为什么 Teleport 需要"延迟"? 因为 teleport 的语义是"把内容渲染到另一个 DOM 位置",但 SSR 的输出是一条顺序流(_push 按序写进 buffer)。同一个 <teleport> 的内容必须被渲染成一份"独立 buffer"(__teleportBuffers),最后再按 target 归并到正确位置。 这在"同一 target 出现多次 teleport"时需要按出现顺序拼接——所以要在编译期把"有哪些 teleport、target 是什么"登记下来,运行期 resolveTeleports(renderToString.ts:140-151)再合并。
16.8.2 第二趟 ssrProcessTeleport:target 解析与 contentRenderFn
ssrProcessTeleport(packages/compiler-ssr/src/transforms/ssrTransformTeleport.ts:18-71)的流程:
const targetProp = findProp(node, 'to')
if (!targetProp) {
context.onError(createSSRCompilerError(SSRErrorCodes.X_SSR_NO_TELEPORT_TARGET, node.loc))
return
}
// ...
const disabledProp = findProp(node, 'disabled', false, true /* allow empty */)
const disabled = disabledProp
? disabledProp.type === NodeTypes.ATTRIBUTE ? `true` : disabledProp.exp || `false`
: `false`
const contentRenderFn = createFunctionExpression([`_push`], undefined, true, false, node.loc)
contentRenderFn.body = processChildrenAsStatement(node, context)
context.pushStatement(
createCallExpression(context.helper(SSR_RENDER_TELEPORT), [
`_push`, contentRenderFn, target, disabled, `_parent`,
]),
)
要点:
to的解析分两种:静态属性<teleport to="#modal">→createSimpleExpression(content, true)(静态字符串);动态指令:to="target"→ 直接用表达式。若to完全缺失或值为空 → 编译期报X_SSR_NO_TELEPORT_TARGET(见 16.9)。 这是一个"编译期就拦下的必然运行期错误"——因为 teleport 没有 target 无法工作。disabled的三种形态:无 →false;静态布尔属性disabled→'true';动态:disabled="foo"→_ctx.foo。产物(ssrPortal.spec.ts:17-40):_ssrRenderTeleport(_push, (_push) => { _push(`<div></div>`) }, _ctx.target, true, _parent) // disabled _ssrRenderTeleport(_push, (_push) => { _push(`<div></div>`) }, _ctx.target, _ctx.foo, _parent) // :disabled="foo"contentRenderFn是"一个接受_push的闭包"(createFunctionExpression([_push], ..., true, false)),注意它显式把_push作为形参——这就是 16.8.1 说的"内容被渲染成独立 buffer":运行期ssrRenderTeleport(packages/server-renderer/src/helpers/ssrRenderTeleport.ts:10-49)会把自己的 buffered push 传进contentRenderFn,从而内容进的是 teleport 专属 buffer,而非主 buffer。pushStatement(硬边界):teleport 调用切断currentString。
16.8.3 ssrRenderTeleport 运行期:锚点、异步、disabled
运行期(ssrRenderTeleport.ts)负责:
- 主 buffer 写锚点注释
<!--teleport start--><!--teleport end-->(hydrate 时定位插入点); - 把
contentRenderFn(bufferedPush)的内容收集进__teleportBuffers.get(target); disabled为真:内容不进 teleport buffer 而直接写主 buffer(等价原地渲染),也不写锚点。- 异步内容需
hasAsync机制配合(16.3.7/16.2.5)。
16.8.4 v-model:把双向绑定编译成"条件属性表达式"
客户端 v-model 是"运行时指令 + 事件监听";SSR 里 v-model 不是指令,而是一个 DirectiveTransform(packages/compiler-ssr/src/transforms/ssrVModel.ts:27-191),它把"当前值 → HTML 属性"的映射在编译期展开成表达式。
总入口:
if (node.tagType === ElementTypes.ELEMENT) {
const res: DirectiveTransformResult = { props: [] }
if (node.tag === 'input') { /* ... */ }
else if (node.tag === 'textarea') {
checkDuplicatedValue()
node.children = [createInterpolation(model, model.loc)]
} else if (node.tag === 'select') {
processSelectChildren(node.children)
} else {
context.onError(createDOMCompilerError(DOMErrorCodes.X_V_MODEL_ON_INVALID_ELEMENT, dir.loc))
}
return res
} else {
// component v-model
return transformModel(dir, node, context)
}
注意最后一行:如果 v-model 用在组件上(<MyComp v-model="x">),退回客户端 transformModel——因为组件 v-model 的 SSR 渲染(props: { modelValue: x, 'onUpdate:modelValue': ... })与客户端完全一致,不需要 SSR 专属改写。只有当 v-model 作用于原生表单元素时,才需要把它"翻译成 HTML 属性"。
16.8.5 <input>:五种 type 分支
ssrVModel.ts:86-171 对 <input> 按 type 分派:
| type | 编译结果(res.props / ssrTagParts) | 产物片段 |
|---|---|---|
| 文本(默认/无 type/email 等) | { value: model } + checkDuplicatedValue() | ${_ssrRenderAttr("value", _ctx.bar)} |
动态 :type="t" | res.ssrTagParts = [SSR_RENDER_DYNAMIC_MODEL(t, model, value)] | 走 16.5 的特例三 |
radio | { checked: _ssrLooseEqual(model, value) } | ${(_ssrLooseEqual(_ctx.model,"1"))?" checked":""} |
checkbox(有 true-value) | { checked: _ssrLooseEqual(model, trueValue) } | 同上,用 true-value 表达式/字面量 |
checkbox(无 true-value) | { checked: Array.isArray(model) ? _ssrLooseContain(model,value) : model } | 数组包含判断 |
file | 报错 X_V_MODEL_ON_FILE_INPUT_ELEMENT | — |
defaultProps = [createObjectProperty('value', model)](87-90) 是文本 type 的核心。checkDuplicatedValue()(30-40)在"文本框已有显式 value"时报 X_V_MODEL_UNNECESSARY_VALUE——v-model 已负责 value,再写会冲突。
checkbox 的无 true-value 分支(ssrVModel.ts:134-146) 是唯一"三选一"结构:
res.props = [createObjectProperty(`checked`,
createConditionalExpression(
createCallExpression(`Array.isArray`, [model]),
createCallExpression(context.helper(SSR_LOOSE_CONTAIN), [model, value]),
model, // 非数组时 checked 直接等于 model(布尔)
))]
SSR_LOOSE_EQUAL/SSR_LOOSE_CONTAIN(loose equal) 的存在是因为 HTML 属性值永远被序列化成字符串:value 可能是数字 1,但 DOM 里是 "1",必须用 == 语义比较(ssrLooseEqual 内部用 ==,见 packages/server-renderer/src/helpers/ssrVModelHelpers.ts)。ssrIncludeBooleanAttr 则负责把布尔结果转成 " checked"/""。
16.8.6 <select>:processSelectChildren 递归改写 <option>
<select v-model> 的处理最特殊:它不在 <select> 上加任何属性,而是递归改写每一个 <option>,往它上面追加一个"是否 selected"的条件表达式(ssrVModel.ts:42-82)。
const processSelectChildren = (children) => {
children.forEach(child => {
if (child.type === NodeTypes.ELEMENT) processOption(child)
else if (child.type === NodeTypes.FOR) processSelectChildren(child.children) // v-for 内
else if (child.type === NodeTypes.IF) child.branches.forEach(b => processSelectChildren(b.children)) // v-if 内
})
}
function processOption(plainNode) {
if (plainNode.tag === 'option') {
if (plainNode.props.findIndex(p => p.name === 'selected') === -1) { // 已有 selected 就跳过
const value = findValueBinding(plainNode) // :value / value / null
plainNode.ssrCodegenNode!.elements.push( // 往 option 的 SSR 属性片段追条件
createConditionalExpression(
createCallExpression(context.helper(SSR_INCLUDE_BOOLEAN_ATTR), [
createConditionalExpression(
createCallExpression(`Array.isArray`, [model]),
createCallExpression(context.helper(SSR_LOOSE_CONTAIN), [model, value]),
createCallExpression(context.helper(SSR_LOOSE_EQUAL), [model, value]),
),
]),
createSimpleExpression(' selected', true),
createSimpleExpression('', true),
false,
),
)
}
} else if (plainNode.tag === 'optgroup') {
processSelectChildren(plainNode.children) // optgroup 递归
}
}
三个 AST 级设计点:
plainNode.ssrCodegenNode!.elements.push(...):<option>的ssrCodegenNode是TemplateLiteral(16.5.2),其elements被追加一个表达式片段——即"把 selected 逻辑注入 option 的字符串模板"。产物(ssrVModel.spec.ts:47-51):<option value="1"${(_ssrIncludeBooleanAttr((Array.isArray(_ctx.model) ? _ssrLooseContain(_ctx.model,"1") : _ssrLooseEqual(_ctx.model,"1"))) ? " selected" : "")}></option>findValueBinding(193-201):取:value="i"表达式 / 静态value="bar"字符串 / 都没有时null。value="1"是字符串"1"而model可能是数字1,故 loose equal 必须存在。- 递归穿透
v-for(FOR)与v-if(IF):option 藏在 FOR 节点的children里。产物(ssrVModel.spec.ts:57-75)中_ssrRenderList循环体内每个 option 都带 selected 判断。
重要约束:processSelectChildren 只处理"编译期就能枚举到的" <option>。 如果 option 列表来自 <slot/>(运行时才知道),就无法注入 selected——产物退化为 _ssrRenderSlot(_ctx.$slots, "default", ...)(ssrVModel.spec.ts:120-130),selected 交给浏览器默认行为。这是 SSR 编译 v-model 的固有局限:编译期可知 → 精确注入;编译期不可知 → 放弃。
16.8.7 <textarea> 与 dynamic v-bind
<textarea v-model>(ssrVModel.ts:172-174):不走属性,而是把v-model的表达式变成 textarea 的子内容插值:产物:node.children = [createInterpolation(model, model.loc)]<textarea>${_ssrInterpolate(_ctx.model)}</textarea>。这符合 HTML 语义:textarea 的值是它的文本内容,不是 value 属性。hasDynamicKeyVBind分支(ssrVModel.ts:163-166):<input v-bind="obj" v-model="x">这种"动态 key 的 v-bind"无法在编译期知道 type,故此处 NOOP,交给ssrTransformElement的第三个_temp特例处理(16.5.4)。产物用_ssrRenderDynamicModel。
16.8.8 v-show:一个条件表达式换掉一个 style 属性
ssrTransformShow(packages/compiler-ssr/src/transforms/ssrVShow.ts:11-35)短到可以整段引用:
export const ssrTransformShow: DirectiveTransform = (dir, node, context) => {
if (!dir.exp) {
context.onError(createDOMCompilerError(DOMErrorCodes.X_V_SHOW_NO_EXPRESSION))
}
return {
props: [
createObjectProperty(
`style`,
createConditionalExpression(
dir.exp!,
createSimpleExpression(`null`, false), // 条件真 → style: null
createObjectExpression([ // 条件假 → style: { display: 'none' }
createObjectProperty(`display`, createSimpleExpression(`none`, true)),
]),
false,
),
),
],
}
}
设计动机: v-show 在客户端是"改 el.style.display",在 SSR 里必须把 display:none 变成 HTML 属性(因为服务端没有运行时样式系统)。而这个属性又与"模板里已有的静态/动态 style"合并——由 ssrTransformElement 的 style 合并逻辑(16.5.3)处理。
产物分四种(ssrVShow.spec.ts):
| 场景 | 产物 |
|---|---|
根 <div v-show="foo"/> | _ssrRenderAttrs(_mergeProps(_attrs, { style: (_ctx.foo) ? null : { display: "none" } })) |
| 非根(被包裹) | <div style="${_ssrRenderStyle((_ctx.foo) ? null : { display: "none" })}"> |
| 静态 style + v-show | _ssrRenderStyle([{"color":"red"}, (_ctx.foo) ? null : { display: "none" }]) |
| 静态 + 动态 style + v-show | _ssrRenderStyle([{"color":"red"}, { fontSize: 14 }, (_ctx.foo) ? null : { display: "none" }]) |
| 动态 v-bind + style + v-show | _ssrRenderAttrs(_mergeProps(_ctx.baz, { style: [...] })) |
为什么 v-show 的 style 值是一个"数组里的条件表达式"? 因为 _ssrRenderStyle 运行期会对数组各项做归一化合并(ssrRenderStyle.ts),null 项被跳过。把 (cond) ? null : {display:'none'} 作为一个数组元素,就优雅地表达了"要么不参与合并、要么加上 display:none"。
易错点: 若 v-show 与静态 style="display:flex" 同时存在,产物是 _ssrRenderStyle([{"display":"flex"}, (_ctx.foo) ? null : { display: "none" }])——注意后者会覆盖前者(数组后面的项优先),这正是期望行为:v-show 应该能覆盖模板里的静态 display。
16.8.9 三者对照小结
一句话概括:Teleport 是"结构级重定向"(编译期登记 + 运行期 buffer 搬运);v-model 是"语义级翻译"(把双向绑定降维成单向属性表达式);v-show 是"样式级内联"(把运行时样式改成 HTML style 属性)。 三者都在回答同一个问题:没有运行时 DOM 的情况下,如何用"字符串输出"表达一个本来是"运行时操作"的语义。
16.9 错误处理与错误码区间
compiler-ssr 的错误码接续 compiler-dom 的扩展点分配(packages/compiler-ssr/src/errors.ts:19-23):
export enum SSRErrorCodes {
X_SSR_UNSAFE_ATTR_NAME = 65 /* DOMErrorCodes.__EXTEND_POINT__ */,
X_SSR_NO_TELEPORT_TARGET, // 66
X_SSR_INVALID_AST_NODE, // 67
}
DOMErrorCodes 已占用 0–64,__EXTEND_POINT__ 指向下一个空位,子编译器必须从其后排以避免碰撞;errors.ts:25-36 的 __TEST__ 断言是"防碰撞保险丝"(esbuild 无法跨文件推断枚举自增,只能手工同步)。createSSRCompilerError(12-17)复用 createCompilerError(code, loc, SSRErrorMessages)。
| 码 | 触发点 | 含义 |
|---|---|---|
X_SSR_UNSAFE_ATTR_NAME (65) | ssrTransformElement 序列化属性 | 属性名含 >/引号等,直接拼进 HTML 不安全 |
X_SSR_NO_TELEPORT_TARGET (66) | ssrProcessTeleport | <teleport> 缺 to,必然运行期失败 → 编译期拦截 |
X_SSR_INVALID_AST_NODE (67) | processChildren 默认分支 | 遇到未预期的 AST 节点类型 |
never 穷尽性检查:processChildren 的 switch 末尾形如 const exhaustiveCheck: never = node; context.onError(createSSRCompilerError(SSRErrorCodes.X_SSR_INVALID_AST_NODE, node.loc))。类型层的 never 让"遗漏某个 NodeTypes 分支"直接编译报错,运行期的 onError 是兜底——双重保险。
复用 DOM 错误码:X_V_MODEL_ON_FILE_INPUT_ELEMENT、X_V_MODEL_ON_INVALID_ELEMENT、X_V_MODEL_UNNECESSARY_VALUE、X_V_SHOW_NO_EXPRESSION 全部复用 compiler-dom 定义(16.8 各处),因为它们是语义级错误、与端无关。这印证"SSR 只负责渲染策略,不重定义语义"。
16.10 SSR 编译 vs CSR 编译差异总表
把本章与第 7 章(compiler-core 代码生成)逐维度对齐:
| 维度 | CSR(compiler-dom 默认) | SSR(compiler-ssr) |
|---|---|---|
| 根函数 | render(_ctx,_cache,$props,$setup,$data,$options) | ssrRender(_ctx,_push,_parent,_attrs) |
| 产物类型 | 返回 vnode 树的表达式 | 立即执行的 _push 语句序列 |
| AST 出口 | codegenNode 是 VNodeCall | 第二趟替换为 BlockStatement |
| hoistStatic | 默认开 | 强制关:字符串拼接已够快,提升无意义 |
| cacheHandlers | 默认开 | 强制关:无监听器可缓存 |
| patchFlag/dynamicProps | 生成,供 diff 用 | 不生成(无 diff) |
事件 @click | onClick: () => ... | isVOn && ssr 跳过(16.5.6),不进产物 |
| class/style | _normalizeClass/_normalizeStyle | _ssrRenderClass/_ssrRenderStyle |
| 属性 | props: { id:"foo" }(运行期 diff) | _ssrRenderAttrs(...) 直接拼 HTML |
| v-if 假分支 | _createCommentVNode | _push(\<!---->`)` |
| v-for | _renderList 返回 vnode 数组 | _ssrRenderList 边遍历边 push |
| 插值 | _toDisplayString(文本 vnode) | _ssrInterpolate(转义字符串) |
| v-model | 事件 + 运行时指令 | value/checked/selected 属性 |
| 组件 | _createVNode | _ssrRenderComponent |
| slot | 函数返回 vnode 数组 | 函数 push 字符串(带 vnode 回退分支) |
| fragment | Fragment,锚点运行期处理 | 显式 <!--[-->/<!--]--> 字符串 |
| helper 来源 | vue | 两拨:vue + vue/server-renderer |
三条主线:(1)CSR 产物是"值",SSR 是"过程"——前者结构留到运行期 diff,后者编译期固化成字符串、运行期只填动态值,故 SSR 无需 patchFlag。(2)静态提升对 SSR 无意义:每次都要重拼静态字符串,提升常量不省事反占内存。(3)helper 分拨是"运行时职责"的镜像:服务端专用 helper 单独 import,避免打进客户端 bundle——正是 ssrRuntimeModuleName='vue/server-renderer'(codegen.ts:152)的目的。
16.11 实战:同一模板,两份产物逐段对照
取一个"含属性、插值、v-if/v-else、v-for、子组件"的单行模板(单行以避免空白文本节点干扰):
<div id="foo" :class="cls">{{ msg }}<span v-if="ok">yes</span><span v-else>no</span><ul><li v-for="(v,i) in list">{{ v + i }}</li></ul><MyComp :n="count"/></div>
SSR 产物(compiler-ssr 等价再现):
const { mergeProps: _mergeProps, resolveComponent: _resolveComponent } = require("vue")
const { ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate,
ssrRenderList: _ssrRenderList, ssrRenderComponent: _ssrRenderComponent } = require("vue/server-renderer")
return function ssrRender(_ctx, _push, _parent, _attrs) {
const _component_MyComp = _resolveComponent("MyComp")
_push(`<div${_ssrRenderAttrs(_mergeProps({ id: "foo", class: _ctx.cls }, _attrs))}>${_ssrInterpolate(_ctx.msg)}`)
if (_ctx.ok) { _push(`<span>yes</span>`) } else { _push(`<span>no</span>`) }
_push(`<ul><!--[-->`)
_ssrRenderList(_ctx.list, (v, i) => { _push(`<li>${_ssrInterpolate(v + i)}</li>`) })
_push(`<!--]--></ul>`)
_push(_ssrRenderComponent(_component_MyComp, { n: _ctx.count }, null, _parent))
_push(`</div>`)
}
CSR 产物(compiler-dom module 模式,关键部分):
return function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_MyComp = _resolveComponent("MyComp")
return (_openBlock(), _createElementBlock("div", {
id: "foo", class: _normalizeClass(_ctx.cls)
}, [
_createTextVNode(_toDisplayString(_ctx.msg) + " ", 1 /* TEXT */),
_ctx.ok ? (_openBlock(), _createElementBlock("span", { key: 0 }, "yes"))
: (_openBlock(), _createElementBlock("span", { key: 1 }, "no")),
_createElementVNode("ul", null, [
(_openBlock(true), _createElementBlock(Fragment, null,
_renderList(_ctx.list, (v, i) => (_openBlock(), _createElementBlock("li", null, _toDisplayString(v + i), 1))),
256 /* UNKEYED_FRAGMENT */))
]),
_createVNode(_component_MyComp, { n: _ctx.count })
], 2 /* CLASS */))
}
逐段对照:
- 根
<div id :class>:SSR 用_mergeProps({...}, _attrs)把根属性与组件_attrs合并后交_ssrRenderAttrs序列化;CSR 直接当 vnode props 并标2 /* CLASS */。_attrs合并时机:SSR 在编译产物内,CSR 在运行期$attrs机制里。 {{ msg }}:SSR 的${_ssrInterpolate(...)}直接内联进 div 的模板字符串;CSR 变成_createTextVNode(...)(+ " "正是空白文本节点问题的暴露)。v-if/v-else:SSR → 两条if/elsepush;CSR → 三元 +key:0/key:1(diff 需要 key)。SSR 无 key。v-for:SSR 用显式<!--[-->/<!--]-->+_ssrRenderList边遍历边 push;CSR 用Fragment+_renderList返回数组 +256 /* UNKEYED_FRAGMENT */。<MyComp/>:SSR 立即_ssrRenderComponent(...)同步渲染(第 4 参null表示无 slots);CSR 只_createVNode,真正渲染交 patch。- 收尾
</div>:SSR 必须单独_push(\`)——因为标签被属性/插值/子内容之间的语句切断了(16.3.2 的硬边界);CSR 整棵树在一个return` 表达式里,天然无需收尾。
一句话:CSR 产物读起来像"构造数据的表达式树",SSR 产物读起来像"按渲染顺序排列的指令表"。
16.12 本章要点回顾
- 定位:
compiler-ssr是给compiler-dom/compiler-core换 transform 管线 + 在generate前插入第二趟ssrCodegenTransform的 preset,非独立编译器(16.1)。 - 产物:
ssrRender(_ctx,_push,_parent,_attrs),"边遍历边 push",与 CSR 的"返回 vnode 树"是两种范式(16.2)。 - 核心机制:
pushStringPart(软,相邻合并)vspushStatement(硬,切断currentString)(16.3)。 - helper 分两拨:
vue+vue/server-renderer,后者经ssrRuntimeModuleName隔离,不污染客户端 bundle(16.4、16.10)。 - 元素:
ssrRenderAttrs/_ssrRenderClass/_ssrRenderStyle+ 三个_temp特例(textarea / input v-bind v-model / 自定义指令),属性编译期即序列化(16.5)。 - 组件:内置组件(Transition/KeepAlive/Suspense/Teleport)走 fallthrough;普通组件用
_ssrRenderComponent;slot 用if (_push)双分支兼容 SSR 与 vnode(16.6)。 <slot>:编译成ssrRenderSlot/ssrRenderSlotInner,运行期用 buffer + 注释判定选择内容或 fallback,transition内单 slot 走 inner 变体(16.7)。- Teleport / v-model / v-show:分别代表"结构级重定向""语义级翻译""样式级内联"(16.8)。
- 错误码:
SSRErrorCodes从 65 起,never穷尽性检查是源码级保险丝(16.9)。 - 总差异:CSR 生成"值"、SSR 生成"过程";静态提升/缓存/patchFlag 在 SSR 强制关闭(16.10)。
至此,编译侧(第 16 章)与渲染侧(第 15 章)合起来构成 Vue 3 服务端渲染的完整链路:编译期把模板降维成 push 指令流,运行期把指令流拼成 HTML 字符串,最后按 teleport 归并、按 hydration 锚点对齐客户端。