深入理解React18中的批处理

2,086 阅读13分钟

引言

批处理在react中并不是新奇的事物,早在react 17中就存在,可以合并多次更新为一次,带来了更好的性能体验。像这样,点击元素只会带来App函数一次执行:

export default function App() {
  const [count, setCount] = useState(0)

  console.log('render!')
  function plus() {
    setCount(count + 1)
    setCount(count + 2)
  }

  return (
    <div style={{ textAlign: 'center', fontSize: '42px', marginTop: '100px' }}>
      <p>你可以点击数字</p>
      <span onClick={plus} onMouseEnter={plus}>
	{count}
      </span>
    </div>
  )
}

点击查看在线示例

但与react 17只能在合成事件中实现批处理不同的是,react 18提供了更为强大的自动批处理机制,使得在setTimeout,Promise,原生事件等其他场景下的更新也能受益。实际上,只要是通过react调度的更新,都能有这样的效果。

这篇文章将澄清批处理中的一些事实,介绍批处理的实现原理(包含了不太复杂的源码),希望能够给读者带来对批处理的清晰的认识。另外示例与源码都是基于react 18.2.0。在线示例在codeSandbox上,如果需要在本地调试示例,请注意关闭react严格模式(严格模式导致组件渲染两次)。

什么是批

当谈论批处理的时候,也许需要先了解下是什么,当然这是就react 18而言。我们也许可以用三个特点来概括批(这不是官方的定义):

  • 包括了多个更新
  • 每个更新具有相同的优先级
  • 每个更新都是待执行

三者需要同时具备。接下来我将进一步说明这些特点。

更新

更新这个词实际上是相当含混的,对于hook有更新队列,对于react也有相应的更新(通常伴随着函数组件render),当然对浏览器还存在页面视图的更新。当我们调用dispatch或者setState时,上述三种更新都是有涉及的。但是要特别指出的是,批中的更新就是指react的更新(你可以用自己习惯的词命名),包含了render,commit阶段等。在后续的批处理部分你将看到三者的差异。

