被撕碎的React - Tearing

397 阅读5分钟

简单介绍下React 18 Concurrent Mode下的Tearing问题,以及一些状态库的在Concurrent Mode的表现



背景

要介绍 Tearing 我们需要先了解下 Concurrent Mode

Concurrent Mode

What is Concurrent React? - React

先看看这个简单例子的对比

SynchronousConcurrent Mode
0.gif1.gif\
🚧卡顿🏂丝滑

Synchronous:在 React 18 之前整个渲染过程是不能被中断的,因此在渲染过程中或许不能及时响应用户的交互,当渲染开销比较大时用户在交互过程中会明显地感觉到卡顿。

  • 🌰举个例子:【一个输入框组件input ➕ 一个耗时的组件Heavy】,依次输入1,2,3然后再全部删掉

0.gif

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

3.gif

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(比如 useTransitionuseDeferredValue




React Tearing


什么是Tearing?

Tearing 通常是指在视觉上产生了不一致的情况。比如 Screen Tearing 中就是指不同帧的画面同时出现在了显示屏幕上,导致画面显示矛盾

4.png


React Tearing 则是指 同一个state在渲染过程中却展示了不同的value

下面是Concurrent Mode下一百个计数器共享一个相同的state,这里我们连续点击三次 +1code

5.gif

🎯可以发现在Concurrent Mode下,这一百个计数器虽然共享一个state,却出现了不同的value


对比 Synchronous 和 Concurrent

我们写一个counter的例子,其中执行add的时候会运行耗时操作。再从React Tearing角度来看看两种模式下的区别

SynchronousConcurrent
6.gif7.gif

可以发现

  • 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

8.gif


用法useStateuseReduceruseSyncExternalStorezustandjotai
是否卡顿
是否Tearing
  • 是否卡顿:指在rerender时是否还能相应用户交互,比如上面的useSyncExternalStore或zustand在点击一次后因为rerender再次点击时就会卡住(可以看见按钮一直处于press的状态)

  • 是否Tearing:状态更新产生不一致的情况,比如jotai连续点击三次后同时出现了不同value的状态值

从上面的测试我们可以发现

  • useState或useReducer是可以保证Concurrent模式下不卡顿同时也不会Tearing

  • useSyncExternalStore 或 zustand 由于无法在Concurrent下响应用户输出会导致卡顿不会Tearing

  • jotai 可以适配Concurrent模式不卡顿会Tearing





一些补充


  1. 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 的好处



  1. Jotai为什么会导致Tearing


9.gif

从上面的例子我们可以知道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问题(releasePR



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

dai-shi/will-this-react-global-state-work-in-concurrent-rendering: Test tearing and branching in React concurrent rendering