什么是React并发模式中的Tearing(撕裂)

🚀 探索更多React Hooks的可能性? ReactUse.com 为你提供精心设计的自定义Hooks,让你的React开发效率翻倍!

并发渲染带来的新挑战

在React 18之前,React总是同步渲染,这意味着一旦开始渲染,整个过程不会被中断。但React 18引入了并发渲染,允许React在渲染过程中暂停和恢复,以便处理更高优先级的任务(如用户交互)。

这种机制虽然提升了用户体验,但也带来了一个新问题:当React在渲染过程中暂停时,外部数据源可能会发生变化,导致同一次渲染中的不同组件看到不同的数据快照,从而产生UI撕裂现象。

什么是撕裂现象?

让我们先通过一个例子来了解撕裂现象:

tsx 复制代码
import { useEffect, useState, startTransition } from 'react';

let externalState = { counter: 0 };
let listeners: any[] = [];

function dispatch(action: { type: any }) {
  if (action.type === 'increment') {
    externalState = { counter: externalState.counter + 1 };
  } else {
    throw Error('Unknown action');
  }
  listeners.forEach((fn) => fn());
}

function subscribe(fn: () => void) {
  listeners = [...listeners, fn];
  return () => {
    listeners = listeners.filter((f) => f !== fn);
  };
}

function useExternalData() {
  const [state, setState] = useState(externalState);
  useEffect(() => {
    const handleChange = () => setState(externalState);
    const unsubscribe = subscribe(handleChange);
    return unsubscribe;
  }, []);
  return state;
}

setInterval(() => {
  dispatch({ type: 'increment' });
}, 50);

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <div className="App">
      <button
        onClick={() => {
          startTransition(() => {
            setShow(!show);
          });
        }}
      >
        切换内容
      </button>
      {show && (
        <>
          <SlowComponent />
          <SlowComponent />
          <SlowComponent />
          <SlowComponent />
          <SlowComponent />
        </>
      )}
    </div>
  );
}

function SlowComponent() {
  let now = performance.now();
  while (performance.now() - now < 200) {
    // 模拟慢组件
  }
  const state = useExternalData();
  return <h3>计数器: {state.counter}</h3>;
}

Try it out

运行这个例子,你会惊讶地发现,当点击按钮显示内容后,几个相同的SlowComponent组件在显示的瞬间竟然显示了不同的内容!

这就是React中所说的"撕裂"现象,它是由React 18的并发特性引入的。

撕裂是图形编程中传统使用的术语,指视觉上的不一致性。

例如,在视频中,屏幕撕裂是指在单个屏幕中看到多个帧,这使得视频看起来"有故障"。在用户界面中,"撕裂"是指UI显示了同一状态的多个值。例如,你可能在列表中显示同一商品的不同价格,或者提交了错误价格的表单,甚至在访问过时的存储值时崩溃。

由于JavaScript是单线程的,这个问题通常不会在Web开发中出现。但在React 18中,并发渲染使这个问题成为可能,因为React在渲染过程中会让出控制权。这意味着当使用像startTransition或Suspense这样的并发特性时,React可以暂停以让其他工作发生。在这些暂停之间,可能会有更新偷偷进入,改变正在用于渲染的数据,这可能导致UI为同一数据显示两个不同的值。

这个问题不是React特有的,它是并发的必然结果。如果你希望能够中断渲染以响应用户输入获得更响应的体验,你需要对正在渲染的数据发生变化并导致用户界面撕裂保持弹性。

同步渲染时代

在React 18之前,React总是同步渲染的。

以下图为例:

第一张图中,我们可以看到组件访问外部store获取状态并将其渲染到组件中。

第二张图中,其他组件也渲染这个状态。由于我们的渲染过程不会被中断,所以渲染到第二个组件。

第三张图中,我们可以看到所有组件都被渲染,此时页面会呈现统一的UI。

如果外部store发生变化,我们讨论的组件将从第一张图重新开始。

并发渲染时代

仍然以下图为例:

第一张图中,初始外部store是蓝色的,第一个组件渲染为蓝色,没有问题。

第二张图中,假设用户点击按钮将外部store改为红色。此时,渲染会被中断,React会让用户操作生效以提高用户体验。

第三张图中,其他组件继续渲染,获取到已更改的外部store并渲染为红色。

