Vue 3 核心源码深度剖析系列(v3.5.44)-第 15 章 SSR 与 Hydration:从 server-renderer 到客户端同构

0 阅读1小时+

Vue 3 核心源码深度剖析系列(v3.5.44)-第 15 章 SSR 与 Hydration:从 server-renderer 到客户端同构

本章对应 packages/server-renderer/src/render.ts(420 行)、renderToString.ts(151 行)、renderToStream.ts(221 行)、internal.ts、index.ts、helpers/ssrRenderAttrs.ts(129 行)、helpers/ssrRenderComponent.ts、helpers/ssrRenderSlot.ts(145 行)、helpers/ssrRenderTeleport.ts(50 行)、helpers/ssrRenderSuspense.ts(15 行)、helpers/ssrCompile.ts(93 行);以及客户端侧 packages/runtime-core/src/hydration.ts(1104 行,核心)、hydrationStrategies.ts(149 行)。交叉引用 runtime-core/src/renderer.ts 的 createHydrationRenderer(333–337)、setupRenderEffect 的 hydration 分支(1379–1415)、baseCreateRenderer 里的 createHydrationFns 装配(2501–2507),runtime-core/src/vnode.ts 的 vnode 字段(456–481)与 block 追踪的 NEED_HYDRATION 例外(521–538),runtime-core/src/apiCreateApp.ts 的 mount(container, isHydrate)(358–399),components/Teleport.ts 的 hydrateTeleport(431–542),components/Suspense.ts 的 hydrate(841–890),apiAsyncComponent.ts 的 __asyncHydrate(125–158),shared/src/patchFlags.ts 的 NEED_HYDRATION(59–64)与 BAIL(116–123)。所有行号对应 vuejs/core@3.5.44。

读完本章你将能回答:SSR 到底把哪一段计算从浏览器搬到了服务器,代价是什么?renderToString、renderToStream、renderToSimpleStream 三者的差异不是「同步/异步」而是什么?为什么 SSR 渲染不用字符串拼接而用一棵 SSRBuffer 树?缓冲区里 hasAsync 这个布尔位省掉了多少次 await?renderComponentSubTree 里那个 while (true) 循环在干什么、它和 <style scoped> 的 data-v-xxx 有什么关系?客户端 createHydrationFunctions 返回的两个函数分别是谁在用?hydrateNode 那三百行 switch 是如何把「DOM 里真实存在的节点」和「客户端 vnode 树」逐节点对齐的——游标到底是怎么移动的?hydrateElement 为什么在 hydration 阶段几乎不设置 props、却在 input/option 上反常地「强制 patch」?class 与 style 的 mismatch 判定为什么要先做集合归一化?data-allow-mismatch 有哪几种粒度?hydrateOnIdle/hydrateOnVisible/hydrateOnInteraction 各自的监听器在什么时候被拆除?为什么 hydration 完成的标志是 container._vnode = vnode 而不是别的?


导语:SSR 是「把首屏渲染搬到另一台机器」的一次赌注

前十四章我们讨论的全部是客户端(CSR)模型:组件实例在浏览器里被创建,响应式依赖在浏览器里被追踪,DOM 在浏览器里被插入。本章要讲的 SSR(Server-Side Rendering)做的事情,是把同一个组件树的第一次渲染搬到服务器上执行,把产物直接以 HTML 字符串的形式送给浏览器;等浏览器把这段 HTML 解析成 DOM 之后,客户端再运行一遍同一棵组件树,但这一遍不重新创建 DOM,而是**认领(adopt)**服务器已经生成好的 DOM 节点,只补上那些无法被序列化进 HTML 的东西——事件监听器、响应式依赖、ref、指令绑定的副作用。

这个「认领」的过程就是 hydration(注水 / 水合)。它不是一个可选的小功能,而是 Vue 3 里与渲染器(第 11 章)平行的一整套代码路径:createHydrationFunctions 会往渲染器内部再塞进两个函数 hydrate 与 hydrateNode,renderer.ts 在安装组件时会判断「这个 vnode 是否已经有 el」,如果有就改走 hydration 分支而不是 mount 分支(renderer.ts:1379)。

本章的核心张力只有一句话可以概括:

服务器知道「HTML 长什么样」,客户端知道「这棵组件树长什么样」。hydration 的全部难度,都在于把这两份互相独立、由不同代码路径生成、还可能被人为修改过的信息,逐节点地重新对齐。

这句话里的每个从句都会在本章变成具体的代码:

  • 「服务器知道 HTML 长什么样」→ 15.2–15.7,server-renderer 的三层结构:renderToString/renderToStream 入口、render.ts 的 SSRBuffer 树与 renderVNode 遍历、ssrRenderAttrs/ssrRenderComponent 等编译产物调用的 helper。
  • 「客户端知道组件树长什么样」→ 15.8,createHydrationFunctions 如何被装配进渲染器。
  • 「可能被人为修改过」→ 15.10–15.11,mismatch 的分类、检测与 data-allow-mismatch 逃生舱;以及 15.11 的 lazy hydration 策略——它连「什么时候注水」都可以被推迟到空闲、可见或交互时。
  • 「逐节点重新对齐」→ 15.9、15.12,hydrateNode 的 switch 分派、hydrateChildren 的游标推进、nextSibling/firstChild 的移动规则。

需要提前建立一个判断,它能帮你在读后面所有代码时省很多力气:hydration 阶段与 mount 阶段的差异,不是「做什么」的差异,而是「DOM 从哪来」的差异。 mount 阶段 DOM 由 createElement/createText 造出来;hydration 阶段 DOM 由浏览器解析 HTML 得到。因此凡是「造 DOM」的代码路径在这里都要被替换成「找 DOM」,凡是「设置属性」的代码路径都要先判断「服务器是否已经把正确的值写进去」。理解了这一点,hydrateElement 里那些看起来零碎的 if——为什么 input/option 要强制 patch、为什么 onClick 有快速路径、为什么 PatchFlags.CACHED 可以整个跳过——就都有统一的解释了。

本章的结构是「取舍 → 服务端三层 → 客户端一层 → 对齐算法 → 逃生舱 → 实战」。15.1 先算清 SSR 的收益与代价并给出整体架构图;15.2–15.4 拆服务端入口、缓冲机制与渲染上下文;15.5–15.6 深入组件渲染与 renderToString 全流程;15.7 讲流式渲染;15.8 转入客户端,总览两套 hydration 模式;15.9 是全书最长的一节,逐段走读匹配算法;15.10–15.12 讲 mismatch、策略扩展点与游标定位;15.13 用两个可复现的最小 SSR 应用打印真实控制台输出;15.14 收口。


15.1 SSR 与 CSR 的取舍:首屏、SEO 与同构渲染的代价

15.1.1 CSR 的首屏为什么慢:一条严格串行的依赖链

要理解 SSR 存在的理由,先要把 CSR 的首屏过程拆成时间线。以一个 SPA 为例,浏览器从收到 index.html 到用户看到内容,必须依次完成:

  1. 下载 HTML 外壳。这个外壳几乎是空的——通常只有 <div id="app"></div> 和一个指向 JS bundle 的 <script>。
  2. 下载 JS bundle。这是首屏里最重的一步,现代框架应用的 bundle 动辄几百 KB 到数 MB(压缩前后),且受限于带宽与 RTT。
  3. 解析并执行 JS。V8 需要 parse、compile、最终 execute 整个 bundle,这一步是纯 CPU 阻塞的。
  4. 创建组件实例、执行首次渲染。此时才第一次调用 render,产出 vnode 树。
  5. patch 生成真实 DOM 并插入文档。用户在这一刻之前看到的一直是空白或留白。
  6. 加载首屏依赖的数据。多数应用在这一步才发起 API 请求。

关键在于:第 1–5 步与第 6 步之间是严格串行的。 组件树在浏览器执行 JS 之后才产生,而组件树里的数据请求通常挂在组件的 setup 里,故 JS 执行完之前你连「要向哪个 API 发请求」都不知道。串行链长度决定了首屏时间(FCP / LCP)的下限。

再叠加两个现实因素:其一,浏览器下载 bundle 与解析执行 bundle 是两段不能重叠的等待;其二,用户的设备越弱,第 3 步越贵——在低端安卓设备上,执行一段 500KB 的 bundle 可能需要几百毫秒到一秒。结果是:CSR 在「服务器已经准备好全部数据」的场景下,仍然要白白多等一整条 JS 链。

15.1.2 SSR 把首屏的哪一段搬走了

SSR 的做法是把第 4、5 步——「执行组件渲染函数、产出 DOM 结构」——搬到服务器上。服务器执行同一棵组件树,产出完整 HTML 直接返回给浏览器。浏览器收到的响应体里已经包含全部首屏内容,因此:

  • 浏览器不需要等 JS 就能渲染出内容。HTML 一到、解析器一走,用户就看到页面了。
  • SEO 爬虫、社交平台抓取器、禁用了 JS 的环境,都能拿到完整内容。这对内容型站点是硬需求。
  • 首屏的数据请求发生在服务器上。服务器到数据源通常是内网、低延迟,而且可以在渲染过程中同步地(或至少并发地)把数据取回来。

于是时间线变成了:服务器渲染(CPU)→ 一次性把「HTML + 数据」送给浏览器。浏览器收到就能画,JS 后面到了再「认领」。

15.1.3 SSR 的代价:五笔必须付的账

但 SSR 不是免费的午餐。它的代价恰恰藏在「同构」这个词里——同一份组件代码必须在两套完全不同的环境里都跑得通。

代价一:代码必须在 Node(或边缘运行时)里可执行。 组件里任何 window、document、localStorage 的直接引用都会在服务器上抛错。Vue 的组件本身不碰 DOM(DOM 操作都在渲染器里),所以编写良好的组件天然是环境无关的;但一旦你在 setup 里手写 document.querySelector,SSR 立刻碎掉。这要求开发者把「副作用」从「渲染」里剥离出来——这正是 onServerPrefetch 与 onMounted 分工的原因(见 15.5)。

代价二:服务器 CPU 成本。 每个请求都要重新执行一遍渲染函数。CSR 只在客户端做这件事一次(甚至零次,因为 HTML 是静态的)。SSR 把一个 «每用户一次» 的 CPU 开销压到了服务器上,需要靠缓存、流式渲染、并发控制来摊薄。renderToString 提供的 options.concurrency(见 15.5/15.6)就是这个账本上的一个旋钮。

代价三:hydration 本身的成本。 客户端仍要下载并执行 JS(除非用 lazy hydration 推迟一部分),然后遍历整棵 DOM 与 vnode 树做对齐——这是 SSR 没有省掉的部分:它只省掉「生成 DOM」,留下了「对齐」。事实上大型页面的 hydration 可能比纯 mount 还慢,因为 mount 一遍写入、hydration 是「读 + 比对 + 部分写入」。这就是 lazy/incremental hydration 的理由(15.11)。

代价四:内存与状态的双份。 服务器侧要维护一个 SSRContext(render.ts:50),客户端侧要维护 appContext,两者之间只能通过序列化(注入 <script> 的 JSON)传递。任何不能被 JSON 序列化的东西——函数、类实例、Map/Set、响应式 proxy——都不能直接传。

代价五:mismatch 的调试成本。 因为服务器与客户端执行的是「两份可能不同步的代码」,任何一处细微差异(时钟、随机数、v-if 依赖了客户端才有的状态)都会造成 DOM 结构对不上。Vue 3 为此建了一整套 mismatch 检测与警告体系(15.10)。

15.1.4 什么时候该用 SSR,什么时候不该

场景建议理由
内容型站点(博客、文档、电商详情)SSR / SSGSEO + 首屏内容可见是硬需求,组件不复杂,服务器成本可接受
强交互后台、内部工具CSR登录后才有内容,SEO 无意义,交互复杂度远大于首屏
首屏内容少、后续全是交互CSR 或 islandshydration 整棵树不划算,用 islands/部分注水更优
数据高度个性化、依赖客户端环境SSR + 客户端补数据服务器只渲染骨架,个性化数据在客户端拉
极弱网络 + 极弱设备SSR + lazy hydration用 hydrateOnVisible 把可视区外的注水推迟

一个容易忽略的点:SSG(静态生成)就是把 SSR 的那次渲染提前到构建时。 对 Vue 而言,renderToString 完全不变——它只是一个「把 vnode 树变成 HTML 字符串」的函数,你在构建时调用它还是在请求时调用它,它不知道也不关心。Vue 本身不提供 SSG 框架(这是 Nuxt/VitePress 的职责),但 @vue/server-renderer 与 createSSRApp 是它们共同的地基。

15.1.5 Vue 3 的 SSR 整体架构图

Vue 3 的 SSR 是编译期 + 运行期两段配合的产物。理解下面这张图,本章后面所有模块的归属就清楚了:

