深入 react 18 的 「setState」

1,380 阅读23分钟

认识 hook 时代的 「setState」

在以 class component 为主要组件形态的 react 时代,组件实例上的 this.setState() 无疑是最重要的 react API。时过境迁,随着 hook 在 react@16.8 横空出世,我们进入了以 hook 心智模型为主导的「现代 react 」时代。时至 2023 年,react 社区的主流的编程模型已经切换到基于 hook 的函数组件了。

在现代 react 中,最重要的 API 之一毫无疑问就是 useState()(另外一个是 useEffect())。也许你纳闷,你题目讨论的「setState」,那它跟 useState()有什么关系呢?

当然有关系。除了 react 小白之外,稍微有点 react 编程经验的开发者都知道,在(基于 hook 的)函数组件中,setState 只不过是 useState() 调用后返回的其中一个(函数)引用而已。既然称之为函数引用了,所以我们可以给它取一个任意的名字:

function App(){
const [count, setCount] = useState(0)
const [name, setName] = useState('sam')
const [age, setAge] = useState(30)

// ...

return (...)
}

当然,我们也可以生硬地墨守成规,继续称之为setState:

function App(){
const [count, setState1] = useState(0)
const [name, setState2] = useState('sam')
const [age, setState3] = useState(30)

// ...

return (...)
}

我相信没有人会死板到写出上面的代码,因为它完全丢失了该有的语义。其实,稍微深入过源码的人都知道, useState()调用后返回的,我们一般命名为setXxxx函数的真正名字为「dispatchSetState()」。这个函数名不是我起的,有下面源码为证:

代码片段1

// react@18.2.0/packages/react-reconciler/src/ReactFiberHooks.old.js
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  // ......
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
// ... 省略函数体代码
}

综上所述,本文中所讨论的的「setState()」准确来说就是指源码中的「dispatchSetState()」。但是,鉴于它的作用也是跟 class component 时代的 this.setState() 是一样的,所以,本文还是沿用之前的,大家也都习惯的叫法 - setState()来指代dispatchSetState()

setState 是异步/批量的,这句话怎么理解

之所以说 setState() 是 react 中最重要的 API,是因为它就是触发 react 进入界面更新流程的那一个开关。关于这一点,这大家都知道。而伴随着它,还有另外一个说法:「react@18 中,setState() 默认是异步/批量的」。那这句话在 react @18 这个语境下,到底该怎么理解呢?

其实在 react@18 之前,在特定场景下,setState() 的表现也是异步/批量的。可以查阅我以前写的文章《深入react的setState机制》

相信大家都有相同的思维直觉:「如果能在 setState() 后面读取到组件的最新状态,那么我们就说setState() 是同步执行,否则就是异步」。

基于上面的推断逻辑,你很快写下了用于验证「setState() 是异步/批量的」的代码:

异步

function App(){
console.log('start App render')
const [count, setCount] = useState(0)

useEffect(()=>{
    console.log('count3: ', count);
},[count])

return (
    <button onClick={()={
      setCount(c=> c + 1);
      console.log('count1: ', count);
      setCount(c=> c + 2);
      console.log('count2: ', count);
    }}>
    {count}
   </button>
 )
}

当你看到下面的打印结果之后:

1. count1: 0
2. count2: 0
3. start App render
2. count3: 3

嗯,你就点点头,心里想:“是的,react@18 中,setState() 默认是异步执行的”。因为什么?因为我们在 setState() 调用之后无法立即获取到最新的 state 值。

异步特性表现验证完之后,你就尝试验证 setState() 的同步特性表现。下面是你用于验证的代码:

function App(){
console.log('start App render')
const [count, setCount] = useState(0)

return (
    <button onClick={()={
      flushSync(()=>{
          setCount(c=> c + 1);
      })
      console.log('count1: ', count);
      flushSync(()=>{
          setCount(c=> c + 2);
      })
      console.log('count2: ', count);
    }}>
    {count}
   </button>
 )
}

打印结果如下:

1. start App render
2. count1: 0
3. start App render
4. count2: 0

