React 的 DOM diff笔记

在 React 的 DOM Diffing(协调 Reconciliation)算法中,无论是单节点还是多节点(列表),其核心目标都是最小化对真实 DOM 的操作。React 会比较新旧两棵虚拟 DOM 树,找出差异,然后只更新真实 DOM 中必要的部分。

1. 单节点(Single Node)的处理

当 React 比较两个单独的 VNode(虚拟节点)时,它会遵循以下步骤:

  1. 类型比较 (Type Comparison)

    • 如果新旧 VNode 的类型不同 (例如,旧的是 <div>,新的是 <span>),React 会认为这是一个完全不同的组件或元素。它会销毁旧的 VNode 及其所有子树 ,并创建新的 VNode 及其所有子树,然后插入到 DOM 中。这是最昂贵的操作。
    • 如果新旧 VNode 的类型相同 (例如,都是 <div>),React 认为它们是同一个组件或元素,可以进行复用。
  2. 属性比较 (Props Comparison)

    • 在类型相同的情况下,React 会比较新旧 VNode 的属性(props)。
    • 它会找出发生变化的属性,并只更新真实 DOM 上对应的属性。例如,如果 className 变了,就只更新 className;如果 style 变了,就只更新 style
    • 对于事件监听器,React 会高效地添加、移除或更新事件处理器。
  3. 子节点递归 (Children Recursion)

    • 在属性比较完成后,React 会递归地对子节点进行 Diff。
    • 如果 VNode 没有子节点,或者子节点是文本节点,则 Diff 过程结束。
    • 如果 VNode 有子节点,React 会进入多节点(列表)的 Diff 阶段来处理这些子节点。

2. 多节点(列表)的处理:两轮遍历

当 React 处理一个组件的多个子节点(通常是列表)时,情况会变得复杂。因为子节点的数量、顺序和内容都可能发生变化。为了高效地处理这些情况,React 采用了一种**两轮遍历(Two Passes)**的启发式算法,并严重依赖 key 属性。

核心问题: 如何在 O(n) 的复杂度下,高效地识别出列表中的增、删、改、移操作?

key 属性的作用:
key 是 React 用来识别列表中每个元素的唯一标识。它帮助 React 跟踪每个元素在多次渲染中是否是同一个实体。

  • 没有 keykey 不唯一:React 无法准确识别元素,可能会导致性能问题(不必要的 DOM 重建)和状态错乱(组件内部状态保留在错误的元素上)。
  • 有了 key :React 可以根据 key 来判断元素是否被移动、删除或新增,从而进行更精准的 DOM 操作。

第一轮遍历:从头开始比较 (Head to Head)

这一轮遍历从新旧子节点列表的头部 开始,向后进行比较。它主要处理更新类型变化导致的替换 以及头部元素的增删

  1. 指针初始化

    • oldStartIndex 指向旧列表的第一个元素。
    • newStartIndex 指向新列表的第一个元素。
  2. 比较过程

    • 同时比较 oldChildren[oldStartIndex]newChildren[newStartIndex]

    • 如果它们的 keytype 都相同

      • React 认为它们是同一个元素。
      • 对这两个 VNode 进行单节点 Diff(比较属性和递归子节点)。
      • oldStartIndexnewStartIndex 同时向后移动一位。
    • 如果它们的 keytype 不同

      • 这一轮比较停止。因为从当前位置开始,头部匹配已经失效,可能发生了元素的插入、删除或移动。

处理情况:

  • 元素更新 :如果 keytype 相同,会进行属性和子节点的更新。
  • 头部新增/删除:如果新列表在头部有新增元素,或旧列表在头部有删除元素,第一轮遍历会很快停止,剩下的由第二轮或第三阶段处理。
  • 元素类型变化 :如果 type 不同,会直接替换。

第二轮遍历:从尾部开始比较 (Tail to Tail)

