03|React 为什么需要协调:比较两棵树,而不是重建页面

对应代码:lessons/03-reconciliation

mini-react 仓库地址

每次状态变化,组件都会重新计算一份 UI 描述。新的元素对象和旧对象不是同一批 JavaScript 对象,但这不意味着页面中的真实 DOM 也必须全部重建。

协调(reconciliation)要回答的是:面对一棵旧树和一棵新树,哪些宿主资源可以继续使用,哪些需要创建、更新或删除?本篇用一个同步、按位置比较的实现,建立这条最小主线。

新元素对象,不等于新 DOM

课程示例把计数值保存在普通变量里:

js 复制代码
let count = 0;

function increment() {
  count += 1;
  render(h(App, null), root);
}

第二次调用 h(App) 会创建新的 App 元素;App() 也会返回一批新的 sectionpbuttonli 元素对象。元素对象只是不可变式使用的 UI 快照,所以重新创建它们很自然。

真实按钮则可能带着焦点、事件监听和浏览器内部状态。只要新旧树中的按钮仍表示同一个位置、同一种类型,渲染器就可以保留原 DOM,只更新发生变化的部分。

text 复制代码
旧元素树 ─┐
          ├─ patch → 必要的 DOM 操作
新元素树 ─┘

协调的价值是给复用和更新提供统一规则;它是一种启发式过程,并不保证求出理论上的全局最少操作。

先把函数组件展开

为了让第一版 Diff 足够直观,lessons/03-reconciliation/mini-react.js 会先执行所有函数组件,只保留宿主元素与文本:

js 复制代码
function resolve(element) {
  if (typeof element.type === "function") {
    return resolve(element.type(element.props));
  }

  return {
    ...element,
    props: {
      ...element.props,
      children: element.props.children.map(resolve),
    },
  };
}

例如输入 { type: App },输出树的根会是 { type: "section" }。这样 patch() 不需要理解函数组件,只比较与 DOM 一一对应的节点。

代价也很明显:组件边界从结果树中消失,状态无法自然地挂到某个组件上。后面的 Fiber 版本会保留函数组件工作节点。

这一教学版还约定组件返回单个元素,不处理 Fragment 或数组返回值。

保存新旧两棵描述树

render() 的实现很短:

js 复制代码
export function render(element, container) {
  const nextTree = resolve(element);
  patch(container, container.__previousTree, nextTree, 0);
  container.__previousTree = nextTree;
}

第一次渲染时,container.__previousTree 不存在,patch() 会创建完整 DOM。提交结束后,新树被保存在容器的自定义属性上。

第二次渲染时:

  • previous 是上次保存的描述树;
  • nextTree 是本次刚生成的描述树;
  • 容器中已有的 DOM 与 previous 按相同结构对应。

把旧树挂到 DOM 容器上只是为了教学方便,并不是 React 保存 Fiber 树的方式。第 04 篇会引入 root 对象,第 06 篇则会正式区分 currentRootworkInProgressRoot

patch() 的四种情况

patch(parent, previous, next, index) 中,index 表示当前元素对应 parent.childNodes 中的哪个真实节点:

js 复制代码
function patch(parent, previous, next, index) {
  const dom = parent.childNodes[index];

  if (!previous) {
    parent.appendChild(createDom(next));
    return;
  }
  if (!next) {
    parent.removeChild(dom);
    return;
  }
  if (previous.type !== next.type) {
    parent.replaceChild(createDom(next), dom);
    return;
  }

  updateProps(dom, previous.props, next.props);
  // 接下来继续比较 children
}

规则可以归纳为:

旧元素 新元素 操作
不存在 存在 创建并追加 DOM
存在 不存在 删除旧 DOM
存在 type 不同 创建新 DOM 并替换
存在 type 相同 复用 DOM,比较 props 和 children

这里的 type 是重要的复用边界。h2 变成 h3 时,节点语义和 DOM 类型都变了,当前实现直接替换整个子树;p 仍是 p 时,则保留节点对象。

props Diff 不只是改文本

同类型 DOM 被复用后,updateProps() 分三步工作:

  1. 解绑已经删除或引用发生变化的旧事件;
  2. 删除新 props 中不再存在的普通属性;
  3. 添加新事件,写入发生变化的属性。

事件需要先解绑旧函数再绑定新函数,否则组件每次执行产生的新闭包会在同一个 DOM 上不断累积。

属性的落地方式也不完全相同:

  • className 通过 class attribute 设置;
  • DOM 对象已有的名字优先写 property,例如 valuenodeValue
  • 其他名字使用 setAttribute()
  • style 对象会逐项清理旧样式并合并新样式。