这个时候你可能就会十分疑惑。从打印顺序来看,使用 flushSync() 包裹之后的 setState() 是同步执行的。为什么这么说呢?因为在这里 start App render 所代表的是 react 的界面更新流程(界面要想更新,组件必须要「渲染」。而组件的渲染就是等同于「调用组件函数」)。也就是说,上面的打印可以翻译为:

1. 更新界面完成
2. count1: 0
3. 更新界面完成
4. count2: 0

但是奇怪的是,为什么我们无法打印出最新的 state 呢?这是因为合成事件的事件处理器的调用是发生在当前的渲染周期中,如此编写代码,实际上产生了「closure stale」的现象。 也就是说不能这么验证。

上面所提到的「如果能在 setState() 后面读取到组件的最新状态,那么我们就说setState() 是同步执行,否则就是异步」这个推断逻辑是没错的。但是,如果想要在同步执行下去访问最新的状态值,恐怕不能像上面那样验证,可以尝试像下面那样:

function App(){
console.log('start App render')
let syncUpdatedCount
const [count, setCount] = useState(0)

return (
    <button onClick={()={
      flushSync(()=>{
          setCount(c=> {
            syncUpdatedCount = c + 1
            return syncUpdatedCount
          });
      })
      console.log('count1: ', count);
      flushSync(()=>{
          setCount(c=> {
            syncUpdatedCount = c + 2
            return syncUpdatedCount
          });
      })
      console.log('count2: ', count);
    }}>
    {count}
   </button>
 )
}

接下来,就如我们所愿,看到了期待的代码同步执行的打印结果:

打印结果如下:

1. start App render
2. count1: 1
3. start App render
4. count2: 3

还有没有其他办法呢?有。有一种 hack 一点的验证方法:

function App(){
console.log('start App render')
let syncUpdatedCount
const [count, setCount] = useState(0)

return (
    <button onClick={()={
      flushSync(()=>{
          setCount(c=> c + 1);
      })
      // window.ReactDOMRoot._internalRoot 访问到的是 fiberRootNode
      // window.ReactDOMRoot._internalRoot.current 访问到的是 hostRootFiber
      // window.ReactDOMRoot._internalRoot.current.child 访问的就是 <App /> 所对应的 fiber 节点
     // window.ReactDOMRoot._internalRoot.current.child.memoizedState 访问到的是<App /> 所对应的 fiber 节点的第一个 hook 对象, 即 useState() 所对应的那个 hook 对象
     // window.ReactDOMRoot._internalRoot.current.child.memoizedState.memoizedState 访问到的就是 `count` 状态值
      console.log('count1: ', window.ReactDOMRoot._internalRoot.current.child.memoizedState.memoizedState);
      flushSync(()=>{
          setCount(c=> c + 2);
      })
      console.log('count2: ', window.ReactDOMRoot._internalRoot.current.child.memoizedState.memoizedState);
    }}>
    {count}
   </button>
 )
};

const root = (window.ReactDOMRoot = ReactDOM.createRoot(
  document.getElementById("root")
));
root.render(<App />);

打印结果如下跟前面一样。通过直接访问 fiber 节点上的数据,我们可以直观地看到 <App /> 组件上的状态值确实已经更新了。

其实,关于如何理解 setState() 同步执行的含义官方也给出了确切的指引

flushSync lets you force React to flush any updates inside the provided callback synchronously. This ensures that the DOM is updated immediately.

从官方的话术来说,「setState() 同步执行」的含义是指:setState() 之后的代码能够马上访问到最新的 DOM。

于是乎,二话不说,来验证一下:

function App(){
console.log('start App render')
const [count, setCount] = useState(0)
const btnRef = useRef(null)

return (
    <button ref={btnRef} onClick={()={
      flushSync(()=>{
          setCount(c=>  c + 1);
      })
      console.log('button text1: ', btnRef.current.innerText);
      flushSync(()=>{
          setCount(c=> c + 2);
      })
      console.log('button text2: ', btnRef.current.innerText);
    }}>
    {count}
   </button>
 )
}