这一轮遍历从新旧子节点列表的尾部 开始,向前进行比较。它主要处理更新类型变化导致的替换 以及尾部元素的增删

  1. 指针初始化

    • oldEndIndex 指向旧列表的最后一个元素。
    • newEndIndex 指向新列表的最后一个元素。
  2. 比较过程

    • 同时比较 oldChildren[oldEndIndex]newChildren[newEndIndex]

    • 如果它们的 keytype 都相同

      • React 认为它们是同一个元素。
      • 对这两个 VNode 进行单节点 Diff。
      • oldEndIndexnewEndIndex 同时向前移动一位。
    • 如果它们的 keytype 不同

      • 这一轮比较停止。

处理情况:

  • 元素更新:同第一轮。

  • 尾部新增/删除:同第一轮。

  • 元素移动 (优化)

    • 从头部移动到尾部:如果元素从列表头部移动到了尾部,第一轮遍历会处理头部,第二轮遍历会处理尾部,直到两个指针相遇,高效完成更新。
    • 从尾部移动到头部:类似地,如果元素从尾部移动到头部,第二轮遍历会高效处理。

两轮遍历结束后的情况 (复杂情况处理)

在两轮遍历结束后,如果 oldStartIndex 仍然小于等于 oldEndIndex,或者 newStartIndex 仍然小于等于 newEndIndex,说明旧列表或新列表中还有未处理的元素。这通常意味着发生了复杂的元素移动、新增或删除

此时,React 会进入第三个阶段:

  1. 旧列表剩余元素映射 (Map Old Children by Key)

    • React 会将旧列表中从 oldStartIndexoldEndIndex 之间的所有剩余元素,以它们的 key 为键,存储在一个 Map 中。
  2. 新列表剩余元素遍历 (Iterate New Children)

    • React 遍历新列表中从 newStartIndexnewEndIndex 之间的所有剩余元素。

    • 对于新列表中的每一个元素 newChild

      • 如果 newChildMap 中找到了相同的 key

        • 说明这是一个移动更新的元素。
        • React 会将该元素从旧列表的 Map 中移除,并对 newChildMap 中找到的旧 VNode 进行单节点 Diff。
        • 如果 newChild 的位置与旧 VNode 在 DOM 中的位置不同,React 会进行 DOM 移动操作。
      • 如果 newChildMap 中没有找到相同的 key

        • 说明这是一个新增的元素。
        • React 会创建新的 DOM 节点并插入到正确的位置。
  3. 旧列表剩余元素删除 (Delete Remaining Old Children)

    • 在遍历完新列表后,Map 中如果还有剩余的旧元素,说明这些元素在新列表中已经不存在了。
    • React 会将这些剩余的旧元素对应的真实 DOM 节点进行删除

总结

  • 单节点处理 :主要比较 VNode 的 typeprops,然后递归处理子节点。

  • 多节点处理(两轮遍历)

    • 第一轮(从头到头) :处理列表头部元素的更新、替换、增删。
    • 第二轮(从尾到尾) :处理列表尾部元素的更新、替换、增删,并优化了头部和尾部元素的移动。
    • 剩余阶段 :如果两轮遍历后仍有未处理元素,则使用 key 属性和 Map 来识别复杂的移动、新增和删除操作。

这种两轮遍历的策略,结合 key 属性,使得 React 的 Diffing 算法在大多数常见场景(如列表头部/尾部增删、简单移动)下能够达到接近 O(n) 的效率,从而保证了高性能的 DOM 更新。

相关推荐
coding随想2 小时前
JavaScript ES6 解构:优雅提取数据的艺术
前端·javascript·es6
小小小小宇2 小时前
一个小小的柯里化函数
前端
灵感__idea2 小时前
JavaScript高级程序设计(第5版):无处不在的集合
前端·javascript·程序员
小小小小宇2 小时前
前端双Token机制无感刷新
前端
小小小小宇2 小时前
重提React闭包陷阱
前端
小小小小宇2 小时前
前端XSS和CSRF以及CSP
前端
UFIT2 小时前
NoSQL之redis哨兵
java·前端·算法
超级土豆粉2 小时前
CSS3 的特性
前端·css·css3
星辰引路-Lefan2 小时前
深入理解React Hooks的原理与实践
前端·javascript·react.js
wyn200011282 小时前
JavaWeb的一些基础技术
前端