Vue 3 核心源码深度剖析系列(v3.5.44)-第 16 章 SSR 编译内幕:compiler-ssr 与 SSR 代码生成

1 阅读27分钟

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)
  ...
}

逐行拆解这段"选项强制":

  1. ...parserOptions 合并:parserOptions 来自 compiler-dom,是把 isVoidTag/isNativeTag/isPreTag 等 parser 钩子接进来的载体。这一步让 SSR 解析与 DOM 解析在"哪些标签是 void、哪些是自定义元素"上保持完全一致——否则 SSR 会给 <input> 生成一个错误的 </input> 闭合标签。
  2. ssr: true、inSSR: true:这两个布尔位是贯穿整条管线的"模式开关"。options.ts:179-195 对它们的注释写得极清楚:ssr 用来声明"当前在生成 SSR render 函数",inSSR 用来声明"当前处在 SSR 编译上下文中"。二者的差别在于 subTransform(16.6 会讲)——slot 的 vnode 回退分支需要临时把 ssr 关掉(ssr: false)但保留 inSSR: true,从而让这段回退代码按客户端规则生成,却又知道"我仍处在 SSR 编译里、不要做客户端专有的预归一化"。
  3. scopeId 的条件清空:当 mode === 'function'(非 SFC 模块模式)时,scopeId 被强制置为 null。原因是 scoped style 的 data-v-xxx 注入依赖"每个组件的 scopeId",而 function 模式通常是编译一个孤立的模板字符串、没有 SFC 语境。
  4. prefixIdentifiers: true 恒真:注释直白——compiler-ssr doesn't have size concern。SSR 产物在服务端执行,包体积无关紧要,因此永远开启标识符前缀(_ctx.foo),从而规避 with(_ctx) 这种无法静态分析的写法,也让 helper/scope 追踪更精确。
  5. cacheHandlers: false、hoistStatic: false:这两行是理解 SSR 与 CSR 优化哲学差异的钥匙。 事件处理器缓存(_cache[0] || ...)是为"避免每次渲染重建函数"服务的,而 SSR 每个请求只渲染一次、且根本不渲染事件监听器(见 16.5),缓存毫无意义;静态提升(把静态 vnode 提升到 render 函数外)是为"跨次渲染复用 vnode"服务的,SSR 同样每个请求只渲染一次,提升反而增加服务端常驻内存。第 7 章讲过的两大优化,在 SSR 语境下被主动关闭。

16.1.3 被强制的编译选项一览

