React 状态更新 dispatchAction 触发全链路

各位同学,大家好!

欢迎来到“React 内部解剖室”。我是你们今天的特邀主讲人,一个在代码堆里摸爬滚打多年,头发依然茂密(主要是因为早睡)的资深工程师。

今天我们不聊 Hooks 的骚操作,不聊 TypeScript 的类型体操,也不聊 Next.js 的 SSR。我们要聊的是 React 的“心脏”——当你按下那个按钮,或者写下 setState 的时候,到底发生了什么?这也就是所谓的“状态更新 dispatchAction 触发全链路”

这听起来很高大上,对吧?好像要去拯救世界一样。但实际上,这就像是一场精心编排的交响乐,或者更准确地说,是一场在浏览器里疯狂运转的大生产流水线

准备好了吗?让我们把 React 拆开,看看里面的齿轮是怎么咬合的。


第一幕:起手式——事件与 Action 的诞生

故事通常从一个点击开始。

你写了一个按钮:

<button onClick={() => setCount(count + 1)}>点我</button>

当你的手指触碰屏幕的那一刻,浏览器捕获到了一个 click 事件。这个事件不是直接飞到 React 面前的,它先得经过浏览器原生的“防弹衣”——合成事件系统。

React 的合成事件会把原生事件包裹起来,塞进一个对象里,然后扔给 React 的渲染层。在 React 的世界里,这个动作被封装成了 dispatchAction

这名字起得挺有武侠小说的感觉,对吧?但其实它就是一个非常朴素的函数。让我们看看源码(简化版):

// 伪代码:React 内部的事件处理
function dispatchAction(fiber, action) {
  // 1. 创建一个 update 对象。这就是你要更新的“弹药”。
  const update = {
    action: action, // 比如 (count + 1)
    next: null,     // 链表结构的下一颗子弹
    expirationTime: ..., // 这个更新什么时候必须完成?这是关键!
    priorityLevel: ...
  };

  // 2. 把这颗子弹塞进当前 Fiber 节点的更新队列
  // 注意:React 并不是每次 setState 都直接重绘,而是先攒着
  if (fiber.updateQueue === null) {
    fiber.updateQueue = { pending: null, shared: null, effects: null };
  }
  fiber.updateQueue.pending = update;

  // 3. 这一步最关键:告诉调度器,“嘿,有活了,快来看看!”
  scheduleUpdateOnFiber(fiber);
}

你看,dispatchAction 做了三件事:

  1. 打包:把你的 count + 1 打包成一个 update 对象。
  2. 入队:把这个对象塞进当前组件对应的 Fiber 节点的队列里。React 这里用了一个链表结构,因为 setState 是可以连续调用的,比如 setState(a); setState(b);,它们会被串成一条长龙。
  3. 呼叫调度器:这是全链路的起点。如果不呼叫调度器,React 就算累死也轮不到它干活。

第二幕:调度器——那个焦虑的工头

上一幕结尾,我们呼叫了 scheduleUpdateOnFiber。这一步,就引出了 React 18 最重要的新成员——调度器

在 React 17 以前,React 是同步的。你点一下,它就得立刻把所有东西算完。如果计算量太大,浏览器就会卡死,连滚动条都动不了。这就像是一个笨重的厨师,你点个菜,他必须立刻把菜做完,中间你不能去上厕所。

现在,React 18 引入了并发模式。调度器就是那个聪明的工头,它手里拿着两样法宝:requestIdleCallback(空闲回调)和 requestAnimationFrame(帧回调)。

让我们看看 scheduleUpdateOnFiber 的逻辑(简化版):

function scheduleUpdateOnFiber(fiber) {
  // 1. 计算优先级
  const priority = calculatePriority(); // 比如:Input 优先级最高,动画次之,普通更新最低

  // 2. 检查是否已经在调度了
  if (fiber.expirationTime === NoWork) {
    // 如果这个更新还没排期,那就去排队
    // 这里的逻辑非常复杂,涉及任务切片、过期时间等
    fiber.expirationTime = priority;

    // 3. 把任务扔给调度器
    // 注意:这里不是直接执行,而是“调度”
    requestWork(fiber, priority);
  }
}