打印结果如下:

1. start App render
2. button text1: 1
3. start App render
4. button text2: 3

如此「同步」的行为表现,react 官方诚不欺我也。

小结

综上所述:

在 react@18 中,「setState() 默认是异步/批量执行的」应该是这样理解的:setState()的「异步执行」意味着在 setState()后面的代码是在新一轮界面更新流程(render -> commit)之前执行的。在后面的代码中无法访问到最新的组件状态和最新的 DOM 数据;「批量执行」意味着连续的多个setState()调用只会被压缩成一个setState()调用,最终只会触发一次的界面更新流程(在不考虑更新优先级的情况下)。 用图表示如下:

graph TD
A["异步的 setState()调用"] --> 马上执行其后的代码 --> 最后才执行界面更新流程

在 react@18 中,通过 flushSync() 这个 API 我们可以实现界面更新的同步/非批量执行。setState()的「同步执行」意味着 setState()调用后面的代码是真真正正在新一轮界面更新流程之后执行的。在后面的代码中,我们可以访问到最新的组件状态和最新的 DOM 数据;「非批量执行」意味着有多少个flushSync()调用就会产生多少次界面更新流程。

graph TD
A["同步的 setState()调用"] --> 马上执行界面更新流程 --> 最后执行其后的代码

为什么要把 setState 实现为异步/批量的呢?

这个问题可以换个相反的角度来问:“如果 react 把 setState()实现为同步/非批量的,结果会是怎样?”

结果就是有多少个flushSync()调用就会产生多少次界面更新流程。而一次界面更新流程最大的工作量就是遍历组件树的所有组件,依次渲染它们。组件树小的话,频繁地重刷整个组件树,性能上也许没什么损耗。但是如果组件树巨大(组件树特别胖,特别深)的话,那么频繁地重刷整个组件树就到导致 js 引擎的 event loop 的 call stack 会被长时间被占用,阻塞了其他代码的执行。

从 UI 进程这边看,这种行为的后果就是大量的事件处理器被累积到了 micro task 队列,但是由于 call stack 没有被清空,所以事件处理器没法及时被执行;从用户的角度来看,这种行为的后果就是在 react 进行渲染的时候,界面上会出现卡顿,甚至是假死的现象。

毫无疑问,这种界面更新性能和用户体验都是无法接受的。所以,为了提高这两方面的表现,react 必须把 setState() 实现为异步/批量执行的。通过把连续的多个 setState() 请求压缩为一个 setState() 请求,我们降低了渲染的频率;通过异步地去开启界面更新流程,我们适度地把程序的控制权让渡出去。这两种做法都是为了尽可能地不要阻塞 js 线程,以免别的代码没有时间执行而导致很差的用户体验。react 后面加进来的并发特性(或者称之为「time slice」)也是为了定期让渡 js 线程的控制权,从而避免上面所提的「界面上会出现卡顿,甚至是假死的现象」。

总结一句话:react 把 setState 实现为异步/批量执行的原因是「为了提高界面更新性能和用户体验」。

react 是如何实现 setState 的异步/批量特性的呢?

在研究这个问题的答案之前,我们需要梳理一下 setState 的生命流程,看看它是从哪里来,又是去向何处,以便从中找到问题的答案。

setState 生命流程

创建

本文开始就蜻蜓点水地提到了, 我们日常命名为的setXxxx 的变量只不过是内部源码中函数 dispatchSetState() 的引用。在本小节中,我们再从源码中具体讲一讲这里面的门道。

所有的 hook 函数都拥有两个阶段:「mount 阶段」和「update 阶段」。这句话,在我的 react 源码系列的文章中已经被重复讲了无数遍。假设我们有下面的代码:

function App(){
    const [count, setCount] = useState(0);
    
    retturn (...)
}

