面向读者:对React原理、前端开发有一定了解和经验的人群,所以一些概念和知识会默认读者已了解,不会详细论述。
在React 18之前,React默认使用同步渲染。更新的渲染在一个单一、不间断的同步事务中完成。使用同步渲染,一旦更新开始渲染,在用户看到结果之前,任何操作都无法中断渲染。
React 18带来了并发渲染,它的一个关键特性是渲染可中断。React 可能会开始渲染更新,中途暂停,然后稍后继续。它甚至可能完全放弃正在进行的渲染。React 保证即使渲染中断,UI 也能保持一致性。为此,它会等到渲染结束,即整个树都评估完毕后,再执行 DOM 修改。借助此功能,React 可以在后台准备新的屏幕,而不会阻塞主线程。这意味着即使 UI 正在执行大型渲染任务,它也能立即响应用户输入,从而打造流畅的用户体验。
并发更新机制本质上是时间切片,并且高优先级会打断低优先级的任务。在渲染的过程中,由于整个连续不断的渲染过程拆分成了一个个分片的渲染片段,因此在渲染的间隙时就有机会去响应用户的操作。而具体时间片上执行哪个任务是由任务上的相关优先级决定的,当高优先级的更新到来时,会中断旧的更新,优先执行高优先级更新,待完成后继续执行低优先级更新。
这里有一个简单例子的对比(本文档中的示例一般采用了类似 while (performance.now() - now < 100) 这样的循环代码来模拟了组件卡顿状态),代码:被撕碎的React - 掘金
| Synchronous(🚧卡顿) | Concurrent Mode(🏂丝滑) |
|---|---|
useDeferredValue包裹的值。从图片可以明显感受到体验流畅了很多。 |
这种渲染可中断的特性必须在使用到特定的API时,才能体验到。以右图为例:
import { useState, useDeferredValue, memo } from 'react';
function Heavy({ value }: { value: string }) {
const start = performance.now();
while (performance.now() - start < 300) {}
return <span>Heavy: {value}</span>;
}
const HeavyMemo = memo(Heavy);
export default function App() {
const [value, setValue] = useState('');
const deferredValue = useDeferredValue(value);
return (
<div>
<input value={value} onChange={e => setValue(e.target.value)} /><br/>
<HeavyMemo value={deferredValue} />
</div>
);
}
在没有使用useDeferredValue之前,每当你在输入框中输入内容时,HeavyMemo都会收到新的 props,并重新渲染整个组件,这会让输入感觉很卡顿。
useDeferredValue是 React 提供的用于性能优化的 hook,它将一个值标记为可以延迟更新(如何标记的?文章后面会讲述),让 React 先处理更紧急的任务,如用户交互引起的界面更新等。
在App组件的顶层调用useDeferredValue,获取value的延迟版本。在上面的代码中,React 会优先更新输入框(必须快速),而不是更新HeavyMemo(换句话说,允许较慢速度地更新它),这防止了HeavyMemo阻塞 UI 的其余部分。这不会加快HeavyMemo的重新渲染速度。但是,它告诉 React,可以降低HeavyMemo重新渲染的优先级,以免阻塞键盘输入。列表将“滞后”于输入,然后“赶上”。
延迟的“后台”渲染是可中断的。例如,如果你再次在输入框中输入内容,React 将放弃该操作并使用新值重新开始。React 将始终使用最新提供的值。而之前版本的React同步渲染时,组件以不可中断的方式渲染,直到渲染完成,UI才会更新。一旦组件耗时较长,用户的交互产生的效果,在渲染完成之前都是不可见的,因而卡顿。
useDeferredValue 与防抖和节流?
防抖和节流是 JavaScript 中常用的函数调用频率控制手段。一些情况下,防抖和节流能够完全被useDeferredValue给替代了,或者可以结合使用。
useDeferredValue不需要选择固定的延迟,后台渲染的时间就是他的延迟。性能强的设备渲染几乎立即发生且不引人注意,性能差的设备则会“滞后”输入。
useDeferredValue的延迟重新渲染是可中断的。若 React 正在重新渲染大型列表时用户再次按键,React 会放弃此次渲染,处理按键后再重新开始渲染,避免卡顿。而防抖和节流是阻塞的,只是推迟了渲染阻塞按键的时刻。卡顿还是会发生,假如在同步渲染的期间,用户再次按键,还是会感受到卡顿。
适用场景
- useDeferredValue:适用于优化 React 应用中的渲染过程,尤其适用于非关键更新,如非关键的 UI 更新、数据的异步加载等场景,能让应用在处理完更重要的更新后,再利用空闲时间处理这些被延迟的更新。(网络请求不会减少!)
- 防抖和节流:适用于需要控制函数调用频率的场景,如搜索框输入联想、窗口大小调整、滚动事件监听、鼠标移动事件处理等,减少函数的执行次数,降低性能开销。此外,它们也可以用于减少网络请求数量等非渲染过程的优化。 (总结:需求优化的工作并非在渲染过程中发生)
useDeferredValue的组件会被永远打断吗?
不会。这个可以称作“任务饥饿问题”。
当高优先级任务执行完,准备执行低优先级任务时,又插入一个高优先级任务,那么又会执行高优先级任务,如果不断有高优先级任务插队执行,那么低优先级任务便一直得不到执行,我们称这种现象为任务饥饿问题。(引用[11])
任务优先级最后会交由 Scheduler 调度,Scheduler 采用 expirationTime 机制解决饥饿问题。Transition 任务对应的是NormalPriority,相应的过期时间是5秒,也就是如果5秒后还没有轮到它执行,就会启用同步渲染模式,进行渲染。
从Tearing问题入手
画面撕裂(英语:Screen Tearing)是显示器把两帧或更多帧(frame)同时显示在同一画面上的一个现象。画面撕裂的相关表现我们不再赘述。在React中,也有类似画面撕裂的这种表现,这里引用一张图片(示例体验链接:codesandbox.io/p/sandbox/t…):
按理来说,JavaScript 本身是单线程的,本身不应该出现撕裂的问题。
这种现象只出现于React的 Concurrent 模式下。React 18 的大版本中,React 默认转为了 Concurrent 模式。React 的“并发渲染”模式可以暂停渲染过程以让其他工作进行。在这些暂停之间,更新可能会悄悄地发生,从而更改用于渲染的数据,这可能导致 UI 对同一数据显示两个不同的值。
大多数情况下,并发渲染可以带来一致的 UI,但在特定条件下,也存在一种极端情况,可能会导致问题。在使用原生的 React setState 和 useReducer 等 hook 时是没有撕裂问题的,但是在一些状态库外部状态管理中(例如老版本的 react-redux 以及 jotai),就可能出现撕裂的状态。
如下图所示,假设 External Store (外部 Store)里的一个值发生了变化,然后开始响应式地更新:第一个节点渲染为蓝色(此时 store 的值)。此时发生了一些事情,导致外部 Store 的值又变化为红色,但在此之后渲染的任何组件都会获得当前外部 Store 的值,所以第二个、第三个组件也就渲染为了红色。此时,“Tearing”现象就发生了。
useSyncExternalStore
useSyncExternalStore这个Hook正是为了对抗“Tearing”问题。“Tearing”的外在原因在于,(外部的)状态可以在渲染过程中更新,而 React 对此毫不知情。反过来说,如果你的应用的状态全部在于 React 自身,你不会遇到 “Tearing”的问题,这一点由 React 来保证。
后面useSyncExternalStore皆简称“uSES”。
组件和自定义 Hooks 在渲染时不访问外部可变数据,并且仅使用 React props、state 或 context 传递信息,因此不应该出现问题,因为 React 原生地同时处理这些问题。- Concurrent React for Library Maintainers - reactwg/react-18 Discussion 2021-7-8
根据 React 18的Release docs:uSES 是一个新的 Hook,它允许 外部存储 支持 并发读取,通过 强制同步变更 的方式 。它消除了在实现对外部数据源的订阅时使用 useEffect 的需要,并且作为实现外部存储的第三方库的推荐 Hook API。
- 外部存储指除开 React 提供的 state、context、reducer 之外的数据源,比如:全局变量、DOM、 localStorage 等等。
- 同步变更指 React 在一次渲染(这里渲染指生成一次 React 节点树的过程)中是同步完成渲染(变更)。
- 并发读取:这里的并发是指并发渲染模式的并发,保证数据读取的一致,防止“Tearing”的出现。 (部分参考[7])
所以换句话说,uSES 允许组件在 React 18 的 Concurrent 模式下安全地有效地读取(订阅)外接数据源,在数据源发生变化的时候,能够调度更新。
可能说的还是有点云里雾里,我们看下一节的效果对比。
状态库对比
这里依旧是引用博客被撕碎的React - 掘金里的一张图,一些讨论做了必要的复制。React 18的Concurrent Mode对于社区的状态库是一个比较大的变更,一些状态管理库都做了重构来适配,例如redux@8。
强调一点,下面的例子中,都用了startTransition – React 来保证并发更新,皆为计数器例子,连续点击三次+1。源代码:react tearing in concurrent mode - github gist
| 用法 | useState | useReducer | useSyncExternalStore | zustand | jotai | Redux |
|---|---|---|---|---|---|---|
| 是否卡顿 | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
| 是否Tearing | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
是否卡顿:指在rerender时是否还能相应用户交互,比如上面的useSyncExternalStore或zustand在点击一次后因为rerender再次点击时就会卡住(可以看见按钮一直处于press的状态)
是否Tearing:状态更新产生不一致的情况,比如jotai连续点击三次后同时出现了不同value的状态值
在AIPA实现的例子:React 18 并发渲染状态管理测试 (可以亲自体验一下,需内网访问)
uSES 和 zustand 为什么会卡顿?uSES 到底做了什么?
import { startTransition } from 'react';
function Controller() {
const increment = useStore((state) => state.increment);
const concurrentAdd = () => startTransition(increment);
return <button onClick={concurrentAdd}>+1</button>;
}
代码中,state的increment操作不是用了startTransition包裹吗?这个状态变更不应该被标记为 Transition,然后开启并发渲染,不会卡顿吗?
我们来阅读源码(来源[7], 作者只保留了核心逻辑,移除开发模式打点、错误处理等次要的内容):
// 主要实现
function mountSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
// 引用的全局变量重新声明一下写在函数最前面,非常好习惯
const fiber = currentlyRenderingFiber;
// 区分 SSR 和 CSR 环境获取不同的 value
const nextSnapshot = getIsHydrating() ? getServerSnapshot() : getSnapshot();
// 挂载一个 hook 来处理 ExternalStore
const hook = mountWorkInProgressHook();
hook.memoizedState = nextSnapshot;
const inst: StoreInstance<T> = {
value: nextSnapshot,
// 存一下 getSnapShot 方法本身为了后续更新时
// 也检查 getSnapshot 是否变更
getSnapshot,
};
hook.queue = inst;
// 挂载一个 effect 来订阅 store 变更 mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
// Schedule an effect to update the mutable instance fields. We will update // this whenever subscribe, getSnapshot, or value changes . Because there's no // clean-up function, and we track the deps correctly, we can call pushEffect // directly, without storing any additional state. For the same reason, we // don't need to set a static flag, either.
pushEffect(
HookHasEffect | HookPassive,
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
undefined,
null,
);
return nextSnapshot;
}
// 订阅 store 变更的实现
function subscribeToStore<T>(fiber, inst: StoreInstance<T>, subscribe) {
const handleStoreChange = () => {
// The store changed. Check if the snapshot changed since the last time we // read from the store. if (checkIfSnapshotChanged(inst)) {
// Force a re-render. forceStoreRerender(fiber);
}
};
// Subscribe to the store and return a clean-up function. return subscribe(handleStoreChange);
}
// 检查是否要触发更新
function checkIfSnapshotChanged<T>(inst: StoreInstance<T>): boolean {
const latestGetSnapshot = inst.getSnapshot;
const prevValue = inst.value;
try {
const nextValue = latestGetSnapshot();
return !is(prevValue, nextValue); // 这里的 is 是 object-is,仅作引用比较。
} catch (error) {
return true;
}
}
// 更新 store 的值
function updateStoreInstance<T>(
fiber: Fiber,
inst: StoreInstance<T>,
nextSnapshot: T,
getSnapshot: () => T,
) {
// These are updated in the passive phase inst.value = nextSnapshot;
inst.getSnapshot = getSnapshot;
// Something may have been mutated in between render and commit. This could // have been in an event that fired before the passive effects, or it could // have been in a layout effect. In that case, we would have used the old // snapsho and getSnapshot values to bail out. We need to check one more time. if (checkIfSnapshotChanged(inst)) {
// Force a re-render. forceStoreRerender(fiber);
}
}
function forceStoreRerender(fiber) {
const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
if (root !== null) {
// 这里传入 SyncLane 来区分这次更新是同步更新
scheduleUpdateOnFiber(root, fiber, SyncLane, NoTimestamp);
}
}
React 文档中这么说(来源[10]):如果在非阻塞 Transition 更新期间 store 发生突变,React 将回退到以阻塞方式执行该更新。具体来说,对于每次调用的 Transition 更新,React 都会在将更改应用于 DOM 之前再次调用getSnapshot。如果返回的值与初始调用时的不同,React 将从头开始重新执行更新,这次将其作为阻塞更新应用,以确保屏幕上的每个组件都反映相同版本的 store。其实就是对上面这段代码的解释。
结合上面的例子讲解:
- 当组件首次使用 uSES 订阅 store 时,它会执行类似上面代码中的 mountSyncExternalStore 函数。它会获取初始状态,如果是服务器端渲染环境(getIsHydrating() 为 true),则使用 getServerSnapshot() 获取服务器端的快照;如果是客户端渲染,则使用 getSnapshot() 获取客户端的当前状态。
- 同时,会创建一个 inst 实例 16行,存储 value(当前Snapshot状态)和 getSnapshot 函数本身,用于后续的比较和更新操作。
- 第一次点击
+1按钮后,startTransition(increment)开启了一次 Transition 更新(此时还是非阻塞性质的) - 当处于非阻塞的 Transition 更新期间,组件的更新操作会被推迟。但在这个过程中,如果 store 发生突变(例如通过外部操作修改了 store 的状态),43行
subscribeToStore函数的 44行handleStoreChange回调会被触发。 handleStoreChange会调用 57行checkIfSnapshotChanged函数来检查状态是否发生了变化。它会使用最新的getSnapshot函数获取新的值,并与之前的 value(存放在inst.value) 进行比较。这里的比较是通过 object - is(即 is 函数)进行的引用比较,如果发现值发生了变化,就会调用 89行forceStoreRerender函数强制重新渲染组件。- 在 89行
forceStoreRerender函数中,会调用enqueueConcurrentRenderForLane函数来将更新放入更新队列,并且指定更新的优先级(SyncLane 表示同步优先级,虽然 因为在非阻塞 Transition 更新期间,整体更新是被推迟的,但 store 的突变更新是需要及时处理的)。 - 然后,会调用 93行
scheduleUpdateOnFiber函数来计划这个更新。React 的更新调度机制会根据优先级来安排这些更新的执行顺序。 - Transition 更新虽然整体是非阻塞的,但如果在期间发生了 store 的突变,这个突变更新会被标记为高优先级(SyncLane),从而在合适的时机(通常是在紧急更新处理之后)尽快执行。
- 在渲染阶段完成后,会进入被动更新阶段。这时,34行 pushEffect 函数添加的
updateStoreInstance作为被动 effect 会被执行。 - 69行
updateStoreInstance会更新 inst 实例的 value 和 getSnapshot 属性。它会先将新的状态值 nextSnapshot 和新的 getSnapshot 函数赋值给 inst。然后再调用 57行checkIfSnapshotChanged函数再次检查是否有变化,因为有可能在渲染阶段和被动效果阶段之间 store 的状态又被修改了。如果发现还有变化,就再次调用 89行forceStoreRerender函数来确保组件能够正确地响应 store 的变化。 - 所以简单来说, 第二次、第三次的点击都是在渲染期间发生的,会造成store的变更,第一次渲染完成后,React 会在将更改应用于 DOM 之前再次调用
getSnapshot。如果返回的值与初始调用时的不同,React 将从头开始重新执行更新,这次将其作为阻塞更新应用,以确保屏幕上的每个组件都反映相同版本的 store。 - 最终,
scheduleUpdateOnFiber会 转到scheduleUpdateOnFiber 方法中,检测更新优先级为 SyncLane ,(……其他条件、中间过程省略),然后performSyncWorkOnRoot 进行同步渲染更新,此时渲染是同步不可中断的。
React团队正在重新审视,计划使用use API原生支持并发外部存储。目标是允许在渲染期间读取外部状态而不会撕裂,并与 React 提供的所有并发功能无缝协作。react.dev/blog/2025/0…
zustand也是采用useSyncExternalStore,因此和useSyncExternalStore一样会卡顿(可以采用 use-zustand 来解决,但会发生Tearing)。react-redux在8.0采用了useSyncExternalStore进行了重构,因此同样也会发生卡顿。因此采用useSyncExternalStore实现的一些状态库无法享受到 Concurrent Mode 的好处
Jotai为什么会导致Tearing
该小节直接参考[2]
从上面的例子我们可以看到Concurrent Mode下简单使用 useState 或 useReducer 既不会卡顿也不会发生Tearing。 而Jotai内部实现采用的是 useReducer,那为什么也导致了Tearing呢?这是因为单独使用useState或useReducer是没问题的,但 Jotai 采用了 useReducer + useEffect 进行同步更新这可能会导致Tearing
参考 jotai/src/react/useAtomValue.ts at main · pmndrs/jotai
react-redux7也是采用的 useReducer + useEffect,因此在concurrent同样会导致tearing问题;但其在react-redux8中替换成了useSyncExternalStore因此不会再有tearing问题(release,PR)
Jotai 可以完美享受到Concurrent Mode带来的优点,但会存在一些偶发的Tearing现象。
Why useSyncExternalStore Is Not Used in Jotai · Daishi Kato's blog Daishi认为在Jotai 中对 Suspense和Concurrent的支持带来的好处 能盖过 temporary tearing的缺点
Tearing现象只是暂时的,最终UI是会达成一致。
对比 Synchronous 和 Concurrent
| Synchronous | Concurrent |
|---|---|
可以看见,同步模式下的表现和 uSES 的表现基本一致。
并发式渲染来说是一个巨大的好处,因为用户可以与页面交互,而不会被 React 阻塞。有了并发渲染,React 可以让点击发生,从而让页面在用户看来流畅且具有交互性。
Concurrent 渲染时怎样可中断的?
再温习一遍 concurrent 模式的介绍:
当有状态更新时,会为每个更新分配优先级,当在更新的过程中,后续进入的更高优先级的任务时,会中断当前低优先级任务,优先去执行高优先级任务,再去执行低优先级任务。除此之外,在浏览器刷新的每帧(Frame)中会预留一定的固定时间让浏览器去渲染页面,若当前更新任务所用时间较长,就会被中断,放到下一帧中继续执行。(参考[9])
一个比较好的图:
React Work Loop(右侧)
-
performWorkUntilDeadline(执行调度)
-
startTime = currentTime;(记录调度开始时间):在每次工作循环开始时,记录当前时间(
startTime),用于后续的时间管理。 -
workLoop(工作循环):核心循环,不断从任务队列中获取最高优先级的任务并执行。
- taskQueue(任务队列):存储待执行的任务,这些任务按照优先级进行排列。
-
currentTask != null(是否还有任务):检查当前是否有任务需要执行。
- 如果任务队列不为空,获取最高优先级任务并执行。
- 任务队列已空则关闭消息队列,任务已执行完。
-
shouldYieldToHost(是否应该让步给宿主):判断当前任务执行时间是否超过调度时间。
- 如果时间超过,则让步给宿主环境,注册一个宏任务,等待下次执行(workloop 自身)。
- 否则继续执行下一个任务。
浏览器事件循环的宏任务调度(左侧)
这一部分希望读者了解浏览器的事件循环。
-
执行宏任务
-
JS是一门单线的非阻塞的脚本语言,JavaScript 主线程和渲染线程互斥。
-
宏任务执行过程:
- 执行一个宏任务(执行栈中没有就从任务队列中获取)。
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中。
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)。
-
-
宏任务执行完毕,开始检查渲染,然后渲染线程接管进行渲染。 如果宏任务队列为空,则休眠直到出现宏任务。
关于 Concurrent 原理推荐阅读:
React concurrent 模式介绍 - ByteTech
个人认为以上资料能比我讲的好。
个人体会
这里分享几点个人思考🤔
-
React 的 workloop使用了 MessageChannel 的API: MessageChannel 是以 DOM Event 的形式发送消息,所以它是一个宏任务,会在下一个事件循环的开头执行。所以能够一直使浏览器的事件循环运行React 的 workloop。 这里有一个使用MessageChannel实现 event loop极简的代码 MessageChannel - 掘金 MDN 参考:MessageChannel - Web API | MDN
-
对于那些没有使用 Transtion API ,因部分用户操作而导致的更新,其实还是相当于同步更新。因为对于用户离散操作事件(如点击、文本框输入),其对应的优先级是
ImmediateSchedulerPriority,会立即执行,在调度中会进入同步渲染模式。所以,如果我们使用React 18开发时,要进行这方面的注意🧐。 一些库,如路由库其实为我们作了兜底,我们点击时,为我们执行了非阻塞的更新,就不会陷入暂时的卡死。 tanstack/react-router example 对于一些连续操作,如滚动、拖拽,其对应的过期时间是2.5秒。然后一些数据请求带来的变更,其 -
React 18带来了很多变化,可以在这个Discussion看见一些相关的讨论。 reactwg/react-18 · Discussions 如:
-
当 useEffect 是离散操作(点击、输入)的结果时,它会同步触发。discussions/128
-
React 18 中自动批处理 discussions/21
-
参考资料:
- React v18.0 2022-3-19 react.dev/blog/2022/0…
- 被撕碎的React - Tearing 2024-10-9 juejin.cn/post/742335…
- Screen Tearing Wiki en.wikipedia.org/wiki/Screen…
- React 撕裂问题 2024-10-27 juejin.cn/post/743047…
- What is tearing? 2021-7-8 github.com/reactwg/rea…
- Concurrent React for Library Maintainers 2021-7-8 github.com/reactwg/rea…
- React.18 的新 API「useSyncExternalStore」 2023-1-16 bytetech.info/articles/71…
- 从用法到实现,一文带你拥抱React 18 2022-5-9 bytetech.info/articles/70…
- React concurrent 模式介绍 2021-10-15 bytetech.info/articles/70…
- useSyncExternalStore - React react.dev/reference/r…
- React源码解析之优先级Lane模型上 2021-9-17 juejin.cn/post/700880…