// 调度器的核心逻辑(极度简化)
function requestWork(root, expirationTime) {
  // 如果当前没有正在运行的任务,那就启动!
  if (!isWorking) {
    isWorking = true;
    // 启动调度循环
    workLoop(root, expirationTime);
  }
}

这里的 requestWork 就是调度器的核心。它不会立刻执行你的代码,而是把你的任务放进一个任务队列。然后,它去检查浏览器的状态:

  1. 如果是空闲时间(比如页面在渲染背景图片):调度器会利用 requestIdleCallback,在浏览器不忙的时候,偷偷摸摸地处理你的 React 更新。
  2. 如果很忙(比如在处理用户的输入):调度器会利用 requestAnimationFrame,在下一帧渲染前,抢一点时间出来处理。

这就解释了为什么 React 18 的响应性这么好。它不再是那个只会死等、死算的笨重厨师,而是一个灵活的管家。


第三幕:渲染阶段——Fiber 树的重组

当调度器决定要干活了,它就会启动 workLoop。这时候,React 就进入了漫长的渲染阶段

在这个阶段,React 做了一件事:重建 Fiber 树

为什么是重建?因为 React 需要对比新旧两棵树,看看哪里变了。为了不卡死浏览器,React 把这棵树拆成了一片片的小任务,通过 requestIdleCallback 一个个处理。这就是所谓的时间切片

3.1 构建 WorkInProgress 树

React 会在内存里维护两棵树:

  1. Current Tree:当前显示在屏幕上的树。
  2. WorkInProgress Tree:正在计算的新树。

当你的状态更新了,React 会以 Current Tree 为基础,开始构建 WorkInProgress Tree。这就像是在原来的房子上盖新房子,而不是拆了重建。

代码逻辑大概是这个样子的:

function workLoop(root, expirationTime) {
  // 这是一个无限循环,直到没有任务了或者时间到了
  while (nextUnitOfWork !== null && nextUnitOfWork.expirationTime <= expirationTime) {
    // 执行当前任务
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }
}

function performUnitOfWork(fiber) {
  // 1. beginWork: 遍历子节点
  // 它负责决定:这个节点要不要更新?要不要创建新的子节点?
  if (fiber.child === null) {
    // 没有子节点了,回退到父节点
    return fiber.sibling;
  }

  // 这里是 Diff 算法的地方(简化)
  const newChild = reconcileChildren(fiber, fiber.child);

  if (newChild) {
    // 有子节点,返回子节点继续处理
    return newChild;
  }

  // 2. completeWork: 完成当前节点的处理
  // 它负责把虚拟 DOM 节点“落实”到具体类型上(比如把函数组件变成 DOM 节点)
  completeWork(fiber);

  // 回到父节点,处理兄弟节点
  return fiber.sibling;
}

3.2 Diff 算法:猫捉老鼠的游戏

reconcileChildren 是最耗时的部分,也是 Diff 算法登场的地方。

React 并不是全量对比。它首先对比类型type):

  • 如果类型变了(比如 div 变成了 span):React 会认为这是完全不同的节点,直接把旧的删了,把新的插进去。
  • 如果类型没变(比如还是 div):React 会进入“子节点 Diff”。

对于子节点,React 会假设列表是静态的(比如 <ul><li>1</li><li>2</li></ul>),只有列表末尾可能变化。这大大提高了性能。

如果类型没变,React 会对比Key。Key 是 React 识别节点的身份证。

