React 渲染机制与调度:Fiber、优先级与并发特性

要理解 React 为什么有时「感觉很卡」,为什么某些更新可以被打断,就得从它的渲染与调度机制说起,尤其是 Fiber 出现之后的那一套。

早期(Stack Reconciler 时代),React 渲染是这样的:

  • 更新一触发,从根组件开始深度遍历;
  • 一路递归到底,中间不能停;
  • 这段时间里浏览器没法处理别的事情(如输入、滚动),就会感觉掉帧。

Fiber 的出现,核心目的只有一个:把这段递归拆碎,做成可以被打断、可以分片执行的工作单元。

可以粗略理解为:

  • 把整个组件树拆成一棵 Fiber 树,每个节点代表一个「工作单元」;
  • 遍历时可以先做一部分,发现「时间片快用完了」,就先停下来,让浏览器先去干别的事(如绘制、处理输入);
  • 下次有空再从上次停下来的地方继续。

这就让 React 有机会在「不阻塞主线程太长时间」和「保持 UI 一致性」之间折中。

React 的一次更新,大致可以分成两个阶段:

  • render 阶段:计算「应该怎么变」,构建新的 Fiber 树,得出一组变更描述(effect list);
  • commit 阶段:把这些变更真正提交到 DOM / 原生视图。

重要区别在于:

  • render 阶段是可以被打断、重做的 —— 这是 Fiber 调度的主要战场;
  • commit 阶段必须一次性执行完 —— DOM 更新需要保持一致性,不能半途而废。

可以类比为:

  • render 阶段在做「草稿」:可以多次重画,直到满意;
  • commit 阶段在「正式盖章」:一旦开始,就要一次做完。

既然渲染工作可以被拆分,接下来要解决的问题就是:哪些工作应该先做,哪些可以稍后再说。

React 内部有一套优先级与调度系统,粗略来说会考虑:

  • 用户输入相关的更新(例如输入框、点击)优先级更高;
  • 不那么着急的更新(例如列表统计、日志)可以晚一点;
  • 同一批更新尽量合并,减少重复工作。

在开发者可见的层面,能看到的一个入口是 startTransition

 1import { startTransition, useState } from 'react';
 2
 3function SearchPage() {
 4  const [keyword, setKeyword] = useState('');
 5  const [results, setResults] = useState<string[]>([]);
 6
 7  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
 8    const value = e.target.value;
 9    setKeyword(value); // 高优先级:保证输入框跟手
10
11    startTransition(() => {
12      // 低优先级:允许稍微延迟渲染结果
13      setResults(expensiveSearch(value));
14    });
15  }
16
17  // ...
18}

可以把 startTransition 看成是对 React 说的一句话:「这块不需要立刻完成,先保证交互顺滑。

useTransition 是在组件内使用「低优先级更新」的一种方式:

1const [isPending, startTransition] = useTransition();
2
3startTransition(() => {
4  // 这里的 setState 是低优先级的
5});

典型场景:

  • 输入框要保证跟手;
  • 输入同时触发一个较重的列表过滤/渲染;
  • 用 transition 把「列表那一块更新」标记成低优先级,允许略微延迟。

在 UI 层面通常会配合 isPending 给出一点反馈(例如 loading skeleton,而不是整页卡住)。

useDeferredValue 则是把某个值的变化「延后传播」:

1const [keyword, setKeyword] = useState('');
2const deferredKeyword = useDeferredValue(keyword);

用法上可以理解成:

  • keyword 立刻变,用在轻量部分(例如输入框显示);
  • deferredKeyword 慢一点变,用在重渲染部分(例如列表过滤)。

在内部,useDeferredValue 也是用低优先级的更新来实现,只不过它更像是「对值做延迟抖动」。

在 Fiber 模型下,只要每一帧内的工作量可控,即便渲染很频繁,也可以保持流畅。

真正导致卡顿的,往往是:

  • 某一批更新触发了大量组件重新渲染;
  • 每个渲染里又做了比较重的计算或 DOM 操作;
  • 加起来超过了一帧的预算时间(大致 16ms)。

所以优化的时候,一般会从这几件事入手:

  • 减少不必要的重渲染(React.memouseMemouseCallback、拆分 Context);
  • 把重计算移出渲染路径(例如交给 worker、延后到 effect);
  • 用并发特性标记「不着急的那部分更新」。

当一个低优先级更新在 render 阶段被打断时,它并不是被丢弃了,而是会:

  • 停在当前已经计算好的 Fiber 节点上;
  • 之后有空时继续往下算;
  • 或者被更新的更高优先级任务「覆盖」,重新从更高优先级路径开始。

最终提交到 DOM 的,仍然是某个时间点上一致的整棵视图树,不会出现「一半旧 UI 一半新 UI」的状态。

这套渲染与调度机制,在日常编码时不需要时刻挂在嘴边,但几个具体影响是可以直接感知到的:

  • 不要在渲染函数里做重计算和副作用;
  • 真正有性能压力的地方,再考虑用 memo、拆组件和并发特性;
  • 需要「交互优先」的场景(搜索、过滤、复杂页面切换),可以考虑 startTransition / useTransition / useDeferredValue

理解 Fiber 和优先级的基本思路之后,看 React DevTools 里的渲染时间线,会更容易判断:当前卡顿是「单次渲染太重」,还是「更新策略不合适」。