什么是Tearing?为什么React的并发渲染可能会有Tearing?

上次讲到了React的并发渲染,说到React18中基于Fiber的并发渲染机制,React可能会中断当前渲染,然后处理优先级更高的UI交互。那么在这个过程中,是否有些问题呢?我们今天接着说。

什么是Tearing

tearing字面意思是撕裂、撕开,对应着React就是页面的信息"被撕裂了"。意思就是UI渲染出来的结果不一致了。即同一个state在不同的组件里显示出了不同的状态,有一个或多个组件渲染了旧状态,有一个或多个组件渲染的最新的状态。

Tearing 通常发生在多个状态更新同时进行时,尤其是在使用并发特性(如useTransitionSuspense)时。当应用程序的状态发生变化,而 React 正在渲染一个组件的旧版本时,如果此时状态再次更新,那么在不同的渲染帧中,用户可能会看到部分界面显示的是旧状态,而另一部分显示的是新状态,从而导致视觉上的不一致。

为什么会发生Tearing

在传统的同步渲染中,React 会一次性完成整个组件的渲染,然后提交到 DOM,因此不会出现中间状态。但在并发模式下,React 可以暂停渲染过程去处理更高优先级的任务(比如用户输入),然后再回来继续渲染。这就可能导致:

  1. 多个状态更新交错进行:一个更新正在渲染中,另一个更新介入并改变了状态,导致先前的渲染结果可能已经过时。
  2. 部分更新提交:React 可能会分批次提交渲染结果(例如,由于Suspense边界),导致界面的不同部分显示不同状态的数据。

注意 :在React的中,tearing仅会在依赖了外部了外部状态的情况下才会发生,并且主要是发生在并发渲染中

为什么在并发渲染中容易出现

React 18 引入的并发渲染允许渲染过程被中断和恢复:

  1. 渲染开始:React 开始基于当前状态 State v1 生成 UI。
  2. 状态更新:此时外部状态突然变为 State v2。
  3. 渲染恢复:部分组件使用 State v1,部分使用 State v2,导致 UI 不一致。

代码演示:

javascript 复制代码
// 外部状态(非 React 管理)
let externalState = 0;