flowchart TB
  subgraph BuildTime[&#34;构建期(Node/Vite)&#34;]
    SFC[&#34;SFC .vue 文件&#34;] --> CST[&#34;@vue/compiler-sfc&#34;]
    CST --> CS[&#34;@vue/compiler-ssr<br/>(transform + codegen)&#34;]
    CS -->|&#34;生成 ssrRender(ctx, push, ...)&#34;| OUT[&#34;服务端渲染函数<br/>如 _ssrRender 或 comp.ssrRender&#34;]
  end

  subgraph Server[&#34;服务端(Node / Edge)&#34;]
    APP[&#34;createSSRApp(App)&#34;] --> RTS[&#34;renderToString(app, ctx)&#34;]
    RTS --> RCV[&#34;render.ts / renderComponentVNode&#34;]
    RCV --> RV[&#34;renderVNode → renderElementVNode&#34;]
    RV --> HELP[&#34;ssrRenderAttrs / ssrRenderSlot / ssrRenderTeleport&#34;]
    RCV --> BUF[&#34;SSRBuffer 树&#34;]
    BUF --> UNROLL[&#34;unrollBuffer → HTML 字符串&#34;]
    UNROLL --> HTML[&#34;完整 HTML 文本&#34;]
    HTML --> INJECT[&#34;序列化 payload 注入 &#34;]
  end

  subgraph Client[&#34;客户端(浏览器)&#34;]
    HTML2[&#34;收到的 HTML&#34;] --> PARSE[&#34;浏览器解析成真实 DOM&#34;]
    PARSE --> HYD[&#34;createSSRApp(App).mount('#app')&#34;]
    HYD --> HF[&#34;createHydrationFunctions&#34;]
    HF --> HN[&#34;hydrateNode → hydrateElement/hydrateChildren&#34;]
    HN -->|&#34;认领 DOM&#34;| READY[&#34;可交互页面(事件/响应式就绪)&#34;]
  end

  OUT -.->|&#34;同一份源码&#34;| APP

图中有两条边最值得注意:

  • 从 compiler-ssr 到服务端的那条虚线:SSR 渲染函数与客户端渲染函数是分别编译的两份产物。 同一个 <template> 会被编译出「客户端的 render」和「服务端的 ssrRender」两个函数。Vue 3 的 SSR 之所以快,很大一部分原因是 ssrRender 是编译期特化的——它在生成代码时就知道「这个标签是静态的、那个属性是动态的」,于是直接产出字符串拼接代码,而不是像客户端那样先造 vnode 再 patch。若组件没有 ssrRender(比如手写 render 函数),服务端会退回 renderVNode 通用路径(render.ts:224),或者在有 template 且是 CJS 构建时用 ssrCompile 现场编译(render.ts:161–168、ssrCompile.ts:22)。
  • 从 HTML 到浏览器解析的那条边:服务器产出的 HTML 必须能被浏览器「无损地」解析回与 vnode 树同构的 DOM。 这是 hydration 能成立的前提,也是所有 mismatch 的根源——因为 HTML 有它自己的规范(属性大小写折叠、空白折叠、<pre> 吃掉首行换行、v-if 的空洞变成注释等),服务器写入时的形态与浏览器解析出的形态可能不同。

15.1.6 服务端 vnode 与客户端 vnode 的不同:SSR 不是「把 CSR 搬到 Node」

初学者常有一个误解:SSR 就是在 Node 里 createApp().mount(el)。这不成立,原因有三:

  1. Node 里没有 DOM。 标准 createApp 绑定的是运行时 DOM 渲染器(runtime-dom),它的 mount 需要真实容器。createSSRApp 走的是另一个渲染器(ensureHydrationRenderer,runtime-dom/src/index.ts:87–93),它的 mount 甚至不渲染任何东西——它只负责把组件实例与 vnode 挂起来,等真正的 HTML 由 renderToString 产出。
  2. 服务端渲染依赖的是 ssrRender 而非 render。 两者签名与语义都不同:客户端 render() 返回 vnode 树;服务端 ssrRender(proxy, push, instance, attrs, props, setupState, data, ctx) 直接向 push 推字符串(render.ts:208–218)。服务端根本不需要一棵 vnode 树去 diff,它只需要「输出」。
  3. 服务端不做响应式追踪。 服务器渲染是一次性的,没有后续更新,因此不需要 patch、不需要 diff、不需要 dynamicChildren 优化。往 SSRBuffer 里推的就是最终字符串。

所以准确的说法是:SSR = 「用一套与客户端同源的组件代码 + 一套独立的服务端渲染管线,产出 HTML」,而不是「在 Node 里跑客户端渲染」。 hydration 才是两者真正交汇的地方。


15.2 server-renderer 入口:renderToString / renderToStream / renderToSimpleStream

15.2.1 三个入口不是「同一件事的三种写法」

packages/server-renderer/src/index.ts:5–16 导出的公开 API 里有三个形似「渲染成字符串」的入口:

// index.ts:5
export type { SSRContext } from './render'
export { renderToString } from './renderToString'
export {
  renderToSimpleStream,
  renderToNodeStream,
  pipeToNodeWritable,
  renderToWebStream,
  pipeToWebWritable,
  type SimpleReadable,
  // deprecated
  renderToStream,
} from './renderToStream'

它们的关系容易被误读为「同步 vs 异步」「Node vs Web 的三种适配」。真实的分层是这样的:

  • renderToSimpleStream 是唯一的底层原语(renderToStream.ts:60)。它接受任意一个形如 { push(chunk|null): void, destroy(err): void } 的 SimpleReadable(接口定义见 renderToStream.ts:20–23),渲染过程中不断调用 push,结束时 push(null),出错时 destroy(error)。
  • renderToString 是「把流攒成一个大字符串」的封装,或者更准确地说,它是独立实现的:它不经过 SimpleReadable,而是先拿到 SSRBuffer 树,再用 unrollBuffer 一次性摊平成字符串(renderToString.ts:70–96)。
  • renderToNodeStream / pipeToNodeWritable / renderToWebStream / pipeToWebWritable 都是 renderToSimpleStream 的适配器:它们各自实现一个 SimpleReadable,把 push 转发到 Node Readable、Node Writable、Web ReadableStream 或 Web WritableStream。
  • renderToStream 只是一个 deprecated 别名,它 console.warn(renderToStream.ts:108)之后转调 renderToNodeStream。

一句话概括差异:

renderToString = 先建完整缓冲区再一次性摊平;renderToSimpleStream = 边遍历缓冲区边把已就绪的片段推给下游。核心区别不在「同异步」,而在「是否等待整棵树完成」。

这个区别在流式场景下是决定性的:renderToString 必须等到所有 async setup / onServerPrefetch 都 resolve、所有 SSRBuffer 都摊平之后,才能开始「往外送」第一个字节;而 renderToSimpleStream 可以在遇到第一个尚未 resolve 的 Promise 时,先把此前已经攒好的同步字符串推给下游(renderToStream.ts:29–46),从而让浏览器边收边解析。

15.2.2 renderToString 的签名与 options

// renderToString.ts:70
export async function renderToString(
  input: App | VNode,
  context: SSRContext = {},
): Promise<string> {
  if (isVNode(input)) {
    // raw vnode, wrap with app (for context)
    return renderToString(createApp({ render: () => input }), context)
  }
  // rendering an app
  const vnode = createVNode(input._component, input._props)
  vnode.appContext = input._context
  // provide the ssr context to the tree
  input.provide(ssrContextKey, context)
  ...
}

要点:

  • input 可以是 App 也可以是裸 VNode。 传 VNode 时它会「用一个只有 render: () => input 的壳 App 包起来」,目的只有一个:让 context 能通过 provide(ssrContextKey, context) 进入组件树(renderToString.ts:83)。这解释了为什么 useSSRContext() 能从组件内部拿到你传给 renderToString 的那个对象——它本质上是一次 inject(ssrContextKey)。
  • context 默认 {},它是一个「任意键 + 三个内部键」的宽松结构(render.ts:50–61):teleports、__teleportBuffers、__watcherHandles。
  • 注意 input.provide 发生在渲染之前,所以即便某个组件在 setup 里同步调用 useSSRContext(),也能拿到。
  • 渲染结束后走到 resolveTeleports(context)(renderToString.ts:89),把收集到的 <Teleport> 内容填进 context.teleports;再 stopWatchers(context)(renderToString.ts:94)停掉 SSR 期间注册的 watcher。
  • 失败路径:catch 里调用 cleanupFailedRender(buffer, context) 后原样 throw(renderToString.ts:90–93)。cleanupFailedRender(renderToString.ts:113–128)做的事很讲究:先把已注册的 watcher 停掉,再异步 settleBuffer 等所有在途的 buffer 落定(吞掉它们的拒绝),最后停掉「失败点之后才注册」的那批 watcher。注释里那句「Components after the failure point are still in flight and can register watchers once the render has settled」正是这段代码存在的理由——渲染已经失败了,但还有 Promise 在飞,它们可能还会再注册 watcher,必须在它们落定后清理,否则内存泄漏。

15.2.3 renderToStream 家族的签名与返回类型

// renderToStream.ts:114 — 唯一能在服务端拿到 Node Readable 的入口
export function renderToNodeStream(input: App | VNode, context: SSRContext = {}): Readable

// renderToStream.ts:133
export function pipeToNodeWritable(input: App | VNode, context?: SSRContext, writable: Writable): void

// renderToStream.ts:152
export function renderToWebStream(input: App | VNode, context: SSRContext = {}): ReadableStream

// renderToStream.ts:189
export function pipeToWebWritable(input: App | VNode, context?: SSRContext, writable: WritableStream): void

几个 MUST 知道的约束:

  1. ESM 构建不支持 renderToNodeStream。 renderToStream.ts:118–128 用 __CJS__ 分支 + require('node:stream') 拿 Readable;在 ESM 下 stream 为 null,于是抛错并提示你改用 pipeToNodeWritable(自己准备一个 Writable)。这是因为打包器通常无法在 ESM 里保留对 node:stream 的运行时 require。
  2. renderToWebStream 要求全局有 ReadableStream 构造器(renderToStream.ts:156–162),Node 18+ 才全局提供;更老的 Node 需要 renderToWebStream 之外的 adapter。
  3. pipeToWebWritable 处理了 CloudFlare Workers 的兼容性:CF 的 writer.ready 不是 Promise,代码用一个 try/catch 探测 hasReady(renderToStream.ts:197–201),只有在是 Promise 时才 await writer.ready。
  4. 流对象返回时机:renderToNodeStream 同步返回 stream,但渲染是异步推进的(renderToSimpleStream 立即返回 stream,内部用 Promise 链驱动)。也就是说你拿到 Readable 时它还是空的,数据会在微任务/后续 tick 里陆续 push。

15.2.4 onError / onWarn / concurrency / scopeId 这些 options 在哪

网上常见的 SSR 文档会提到 onError、onWarn、concurrency、scopeId 等选项,但在 server-renderer 的 renderToString/renderToSimpleStream 签名里,context 才是唯一的第二参数。这些「选项」实际是通过 SSRContext 透传、或通过 app.config / 编译配置生效的:

名义上的 option在 3.5.44 中的真实落点说明
onErrorapp.config.errorHandler / handleError(render.ts:220 捕获渲染错误后调用 handleError)组件渲染函数抛错时进入错误处理链,而非静默丢弃
onWarnapp.config.warnHandler;DEV 下 pushWarningContext/popWarningContext(render.ts:104–106、145–146、237)用来把警告归属到正确的组件
concurrency不在 server-renderer 运行时;Nuxt 等框架在上层实现指「同一时刻允许并发渲染的请求数」,属于框架层调度
scopeId组件 vnode.scopeId + slotScopeId(render.ts:178–203),来自 compiler-sfc 的 data-v- 注入服务端在开标签后拼接 data-v-xxx(见 15.4)
context(自定义键)直接挂在 SSRContext 上useSSRContext() 读它;teleports/payload 都存这里

这张表想传达一个判断:server-renderer 暴露的「配置面」很窄,它把「并发控制」「缓存」「payload 注入」都留给了上层框架。 这是刻意的分层——Vue 只保证「给定 vnode 树,产出正确 HTML」,至于「一个进程里同时渲染多少个请求」「渲染结果要不要缓存」,那是 HTTP 层的事。

15.2.5 build 产物与 initDirectivesForSSR

index.ts:1–2 有一行容易忽略但很关键:

import { initDirectivesForSSR } from '@vue/runtime-dom'
initDirectivesForSSR()

它在模块加载时就把内置指令的 SSR 版本注册进 runtime-dom。原因是指令在服务端与客户端的生命周期不同:客户端 v-show 是在 mounted/updated 里操作 DOM,而服务端没有 DOM,只能通过 getSSRProps 在属性渲染阶段把结果「算进 HTML」(见 render.ts:387–404 的 applySSRDirectives)。initDirectivesForSSR 会把 v-show、v-model 等指令的 getSSRProps 装上,这样 renderVNode 在遇到带指令的 vnode 时才有一份「服务端属性」可合并。

renderVNode(push, vnode, ...)            // render.ts:241
  └─ if (vnode.dirs)                     // :248
        vnode.props = applySSRDirectives(vnode, props, dirs)  // :249, :387
           └─ 对每个 dir 调 getSSRProps(binding, vnode),mergeProps 合并  // :393–403

这一条解释了 15.10 里一个坑:带 created 钩子的自定义指令会在 hydration 阶段跳过属性 mismatch 检查(hydration.ts:526–529 的注释「#11189 skip if this node has directives that have created hooks as it could have mutated the DOM in any possible way」)——因为指令可能任意改写 DOM,比较属性没有意义。


15.3 render.ts 的核心缓冲机制:SSRBuffer / PushFn 的设计

15.3.1 为什么不是字符串拼接

最朴素的服务端渲染实现是「递归遍历 vnode 树,遇到元素就 html += '<div>',遇到文本就 html += text,最后返回这个大字符串」。Vue 3 没有这么做,而是选择了一棵嵌套的数组树:

// render.ts:45
export type SSRBuffer = SSRBufferItem[] & { hasAsync?: boolean }
export type SSRBufferItem = string | SSRBuffer | Promise<SSRBuffer>
export type PushFn = (item: SSRBufferItem) => void

先看这个类型本身。SSRBufferItem 有三种可能:字符串、子缓冲(SSRBuffer,即递归的数组)、或「解析为缓冲的 Promise」。SSRBuffer 是数组,但额外挂了一个 hasAsync?: boolean —— 用一个数组的自定义属性当「这位携带异步内容」的标记。这个布尔位是整套设计的枢纽,下面会看到它被用来跳过大量 await。

为什么用数组树而不是字符串?三个理由:

理由一:async 内容无法在同步拼接里表示。 一旦组件里有 async setup() 或 onServerPrefetch,它的输出就要等一个 Promise。字符串拼接到一半,你没法「先拼一半,等会儿再接上」。数组允许在任意位置塞进一个「将来会变成字符串」的占位符(Promise<SSRBuffer>),遍历时遇到就 await。

理由二:同步片段可以整体下推,不必回填。 数组树让「已完成的子树」保持为一个独立数组。流式渲染时,遇到未 resolve 的 Promise 之前的所有已完成片段可以整体交给下游(renderToStream.ts:41–45),不需要等整棵树。

理由三:顺序保证与嵌套 Teleport 的局部重排。 <Teleport> 的内容最终要出现在页面别处(teleports[target]),但它在原位置只是一个注释 <!--teleport start-->(ssrRenderTeleport.ts:17)。用数组树并给每个 target 维护一个 buffer,就能实现「先渲染、后拼接、还能嵌套」(ssrRenderTeleport.ts:22–42)。

15.3.2 createBuffer:appendable 与 hasAsync 两个状态位

export function createBuffer() {
  let appendable = false
  const buffer: SSRBuffer = []
  return {
    getBuffer: () => buffer,
    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
    },
  }
}

逐行看,它包含三个优化决策:决策一:字符串连续时原地追加(appendable)。 上一个是字符串就直接 += 到 buffer 末尾而非 push 新元素——编译产物里 push('<div class="a">')、push(text)、push('</div>') 连续出现,没有这个合并一个页面会产生成千上万个长度个位数的数组元素;有了它连续字符串被折成几个长字符串,摊平阶段(unrollBufferSync)的循环与拼接次数都大幅下降。决策二:遇到非字符串就把 appendable 置 false。 push(item); appendable = isStringItem:推的是子缓冲或 Promise 时下一个字符串会作为新元素入列,而不会被误拼到 Promise 上(否则得到 "[object Promise]xxx"),保证「只在紧邻字符串时合并」的不变量。决策三:hasAsync 只在「真异步」时置位。 条件是 isPromise(item) 或 isArray(item) && item.hasAsync——子缓冲内部全同步则不污染父级,只有它自己标记了 hasAsync 父级才跟着标记;这条传播规则使「整棵树是否含异步」可在 O(1) 判断:根 buffer 的 hasAsync 为 false 就说明整棵树能纯同步摊平,unrollBuffer 走 unrollBufferSync,一次 await 都不需要。

buffer = []
push('<div>')      → ['<div>'],          appendable=true
push('hello')      → ['<div>hello'],     appendable=true
push('</div>')     → ['<div>hello</div>'],appendable=true
push(subBuffer)    → ['<div>hello</div>', subBuffer], appendable=false
push('</section>') → [..., subBuffer, '</section>'],  appendable=true

15.3.3 「缓冲区刷新阈值」到底存不存在

任务大纲里问「缓冲区刷新阈值(buffer 大小)」——需要澄清:Vue 3.5.44 的 SSRBuffer 没有容量阈值,也不会在超过 N 个字符后自动 flush。 它是一个纯内存的数组树,直到 renderToString/renderToSimpleStream 的摊平阶段才被消费。所谓「背压」也不是通过 buffer 大小实现的,而是由下游 stream 的行为决定:

  • pipeToNodeWritable 直接把每段字符串 writable.write(content)(renderToStream.ts:140–142),Node Writable 自己维护 highWaterMark 并返回 false 表示「先别写了」。但 Vue 的实现没有 await write 的返回值,所以严格来说它不做背压(backpressure)——它信任下游 Writable 的内部缓冲。
  • pipeToWebWritable 做了背压:await writer.ready(若存在)之后才 writer.write(...)(renderToStream.ts:204–210),并把 write 返回的 Promise 返回给调用方。

所以准确表述是:buffer 的「粒度」是子树级(每个组件一个 buffer),不存在阈值刷新;阻塞/背压由各 adapter 各自决定,Web Writable adapter 是唯一严格做背压的。

15.3.4 摊平:nestedUnrollBuffer 的同步/异步双通道

renderToString.ts 的摊平算法值得完整推演,因为它把「数组树 + hasAsync」的威力用到了极致:

function nestedUnrollBuffer(buffer: SSRBuffer, parentRet: string, startIndex: number): Promise<string> | string {
  if (!buffer.hasAsync) return parentRet + unrollBufferSync(buffer)
  let ret = parentRet
  for (let i = startIndex; i < buffer.length; i += 1) {
    const item = buffer[i]
    if (isString(item)) { ret += item; continue }
    if (isPromise(item)) return item.then(nestedItem => { buffer[i] = nestedItem; return nestedUnrollBuffer(buffer, ret, i) })
    const result = nestedUnrollBuffer(item, ret, 0)
    if (isPromise(result)) return result.then(nestedItem => { buffer[i] = nestedItem; return nestedUnrollBuffer(buffer, '', i) })
    ret = result
  }
  return ret
}

算法要点:1)同步子树的「快车道」——if (!buffer.hasAsync) return parentRet + unrollBufferSync(buffer) 让所有不含异步的子树(页面上绝大多数静态内容)完全同步拼成一整段字符串,零 Promise、零微任务。2)异步回填与「续跑」——遇到 Promise 就 await 并替换成解析结果(buffer[i] = nestedItem),再从同一 startIndex i 重新进入 nestedUnrollBuffer(buffer, ret, i);这就是 startIndex 参数的用途:摊平可「中断—续跑」不必重头,且 ret 带入续跑(保留已拼好的前缀)以避免重复拼接。3)子缓冲异步的区分处理——若 item 是子缓冲且 nestedUnrollBuffer(item, ret, 0) 返回 Promise,则 buffer[i] = nestedItem 之后用 ret='' 续跑(renderToString.ts:41–43):因为子缓冲的解析结果已包含它自己的全部内容,父级此前累积的 ret 已通过 parentRet 参数带进子缓冲的拼接里,这里传 ret 会重复拼接——一处极容易被误读为笔误的精心设计。

用一个时序图表示「两段同步 + 一个 async 组件 + 一段同步」的摊平:

sequenceDiagram
  participant B as SSRBuffer(hasAsync=true)
  participant U as nestedUnrollBuffer
  participant P as async 组件 Promise
  B->>U: unrollBuffer(buffer)
  Note over U: i=0 字符串 → ret += str0
  Note over U: i=1 字符串 → ret += str1
  Note over U: i=2 是 Promise → 返回 item.then(...)
  U->>P: await
  P-->>U: 子字符串 subHTML
  Note over U: buffer[2] = subHTML
  U->>U: nestedUnrollBuffer(buffer, ret, 2) 续跑
  Note over U: i=2 现在已是字符串 → ret += subHTML
  Note over U: i=3 字符串 → ret += str3
  U-->>B: 完整字符串

15.3.5 流式摊平:unrollBuffer(stream) 的差别

renderToStream.ts 里的 unrollBuffer 与上面的 nestedUnrollBuffer 结构相似,但不返回字符串,而是把片段推进 stream:

async function unrollBuffer(buffer: SSRBuffer, stream: SimpleReadable): Promise<void> {
  if (buffer.hasAsync) {
    for (let i = 0; i < buffer.length; i++) {
      let item = buffer[i]
      if (isPromise(item)) item = await item
      isString(item) ? stream.push(item) : await unrollBuffer(item, stream)
    }
  } else unrollBufferSync(buffer, stream)
}

与 renderToString 版本的三个关键差异:1)不再回填 buffer——流一旦推出去就不再回头,省掉写操作,但也意味着 buffer 在流式路径里是「只读」的。2)同步子树一次性下发——unrollBufferSync(renderToStream.ts:48)把一整棵同步子树的所有字符串按顺序 stream.push,下游看到「一大块连续 HTML」,符合「一个组件的输出是完整的一段」的直觉。3)恰恰是流式的意义所在——异步 buffer 的 for 循环中,await item 之前的项早已 push 出去,下游收到的数据与上游的等待是重叠的:浏览器可在服务器还在等某个 async 组件数据时,就已开始解析前面同步的内容。

15.3.6 一个 buffer 的实例:一棵组件树的 buffer 形态

假设页面结构是 <App> 里有个 <Layout>,Layout 里有同步的 <Header> 和异步的 <Feed>:

App buffer                = [ '<!---->', Layout buffer, '<!---->' ]
Layout buffer             = [ '<div class="layout">', Header buffer, Feed Promise, '</div>' ]
Header buffer             = [ '<header>...</header>' ]           // hasAsync 未置位
Feed Promise              → 将来 resolve 为 Feed buffer = [ '<ul>...</ul>' ]

App buffer.hasAsync 因为 Layout buffer hasAsync 为 true 而为 true(注意传播规则只在 push 时检查 isArray(item) && item.hasAsync,而 Layout buffer 被 push 进 App buffer 时已经是 async 的——这依赖子组件先渲染完自己的 buffer 再 push 给父级,顺序在 renderComponentSubTree 里天然成立,因为 push(renderComponentVNode(...)) 返回的是子 buffer 或 Promise)。


15.4 SSR 渲染上下文:SSRContext 结构与调用约定

15.4.1 SSRContext 的字段与内部约定

export type SSRContext = {
  [key: string]: any
  teleports?: Record<string, string>
  /** @internal */ __teleportBuffers?: Record<string, SSRBuffer>
  /** @internal */ __watcherHandles?: (() => void)[]
}
字段归属用途生命周期
[key: string]: any用户任意自定义数据(payload、路由信息、<html lang> 等)用户自管
teleports用户读取最终 <Teleport> 内容的字符串映射({ '#modal': '<div>...' })resolveTeleports 写入(renderToString.ts:140–150)
__teleportBuffers内部渲染过程中收集的 teleport 缓冲(未摊平)ssrRenderTeleport 写入(ssrRenderTeleport.ts:22–24)
__watcherHandles内部SSR 期间注册的 watch 停止句柄stopWatchers 消费(renderToString.ts:98–105)

为什么要分 teleports 和 __teleportBuffers 两层? 因为渲染时内容还没定稿——可能有异步组件在 target 里,所以渲染阶段只能存「缓冲」,等 renderToString 的收尾阶段统一摊平成字符串写进 teleports。这是一个典型的「渲染期收集、收尾期结算」模式。

resolveTeleports(renderToString.ts:140)里那句注释「it's OK to await sequentially here because the Promises were created eagerly in parallel」是理解设计意图的钥匙:虽然这里是 for 循环顺序 await,但每个 teleport 的 Promise 在渲染阶段就已经并发创建了,所以顺序 await 只是「按序取值」,不引入串行等待。这是并发编程里「先并发发起、后顺序收集」的标准模式。

15.4.2 useSSRContext 的注入点

ssrContextKey 由 renderToString 通过 input.provide(ssrContextKey, context)(renderToString.ts:83)注入根组件。组件内 useSSRContext() 就是 inject(ssrContextKey)。因此:

  • 每个 SSR 请求必须传一个独立的 context。如果你复用同一个 context 对象跨请求,teleports/__teleportBuffers/__watcherHandles 会串味——这是 SSR 框架里最常见的并发 bug 之一。
  • context 是「每请求」而不是「每进程」。__teleportBuffers 里的 buffer 数组持有渲染中间态,跨请求复用会导致内容错位。

15.4.3 renderComponentVNode 与 ssrRender 的调用约定

服务端的核心转发函数是 renderVNode(render.ts:241)。它把不同 vnode 类型分派到不同处理:

// render.ts:241–297(节选)
export function renderVNode(push, vnode, parentComponent, slotScopeId) {
  const { type, shapeFlag, children, dirs, props } = vnode
  if (dirs) {
    vnode.props = applySSRDirectives(vnode, props, dirs)
  }
  switch (type) {
    case Text:     push(escapeHtml(children as string)); break
    case Comment:  push(children ? `<!--${escapeHtmlComment(children as string)}-->` : `<!---->`); break
    case Static:   push(children as string); break             // 已编译的静态串,直接推
    case Fragment: push(`<!--[-->`); renderVNodeChildren(...); push(`<!--]-->`); break
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) renderElementVNode(push, vnode, parentComponent, slotScopeId)
      else if (shapeFlag & ShapeFlags.COMPONENT) push(renderComponentVNode(vnode, parentComponent, slotScopeId))
      else if (shapeFlag & ShapeFlags.TELEPORT) renderTeleportVNode(...)
      else if (shapeFlag & ShapeFlags.SUSPENSE) renderVNode(push, vnode.ssContent!, parentComponent, slotScopeId)
      else warn('[@vue/server-renderer] Invalid VNode type:', type, `(${typeof type})`)
  }
}

对照表:服务端承载与客户端承载的对应关系

vnode 类型客户端 renderer服务端 render.ts输出/行为差异
TexthostCreateText + 插入push(escapeHtml(children))服务端必须 HTML 转义;客户端不需要
CommenthostCreateCommentpush('<!---->') 或 <!--xxx-->空注释用于占位(v-if 空洞、Fragment 边界)
StaticcloneStaticVNode 复用push(children) 直接输出服务端零成本;客户端靠 CACHED 跳过 patch
FragmentcreateText/锚点注释<!--[--> ... <!--]-->用注释模拟 fragment 边界,供 hydration 识别
ElementhostCreateElement + patchProprenderElementVNode 拼开/闭标签 + ssrRenderAttrs属性合并成字符串
ComponentmountComponent + effectrenderComponentVNode → ssrRender 或 renderVNode无响应式 effect
Teleport真把节点搬到 targetrenderTeleportVNode → 注释占位 + 存 target buffer内容后置拼接
Suspense边界 + fallback 切换renderVNode(ssrContent) 只渲染已解析内容服务端无 fallback 切换逻辑(见 ssrRenderSuspense)

15.4.4 ssrRender 的八个参数:为什么这么「宽」

编译产物调用 ssrRender 时传入了八个参数(render.ts:207–218):

ssrRender(
  instance.proxy,      // 1. 组件公开实例(模板里 this 的替代)
  push,                // 2. PushFn
  instance,            // 3. 组件内部实例
  attrs,               // 4. 透传属性(含 scopeId 注入后)
  instance.props,      // 5. props
  instance.setupState, // 6. setup 返回的绑定
  instance.data,       // 7. Options API 的 data
  instance.ctx,        // 8. 渲染上下文
)

这是 compiler-ssr codegen 的固定 ABI。编译期会做「绑定特化」:模板里 {{ msg }} 如果 msg 来自 setupState,编译产物就直接写 _ctx.msg 或把对应参数作为 $setup 传入,运行时不再做「先查 props 再查 setup 再查 data」的动态查找。helpers/ssrInterpolate.ts 与 ssrRenderAttrs 也在编译期被替换成对内联 helper 的调用。

调用约定里还有两个隐含约束:

  • push 必须同步调用,除非你推的是 Promise。 ssrRenderSuspense(ssrRenderSuspense.ts:6–14)的注释明确写了「Must remain synchronous: compiled output is ssrRenderSuspense(_push, ...), not _push(ssrRenderSuspense(...)), so any returned Promise (and its rejection) would be silently discarded.」。凡是编译产物不把返回值交给 push 的地方,你都不能让那个 helper 返回 Promise——否则 rejection 会被吞掉。
  • attrs 传的是「克隆后的」透传属性。 renderComponentSubTree 里的 hasCloned 逻辑(render.ts:175–203)保证只在真的要改 attrs(加 scopeId 或 slotScopeId)时才浅拷贝,避免每层都拷贝一份。

15.4.5 scopeId 与 slotScopeId:<style scoped> 的服务端拼接

<style scoped> 在编译期会给组件的每个元素加上 data-v-xxxxxxxx。服务端渲染时,这个 id 不能靠 patchProp 设置(服务端没有 DOM),而是在拼接开标签字符串时手工拼上去:

// render.ts:336–354
if (scopeId) appendScopeId(scopeId)
// 本 vnode 恰为父组件 subTree 根时,把父链上所有 scopeId 也拼上
let curParent = parentComponent, curVnode = vnode
while (curParent && curVnode === curParent.subTree) {
  curVnode = curParent.vnode
  if (curVnode.scopeId) appendScopeId(curVnode.scopeId)
  curParent = curParent.parent
}
if (slotScopeId) slotScopeId.trim().split(' ').forEach(appendScopeId)

这里有三个要点:

  1. renderedScopeIds 去重(render.ts:324–334):同一个 scope id 不会重复拼两次。
  2. hasOwn(props, id) 例外(render.ts:327):如果 props 里已经显式有同名属性,说明这个 scopeId 会通过 ssrRenderAttrs 输出,这里就跳过,避免重复。
  3. 父链继承(那个 while 循环):当某个 vnode 恰好是它父组件的 subTree 根(即这是「子组件渲染的根元素」),要把父链上所有 scopeId 也拼上。这样 <style scoped> 的样式才能正确命中「子组件根元素被父组件样式影响」的场景。

而 renderComponentSubTree 里那段同样味道的 while (true)(render.ts:177–195)解决的是attrs 继承:如果某个非 SSR 编译的组件以本组件为根渲染这个子组件,要把父的 scopeId 注入到子组件的 attrs 里,让子组件的 ssrRender 在开标签上带上它。两者一内一外,共同保证 scoped 样式在 SSR 下与 CSR 下产出逐字节一致的 data-v- 集合——这也是 hydration 时属性 mismatch 检查不会误报的前提。


15.5 renderComponentVNode 与组件渲染:setup 在服务端如何执行

15.5.1 renderComponentVNode 的完整代码

组件是 SSR 里唯一「有内部状态、有生命周期」的单元,因此它的渲染路径最长。完整代码如下:

// render.ts:94
export function renderComponentVNode(vnode: VNode, parentComponent = null, slotScopeId?: string): SSRBuffer | Promise<SSRBuffer> {
  const instance = (vnode.component = createComponentInstance(vnode, parentComponent, null))
  const res = setupComponent(instance, true /* isSSR */)
  const hasAsyncSetup = isPromise(res)
  let prefetches = instance.sp /* LifecycleHooks.SERVER_PREFETCH */
  let p: Promise<SSRBuffer>
  if (hasAsyncSetup || prefetches) {
    p = Promise.resolve(res as Promise<void>)
      .then(() => {
        // instance.sp may be null until an async setup resolves, so evaluate it here
        if (hasAsyncSetup) prefetches = instance.sp
        if (prefetches) return Promise.all(prefetches.map(prefetch => prefetch.call(instance.proxy))).catch(NOOP)
      })
      .then(() => renderComponentSubTree(instance, slotScopeId))
  } else {
    try { return renderComponentSubTree(instance, slotScopeId) } catch (err) { p = Promise.reject(err) }
  }
  p.catch(NOOP)
  return p
}

这段代码有五个 MUST 理解的细节:

细节一:setupComponent(instance, true) 的第二个参数 isSSR。 这是「服务端没有 DOM」这件事在 setup 层面的唯一体现。它会影响生命周期注册(onMounted 在 SSR 下不入队)以及 setupState 的 ssrContext 处理。第 13 章讲组件实例时已提到,setup 在服务端与客户端执行的是同一份用户代码,差别在于运行环境。

细节二:vnode.component = createComponentInstance(...)。 服务端也要创建组件实例(虽然不需要 render effect)。原因:ssrRender 的 ABI 需要 instance.proxy、instance.props、instance.setupState 等;而且 onServerPrefetch 的注册依赖实例;useSSRContext 需要 appContext.provides(见 ssrRenderTeleport.ts:19 通过 parentComponent.appContext.provides[ssrContextKey] 取回 context)。服务端实例是「轻量版」——有实例、无 effect。

细节三:hasAsyncSetup 与 prefetches 的「重取」逻辑。 instance.sp(serverPrefetch hooks)在同步 setup 时就能读到;但如果是 async setup,instance.sp 可能在 setup resolve 之前为 undefined,所以代码在 .then() 里重新读一次 prefetches = instance.sp(render.ts:113)。这是异步 setup 最容易被忽略的时序陷阱:同步读到的可能是空。

细节四:prefetch.call(instance.proxy) 与 .catch(NOOP)。 onServerPrefetch 通常是 async 函数,返回 Promise;用 Promise.all 并发等待它们。.catch(NOOP) 保证即便某个 prefetch 抛错也不阻断渲染(错误已经由 onServerPrefetch 的包装器上报过,见 15.5.3)。

细节五:p.catch(NOOP) 的「先订阅」技巧。 render.ts:137 这行让 Promise 在被 push 进父 buffer 之前就有一个 catch 订阅者。这是为了避免「在一段时间内没有任何 catch handler 的 rejected Promise 触发 unhandledRejection」——注意它仍然返回原始 p,所以调用方拿到的还是那个可能 reject 的 Promise,错误不会被吞掉。这是一处体现了作者对 Promise 语义精细把握的代码。

15.5.2 renderComponentSubTree:三条分支

拿到实例后,真正产出内容的是 renderComponentSubTree(render.ts:141)。它根据组件类型分三条路:

// render.ts:147–236(结构)
const { getBuffer, push } = createBuffer()
if (isFunction(comp)) {
  // ── A:函数式组件(#5817 补 data-v- scopeId 到 root.props)
  renderVNode(push, (instance.subTree = renderComponentRoot(instance)), instance, slotScopeId)
} else {
  if ((!instance.render || instance.render === NOOP) && !instance.ssrRender && !comp.ssrRender && isString(comp.template)) {
    comp.ssrRender = ssrCompile(comp.template, instance)   // B 前置:现场编译
  }
  const ssrRender = instance.ssrRender || comp.ssrRender
  if (ssrRender) {
    // ── B:有 ssrRender(编译优化路径):解析 attrs / scopeId / slotScopeId;
    //    外层 setCurrentRenderingInstance + try/catch(handleError RENDER_FUNCTION) + finally 复位
    ssrRender(instance.proxy, push, instance, attrs, instance.props, instance.setupState, instance.data, instance.ctx)
  } else if (instance.render && instance.render !== NOOP) {
    // ── C:只有客户端 render(手写 render / h())
    renderVNode(push, (instance.subTree = renderComponentRoot(instance)), instance, slotScopeId)
  } else {
    // ── 兜底:既无 render 也无 ssrRender 也无 template
    warn(`Component ${comp.name || comp.__file || '<Anonymous>'} is missing template or render function.`); push(`<!---->`)
  }
}
return getBuffer()
分支触发条件用到的函数性能
A 函数式typeof comp === 'function'renderComponentRoot(客户端渲染路径)一般(未特化)
B ssrRenderinstance.ssrRender 或 comp.ssrRender 存在编译产物,直接 push最优
B' 现场编译!render && !ssrRender && isString(template) 且 __CJS__ssrCompile一次性编译后缓存
C 通用 render有客户端 renderrenderComponentRoot → renderVNode一般
兜底什么都没有warn + <!---->—

关键点:renderVNode(push, instance.subTree, instance, slotScopeId) 是「通用回退」。 手写 render() 返回 vnode 树,服务端只能走 renderVNode 通用遍历而非编译优化路径——这就是「h() 手写组件在 SSR 下通常比 SFC 编译组件慢」的原因:跳过了编译期特化。

15.5.3 setup 在服务端的执行细节

setupComponent 内部(第 13 章已拆)在 isSSR = true 时会:

  • 不注册 onMounted/onUpdated/onUnmounted(这些钩子在服务端没有意义;Vue 通过 isInSSRComponentSetup 标志让 onMounted 变成 no-op)。
  • 注册 onServerPrefetch,并把它存进 instance.sp(数组)。onServerPrefetch 的包装器会捕获错误并 handleError。
  • 给 setup 返回对象做响应式处理时(proxyRefs)依然照常,因为 ssrRender 要从 setupState 读值。
  • provide/inject 照常工作:appContext.provides 在服务端就是那个包含 ssrContextKey 的原型链。这意味着 inject 在 SSR 下与 CSR 下行为一致(第 14 章 14.11 讲过原型链查找)。

一个常见误区:以为 SSR 下要在 onMounted 里做的事应该搬到 onServerPrefetch。 严格说,两者语义不同:onMounted 是「DOM 可用后」,onServerPrefetch 是「渲染前把数据准备好」。正确的分层是:

在服务器上需要「渲染前」就有的数据        → onServerPrefetch(async)
在浏览器上需要「DOM 可用后」才做的副作用 → onMounted(客户端专属)或 onMounted 里再判 isClient
纯渲染无关的、两边都需要的初始化          → setup 顶层直接写(保证环境无关)

15.5.4 async setup 与 Suspense 在服务端的协作

当组件用 async setup() 时,setupComponent 返回一个 Promise,renderComponentVNode 的 hasAsyncSetup 分支接管:

renderComponentVNode
  └─ setupComponent(instance, true) → Promise
       └─ await 该 Promise
            ├─ (重取 instance.sp) → 并发跑所有 onServerPrefetch
            └─ renderComponentSubTree(instance) → SSRBuffer

在服务端,Suspense 的行为与客户端完全不同:

  • 服务端不等待 Suspense 的 fallback。 renderVNode 对 ShapeFlags.SUSPENSE 的处理是 renderVNode(push, vnode.ssContent!)(render.ts:288)——它只渲染「已解析的内容」ssContent。ssrRenderSuspense(ssrRenderSuspense.ts:6)同样只在有内容时渲染内容,否则推一个空注释。
  • 原因:服务端渲染的目标是「产出完整 HTML」。如果服务端也像客户端那样先渲染 fallback 再切换,产出就会是 fallback + 内容两份,浏览器解析时反而错乱。所以服务端 Suspense 的语义被简化成「等内置异步依赖全部 resolve,再输出最终内容」。
  • 等待发生在哪? 在 renderComponentVNode 的 Promise 链里。Suspense 内部有 asyncDep 的组件会阻塞父 buffer 的摊平——这正是「服务端 Suspense = 强制等待」的实现方式。

一个要注意的边界:如果服务端某个 async setup 永远不 resolve,renderToString 会永久挂起(没有超时机制)。生产环境要靠框架层的 Promise.race + 超时兜底,或在 onServerPrefetch 里自己做超时。Vue 本身不提供这个。

15.5.5 组件缓存:ssrRender 的缓存与 compileCache

服务端有两层缓存:

  1. comp.ssrRender 的「一次编译、多次复用」(render.ts:161–168)。ssrCompile 只在组件没有 ssrRender/render 而有 template 时才触发,结果写回 comp.ssrRender,跨请求复用。这是安全的,因为 ssrRender 是纯函数(输入 ctx/push,输出字符串),不含请求态。
  2. ssrCompile 内部的 compileCache(ssrCompile.ts:20、ssrCompile.ts:65)。key 是 { template, compilerOptions } 的 JSON 序列化(函数用 toString())。这意味着同一个 template + 同一套编译选项只会编译一次,即使组件对象被重新创建。注意这个 cache 是模块级全局对象 Object.create(null),跨请求共享。

一个必须知道的限制:ESM 构建不支持现场编译。ssrCompile.ts:27–33 里 if (!__CJS__) throw。也就是说用 Vite(ESM)时,如果组件没有预编译的 ssrRender、又没有 render、只有 template 字符串,SSR 会直接抛错。Vite 的 @vitejs/plugin-vue 会为 SSR 构建生成 ssrRender,因此正常项目不会踩到;但「运行时传 template 字符串给组件」这种动态场景在 ESM 下会碎。

15.5.6 服务端不做的事:一张「缺席清单」

对照客户端渲染器,服务端刻意不做以下事情,理解这份清单能帮你判断「为什么 SSR 出来的 HTML 是这个样子」:

客户端有服务端没有后果
render effect(响应式重渲染)无服务端渲染一次即终态,无更新
dynamicChildren block 优化不参与服务端不需要 diff
patchProp 逐个设属性用 ssrRenderAttrs 拼串属性顺序由遍历顺序决定
事件监听(addEventListener)不输出 onXxx事件在 hydration 时才绑定
ref 赋值不赋值ref 在 hydration 时(hydration.ts:375)才 setRef
指令 mounted 钩子只跑 getSSRProps指令副作用留在客户端
Teleport 真实搬节点存 buffer 后置拼接页面里是注释 + 目标处的内容
Suspense fallback 切换只输出最终内容无闪烁,但首屏被 async 依赖阻塞
v-if 的 DOM 增删输出 <!----> 注释占位hydration 用注释识别空洞

最后一条值得展开。 服务端渲染 v-if=false 时会输出一个空注释 <!---->(render.ts:258–262),而不是省略。原因:hydration 需要知道「这里本来有一个 vnode,只是它没渲染」,否则客户端 vnode 树里那个 v-if 分支对应的节点在 DOM 里找不到,游标就会错位。这就是为什么 hydration 里到处能看到 isComment(node) && node.data === '['、node.data === ']'、'teleport start' 这类注释判定——注释是服务端与客户端之间的「结构信标」。


15.6 RenderToString 完整流程

15.6.1 端到端的调用链

把 15.2–15.5 串起来,renderToString 从 createSSRApp 到最终 HTML 字符串的完整调用链是:

flowchart TD
  A[&#34;createSSRApp(App)&#34;] --> B[&#34;app.provide(ssrContextKey, ctx) 之外的 appContext&#34;]
  B --> C[&#34;renderToString(app, context)&#34;]
  C --> D[&#34;createVNode(app._component, app._props)&#34;]
  D --> E[&#34;vnode.appContext = app._context&#34;]
  E --> F[&#34;app.provide(ssrContextKey, context)&#34;]
  F --> G[&#34;renderComponentVNode(vnode)&#34;]
  G --> H[&#34;createComponentInstance → setupComponent(instance, true)&#34;]
  H --> I{&#34;async setup / prefetch?&#34;}
  I -- 否 --> J[&#34;renderComponentSubTree(instance)&#34;]
  I -- 是 --> K[&#34;await → 跑 onServerPrefetch → renderComponentSubTree&#34;]
  J --> L[&#34;createBuffer → push 序列&#34;]
  K --> L
  L --> M[&#34;ssrRender 或 renderVNode 递归遍历 vnode 树&#34;]
  M --> N[&#34;SSRBuffer 树&#34;]
  N --> O[&#34;unrollBuffer → HTML 字符串&#34;]
  O --> P[&#34;resolveTeleports(context)&#34;]
  P --> Q[&#34;stopWatchers(context)&#34;]
  Q --> R[&#34;返回 HTML&#34;]

15.6.2 遍历 vnode 树的三个「截断点」

renderVNode 递归时会在三个地方「不往下走」,理解它们对估算 SSR 性能很关键:

  1. Static vnode(render.ts:263):push(children) 直接输出。静态子树在编译期被提升(hoist),其内容作为一个完整字符串存进 vnode.children(甚至可能是空串,等 hydration 时再「adopt」——见 15.9 的 Static 分支)。服务端不必递归。
  2. props.innerHTML / props.textContent / textarea.value(render.ts:359–369):hasChildrenOverride = true 后不再渲染 children,直接 push(String(props.innerHTML)) 或 push(escapeHtml(props.textContent))。这防止了「HTML 里既写 children 又写 innerHTML」的重复。注意 innerHTML 是不转义的(它本来就是 HTML),textContent 则要转义。
  3. v-if=false 的空分支:输出 <!---->,不进入任何子树。

15.6.3 错误处理与 fallback

SSR 的错误有三层:

第一层:组件渲染函数抛错。 renderComponentSubTree 用 try/catch 包住 ssrRender,交给 handleError(err, instance, ErrorCodes.RENDER_FUNCTION)(render.ts:219–220),沿 errorCaptured → app.config.errorHandler 冒泡(第 14 章 14.13)。注意:捕获后 buffer 仍是「已 push 的部分」,不中断整个渲染——错误被上报,但那段输出可能不完整。

第二层:renderComponentVNode 把同步抛错转 Promise。 非 async 分支里 renderComponentSubTree 同步抛错会被转成 Promise.reject(err)(render.ts:127–133),注释即「let the SSR buffer propagate the error so parent render functions don't handle the same error again」——避免同一个错误被父、子组件各处理一次,让它顺着 buffer 链浮到 renderToString 的 catch。

第三层:renderToString 收尾清理。 catch (err) { cleanupFailedRender(buffer, context); throw err }(renderToString.ts:90–93),处理「在途 Promise 还会注册 watcher」(15.2.2)。

实战建议:不要指望 SSR 失败后还能返回「部分可用」的 HTML。 失败即整页失败;框架层通常捕获错误后回退 CSR 外壳或返回错误页,Vue 本身不提供 fallback。

15.6.4 watcher 的生命周期:__watcherHandles

SSR 期间 watch/watchEffect 有个陷阱:若非 immediate、依赖在渲染中变化,回调可能被调度到「渲染结束后」执行,那时你已在用结果了。Vue 把它们记进 context.__watcherHandles,收尾时统一停掉(stopWatchers,renderToString.ts:98–105)——防止 watcher 泄漏到请求结束之后(服务器进程常驻,泄漏会累积)。

cleanupFailedRender 里那段更难:

// renderToString.ts:113
export function cleanupFailedRender(buffer, context): void {
  const stopped = context.__watcherHandles ? context.__watcherHandles.length : 0
  try { stopWatchers(context) } catch {}
  ;(async () => {
    if (buffer) await settleBuffer(buffer)
    for (const key in context.__teleportBuffers) await settleBuffer(context.__teleportBuffers[key])
    stopWatchers(context, stopped)   // 从 stopped 开始,只停「新注册的」
  })().catch(NOOP)
}

stopped 的用意:第一次 stopWatchers 已停掉「失败前已注册」的 watcher;等在途 buffer 落定后可能又有新增(Promise 的后续代码),故第二次从 stopped 开始停。这是「两阶段清理」——先停当前能停的(不阻塞上抛),再异步收拾残局。

15.6.5 一个完整的 renderToString 时序表

步骤代码位置同步/异步失败行为
规范化 input(App 或 VNode)renderToString.ts:74–77同步—
建根 vnode + 挂 appContextrenderToString.ts:80–81同步—
provide(ssrContextKey, context)renderToString.ts:83同步—
渲染根组件renderToString.ts:87可能异步抛错 → catch
摊平 bufferrenderToString.ts:88可能异步抛错 → catch
结算 teleportsrenderToString.ts:89异步抛错 → catch
失败清理renderToString.ts:91异步(不阻塞上抛)吞掉清理错误
停 watcherrenderToString.ts:94同步由 watcher 自身上报

15.7 renderToStream:把「一次性字符串」切成「一条流」

15.7.1 流式渲染要解决的到底是什么问题

renderToString 是「全有或全无」:await 拿到完整字符串再写响应,意味着服务器必须先跑完整棵树,浏览器才收到第一个字节;含异步数据的深层树可能等几十到几百毫秒,TTFB 等于「整棵树渲染完成的时间」。流式砍掉的就是这段——让渲染与传输重叠:组件树有天然顺序(父先于子、兄弟按声明顺序),算出「父组件前半段」即可立刻推给浏览器;HTML 解析器增量式,收到前半段就能先建 DOM、先解析资源。

易误解点:Vue 3 流式不是 Suspense 的 out-of-order streaming。 React 的 renderToPipeableStream 遇 Suspense 先发 fallback、主内容算完用 <script> 反向补位;Vue 的 renderToSimpleStream 只做顺序流式——缓冲区被摊平的顺序即输出顺序。故 Suspense 在流式下必须同步完成(见 15.7.8),否则整个 buffer 会因某个 Promise 暂停——是「卡住」,不是乱序。

15.7.2 三个入口与 SimpleReadable 抽象

renderToStream.ts(221 行)导出四个函数,但真正实现只有一层:一个最底层原语 + 四个薄适配器。

// renderToStream.ts:20
export interface SimpleReadable {
  push(chunk: string | null): void
  destroy(err: any): void
}

这是流式模块的抽象核心:只有两个方法,且刻意不是 Node 的 Readable、也不是 Web 的 ReadableStream。push(chunk) 塞一段、null 表示流结束(EOF);destroy(err) 表示出错毁流。push(null) 照搬 Node Readable 约定(触发 'end'),好处是渲染逻辑完全不需要知道输出送到哪里——HTTP 响应、Writable、ReadableStream controller 都只是各自实现一个 {push, destroy}。四个入口的分工:

入口位置返回/参数依赖
renderToSimpleStreamrenderToStream.ts:60T extends SimpleReadable无(底层原语,自定义 stream)
renderToNodeStreamrenderToStream.ts:114Readable需要 node:stream,CJS 构建才可用
renderToStream(废弃)renderToStream.ts:104Readable转调 renderToNodeStream,打印弃用警告
pipeToNodeWritablerenderToStream.ts:133void接收一个已有 Writable
renderToWebStreamrenderToStream.ts:152ReadableStream需要全局 ReadableStream
pipeToWebWritablerenderToStream.ts:189void接收一个已有 WritableStream

为什么 renderToNodeStream 在 ESM 构建里会直接抛错?看 renderToStream.ts:118–128:

// renderToStream.ts:118
const stream = __CJS__ ? new (require('node:stream').Readable)({ read() {} }) : null
if (!stream) throw new Error(`ESM build ... use pipeToNodeWritable() instead.`)

__CJS__ 是构建期常量。ESM 构建无 require,拿不到 node:stream 的 Readable,于是不偷偷 import(那会破坏 edge/浏览器打包),而抛错引导到 pipeToNodeWritable——后者只需用户传入自己的 Writable。这是「库不在运行时引入平台专属依赖」的取舍。

15.7.3 renderToSimpleStream 的 Promise 链

renderToSimpleStream(renderToStream.ts:60–99)是流式的全部逻辑,去掉输入归一化后只剩一条 Promise 链:

// renderToStream.ts:80
Promise.resolve(renderComponentVNode(vnode))
  .then(b => unrollBuffer((buffer = b), stream))   // 边摊平边 push
  .then(() => resolveTeleports(context))
  .then(() => { stopWatchers(context); stream.push(null) },
        error => { cleanupFailedRender(buffer, context); throw error })
  .catch(error => stream.destroy(error))

与 renderToString(renderToString.ts:86–95)并排看骨架一模一样:renderComponentVNode → unrollBuffer → resolveTeleports → stopWatchers,区别只在最后一步——前者返回摊平结果,后者边算边 push、最后 push(null)。renderToSimpleStream 同步返回 stream(非 async),调用者可立刻 pipe/读取,渲染在后台微任务链上推进。

错误路径:第一个 .then 的成功回调做正常收尾(stopWatchers + push(null)),失败回调做 cleanupFailedRender(buffer, context) 并 throw,外层 .catch 才交给 stream.destroy(error)。不直接 destroy 是为了先清理再通知流——清理是异步的,但错误通知不应被它阻塞(「先上抛、后清理」,见 15.6)。

15.7.4 unrollBuffer 与 unrollBufferSync:hasAsync 决定的不是「行为」而是「性能」

流式里摊平缓冲区的是流式版 unrollBuffer(renderToStream.ts:25–46),它是唯一一处 hasAsync 直接决定走哪条分支的地方:

// renderToStream.ts:25
async function unrollBuffer(buffer, stream) {
  if (!buffer.hasAsync) return unrollBufferSync(buffer, stream) // 纯静态 → 零 await
  for (let i = 0; i < buffer.length; i++) {
    let item = buffer[i]
    if (isPromise(item)) item = await item
    if (isString(item)) stream.push(item)
    else await unrollBuffer(item, stream)   // 边摊平边 push
  }
}

这条 if (buffer.hasAsync) 是整个模块性价比最高的一行。hasAsync 是 createBuffer 推入 Promise 项时置上的标记(见 15.3);纯静态页面它为 false,走 unrollBufferSync(renderToStream.ts:48–58)——纯递归、每节点零 await:

// renderToStream.ts:48
function unrollBufferSync(buffer, stream) {
  for (let i = 0; i < buffer.length; i++) {
    const item = buffer[i]
    isString(item) ? stream.push(item) : unrollBufferSync(item, stream) // 子 buffer 从不是 Promise
  }
}

不分这条支路,await 会在每个缓冲区项注入一次微任务让出(见 15.3),对成千上万静态节点的页面就是成千上万次调度。hasAsync 为真时整个缓冲区被当作「可能含 Promise」的序列:遇 Promise 就 await、遇子 buffer 就递归摊平——这是流式与 renderToString 的本质差别:后者须先把 buffer 递归摊平成一个大字符串(nestedUnrollBuffer 返回 string),流式版在递归每层立刻 push、不需不断增长的 ret。

15.7.5 分块粒度与内存占用

一次 push 的粒度就是一个 SSRBufferItem:字符串项送一段,子 buffer 递归按项推。分块边界由推入粒度决定,推入点多为「相对完整的片段」(ssrRenderAttrs 整段属性、ssrInterpolate 单值、静态模板整块 HTML)——按渲染逻辑的自然边界切:每块可独立写出、无需分词,代价是块可能很碎(大量单值插值),靠下游流的缓冲合并削峰。

内存上 renderToString 峰值是「整棵树摊平后的完整字符串」;流式版不需累加器,常驻主要是 SSRBuffer 树(异步项 settle 后 buffer[i] = nestedItem 就地替换成字符串,renderToString.ts:33–34、40–43)加已 push 的块——下游消费快即可释放,即把「输出缓冲」的责任交给下游流。

15.7.6 四个适配器:同一原语的不同落点

既然 renderToSimpleStream 只要求 {push, destroy},四个适配器就是四次「把原语翻译成具体流 API」:

(1)pipeToNodeWritable(renderToStream.ts:133–150) —— 把 chunk 写进已有 Writable:push(content) 为 content != null ? writable.write(content) : writable.end(),destroy(err) 为 writable.destroy(err)。content == null 时调的是 writable.end() 而非「什么都不做」——push(null) 的语义被翻译成「关闭可写流」。

(2)renderToWebStream(renderToStream.ts:152–187) —— 产出一个 Web ReadableStream:start() 里调 renderToSimpleStream,push(content) 为 content != null ? controller.enqueue(encoder.encode(content)) : controller.close(),destroy(err) 为 controller.error(err),cancel() 置 cancelled = true。

两个关键细节:其一,Web 流 enqueue 只接受 Uint8Array,故必须 TextEncoder 编码——输出的是字节流;其二,cancel() 把 cancelled 置真、之后 push 直接返回,这是对「下游中途放弃读取」的响应——否则已取消的流再 enqueue 会抛错,而异步推进的渲染并不知道下游已走,cancelled 是两侧间传递「别再写了」的最小信号。

(3)pipeToWebWritable(renderToStream.ts:189–221) —— 把 chunk 写进已有 WritableStream,但有一道特殊兼容处理:以 let hasReady = false; try { hasReady = isPromise(writer.ready) } catch {} 探测 writer.ready 是否存在(#4287 CloudFlare workers 未实现 ready)。

Web 流的 getWriter().ready 是一个「可以安全写入」的 Promise(用于背压),但 Cloudflare Workers 的实现里访问它会抛异常(#4287),于是用 try/catch 探测,只有确实存在时才 await writer.ready。这是「框架适配真实运行环境碎片」的注脚:规范上 ready 应该有,实际有环境没有,只能探测。

(4)renderToNodeStream(renderToStream.ts:114–131) —— 唯一的「自造流」适配器:new Readable({ read() {} }),把 push 直接委托给它。read() {} 是空实现,因为 Vue 不关心下游何时拉取——它只是「主动推」,这也让 Node 流的 'data' 事件模式与「推模式」天然契合。

15.7.7 结束与错误:push(null) / destroy(err) 两种终态

一个流只有两种正常终态(外加一种异常终态):

  • 正常结束:最后一个缓冲区项 push 完、stopWatchers 被调用后 stream.push(null)——Node 流是 'end',Web 流是 controller.close(),Writable 是 end()。
  • 异常结束:渲染链任意一步 reject,都走到最外层 .catch(error => stream.destroy(error))——Node 流是 'error' 事件,Web 流是 controller.error(err),Writable 是 destroy(err)。

细节:cleanupFailedRender 内部「等残余 Promise settle」的清理不 await(renderToString.ts:121–127 包在 (async () => {...})() 里并以 .catch(NOOP) 收尾),故 throw error 后控制流立刻走到下游 .catch、销毁流——错误通知不被清理拖延(15.6「清理异步、上抛同步」在流式下的延续)。

15.7.8 流式渲染与 Suspense:为什么 Vue 不做乱序流

ssrRenderSuspense(helpers/ssrRenderSuspense.ts)在服务端把 ssContent(或 fallback)同步地渲染进 buffer,不支持「先发 fallback 再换主内容」——输出顺序已定,反向插入需一段 <script> 在客户端替换 fallback。故 Vue 让 Suspense 在服务端要么已完全 resolve(输出主内容)、要么直接输出 fallback,由此得约束:想让流式真正「流」起来,树上的异步数据必须在父组件之前拿到(如 onServerPrefetch 或 SSR 框架的路由级预取,见 15.5、15.13)。

回到顺序流式的本质:流式版 unrollBuffer 遇 Promise 就 await 再往下推——缓冲区某处出现 Promise,流就在那点停下等,不会「跳过这块先发后面的」。故 Vue 流式的收益主要是「静态骨架先吐出去」,而非「慢接口被延后」。若确需「先发骨架、慢数据后发」,正确做法在路由/框架层(先发静态 shell 再用脚本注入数据),而非指望 renderToStream。

15.7.9 renderToString 与 renderToStream 的产物对照

最后用一张对照表收口,把两者在同一个缓冲区树上的行为差异固化下来:

维度renderToStringrenderToStream(renderToSimpleStream)
返回值Promise<string>(完整 HTML)SimpleReadable(同步返回)
摊平实现nestedUnrollBuffer → 累加 ret 字符串流式 unrollBuffer → 边摊边 push
同步快车道unrollBufferSync(renderToString.ts:56)unrollBufferSync(renderToStream.ts:48)
峰值内存完整字符串常驻缓冲区树 + 已推块(交给下游)
TTFB整棵树渲染完才吐第一字节第一个静态块算出即吐
错误传播reject + cleanupFailedRenderstream.destroy(err) + cleanupFailedRender
适用场景SSG、小页面、需要字符串的场景HTTP 响应流、大页面、边缘运行时

两者骨架共享 renderComponentVNode、resolveTeleports、stopWatchers、cleanupFailedRender,只是「摊平」这一步形态不同。这也是为什么理解 SSRBuffer(15.3)比理解任何一个入口都重要:入口只是缓冲区树的两种消费方式。


15.8 hydration 总览:createHydrationFunctions 的两种模式

15.2–15.7 都在服务端。从这节起翻到浏览器:客户端 hydration 的全部代码在一个文件里——packages/runtime-core/src/hydration.ts(1104 行),对外只有一个导出函数:

// hydration.ts:92
export function createHydrationFunctions(
  rendererInternals: RendererInternals<Node, Element>,
): [RootHydrateFunction, HydrateNodeFn]   // 二元组 [hydrate, hydrateNode]

它返回一个二元组:[hydrate, hydrateNode]——这两个函数就是整个 hydration 的两个模式,后面所有内容都是它的展开:

函数输入角色谁调用
hydrate(vnode, container)根入口:从容器开始,对齐整棵树的根apiCreateApp.mount(apiCreateApp.ts:395)与 renderer.ts 里的 hydrateSubTree
hydrateNode(node, vnode, parentComponent, parentSuspense, ...)递归节点:把「一个已存在的 DOM node」与「一个 vnode」对齐,返回「下一个待对齐的 node」hydrate、hydrateChildren、hydrateFragment、Suspense.hydrate、Teleport.hydrate、setupRenderEffect

createHydrationFunctions 是闭包工厂——它接收 rendererInternals(patch、mountComponent、patchProp、createText、nextSibling、remove、insert 等)闭包进 hydrate/hydrateNode。为什么不拆成独立包?文件顶部注释给了答案:

文件顶部注释(hydration.ts:87)即答案:“hydration is DOM-specific, but must live in core due to tight coupling; it depends on renderer internals passed in via arguments.”

hydration 逻辑本质 DOM 专属(要读 nodeType/tagName/getAttribute/nextSibling),却又必须调渲染器内部的 patch/mountComponent(mismatch 时重挂、组件建实例)。拆到 runtime-dom 会造成「核心反向依赖 DOM」或「公开全部渲染器内部」的两难,故放进 core、靠 rendererInternals 注入。

15.8.1 装配链路:hydration 能力是怎么进入渲染器的

hydration 函数不是默认存在的,它由 createHydrationRenderer 显式开启,链路分四层:

① baseCreateRenderer 末尾的条件装配(renderer.ts:2501–2507):若 __DOM__ || __TEST__,把 createHydrationFns(internals) 的结果挂进 internals:

// renderer.ts:2501(示意)
if (__DOM__ || __TEST__) [hydrate, hydrateNode] = createHydrationFns(internals)

② createHydrationRenderer(renderer.ts:333–337):轻量包装,调用 baseCreateRenderer(options, 1) 并取出 hydrate/hydrateNode 返回。

③ runtime-dom 的 ensureHydrationRenderer(runtime-dom/src/index.ts:87–93):浏览器侧触发点——createSSRApp 发现全局还没有「已开启 hydration 的渲染器」,就惰性创建一个 createHydrationRenderer(domRendererOptions);随后(:154–171)把挂载改为 mount(container, true /* isHydrate */, namespace)。

④ 触发点 apiCreateApp.mount(apiCreateApp.ts:395):

// apiCreateApp.ts:395(示意)
isHydrate && hydrate ? hydrate(vnode, rootContainer) : render(vnode, rootContainer, namespace)

这就是那个「魔法」:同一个 createApp().mount(),在 isHydrate 为真时调 hydrate 而非 render——isHydrate 是 createSSRApp 在 mount 层面唯一与 createApp 不同之处。

15.8.2 hydrate:根入口与「容器为空」的兜底

hydrate 只有十几行(hydration.ts:119–135),却是 hydration 生命周期的起点:

// hydration.ts:119
const hydrate: RootHydrateFunction = (vnode, container) => {
  if (!container.hasChildNodes()) {   // 容器为空 → 退回全量 mount
    ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
      warn(`Attempting to hydrate existing markup but container is empty. Performing full mount instead.`)
    patch(null, vnode, container)
  } else {
    hydrateNode(container.firstChild!, vnode, null, null, null)
  }
  flushPostFlushCbs(); container._vnode = vnode
}

三件事值得拆开:

其一,container.hasChildNodes() 分支。 处理「SSR HTML 与容器对不上」(如 SSG 页面里 <div id="app"> 为空):hydration 无从谈起,Vue 退回全量 mount,并在 dev(或开启 mismatch 详情的 prod)打警告——不是错误,而是 graceful degradation:宁可全量渲染一次,也不崩。

其二,三个 null 参数。 第 3/4/5 个参数是 parentComponent、parentSuspense、slotScopeIds,根组件皆无故全为 null。这说明 hydration 上下文「顺着树传下去」,而非全局状态——mount 可从 currentInstance 等全局拿,hydration 却需要精确的「本节点属于谁」。

其三,container._vnode = vnode 作为完成标志。 它记录「容器当前挂着哪棵 vnode 树」。hydration 结束时设为根 vnode,意味着「此后本容器由 Vue 管理,后续 render(newVNode, container) 拿它做 patch(old, new) 的基准」;漏设则下次更新会退化成全量重建。两个分支里都出现这行——无论走哪条,完成标志都必须设上。

flushPostFlushCbs() 在整树对齐后统一冲刷 post 队列回调(onVnodeMounted、指令 mounted 等,见 15.9 的 queueEffectWithSuspense),保证「DOM 与 vnode 结构已一致」在用户钩子里成立。

15.8.3 hydrateNode:一个 switch 分派十三种 vnode 类型

hydrateNode(hydration.ts:137–380)是 hydration 的心脏,约 240 行。它的前半段是公共准备,后半段是一个按 vnode.type 分派的大 switch。

公共准备(hydration.ts:145–171)的几个关键动作(optimized/isFragmentStart/onMismatch 的完整代码见 15.9.1):

  • optimized = optimized || !!vnode.dynamicChildren:hydration 支持 block 优化——带 dynamicChildren 的 vnode(编译器 block,动态子节点位置可预测)置真 optimized,下传后 hydrateChildren 跳过 normalizeVNode、直接按位置对齐。与 mount 阶段(第 11 章)是同一套 optimized 语义。
  • isFragmentStart = isComment(node) && node.data === '[':服务端渲染 fragment 会输出 <!--[--> ... <!--]--> 一对注释(slot 亦然,见 15.4),故「遇到 <!--[-->」=「fragment 开始」,此判断遍布 hydration。
  • onMismatch:闭包便捷函数,打包「当前节点对不上」的全部上下文并调 handleMismatch(见 15.10)——mismatch 的统一出口。
  • vnode.el = node:把 vnode 的 el 直接指向真实 DOM。这是 hydration 与 mount 最本质的区别——mount 的 el 由 hostCreateElement 造,hydration 的 el 是「认领」来的,后续所有更新/卸载/ref 都建立在此前提上。
  • DEV devtools 反查字段(:161–164):用 def(..., true)(不可枚举,避免污染 for...in)把 vnode 与 __vueParentComponent 挂到 DOM 节点,供 devtools 从任意节点反查组件。
  • PatchFlags.BAIL 处理(:166–169):BAIL = 编译期遇 run-time 指令等无法静态分析时「放弃优化」。遇 BAIL 要取消 optimized 并清空 dynamicChildren,因子节点顺序不再可预测,必须逐个对齐。

分派 switch(hydration.ts:172–373)按 case 顺序覆盖:Text(:173)、VComment(:200)、Static(:216)、Fragment(:244)、default(:258,再按 shapeFlag 细分 ELEMENT / COMPONENT / TELEPORT / SUSPENSE)。「十三种」即四个具名 case + 四类 shapeFlag,外加一个兜底 warn。

函数末尾(:375–379):

// hydration.ts:375
if (ref != null) {
  setRef(ref, null, parentSuspense, vnode)
}
return nextNode

ref 也必须处理:模板 ref 指向真实 DOM,而服务端没有 ref(纯客户端概念),故在认领到节点后绑上——凡服务端无法序列化、只能客户端重建者(ref、事件、响应式追踪)都在此补齐。

return nextNode 是游标推进契约:每个 hydrateNode 返回「下一个待对齐的 DOM 节点」(null 表示容器内无更多兄弟)。 15.9 与 15.12 反复用到——父子函数靠返回值串联游标。

15.8.4 mount 与 hydrate 的对称结构

理解 hydration 最快的方式,是把 hydrateElement 与 mount 的 mountElement 并排读。它们处理的是同一类 vnode(元素),但一个是「造」,一个是「找 + 补」:

步骤mountElement(mount)hydrateElement(hydration)
得到 DOMhostCreateElement(vnode.type, ...)直接用 el(已传入的真实元素)
children逐个递归 patch(null, child, el)hydrateChildren(el.firstChild, vnode, el)
props遍历 props 全部 patchProp 写入分条件:多数跳过,少数(input/option/事件/动态属性)才 patch
指令 createdinvokeDirectiveHook(... 'created')同样调用(:415–417)
指令 beforeMount/mounted同样调用同样调用(:572–585)
挂载后效果queueEffectWithSuspense同样(:580–584)
返回值无(不返回游标)return el.nextSibling(游标)

这张表摊开一个常被忽略的事实:hydration 不是「不设置 props」,而是「尽量不设置」。 顺序、指令钩子、挂载后效果这些「行为」全部照做;只有「写入 DOM 属性」被大幅削减——绝大部分属性值已由服务端写进 HTML、解析后就在 DOM 上,重复写既浪费又可能触发副作用(img.src 重赋值会重新发起请求,即 isUnchangedResourceProp 的由来,见 15.9)。

15.8.5 客户端从 createSSRApp 到「可交互」的时序

最后把客户端侧从「收到 HTML」到「可交互」的时序固化下来:

sequenceDiagram
  participant B as 浏览器
  participant P as HTML 解析器
  participant J as JS 运行
  participant H as hydration

  B->>P: 收到 SSR HTML
  P->>P: 解析 → 真实 DOM(静态内容可见)
  Note over B: 首屏可见(无需 JS)
  B->>J: 下载并执行客户端 bundle
  J->>H: createSSRApp(App).mount('#app')
  H->>H: hydrate(vnode, container)
  H->>H: hasChildNodes? 否→全量 mount;是→hydrateNode
  H->>H: hydrateNode(container.firstChild, vnode, ...)
  H->>H: 递归对齐:认领 el / 绑定事件 / 恢复 ref
  H->>H: 补齐响应式追踪(setup 执行)
  H->>H: flushPostFlushCbs()(执行 mounted 类钩子)
  H->>H: container._vnode = vnode
  Note over B: 可交互(事件/更新就绪)

注意时序里两个「可见点」的错位:HTML 解析完成后内容就可见,但此时还不可交互——事件监听器未绑、响应式未建立;hydrate 完成后才到达可交互点。这段「可见但不可交互」窗口是 SSR 的固有缺陷,也是 lazy hydration(15.11)想让「不可交互窗口」只覆盖可视区外内容的原因。


15.9 hydration 匹配算法逐段走读

本节按执行顺序走读 hydrateNode(hydration.ts:137–380)、hydrateElement(:382–589)、hydrateChildren(:591–668)、hydrateFragment(:670–706)这不到 600 行代码,回答:游标指向的这个真实 DOM 节点,和 vnode 里那个 vnode,怎么确认是同一个东西?

15.9.1 公共准备与游标契约

hydrateNode 开头(hydration.ts:145–171)这三件事决定后续每个分支的行为:

// hydration.ts:145
optimized = optimized || !!vnode.dynamicChildren
const isFragmentStart = isComment(node) && node.data === '['
const onMismatch = () => handleMismatch(node, vnode, parentComponent, parentSuspense, slotScopeIds, isFragmentStart)
  • (1)optimized:父传向子的布尔,来自外部(setupRenderEffect)或 vnode.dynamicChildren 存在;为真即按「动态子节点顺序可预测」整段处理,复用编译期 dynamicChildren,省掉逐个 normalizeVNode 与类型判断。
  • (2)isFragmentStart:注释节点且 data === '[',供 Text/VComment/Fragment 判断是否被 fragment 包裹,并传给 handleMismatch 决定是否移除「过量 fragment 节点」。
  • (3)onMismatch 闭包:绑定节点、vnode、父组件/父 Suspense、插槽作用域、是否 fragment 开头,是所有「对不上」路径共用的懒执行失配函数。

认领动作(:158)let domType = node.nodeType; vnode.el = node:hydration 里 el 不是「将创建的 DOM」而是「已存在的 DOM」,赋值即完成绑定,后续 patch/unmount/setRef/updateHOCHostEl 直接用;另有 dev/devtools 挂 __vnode、PatchFlags.BAIL 时取消优化(:161–169)。

游标契约:hydrateNode 返回「下一个待对齐的 DOM 节点」,null 表示容器里没有更多兄弟。 hydrateChildren(:604–635)维护局部 node,循环 node = hydrateNode(node, vnode, ...),把上一个节点返回的游标喂给下一个 vnode,直到走完或 node === null。mount 靠「创建 + 插入」推进,hydration 靠「游标前进」推进——两者的本质差异。

15.9.2 三类节点的具名分支:Text / VComment / Static

(1)Text(hydration.ts:173–199):DOM 非文本节点时,vnode.children === '' 不算失配——空文本在服务端 HTML 里不产生节点(#5728),就地 insert(createText('')) 并把 vnode.el 指向它、nextNode 仍返回当前 node(未消费 DOM 节点),解决「插槽兜底内容为空」的「少一个节点」;其余走 onMismatch()。DOM 是文本但 (node as Text).data !== vnode.children 时是真 mismatch,就地改写 (node as Text).data——文本可无损修正故不重建节点,data-allow-mismatch="text" 可放行(见 15.10),警告用 JSON.stringify 包裹两侧以看清不可见字符;一致则游标前进到 nextSibling(node)。

(2)VComment(hydration.ts:200–215) —— v-if 假分支、v-for 空列表占位、fragment 锚点等都产生注释:

case VComment:
  if (isTemplateNode(node)) {   // 包裹 <transition appear>
    nextNode = nextSibling(node); replaceNode((vnode.el = node.content.firstChild!), node, parentComponent)
  } else if (domType !== DOMNodeTypes.COMMENT || isFragmentStart) nextNode = onMismatch()
  else nextNode = nextSibling(node)

isTemplateNode(node) 分支:<transition appear> 下 v-if 真分支的内容以 <template> 出现(见 15.9.4),此时 vnode 是注释、DOM 却是 <template>,故**「提升」content 并替换 template 本身**,vnode.el 指向内容。replaceNode(:792–811)除 replaceChild(newNode, oldNode) 外,还沿组件链向上把每个 vnode.el === oldNode 组件的 vnode.el 与 subTree.el 都改为新节点——组件根元素可能正是被替换的 <template>,不修正则父实例 el 指向脱离文档的节点,后续 updateHOCHostEl/过渡/style 注入都会错。

(3)Static(hydration.ts:216–243) —— 编译期提升的 createStaticVNode(staticCount + 一整块静态 children 字符串):fragment 开头先跳过开锚;DOM 为元素/文本时把 nextNode 起连续 staticCount 个节点视作整块,最后一个记进 vnode.anchor(结束锚点,vnode.ts:188)。children 为空时从服务端 DOM 反向读回(元素 outerHTML、文本 .data)拼进 vnode.children——这是 hydration 里少数「DOM 是权威、反过来修正 vnode」的地方:静态内容为减小体积在客户端构建时可能被剥离,认领时直接从 HTML 拿回。返回按 isFragmentStart 决定是否再跳过 <!--]-->;DOM 非元素/文本则失配。

要点:把「静态块」当整体单元对齐、不逐个子节点比类型——编译期已保证内容固定,hydration 只需确认「这里有这么多节点」并记下边界。

15.9.3 Fragment 与 hydrateFragment

Fragment 分支(hydration.ts:244):if (!isFragmentStart) nextNode = onMismatch(); else nextNode = hydrateFragment(node as Comment, vnode, ...)。fragment vnode 必须对应一个 <!--[--> 开锚——fragment 的「形态」在 DOM 里就体现为这对注释,连开锚都没有就是结构完全不对,直接失配。hydrateFragment(:670–706)核心是「对齐区间」:

const next = hydrateChildren(nextSibling(node)!, vnode, container, ...)   // 先合并 vnode.slotScopeIds,见下
if (next && isComment(next) && next.data === ']') return nextSibling((vnode.anchor = next))
else { logMismatchError(); insert((vnode.anchor = createComment(`]`)), container, next); return next }   // 结束锚缺失则补造

四步:1)合并插槽作用域:vnode.slotScopeIds(该 fragment 是插槽根时的 id 列表,vnode.ts:179)与父级 slotScopeIds concat,使内部元素认领后带上正确的 data-v-xxx-s;2)从开锚的下一个兄弟开始对齐子节点(<!--[--> 只是边界、不参与对齐);3)期待结束锚:next 为 <!--]--> 时记 vnode.anchor = next 并返回其下一个兄弟,整个区间被整段跳过,体现「以区间为单位对齐」;4)结束锚缺失:记录 mismatch 并补造 <!--]-->——fragment 的增删、卸载都依赖 anchor 定位区间,缺锚则 unmount 不知「移除到哪为止」。

共同模式:hydration 对「容器/边界的注释节点」零容忍——fragment 缺开锚直接失配、缺结束锚则补造;锚点是「结构性」信息,不像文本可就地改写(文本/class/style 等「内容/属性级」不一致则可就地修正或仅告警,见 15.10)。


15.9.4 ELEMENT:hydrateElement 入口与「跳过策略」

default 分支(hydration.ts:258–276)里元素类型判定只有两步:domType !== ELEMENT(vnode 说「我是元素」、DOM 不是)直接失配;否则再比标签名——vnode.type 可能是 'DIV',而 Element.tagName 总是大写(规范要求),故两侧 toLowerCase() 再比(「HTML 解析规范 vs 源码写法」的经典桥接)。唯一例外 !isTemplateNode(node):DOM 是 <template>(<transition appear>)时即便标签名不符也不判失配,放行给 hydrateElement(入口处提升 template 内容)。

hydrateElement(hydration.ts:382–589)第一段是「要不要做 props/children 对齐」的总开关:

const forcePatch = type === 'input' || type === 'option'   // #4006 非字符串 v-model
const hasDynamicProps = !!dynamicProps                     // #9033
if (__DEV__ || forcePatch || hasDynamicProps || patchFlag !== PatchFlags.CACHED) {
  // ... 指令 created、template appear、children、props、hooks
}
return el.nextSibling

条件为假时元素除 return el.nextSibling 外什么都不做——当「已正确的静态元素」跳过。唯一假条件是 patchFlag === PatchFlags.CACHED(-1,patchFlags.ts:115):编译期提升的静态 vnode,props 与 children 永不改变,既不变、服务端已写对、客户端也不会改,便无需任何 props/children 检查——这就是「跳过策略」:用编译期已知的不变性砍掉运行期比对。 四个「必须做」的例外:

条件含义为什么必须做
__DEV__开发构建HMR 需重新 hydrate 子节点;dev 要跑 mismatch 检查(#5405)
forcePatch(input/option)表单元素#4006:<option :value="obj">、<input :true-value="1"> 这类非字符串 v-model 无法可靠序列化进 HTML
hasDynamicProps带 :foo 动态属性#9033:动态属性需托管 patchProp/响应式追踪
patchFlag !== CACHED任何非「提升静态」flag可能带动态内容,需对齐

故生产构建里无动态内容的静态元素(CACHED 且非 input/option)直接 return el.nextSibling——零 props 检查、零 children 检查、零指令 hook,这是 hydration 性能的主要来源之一。

进入 if 后三步:指令 created 钩子(:415,invokeDirectiveHook(vnode, null, parentComponent, 'created')——「指令绑定前」时刻,第二参数 null 表示 hydration 没有旧 vnode,是首次绑定);<transition appear> 的 <template> 提升(:419–443,与 15.9.2 VComment 分支的 template 处理是一件事的两半):

  • needCallTransitionHooks 需三条件同时成立:有 transition 且 needTransition 为真(非 v-show 等)、有父组件、父组件 props 上有 appear——只有 <transition appear> 才「一出现就播入场动画」。
  • 需播动画时先把 content 的 class 存进临时属性 $cls(动画会改 class,认领时应比服务端渲染时的 class;propHasMismatch 比 class 时优先读 el.$cls,:858–862——「过渡改 class 导致误报」的精准补丁),再调 transition.beforeEnter(content)。
  • replaceNode(content, el, parentComponent) 把 template 换成内容节点(同 15.9.2),vnode.el 与局部 el 都指向 content——后续 children/props 处理都在 content 上做。

随后 children、props、hooks 三步,最后 return el.nextSibling。

15.9.5 子节点对齐:hydrateChildren 与文本复用、空文本节点

(A)元素自身的 children 分派(hydration.ts:445–504):ARRAY_CHILDREN 最常见——先看跳过条件 props.innerHTML || props.textContent(用原生属性时服务端内容与「子 vnode」是两套东西),再 hydrateChildren(el.firstChild, vnode, el, ...);返回值 next 非 null 即服务端 DOM 比客户端 vnode 多,先警告再 while (next) 逐个 remove(须先取 next.nextSibling 再 remove(cur))——多余节点必须删,否则后续 diff 全错位。TEXT_CHILDREN 走 :475–503,需抹平两处「HTML 规范导致的合法差异」否则误报:1)<pre>/<textarea> 吃掉首个换行(#11873,客户端值以 \n 开头要去掉);2)换行归一化(DOM 把 \r\n/\r 统一成 \n)。两条都失败才算真 mismatch:未放行时告警,并把 el.textContent 改成原始 vnode.children(带 \n 的权威值)——DOM 应向客户端渲染值对齐。

(B)hydrateChildren(hydration.ts:591–668) 是游标推进的实现处:

for (let i = 0; i < l; i++) {
  const vnode = optimized ? children[i] : (children[i] = normalizeVNode(children[i]))
  const isText = vnode.type === Text
  if (node) {
    if (isText && !optimized && i + 1 < l && normalizeVNode(children[i + 1]).type === Text) {   // #7285 相邻文本拆分
      insert(createText((node as Text).data.slice((vnode.children as string).length)), container, nextSibling(node))
      ;(node as Text).data = vnode.children as string
    }
    node = hydrateNode(node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized)
  } else if (isText && !vnode.children) {
    insert((vnode.el = createText('')), container)   // #7215 空文本:补节点,不算 mismatch
  } else {
    if (!hasCheckedMismatch) { hasCheckedMismatch = true; if (!isMismatchAllowed(container, MismatchTypes.CHILDREN)) logMismatchError() }  // 只警告一次
    patch(null, vnode, container, null, parentComponent, parentSuspense, getContainerType(container), slotScopeIds)
  }
}
return node
  • optimized ? children[i] : normalizeVNode(...):为真时顺序与内容可信直接用;否则每个 child 要 normalizeVNode(处理 null/字符串/数字/数组等)——hydration 把「任意 child 形态」转成标准 vnode 的唯一入口。
  • 连续文本拆分(#7285)。 手写 render/JSX 可能产生两个连续文本 vnode(如 [h('span'), 'a', 'b']),浏览器却把相邻文本合并成一个文本节点(ab)。仅非 optimized 时处理:前瞻下一 child 也是文本,就把当前 DOM 文本 data 截断为第一个 vnode 内容,被截掉的 slice(len) 造新文本节点插其后供第二个 vnode 认领。
  • node 为空的两条路。 当前是空文本(isText && !vnode.children)→不算 mismatch,直接补空文本节点(#7215);否则是真「服务端节点不够」→警告后 patch(null, vnode, container, ...) 用 mount 路径补齐。
  • hasCheckedMismatch:「只警告一次」闸门,只控警告、不影响行为——每个缺失 vnode 照样 patch(null, ...)。
  • return node 返回「vnode 走完时的游标位置」,若有剩余兄弟就是「多余节点」的第一个,正是外层 while (next) remove(next) 的起点——两者构成完整的「多余节点清理」链。

15.9.6 props 对齐与 propHasMismatch

hydrateElement 的 props 处理(hydration.ts:506–565)是 hydration 里「最像 mount、又最不像 mount」的部分:

// hydration.ts:507
if (props && (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ || forcePatch || hasDynamicProps ||
    !optimized || patchFlag & (PatchFlags.FULL_PROPS | PatchFlags.NEED_HYDRATION))) {
  const namespace = el.namespaceURI!.includes('svg') ? 'svg' : el.namespaceURI!.includes('MathML') ? 'mathml' : undefined
  for (const key in props) {
    if (__DEV__ && !(dirs && dirs.some(d => d.dir.created)) && propHasMismatch(el, key, props[key], vnode, parentComponent)) logMismatchError()   // #11189 有 created 指令则跳过检查
    if ((forcePatch && (key.endsWith('value') || key === 'indeterminate')) || (isOn(key) && !isReservedProp(key)) ||   // value 类 / 事件监听器必须补绑
        key[0] === '.' || (el.tagName.includes('-') && !isReservedProp(key)) || (dynamicProps && dynamicProps.includes(key))) {
      if (isUnchangedResourceProp(el, key, props[key])) continue   // 避免重复请求 src/href
      patchProp(el, key, null, props[key], namespace, parentComponent)
    }
  }
} else if (props.onClick) { patchProp(el, 'onClick', null, props.onClick, undefined, parentComponent) }  // 快路径:最常见
else if (patchFlag & PatchFlags.STYLE && isReactive(props.style)) for (const k in props.style) props.style[k]   // #11372 补响应式依赖收集

(1)外层条件比 children 的开关更宽,多了 !optimized(不在 block 快车道须逐个处理)与 patchFlag & (FULL_PROPS | NEED_HYDRATION)。NEED_HYDRATION(patchFlags.ts:64,1<<5)是专为 hydration 引入的 patchFlag:编译期遇到「props 在 hydration 时可能需检查」(如 data-allow-mismatch、lazy hydration 标记)即打上,并被 block 追踪特殊对待(vnode.ts:535:patchFlag !== NEED_HYDRATION 才进 currentBlock);FULL_PROPS 是「props 结构可能整体变化」(v-bind="obj")。(2)mismatch 检查按 #11189 豁免「有 created 钩子的指令」:dev 下先跑 propHasMismatch(15.10 主角,比 class/style/属性并考虑 v-show、CSS 变量等),条件 !(dirs && dirs.some(d => d.dir.created))——created 已在 :415 执行且可能改了 DOM,比对只会假阳性,取舍「宁可漏检,也不误报」。

(3)真正 patchProp 的五类条件(满足任一才真正把 props[key] 写进 DOM):

条件含义
forcePatch && (key.endsWith('value') || key === 'indeterminate')input/option 上的 value 类与 indeterminate(#7476)——非字符串绑定必须重写
isOn(key) && !isReservedProp(key)事件监听器(onXxx)——服务端 HTML 里只有属性没有监听器,必须客户端补绑
key[0] === '.'v-bind 的 .prop 修饰符——强制以 property 方式写入
isCustomElement && !isReservedProp(key)自定义元素(tag 含 -)的全部 props——属性可能被浏览器折叠/规范化,服务端形态不可靠
dynamicProps && dynamicProps.includes(key)编译期标为动态的 prop(#9033)

五类之外的所有 props 一律不写,印证 15.8.4:默认信任「服务端 HTML 里属性已正确」,只重写「服务端无法表达或不可靠」的东西。事件监听器最典型——HTML 根本没有事件绑定,onClick 是首屏交互能否工作的关键。

(4)isUnchangedResourceProp:避免重复请求资源(:542、:826–842)。 实现即 new Set(['src','srcset','href','poster']),未命中 false,否则比较 el.getAttribute(key) 与 String(clientValue),相等就 continue。这四个属性重新赋值会触发资源(重新)加载:hydration 时浏览器已据服务端 HTML 发起请求,再写同样 URL 会取消在途请求并重新发起(弱网代价极高)。基准用 getAttribute 而非 DOM property——el.src 会把相对 URL 规范成绝对而误判。

(5)两条「不做全遍历」的旁路(:548–564)。 外层条件为假时(如生产里 CACHED 且非 input/option)不进 props 遍历,但仍有两条极窄快路径(主代码块末尾两条 else if):props.onClick(注释「which is most often」)给最常见的 @click 一条直达通道——静态提升意味着内容静态但仍可点击;PatchFlags.STYLE + 响应式 style(#11372)手动迭代 key,目的是触发响应式依赖收集——常规下 style 在 patchStyle 阶段被迭代、依赖被收集,hydration 跳过了 style patch,依赖从未被读取,后续变化不会触发更新。这是「hydration 跳过 patch,就得把其顺带的依赖收集单独补上」的典型补丁。

15.9.7 hooks 与返回值

props 处理完,剩下「挂载后」钩子(hydration.ts:567–585):onVnodeBeforeMount 与指令 beforeMount 同步执行——前者语义是「DOM 已存在、副作用未绑」,后者与 mount 阶段一致。三样「挂载后」类效果(onVnodeMounted、transition.enter、指令 mounted)用 queueEffectWithSuspense 入队 post 队列(受 Suspense 管理),等 15.8.2 的 flushPostFlushCbs() 统一冲刷——这就是 hydrate 末尾必须调它的原因,否则用户 onMounted 不执行。最后 return el.nextSibling 是游标出口,用局部变量 el(template 提升场景下它已指向 content,:442)。

一句话概括 15.9:每一步都在「用编译期已知信息(patchFlag、staticCount、dynamicChildren)换取运行期工作量削减」,而游标(nextNode/el.nextSibling)是把分散的局部判断串成一次线性扫描的那根线。


15.10 hydration mismatch:分类、检测与逃生舱

15.9 把「对不上」抽象成两个出口:onMismatch()(走 handleMismatch)与 propHasMismatch()(走告警)。本节讲清:什么算 mismatch、怎么检测、检测后做什么、如何用 data-allow-mismatch 放行。

15.10.1 五种 mismatch 类型与「结构级 / 内容级」的分野

类型定义在 hydration.ts:1028–1034:MismatchTypes 为 TEXT=0/CHILDREN=1/CLASS=2/STYLE=3/ATTRIBUTE=4,MismatchTypeString(:1036–1042)给出字符串名(text/children/class/style/attribute)——正是 data-allow-mismatch 接受的值。真正决定「检测后做什么」的不是枚举,而是背后的两类失配:

类别类型触发点检测到后的动作
结构级CHILDREN(节点数量/类型对不上)onMismatch → handleMismatch;children 多/少重建:删掉不匹配的节点并 patch(null, vnode) 重挂;多的删、少的补
结构级TEXT(文本内容不符)hydrateNode 的 Text 分支与元素 TEXT_CHILDREN就地改写:node.data = vnode.children / el.textContent = ...
内容级CLASSpropHasMismatch仅检查告警,不修正
内容级STYLEpropHasMismatch仅检查告警,不修正
内容级ATTRIBUTEpropHasMismatch仅检查告警,不修正

这条分野极其重要,它解释了「属性/class/style 的 mismatch 在生产不会被修正」——propHasMismatch 注释明证:Note: this mismatch is check-only. The DOM will not be rectified in production due to performance overhead. 即服务端写错 class,客户端不纠正,只在 dev(或开启详情)时报警。不修是因为「对比 + 修正所有属性」开销大(每元素每 prop 都要读 DOM、归一化、比较),而 hydration 的目标是快。属性级失配被视为「开发者应避免的 bug」,结构级失配则必须兜底(不修则后续 diff 全错位)。「结构必兜底、内容只报警」是本节最该记住的判断。

15.10.2 handleMismatch:结构级失配的兜底流程

handleMismatch(hydration.ts:708–767)是 onMismatch 的实现,流程六步:

const handleMismatch = (node, vnode, ...) => {
  if (!isNodeMismatchAllowed(node, vnode)) { (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && warn(...); logMismatchError() }
  vnode.el = null
  if (isFragment) { /* 用 locateClosingAnchor 找结束锚 end,把其间节点逐个 remove */ }
  const next = nextSibling(node), container = parentNode(node)!
  remove(node)
  patch(null, vnode, container, next, parentComponent, parentSuspense, getContainerType(container), slotScopeIds)
  if (parentComponent) { parentComponent.vnode.el = vnode.el; updateHOCHostEl(parentComponent, vnode.el) }
  return next
}

(1)放行与告警(:716–730)。 isNodeMismatchAllowed 判断是否在 data-allow-mismatch 范围内(15.10.5):放行则连警告都不打,但重建照做——只放过警告、不放过修正。警告带服务端内容与客户端期望(vnode.type),并识别 (text)/(start of fragment)。(2)vnode.el = null:随后 patch(null, vnode) 走 mount 重造节点,须先清掉指向旧节点的 el,否则 mount 会以为已有节点——「失配 = 放弃认领、退回 mount」。(3)fragment 过量节点清理(:734–745):fragment 开头失配时用 locateClosingAnchor(node) 找结束锚 end,把其间节点逐个 remove——fragment 失配通常意味整段 DOM 是另一套结构,不如整段清空再重挂。(4)(5)(6)删节点、用 mount 重挂、修正父组件 el(:747–765):先 next = nextSibling(node)(删前取游标)再 remove(node),随后 patch(null, vnode, container, next, ...) 把新节点插回原位置——这段从此是纯 mount 的 DOM,结构必然正确;最后若有父组件,更新 parentComponent.vnode.el = vnode.el 并调 updateHOCHostEl(与 15.9.2 replaceNode 同源):组件根 DOM 被换掉,所有引用它的地方都要跟着更新,否则后续更新会指向脱离文档的节点。

15.10.3 propHasMismatch:内容级检测的三个分支

propHasMismatch(hydration.ts:844–947)只「比较」、不修改,返回布尔。按 key 分三支:

(1)class(:855–868)—— 集合比较而非字符串比较。 注释「classes might be in different order, but that doesn't affect cascade」——class 顺序不影响层叠,"a b" 与 "b a" 等价,字符串比较会因顺序误报;故转 Set(toClassSet :961,/\s+/ 切分并 trim),用 isSetEqual(:965)比大小与包含。actual 优先取 el.$cls(15.9.4 的过渡备用值,读后 delete),否则 el.getAttribute('class')。

(2)style(:869–894)—— Map 比较 + 两处修正。 把 actual(el.getAttribute('style') || '')与 expected(字符串直接、否则 stringifyStyle(normalizeStyle(clientValue)))都转 Map<key,value>(toStyleMap :977,按 ;/: 切分 trim),同样因为「声明顺序不影响级联」,再用 isMapEqual 比较。两处「把不会写进 HTML 的 style 补进期望值」的修正:① v-show=false 补 display: none——v-show 服务端会输出 style="display:none",而 props.style 没显式写 display 时 expectedMap 缺这项会误判,故从 vnode.dirs 找 show 且值为 false 时主动塞入;② resolveCssVars(:1002–1024)补 CSS 变量——useCssVars(v-bind(${color}))产生的 --xxx 由实例运行期计算并写进 HTML,比对时从实例 getCssVars() 取值补进 expectedMap(normalizeCssVarValue 归一化);它递归向上:若当前元素正是组件根节点(或根 Fragment 成员)且还有父组件,就往父组件继续找——CSS 变量可从祖先继承到同一元素。

(3)属性分支(:895–925)—— 只在「已知属性/布尔属性」内比。 先筛掉非标准属性:只有 isKnownSvgAttr/isKnownHtmlAttr 覆盖的才检查,避免自定义/未知属性误报。四类取值规则:hidden 用 normalizeHiddenValue(:949–959)归一到 false | '' | 'until-found'(until-found 配 beforematch);布尔属性用 hasAttribute(非 getAttribute),期望 includeBooleanAttr(clientValue)——只看「在不在」;null 值期望「不该有该属性」,expected = false;textarea 的 value 坑(#10000)——<textarea> 初值是子文本节点而非 value 属性,hasAttribute('value') 恒为假,故 key 为 value 且元素是 TEXTAREA 时直接读 el.value。任一分支 actual !== expected 即 mismatchType = MismatchTypes.ATTRIBUTE。

归一化工具的一致性。 三分支共用 toClassSet/isSetEqual、toStyleMap/isMapEqual、normalizeHiddenValue/isRenderableAttrValue,目的只有一个:把「语义等价、字面不同」的表示抹平,避免假阳性——这也是 mismatch 检测最难处:区分「真不一致」与「HTML 规范导致的合法差异」(如 15.9.5 的换行归一化)。

(4)告警出口(:927–946)。 mismatchType != null 且未放行时构造告警:format 把值格式化为 (not rendered)(值为 false)或 key="value";preSegment 形如 Hydration class mismatch on,postSegment 是「服务端渲染了什么 / 客户端期望什么 / check-only 不修正」三段。dev 下调用点(hydrateElement :525–532)返回 true 还会 logMismatchError()——故一次属性 mismatch 产出两条输出:详细 prop 级警告 + 汇总 console.error。

15.10.4 开发 / 生产的行为差异与两个开关

mismatch 检测的成本不进生产默认构建,靠两个编译期布尔:

  • __DEV__:开发构建总为真,所有 mismatch 检查(propHasMismatch、节点类型比对、文本比对)都会执行——目标是「尽早发现不一致」。
  • __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__:一个可在生产构建单独开启的特性开关,让生产也保留详细警告(propHasMismatch 调用、各类 warn(...)\n- rendered on server: ... 文案),代价是体积与运行时开销,默认关闭。

「警告」与「汇总错误」是两条不同层级的输出:logMismatchError(hydration.ts:58)打 console.error,注释明确「this error should show up in production」——这条汇总错误在生产构建里也会出现(不受 __DEV__ 约束),只是每页只打一次(hasLoggedMismatchError 闸门)。文案是 "Hydration completed but contains mismatches." 而非 "failed":hydration 结构上已完成(DOM 已与 vnode 对齐、页面可用),只是过程中发现不一致。这是重要运维信号——生产出现这条 error,等于「SSR 与 CSR 产出了不同结构」,值得排查。dev 下另有 warn 级详情。分工是「汇总给生产、详情给开发」。

一个细节:结构性失配(node/children)无论是否开启详情开关都会打标记,因为它们是「必须兜底」的类别、兜底动作无条件执行;而 propHasMismatch 的调用是条件性的(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__,见 :525),故纯生产构建里属性级 mismatch 既不检测、不报警、也不修正——完全交给开发者保证。

15.10.5 data-allow-mismatch:mismatch 的逃生舱

有些 mismatch 是故意且无法消除的——最典型是「时间戳/随机数/用户本地时区」这类服务端与客户端必然不同的值。开发者需要一个「我知道这里会不一致,请别报警」的声明,这就是 data-allow-mismatch(hydration.ts:1026 的 allowMismatchAttr)。

(1)值的三档粒度(isMismatchAllowedByAttr,:1062–1078):null → 不放行;空串 → 放行该元素上全部类型;否则按逗号分隔列表匹配 MismatchTypeString[allowedType](text/children/class/style/attribute)。还有一个包含关系:text 是 children 的子集——"children" 会同时放行 TEXT 与 CHILDREN(代码里显式判断 allowedType === TEXT && list.includes('children'))。

(2)向上冒泡的查找范围(isMismatchAllowed,:1044–1060):这是粒度设计里最关键的一处不对称——对 TEXT / CHILDREN,从当前元素向上遍历所有祖先直到找到带 attr 的祖先(while (el && !el.hasAttribute(allowMismatchAttr)) el = el.parentElement):文本/子节点可被容器包裹,<div data-allow-mismatch="text"> 内任意深度的文本 mismatch 都应放行,表达「这个子树内容允许不一致」;对 CLASS / STYLE / ATTRIBUTE,不向上找,只在自身找——它们是本元素的属性,祖先声明不能代表子元素。

(3)三个入口函数(isNodeMismatchAllowed,:1080):① 父容器声明 isMismatchAllowed(node.parentElement, CHILDREN)——父元素带 data-allow-mismatch="children",任意子节点失配被放行;② 节点自身(isMismatchAllowedByNode :1088)——节点是元素且自身带 attr(按 CHILDREN 解释);③ vnode 层(isMismatchAllowedByVNode :1098)——vnode.props 里有 data-allow-mismatch,覆盖「服务端 DOM 上没有该属性、但 vnode props 声明了」的情形(可能靠 inheritAttrs 传下)。三者任一为真即放行。

(4)放行的实际效果:

失配处受 data-allow-mismatch 影响吗影响什么
handleMismatch(node/children)是(isNodeMismatchAllowed)抑制警告;修正动作照做(重建节点仍进行)
hydrateElement children 多出节点(:460)是(isMismatchAllowed(el, CHILDREN))抑制警告;多余节点仍被 remove
hydrateElement TEXT_CHILDREN(:492)是(isMismatchAllowed(el, TEXT))抑制警告;el.textContent 仍被改写
hydrateChildren 节点不足(:643)是(isMismatchAllowed(container, CHILDREN))抑制警告;缺失节点仍被 patch 补挂
propHasMismatch 内部(:927)是(isMismatchAllowed(el, mismatchType))抑制 prop 级详细警告

贯穿这张表的一句话:data-allow-mismatch 抑制「报警」,不抑制「修正/重建」。 与属性级的 check-only 叠加后:生产里属性级 mismatch 本就既不检测也不修正,该 attr 对其几乎无实际作用(除非开详情开关);而文本/子节点级,它让「修正动作静默发生」——被包裹区块两端文本不同时,客户端会无声地把文本改成客户端值、删掉多余节点,不打扰控制台。这是它真正的用途:让「有意为之的不一致」在修正时不产生噪音。

15.10.6 一张表收束:各类 mismatch 的检测、后果与放行

mismatch检测位置触发的动作生产默认data-allow-mismatch 能否放行
节点类型不符hydrateNode 各分支 → onMismatchhandleMismatch:删旧节点 + patch(null,vnode) 重挂兜底执行,汇总 error能(isNodeMismatchAllowed)
子节点多于预期hydrateElement :460警告 + remove 多余节点兜底执行能(children)
子节点少于预期hydrateChildren :643警告(仅一次)+ patch(null,...) 补挂兜底执行能(children)
文本内容不符Text 分支 / TEXT_CHILDREN警告 + 就地改写 data/textContent兜底执行能(text,或 children)
class 不同(集合)propHasMismatch :855仅警告不检测(除非详情开关)能(class)
style 不同(Map)propHasMismatch :869仅警告不检测能(style)
属性不同propHasMismatch :895仅警告不检测能(attribute)

一句话总结 15.10:mismatch 系统是「结构必兜底、内容只报警、全部可用 data-allow-mismatch 静音」的分层设计——把「正确的代价」集中在结构层(必须修,否则后续全乱),把「属性的正确性」留给开发者(不修,只提醒),并用一个属性级开关决定这套提醒在不在生产里活着。


15.11 lazy hydration:把「什么时候注水」也变成可配置的

15.11.1 一个策略接口

常规 hydration 的时机是固定的:mount() 一被调用就立刻注水整棵树。lazy hydration 允许把这个时机交给一个策略函数:

export type HydrationStrategy = (hydrate: () => void, forEachElement: (cb: (el: Element) => any) => void) => (() => void) | void
export type HydrationStrategyFactory<Options> = (options?: Options) => HydrationStrategy

HydrationStrategy 拿到 hydrate(真正注水的回调)与 forEachElement(遍历该组件尚未注水的根元素,含 fragment 情形),返回可选的 teardown(组件在注水前被卸载时清理监听器)。策略不自己注水,而是「决定何时调用别人给的 hydrate」——一次控制反转。四个内置工厂:

(1)hydrateOnIdle(:29–34) —— 空闲时注水:requestIdleCallback(hydrate, { timeout }),teardown 为 cancelIdleCallback。timeout 默认 10000ms——「最多等 10 秒,之后即便不空闲也要注水」(requestIdleCallback 的 timeout 语义)。文件顶部为 Safari 打了 polyfill(:6–9):requestIdleCallback 不存在时退化为 setTimeout(cb, 1)。teardown 只取消「尚未触发的注水」;一旦 hydrate 已执行就无意义。

(2)hydrateOnVisible(:46–77) —— 进入视口时注水:用 IntersectionObserver 监听可见性,第一个可见 entry 即触发注水并 disconnect()(注水是「整个组件一次」的);elementIsVisibleInViewport(:36–44)是观察前的同步预检(建立观察时已在视口内就直接注水,省掉一次异步回调);display: contents 递归观察(:63–69)——该元素自身不生成盒子,IntersectionObserver 永远不报「可见」,但它可能承载组件根内容,故递归观察子元素直到找到有盒子的后代;teardown 为 ob.disconnect()(幂等)。

(3)hydrateOnMediaQuery(:79–90) —— 匹配媒体查询时注水:matchMedia(query).matches 已经成立就立即注水;否则挂 change 监听({ once: true }),teardown 是移除该监听。适用于「桌面端立即注水、移动端等命中某个查询再注水」之类的分端策略。

(4)hydrateOnInteraction(:92–121) —— 用户交互时注水,且补发那次交互事件。两个关键设计:事件回放(replay)——hydrate() 执行时才绑上 @click,而用户那次点击发生在监听器存在之前,第一次点击会丢失;故 doHydrate 在注水后用同一构造函数造新事件重新派发到原目标(new e.constructor(e.type, e)),刚绑上的监听器立刻收到「回放」(回放在 hydrate() 同步完成后立即派发,见 15.9.6 的 patchProp(el, 'onClick', ...))。hasHydrated 闸门与 { once: true } 双保险确保只注水、只回放一次。teardown 遍历每个元素移除各交互类型的监听器(forEachElement 遍历的可能是 fragment 里的多个根元素)。

(5)forEachElement(:123–149) —— 策略遍历元素的工具,要处理 fragment:fragment 用注释深度计数遍历——<!--[--> 加、<!--]--> 减,归零即到末尾;沿途对每个元素调 cb,false 即提前停止(这就是 hydrateOnVisible 里 observe 返回 false 能中断遍历的原因);非 fragment 直接 cb(node)。任何在 hydration 后要遍历「组件根节点」的逻辑,都必须懂这套注释约定。

15.11.2 策略是怎么接入异步组件的:__asyncHydrate

策略的消费方只有一个:异步组件。apiAsyncComponent.ts:124–158 定义 AsyncComponentWrapper 的 __asyncHydrate(el, instance, hydrate):内部先 performHydrate 做两次安全检查——patched 为真则跳过(instance.bu(beforeUpdate 数组)注册的回调会在任何更新前把 patched 置真,意味「组件在懒注水触发前已被更新」,再注水会冲突,dev 警告);!el.parentNode || (wasConnected && !el.isConnected) 覆盖根元素被移出文档、或曾连着现在断开的情形(wasConnected 指注水时就没连着,后来断开不算「被卸载」,仍可注水)。若给了 hydrateStrategy,就把 performHydrate 与 cb => forEachElement(el, cb) 交给策略,返回的 teardown 存进 instance.bum(beforeUnmount 数组),组件卸载时自动清理——这就是 hydrateOnInteraction 返回的 teardown 的归宿。组件已 resolve 则直接执行,否则先 load() 并在 !instance.isUnmounted 时才执行。

接入点在渲染器(renderer.ts:1404–1415):setupRenderEffect 遇到「vnode 已有 el(说明要 hydration)」且该 vnode 是异步包装组件、type 上有 __asyncHydrate 时,调 __asyncHydrate(el, instance, hydrateSubTree),否则直接 hydrateSubTree()。传给它的第三个参数 hydrateSubTree 就是「真正执行这个组件子树 hydration」的函数(renderComponentRoot + hydrateNode,:1381–1402)。这就是控制反转的落点:策略决定何时调用 hydrateSubTree,而不是渲染器直接调用它。

15.11.3 lazy hydration 的适用边界

结合 15.11.1–15.11.2,可以给出它的定位:

维度说明
粒度只对异步组件生效(defineAsyncComponent),普通同步组件无法用策略延迟注水
前提组件被 async 包装、且提供 hydrateStrategy;包裹的 JS 也要能被延迟加载
收益可视区外、空闲期之外、未交互的组件不注水 → 减少首屏 hydration 的 CPU 与 JS 执行
代价「可见但不可交互」的窗口可能变长;交互组件需要 replay 事件保证不丢首击
风险组件在注水前被更新(patched)→ 静默跳过注水

一句话:lazy hydration 把 hydration 从「mount 时的同步全量扫描」变成了「按需触发的局部扫描」,代价是把「何时注水」的责任与风险交给了策略作者。


15.12 节点复用与定位:游标、锚点与 Suspense / Teleport 的特殊路径

15.12.1 COMPONENT 分支:锚点定位的三种情形

把 15.9.4 里略过的 COMPONENT 分支(hydration.ts:277–342)补全。它先定位游标、再 mountComponent:

// hydration.ts:277
} else if (shapeFlag & ShapeFlags.COMPONENT) {
  vnode.slotScopeIds = slotScopeIds
  const container = parentNode(node)!
  if (isFragmentStart) nextNode = locateClosingAnchor(node)   // 组件可能异步,不能靠渲染输出判断 fragment 结束
  else if (isComment(node) && node.data === 'teleport start') nextNode = locateClosingAnchor(node, node.data, 'teleport end')   // #4293 #6152
  else nextNode = nextSibling(node)
  mountComponent(vnode, container, null, parentComponent, parentSuspense, getContainerType(container), optimized)
}

三种定位:

  1. isFragmentStart(组件根是 fragment,DOM 上是 <!--[-->):用 locateClosingAnchor(node) 前瞻到 <!--]--> 的下一格作为游标。注释「components may be async, we cannot rely on component's rendered output...」——组件子树(尤其异步)此刻可能还没渲染出任何东西,无法靠「子节点对齐完自然走到结束锚」定边界,只能直接看 DOM 里那对注释在哪。
  2. teleport start:组件根是 <Teleport> 时用 locateClosingAnchor(node, 'teleport start', 'teleport end') 前瞻到 <!--teleport end--> 之后(#4293 #6152)——正是 locateClosingAnchor 参数化的用途。
  3. 普通元素/文本:nextSibling(node) 前进一格。

locateClosingAnchor(hydration.ts:770–790) 是公共工具:向右扫描兄弟节点,用 match 计数处理嵌套——每遇开锚 match++,遇闭锚且 match === 0 就返回「闭锚的下一格」,否则 match--(内层闭锚,跳过)。返回 nextSibling(node) 而非 node,是游标契约的又一处体现——游标永远指向「下一个待对齐的位置」。

15.12.2 异步组件的占位 subTree(#3787)

mountComponent 创建实例、设置渲染副作用,但异步组件此刻可能尚未 resolve,vnode.component.subTree 为空——而 hydration 已「认领」这段 DOM(vnode.el = node),subTree 为空则后续移动/卸载都找不到 DOM 范围。于是 hydration.ts:318–342 补一个占位 subTree。注释列了三种场景:组件还没 resolve;组件已 resolve 但用了尚未触发的 lazy hydration 策略(15.11);async setup() 还没 resolve。fragment 情形造 Static vnode 作占位,anchor 设为「游标前一格(即结束锚)」或容器最后一个子节点——Static 在此是「不透明范围」,不渲染内容但记录 DOM 起止边界,可被移动/移除。非 fragment 情形则镜像已认领节点类型——文本 → createTextVNode('');注释 → VComment;元素 → 'div'(「transition hooks assume an element」)。最后把占位挂到已认领 DOM 上,等异步组件真正 resolve 后被替换。这是 hydration 里唯一为「异步组件尚未就绪」预留的结构——把「DOM 已认领但 vnode 子树为空」用占位 vnode 表达出来,使后续操作有合法对象可依。

15.12.3 Suspense 在 hydration 下的处理

Suspense.hydrate(components/Suspense.ts:839–889)用 createSuspenseBoundary(..., true /* hydrating */) 创建边界(:857),true 让边界知道自己在 hydration 模式(影响 pendingBranch、动画与 resolve 路径)。:871–887 注释点明服务端两种可能(成功则内容完全 resolve、失败则 fallback),「然而客户端并不能真正知道它是成功还是失败」,于是(:877)把 vnode.ssContent(主内容分支)当 pendingBranch 去 hydrate——即「先假设服务端成功了」;hydrate 完后若 suspense.deps === 0(无待 resolve 的异步依赖),就 suspense.resolve(false, true)(第二参数 true 表示 hydration 场景、跳过过渡动画),把边界从 pending 切到 resolved——否则边界会一直停在 pending,首次更新行为就不对。若服务端渲染的其实是 fallback(数据没赶上),客户端会把 fallback 当主内容 hydrate 从而 mismatch——典型不一致来源,靠 15.13 的「渲染前拿到异步数据」规避。

15.12.4 Teleport 在 hydration 下的处理

Teleport 是 hydration 里最反直觉的一个:它的内容在 SSR 时被渲染到了另一个位置(目标容器),因此主视图里只有一对注释 <!--teleport start--> / <!--teleport end-->,而真实内容在目标元素里。hydrateTeleport(Teleport.ts:431–542)用两个内部函数处理:

hydrateAnchor(:454–472) 在目标容器里向右扫描注释节点寻找两个锚——teleport start anchor 记入 vnode.targetStart、teleport anchor 记入 vnode.targetAnchor,并把「它之后一格」写进 target._lpa(last teleport anchor)。_lpa 在多 teleport 时起作用:同一目标容器有多个 teleport 服务端渲染过内容时,第二个不能从 target.firstChild 重新找(会撞上前一个的内容),必须从上一个停止处接着找(:494 的 const targetNode = (target as ...)._lpa || target.firstChild)——_lpa 的全部意义:在共享目标容器里为「下一个 teleport 的内容」记录接续点。

hydrateDisabledTeleport(:474–484) 服务端若 teleport 是 disabled(<Teleport disabled>,内容留在原地),DOM 里内容就在主视图内,直接对主视图 children 走 hydrateChildren,结果记为 vnode.anchor。

主流程(:486–541)按「有目标容器 / disabled」分派:

  • 有 target 且 enabled:vnode.anchor = nextSibling(node)(跳过 <!--teleport start-->),hydrateAnchor 在目标容器找锚,找不到则 prepareAnchor(target, vnode, ...) 手工补造(#11400:HTML 里 Teleport 内容未按预期位置嵌入时 targetAnchor 为 null,手工补锚保证后续能正常 unmount/移动),再对目标容器内容走 hydrateChildren(targetNode && nextSibling(targetNode), vnode, target, ...)。
  • 有 target 且 disabled:hydrateDisabledTeleport 处理主视图内容,再 hydrateAnchor 补锚;无 targetAnchor 也 prepareAnchor,insertBefore 位置要区分「目标就是主视图」(parentNode(node) === target ? node : null)以避免 mismatch。
  • 无 target 且 enabled:什么都不做。
  • 无 target 且 disabled:hydrateDisabledTeleport + 把 targetStart/targetAnchor 记为当前节点与兄弟。

最后 return vnode.anchor && nextSibling(vnode.anchor)——游标跳到 teleport 区间末尾之外。Teleport 的 hydration 本质是「在目标容器里重新找到那对锚注释、并认领锚之间的内容」,它对主视图与目标容器同时对齐,这也是它必须拿到 hydrateChildren 作参数的原因。

15.12.5 游标与锚点:一张全景表

把 15.9 与 15.12 的定位规则汇总成一张表,就得到 hydration 的「空间模型」:

vnode 类型DOM 上的形态游标推进方式
文本Text 节点nextSibling(node)(内容不符则先改写)
注释(v-if 假分支等)CommentnextSibling(node)(<template> 则提升后前进)
静态块N 个连续节点循环 staticCount 次前进,记 anchor
fragment<!--[--> … <!--]-->对齐子节点到 <!--]-->,出口为其 nextSibling,记 anchor
元素ElementhydrateChildren(firstChild),出口 el.nextSibling
组件(根是元素)ElementnextSibling(node);mountComponent 内部再 hydrate 子树
组件(根是 fragment)<!--[--> … <!--]-->locateClosingAnchor(node)
组件(根是 teleport)<!--teleport start-->locateClosingAnchor(node, 'teleport start', 'teleport end')
Teleportstart/end 注释 + 目标容器内锚vnode.anchor 的 nextSibling
Suspense主内容或 fallback直接 hydrate ssContent

一句话概括这一节:hydration 的「定位」由两套标记支撑——fragment 的 [/] 注释对与 teleport 的 start/end 注释对;「推进」则统一为一条规则:每个函数返回「下一个待对齐节点」,游标永远是「下一个」,而不是「当前」。


15.13 实战:一个最小 SSR 应用与两类真实的 mismatch 输出

理论走完了,这一节用一个可运行的最小 SSR 应用把全链路串起来,并故意制造两种 mismatch,打印它们在控制台的真实表现。为不依赖构建工具,用 @vue/server-renderer + vue 运行时、render 函数(不用模板),并用 Node 原生 http 提供服务。

15.13.1 三个文件

(1)组件 App.js —— 一个计数器:count = ref(0),setup 返回 render 闭包 h('main', [h('h1', 'SSR + Hydration Demo'), h('button', { onClick: () => count.value++ }, count: ${count.value})])。

(2)服务端 server.mjs —— 用 Node 原生 http.createServer,每次请求里 createSSRApp(App)、await renderToString(app, ctx) 产出 HTML,并把 ctx(SSRContext)经 JSON.stringify 序列化后注入 <script>。两个必须注意的点,恰好对应前面章节:

  • JSON.stringify(ctx).replace(/</g, '\\u003c') —— 序列化 payload 必须转义 <,否则数据含 </script> 时会提前闭合 script 标签、破坏 HTML 结构(这也是 hydration mismatch/注入风险的常见来源)。这是「SSR 数据传递」的安全底线。
  • <div id="app">${html}</div> —— renderToString 的输出直接嵌入挂载点,客户端 hydrate 的正是这段 HTML。

(3)客户端 client.js —— createSSRApp 而非 createApp:const app = createSSRApp(App); app.mount('#app')(createSSRApp 的 mount 默认 isHydrate=true;window.__SSR__ 即 ctx)。

createSSRApp 与 createApp 的唯一实质差别:前者让 mount 以 isHydrate = true 调用(runtime-dom/src/index.ts:154–171),于是走 ensureHydrationRenderer 创建的开启 hydration 的渲染器,mount 最终调 hydrate(vnode, container) 而非 render(...)(apiCreateApp.ts:395)——即 15.8→15.9 那条调用链的入口。

启动服务端后访问页面:HTML 一到达,标题与按钮文本立即可见(无需 JS);client.js 执行后 @click 才被绑上——点一次计数变 count: 1。hydration 成功的证据:控制台无任何 Vue 警告,页面无闪烁/重绘(DOM 被复用而非重建)。

15.13.2 制造 mismatch 一:服务端与客户端文本不同

把组件换成「渲染一个时间戳」——服务端与客户端的 Date 必然不同,这是最经典的 mismatch:

// App.js(改造)
export default {
  setup() { const now = new Date().toLocaleTimeString(); return () => h('p', `rendered at ${now}`) },
}

服务端渲染出 rendered at 10:00:00,客户端执行时却是 rendered at 10:00:01。控制台输出(dev 构建):

[Vue warn]: Hydration text mismatch in
 <p> (…) 
  - rendered on server: "rendered at 10:00:00"
  - expected on client: "rendered at 10:00:01"
Hydration completed but contains mismatches.

对应源码:第一条 warn 来自 hydrateElement 的 TEXT_CHILDREN 分支(hydration.ts:493–499),第二条 console.error 来自 logMismatchError(:59–66)。行为:客户端把 <p> 文本就地改成 rendered at 10:00:01(:502 的 el.textContent = vnode.children),用户看到的是客户端时间。这是 15.10.1「TEXT 属结构级、会被就地修正」的实证。

用 data-allow-mismatch 静音:改成 h('p', { 'data-allow-mismatch': 'text' }, \rendered at ${now}`)。再刷新:**两条输出都消失**(isMismatchAllowed(el, MismatchTypes.TEXT)返回 true,跳过logMismatchError,见 hydration.ts:492),但**修正动作仍发生**(文本照样改成客户端值)。这验证 15.10.5 的核心结论:**data-allow-mismatch` 抑制报警、不抑制修正。**

15.13.3 制造 mismatch 二:节点类型在两端不同

再看结构级 mismatch——服务端与客户端渲染出不同的标签:

// 客户端渲染 <div>,服务端渲染 <span>(通过条件分支依赖客户端才有的信息)
export default {
  setup() { const isClient = typeof window !== 'undefined'; return () => h(isClient ? 'div' : 'span', 'content') },
}

服务端产出 <span>content</span>,客户端期望 <div>。控制台输出:

[Vue warn]: Hydration node mismatch:
- rendered on server: <span>content</span>
- expected on client: div 
Hydration completed but contains mismatches.

对应 handleMismatch 的警告(hydration.ts:717–729)。行为:handleMismatch 删掉服务端的 <span>,再用 patch(null, vnode) 重新挂一个 <div>(:749–760)——即 15.10.1「结构级失配必兜底:重建节点」的实证,用户会看到内容被「重建」(可能有一次极短的闪烁)。

这里也是 data-allow-mismatch 的边界:加它能让警告消失,但 handleMismatch 的重建依然执行——对结构级不一致只能「消音」,不能「消除重建」。真正的修复只有让两端渲染出相同结构(避免 typeof window 这类分叉)。

15.13.4 为什么「结构必须一致」:一个上游视角

把两个 mismatch 放一起看,就能理解 Vue 这套设计背后的排序:

症状类型是否有兜底后果
文本不同TEXT有(就地改写)内容跳变,结构不变,页面可用
节点类型不同CHILDREN/node有(删旧造新)内容重建,可能闪烁,页面可用
class/style/attribute 不同CLASS/STYLE/ATTR无(生产不检测)不修正、不报警,页面按服务端产物呈现

结论与 15.10.1 一致:结构正确是「能用」的底线(客户端无条件兜底),属性正确是「对」的追求(只提醒、生产连提醒都没有)。 写 SSR 组件的第一条纪律就是「渲染逻辑不能在两端分叉」;唯一例外是有意为之的值差异(时间戳、随机数),用 data-allow-mismatch 声明。

15.13.5 一个能真正「流」起来的服务端

最后把 15.7 的结论落到这个例子上:想让响应真正流式到达,就必须把数据预取放在渲染之前。

改造要点:数据必须在 renderToStream 之前拿到(onServerPrefetch / 路由级预取),否则流会在 Promise 处卡住;随后 res.write('<!DOCTYPE html>...<div id="app">')、pipeToNodeWritable(app, {}, res) 把 chunk 直接写进 res、再 res.write('</div><script type="module" src="/client.js">...') 收尾。

注意这里用 pipeToNodeWritable(renderToStream.ts:133)把 chunk 直接写进 HTTP 响应——就是 15.7.6 那个「把 SimpleReadable 翻译成 Writable」的适配器。组件里有慢接口时,它会在那个 Promise 处停下等(15.7.8):所以顺序流式的正确用法,仍是把「慢的部分」尽量做成「可先算出的静态骨架 + 后注入的数据」,而不是指望流式本身替你解决延迟。


15.14 本章要点回顾

15.14.1 服务端

  1. SSR 搬走的是「首屏 DOM 的生成」,而 hydration 把「对齐」留给了客户端。SSR 的收益是首屏可见性与 SEO,代价是五笔账:环境依赖、服务器 CPU、hydration 成本、状态双份、mismatch 调试。
  2. ssrRender 与客户端 render 是同源项目的两份编译产物。有 ssrRender 走编译特化路径,没有则退回 renderVNode 通用路径或(CJS + 模板)现场 ssrCompile。
  3. 三个入口的差异不是「同步/异步」,而是「输出形态」:renderToString 返回完整字符串,renderToSimpleStream 边算边 push,其余是它的适配器。
  4. SSRBuffer 是一棵树,不是一段字符串(render.ts:45)。推入项可以是字符串、子 buffer 或 Promise;hasAsync 标记「这棵树里有没有 Promise」,从而让纯静态树走零 await 的同步摊平快车道。
  5. 流式 = 顺序流式。遇到 Promise 就停下等,不做乱序补位;TTFB 的收益来自「静态块先吐出去」。
  6. SSRContext 与 __teleportBuffers:teleport 的内容在服务端被单独渲染进目标 buffer、最后 resolveTeleports 统一结算;onServerPrefetch 是唯一推荐的「服务端取数」钩子;initDirectivesForSSR 让服务端能识别指令但不执行。
  7. scopeId/slotScopeId 在服务端就写进每个标签的 data-v-xxx,hydration 认领后靠 slotScopeIds 合并传递延续作用域。

15.14.2 客户端

  1. hydration 只有两个函数:hydrate(根)+ hydrateNode(递归),由 createHydrationFunctions 闭包渲染器内部能力生成。所有 vnode 类型在 hydrateNode 的 switch 里被分派。
  2. 游标契约是 hydration 的骨架:每个函数返回「下一个待对齐节点」,hydrateChildren 据此线性推进;hydrateElement 出口是 el.nextSibling。
  3. vnode.el = node 是「认领」:hydration 阶段 el 来自已存在的 DOM,而非新建。ref、hook、scopeId 都在认领后补上。
  4. 跳过策略靠编译期信息:patchFlag === CACHED(提升静态)直接跳过 props/children;optimized/dynamicChildren 走 block 快车道;NEED_HYDRATION 是专为 hydration 引入的 patchFlag。
  5. props 只在五类情形重写:input/option 的 value、事件监听器、.prop 修饰、自定义元素、动态属性;isUnchangedResourceProp 避免 src/href 等重复触发资源加载。
  6. 结构性差异必兜底、属性级差异只报警:文本与节点失配会就地改写或重建;class/style/attribute 在生产默认既不检测也不修正。
  7. data-allow-mismatch 只抑制报警、不抑制修正,粒度到元素(TEXT/CHILDREN 可向上冒泡,CLASS/STYLE/ATTR 只在元素自身)。
  8. lazy hydration 只对异步组件生效,用 hydrateOnIdle/hydrateOnVisible/hydrateOnMediaQuery/hydrateOnInteraction 推迟注水;hydrateOnInteraction 需要 replay 那次交互事件;teardown 挂在 instance.bum 上。
  9. 锚点是定位的全部:fragment 的 <!--[-->/<!--]-->、teleport 的 <!--teleport start/end--> 与目标容器内的 teleport anchor;locateClosingAnchor 处理嵌套;异步组件的 subTree 用占位(Static 或镜像类型)表达「已认领但未渲染」。

15.14.3 若干深度问答

Q1:为什么 hydration 不能用「服务器生成的 HTML 字符串」与「客户端 vnode 树」做一次 diff,而要逐节点遍历? 因为 diff 需要两侧结构可比。HTML 字符串解析成 DOM 后,节点的类型、顺序、层级是浏览器解析器决定的(空白折叠、标签大小写、<pre> 吃换行、相邻文本合并……),与 vnode 树并非同构。因此不能做「树对树」的算法 diff,只能按 vnode 的顺序线性游标扫描 DOM,并在扫描中对每个位置判断「这里应不应该是我」。这也是为什么游标机制(15.9.1)是整个算法的基石。

Q2:optimized(block 优化)在 hydration 里到底省了什么? 省掉的是逐个 child 的 normalizeVNode 与类型判断。mount 的 diff 里,block 让「只 patch 动态子节点」成为可能;hydration 里,optimized 让 hydrateChildren 直接用 children[i] 且相信其顺序,跳过规范化和「连续文本拆分」等防御逻辑(那些逻辑只在非 optimized 时跑)。

Q3:hasAsync 为什么是一个布尔位而不是「缓冲区里有没有 Promise 的数组」? 因为摊平只需要一个二值信号:有 Promise 就整体走 await 慢车道,没有就走同步快车道。用一个 hasAsync 布尔成本最低(createBuffer 首次遇到 Promise 时置位),且足以在两个快慢车道间做 O(1) 选择。它是「用最少的元数据换取最大的分支收益」的典型例子。

Q4:hydration 完成后靠什么保证「下一次更新」是正确的? 靠三样:container._vnode = vnode(根基准,15.8.2)、每个 vnode 的 el 已指向真实 DOM(认领)、以及 anchor/targetAnchor 等边界标记已就位。这三者一起,让后续 patch(oldVNode, newVNode) 能像纯客户端渲染一样工作——hydration 的最终目的不是「渲染一次」,而是「把页面交还给正常的客户端渲染器」。

15.14.4 收束

这一章走过了一条完整的同构链路:compiler-ssr 生成 ssrRender → renderComponentVNode 遍历成 SSRBuffer 树 → renderToString/renderToStream 摊平成 HTML → 浏览器解析成 DOM → createSSRApp 触发 hydrate → hydrateNode 用游标与锚点逐节点认领 → mismatch 检测与逃生舱 → 交还给客户端渲染器。

贯穿始终的一条线索是 「用编译期的确定性换运行期的不确定性」:服务端靠「静态/动态」的编译期标记直接拼字符串(跳过 diff);客户端靠 patchFlag/dynamicChildren/staticCount 跳过大半 props/children 检查。hydration 的每一次「跳过」,背后都是编译期已经知道的那件事。反过来说,凡是编译期无法确定的东西(时间戳、随机数、客户端专有状态、非字符串 v-model、事件监听器),就是 hydration 必须付出代价的地方——理解这条界线,就等于理解了整个 Vue 3 SSR 体系的设计边界。