如果我们看dispatch和setState的源码,会发现它们主要做了两件事:

  1. 记录一次hook更新(enqueueConcurrentHookUpdate
  2. 调度一次react更新(scheduleUpdateOnFiber

批中的更新就是指scheduleUpdateOnFiber。

// 以下是dispatchReducerAction中同样包含的逻辑
// 这个函数中fiber和queue都是提前bind好的,我们调用setState时传入的是action
function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  // ...
  const lane = requestUpdateLane(fiber);
  const update: Update<S, A> = {
    lane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

  if (isRenderPhaseUpdate(fiber)) {
    // ...异常情形
  } else {
    // ...
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    // 首次渲染后root !== null
    if (root !== null) {
      const eventTime = requestEventTime();
      scheduleUpdateOnFiber(root, fiber, lane, eventTime);
      // ...
    }
  }

  // ...
}

相同的优先级

更新部分的相关源码示例中,你可以看到lane这个字段,它表示的就是这次更新的优先级。只有优先级相同的多个更新才在一个批中,与之相应的就是这些更新被批处理。反之则不然。

优先级是react并发特性中重要的概念,但是并发和优先级并不是本文的主题,所以这里不会花太多时间讨论。一般而言,如果优先级没有被手动改变,那么相同场景下多次调用setState或者dispatch对应的更新优先级是相同的。

例外的情况是具有一整个序列而非单一的优先级,像TransitionLanes和RetryLanes。以TransitionLanes为例,它们包含了许多个优先级并不相同并且依次排列的lane,但是在render场景下,这些lane是一起被处理的。

像下面这样的示例中的更新是不会被视为同一批的,startTransition改变了第二个更新的优先级:

setCount(count + 1)
startTransition(() => setCount(count + 2)) // startTransition引自react

点击查看在线示例,可以编辑调试

待执行

这里指的是已经调度但还未被执行。通常执行相对于调度而言是异步的。假如两个更新具有相同的优先级,那么:

  • 只要一个已执行,另一个未执行,无法批处理
  • 只要都未执行,就能批处理(一些异步场景可能带来迷惑性)

对于第一点,当我们手动调用同步执行更新的api时,后续的更新就无法与同步的更新成批,在下面的示例中,你会发现点击将带来两次render。

export default function App() {
  const [count, setCount] = useState(0)

  console.log('render!')
  function plus() {
    flushSync(() => {
      setCount(count + 1)
    })
    setCount(count + 2)
    
    // 然而,这样做是可以批处理的
    // flushSync(() => {
    //   setCount(count + 1)
    //   setCount(count + 2)
    // })
  }
  
  return (
    <div style={{ textAlign: 'center', fontSize: '42px', marginTop: '100px' }}>
      <p>你可以点击数字</p>
      <span onClick={plus} onMouseEnter={plus}>
	{count}
      </span>
    </div>
  )
}

点击查看在线示例,可以编辑调试

flushSync可以使更新同步地被执行,这样一来,第二个setCount带来的更新与第一个setCount的更新无法被批处理,因为setCount(count + 2)调用时,第一个更新已经执行完了。

对于第二点,考虑到js事件循环带来的复杂异步特性,在一些让人意想不到的场景也能批处理,下面是一个有趣的示例。

export default function App() {
  const [count, setCount] = useState(0)

  console.log('render!')
  function plus() {
    setCount(count + 1)
    Promise.resolve().then(() => {
      setCount(count + 2)
    })
  }
  
  return (
    <div style={{ textAlign: 'center', fontSize: '42px', marginTop: '100px' }}>
      <p>划入render一次,点击render两次</p>
      <span onClick={plus} onMouseEnter={plus}>
	{count}
      </span>
    </div>
  )
}

点击查看在线示例

就示例而论,在mouseEnter事件中react似乎将两次更新视为同一批,而在click事件中却不是这么处理的。

这个示例可能让你感到困惑,不同的结果与react的调度方式有关,但即使不理解背后的原因也不影响你理解批处理本身。做一个简要解释:click事件对应的更新优先级是被调度在微任务中的,而mouseEnter事件则是另一类。

批处理

对于多个未执行但是已经调度的react更新,如果它们具有相同的优先级,只会有一次更新会被执行,通常涉及了需要更新的react组件的render,这就是react中的批处理。为了更清晰明了的认识这一点,接下来本文将从react更新与hook更新以及前者与浏览器的页面视图更新两个方面的区别来说明。

react更新与hook更新

只有一次更新会被执行中的更新就是这一批中的第一个更新。这可能有些反直觉,因为就我们看到的事实而言,react组件只render了一次,但是状态确实是最后一次调度所设置的状态。

区分hook的更新与react更新的意义就在此:在react中,react的更新是会被批处理的,而hooks的更新则不会。考虑如下示例,点击标签,render只打印了一次,hook update 1hook update 2都被打印了,而count也加了2。

export default function App() {
  const [count, setCount] = useState(0)

  console.log('render!')
  function plus() {
    setCount(prev => {
      console.log('hook update 1')
      return prev + 1
    })
    setCount(prev => {
      console.log('hook update 2')
      return prev + 1
    })
  }   

  return (
    <div style={{ textAlign: 'center', fontSize: '42px', marginTop: '100px' }}>
      <p>你可以点击数字</p>
      <span onClick={plus}>
	{count}
      </span>
    </div>
  )
}

点击查看在线示例

这解释了为什么批处理中实际执行的更新可以是第一个:

  1. 第一个setCount记录了一次hook更新,同时调度了一次react更新;
  2. 第二个setCount记录了另一个hook更新,然后试图调度一次react更新,但是发现已经存在优先级相同的待执行更新,提前返回;
  3. 在实际执行的更新中,Counter函数被调用,随后useState函数被调用,hook更新队列里的两个更新被执行。

值得注意的是,react更新是带有优先级的,hook的更新也是,实际上只有当hook的更新与本次react更新优先级相同时,这个hook更新才会被执行。在上一个示例中,如果我们在两次setCount之后再追加一个低优先级的setCount(手动降级优先级),那么一方面这次更新不会与上两次一起被批处理,另一方面它所记录的hook更新也不会在批处理更新中被执行。

react更新与页面视图更新

如果react更新与页面视图更新(或者说浏览器渲染)是一一对应的,那么批处理就好像是为了避免浏览器的多次渲染。这样的说法并非完全没有道理,在有些场景下,批处理确实可以避免浏览器多次渲染。但这个前提是不正确的,从正反两个方面而言:

  • 浏览器的渲染并不依赖react更新,css动画,js脚本,定时器任务都可以改变页面视图,这是显而易见的。
  • react更新也并不必然导致浏览器渲染,这在有些场合显明,有些场合却不是。

也就是说,即便没有批处理,react多次更新可能只会改变一次页面视图,下面的示例可以说明这一点:

import React, { useState } from 'react'
import { flushSync } from 'react-dom'

export default function App() {
  const [count, setCount] = useState(0)

  console.log('render!')
  function plus() {
    flushSync(() => {
      setCount(count + 1)
    })

    sleep(1000)

    setCount(count + 2)
  }

  return (
    <div style={{ textAlign: 'center', fontSize: '42px', marginTop: '100px' }}>
      <p>你可以点击数字</p>
      <span onClick={plus}>
	{count}
      </span>
    </div>
  )
}

function sleep(milliseconds) {
  const now = Date.now()
  while (Date.now() - now < milliseconds) {}
}

点击查看在线示例,可以编辑调试

点击标签后,页面会停顿一会,数字直接跳到2,但是在控制台,你可以看到render被打印了两次。

flushSync可以使更新同步地被执行,这样一来,第二个setCount带来的更新与第一个setCount的更新无法被批处理,因为setCount(count + 2)调用时,第一个更新已经执行完了。

上述例子可能较好理解,但还有不那么显然的情况,我们可以改造一下plus函数:

function plus() {
  flushSync(() => {
    setCount(count + 1)
  })

  Promise.resolve().then(() => {
    sleep(1000)
    setCount(count + 2)
  })
}

组件依然render两次,而浏览器只渲染了一次,与plus函数改造前的情况并没有什么显著差异。如果要深刻地理解react更新与浏览器的渲染之间的关系,那就要真正理解js事件循环,感兴趣的话可以观看这个视频:深入JavaScript中的EventLoop(中英字幕,B站随便找的一个)

小结

在思考批处理的时候,也许我们更应聚焦在react更新自身上,既不是react更新内部触发的hook更新,也不是react机制外部的浏览器渲染。这个机制直接地避免了react的重复更新。关于批处理可以做三点总结:

  • 可以避免react组件多次render
  • 不会跳过相关的hook状态更新
  • 避免浏览器重复渲染并不必然需要一个批处理机制

批处理的实现

每一次更新都存在优先级,对于有相同优先级的多次更新,只要实际调度第一个更新,而在后续的更新请求中提前返回函数就能实现批处理。

透过react 18.2.0的相关源码来了解批处理的实现,这个过程不会很困难,甚至比较简单。这里会忽略与批处理不相关的细节,便于阅读。

从调用setState(或者dispatch),到最后的react完成更新,流程大致是这样的:

  1. setState
  2. scheduleUpdateOnFiber
  3. ensureRootIsScheduled(有部分逻辑判断是否批处理,如需要,提前return)
  4. 如果第三步没有提前中断,调度react更新的回调函数performSyncWorkOnRoot或者performConcurrentWorkOnRoot
  5. 异步地执行performXXXWorkOnRoot(包含了render阶段)

在第四步中,根据条件的不同,更新回调会注册在微任务或者是MessageChannel的onmessage回调中,所以第五步中的异步是因条件而异的。

关于批处理我们要关注的是前三点,当发生自动批处理时,ensureRootIsScheduled会提前返回。下面三个小节中,分别展示了这些函数的执行逻辑和部分源码。

setState

下面的代码展示了setState的来源与其内部的逻辑:

// 源码文件目录是packages/react-reconciler/src/ReactFiberHooks.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,
): void {
  // ...
  const lane = requestUpdateLane(fiber);
  const update: Update<S, A> = {
    lane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };
  
  if (isRenderPhaseUpdate(fiber)) {
    // ...异常情形
  } else {
    // ...
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    // 首次渲染后root !== null
    if (root !== null) {
      const eventTime = requestEventTime();
      scheduleUpdateOnFiber(root, fiber, lane, eventTime);
      // ...
    }
  }

  // ...
}

react hook github源码链接

dispatchSetState的源码在前文已经展示过一次。这里只是重复什么是批-更新的说法,setState所做的就是:

  • 将hook更新加入更新队列
  • 尝试调度一次react更新

如果你不纠结fiber和queue的细节的话,就批处理而言,这就是setState的全部了。

useState可以划分为mountState和updateState,其中updateState返回的setState与mountState返回的是同一个。setState就是dispatchSetState.bind(null, currentlyRenderingFiber, queue)

scheduleUpdateOnFiber

忽略一些琐碎的细节后,你可以发现这个函数的核心逻辑甚至更简单:

  • 标记一次具有某一优先级的更新(markRootUpdated)
  • 调用ensureRootIsScheduled
// 源码文件目录是packages/react-reconciler/src/ReactFiberWorkLoop.js
export function scheduleUpdateOnFiber(
  root: FiberRoot,
  fiber: Fiber,
  lane: Lane,
  eventTime: number,
) {
  // ...
  markRootUpdated(root, lane, eventTime);
  // ...
  ensureRootIsScheduled(root, eventTime);
  // ...
}

react workLoop github源码链接

在ensureRootIsScheduled中,会判断是否需要批处理。

ensureRootIsScheduled

这个函数所处理的情形较多,包括了批处理,取消无意义的更新,高优先级更新打断低优先级更新,调度实际的更新等,而对批处理的判断实际上是相当简单的——判断上一次等待的更新与本次更新的优先级。

// 源码文件目录是packages/react-reconciler/src/ReactFiberWorkLoop.js
export function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
  // ...
  const nextLanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );
  
  // ...
  
  const newCallbackPriority = getHighestPriorityLane(nextLanes);

  const existingCallbackPriority = root.callbackPriority;
  if (existingCallbackPriority === newCallbackPriority) {
    // 这里就是做批处理
    // ...
    return;
  }
  
  // ... 高优先级打断低优先级
  
  // ...实际的调度,最后会给root.callbackPriority赋值
}

