Appearance
SSR 之同构与 Hydration:客户端如何"激活"静态 HTML?
概述
服务端渲染(SSR)的 renderToString 函数为我们生成了静态 HTML。这份 HTML 响应快,利于 SEO,但不具备交互能力——@click 事件无效,响应式数据也不会更新。
Hydration(水合),就是 Vue 在客户端“激活”这份静态 HTML 的过程。
这个过程的目标是:在不销毁和重建 DOM 的前提下,让客户端的 Vue VNode“认领”并“接管”服务端渲染的 DOM 节点,并为其附加事件监听器和响应式能力。
入口:createSSRApp 与“水合模式”
Hydration 流程的起点是 createSSRApp,它与 createApp 的唯一区别在于它重写了 mount 方法。
typescript
// core/packages/runtime-dom/src/index.ts
export const createSSRApp = ((...args) => {
// 1. 获取一个“水合渲染器”
// (内部调用 createRenderer,但传入了 hydration 相关的函数)
const app = ensureHydrationRenderer().createApp(...args)
const { mount } = app
// 2. 【核心】重写 mount 方法
app.mount = (containerOrSelector: Element | string): any => {
const container = normalizeContainer(containerOrSelector)
if (container) {
// 3. 调用原始 mount,但【强制】传入
// isHydrating = true
return mount(container, true /* isHydrating */)
}
}
return app
})createSSRApp 确保了 mount 函数在执行时,会开启“水合模式”。
hydrate:“DOM 认领”的启动
当 mount(container, true) 被调用时,渲染器会调用 renderer.hydrate (runtime-core/src/hydration.ts)。
hydrate 函数是“认领”流程的入口:
typescript
// core/packages/runtime-core/src/hydration.ts
function createHydrationFunctions(rendererInternals) {
const hydrate: RootHydrateFunction = (
vnode, // 客户端 App VNode
container // #app 容器
) => {
// 1. 检查:容器是否为空?
if (!container.hasChildNodes()) {
// 如果 SSR 失败,容器是空的,
// 则“降级”为标准的客户端渲染 (patch)
patch(null, vnode, container)
return
}
// 2. 【启动】
// 调用 hydrateNode,从“第一个子节点”开始,
// 尝试用“根 VNode”去“认领”它
hydrateNode(
container.firstChild!, // 真实的 DOM 节点
vnode, // App VNode
// ...
)
// 3. 清理 post-flush 队列 (如 onMounted 钩子)
flushPostFlushCbs()
}
return [hydrate, hydrateNode]
}hydrateNode:VNode 与 DOM 的“1:1 匹配”
hydrateNode 是 Hydration 的核心递归函数。它的职责是:判断“当前 VNode”和“当前 DOM 节点”是否匹配。
typescript
// core/packages/runtime-core/src/hydration.ts
function hydrateNode(
node: Node, // 当前 DOM 节点 (e.g., <p>)
vnode: VNode, // 当前 VNode (e.g., VNode<p>)
// ...
): Node | null { // 返回“下一个”要匹配的 DOM 兄弟节点
// 1. 【乐观认领】
// 先假设它们匹配,将 vnode.el 指向 DOM
vnode.el = node
// 2. 【类型匹配】
// 使用 switch 检查 vnode.type 和 node.nodeType
switch (vnode.type) {
case Text:
if (node.nodeType !== DOMNodeTypes.TEXT) {
// VNode 是 Text,DOM 不是 Text -> 【不匹配】
return handleMismatch(node, vnode, ...)
}
// (省略文本内容不匹配的警告) ...
return nextSibling(node) // 返回下一个 DOM 兄弟节点
case Comment:
if (node.nodeType !== DOMNodeTypes.COMMENT) {
// VNode 是 Comment,DOM 不是 Comment -> 【不匹配】
return handleMismatch(node, vnode, ...)
}
return nextSibling(node) // 返回下一个
case Fragment:
// ... (处理 Fragment 的起止锚点) ...
default:
// 3. 【元素/组件匹配】
if (vnode.shapeFlag & ShapeFlags.ELEMENT) {
return hydrateElement(node as Element, vnode, ...)
} else if (vnode.shapeFlag & ShapeFlags.COMPONENT) {
return hydrateComponent(node, vnode, ...)
}
// ...
}
}
// hydrateElement (简化)
function hydrateElement(
el: Element, // DOM <p>
vnode: VNode, // VNode <p>
// ...
): Node | null {
// 4. 【标签名匹配】
if (el.tagName.toLowerCase() !== (vnode.type as string).toLowerCase()) {
// VNode 是 <span>,DOM 是 <p> -> 【不匹配】
return handleMismatch(el, vnode, ...)
}
// 5. 【“认领” Props 和 Events】
if (vnode.props) {
hydrateProps(el, vnode.props, vnode, ...)
}
// 6. 【递归“认领”子节点】
if (vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
hydrateChildren(el.firstChild, vnode, el, ...)
}
// 7. 返回下一个 DOM 兄弟节点
return el.nextSibling
}hydrateNode 递归地“深度优先”遍历 VNode 树和 DOM 树,并同步地移动“DOM 指针”(nextSibling),确保 VNode 和 DOM 1:1 匹配。
“不匹配”(Mismatch)的处理:handleMismatch
如果 VNode 和 DOM 不匹配(“水合失败”),Vue 会放弃在该节点的水合,并强制“降级”为客户端渲染。
typescript
// core/packages/runtime-core/src/hydration.ts
const handleMismatch = (
node: Node, // 错误的 DOM
vnode: VNode, // 期望的 VNode
// ...
): Node | null => {
// 1. (开发环境) 发出警告:
warn('Hydration node mismatch:', node, 'expected:', vnode.type)
// 2. 【放弃认领】
vnode.el = null
// 3. 找到下一个兄弟节点,作为“锚点”
const next = nextSibling(node)
// 4. 获取父 DOM 容器
const container = parentNode(node)!
// 5. 【移除】
// 从 DOM 树中移除“错误”的 SSR 节点
remove(node)
// 6. 【降级为 patch】
// 在“锚点”之前,“全新地”挂载 (mount)
// 这个 VNode 及其所有子节点。
patch(
null, // n1 = null (挂载)
vnode,
container,
next, // 锚点
// ...
)
// 7. 返回“锚点”,让父级的 hydrateChildren 继续
return next
}“激活”:patchProp 在水合模式下的特殊行为
“认领” DOM 结构只是第一步,“激活”(附加事件)是第二步。
在 hydrateElement 中,会调用 hydrateProps,它会遍历 props 并调用 patchProp。在“水合模式”(isHydrating = true)下,patchProp 的行为完全不同。
typescript
// core/packages/runtime-dom/src/patchProp.ts
export const patchProp: DOMRendererOptions['patchProp'] = (
el,
key,
prevValue, // 水合时为 null
nextValue, // VNode 上的值
namespace,
parentComponent,
isHydrating
) => {
if (isOn(key)) {
// 1. 【关键:激活事件】
// 无论是否为水合模式,
// 事件监听器“必须”被附加
patchEvent(el, key, prevValue, nextValue, parentComponent)
}
// ... (省略 class, style, attr, prop 的逻辑) ...
// 2. 【关键:跳过属性设置】
if (isHydrating) {
// 如果是水合模式,我们“假设”服务端的 HTML 是正确的。
// 我们“不需要” (也不应该) 再次设置 DOM 属性
// (e.g., el.className = ..., el.style = ...)
// 我们只做一件事:附加事件监听器。
// (但 Vue 会在 DEV 模式下,
// 执行 propHasMismatch 来“检查”属性是否匹配,
// 如果不匹配,只会“警告”,不会“纠正”)
return
}
// 3. 【非水合模式】
// (正常的客户端渲染/更新)
if (key === 'class') {
patchClass(el, nextValue, ...)
} else if (key === 'style') {
patchStyle(el, prevValue, nextValue)
} else {
// ...
}
}patchProp 的水合逻辑:isHydrating 标志是一个“开关”。它关闭了 patchProp 所有的“DOM 写入”操作(setAttribute, el.style),只保留了 patchEvent(addEventListener)这个“DOM 激活”操作。
特殊组件的水合
<Suspense>(hydrateSuspense):Suspense的水合必须同时检查“默认插槽”和“回退插槽”的 DOM。- 如果 SSR 渲染的是“默认”内容(
ssContent),它就尝试**“认领”默认内容**。 - 如果 SSR 渲染的是“回退”内容(
ssFallback),它就尝试**“认领”回退内容**,并在客户端立即启动异步解析(suspense.deps > 0)。hydrateSuspense的职责是让客户端的Suspense状态与服务端的 DOM 保持同步。
- 如果 SSR 渲染的是“默认”内容(
<Teleport>(hydrateTeleport): Teleport 的水合需要在两个位置进行“认领”:- 在“逻辑位置”(原位)“认领”占位符(
vnode.anchor)。 - 在“物理位置”(
to属性指定的目标)“认领”真实内容(vnode.targetAnchor)。
- 在“逻辑位置”(原位)“认领”占位符(
总结
Hydration 是 Vue SSR 的“最后一步”,它是一个**“认领”和“激活”**的过程,而非“创建”:
- 入口 (
createSSRApp):设置isHydrating = true标志,启动“水合模式”。 - 入口 (
hydrate):启动hydrateNode,开始 1:1 “认领” DOM 节点。 - 执行 (
hydrateNode):- 匹配:递归地对比 VNode 与 DOM 节点。
- “认领”:如果匹配,将
vnode.el指向 DOM,并递归hydrateChildren。 - “纠错”:如果不匹配(
handleMismatch),则放弃水合,移除错误 DOM,并降级为客户端patch。
- 激活 (
patchProp):- 在水合模式下,
patchProp会跳过所有“DOM 属性设置”操作(假定 SSR 是正确的)。 - 它的唯一职责是调用
patchEvent附加事件监听器,为静态 HTML 赋予交互能力。
- 在水合模式下,