// 伪代码:Reconcile 逻辑
function reconcileChildren(currentFiber, workInProgressFiber) {
  const newChildren = workInProgressFiber.pendingProps.children;
  let index = 0;
  let oldFiber = currentFiber.child;

  while (index < newChildren.length || oldFiber !== null) {
    const newChild = newChildren[index];

    // 1. 对比 Key
    if (newChild.key === oldFiber?.key) {
      // Key 相同,可能是移动或复用
      const newFiber = createFiberFromType(newChild.type);
      newFiber.return = workInProgressFiber;
      newFiber.stateNode = oldFiber.stateNode; // 复用 DOM 节点

      // 继续处理子节点
      return newFiber;
    } else {
      // Key 不同,删除旧的,创建新的
      deleteOldFiber(oldFiber);
      const newFiber = createFiberFromType(newChild.type);
      newFiber.return = workInProgressFiber;
      return newFiber;
    }

    index++;
    oldFiber = oldFiber.sibling;
  }

  // 清理多余的旧节点
  while (oldFiber !== null) {
    deleteOldFiber(oldFiber);
    oldFiber = oldFiber.sibling;
  }
}

这个过程就像是在玩“猫捉老鼠”。React 试图复用旧的 DOM 节点,而不是全部销毁重建。这种优化在列表渲染时效果显著。


第四幕:提交阶段——大扫除与 DOM 操作

经过漫长的 beginWorkcompleteWorkWorkInProgress Tree 终于构建完成了。此时,React 拿到了最新的虚拟 DOM。

接下来,就是提交阶段。这是最脏活累活,也是最关键的一步。

提交阶段只有一个目标:把最新的虚拟 DOM 变成真实的 DOM

function commitRoot(root) {
  // 1. commitBeforeMutationEffects
  // 处理副作用,比如 `useLayoutEffect` 的清理函数
  commitBeforeMutationEffects(root.current, root.nextEffect);

  // 2. commitMutationEffects
  // 真正的 DOM 更新!这会修改 DOM 树
  commitMutationEffects(root.current, root.nextEffect);

  // 3. commitLayoutEffects
  // DOM 更新完成后,执行 `useLayoutEffect` 的设置函数
  // 以及 `useEffect` 的回调(注意:useEffect 是异步的,在这里不会执行)
  commitLayoutEffects(root.current, root.nextEffect);

  // 4. 把 Current 指针指向 WorkInProgress
  root.current = workInProgressRoot;

  // 5. 恢复渲染状态
  isWorking = false;
}

4.1 DOM 的变更

commitMutationEffects 中,React 会遍历变更列表(nextEffect 链表)。对于每个需要变更的 Fiber 节点,它会调用底层的 DOM API。

比如,如果 React 决定移动一个节点:

function commitWork(fiber) {
  if (fiber.tag === HostComponent) {
    const newProps = fiber.memoizedProps;
    const oldProps = fiber.alternate?.memoizedProps;
    const domNode = fiber.stateNode;

    // 这里会调用浏览器原生的 API
    // 比如:domNode.textContent = newProps.children;
    // 比如:domNode.style.color = newProps.style.color;

    // 还有一个超级重要的操作:DOM 移动
    // React 会检查父节点的子节点顺序,如果变了,就移动 DOM 节点
    // 而不是销毁重建!这保证了动画的流畅性。
    commitPlacement(domNode); 
  }
}

4.2 useLayoutEffect 的坑

注意,在提交阶段,useLayoutEffect 的回调函数会被同步执行。这意味着,如果你在 useLayoutEffect 里做复杂的计算,会阻塞浏览器的下一次渲染。

useLayoutEffect(() => {
  // 这里的代码会在 DOM 更新后、浏览器绘制前同步执行
  // 如果这里卡住了,页面就会卡住
  const rect = ref.current.getBoundingClientRect();

  // 你可以在这里做 DOM 的测量,然后同步修改样式
  // 比如实现一个自动调整高度的输入框
}, []);

这就是为什么 useLayoutEffectuseEffect 更快,但也更容易卡死页面的原因。


第五幕:浏览器渲染——重绘与回流

