Appearance
第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 不匹配
- 避免在服务端和客户端使用不同的数据
- 处理时间相关的内容
- 正确处理随机数和 ID
- 避免在 mounted 钩子中修改 DOM
7.2 优化 Hydration 性能
- 使用渐进式 Hydration
- 合理拆分组件
- 避免不必要的响应式数据
- 使用 v-once 优化静态内容
7.3 错误处理策略
- 设置错误边界
- 提供降级方案
- 监控 Hydration 错误
- 使用 data-allow-mismatch 属性
总结
Vue 3 的 Hydration 机制是一个精密的系统,它通过以下核心技术实现了服务端渲染到客户端交互的无缝转换:
- 精确的 DOM 匹配:通过
hydrateNode函数实现服务端 HTML 与客户端 VNode 的精确匹配 - 渐进式激活:支持延迟 Hydration 和选择性 Hydration,优化性能
- 完善的错误处理:提供详细的不匹配检测和恢复机制
- 状态同步:通过
onServerPrefetch和useSSRContext实现状态的序列化和恢复
这套机制确保了同构应用的可靠性和性能,是现代 Web 应用 SSR 实现的重要基础。
