2026.01.11

Renderer 更新 -> Diff 算法基础

在上一章中,我们实现了渲染器的挂载和卸载功能。现在,让我们继续完善渲染器的更新机制,当数据发生变化时,渲染器需要高效地对比新旧虚拟节点,并将变化应用到真实 DOM 上。

在上一章中,我们实现了渲染器的挂载和卸载功能。现在,让我们继续完善渲染器的更新机制,当数据发生变化时,渲染器需要高效地对比新旧虚拟节点,并将变化应用到真实 DOM 上。

有效的更新策略

判断节点类型是否相同

在更新过程中,并不是所有情况都需要进行对比更新。考虑这样一个场景:如果最开始渲染的是 span 元素,而更新后变成了 div 元素,这两个节点类型完全不同,直接复用是不合理的。

function patch(n1, n2, container) {
  // 如果新旧节点是同一个对象引用,无需任何操作
  if (n1 === n2)
    return

  // 如果老节点存在,且新旧节点类型不同
  if (n1 && !isSameVNodeType(n1, n2)) {
    // 先卸载老节点,然后将 n1 置为 null
    // 这样后续逻辑就会将 n2 作为新节点进行挂载
    unmount(n1)
    n1 = null
  }

  // 如果 n1 为 null,说明是首次挂载或类型不同需要重新挂载
  if (n1 == null) {
    mountElement(n2, container)
  }
  else {
    // 新旧节点类型相同,进行更新
    patchElement(n1, n2)
  }
}

通过这个逻辑,我们实现了节点类型变化时的优雅处理:先卸载旧节点,再挂载新节点,而不是尝试进行无意义的对比。

元素的对比更新

patchElement 的核心流程

当新旧节点类型相同时,我们需要对元素进行对比更新。patchElement 函数负责处理这个过程:

function patchElement(n1, n2) {
  // 1. 复用 DOM 元素
  // 将老节点的真实 DOM 赋值给新节点的 el 属性
  // 这样新节点就指向了同一个 DOM 元素,实现了复用
  const el = (n2.el = n1.el)

  // 2. 更新 props(属性和事件)
  const oldProps = n1.props
  const newProps = n2.props
  patchProps(el, oldProps, newProps)

  // 3. 更新 children(子节点)
  patchChildren(n1, n2)
}

patchElement 的核心思想是复用已有的 DOM 元素,然后分别更新其属性和子节点。这样可以避免不必要的 DOM 创建和销毁操作,提升性能。

Props 的更新策略

patchProps 的实现

属性更新需要处理两种情况:删除老的属性,设置新的属性。

function patchProps(el, oldProps, newProps) {
  // 1. 删除老的 props
  // 遍历老属性,如果不在新属性中,则删除
  if (oldProps) {
    for (const key in oldProps) {
      hostPatchProp(el, key, oldProps[key], null)
    }
  }

  // 2. 设置新的 props
  // 遍历新属性,更新到 DOM 元素上
  if (newProps) {
    for (const key in newProps) {
      // 对比老值和新值,如果不同则更新
      hostPatchProp(el, key, oldProps?.[key], newProps[key])
    }
  }
}

使用 hostPatchProp 函数来统一处理属性的增删改,具体的 DOM 操作由平台相关的渲染选项提供。

子节点的更新策略

patchChildren 的复杂场景

子节点的更新是渲染器中最复杂的部分,因为需要考虑多种组合情况。新旧节点的子节点可能是文本、数组或者 null,让我们逐一分析:

function patchChildren(n1, n2) {
  const el = n2.el
  const prevShapeFlag = n1.shapeFlag
  const shapeFlag = n2.shapeFlag

  /**
   * 子节点更新有以下几种情况:
   * 1. 新节点的子节点是文本
   *    1.1 老的是数组 - 卸载所有老的子节点,然后设置文本
   *    1.2 老的也是文本 - 如果文本不同,直接更新文本内容
   * 2. 新节点的子节点是数组
   *    2.1 老的是文本 - 清空文本,然后挂载新的数组子节点
   *    2.2 老的也是数组 - 进行完整的 diff 算法
   *    2.3 老的是 null - 直接挂载新的数组子节点
   * 3. 新节点的子节点是 null
   *    3.1 老的是数组 - 卸载所有老的子节点
   *    3.2 老的是文本 - 清空文本内容
   */

  // 情况 1:新节点的子节点是文本
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 1.1 老的是数组,先卸载所有老的子节点
      unmountChildren(n1.children)
    }
    // 无论老的是什么类型,只要新旧文本不同就更新
    if (n1.children !== n2.children) {
      hostSetElementText(el, n2.children)
    }
  }
  else {
    // 情况 2 和 3:新的可能是数组或 null
    if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 老的是文本,先清空文本内容
      hostSetElementText(el, '')
      // 如果新的是数组,挂载新的子节点
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        mountChildren(n2.children, el)
      }
    }
    else {
      // 老的是数组或 null
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 2.2
          // TODO: 全量 diff
        }
        else {
          // 3.1 新的不是数组(是 null),卸载老的数组
          unmountChildren(n1.children)
        }
      }
      else {
        // 老的是 null
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 2.3 新的是数组,直接挂载
          mountChildren(n2.children, el)
        }
      }
    }
  }
}

关键点解析

  1. 使用 ShapeFlags 位运算:通过位运算快速判断节点类型,避免多次类型检查
  2. 分情况处理:根据新旧子节点的类型组合,采用不同的更新策略
  3. 性能优化
    • 文本到文本:直接更新文本内容
    • 数组到文本:先卸载,再设置文本
    • 文本到数组:先清空,再挂载
    • 数组到数组:使用 diff 算法(TODO)

总结

通过本章的学习,我们完善了渲染器的更新机制,了解了:

  1. 节点类型判断
    • 使用 isSameVNodeType 判断新旧节点是否为同一类型
    • 类型不同时,卸载旧节点并重新挂载新节点
    • 类型相同时,进行对比更新以提高性能
  2. 元素更新流程
    • 复用已有的 DOM 元素(n2.el = n1.el
    • 分别更新 props 和 children
    • 避免不必要的 DOM 操作
  3. Props 更新策略
    • 遍历旧属性,删除不再需要的属性
    • 遍历新属性,更新或添加属性
    • 使用平台相关的 hostPatchProp 处理具体操作
  4. Children 更新策略
    • 根据 ShapeFlags 判断子节点类型
    • 处理文本、数组、null 三种类型的各种组合
    • 文本到文本:直接更新
    • 数组到数组:需要 diff 算法(后续实现)
    • 其他情况:卸载旧的,挂载新的
  5. 性能优化思路
    • 使用位运算快速判断类型
    • 复用 DOM 元素而不是重新创建
    • 针对不同场景采用最优更新策略

至此,我们已经实现了一个功能相对完整的渲染器更新机制。虽然数组到数组的 diff 算法还未实现,但基础的更新框架已经搭建完成。