React 更新机制详解:从 State/Props 变化到 DOM 更新

480 阅读5分钟

精确步骤分解

  1. state 或 props 变化 (触发更新):

    • 当 setState 被调用或父组件传递下来的 props 发生变化时,React 会标记该组件(及其子组件)为“需要更新”(这个过程是通过更新某个fiber节点的flag标记来完成的)。
    • 注意:setState 调用是异步的批量更新,不会立即触发 render
    • setState 之后, 会通知 React 调度器有一个更新需要处理, 同时在 fiber 节点中插入一些标记。

不是立即执行:  当你调用 setState 时,React 不会立即同步地更新组件的 state 以及触发重新渲染。相反,React 会将这些 setState 调用放入一个队列中。

为什么 setState 是异步的呢? 主要还是为了性能的考虑。

避免多次渲染:如果每次 setState 都立即触发渲染,那么在短时间内多次调用 setState 会导致组件多次重新渲染,这会非常消耗性能。

批量更新:React 会在合适的时机(例如:当前事件循环结束后,浏览器准备绘制之前)将队列中的 setState 调用合并为一次更新,然后触发组件的重新渲染 (也就是我们说的批量更新)。

优化渲染:React 会把多个 setState 调用合并成一次。

发生在事件处理函数中:通常,在 React 事件处理函数中(例如:onClickonChange 等), React 会自动进行批量更新。

  1. 开始 Render 阶段 (Reconciliation):

    • React 会调用组件函数,执行函数组件的代码。
      • 这个阶段,会执行 Hooks,例如 useState 返回新的值和更新函数、 useEffect 会被"遇到"/“注册” (收集 Effect 信息), useMemo 也会重新计算。
        • 注意: 这个 "执行", 并不是 执行 useEffect 的回调函数。
    • React 会构建新的 Fiber 树,并在构建的过程中利用 diff 算法 进行新旧 Fiber 树的比较
      • 对比过程中,React 会检查当前 Fiber 树节点对应的组件的 props 和 state 是否发生了改变。
    • Diff 算法会找出需要更新的 DOM 节点,并将这些节点标记为“需要更新”。
      • 如果没有需要更新的 dom 节点, 此时会跳过commit阶段, 只执行 effect
  2. useEffect 的收集和准备:

    • 在 Render 阶段(即 Fiber 创建和 Diff 过程中):
      • React 会依据本次渲染的 useEffect 和上次渲染的 useEffect 依赖值比较,来判断是否更新 useEffect
    • 如果没有依赖更新, 那么上次的副作用会被保留, 这次 render 不会添加新的 effect
    • 如果依赖更新, 把上次的 useEffect 的清除函数记录下来,同时, 也把当前的 useEffect 的回调函数记录下来,等待执行。
      • 注意:这里仅仅是 "收集" useEffect 信息,并放入本次渲染的副作用列表, useEffect 的回调函数代码不执行。
  3. 进入 Commit 阶段:

    • React 会真正地开始执行 UI 更新 的操作。
    • 这个阶段会不可打断。
    • 在这个阶段,React 从 fiber 树 中获取需要更新的 DOM 节点,并执行对应的 DOM 操作。
    • 紧接着会执行 useLayoutEffect 队列里所有的 useLayoutEffect 的回调函数, 并 清理上次更新的 useLayoutEffect 副作用。
      • useLayoutEffect 是 同步执行,会阻塞浏览器的绘制。
  4. DOM 更新完成:

    • 此时更新还仅仅发生在浏览器内部,还没有同步到屏幕上,你可以粗略地理解成 react 完成了 DOM 的写入操作,完成了 JS 代码的任务
  5. useEffect 的排队执行:

    • React 会按照顺序, 遍历整个 Fiber 树节点,找出本次渲染更新的 useEffect
    • 对每一个本次渲染需要更新的 useEffect:
      • 如果上次 useEffect 有清除函数,则把 上次的清除函数放入微任务队列,等待执行。
      • 把当前的 effector (也就是 useEffect 的回调函数) 放入微任务队列中。
  6. 浏览器绘制:

    • 浏览器进行屏幕绘制, 这时用户才能看到界面变化。
  7. useEffect 回调函数执行 (微任务队列执行):

    • 在浏览器完成绘制 之后, 微任务队列开始执行。
      • 微任务队列里, useEffect 的清除函数会先于 useEffect 回调函数执行。
    • React 异步 执行 useEffect 中的回调函数, 这些回调函数中可以执行副作用,比如:
      • 发送 API 请求
      • 操作 DOM
      • 监听事件
      • 设置定时器
    • 注意: 这些操作在浏览器进行页面渲染之后进行执行,所以不会阻塞 UI 渲染。

useLayoutEffect 在浏览器绘制完成之前执行useEffect 在浏览器绘制完成之后执行

精简总结:

  1. 状态/属性变化:  标记组件需要更新。
  2. Render 阶段:
    • 执行组件,执行 hooks,收集 useEffect 信息。
    • 构建 Fiber 树,计算 Diff, 识别需要更新的 UI。
  3. Commit 阶段:  更新 DOM, 执行 useLayoutEffect
  4. DOM 更新完成:  屏幕上可见新的 UI。
  5. useEffect 放入微任务队列:
  6. 异步执行useEffect  执行 useEffect 回调。 避免误解的关键:
  • Render != DOM 更新 和 执行副作用:  Render 只是计算和比较 Fiber 树, 找出需要更新的节点,并收集需要执行的副作用信息。
  • useEffect 的 “触发” != “执行”useEffect 的回调在 render 阶段并不会执行, 只是收集信息等待执行。
  • useLayoutEffect 同步执行:  DOM 更新后, useLayoutEffect 的代码同步执行。
  • 异步副作用:  DOM 更新后, useEffect 的代码执行,是由微任务队列异步调度执行, 而且是在浏览器绘制之后。 结合 Hook 生命周期:
  • Mount:  组件首次渲染时,执行一次上述所有步骤。
  • Update:  当 state/props 变化时, 重复步骤 1-8。 每次更新时,如果依赖更新会先执行上次的 cleanup function。
  • Unmount:  组件卸载时,会执行 useEffect 返回的清除函数。 简单的流程图帮助理解:

Untitled diagram-2025-01-22-095806.png