useState(0) 这个 hook 函数会随着 <App /> 组件的不断重复地渲染而调用。useState(0) 的第一次调用就是调用它的 mount 阶段的版本 - mountState()。它的源码在代码片段1中已经给出了。深入分析源码,我们可以得知,mountState()的函数实现就做了三件事:

  1. 创建 hook 对象;
  2. 创建 queue 对象(在该对象里面,包含了一个跟本文密切相关的数据结构 - update queue。它是一个单向循环链表。用于存放 update 对象);
  3. 创建 dispatchSetState() 函数的新实例。

从源码看出,react 是通过bind() 方法来创建dispatchSetState() 函数的新实例,与此同时还闭包了两个变量:

  • currentlyRenderingFiber - 它是一个全局变量。当 react 进入 render 阶段后,它始终指向当前正在 begin work 的那个 fiber 节点。换句话来说,我们的 dispatchSetState() 是通过闭包的技术关联到正确的 fiber (当前组件所对应的那个 fiber 节点)的。
  • queue - 就是上一步刚刚创建的 queue 对象。

借助闭包技术,日后,我们在 dispatchSetState() 内部总是能访问到这个两个变量的最新值。

传递

dispatchSetState() 的传递发生在 useState() 的 update 阶段。useState() 在 update 阶段 的函数版本是 updateState。而 updateState() 本质上就是内置了一个叫做 basicStateReducer reducer 的 updateReducer()updateReducer()的源码就不全部贴出来了,我们只是把传递 dispatchSetState() 引用的部分贴出来:

 function updateReducer(reducer, initialArg, init) {
    const hook = updateWorkInProgressHook();
    const queue = hook.queue;

    if (queue === null) {
      throw Error(formatProdErrorMessage(311));
    }

    // ...省略了中间的代码

    const dispatch = queue.dispatch;
    return [hook.memoizedState, dispatch];
  }

我在 《【react】react hook运行原理解析》指出过,updateWorkInProgressHook() 要做的事情就是在旧 hook 链表上找到当前 hook 函数对应的 hook 对象,然后对它进行「浅拷贝」。都说是「浅拷贝」,这就意味着 hook 对象的 queue 是「按引用传递」的。所以,updateReducer() 函数实现中的 queue 变量指向的就是 useState() 在 mount 阶段所创建的 queue 对象。

const dispatch = queue.dispatch;
return [hook.memoizedState, dispatch];

从上面的代码中,我们也可以看出,dispatch 其实就是我们在 mount 阶段通过 bind()所创建的 dispatchSetState() 函数的新实例。该函数实例一路「按照引用传递」下来,最终暴露给开发者来使用。

综上所述,setState() 在 mount 阶段和 update 阶段都是指向同一个 dispatchSetState 函数实例引用。该函数实例引用是react 在 mount 阶段通过 bind() 这个方法来创建的。借助 bind() ,react 还往 setState() 函数作用域中内置两个变量:

  • 全局变量 currentlyRenderingFiber - setState()所在的组件所关联的 fiber 节点;
  • 局部变量 queue - 跟 useState hook 对象一一对应的那个 queue 对象,最终挂载在 hook 对象上。

调用

setState() 的调用无疑是发生在 hook 的 update 阶段。我们可以在 effect 类的 hook 函数或者合成事件的事件处理器中去消费它。我们把 dispatchSetState 函数的源码中的 fast path 和优先级相关的代码去掉,简化版的 dispatchSetState 函数如下:

  function dispatchSetState(fiber, queue, action) {
    const update = {
      lane: 0,
      action,
      hasEagerState: false,
      eagerState: null,
      next: null,
    };

    if (isRenderPhaseUpdate(fiber)) {
      enqueueRenderPhaseUpdate(queue, update);
    } else {
      // ...省略了 fast path 的代码
    
      const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);

      if (root !== null) {
       // ...
       scheduleUpdateOnFiber(root, fiber, lane, eventTime);
       // ...
      }
    }
  }

通过上面的源码,我们可以清楚地看到,当我们调用 setState() 之后,实际上发生了下面几件事情:

  1. 创建全新的 update 对象;
  2. 把创建的 update 对象入队到对应的 update queue 中;
  3. 调度更新请求。

创建全新的 update 对象

