《深入浅出react》总结之10. 4 State 更新揭秘

这一节,作者分别介绍了legacy模式和Concurrent模式下的更新流程,首先科普一下 这两种模式。

Legacy 模式和 Concurrent 模式

Legacy 模式是 React 在 18 版本之前使用的传统渲染模式的核心工作机制。这种模式在现代 React 中仍然可用,但正在被更先进的并发模式(Concurrent Mode)所取代。

看下下面的对比,我们今天 主要看下两种模式下 批量更新实现的对比, 那么从何说起呢? 就从 更新入口说起吧

🔄 Legacy 模式 vs 并发模式

| 特性 | Legacy 模式 | 并发模式 (Concurrent Mode) |

|-------------------|--------------------------------|-----------------------------------|

| 渲染方式 | 同步不可中断 | 可中断异步渲染 |

| 任务优先级 | 无优先级概念 | 基于任务优先级调度 |

| 批量更新范围 | 仅 React 事件处理器内 | 所有更新(包括异步) |

| Suspense 支持 | 有限支持 | 完全支持 |

| API 入口 | ReactDOM.render() | ReactDOM.createRoot().render() |

| 阻塞主线程风险 | 高(长列表/复杂组件) | 低(时间切片)

更新入口 scheduleUpdateOnFiber

还记得入口函数 scheduleUpdateOnFiber 吗?不管你是初次渲染还是后续更新,都会从 这个入口开始渲染流程,我们先看下它的大致逻辑

js 复制代码
export function scheduleUpdateOnFiber(fiber,lane,eventTime){
     /* 递归向上标记更新优先级 */
     const root = markUpdateLaneFromFiberToRoot(fiber, lane);
     if (root = = = null) {
         return null;
     }
     if (lane = = = SyncLane) {
         if (
         (executionContext & LegacyUnbatchedContext) !== NoContext && // unbatch 情况,比
        如初始化
         (executionContext & (RenderContext | CommitContext)) === NoContext) {
         /* 开始同步更新,进入 workloop 流程 */ 
             performSyncWorkOnRoot(root);
         } else {
            /* 进入调度,把任务放入调度中 */
             ensureRootIsScheduled(root, eventTime);
             if (executionContext === NoContext) {
                  /* 当前的执行任务类型为 NoContext,说明当前任务是非可控的,会调用 flushSyncCallbackQueue 方法。*/
                 flushSyncCallbackQueue();
             }
         }
     }
}

请问:对于 react state的批量更新,会走哪个分支呢?

回顾10.1我们知道会走 else 分支,因为Legacy模式下(如下面代码), state 的批量更新,会设置 executionContext 为 批量更新上下文,而非 NoContext,所以 会进入else分支

js 复制代码
function batchedEventUpdates(){
     var prevExecutionContext = executionContext;
     executionContext |= EventContext; // 运算赋值 
     try {
         return fn(a); // 执行函数 
     }finally {.
         executionContext = prevExecutionContext; // 重置之前的状态 
         if (executionContext = = = NoContext) {
             flushSyncCallbacksOnlyInLegacyMode() // 同步执行更新队列中的任务 }
         }
    }
}
export function flushSyncCallbacksOnlyInLegacyMode(){
    if(includesLegacySyncCallbacks){ 
        /*只有在legacy模式下,才会走这里的流程。*/
        flushSyncCallbacks();
    }
}

接下来就到了批量更新的关键函数 ensureRootIsScheduled

legacy 模式更新流程

ensureRootIsScheduled

首先看下它的实现:

js 复制代码
function ensureRootIsScheduled(root,currentTime){
     /* 计算一下执行更新的优先级 */
     var newCallbackPriority = returnNextLanesPriority();
     /* 当前 Root 上存在的更新优先级 */
     const existingCallbackPriority = root.callbackPriority;
     /* 如果两者相等,说明是在一次更新中,那么将退出 */
     if(existingCallbackPriority = = = newCallbackPriority){
         return
     }
     if (newCallbackPriority = = = SyncLanePriority) {
         /* 在正常情况下,会直接进入调度任务中。*/
         newCallbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
     }else{
         /* 这里先忽略 */
     }
     /* 将当前 Root 的更新优先级,绑定到最新的优先级 */
     root.callbackPriority = newCallbackPriority;
}

我们来通俗的讲下这个流程:

javascript 复制代码
function handleClick() {
    setCount(1); // 第一次:调度渲染
    setCount(2); // 第二次:优先级相同,跳过调度
    setCount(3); // 第三次:优先级相同,跳过调度
    // 最终只会有一次渲染
}

当第一次调用setState时:

  • 计算newCallbackPriority(假设为优先级X)

  • 此时existingCallbackPriority为NoPriority(初始值)

  • 两者不相等 → 进入调度流程

  • 调度完成后设置 root.callbackPriority = X

  • 当在同一个事件循环中再次调用setState:

  • 计算新的newCallbackPriority(仍然是X,因为相同类型的更新)

  • 此时existingCallbackPriority已经是X

  • 两者相等 → 直接退出,不再调度

结果:同一个事件循环中的多次setState只会触发一次调度(即一次渲染)

sequenceDiagram participant Event as 事件处理器 participant React as React调度系统 participant Render as 渲染 Event->>React: setCount(1) React->>React: 计算优先级P React->>React: 当前优先级? null React->>React: 安排调度 React-->>Render: 计划渲染 Event->>React: setCount(2) React->>React: 计算优先级P React->>React: 当前优先级? P (匹配) React->>React: 跳过调度 Event->>React: setCount(3) React->>React: 计算优先级P React->>React: 当前优先级? P (匹配) React->>React: 跳过调度 Note over React: 事件处理器结束后 Render->>Render: 执行一次渲染

我的小疑问

看到这里的时候我其实有个小疑问

批量更新,跳过调度,那怎么拿到最新的状态呢?

其实,在React中,批量更新并不会跳过状态更新,而是跳过重复的调度任务。所有状态更新都会被收集起来,在同一个调度任务中处理。让我们深入理解这个过程:

  1. 状态更新流程:
  • 当调用setState或useState时,会创建一个更新对象,并添加到对应Fiber节点的更新队列中

  • 触发调度请求(通过ensureRootIsScheduled)

  • 调度系统会安排一个任务来处理所有积累的更新

  1. 跳过调度的含义:
  • 跳过的是重复创建调度任务的过程,不是跳过状态更新

  • 当多次setState时,只有第一次会创建调度任务

  • 后续setState只会将更新添加到队列,而不会创建新任务

  1. 关键代码流程:
sequenceDiagram participant U as 用户代码 participant R as React系统 participant S as 调度器 participant F as Fiber节点 U->>R: 调用setState() R->>F: 创建更新对象,添加到更新队列 R->>R: ensureRootIsScheduled() R->>R: 计算优先级newPriority alt 首次更新 R->>S: 创建调度任务(scheduleSyncCallback) S-->>S: 将任务加入队列 else 后续更新 R->>R: 判断优先级相同,跳过创建新任务 end par 多次调用 U->>R: 再次setState() R->>F: 添加更新到队列 R->>R: ensureRootIsScheduled() R-->>R: 跳过调度 end S->>R: 执行调度任务(performSyncWorkOnRoot) R->>F: 遍历所有待处理更新 F->>F: 计算最新状态 R->>R: 触发re-render

进入调度流程 scheduleSyncCallback

js 复制代码
function scheduleSyncCallback(callback) {
     if (syncQueue = = = null) {
         /* 如果队列为空 */
         syncQueue = [callback];
         /* 放入调度任务 */
         immediateQueueCallbackNode = Scheduler_scheduleCallback(Scheduler_ImmediatePriority, flushSyncCallbackQueueImpl);
     }else{
         /* 如果任务队列不为空,那么将任务放入队列中。*/
         syncQueue.push(callback);
     }
}
  • flushSyncCallbackQueueImpl 会真正执行 callback,本质上就是调和函数 performSyncWorkOnRoot。
  • Scheduler_scheduleCallback 就是在调度章节讲的调度的执行方法,就是通过 MessageChannel 向浏览器 请求下一个空闲帧,在空闲帧中执行更新任务。

scheduleSyncCallback 做的事情如下:

  • 如果执行队列为空,那么把当前任务放入队列中,然后执行调度任务。
  • 如果队列不为空,此时已经在调度中,那么不需要执行调度任务,只需要把当前更新放入队列中即可,调度中心会逐一按照顺序执行更新任务。

讲到这,作者提到了一个问题:

比如在浏览器空闲状态下发生一次 state 更新,那么最后一定会进入调度,等到下一次空闲帧执行吗?