const ComponentA = () => {
  const [state, setState] = React.useState(externalState);
  
  // 监听外部状态变化(非 React 标准方式)
  React.useEffect(() => {
    const interval = setInterval(() => {
      externalState = Math.random(); // 外部状态突变
      setState(externalState);      // 触发 React 更新
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  return <div>ComponentA: {state}</div>;
};

const ComponentB = () => {
  // 直接读取外部状态(可能过时)
  return <div>ComponentB: {externalState}</div>;
};

function App() {
  return (
    <>
      <ComponentA />
      <ComponentB />
    </>
  );
}

问题:ComponentA 通过 React 状态更新,而 ComponentB 直接读取外部状态。当 externalState 突变时,两者可能显示不同的值。

如何解决Tearing

React18提供了专用API,即useSyncExternalStore来确保了状态的一致性:

  1. 直接使用useSyncExternalStore:

转为外部数据集成设计,保证状态读取的原子性。

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

// 定义外部 store
const store = {
  state: 0,
  listeners: new Set(),
  setState(newState) {
    this.state = newState;
    this.listeners.forEach(listener => listener());
  },
  subscribe(listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  },
  getSnapshot() {
    return this.state;
  }
};

const Component = () => {
  // 使用 useSyncExternalStore 绑定外部 store
  const state = useSyncExternalStore(
    store.subscribe,
    store.getSnapshot
  );
  return <div>{state}</div>;
};
  1. 使用基于React的状态管理

主流库(Redux、Zustand)已经适配了useSyncExternalStore

javascript 复制代码
// store.js
import { create } from 'zustand'

const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
  updateBears: (newBears) => set({ bears: newBears }),
}))

// BearCounter.jsx
function BearCounter() {
  const bears = useStore((state) => state.bears)
  return <h1>{bears} bears around here...</h1>
}

function Controls() {
  const increasePopulation = useStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

关于Tearing的思考

刚刚有说过,如果你使用的是外部数据管理才会有Tearing,那么如果使用React中的状态管理,即useState/useContext等就不会产生Tearing了。那么这是如何保证的呢?

其实这个还是得益于React的核心更新机制:

  1. 原子性快照:当 React 开始一次渲染时,它会为整个组件树捕获当前所有状态的一致性快照。即使有交错的状态更新:
scss 复制代码
// 示例:交错的状态更新
const [count, setCount] = useState(0);
const [text, setText] = useState('');

useEffect(() => {
  setCount(1);       // 更新1
  setText('Hello');  // 更新2
}, []);

React 会将这两个更新批处理,并在下一次渲染中使用统一的新状态值。

  1. 不可变状态读取:在渲染过程中,组件读取的状态值来自 "渲染开始时确定的快照"。即使更新在渲染中途被触发:
javascript 复制代码
const ComponentA = () => {
  const [value] = useState(0);
  // 即使其他组件在此处触发更新...
  return <div>{value}</div>; // 仍显示渲染开始时的值
};

纯React中的状态不会引起tearing

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

function App() {
  const [count, setCount] = useState(0);
  
  // 模拟快速交错更新
  useEffect(() => {
    const interval = setInterval(() => {
      setCount(c => c + 1);  // 更新1
      setCount(c => c + 1);  // 更新2(交错)
    }, 100);
    return () => clearInterval(interval);
  }, []);

  return (
    <>
      <ComponentA count={count} />
      <ComponentB count={count} />
    </>
  );
}

// 两个组件接收相同的props
const ComponentA = ({ count }) => <div>A: {count}</div>;
const ComponentB = ({ count }) => <div>B: {count}</div>;

在此示例中:

  1. 即使 setCount 被快速连续调用
  2. 即使使用并发渲染(如 createRoot)
  3. ComponentA 和 ComponentB 总是显示相同的值

Tearing只发送在下图场景:

为什么外部状态会导致tearing

  1. 脱离 React 调度:外部状态更新不受 React 批处理和优先级控制
  2. 读取非原子性:不同组件在不同时间点读取值可能不同
javascript 复制代码
// 伪代码演示危险操作
let externalState = 0;

const updateState = () => {
  externalState = Date.now(); // 突变外部状态
};

// 组件A
const A = () => <div>{externalState}</div>;

// 组件B
const B = () => {
  // 如果渲染在此处暂停,A和B可能显示不同值
  return <div>{externalState}</div>;
};

结论:

场景 是否可能发生 tearing 原因
纯 React 状态(useState/useContext) ❌ 不可能 React 保证原子性渲染
外部状态 + useSyncExternalStore ❌ 不可能 React 18+ 提供原子读取
直接操作外部状态 ✅ 可能 脱离 React 控制机制

关键总结: 只要使用 React 内置状态管理且不直接操作外部可变状态,即使在并发渲染下,React 也绝对保证不会发生 tearing。这是 React 并发模型设计的核心安全特性之一。

喜欢就点个关注,不定期分享一些技术细节、感悟、新鲜事等。感谢!

相关推荐
tianzhiyi1989sq25 分钟前
Vue3 Composition API
前端·javascript·vue.js
今禾31 分钟前
Zustand状态管理(上):现代React应用的轻量级状态解决方案
前端·react.js·前端框架
用户25191624271133 分钟前
Canvas之图形变换
前端·javascript·canvas
今禾41 分钟前
Zustand状态管理(下):从基础到高级应用
前端·react.js·前端框架
gnip1 小时前
js模拟重载
前端·javascript
Naturean1 小时前
Web前端开发基础知识之查漏补缺
前端
curdcv_po1 小时前
🔥 3D开发,自定义几何体 和 添加纹理
前端
单身汪v1 小时前
告别混乱:前端时间与时区实用指南
前端·javascript
鹏程十八少1 小时前
2. Android 深度剖析LeakCanary:从原理到实践的全方位指南
前端
我是ed1 小时前
# cocos2 场景跳转传参
前端