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 到用户看到内容,必须依次完成:
- 下载 HTML 外壳。这个外壳几乎是空的——通常只有
<div id="app"></div>和一个指向 JS bundle 的<script>。 - 下载 JS bundle。这是首屏里最重的一步,现代框架应用的 bundle 动辄几百 KB 到数 MB(压缩前后),且受限于带宽与 RTT。
- 解析并执行 JS。V8 需要 parse、compile、最终 execute 整个 bundle,这一步是纯 CPU 阻塞的。
- 创建组件实例、执行首次渲染。此时才第一次调用
render,产出 vnode 树。 patch生成真实 DOM 并插入文档。用户在这一刻之前看到的一直是空白或留白。- 加载首屏依赖的数据。多数应用在这一步才发起 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 / SSG | SEO + 首屏内容可见是硬需求,组件不复杂,服务器成本可接受 |
| 强交互后台、内部工具 | CSR | 登录后才有内容,SEO 无意义,交互复杂度远大于首屏 |
| 首屏内容少、后续全是交互 | CSR 或 islands | hydration 整棵树不划算,用 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["构建期(Node/Vite)"]
SFC["SFC .vue 文件"] --> CST["@vue/compiler-sfc"]
CST --> CS["@vue/compiler-ssr<br/>(transform + codegen)"]
CS -->|"生成 ssrRender(ctx, push, ...)"| OUT["服务端渲染函数<br/>如 _ssrRender 或 comp.ssrRender"]
end
subgraph Server["服务端(Node / Edge)"]
APP["createSSRApp(App)"] --> RTS["renderToString(app, ctx)"]
RTS --> RCV["render.ts / renderComponentVNode"]
RCV --> RV["renderVNode → renderElementVNode"]
RV --> HELP["ssrRenderAttrs / ssrRenderSlot / ssrRenderTeleport"]
RCV --> BUF["SSRBuffer 树"]
BUF --> UNROLL["unrollBuffer → HTML 字符串"]
UNROLL --> HTML["完整 HTML 文本"]
HTML --> INJECT["序列化 payload 注入 "]
end
subgraph Client["客户端(浏览器)"]
HTML2["收到的 HTML"] --> PARSE["浏览器解析成真实 DOM"]
PARSE --> HYD["createSSRApp(App).mount('#app')"]
HYD --> HF["createHydrationFunctions"]
HF --> HN["hydrateNode → hydrateElement/hydrateChildren"]
HN -->|"认领 DOM"| READY["可交互页面(事件/响应式就绪)"]
end
OUT -.->|"同一份源码"| 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)。这不成立,原因有三:
- Node 里没有 DOM。 标准
createApp绑定的是运行时 DOM 渲染器(runtime-dom),它的mount需要真实容器。createSSRApp走的是另一个渲染器(ensureHydrationRenderer,runtime-dom/src/index.ts:87–93),它的mount甚至不渲染任何东西——它只负责把组件实例与 vnode 挂起来,等真正的 HTML 由renderToString产出。 - 服务端渲染依赖的是
ssrRender而非render。 两者签名与语义都不同:客户端render()返回 vnode 树;服务端ssrRender(proxy, push, instance, attrs, props, setupState, data, ctx)直接向push推字符串(render.ts:208–218)。服务端根本不需要一棵 vnode 树去 diff,它只需要「输出」。 - 服务端不做响应式追踪。 服务器渲染是一次性的,没有后续更新,因此不需要
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转发到 NodeReadable、NodeWritable、WebReadableStream或 WebWritableStream。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 知道的约束:
- ESM 构建不支持
renderToNodeStream。renderToStream.ts:118–128用__CJS__分支 +require('node:stream')拿Readable;在 ESM 下stream为null,于是抛错并提示你改用pipeToNodeWritable(自己准备一个Writable)。这是因为打包器通常无法在 ESM 里保留对node:stream的运行时require。 renderToWebStream要求全局有ReadableStream构造器(renderToStream.ts:156–162),Node 18+ 才全局提供;更老的 Node 需要renderToWebStream之外的 adapter。pipeToWebWritable处理了 CloudFlare Workers 的兼容性:CF 的writer.ready不是 Promise,代码用一个try/catch探测hasReady(renderToStream.ts:197–201),只有在是 Promise 时才await writer.ready。- 流对象返回时机:
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 中的真实落点 | 说明 |
|---|---|---|
onError | app.config.errorHandler / handleError(render.ts:220 捕获渲染错误后调用 handleError) | 组件渲染函数抛错时进入错误处理链,而非静默丢弃 |
onWarn | app.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),NodeWritable自己维护highWaterMark并返回false表示「先别写了」。但 Vue 的实现没有 awaitwrite的返回值,所以严格来说它不做背压(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 | 输出/行为差异 |
|---|---|---|---|
Text | hostCreateText + 插入 | push(escapeHtml(children)) | 服务端必须 HTML 转义;客户端不需要 |
Comment | hostCreateComment | push('<!---->') 或 <!--xxx--> | 空注释用于占位(v-if 空洞、Fragment 边界) |
Static | cloneStaticVNode 复用 | push(children) 直接输出 | 服务端零成本;客户端靠 CACHED 跳过 patch |
Fragment | createText/锚点注释 | <!--[--> ... <!--]--> | 用注释模拟 fragment 边界,供 hydration 识别 |
| Element | hostCreateElement + patchProp | renderElementVNode 拼开/闭标签 + ssrRenderAttrs | 属性合并成字符串 |
| Component | mountComponent + effect | renderComponentVNode → ssrRender 或 renderVNode | 无响应式 effect |
| Teleport | 真把节点搬到 target | renderTeleportVNode → 注释占位 + 存 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 isssrRenderSuspense(_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)
这里有三个要点:
renderedScopeIds去重(render.ts:324–334):同一个 scope id 不会重复拼两次。hasOwn(props, id)例外(render.ts:327):如果 props 里已经显式有同名属性,说明这个 scopeId 会通过ssrRenderAttrs输出,这里就跳过,避免重复。- 父链继承(那个
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 ssrRender | instance.ssrRender 或 comp.ssrRender 存在 | 编译产物,直接 push | 最优 |
| B' 现场编译 | !render && !ssrRender && isString(template) 且 __CJS__ | ssrCompile | 一次性编译后缓存 |
| C 通用 render | 有客户端 render | renderComponentRoot → 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
服务端有两层缓存:
comp.ssrRender的「一次编译、多次复用」(render.ts:161–168)。ssrCompile只在组件没有ssrRender/render而有template时才触发,结果写回comp.ssrRender,跨请求复用。这是安全的,因为ssrRender是纯函数(输入 ctx/push,输出字符串),不含请求态。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["createSSRApp(App)"] --> B["app.provide(ssrContextKey, ctx) 之外的 appContext"]
B --> C["renderToString(app, context)"]
C --> D["createVNode(app._component, app._props)"]
D --> E["vnode.appContext = app._context"]
E --> F["app.provide(ssrContextKey, context)"]
F --> G["renderComponentVNode(vnode)"]
G --> H["createComponentInstance → setupComponent(instance, true)"]
H --> I{"async setup / prefetch?"}
I -- 否 --> J["renderComponentSubTree(instance)"]
I -- 是 --> K["await → 跑 onServerPrefetch → renderComponentSubTree"]
J --> L["createBuffer → push 序列"]
K --> L
L --> M["ssrRender 或 renderVNode 递归遍历 vnode 树"]
M --> N["SSRBuffer 树"]
N --> O["unrollBuffer → HTML 字符串"]
O --> P["resolveTeleports(context)"]
P --> Q["stopWatchers(context)"]
Q --> R["返回 HTML"]
15.6.2 遍历 vnode 树的三个「截断点」
renderVNode 递归时会在三个地方「不往下走」,理解它们对估算 SSR 性能很关键:
Staticvnode(render.ts:263):push(children)直接输出。静态子树在编译期被提升(hoist),其内容作为一个完整字符串存进 vnode.children(甚至可能是空串,等 hydration 时再「adopt」——见 15.9 的Static分支)。服务端不必递归。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则要转义。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 + 挂 appContext | renderToString.ts:80–81 | 同步 | — |
provide(ssrContextKey, context) | renderToString.ts:83 | 同步 | — |
| 渲染根组件 | renderToString.ts:87 | 可能异步 | 抛错 → catch |
| 摊平 buffer | renderToString.ts:88 | 可能异步 | 抛错 → catch |
| 结算 teleports | renderToString.ts:89 | 异步 | 抛错 → catch |
| 失败清理 | renderToString.ts:91 | 异步(不阻塞上抛) | 吞掉清理错误 |
| 停 watcher | renderToString.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}。四个入口的分工:
| 入口 | 位置 | 返回/参数 | 依赖 |
|---|---|---|---|
renderToSimpleStream | renderToStream.ts:60 | T extends SimpleReadable | 无(底层原语,自定义 stream) |
renderToNodeStream | renderToStream.ts:114 | Readable | 需要 node:stream,CJS 构建才可用 |
renderToStream(废弃) | renderToStream.ts:104 | Readable | 转调 renderToNodeStream,打印弃用警告 |
pipeToNodeWritable | renderToStream.ts:133 | void | 接收一个已有 Writable |
renderToWebStream | renderToStream.ts:152 | ReadableStream | 需要全局 ReadableStream |
pipeToWebWritable | renderToStream.ts:189 | void | 接收一个已有 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 的产物对照
最后用一张对照表收口,把两者在同一个缓冲区树上的行为差异固化下来:
| 维度 | renderToString | renderToStream(renderToSimpleStream) |
|---|---|---|
| 返回值 | Promise<string>(完整 HTML) | SimpleReadable(同步返回) |
| 摊平实现 | nestedUnrollBuffer → 累加 ret 字符串 | 流式 unrollBuffer → 边摊边 push |
| 同步快车道 | unrollBufferSync(renderToString.ts:56) | unrollBufferSync(renderToStream.ts:48) |
| 峰值内存 | 完整字符串常驻 | 缓冲区树 + 已推块(交给下游) |
| TTFB | 整棵树渲染完才吐第一字节 | 第一个静态块算出即吐 |
| 错误传播 | reject + cleanupFailedRender | stream.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) |
|---|---|---|
| 得到 DOM | hostCreateElement(vnode.type, ...) | 直接用 el(已传入的真实元素) |
| children | 逐个递归 patch(null, child, el) | hydrateChildren(el.firstChild, vnode, el) |
| props | 遍历 props 全部 patchProp 写入 | 分条件:多数跳过,少数(input/option/事件/动态属性)才 patch |
指令 created | invokeDirectiveHook(... '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 = ... |
| 内容级 | CLASS | propHasMismatch | 仅检查告警,不修正 |
| 内容级 | STYLE | propHasMismatch | 仅检查告警,不修正 |
| 内容级 | ATTRIBUTE | propHasMismatch | 仅检查告警,不修正 |
这条分野极其重要,它解释了「属性/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 各分支 → onMismatch | handleMismatch:删旧节点 + 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)
}
三种定位:
isFragmentStart(组件根是 fragment,DOM 上是<!--[-->):用locateClosingAnchor(node)前瞻到<!--]-->的下一格作为游标。注释「components may be async, we cannot rely on component's rendered output...」——组件子树(尤其异步)此刻可能还没渲染出任何东西,无法靠「子节点对齐完自然走到结束锚」定边界,只能直接看 DOM 里那对注释在哪。teleport start:组件根是<Teleport>时用locateClosingAnchor(node, 'teleport start', 'teleport end')前瞻到<!--teleport end-->之后(#4293 #6152)——正是locateClosingAnchor参数化的用途。- 普通元素/文本:
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 假分支等) | Comment | nextSibling(node)(<template> 则提升后前进) |
| 静态块 | N 个连续节点 | 循环 staticCount 次前进,记 anchor |
| fragment | <!--[--> … <!--]--> | 对齐子节点到 <!--]-->,出口为其 nextSibling,记 anchor |
| 元素 | Element | hydrateChildren(firstChild),出口 el.nextSibling |
| 组件(根是元素) | Element | nextSibling(node);mountComponent 内部再 hydrate 子树 |
| 组件(根是 fragment) | <!--[--> … <!--]--> | locateClosingAnchor(node) |
| 组件(根是 teleport) | <!--teleport start--> | locateClosingAnchor(node, 'teleport start', 'teleport end') |
| Teleport | start/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 服务端
- SSR 搬走的是「首屏 DOM 的生成」,而 hydration 把「对齐」留给了客户端。SSR 的收益是首屏可见性与 SEO,代价是五笔账:环境依赖、服务器 CPU、hydration 成本、状态双份、mismatch 调试。
ssrRender与客户端render是同源项目的两份编译产物。有ssrRender走编译特化路径,没有则退回renderVNode通用路径或(CJS + 模板)现场ssrCompile。- 三个入口的差异不是「同步/异步」,而是「输出形态」:
renderToString返回完整字符串,renderToSimpleStream边算边push,其余是它的适配器。 SSRBuffer是一棵树,不是一段字符串(render.ts:45)。推入项可以是字符串、子 buffer 或 Promise;hasAsync标记「这棵树里有没有 Promise」,从而让纯静态树走零await的同步摊平快车道。- 流式 = 顺序流式。遇到 Promise 就停下等,不做乱序补位;TTFB 的收益来自「静态块先吐出去」。
SSRContext与__teleportBuffers:teleport 的内容在服务端被单独渲染进目标 buffer、最后resolveTeleports统一结算;onServerPrefetch是唯一推荐的「服务端取数」钩子;initDirectivesForSSR让服务端能识别指令但不执行。scopeId/slotScopeId在服务端就写进每个标签的data-v-xxx,hydration 认领后靠slotScopeIds合并传递延续作用域。
15.14.2 客户端
- hydration 只有两个函数:
hydrate(根)+hydrateNode(递归),由createHydrationFunctions闭包渲染器内部能力生成。所有 vnode 类型在hydrateNode的 switch 里被分派。 - 游标契约是 hydration 的骨架:每个函数返回「下一个待对齐节点」,
hydrateChildren据此线性推进;hydrateElement出口是el.nextSibling。 vnode.el = node是「认领」:hydration 阶段el来自已存在的 DOM,而非新建。ref、hook、scopeId 都在认领后补上。- 跳过策略靠编译期信息:
patchFlag === CACHED(提升静态)直接跳过 props/children;optimized/dynamicChildren走 block 快车道;NEED_HYDRATION是专为 hydration 引入的 patchFlag。 - props 只在五类情形重写:input/option 的 value、事件监听器、
.prop修饰、自定义元素、动态属性;isUnchangedResourceProp避免src/href等重复触发资源加载。 - 结构性差异必兜底、属性级差异只报警:文本与节点失配会就地改写或重建;class/style/attribute 在生产默认既不检测也不修正。
data-allow-mismatch只抑制报警、不抑制修正,粒度到元素(TEXT/CHILDREN 可向上冒泡,CLASS/STYLE/ATTR 只在元素自身)。- lazy hydration 只对异步组件生效,用
hydrateOnIdle/hydrateOnVisible/hydrateOnMediaQuery/hydrateOnInteraction推迟注水;hydrateOnInteraction需要 replay 那次交互事件;teardown 挂在instance.bum上。 - 锚点是定位的全部: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 体系的设计边界。