一次setState() 调用必定会产生一个全新的 update 对象。这是雷打不动的。就本文的主题而言,我们只需要关注两个属性即可:

  • action 属性
  • next 属性

大家都知道在 hook 时代,传入 setState() 的实参有两种类型,一种是函数类型,一种是其他的非函数类型。在 react 内部源码中,它们都被叫做 action。这正好呼应了 setState() 的实际名字 dispatchSetState()actiondispatch 这些都是 redux 推广开来的函数式编程概念。熟悉 redux 的人想必并不陌生。action 有什么用?其实它才是 update 对象的核心数据。它是用于组件所有 hook 对象的下一个状态的计算。组件状态的计算逻辑并不属于本文的主题,故略过不谈。

轮到 next 属性了。react 源码内部大量用到了链表这种数据结构。如果一个对象是有一个叫做next 属性的,那很大程度上它就是身处在一个链表上。在这里也不例外。所有的 update 对象就是通过 next 属性进行连接的,最终形成一条叫做 update queue 的链表。

把创建的 update 对象入队到对应的 update queue 中

眼尖的读者可能都看到的,在把 udpate 对象入队到 update queue 可以分为两种情况:

  • render 阶段的更新
  • 非 render 阶段的更新

那何为「render 阶段的更新」呢?其他它就是指在组件函数顶级作用域中调用setState()所触发的更新。比如这样:

function App(){
    const [count, setCount] = useState(0);
    
    if(count === 1){
        setCount(2);
    }
    
    retturn (...)
}

也许你会很惊讶,这么干也行?是的,可以的。只要你有一个条件防守来防止组件函数陷入无限循环调用即可。

那何为「非 render 阶段的更新」呢?这个主要是指我们在 effect 类 hook 函数或者合成事件处理器中所发起的更新请求:

function App(){
    const [count, setCount] = useState(0);
    
    useEffect(()=>{
      if(count === 1){
          setCount(2);
       }
    },[count])
    
    retturn (
    <button onClick={()=>{ setCount(c=> c+1)}}>
      {count}
    <button />
    )
}

虽然两种情况最终都会导致特定于某个 setState() 的所有 update 对象按照它们的调用顺序来入队到 update queue 里面,但是两者还是有明显的差别。

render 阶段的更新

对于「render 阶段的更新」,react 会把更新请求的 update 对象按照 setState()的调用顺序推入到对应的 update queue里面。

function enqueueRenderPhaseUpdate(queue, update) {
    // ...
    const pending = queue.pending;

    if (pending === null) {
      // This is the first update. Create a circular list.
      update.next = update;
    } else {
      // pending.next 永远是指向第一个入队的 update
      // 最后一个入队的 update 的 next永远指向第一个入队的 udpate
      update.next = pending.next;
      // 此时 pending 已经不应该是最后入队的 update,所以要修正它的 next 指向
      pending.next = update;
    }

    // queue.pending 永远是指向最后一个入队的 update 
    queue.pending = update;
  } 

上面的代码已经写得很明确了。update queue 就是一个单向循环链表。链表中 update 对象的顺序跟setState()的调用顺序是一致的。然后,尾首相连。最后,让 pending 这个头指针永远指向最后一个入队的 update。

假如我们以下代码:

function App(){
    const [count, setCount] = useState(0);
      
    retturn (
        <button onClick={()=>{ 
            setCount(c=> c+1)
            setCount(c=> c+2)
            setCount(c=> c+3)
         }}>
          {count}
        <button />
    )
}

那么,setCount 所关联的 update queue(update 对象省略了其他属性) 应该长这样的:

graph LR
A["{action: c=> c +1}"] --> B["{action: c=> c +2}"] --> C["{action: c=> c +3}"] --> A
非 render 阶段的更新

