18.4 客户端激活的原理
服务端只产出了静态 HTML,客户端要在此基础上复用已有 DOM:让真实 DOM 与虚拟 DOM 建立联系,并把事件绑定补回去,这个过程叫客户端激活(hydration)。
一、激活要解决的两件事
同构渲染中组件的代码会在服务端和客户端各执行一次:服务端把它渲染成静态 HTML,浏览器先把这段纯静态 HTML 展示出来。组件代码在客户端运行时,仍需做两件重要的事:
- 在页面中的 DOM 元素与虚拟节点对象之间建立联系;
- 为页面中的 DOM 元素添加事件绑定。
真实 DOM 元素与虚拟 DOM 对象都是树型结构,节点之间一一对应,可以认为它们是「同构」的。激活的原理正是基于这一事实:递归地在真实 DOM 元素与虚拟 DOM 节点之间建立关系。
vnode 中并不存在与容器元素(挂载点)对应的节点,所以激活要从容器元素的第一个子节点开始。
二、hydrate 与 hydrateNode
function createRenderer(options) {
const { setElementText, patchProps } = options
function hydrate(vnode, container) {
// 从容器元素的第一个子节点开始
hydrateNode(container.firstChild, vnode)
}
function hydrateNode(node, vnode) {
const { type } = vnode
// 1. 让 vnode.el 引用真实 DOM,建立联系
vnode.el = node
// 2. 组件类型 → 交给 mountComponent 完成激活
if (typeof type === 'object') {
mountComponent(vnode, container, null)
} else if (typeof type === 'string') {
// 3. 检查真实 DOM 的类型与虚拟 DOM 的类型是否匹配
if (node.nodeType !== 1) {
console.error('mismatch')
console.error('服务端渲染的真实 DOM 节点是:', node)
console.error('客户端渲染的虚拟 DOM 节点是:', vnode)
} else {
// 4. 普通元素 → 交给 hydrateElement 完成激活
hydrateElement(node, vnode)
}
}
// 5. 返回当前节点的下一个兄弟节点,供后续按顺序激活兄弟节点
return node.nextSibling
}
}
配套的按钮:
// 激活普通元素类型的节点
function hydrateElement(el, vnode) {
// 1. 为 DOM 元素添加事件
if (vnode.props) {
for (const key in vnode.props) {
// 服务端渲染忽略事件,只有事件类型的 props 需要在这里补回来
if (/^on/.test(key)) {
patchProps(el, key, null, vnode.props[key])
}
}
}
// 2. 递归地激活子节点
if (Array.isArray(vnode.children)) {
let nextNode = el.firstChild
const len = vnode.children.length
for (let i = 0; i < len; i++) {
// hydrateNode 会返回当前子节点的下一个兄弟节点,于是可以继续激活后续节点
nextNode = hydrateNode(nextNode, vnode.children[i])
}
}
}
hydrateNode必须返回node.nextSibling:它是兄弟节点顺序推进的唯一依据,缺了它就无法继续激活后面的节点。
因此激活的起点是
container.firstChild(容器本身没有对应的 vnode),而元素子节点之间靠nextSibling串起来。
三、元素类型不匹配时为什么要报错
hydrateNode 在 typeof type === 'string' 分支里先做类型校验:
if (node.nodeType !== 1) {
console.error('mismatch')
// 服务端渲染的真实 DOM 节点是:...
// 客户端渲染的虚拟 DOM 节点是:...
}
原因在于「复用」的前提是两边结构一致:
- 服务端的 HTML 是客户端首次渲染 vnode 的「快照」,只有一一对应才能把
vnode.el指到正确的节点上; - 若真实节点不是元素节点(
nodeType !== 1),或组件类型/标签类型与客户端首次渲染的 vnode 对不上,说明服务端产出与客户端预期不一致(例如服务端渲染出的是div,客户端首次渲染却是某个组件,或 v-if 分支在两端不一致); - 此时无法完成对应关系,只能打印 mismatch 警告并放弃复用,否则后续事件绑定与 Diff 都会挂到错误的节点上。
激活只做「建立联系 + 绑定事件」,不会重新创建 DOM;一旦结构不匹配,正确性就无从谈起。
四、组件的激活:复用已有 DOM
服务端已经渲染出真实 DOM,所以 mountComponent 在挂载阶段无须再次创建 DOM,而是判断 vnode.el 是否存在来走激活分支:
instance.update = effect(() => {
const subTree = render.call(renderContext, renderContext)
if (!instance.isMounted) {
beforeMount && beforeMount.call(renderContext)
// 如果 vnode.el 存在,则意味着要执行激活
if (vnode.el) {
// 复用已有 DOM,直接调用 hydrateNode 完成激活
hydrateNode(vnode.el, subTree)
} else {
// 正常挂载(首次挂载时 patch 的第一个参数为 null)
patch(null, subTree, container, anchor)
}
instance.isMounted = true
mounted && mounted.call(renderContext)
instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
} else {
// 已挂载则按 CSR 流程走更新
patch(instance.subTree, subTree, container, anchor)
updated && updated.call(renderContext)
}
instance.subTree = subTree
}, { scheduler: queueJob })
判断依据就是
vnode.el:激活时它由hydrateNode提前赋值,因此组件会走hydrateNode分支而不是patch分支;激活完成后组件依旧是 CSR 组件,后续更新走正常 patch。
五、事件绑定的补丁:patchProps
激活阶段绑定事件复用的仍是渲染器的 patchProps(el._vei 缓存 invoker、invoker.attached 记录绑定时间以屏蔽绑定前触发的事件等机制),此处只需保证事件能正确挂上:
// 作为渲染器选项传给 createRenderer
function patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase() // onClick → click
if (nextValue) {
if (!invoker) {
// 用伪造的 invoker 作为真正的事件处理函数并缓存
invoker = el._vei[key] = e => {
if (e.timeStamp < invoker.attached) return
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
invoker.value = nextValue
invoker.attached = performance.now()
el.addEventListener(name, invoker)
} else {
// 已存在 invoker → 只更新 value
invoker.value = nextValue
}
} else if (invoker) {
// 新值为空且存在旧 invoker → 解绑
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}
完整的验证流程:服务端 renderComponentVNode 得到 HTML → 客户端 container.innerHTML = html 模拟服务端产出 → renderer.hydrate(CompVNode, container) 完成激活。
相关
- 18.5.1 组件的生命周期:服务端不挂载、不更新,钩子函数的执行情况因此大不相同