Jw
首页
技术
英语
首页
技术
英语
  • 01-权衡的艺术

    • 1.1 命令式与声明式
    • 1.2 性能与可维护性的权衡
    • 1.3 虚拟DOM的性能到底如何
    • 1.4 运行时和编译时
  • 02-框架设计的核心要素

    • 2.1 提升用户的开发体验
    • 2.2 控制框架代码的体积
    • 2.3 框架要做到良好的 Tree-Shaking
    • 2.4 框架应该输出怎样的构建产物
    • 2.5 特性开关
    • 2.6 错误处理
    • 2.7 良好的 TypeScript 类型支持
  • 03-Vue.js 3 的设计思路

    • 3.1 声明式地描述UI
    • 3.2 初识渲染器
    • 3.3 组件的本质
    • 3.4 模板的工作原理
    • 3.5 Vue.js 是各个模块组成的有机整体
  • 04-响应系统的作用与实现

    • 4.1 响应式数据与副作用函数
    • 4.2 响应式数据的基本实现
    • 4.3 设计一个完善的响应系统
    • 4.4 分支切换与 cleanup
    • 4.5 嵌套的effect与effect栈
    • 4.6 避免无限递归循环
    • 4.7 调度执行
    • 4.8 计算属性 computed 与 lazy
    • 4.9 watch 的实现原理
    • 4.10 立即执行的 watch 与回调执行时机
    • 4.11 过期的副作用
  • 05-非原始值的响应式方案

    • 5.1 理解 Proxy 和 Reflect
    • 5.2 JavaScript 对象及 Proxy 的工作原理
    • 5.3 如何代理 Object
    • 5.4 合理地触发响应
    • 5.5 浅响应与深响应
    • 5.6 只读和浅只读
    • 5.7 代理数组
    • 5.7.1 数组的索引与 length
    • 5.7.2 遍历数组
    • 5.7.3 数组的查找方法
    • 5.7.4 隐式修改数组长度的原型方法
    • 5.8 代理 Set 和 Map
    • 5.8.1 如何代理Set和Map
    • 5.8.2 建立响应联系
    • 5.8.3 避免污染原始数据
    • 5.8.4 处理forEach
    • 5.8.5 迭代器方法
    • 5.8.6 values与keys方法
  • 06-原始值的响应式方案

    • 6.1 引入 ref 的概念
    • 6.2 响应丢失问题
    • 6.3 自动脱ref
  • 07-渲染器的设计

    • 7.1 渲染器与响应系统的结合
    • 7.2 渲染器的基本概念
    • 7.3 自定义渲染器
  • 08-挂载与更新

    • 8.1 挂载子节点和元素的属性
    • 8.2 HTML Attributes 与 DOM Properties
    • 8.3 正确地设置元素属性
    • 8.4 class的处理
    • 8.5 卸载操作
    • 8.6 区分vnode类型
    • 8.7 事件的处理
    • 8.8 事件冒泡与更新时机问题
    • 8.9 更新子节点
    • 8.10 文本节点和注释节点
    • 8.11 Fragment
  • 09-简单 Diff 算法

    • 9.1 减少 DOM 操作的性能开销
    • 9.2 DOM 复用与 key 的作用
    • 9.3 找到需要移动的元素
    • 9.4 如何移动元素
    • 9.5 添加新元素
    • 9.6 移除不存在的元素
  • 10-双端 Diff 算法

    • 10.1 双端比较的原理
    • 10.3 非理想状况的处理方式
    • 10.4 添加新元素
    • 10.5 移除不存在的元素
  • 11-快速 Diff 算法

    • 11.1 相同的前置元素和后置元素
    • 11.2 判断是否需要进行 DOM 移动操作
    • 11.3 如何移动元素
  • 12-组件的实现原理

    • 12.1 渲染组件
    • 12.2 组件状态与自更新
    • 12.3 组件实例与组件的生命周期
    • 12.4 props与组件的被动更新
    • 12.5 setup 函数的作用与实现
    • 12.6 组件事件与 emit 的实现
    • 12.7 插槽的工作原理与实现
    • 12.8 注册生命周期
  • 13-异步组件与函数式组件

    • 13.1 异步组件要解决的问题
    • 13.2.1 封装defineAsyncComponent函数
    • 13.2.2 超时与 Error 组件
    • 13.2.3 延迟与 Loading 组件
    • 13.2.4 重试机制
    • 13.3 函数式组件
  • 14-内建组件和模块

    • 14.1.1 组件的激活与失活
    • 14.1.2 include 和 exclude
    • 14.1.3 缓存管理
    • 14.2.1 Teleport 组件要解决的问题
    • 14.2.2 实现 Teleport 组件
    • 14.3.1 原生 DOM 的过渡
    • 14.3.2 实现 Transition 组件
  • 15-编译器核心技术概览

    • 15.1 模板 DSL 的编译器
    • 15.2 parser 的实现原理与状态机
    • 15.3 构造AST
    • 15.4 AST 的转换与插件化架构
    • 15.4.1 节点的访问
    • 15.4.2 转换上下文与节点操作
    • 15.4.3 进入与退出
    • 15.5 将模板 AST 转为 JavaScript AST
    • 15.6 代码生成
  • 16-解析器

    • 16.1 文本模式及其对解析器的影响
    • 16.2 递归下降算法构造模板 AST
    • 16.3 状态机的开启与停止
    • 16.4 解析标签节点
    • 16.5 解析属性
    • 16.6.1 解析文本
    • 16.6.2 解码命名字符引用
    • 16.6.3 解码数字字符引用
    • 16.7 解析插值与注释
  • 17-编译优化

    • 17.1.1 传统Diff算法问题
    • 17.1.2 Block 与 PatchFlags
    • 17.1.3 收集动态节点
    • 17.1.4 渲染器的运行时支持
    • 17.2.1 带有 v-if 指令的节点
    • 17.2.2 带有 v-for 指令的节点
    • 17.2.3 Fragment 的稳定性
    • 17.3 静态提升
    • 17.4 预字符串化
    • 17.5 缓存内联事件处理函数
    • 17.6 v-once
  • 18-同构渲染

    • 18.1 CSR、SSR 以及同构渲染
    • 18.2 将虚拟 DOM 渲染为 HTML 字符串
    • 18.3 将组件渲染为 HTML 字符串
    • 18.4 客户端激活的原理
    • 18.5.1 组件的生命周期
    • 18.5.2 使用跨平台的 API
    • 18.5.3 只在某一端引入模块
    • 18.5.4 避免交叉请求引起的状态污染
    • 18.5.5 ClientOnly 组件

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 组件的生命周期:服务端不挂载、不更新,钩子函数的执行情况因此大不相同
最近更新: 2026/10/12 00:05
Contributors: liujw
Prev
18.3 将组件渲染为 HTML 字符串
Next
18.5.1 组件的生命周期