Skip to content

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),保留了 patchEventaddEventListener)这个“DOM 激活”操作。


特殊组件的水合

  • <Suspense> (hydrateSuspense): Suspense 的水合必须同时检查“默认插槽”和“回退插槽”的 DOM。

    • 如果 SSR 渲染的是“默认”内容(ssContent),它就尝试**“认领”默认内容**。
    • 如果 SSR 渲染的是“回退”内容(ssFallback),它就尝试**“认领”回退内容**,并在客户端立即启动异步解析(suspense.deps > 0)。 hydrateSuspense 的职责是让客户端的 Suspense 状态与服务端的 DOM 保持同步
  • <Teleport> (hydrateTeleport): Teleport 的水合需要在两个位置进行“认领”:

    1. 在“逻辑位置”(原位)“认领”占位符vnode.anchor)。
    2. 在“物理位置”(to 属性指定的目标)“认领”真实内容vnode.targetAnchor)。

总结

Hydration 是 Vue SSR 的“最后一步”,它是一个**“认领”和“激活”**的过程,而非“创建”:

  1. 入口 (createSSRApp):设置 isHydrating = true 标志,启动“水合模式”。
  2. 入口 (hydrate):启动 hydrateNode,开始 1:1 “认领” DOM 节点。
  3. 执行 (hydrateNode)
    • 匹配:递归地对比 VNode 与 DOM 节点。
    • “认领”:如果匹配,将 vnode.el 指向 DOM,并递归 hydrateChildren
    • “纠错”:如果不匹配(handleMismatch),则放弃水合,移除错误 DOM,并降级为客户端 patch
  4. 激活 (patchProp)
    • 在水合模式下,patchProp跳过所有“DOM 属性设置”操作(假定 SSR 是正确的)。
    • 它的唯一职责是调用 patchEvent 附加事件监听器,为静态 HTML 赋予交互能力。

微信公众号二维码

Last updated: