什么是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撕裂问题,构建更加健壮和用户友好的应用程序。

相关推荐
知识分享小能手3 小时前
Vue3 学习教程,从入门到精通,Axios 在 Vue 3 中的使用指南(37)
前端·javascript·vue.js·学习·typescript·vue·vue3
程序员码歌5 小时前
【零代码AI编程实战】AI灯塔导航-总结篇
android·前端·后端
用户21411832636026 小时前
免费玩转 AI 编程!Claude Code Router + Qwen3-Code 实战教程
前端
小小愿望7 小时前
前端无法获取响应头(如 Content-Disposition)的原因与解决方案
前端·后端
小小愿望7 小时前
项目启功需要添加SKIP_PREFLIGHT_CHECK=true该怎么办?
前端
烛阴7 小时前
精简之道:TypeScript 参数属性 (Parameter Properties) 详解
前端·javascript·typescript
海上彼尚8 小时前
使用 npm-run-all2 简化你的 npm 脚本工作流
前端·npm·node.js
开发者小天9 小时前
为什么 /deep/ 现在不推荐使用?
前端·javascript·node.js
恋喵大鲤鱼9 小时前
Golang 后台技术面试套题 1
面试·golang
why技术9 小时前
也是震惊到我了!家里有密码锁的注意了,这真不是 BUG,是 feature。
后端·面试