前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版

React 18 并发机制深度解析,Fiber 可中断渲染与时间切片原理

首页2024-12-28 17:00:00Front-End
ReactReact 18并发渲染Fiber前端进阶

搜索框里敲一个字,输入框要过半秒才把字显出来;点一下 Tab 切页签,整个界面像被冻住。打开 Chrome DevTools 的 Performance 面板录一段,看到的往往是一根几百毫秒的黄色长条,一个 Task 里塞满了 React 的 render 调用。这时候再去加 memo、加 useMemo 基本没用,因为问题不在渲染了几次,而在于这一次渲染太长,并且中途没有任何人能打断它。

React 18 的并发机制就是冲着这个场景来的。这篇不铺 API 清单,只讲一件事:React 是怎么把一次「一口气跑完」的渲染,改造成可以随时暂停、让出主线程、之后再接着跑的。读完你应该能自己解释清楚 Lane、时间切片、startTransition 三者是怎么串起来的。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • React 18 之前的渲染为什么「不可中断」,卡的到底是哪一步
  • Fiber 架构把递归改成链表遍历,换来了什么
  • Lane 模型用二进制位表示优先级的设计动机和位运算技巧
  • React 为什么放弃 requestIdleCallback,改用 MessageChannel 自己实现调度器
  • 5ms 时间片、shouldYieldToHost 与浏览器一帧的关系
  • startTransition 和 useDeferredValue 在调度行为上的真实差别
  • 并发渲染带来的新坑,以及面试里怎么把这套机制讲明白

面向使用者的那一批新 API 和升级注意事项,比如 createRoot、自动批处理、Suspense SSR、useId、严格模式二次挂载,我拆到了另一篇里讲,见 React 18 新特性与升级指南。这篇只聚焦调度机制本身。

# 一、卡顿卡在哪一步

# 1.1 渲染是一次深度优先遍历

假设我们有这样一棵组件树:

function App() {
  return (
    <div>
      <Header />
      <Sidebar />
      <Content>
        <ComponentA />
        <ComponentB />
      </Content>
      <Footer />
    </div>
  );
}
@前端进阶之旅: 代码已经复制到剪贴板

React 会以 DFS(深度优先搜索)的顺序走完整棵树:

App -> Header -> Sidebar -> Content -> ComponentA -> ComponentB -> Footer
@前端进阶之旅: 代码已经复制到剪贴板

每走到一个组件,React 都会为它创建或复用一个 Fiber Node,把 props、key、ref、lanes 这些渲染需要的信息挂在上面。整棵树对应一张 Fiber 链表结构,这就是从 React 16 开始的 Fiber 架构。

触发这次遍历的场景只有两类。一类是 mount,也就是首次渲染,入口是 ReactDOM.createRoot(document.querySelector('#root')).render(<App />);另一类是 update,由 useState、useReducer 这些 Hook 的 setter 触发。两类走的是同一套协调流程,区别只在于有没有可复用的老 Fiber。

# 1.2 真正的问题是这次遍历停不下来

React 18 之前,这次遍历一旦开始就必须走到底。

后果有三条。渲染开销大的组件(比如几百个列表项)会把主线程占满,用户看到的是页面完全没反应;遍历期间浏览器拿不到主线程,没法处理点击、没法重绘;哪怕这时候来了个明显更紧急的任务,比如用户点了另一个按钮,也只能排队等这一轮渲染跑完。

React 官方文档里有个很直白的例子,把这三条一次性演出来:

function App() {
  const [tab, setTab] = useState('posts');

  return (
    <div>
      <button onClick={() => setTab('posts')}>Posts</button>
      <button onClick={() => setTab('about')}>About</button>
      {tab === 'posts' ? <PostsTab /> : <AboutTab />}
    </div>
  );
}

// PostsTab 包含500个渲染开销大的组件
function PostsTab() {
  return (
    <div>
      {Array(500).fill(0).map((_, i) => (
        <SlowPost key={i} index={i} />
      ))}
    </div>
  );
}

// 每个SlowPost组件渲染需要1ms
function SlowPost({ index }) {
  // 模拟渲染开销
  let startTime = performance.now();
  while (performance.now() - startTime < 1) {}

  return <div>Post #{index}</div>;
}
@前端进阶之旅: 代码已经复制到剪贴板

500 个组件、每个 1ms,这一轮渲染就是 500ms 的同步任务。点 Posts 之后页面明显卡一下,卡住期间点 About 完全没反应,等前一次渲染结束才会一起处理。

我一开始也是这么想的:把 SlowPost 包一层 React.memo 不就好了?没用。memo 解决的是「不该渲染的组件被重渲染」,而这里的 500 个组件是第一次挂载,本来就得渲染一遍。要解决的是「这 500ms 能不能分几次跑」,这是调度问题,不是记忆化问题。

这就是并发机制要处理的核心矛盾。

# 二、Fiber 让什么变成了可中断的

很多人可能没注意到,Fiber 架构本身(React 16)就已经为可中断做好了地基,只是 React 16、17 没把这个能力对外开放。

React 15 的协调是递归的。父组件调子组件,子组件再调孙组件,整个过程压在 JS 调用栈上。调用栈这个东西的特点是,你没法在中间「存档退出」,一旦 return 回去,中间状态就丢了。所以老架构想中断也中断不了。

