简单介绍下React 18 Concurrent Mode下的Tearing问题,以及一些状态库的在Concurrent Mode的表现
背景
要介绍 Tearing 我们需要先了解下 Concurrent Mode
Concurrent Mode
先看看这个简单例子的对比
| Synchronous | Concurrent Mode | |
|---|---|---|
| \ | ||
| 🚧卡顿 | 🏂丝滑 |
Synchronous:在 React 18 之前整个渲染过程是不能被中断的,因此在渲染过程中或许不能及时响应用户的交互,当渲染开销比较大时用户在交互过程中会明显地感觉到卡顿。
- 🌰举个例子:【一个输入框组件input ➕ 一个耗时的组件Heavy】,依次输入1,2,3然后再全部删掉
import { useState } from 'react';
function Heavy({ value }: { value: string }) {
const start = performance.now();
while (performance.now() - start < 500) {}
return <span>Heavy: {value}</span>;
}
export default function App() {
const [value, setValue] = useState('');
return (
<div>
<input value={value} onChange={e => setValue(e.target.value)} /><br/>
<Heavy value={value} />
</div>
);
}
我们可以发现在这个例子中,用户的输入由于受到了 组件较大的渲染开销,因此交互出现了明显的卡顿现象
Concurrent:React 18 引入了 Concurrent Mode,在这个模式下渲染过程变成了可中断(interruptible)的。因此即使正在进行开销极大的渲染工作,也能立刻响应用户的操作,带来丝滑的体验😎
依旧采用上面的例子,但使用了Concurrent Mode
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>
);
}
可以发现虽然 组件会进行极大的渲染工作,但用户在input中的输入依旧很流畅,无卡顿现象
简单总结一下,React 18中的 Concurrent Mode 最主要的特性就是 「渲染可中断」 interruptible
Concurrent Mode不会默认开启,需要使用特定的API(比如 useTransition、useDeferredValue)
React Tearing
什么是Tearing?
Tearing 通常是指在视觉上产生了不一致的情况。比如 Screen Tearing 中就是指不同帧的画面同时出现在了显示屏幕上,导致画面显示矛盾
React Tearing 则是指 同一个state在渲染过程中却展示了不同的value
下面是Concurrent Mode下一百个计数器共享一个相同的state,这里我们连续点击三次
+1(code)
🎯可以发现在Concurrent Mode下,这一百个计数器虽然共享一个state,却出现了不同的value
对比 Synchronous 和 Concurrent
我们写一个counter的例子,其中执行add的时候会运行耗时操作。再从React Tearing角度来看看两种模式下的区别
| Synchronous | Concurrent | |
|---|---|---|
可以发现
-
Synchronous:所有的状态都是同步更新,不会出现状态不一致的情况,但会造成卡顿...
-
Concurrent: 状态更新会因为响应优先级更高的用户交互而被打断,更新状态可能会不一致(Tearing),但能减少卡顿
状态库对比
React 18的Concurrent Mode其实对于社区的状态库是一个比较大的变更,redux@8都专门为此进行了重构来适配。这里简单针对 React Tearing进行一些对比:
其中比较详细的对比可以参考这个repo: dai-shi/will-this-react-global-state-work-in-concurrent-rendering: Test tearing and branching in React concurrent rendering
我们来看看Concurrent Mode下各个状态库的表现 (采用 startTransition – React),皆为计数器例子,连续点击三次 +1 ( react tearing in concurrent mode - github gist )
| 用法 | useState | useReducer | useSyncExternalStore | zustand | jotai |
|---|---|---|---|---|---|
| 是否卡顿 | ❌ | ❌ | ✅ | ✅ | ❌ |
| 是否Tearing | ❌ | ❌ | ❌ | ❌ | ✅ |
-
是否卡顿:指在rerender时是否还能相应用户交互,比如上面的useSyncExternalStore或zustand在点击一次后因为rerender再次点击时就会卡住(可以看见按钮一直处于press的状态)
-
是否Tearing:状态更新产生不一致的情况,比如jotai连续点击三次后同时出现了不同value的状态值
从上面的测试我们可以发现
-
useState或useReducer是可以保证Concurrent模式下不卡顿同时也不会Tearing
-
useSyncExternalStore 或 zustand 由于无法在Concurrent下响应用户输出会导致卡顿但不会Tearing;
-
jotai 可以适配Concurrent模式不卡顿但会Tearing
一些补充
-
useSyncExternalStore为什么会卡顿
因为useSyncExternalStore会强制将update从Concurrent Mode变回Synchronous,此时渲染变成不可中断。
If the store is mutated during a non-blocking Transition update, React will fall back to performing that update as blocking. -- useSyncExternalStore – React 😁实际上注意useSyncExternalStore里的Sync也可以猜出一点来 useTransition and uSES do not work together - issue
zustand也是采用useSyncExternalStore(会强制将concurrent转变成synchronous),因此和useSyncExternalStore一样会卡顿(可以采用 use-zustand 来解决,但同样会发生Tearing)
react-redux也是在8.0采用了useSyncExternalStore进行了重构,因此同样也会发生卡顿
🙈因此采用useSyncExternalStore实现的一些状态库无法享受到 Concurrent Mode 的好处
-
Jotai为什么会导致Tearing
从上面的例子我们可以知道Concurrent Mode下简单使用 useState 或 useReducer 既不会卡顿也不会发生Tearing。 而Jotai内部实现采用的是 useReducer,那为什么也导致了Tearing呢?
这是因为单独使用useState或useReducer是没问题的,但 Jotai 采用了 useReducer + useEffect 进行同步更新这可能会导致Tearing
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的缺点
参考
What is tearing? · reactwg/react-18 · Discussion #69
Why useSyncExternalStore Is Not Used in Jotai · Daishi Kato's blog