Skip to content

第7.4节:SSR 之同构与 Hydration:客户端如何"激活"静态 HTML?

概述

在服务端渲染(SSR)的完整流程中,服务端生成静态 HTML 只是第一步。当这些静态 HTML 到达客户端后,需要通过 Hydration(水合/激活) 过程将静态的 HTML 转换为具有完整交互能力的 Vue 应用。这个过程是同构应用的核心,它确保了服务端和客户端的一致性。

1. 同构应用的四个核心方面

1.1 代码同构

定义:同一套 Vue 组件代码既能在服务端运行(生成 HTML),也能在客户端运行(提供交互)。

实现机制

typescript
// 通用组件代码
const MyComponent = {
  setup() {
    const count = ref(0)
    
    // 服务端和客户端都会执行
    const increment = () => {
      count.value++
    }
    
    return { count, increment }
  },
  
  template: `
    <div>
      <span>{{ count }}</span>
      <button @click="increment">+</button>
    </div>
  `
}

1.2 状态同构

核心挑战:确保服务端渲染时的应用状态能够在客户端正确恢复。

解决方案

typescript
// 服务端:序列化状态
const ssrContext = {
  state: {
    user: { id: 1, name: 'John' },
    posts: [/* ... */]
  }
}

// 客户端:反序列化状态
const initialState = window.__INITIAL_STATE__
const store = createStore(initialState)

1.3 路由同构

目标:服务端和客户端使用相同的路由配置和匹配逻辑。

实现

typescript
// 通用路由配置
const routes = [
  { path: '/', component: Home },
  { path: '/user/:id', component: User }
]

// 服务端:根据请求 URL 匹配路由
const router = createRouter({ routes })
router.push(req.url)

// 客户端:接管路由控制
const router = createRouter({ routes })
app.use(router)

1.4 环境同构

挑战:处理服务端和客户端环境差异(如 DOM API、Node.js API)。

策略

typescript
// 环境检测
const isServer = typeof window === 'undefined'

// 条件执行
if (!isServer) {
  // 仅在客户端执行
  document.addEventListener('click', handler)
}

2. Hydration 过程的四个核心要点

2.1 DOM 节点匹配机制

核心文件runtime-core/src/hydration.ts

匹配流程

typescript
// hydrateNode 函数 - 核心匹配逻辑
function hydrateNode(
  node: Node,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  slotScopeIds: string[] | null,
  optimized: boolean
): Node | null {
  const { type, ref, shapeFlag } = vnode
  vnode.el = node

  switch (type) {
    case Text:
      // 文本节点匹配
      if (node.nodeType !== DOMNodeTypes.TEXT) {
        return handleMismatch(node, vnode, parentComponent, parentSuspense, slotScopeIds, false)
      }
      if ((node as Text).data !== vnode.children) {
        // 文本内容不匹配处理
        if (!isMismatchAllowed(node.parentElement!, MismatchTypes.TEXT)) {
          warn(`Hydration text mismatch`)
          logMismatchError()
        }
        (node as Text).data = vnode.children as string
      }
      return nextSibling(node)
      
    case Comment:
      // 注释节点匹配
      if (node.nodeType !== DOMNodeTypes.COMMENT) {
        return handleMismatch(node, vnode, parentComponent, parentSuspense, slotScopeIds, false)
      }
      return nextSibling(node)
      
    case Static:
      // 静态节点处理
      if (node.nodeType === DOMNodeTypes.ELEMENT) {
        vnode.el = node
        vnode.anchor = nextSibling(node)
      }
      return vnode.anchor
      
    case Fragment:
      // 片段节点处理
      return hydrateFragment(node as Comment, vnode, parentComponent, parentSuspense, slotScopeIds, optimized)
      
    default:
      // 元素和组件节点
      if (shapeFlag & ShapeFlags.ELEMENT) {
        return hydrateElement(node as Element, vnode, parentComponent, parentSuspense, slotScopeIds, optimized)
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        return hydrateComponent(node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized)
      }
  }
}

元素节点匹配