Fiber 做的事情是把这棵树的遍历从「递归」改写成「循环 + 链表指针」。每个 Fiber 节点上有 return(指向父节点)、child(指向第一个子节点)、sibling(指向下一个兄弟节点)三根指针,遍历过程变成了一个 while 循环,当前进度就是一个全局变量 workInProgress。

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }

  if (workInProgress !== null) {
    // 还有工作没完成,让出主线程
    return true;
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

看这段循环的条件就懂了。跳出 while 之后,workInProgress 还留在原地指着下一个要处理的节点,下次调度进来接着从这里跑就行。进度存在堆上的一个变量里,而不是存在调用栈里,这就是「可中断」的物理基础。

对照 React 15 的同步版本,循环条件里没有 shouldYield(),只有 workInProgress !== null,那就是一路跑到底。

顺着上面聊,还有一个配套设计叫双缓存。React 同时维护 current 树(当前屏幕上的)和 workInProgress 树(正在构建的),中途放弃 workInProgress 树不会影响屏幕,因为屏幕上挂的一直是 current。只有构建完整、进入 commit 阶段的那一刻,才会把 current 指针切过去。所以 render 阶段可以被中断甚至被完全丢弃重来,commit 阶段则必须一次性同步跑完,不然用户会看到画到一半的界面。

记住这条分界线,后面很多行为都能从这里推出来:render 可中断,commit 不可中断。

# 三、Lane 模型,优先级怎么编码

# 3.1 Lane 是什么

有了可中断的能力还不够,得有人告诉 React「什么时候该中断」「先做哪个」。这就是 Lane 模型的职责。

Lane 直译是赛道。React 给每一次更新分配一条或多条赛道,赛道的位置代表优先级高低,然后调度器按优先级决定这一轮先处理哪些更新。

# 3.2 为什么用二进制位

React 用二进制位来表示 Lane,源码里长这样(简化过):

// React 源码中的 Lane 定义(简化)
const Lane = {
  NoLane: 0b0000000000000000000000000000000,
  SyncLane: 0b0000000000000000000000000000001,  // 最高优先级
  InputContinuousLane: 0b0000000000000000000000000000100,
  DefaultLane: 0b0000000000000000000000000010000,
  IdleLane: 0b0000000000000000000001000000000,   // 最低优先级
};
@前端进阶之旅: 代码已经复制到剪贴板

为什么非要用二进制,而不是 0 到 10 的数字?

一个原因是 JS 的位运算跑在 32 位整数上,快且没有内存分配。更关键的原因是,一个 Fiber 上可能同时挂着好几种优先级的更新,用位掩码可以把「一组优先级」压进一个数字里,合并和判断都是一步位运算:

// 合并多个 Lane
export function mergeLanes(a, b) {
  return a | b;  // 位运算 OR
}

// 移除某个 Lane
export function removeLanes(set, subset) {
  return set & ~subset;  // 位运算 AND NOT
}
@前端进阶之旅: 代码已经复制到剪贴板

如果换成数组或者对象来存这一组优先级,每次合并都要遍历、去重、分配新对象,而这套逻辑在一次渲染里会被调用成千上万次。React 还有个叫 getHighestPriorityLane 的函数,实现就是 lanes & -lanes,取出最低位的那个 1,一行搞定「找出这组里最紧急的那条赛道」。这类小技巧是 Lane 用位表示的直接收益。

# 3.3 事件类型决定初始优先级

那一次 setState 的优先级是谁定的?答案是触发它的那个事件。React 在事件系统里维护了一张映射表:

export function getEventPriority(domEventName) {
  switch (domEventName) {
    case 'click':
    case 'input':
    case 'keydown':
      return DiscreteEventPriority;  // 最高优先级
    case 'scroll':
    case 'wheel':
    case 'mouseenter':
      return ContinuousEventPriority; // 中等优先级
    default:
      return DefaultEventPriority;    // 默认优先级
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

优先级顺序是 DiscreteEventPriority 大于 ContinuousEventPriority 大于 DefaultEventPriority。

背后的判断标准挺朴素的。点击、输入、按键这类离散事件,用户预期是「按下去立刻有反应」,慢一帧就能感知到,所以给最高优先级;滚动、滚轮、鼠标移入这类连续事件本来就会高频触发,中间丢几帧影响不大;剩下的网络回调、定时器里的更新,默认优先级就够。

fe
  • 一、卡顿卡在哪一步
    • 1.1 渲染是一次深度优先遍历
    • 1.2 真正的问题是这次遍历停不下来
  • 二、Fiber 让什么变成了可中断的
  • 三、Lane 模型,优先级怎么编码
    • 3.1 Lane 是什么
    • 3.2 为什么用二进制位
    • 3.3 事件类型决定初始优先级
  • 四、时间切片,5ms 是怎么来的
    • 4.1 先看浏览器这一侧的约束
    • 4.2 为什么不用 requestIdleCallback
    • 4.3 调度器的核心循环
    • 4.4 和 requestAnimationFrame 的位置关系
  • 五、startTransition 与 useDeferredValue
    • 5.1 startTransition 做的事
    • 5.2 useDeferredValue 差在哪
    • 5.3 从卡顿到不卡的完整对照
  • 六、并发之后多出来的坑
  • 七、面试里怎么答
    • 7.1 一句话概括
    • 7.2 高频追问
  • 总结
  • 参考

← Zustand 极简 React 状态管理使用指南与实战技巧MobX 核心概念解析与 React 协同最佳实践,以及 2026 年的替代选择 →