而对于「非 render 阶段的更新」,update 对象通过 enqueueUpdate() 函数被暂存在一个叫做 concurrentQueues 的全局变量中。react 每次在进入界面更新流程之前,会调用一个叫做 finishQueueingConcurrentUpdates() 的函数。在这个函数里面,会把所有暂存在 concurrentQueues 的 update 对象都会被入队到所对应的 update queue 里面。

  function finishQueueingConcurrentUpdates() {
    // ...

    while (i < endIndex) {
       // ...

      if (queue !== null && update !== null) {
        const pending = queue.pending;

        if (pending === null) {
          // This is the first update. Create a circular list.
          update.next = update;
        } else {
          update.next = pending.next;
          pending.next = update;
        }

        queue.pending = update;
      }

     // ...
    }
  }

可以看出,finishQueueingConcurrentUpdates() 函数入队 update 对象的逻辑跟 enqueueRenderPhaseUpdate() 函数才采用的逻辑是一模一样的。没错,是一模一样。

小结

无论哪种代码路径,我们连续多次调用 setState() 所产生的 udpate 对象都会在 react 开始计算组件新状态之前按照调用顺序入队在所对应的 update queue,以备状态计算时所用。

调度更新请求

负责调度的函数叫做 scheduleUpdateOnFiber()。而该函数核心调用的是 ensureRootIsScheduled() 函数。ensureRootIsScheduled() 才是重点。它简化后的源码如下:

简化版的 ensureRootIsScheduled()

  function ensureRootIsScheduled(root, currentTime) {
     // ...

    const nextLanes = getNextLanes(
      root,
      root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes
    );

    // ....

    const newCallbackPriority = getHighestPriorityLane(nextLanes); // Check if there's an existing task. We may be able to reuse it.

    const existingCallbackPriority = root.callbackPriority;

    if (existingCallbackPriority === newCallbackPriority) {
      return;
    }

    // ...

    if (newCallbackPriority === SyncLane) {
     // ...
     scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));

      {
        // Flush the queue in a microtask.
        {
          scheduleMicrotask(() => {
            // 针对 safari 浏览器上的特别行为添加了一个防守条件
            // In Safari, appending an iframe forces microtasks to run.
            // https://github.com/facebook/react/issues/22459
            // We don't support running callbacks in the middle of render
            // or commit so we need to check against that.
            if (
              (executionContext & (RenderContext | CommitContext)) ===
              NoContext
            ) {
              // Note that this would still prematurely flush the callbacks
              // if this happens outside render or commit phase (e.g. in an event).
              flushSyncCallbacks();
            }
          });
        }
      }

      newCallbackNode = null;
    } else {
      // 省略并发任务的调度代码
    }

    root.callbackPriority = newCallbackPriority;
    root.callbackNode = newCallbackNode;
  }

因为我们当前暂时不关注并发情况,只讨论同步更新的情况。所以,源码可以暂时简化为以上的代码。眼尖的读者一看看到上面的代码中的重点:

 scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
 scheduleMicrotask(() => {
    // 针对 safari 浏览器上的特别行为添加了一个防守条件
    // In Safari, appending an iframe forces microtasks to run.
    // https://github.com/facebook/react/issues/22459
    // We don't support running callbacks in the middle of render
    // or commit so we need to check against that.
    if (
      (executionContext & (RenderContext | CommitContext)) ===
      NoContext
    ) {
      // Note that this would still prematurely flush the callbacks
      // if this happens outside render or commit phase (e.g. in an event).
      flushSyncCallbacks();
    }
  });

就像源码中注释所讲的那样,那个防守条件主要为了兼容 react 在 safari 浏览器下的某个特殊的场景所出现的bug(往 document 中 append 一个 iframe 元素会导致 event loop 中的所有 microtask 都会被执行),其实我们还可以精简一下上面的代码:

 scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
 scheduleMicrotask(() => {
    flushSyncCallbacks();
  });

到这里,我们终于跟「调度」扯上边了。scheduleSyncCallback() 的实现也很简单:

 let syncQueue = null;
  function scheduleSyncCallback(callback) {
    // Push this callback into an internal queue. We'll flush these either in
    // the next tick, or earlier if something calls `flushSyncCallbackQueue`.
    if (syncQueue === null) {
      syncQueue = [callback];
    } else {
      // Push onto existing queue. Don't need to schedule a callback because
      // we already scheduled one when we created the queue.
      syncQueue.push(callback);
    }
  }