typescript
function hydrateElement(
  el: Element,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  slotScopeIds: string[] | null,
  optimized: boolean
): Node | null {
  const { type, props, patchFlag, shapeFlag, dirs } = vnode
  
  // 标签名匹配检查
  if (el.tagName.toLowerCase() !== (type as string).toLowerCase()) {
    return handleMismatch(el, vnode, parentComponent, parentSuspense, slotScopeIds, false)
  }
  
  vnode.el = el
  
  // 子节点 hydration
  if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    let next = hydrateChildren(
      el.firstChild,
      vnode,
      el,
      parentComponent,
      parentSuspense,
      slotScopeIds,
      optimized
    )
    
    // 处理多余的 DOM 节点
    while (next) {
      if (!isMismatchAllowed(el, MismatchTypes.CHILDREN)) {
        warn('Hydration children mismatch')
        logMismatchError()
      }
      const cur = next
      next = next.nextSibling
      remove(cur)
    }
  }
  
  return el.nextSibling
}

2.2 属性和事件绑定激活

属性处理

typescript
// patchProp 函数 - 属性激活
export const patchProp: DOMRendererOptions['patchProp'] = (
  el,
  key,
  prevValue,
  nextValue,
  namespace,
  parentComponent
) => {
  const isSVG = namespace === 'svg'
  
  if (key === 'class') {
    patchClass(el, nextValue, isSVG)
  } else if (key === 'style') {
    patchStyle(el, prevValue, nextValue)
  } else if (isOn(key)) {
    // 事件监听器处理
    if (!isModelListener(key)) {
      patchEvent(el, key, prevValue, nextValue, parentComponent)
    }
  } else {
    // 其他属性处理
    if (shouldSetAsProp(el, key, nextValue, isSVG)) {
      patchDOMProp(el, key, nextValue, parentComponent)
    } else {
      patchAttr(el, key, nextValue, isSVG, parentComponent)
    }
  }
}

事件绑定激活

typescript
// patchEvent 函数 - 事件激活
export function patchEvent(
  el: Element & { [veiKey]?: Record<string, Invoker | undefined> },
  rawName: string,
  prevValue: EventValue | null,
  nextValue: EventValue | unknown,
  instance: ComponentInternalInstance | null = null
): void {
  const invokers = el[veiKey] || (el[veiKey] = {})
  const existingInvoker = invokers[rawName]
  
  if (nextValue && existingInvoker) {
    // 更新事件处理器
    existingInvoker.value = nextValue as EventValue
  } else {
    const [name, options] = parseName(rawName)
    if (nextValue) {
      // 添加新的事件监听器
      const invoker = (invokers[rawName] = createInvoker(
        nextValue as EventValue,
        instance
      ))
      addEventListener(el, name, invoker, options)
    } else if (existingInvoker) {
      // 移除事件监听器
      removeEventListener(el, name, existingInvoker, options)
      invokers[rawName] = undefined
    }
  }
}

2.3 组件实例激活

组件 Hydration

typescript
function hydrateComponent(
  node: Node,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  slotScopeIds: string[] | null,
  optimized: boolean
): Node | null {
  const { type, ref, props, shapeFlag } = vnode
  
  if (shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
    // KeepAlive 组件处理
    ;(parentComponent!.ctx as KeepAliveContext).activate(
      vnode,
      parentComponent,
      parentSuspense,
      true /* isHydrating */
    )
  } else {
    // 普通组件挂载
    mountComponent(
      vnode,
      parentComponent,
      parentSuspense,
      true /* isHydrating */
    )
  }
  
  return vnode.component!.subTree.el
}

异步组件处理

typescript
// 异步组件的 hydration 策略
export const hydrateOnIdle: HydrationStrategyFactory<number> =
  (timeout = 10000) => hydrate => {
    const id = requestIdleCallback(hydrate, { timeout })
    return () => cancelIdleCallback(id)
  }

export const hydrateOnVisible: HydrationStrategyFactory<IntersectionObserverInit> = 
  opts => (hydrate, forEach) => {
    const ob = new IntersectionObserver(entries => {
      for (const e of entries) {
        if (!e.isIntersecting) continue
        ob.disconnect()
        hydrate()
        break
      }
    }, opts)
    
    forEach(el => {
      if (elementIsVisibleInViewport(el)) {
        hydrate()
        ob.disconnect()
        return false
      }
      ob.observe(el)
    })
    
    return () => ob.disconnect()
  }