答案是否定的,如果这样,那么就是一种性能的浪费,因为正常情况下,发生更新希望的是在第一次事件循环中执行完更新并且渲染视图,如果在下一次事件循环中执行,那么更新肯定会延时。

React 是如何处理这个情况的呢?

空闲期的同步任务 -- 澄清

虽然没有提供源码,但是作者在这里做出了澄清:

在没有更新任务空闲期的条件下,为了让更新变成同步的,也就是本次更新不在调度中执行, React 对于更新,会用 flushSyncCallbackQueue 立即执行更新队列,发起更新任务,目的就是让任务不延时到下一帧。但是此时调度会正常执行,不过调度中的任务已经被清空。

有的同学可能会产生疑问,既然不让任务进入调度,而选择同步执行任务,那么调度的意义是什么呢?

调度的目的是处理存在多个更新任务的情况,比如发生了短时间内连续的点击事件,每次点击事件都会更新 state,那么对于这种更新并发的情况,第一个任务以同步任务执行,接下来的任务将放入调度,等到调度完成后,在下一个空闲帧时执行。

同步更新流程

我们通过一个例子,总结一下更新流程

js 复制代码
 const [number, setNumber ]= React.useState(0)

 const handleClick = () =>{ /* 同步条件下 */
     setNumber(1)
     setNumber(2)
 }
 return <div>
     {number}
     <button onClick ={handleClick} >点击</button>
     </div>
}

当点击按钮的时候,会触发两次 setNumber,那么这两次 setNumber 都做了些什么呢?

整个流程如下:

  1. 事件上下文:开启事件开关 →进入第一次 setNumber。
  2. 第一次 setNumber 上下文:scheduleUpdateOnFiber → ensureRootIsScheduled → scheduleSyncCallback (放入回调函数 performSyncWorkOnRoot)。
  3. 第二次 setNumber 上下文:scheduleUpdateOnFiber → ensureRootIsScheduled (优先级一致)→ 退出。
  4. 事件上下文:关闭事件开关 → flushSyncCallbackQueue。
  5. flushSyncCallbackQueue →执行回调函数 performSyncWorkOnRoot →进入调和阶段→ renderRoot → commitRoot →浏览器绘制。

异步更新流程

js 复制代码
const handleClick = () =>{
     setTimeout(() => { /* 异步条件下 */
         setNumber(1)
         setNumber(2)
     },0) 
}

还记得 调用 ensureRootIsScheduled之后还有什么逻辑吗?

js 复制代码
if (executionContext === NoContext) {
       /* 当前的执行任务类型为 NoContext,说明当前任务是非可控的,会调用 flushSyncCallbackQueue 方法。*/
       flushSyncCallbackQueue();
}

在 setTimeout 中的更新,变成了异步更新。

事件上下文:开启事件开关 →关闭事件开关 → flushSyncCallbackQueue (此时更新队列为空)。

setTimeout 上下文:执行第一次 setNumber。