选项客户端默认SSR 强制值理由
ssrfalsetrue声明生成 ssrRender 而非 render;影响 codegen 前缀与 helper 归属
inSSRfalsetrue标记处于 SSR 编译上下文;禁止对 props 做客户端预归一化(transformElement.ts:776 的 !context.inSSR 分支)
prefixIdentifiers依模式true服务端无体积顾虑,永远前缀化,避免 with
cacheHandlers可选falseSSR 一次性渲染,事件处理器不缓存(且 SSR 不渲染事件)
hoistStatic可选false跨次复用无意义,反而增加服务端常驻内存
scopeId(function 模式)传入值nullfunction 模式无 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[&#34;template 字符串&#34;] --> B[&#34;baseParse (compiler-dom)&#34;]
  B --> C[&#34;模板 AST (RootNode)&#34;]
  C --> D[&#34;transform (SSR preset, 第一趟)&#34;]
  D --> D1[&#34;IfNode / ForNode / ElementNode.ssrCodegenNode / ComponentNode.ssrCodegenNode 等&#34;]
  D1 --> E[&#34;ssrCodegenTransform (第二趟)&#34;]
  E --> E1[&#34;遍历 AST,processChildren→pushStringPart/pushStatement&#34;]
  E1 --> E2[&#34;ast.codegenNode = createBlockStatement(context.body)&#34;]
  E2 --> F[&#34;generate (共享)
  function/module preamble + 输出语句&#34;]
  F --> G[&#34;ssrRender 源码字符串&#34;]

关键在第 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[&#34;根组件 push&#34;] --> S1[&#34;'<div>hello'&#34;]
  S1 --> SUB[&#34;子 buffer: ssrRenderComponent 返回&#34;]
  SUB --> S2[&#34;'<span>world'&#34;]
  SUB --> S3[&#34;Promise (async setup)&#34;]
  R --> S4[&#34;'</div>'&#34;]
  note[&#34;hasAsync 决定 unrollBuffer 是否需要 await<br/>同步 buffer 走 unrollBufferSync 快路径&#34;]

设计动机:如果 _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"

注意两点:

  1. helper 的字符串名由 runtimeHelpers.ts 的 ssrHelpers 决定(16.4 全表),符号 SSR_INTERPOLATE 会被 codegen 翻译成 _ssrInterpolate。
  2. 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、再序列化——纯属浪费。

字符串拼接的三个直接收益:

  1. 零中间对象。_push('<div>hello') 直接进 buffer,不创建 vnode,不分配 children 数组。
  2. 把"合并"下推到编译期。相邻静态内容在产物里就是一个字面量里的连续字符,运行期不需要"合并子 vnode children"。
  3. 流式友好。字符串可以边产生边 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) 的三条规则:

  1. 懒创建:若 currentString 为空,新建一个 _push(...) 调用表达式 push 进 body,并创建空的 TemplateLiteral 作为它的第一个实参。每次"全新一段字符串"都会对应一个新的 _push 调用。
  2. 相邻字符串合并:若 part 是字符串、且当前字面量最后一项也是字符串,则直接字符串拼接(+=)——例如 <div> 的 < 与标签名、或开标签与静态文本。这保证了 _push(\
    hello`)里不会出现'
    ' + 'hello'` 这种运行期相加的残骸。
  3. 非字符串则作为字面量的 ${...} 项插入: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.ELEMENTssrProcessElement开标签字面量 + children + 闭标签
ELEMENT / COMPONENTssrProcessComponent_push(_ssrRenderComponent(...)) 或 _ssrRenderVNode(...)
ELEMENT / SLOTssrProcessSlotOutlet_ssrRenderSlot(...) 语句
ELEMENT / TEMPLATE空(// TODO)template 本身不产出,由结构指令处理
TEXTpushStringPart(escapeHtml(content))转义后拼进字符串
COMMENTpushStringPart('<!--...-->')(除非 disableComment)原样拼进字符串
INTERPOLATIONpushStringPart(_ssrInterpolate(content))模板字面量里的 ${...}
IFssrProcessIfif/else if/else 语句
FORssrProcessFor_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`, ['`<!---->`']),
  ])
}

三个要点:

  1. 第一个 branch 是 if,其后有 condition 的 branch 是 else if(挂成上一个的 alternate),无 condition 的是 else。 这利用了 IfStatement.alternate 的链式结构。
  2. **若最终没有 else 且未禁用注释,补一个 else { _push(\<!---->`) }**。**这个 <!---->是 SSR 的关键约定:v-if 为假时不能"什么都不输出",否则 hydration 时客户端找不到节点来对齐。** 这与客户端createCommentVNode('v-if')` 是同一目的、不同实现。
  3. 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(`<!--]-->`)
}

要点:

  1. 循环变量通过 createForLoopParams 提取,保证 (item, index) 形参与模板一致。
  2. _ssrRenderList(source, renderLoop) 是"运行期遍历"——因为 source 可能是数组、字符串、数字范围、对象、迭代器(见 16.4.4)。
  3. 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)))

三点:

  1. 根是否是 fragment 由"子节点多于一个且至少一个非文本"决定,多根模板因此被 <!--[-->...<!--]--> 包裹(对应 ssrInjectCssVars.spec.ts:31-35 的多根产物)。
  2. ast.codegenNode = createBlockStatement(context.body) —— 用一棵全新的语句块替换掉第一趟留下的 VNodeCall。从这一刻起,generate 看到的不再是 vnode IR,而是纯 JS 语句。
  3. 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_INTERPOLATEssrInterpolatehelpers/ssrInterpolate.ts:3
SSR_RENDER_VNODEssrRenderVNoderender.ts:241(renderVNode 的别名)
SSR_RENDER_COMPONENTssrRenderComponenthelpers/ssrRenderComponent.ts:10
SSR_RENDER_SLOTssrRenderSlothelpers/ssrRenderSlot.ts:24
SSR_RENDER_SLOT_INNERssrRenderSlotInnerhelpers/ssrRenderSlot.ts:48
SSR_RENDER_CLASSssrRenderClasshelpers/ssrRenderAttrs.ts:100
SSR_RENDER_STYLEssrRenderStylehelpers/ssrRenderAttrs.ts:104
SSR_RENDER_ATTRSssrRenderAttrshelpers/ssrRenderAttrs.ts:27
SSR_RENDER_ATTRssrRenderAttrhelpers/ssrRenderAttrs.ts:93
SSR_RENDER_DYNAMIC_ATTRssrRenderDynamicAttrhelpers/ssrRenderAttrs.ts:63
SSR_RENDER_LISTssrRenderListhelpers/ssrRenderList.ts:4
SSR_INCLUDE_BOOLEAN_ATTRssrIncludeBooleanAttr@vue/shared 的 includeBooleanAttr
SSR_LOOSE_EQUALssrLooseEqualhelpers/ssrVModelHelpers.ts:4
SSR_LOOSE_CONTAINssrLooseContainhelpers/ssrVModelHelpers.ts:6
SSR_RENDER_DYNAMIC_MODELssrRenderDynamicModelhelpers/ssrVModelHelpers.ts:11
SSR_GET_DYNAMIC_MODEL_PROPSssrGetDynamicModelPropshelpers/ssrVModelHelpers.ts:30
SSR_RENDER_TELEPORTssrRenderTeleporthelpers/ssrRenderTeleport.ts:10
SSR_RENDER_SUSPENSEssrRenderSuspensehelpers/ssrRenderSuspense.ts:6
SSR_GET_DIRECTIVE_PROPSssrGetDirectivePropshelpers/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)干三件事:

  1. 调用 slot 函数——若它是 SSR 编译产物(形参含 _push),会往一个临时 slotBuffer 里 push;若它是 vnode 分支(返回数组),则走 renderVNodeChildren。
  2. 空内容用 fallback 兜底:SSR 分支下若 slotBuffer 里全是注释,则视为空、调 fallbackRenderFn(ssrRenderSlot.ts:90-104)。
  3. 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/refcontinue(跳过)无
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 分支"的场景保留一份客户端版本。 这是通过"三次遍历"协作完成的:

  1. 第一趟进入阶段(ssrTransformComponent.ts:91-122):判断组件类型、resolveComponentType、识别是否内置组件。
  2. 第一趟退出阶段(ssrTransformComponent.ts:124-201):用克隆节点 + 客户端 transform 生成 vnode 回退分支;用 SSR slot builder 生成 _ssrRenderComponent 调用。
  3. 第二趟(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 级细节值得展开:

  1. processSlotOutlet 来自 compiler-dom(ssrTransformSlotOutlet.ts:11)。它负责把 <slot name="foo" :msg="x"> 解析成 slotName("default" 或动态表达式)+ slotProps(一个对象表达式)。SSR 完全复用客户端的槽解析逻辑,只替换后续的"如何渲染"。 这就是 16.1 说的"compiler-ssr 是插件、不是独立编译器"的又一例证。
  2. args[0] 是 _ctx.$slots——注意它不是 _ctx.$slots.default 这样的静态取值,而是把整个 slots 对象传给运行期,由运行期用 slotName 去索引。这样动态插槽名(<slot :name="n">)在编译期不需要被解析,只需把表达式 _ctx.n 当参数传下去。
  3. 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!)
}

要点:

  1. 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)
  1. 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 保证)。
  2. 最后 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[&#34;slotFn(props||{}, bufferedPush, parent)&#34;]
  D -->|返回 Array| F1[&#34;renderVNodeChildren(vnode 分支)&#34;]
  D -->|否则| G{&#34;slotBuffer 全为注释?&#34;}
  G -->|是| C
  G -->|否| I[&#34;剥掉首尾 fragment 注释后逐条 push&#34;]
  A -->|无| C{fallback?}
  C -->|有| C1[渲染 fallback]
  C -->|无且 transition| C3[&#34;push('

关键设计点:

  1. bufferedPush(ssrRenderSlot.ts:60-63):slot 函数的 _push 不是直通的外层 _push,而是先写进一个局部数组 slotBuffer。 为什么要缓冲?因为运行期必须先判断"这个 slot 是不是空的",再决定渲染它还是渲染 fallback。若直接写进外层 buffer 就无法撤销。缓冲让"渲染内容"与"是否采用"分离。
  2. isEmptySlot 的判定(ssrRenderSlot.ts:88-100):遍历 slotBuffer,只要有一条 isComment 为假,就是"非空"。isComment(ssrRenderSlot.ts:138-145)用正则 /^<!--[\s\S]*-->$/ 判断"整条是不是一条 HTML 注释",并且对 <!---->/<!--[-->/<!--]-->(长度 ≤ 8)直接返回真。这意味着"条件渲染出一个假分支 <!---->"在 slot 里被视为空——于是会渲染 fallback。 这正是 16.6.7 里 #5352 要"push 空串骗过空判定"的原因。
  3. transition 为真时 isEmptySlot = false 被强制(ssrRenderSlot.ts:91-92):因为 transition 内的 slot 不应该被当成"空"来处理(它是被解包的 vnode)。同时尾部还有"若 buffer 首尾恰好是 fragment 注释就剥掉"的特殊处理(ssrRenderSlot.ts:111-128,#9933),避免重复包 fragment。
  4. 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`,
  ]),
)

要点:

  1. to 的解析分两种:静态属性 <teleport to="#modal"> → createSimpleExpression(content, true)(静态字符串);动态指令 :to="target" → 直接用表达式。若 to 完全缺失或值为空 → 编译期报 X_SSR_NO_TELEPORT_TARGET(见 16.9)。 这是一个"编译期就拦下的必然运行期错误"——因为 teleport 没有 target 无法工作。
  2. 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"
    
  3. 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。
  4. 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 级设计点:

  1. 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>
    
  2. findValueBinding(193-201):取 :value="i" 表达式 / 静态 value="bar" 字符串 / 都没有时 null。value="1" 是字符串 "1" 而 model 可能是数字 1,故 loose equal 必须存在。
  3. 递归穿透 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)
事件 @clickonClick: () => ...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 回退分支)
fragmentFragment,锚点运行期处理显式 <!--[-->/<!--]--> 字符串
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 */))
}

逐段对照:

  1. 根 <div id :class>:SSR 用 _mergeProps({...}, _attrs) 把根属性与组件 _attrs 合并后交 _ssrRenderAttrs 序列化;CSR 直接当 vnode props 并标 2 /* CLASS */。_attrs 合并时机:SSR 在编译产物内,CSR 在运行期 $attrs 机制里。
  2. {{ msg }}:SSR 的 ${_ssrInterpolate(...)} 直接内联进 div 的模板字符串;CSR 变成 _createTextVNode(...)(+ " " 正是空白文本节点问题的暴露)。
  3. v-if/v-else:SSR → 两条 if/else push;CSR → 三元 + key:0/key:1(diff 需要 key)。SSR 无 key。
  4. v-for:SSR 用显式 <!--[-->/<!--]--> + _ssrRenderList 边遍历边 push;CSR 用 Fragment + _renderList 返回数组 + 256 /* UNKEYED_FRAGMENT */。
  5. <MyComp/>:SSR 立即 _ssrRenderComponent(...) 同步渲染(第 4 参 null 表示无 slots);CSR 只 _createVNode,真正渲染交 patch。
  6. 收尾 </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(软,相邻合并)vs pushStatement(硬,切断 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 锚点对齐客户端。