2.4 Mismatch 处理和错误恢复

不匹配类型定义

typescript
enum MismatchTypes {
  TEXT = 0,
  CHILDREN = 1,
  CLASS = 2,
  STYLE = 3,
  ATTRIBUTE = 4
}

const MismatchTypeString: Record<MismatchTypes, string> = {
  [MismatchTypes.TEXT]: 'text',
  [MismatchTypes.CHILDREN]: 'children',
  [MismatchTypes.CLASS]: 'class',
  [MismatchTypes.STYLE]: 'style',
  [MismatchTypes.ATTRIBUTE]: 'attribute'
}

属性不匹配检查

typescript
function propHasMismatch(
  el: Element,
  key: string,
  clientValue: any,
  vnode: VNode,
  instance: ComponentInternalInstance | null
): boolean {
  let mismatchType: MismatchTypes | undefined
  let actual: string | boolean | null | undefined
  let expected: string | boolean | null | undefined
  
  if (key === 'class') {
    // 类名匹配检查
    actual = el.getAttribute('class')
    expected = normalizeClass(clientValue)
    if (!isSetEqual(toClassSet(actual || ''), toClassSet(expected))) {
      mismatchType = MismatchTypes.CLASS
    }
  } else if (key === 'style') {
    // 样式匹配检查
    actual = el.getAttribute('style') || ''
    expected = isString(clientValue) ? clientValue : stringifyStyle(normalizeStyle(clientValue))
    
    const actualMap = toStyleMap(actual)
    const expectedMap = toStyleMap(expected)
    
    // v-show 特殊处理
    if (vnode.dirs) {
      for (const { dir, value } of vnode.dirs) {
        if (dir.name === 'show' && !value) {
          expectedMap.set('display', 'none')
        }
      }
    }
    
    if (!isMapEqual(actualMap, expectedMap)) {
      mismatchType = MismatchTypes.STYLE
    }
  } else {
    // 其他属性检查
    if (isBooleanAttr(key)) {
      actual = el.hasAttribute(key)
      expected = includeBooleanAttr(clientValue)
    } else {
      actual = el.getAttribute(key)
      expected = String(clientValue)
    }
    
    if (actual !== expected) {
      mismatchType = MismatchTypes.ATTRIBUTE
    }
  }
  
  if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) {
    warn(`Hydration ${MismatchTypeString[mismatchType]} mismatch`)
    return true
  }
  
  return false
}

错误恢复策略

typescript
const handleMismatch = (
  node: Node,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  slotScopeIds: string[] | null,
  isFragment: boolean
): Node | null => {
  if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
    warn('Hydration node mismatch:', node, 'expected:', vnode.type)
    logMismatchError()
  }
  
  vnode.el = null
  
  if (isFragment) {
    // 移除多余的片段节点
    const end = locateClosingAnchor(node)
    while (true) {
      const next = nextSibling(node)
      if (next && next !== end) {
        remove(next)
      } else {
        break
      }
    }
  }
  
  const next = nextSibling(node)
  const container = parentNode(node)!
  remove(node)
  
  // 重新挂载正确的 vnode
  patch(
    null,
    vnode,
    container,
    next,
    parentComponent,
    parentSuspense,
    getContainerType(container),
    slotScopeIds
  )
  
  return next
}

允许不匹配的配置

typescript
// data-allow-mismatch 属性支持
function isMismatchAllowed(
  el: Element | null,
  allowedType: MismatchTypes
): boolean {
  if (allowedType === MismatchTypes.TEXT || allowedType === MismatchTypes.CHILDREN) {
    while (el && !el.hasAttribute('data-allow-mismatch')) {
      el = el.parentElement
    }
  }
  
  const allowedAttr = el && el.getAttribute('data-allow-mismatch')
  if (allowedAttr == null) {
    return false
  } else if (allowedAttr === '') {
    return true
  } else {
    const list = allowedAttr.split(',')
    return list.includes(MismatchTypeString[allowedType])
  }
}

3. 客户端激活入口

3.1 createSSRApp vs createApp

createSSRApp 实现