文本元素的 type 始终是 TEXT_ELEMENT。它对应一个 Text DOM,文字内容保存在 props.nodeValue,所以文本更新最终也是一次 property Diff。

这个属性层是为了说明机制,并没有覆盖 React DOM 的全部浏览器兼容和表单规则。

children 为什么按位置比较

本章让第 N 个旧 child 与第 N 个新 child 配对。共有部分从前向后递归:

js 复制代码
const commonLength = Math.min(oldChildren.length, newChildren.length);

for (let i = 0; i < commonLength; i += 1) {
  patch(dom, oldChildren[i], newChildren[i], i);
}

如果新孩子更多,就从尾部创建;如果旧孩子更多,则必须从后向前删除:

js 复制代码
for (let i = oldChildren.length - 1; i >= newChildren.length; i -= 1) {
  patch(dom, oldChildren[i], null, i);
}

倒序删除是因为 childNodes 是动态集合。若先删除下标 1,原下标 2 会立刻移动到 1,后续下标就不再对应原节点;从末尾开始不会影响前面的下标。

跟踪一次 count: 0 → 1

示例初次渲染后,firstButton 保存了按钮 DOM 的引用。点击一次时,新旧树大致发生这些变化:

  • sectionh2pbuttonul 类型和位置不变,因此 DOM 被复用;
  • p 的颜色样式改变;
  • p 的文本节点从 0 更新为 1
  • ul 尾部多出一个 li,因此创建并追加该子树;
  • increment 是同一个函数引用,按钮事件无需重新绑定。

提交后:

js 复制代码
root.querySelector("button") === firstButton // true

这证明"元素对象重新创建"和"DOM 对象重新创建"是两件不同的事。

按位置匹配的盲点

假设旧列表是:

text 复制代码
下标     0   1   2
旧数据   A   B   C

现在在开头插入 X:

text 复制代码
下标     0   1   2   3
新数据   X   A   B   C

按位置比较会把旧 A 对应的 DOM 改写为 X,把旧 B 改写为 A,把旧 C 改写为 B,最后再创建 C。视觉文字可能正确,但 A、B、C 没有保留各自原来的 DOM 身份。

如果列表项内有输入框、播放状态或组件状态,这种身份错配就会变成真实问题。数组下标只说明当前位置,无法表达"移动后的它仍是同一条数据"。key 正是用来补充稳定身份的,不过完整算法要到第 07 篇才加入。

动手验证

运行 npm run dev 并打开第 03 章:

  1. 在 Elements 面板中选中按钮,点击更新后观察临时标记是否仍在;页面日志也会显示按钮引用相等。
  2. 让标题在奇数次渲染时使用 h3,偶数次使用 h2。保存标题和按钮引用,验证标题被替换而按钮仍被复用。
  3. 把列表改为可在开头插入项目,并在某个 li 中放入 input。输入内容后再插入,观察内容为什么留在"位置"上,而不是跟随数据项移动。

做实验时先预测四条 patch 规则中的哪一条会触发,再查看结果。

小结

  • 协调比较新旧 UI 描述,决定宿主资源的复用和修改。
  • 同位置、同 type 的元素复用 DOM;类型变化则替换。
  • 属性、事件、样式、文本和孩子都需要参与 Diff。
  • 本章按位置匹配,能处理尾部增删,却不能表达列表项的稳定身份。

现在页面可以更新了,但状态仍由业务代码手工保存在模块变量中。下一篇会实现 useState,并解释函数组件每次重跑时,状态为什么没有随局部变量一起消失。

相关推荐
灯澜忆梦41 分钟前
【基于GO的Web开发11】gin获取URL‑Path 路径参数
前端·后端·golang·html·gin
王霸天41 分钟前
GLB 文件太大怎么办?我把它从 47MB 压到 8MB,全程没碰命令行
前端·javascript·程序员
半个落月41 分钟前
Next.js 16 笔记应用实战:Redis 数据链路与组件两版拆分详解
前端·redis·next.js
YIAN42 分钟前
告别等后端!React+Vite 前端独立开发全流程:接口封装 + Mock 方案 + 状态管理
前端·react.js·vite
PedroQue991 小时前
@meng-xi/vite-plugin v1.3.0:generateUni 一键流水线
前端·vite
胡萝卜术1 小时前
权限系统的四道防线:从数据库事务锁到操作级保护规则的完整设计
前端·javascript·面试
IMPYLH1 小时前
HTML 的 <legend> 元素
java·前端·html
werdedeage1 小时前
用 useReducer 管理多参考图生成器的前端状态
react.js·typescript
今日无bug1 小时前
从同步阻塞到 async/await:一文梳理 JS 异步流程控制的进化史
javascript·promise