它就负责把 performSyncWorkOnRoot.bind(null, root) 所产生的函数实例,这里也可以称之为「callback」放到 syncQueue 这么一个全局的数组中。

重点是 scheduleMicrotask() 函数的实现:

const scheduleMicrotask =
    typeof queueMicrotask === "function"
      ? queueMicrotask
      : typeof localPromise !== "undefined"
      ? (callback) =>
          localPromise.resolve(null).then(callback).catch(handleErrorInNextTick)
      : scheduleTimeout; // TODO: Determine the best fallback here.

可能有部分人没用过 queueMicrotask() 这个 API,有空可以自己去 MDN 上看看。它是浏览器自己实现的一个 web API, 用于显式地向 event loop 的微任务队列中入队一个 micortask。

不了解 event loop?可以查看我的文章《深入Event Loop》

上面的这段代码的意思是:优先考虑使用 queueMicrotask(), 其次再考虑使用 promise.then(), 最后才考虑 setTimeout() 来调度 flushSyncCallbacks 函数。flushSyncCallbacks() 负责遍历上面提到的 syncQueue 数组,以同步的方式去一个一个地调用里面的 callback。一般情况下,这些 callback 就是指 performSyncWorkOnRoot()

到这里,我们就看到了「调度」的真容了 - 通过异步代码调度 API(queueMicrotask()/promise.then()/setTimeout()) 把界面更新流程的入口函数 (比如:同步更新流程的入口函数 performSyncWorkOnRoot()或者并发更新流程的入口函数 performConcurrentWorkOnRoot)的执行延迟到 event loop 的 next-tick 来执行。

上面简化版的 ensureRootIsScheduled() 的代码中还有一个重点,那就是这个防守条件:

  if (existingCallbackPriority === newCallbackPriority) {
      return;
  }{
  // .....
  }

这个防守条件呼应了 ensureRootIsScheduled() 函数名所蕴含的语义 - 也就是说,这个函数会确保来自于相同更新场景下的多个更新请求只会产生一次调度。何谓「自于相同更新场景下的多个更新请求」呢?

function App(){
    const [count, setCount] = useState(0);
      
    retturn (
        <button onClick={()=>{ 
            setCount(c=> c+1)
            setCount(c=> c+2)
            setCount(c=> c+3)
         }}>
          {count}
        <button />
    )
}

上面的代码中,我们在 button 的点击事件处理器连续调用了三次 setCount(),那这三个更新请求因为都是来自于合成事件处理器中,所有都是具备相同的优先级的。这个三次 setCount() 调用都会进入 ensureRootIsScheduled() 函数。由于 react 检测到第二次和第三次的更新请求的优先级都是跟第一次是一样的,所以,代码没有执行到更新调度那里就半道返回了。

小结

经过上面对 setState 生命流程的梳理,我们现在可以回答本节的主题问题了:“react 是如何实现 setState 的异步/批量特性的呢?”。 从更加严谨的角度,这个问题的表述是有问题的。因为其实,setState() 这个方法是本身就是同步执行的,跟异步扯不上边。我们所说的「异步」或者「批量」其实针对的是 setState() 所携带的语义 - 「界面更新」,所以这个问题应该严谨表述为:“react 是如何实现界面的异步/批量更新的?”。修正后的问题又可以拆分为两个小问题:

  1. react 是如何实现界面的异步更新?
  2. react 是如何实现界面的批量更新?

实现界面异步更新的原理

我们开发者对 setState() 这个 API 在语义上的期待是这样的:“请马上给我更新界面”。但是实际上,react 在我们的背后做了一些「调度」的动作。用代码的语言来说,react 通过异步代码调度 API(queueMicrotask()/promise.then()/setTimeout()) 把界面更新流程的入口函数的执行延迟到 event loop 的 next-tick 来执行。这就使得我们所请求的界面更新流程一定会在当前同步代码执行完之后才开始。这就是 react 实现界面异步更新的原理。