typescript
// runtime-dom/src/index.ts
export const createSSRApp = ((...args) => {
  const app = ensureHydrationRenderer().createApp(...args)
  
  const { mount } = app
  app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
    const container = normalizeContainer(containerOrSelector)
    if (container) {
      // 关键:第二个参数为 true,表示 hydration 模式
      return mount(container, true, resolveRootNamespace(container))
    }
  }
  
  return app
}) as CreateAppFunction<Element>

hydrate 函数

typescript
export const hydrate = ((...args) => {
  ensureHydrationRenderer().hydrate(...args)
}) as RootHydrateFunction

// 内部实现
function createHydrationFunctions(
  rendererInternals: RendererInternals
) {
  const hydrate: RootHydrateFunction = (
    vnode,
    container
  ) => {
    if (!container.hasChildNodes()) {
      // 容器为空,直接挂载
      patch(null, vnode, container)
      flushPostFlushCbs()
      container._vnode = vnode
      return
    }
    
    // 开始 hydration
    hydrateNode(container.firstChild!, vnode, null, null, null, false)
    flushPostFlushCbs()
    container._vnode = vnode
  }
  
  return [hydrate, hydrateNode]
}

3.2 特殊组件的 Hydration

Suspense 组件

typescript
function hydrateSuspense(
  node: Node,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  slotScopeIds: string[] | null,
  optimized: boolean,
  rendererInternals: RendererInternals,
  hydrateNode: Function
): Node | null {
  const suspense = (vnode.suspense = createSuspenseBoundary(
    vnode,
    parentSuspense,
    parentComponent,
    node.parentNode!,
    document.createElement('div'),
    null,
    namespace,
    slotScopeIds,
    optimized,
    rendererInternals,
    true /* hydrating */
  ))
  
  // 尝试 hydrate 成功分支
  const result = hydrateNode(
    node,
    (suspense.pendingBranch = vnode.ssContent!),
    parentComponent,
    suspense,
    slotScopeIds,
    optimized
  )
  
  if (suspense.deps === 0) {
    suspense.resolve(false, true)
  }
  
  return result
}

Teleport 组件

typescript
function hydrateTeleport(
  node: Node,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  slotScopeIds: string[] | null,
  optimized: boolean,
  {
    o: { nextSibling, parentNode, querySelector }
  }: RendererInternals,
  hydrateChildren: Function
): Node | null {
  const target = (vnode.target = resolveTarget(
    vnode.props,
    querySelector
  ))
  
  if (target) {
    const targetNode = target._lpa || target.firstChild
    if (vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      if (isDisabled(vnode.props)) {
        // disabled teleport,内容在原地
        vnode.anchor = hydrateChildren(
          nextSibling(node),
          vnode,
          parentNode(node)!,
          parentComponent,
          parentSuspense,
          slotScopeIds,
          optimized
        )
        vnode.targetAnchor = targetNode
      } else {
        // enabled teleport,内容在目标位置
        vnode.anchor = nextSibling(node)
        vnode.targetAnchor = hydrateChildren(
          targetNode,
          vnode,
          target,
          parentComponent,
          parentSuspense,
          slotScopeIds,
          optimized
        )
      }
    }
  }
  
  return vnode.anchor && nextSibling(vnode.anchor)
}

4. 状态同步机制

4.1 服务端状态序列化

onServerPrefetch 钩子

typescript
// 组件中使用
export default {
  async serverPrefetch() {
    // 服务端数据预取
    this.data = await fetchData()
  },
  
  setup() {
    const data = ref(null)
    
    onServerPrefetch(async () => {
      data.value = await fetchData()
    })
    
    return { data }
  }
}

SSR Context 使用

typescript
// 服务端
import { useSSRContext } from 'vue'

export default {
  setup() {
    const ssrContext = useSSRContext()
    
    onServerPrefetch(async () => {
      const data = await fetchUserData()
      // 将数据存储到 SSR 上下文
      ssrContext.userData = data
    })
  }
}

// useSSRContext 实现
export const useSSRContext = <T = Record<string, any>>(): T | undefined => {
  const ctx = inject<T>(ssrContextKey)
  if (!ctx) {
    warn('Server rendering context not provided')
  }
  return ctx
}

4.2 客户端状态恢复

状态注入