当提交阶段结束,React 把最新的 DOM 树交给了浏览器。

浏览器终于可以干活了。它会根据新的 DOM 树,开始计算布局绘制

5.1 回流

如果 React 修改了元素的布局属性(比如 width, height, top, left, padding, margin),浏览器不得不重新计算元素在页面中的位置。这就是回流。回流是昂贵的操作。

5.2 重绘

如果 React 只修改了视觉效果(比如 color, background-color, visibility),浏览器只需要重新绘制像素。这就是重绘。重绘相对便宜,但依然消耗性能。

// 这会触发回流
setState({ width: 100 });

// 这只会触发重绘
setState({ color: 'red' });

React 为了减少回流,会尽量把多个 DOM 变更合并起来。比如:

function App() {
  // React 会把这两个 setState 合并成一次渲染
  // 虽然写了两次,但浏览器只回流一次
  setCount(c => c + 1);
  setCount(c => c + 1);

  return <div>{count}</div>;
}

第六幕:事件系统的闭环

故事讲到这里,状态更新已经完成了。但是,React 的魔法还没结束。还记得我们在第一幕提到的合成事件吗?

当浏览器把 DOM 事件传给 React 的 dispatchEvent 时,React 会做一件大事:事件委托

React 会在根节点(documentdiv#root)上挂载一个监听器。当用户点击页面任何地方时,事件都会先到达这里。

// React 事件委托的核心逻辑
function dispatchEventsForElement(element, domEvent) {
  // 1. 获取 React 绑定在这个元素上的所有事件监听器
  const listeners = getListeners(element);

  // 2. 遍历监听器
  for (const listener of listeners) {
    // 3. 执行监听器
    // 注意:这里的 listener 已经被 React 包装过,包含了 React 的合成事件对象
    listener(domEvent);
  }
}

这个包装过的事件对象(SyntheticEvent)屏蔽了浏览器的兼容性问题(比如 e.targete.currentTarget 的区别),并且支持事件冒泡。

这就形成了一个闭环:

  1. 用户点击。
  2. 浏览器捕获事件。
  3. React 合成事件接收事件。
  4. 调用 dispatchAction
  5. 触发全链路更新。
  6. DOM 变更。
  7. 浏览器渲染。
  8. (可选)如果事件冒泡继续,React 再次捕获。

第七幕:总结——一场精密的手术

好了,同学们,我们的全链路之旅到此结束。

让我们回顾一下这条从 dispatchAction 到浏览器渲染的漫长道路:

  1. 触发:用户交互 -> dispatchAction -> 打包 update
  2. 调度scheduleUpdateOnFiber -> 调度器 -> 时间切片 -> 优先级队列。
  3. 渲染workLoop -> beginWork/completeWork -> Diff 算法 -> 构建 WorkInProgress Tree
  4. 提交commitRoot -> commitMutationEffects -> DOM API 调用 -> 移动/删除/插入节点。
  5. 渲染:浏览器 -> 计算布局(回流)-> 绘制(重绘)。
  6. 闭环:事件系统 -> 事件委托 -> 再次触发(如果需要)。

你看,React 的状态更新并不是简单的“修改数据 -> 重新渲染”。它是一个复杂的系统工程。React 使用了调度器来管理时间,使用Fiber来拆分任务,使用Diff 算法来优化性能。

理解这个全链路,能让你在遇到性能问题时,精准地找到病灶。

  • 如果页面卡顿,可能是调度器没跑完,或者是Diff 算法太慢了。
  • 如果布局抖动,可能是Layout Effects 做了太多同步计算。
  • 如果动画不流畅,可能是回流太频繁了。

所以,下次当你按下那个按钮,看着数字跳动的时候,请对 React 内部那几百个正在疯狂运转的线程,致以崇高的敬意。毕竟,它们为了让你那行 setState 能顺利执行,可是费了九牛二虎之力的。

谢谢大家!下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注