react workLoop github源码链接

关于批处理的逻辑,主要有两点:

  • 通过getNextLanesgetHighestPriorityLane拿到本次应该(不一定是setState时的那个)更新的优先级newCallbackPriority
  • 对比上次等待的更新和本次更新的优先级,即newCallbackPriority === newCallbackPriority,如果相等,则提前return

scheduleUpdateOnFiber中已经对setState对应的优先级做了标记,所以那个优先级在这里是可以被读取到的。如果两次更新的优先级相同,批处理就会起作用。

批处理的发生当然意味着代码进入上述的newCallbackPriority === newCallbackPriority分支内。但是即使是其他情形也有可能进入这个分支。例如,当连续的两次setState被调用,前者优先级高于后者,那么当第二次setState被调用,从而进入ensureRootIsScheduled时,existingCallbackPriority与newCallbackPriority都是第一次调用时的优先级(每次所取的都是最高优先级),导致函数提前返回。这并不意味着低优先级的更新被忽略,在高优先级的更新即将完成时,ensureRootIsScheduled会被再次调用,确保所有更新会被执行。

以上就是批处理发生的全过程。

结语

在认识一个事物的过程中,从看到其表面到理解背后的原理总是令人兴奋的。这是作者在掘金的第一篇文章,如果任何人在读完后有收获,那这项工作就不是毫无价值的。

对于这篇文章有疑问或者需要指正的小伙伴,欢迎在评论区留言或者直接私信我,作者对一切交流保持开放。