typescript
// 服务端渲染时注入状态
const html = `
  <div id="app">${appHtml}</div>
  <script>
    window.__INITIAL_STATE__ = ${JSON.stringify(ssrContext.state)}
  </script>
`

// 客户端恢复状态
const initialState = window.__INITIAL_STATE__
const app = createSSRApp(App)

// 状态管理器初始化
if (initialState) {
  app.provide('initialState', initialState)
}

app.mount('#app')

5. 性能优化策略

5.1 渐进式 Hydration

延迟 Hydration

typescript
// 基于可见性的延迟 hydration
const LazyComponent = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  hydrate: hydrateOnVisible()
})

// 基于交互的延迟 hydration
const InteractiveComponent = defineAsyncComponent({
  loader: () => import('./InteractiveComponent.vue'),
  hydrate: hydrateOnInteraction(['click', 'keydown'])
})

// 基于空闲时间的延迟 hydration
const IdleComponent = defineAsyncComponent({
  loader: () => import('./IdleComponent.vue'),
  hydrate: hydrateOnIdle(5000)
})

5.2 选择性 Hydration

静态内容跳过

typescript
// 标记静态内容
<div data-hydrate="false">
  <!-- 这部分内容不会被 hydrate -->
  <p>静态内容</p>
</div>

// 实现逻辑
function shouldHydrate(el: Element): boolean {
  return el.getAttribute('data-hydrate') !== 'false'
}

5.3 Hydration 优化

批量处理

typescript
// 批量 hydration
function batchHydrate(nodes: Node[], vnodes: VNode[]) {
  const batch = []
  
  for (let i = 0; i < nodes.length; i++) {
    batch.push(() => hydrateNode(nodes[i], vnodes[i]))
  }
  
  // 使用 scheduler 批量执行
  queuePostFlushCb(() => {
    batch.forEach(fn => fn())
  })
}

6. 调试和错误处理

6.1 Hydration 调试

开发模式警告

typescript
if (__DEV__) {
  // 详细的不匹配信息
  const formatMismatchError = (actual: any, expected: any, type: string) => {
    return (
      `Hydration ${type} mismatch:\n` +
      `  - Server rendered: ${actual}\n` +
      `  - Client expected: ${expected}\n` +
      `  Note: this mismatch is check-only. The DOM will not be rectified.`
    )
  }
}

6.2 错误边界

Hydration 错误捕获

typescript
const HydrationErrorBoundary = {
  setup(_, { slots }) {
    const hasHydrationError = ref(false)
    
    onErrorCaptured((err, instance, info) => {
      if (info.includes('hydration')) {
        hasHydrationError.value = true
        console.error('Hydration error:', err)
        return false // 阻止错误继续传播
      }
    })
    
    return () => {
      if (hasHydrationError.value) {
        return h('div', 'Hydration failed, falling back to client-side rendering')
      }
      return slots.default?.()
    }
  }
}

7. 最佳实践

7.1 避免 Hydration 不匹配

  1. 避免在服务端和客户端使用不同的数据
  2. 处理时间相关的内容
  3. 正确处理随机数和 ID
  4. 避免在 mounted 钩子中修改 DOM

7.2 优化 Hydration 性能

  1. 使用渐进式 Hydration
  2. 合理拆分组件
  3. 避免不必要的响应式数据
  4. 使用 v-once 优化静态内容

7.3 错误处理策略

  1. 设置错误边界
  2. 提供降级方案
  3. 监控 Hydration 错误
  4. 使用 data-allow-mismatch 属性

总结

Vue 3 的 Hydration 机制是一个精密的系统,它通过以下核心技术实现了服务端渲染到客户端交互的无缝转换:

  1. 精确的 DOM 匹配:通过 hydrateNode 函数实现服务端 HTML 与客户端 VNode 的精确匹配
  2. 渐进式激活:支持延迟 Hydration 和选择性 Hydration,优化性能
  3. 完善的错误处理:提供详细的不匹配检测和恢复机制
  4. 状态同步:通过 onServerPrefetchuseSSRContext 实现状态的序列化和恢复

这套机制确保了同构应用的可靠性和性能,是现代 Web 应用 SSR 实现的重要基础。


微信公众号二维码