第一次 setNumber 上下文:scheduleUpdateOnFiber → ensureRootIsScheduled → scheduleSyncCallback(放 入 回 调 函 数 performSyncWorkOnRoot)→ flushSyncCallbackQueue同步执 行 回 调 函 数 performSyncWorkOnRoot →进入调和阶段→ renderRoot → commitRoot。(我理解此时优先级已经reset

回到 setTimeout 上下文:执行第二次 setNumber。

第二次 setNumber 上下文:scheduleUpdateOnFiber → ensureRootIsScheduled → scheduleSyncCallback(放 入 回 调 函 数 performSyncWorkOnRoot)→ flushSyncCallbackQueue同步执 行 回 调 函 数 performSyncWorkOnRoot →进入调和阶段→ renderRoot → commitRoot。

JS 执行完毕,浏览器进行绘制。这种情况下渲染了两遍。到此为止,legacy 模式下更新流程真相大白了。

Concurrent 模式更新流程

ensureRootIsScheduled

新版 React:

js 复制代码
function ensureRootIsScheduled(root,currentTime){
     var existingCallbackNode = root.callbackNode;
     var newCallbackPriority = getHighestPriorityLane(nextLanes);
     var existingCallbackPriority = root.callbackPriority;
     if (existingCallbackPriority = = = newCallbackPriority &&
     ! (ReactCurrentActQueue.current! = = null && existingCallbackNode !== fakeActCallbackNode)) {
         /* 批量更新退出* */ 
         return;
     }
     /* 同步更新条件下,会走这里的逻辑 */
     if (newCallbackPriority = = = SyncLane) {
         scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
         /* 用微任务去立即执行更新 */
         scheduleMicrotask(flushSyncCallbacks);
     }else{
         var schedulerPriorityLevel
         // ...计算当前的优先级 /* 不是同步的情况下,会走这里 */
         newCallbackNode = scheduleCallback(
         schedulerPriorityLevel,
         performConcurrentWorkOnRoot.bind(null, root),
         );
     }
     /* 这里很重要就是给当前 Root 赋予 callbackPriority 和 callbackNode 状态 */
     root.callbackPriority = newCallbackPriority;
     root.callbackNode = newCallbackNode;
 }

我们再跟旧版react对比一下:

js 复制代码
function ensureRootIsScheduled(root,currentTime){
     /* 计算一下执行更新的优先级 */
     var newCallbackPriority = returnNextLanesPriority();
     /* 当前 Root 上存在的更新优先级 */
     const existingCallbackPriority = root.callbackPriority;
     /* 如果两者相等,说明是在一次更新中,那么将退出 */
     if(existingCallbackPriority = = = newCallbackPriority){
         return
     }
     if (newCallbackPriority = = = SyncLanePriority) {
         /* 在正常情况下,会直接进入调度任务中。*/
         newCallbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
     }else{
         /* 这里先忽略 */
     }
     /* 将当前 Root 的更新优先级,绑定到最新的优先级 */
     root.callbackPriority = newCallbackPriority;
}

其实核心逻辑很像,都是通过更新优先级来判断是否创建新的更新任务

同步步条件下的逻辑

  1. 同步更新流程(当newCallbackPriority === SyncLane时)
  • 第一次调用useState时:

  • 计算newCallbackPriority(此时为SyncLane

  • 由于是首次更新,existingCallbackPriority(即root.callbackPriority)与新的优先级不同

  • 进入同步更新分支:

  • 调用scheduleSyncCallback:将任务(performSyncWorkOnRoot)推入syncQueue队列

  • 调用scheduleMicrotask:安排一个微任务来执行flushSyncCallbacks(该函数会清空syncQueue)-- 注意这里是新旧版的差别哦!!!

  • 更新root.callbackPriorityroot.callbackNode为当前任务的信息

  • 任务将在微任务中执行(即当前JS执行栈清空后立即执行)

  1. 第二次调用useState(同步更新中)
  • 再次进入ensureRootIsScheduled,计算newCallbackPriority(仍为SyncLane

  • 此时existingCallbackPriority(即前一次设置的优先级)等于newCallbackPriority

  • 满足条件:existingCallbackPriority === newCallbackPriority,直接退出函数

  • 结果:第二次更新不会产生新的调度任务,但它的更新会被收集到同一个同步队列中

异步条件下的逻辑

这里注意一点: 在并发模式(ReactDOM.createRoot)下,非可控更新(如setTimeout中的更新)会被赋予默认优先级(不是SyncLane),因此进入else分支,使用scheduleCallback(即Scheduler中的调度方法)进行调度,从而实现并发渲染。

最后放上一张图,你看懂了吗?

新老react的批量更新原理我们基本都讲完了,这节不知道为什么看了很多遍,才看懂一些,我们下一节见吧!

相关推荐
十盒半价4 小时前
React 性能优化秘籍:从渲染顺序到组件粒度
react.js·性能优化·trae
超级土豆粉4 小时前
Taro 位置相关 API 介绍
前端·javascript·react.js·taro
伍哥的传说5 小时前
React & Immer 不可变数据结构的处理
前端·数据结构·react.js·proxy·immutable·immer·redux reducers
zhuà!8 小时前
taro+react重新给userInfo赋值后,获取的用户信息还是老用户信息
javascript·react.js·taro
tianchang8 小时前
React Hook 解析(二):`useEffect` 与 `useLayoutEffect`
前端·react.js
FogLetter11 小时前
TypeScript + React:大型项目的黄金搭档
前端·react.js·typescript
小螺号dididi吹15 小时前
菜鸟速通:React入门 01
前端·react.js·前端框架
裘乡15 小时前
storybook配合vite + react生成组件文档
前端·react.js
轻语呢喃16 小时前
TypeScript:从类型安全到高效开发
react.js·typescript
默默地离开16 小时前
从0到1掌握React+TypeScript开发:前端工程化实践指南
前端·react.js·typescript