引言
批处理在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的源码,会发现它们主要做了两件事:
- 记录一次hook更新(
enqueueConcurrentHookUpdate
) - 调度一次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 1
与hook 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>
)
}
这解释了为什么批处理中实际执行的更新可以是第一个:
- 第一个setCount记录了一次hook更新,同时调度了一次react更新;
- 第二个setCount记录了另一个hook更新,然后试图调度一次react更新,但是发现已经存在优先级相同的待执行更新,提前返回;
- 在实际执行的更新中,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完成更新,流程大致是这样的:
setState
scheduleUpdateOnFiber
ensureRootIsScheduled
(有部分逻辑判断是否批处理,如需要,提前return)- 如果第三步没有提前中断,调度react更新的回调函数performSyncWorkOnRoot或者performConcurrentWorkOnRoot
- 异步地执行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);
// ...
}
}
// ...
}
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);
// ...
}
在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赋值
}
关于批处理的逻辑,主要有两点:
- 通过
getNextLanes
和getHighestPriorityLane
拿到本次应该
(不一定是setState时的那个)更新的优先级newCallbackPriority
- 对比上次等待的更新和本次更新的优先级,即
newCallbackPriority
===newCallbackPriority
,如果相等,则提前return
在scheduleUpdateOnFiber
中已经对setState对应的优先级做了标记,所以那个优先级在这里是可以被读取到的。如果两次更新的优先级相同,批处理就会起作用。
批处理的发生当然意味着代码进入上述的
newCallbackPriority === newCallbackPriority
分支内。但是即使是其他情形也有可能进入这个分支。例如,当连续的两次setState被调用,前者优先级高于后者,那么当第二次setState被调用,从而进入ensureRootIsScheduled时,existingCallbackPriority与newCallbackPriority都是第一次调用时的优先级(每次所取的都是最高优先级),导致函数提前返回。这并不意味
着低优先级的更新被忽略,在高优先级的更新即将完成时,ensureRootIsScheduled会被再次调用,确保所有更新会被执行。
以上就是批处理发生的全过程。
结语
在认识一个事物的过程中,从看到其表面到理解背后的原理总是令人兴奋的。这是作者在掘金的第一篇文章,如果任何人在读完后有收获,那这项工作就不是毫无价值的。
对于这篇文章有疑问或者需要指正的小伙伴,欢迎在评论区留言或者直接私信我,作者对一切交流保持开放。