第四张图中,几个组件被渲染,但由于并行渲染问题,不会检测到外部store的变化。要重新渲染所有组件,相同的数据将显示不同的值。这种情况就是撕裂现象。

解决方案:useSyncExternalStore

那么我们如何解决外部状态的撕裂问题呢?这就是useSyncExternalStore Hook发挥作用的地方。

useSyncExternalStore钩子的一个例子是,它将在渲染期间检测外部状态的变化,并在向用户显示不一致的UI之前重新开始渲染。这里最坏的情况是渲染需要很长时间,但用户总是会看到一致的UI。

让我们使用useSyncExternalStore重写上面的例子:

javascript 复制代码
import { useState, useSyncExternalStore, useTransition } from 'react';

const counterStore = {
  value: 0,
  listeners: new Set(),

  subscribe(listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  },

  getSnapshot() {
    return this.value;
  },

  increment() {
    this.value++;
    this.listeners.forEach(listener => listener());
  }
};

setInterval(() => {
  counterStore.increment();
}, 10); 

function useCounter(): number {
  return useSyncExternalStore(
    counterStore.subscribe.bind(counterStore),
    counterStore.getSnapshot.bind(counterStore)
  );
}

function SlowComponent() {
  const count = useCounter();

  let now = performance.now();
  while (performance.now() - now < 200) {
  }

  return <div>计数器: {count}</div>;
}

export default function App() {
  const [show, setShow] = useState(false);
  const [isPending, startTransition] = useTransition();

  const toggleSlowComponents = () => {
    startTransition(() => {
      setShow(!show);
    });
  };

  return (
    <div>
      <button onClick={toggleSlowComponents} disabled={isPending}>
        {isPending ? '加载中...' : '切换慢组件'}
      </button>
      {show && (
        <>
          <SlowComponent />
          <SlowComponent />
          <SlowComponent />
          <SlowComponent />
          <SlowComponent />
        </>
      )}
    </div>
  );
}

Try it out

总结

React 18的并发渲染特性虽然提升了应用的响应性和用户体验,但也引入了UI撕裂这一新的挑战。当React在渲染过程中暂停以处理高优先级任务时,外部数据源的变化可能导致同一次渲染中的不同组件看到不一致的数据快照。

核心要点:

  1. 问题本质:撕裂现象是并发渲染的必然结果,不是React特有的问题,而是所有并发系统都需要面对的挑战。

  2. 解决策略useSyncExternalStore Hook通过在渲染期间监测外部状态变化,并在检测到不一致时重新开始渲染,确保UI的一致性。

  3. 最佳实践

    • 对于外部状态管理,优先使用useSyncExternalStore
    • 理解并发渲染的工作机制,合理使用startTransitionSuspense
    • 在设计状态管理方案时考虑并发安全性
  4. 性能权衡:虽然重新渲染可能影响性能,但保证UI一致性比渲染速度更重要,用户体验的可预测性是首要目标。

通过正确使用useSyncExternalStore,开发者可以在享受React 18并发特性带来的性能提升的同时,避免UI撕裂问题,构建更加健壮和用户友好的应用程序。

相关推荐
a cool fish(无名)9 分钟前
rust-模块树中引用项的路径
java·前端·rust
前端进阶者19 分钟前
天地图Marker跳一跳动画
前端
火柴就是我23 分钟前
每日见闻之Three.js 根据官方demo 理解相机位置
前端
KarrySmile24 分钟前
Day04–链表–24. 两两交换链表中的节点,19. 删除链表的倒数第 N 个结点,面试题 02.07. 链表相交,142. 环形链表 II
算法·链表·面试·双指针法·虚拟头结点·环形链表
JosieBook32 分钟前
【web应用】基于Vue3和Spring Boot的课程管理前后端数据交互过程
前端·spring boot·交互
刘大猫.39 分钟前
npm ERR! cb() never called!
前端·npm·node.js·npm install·npmm err·never called
咔咔一顿操作43 分钟前
常见问题三
前端·javascript·vue.js·前端框架
上单带刀不带妹44 分钟前
Web Worker:解锁浏览器多线程,提升前端性能与体验
前端·js·web worke
电商API大数据接口开发Cris1 小时前
Node.js + TypeScript 开发健壮的淘宝商品 API SDK
前端·数据挖掘·api
还要啥名字1 小时前
基于elpis下 DSL有感
前端