实现界面批量更新的原理

同一个场景下,连续的多次 setState() 的调用最终只会触发一次的界面更新流程。这就是所谓的「批量更新」。react 通过组合三个步骤来实现批量更新:

  1. 把连续的多次 setState() 的调用所产生的 update 对象按照产生的顺序放入一个循环的单向链表中 - update queue
  2. react 认定来自于相同场景的更新请求拥有相同的更新优先级,通过判断是否是相同的优先级来确保多次连续的更新请求只会触发一次的更新调度;
  3. 进入 render 阶段后,react 会使用 reduce 算法把 update queue 中的所有的 action 「压缩」成一个最终的 hook 状态值。

基于上面的三个步骤,react 保证了连续的多次 setState() 的调用只会触发一次的界面更新流程。

更准确的表述是:「同一个 event loop tick(或者说同处于一个 call stack)」的连续的多次 setState() 的调用只会触发一次的界面更新流程。大家可以有空思考一下下面的代码会导致几次界面更新流程:

function App(){
    const [count, setCount] = useState(0);
      
    retturn (
        <button onClick={async ()=>{ 
            setCount(1)
            setCount(2)
            await fetch('https://fake.api.com/getData')
            setCount(3)
            setCount(4)
         }}>
          {count}
        <button />
    )
}

其实上面的代码可以等同于:

function App(){
    const [count, setCount] = useState(0);
      
    retturn (
        <button onClick={async ()=>{ 
            setCount(1)
            setCount(2)
            fetch('https://fake.api.com/getData').json().then(()=>{
                 setCount(3)
                 setCount(4)
            })
         }}>
          {count}
        <button />
    )
}

如此一来,我们就清晰地看到两个 event loop tick:

  • setCount(1); setCount(2) 处在同一个 event loop tick 里面;
  • setCount(3); setCount(4) 处在同一个 event loop tick 里面;

所以,上面这道思考题的答案是:“两次”。

总结

大概是三年前(2020-03-08),我针对同样的主题发表了一篇文章《深入react的setState机制》。只不过,这篇文章研究的是 react@0.8.0。真的是时光荏苒,如今 react 的最新版本已经是 18.3.0。尽管版本迭代了很多,但是 react 将setState的语义(界面更新)实现为「异步/批量执行」,这一点未曾改变过。不同点是:

  • 以前是,只有在特定场景下(比如合成事件处理器中)触发的界面更新才会实现为「异步/批量执行」;
  • 现在是, react@18 之后,所有的场景下触发的界面更新都是实现为「异步/批量执行」。

这背后是什么东西在引导着 react core team 来保持这种不变呢?那就是 react 的设计原则中提到的调度(Scheduling)思想。

在「调度」这一小节中,react 官方表述得很清楚了。他们言下之意就是:“用户只要负责声明组件,表达想要界面更新的意图就好,至于什么时候调用你的组件函数,什么时候真正地去更新界面由我 react 来决定,你们开发者放心吧”。这就是「调度」的浅白的含义。从不好的角度来看,其实我们开发者是在某种程度上丧失了程序执行的主动控制权的。

setState 的实现原理来看,setXxxx 这种叫做不太贴切,硬是这么叫其实是会误导初学者的。它其实应该叫 requestSetXxxx。为什么啊?因为我们调用 setXxxx 充其量只能算是我们向 react 发出一个界面更新的请求而已。react 并不是马上就能触发一次界面更新。类似的观点,我早就在 《深入react的setState机制》这篇文章提过了。兜兜转转,三年多过去了。但是我的这个观点还是做到历久弥新,真的是让我欣慰啊。

记住,setXxxx 应该叫做 requestSetXxxx。你一旦这么理解了。那么每一次你尝试理解它背后的原理的时候,你就会从「请求」联想到「调度」,从而获取到它背后原理的核心思想。

最后,我引用 react 官方的一段话来结束文本吧:

团队内部有一个笑话,认为 React 应该被称为“Schedule”,因为 React 不想完全实现为“响应式”。

客官,你品,你细品,你细细地品!