什么是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 并发模型设计的核心安全特性之一。

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

相关推荐
Larcher31 分钟前
新手也能学会,100行代码玩AI LOGO
前端·llm·html
徐子颐43 分钟前
从 Vibe Coding 到 Agent Coding:Cursor 2.0 开启下一代 AI 开发范式
前端
小月鸭1 小时前
如何理解HTML语义化
前端·html
jump6801 小时前
url输入到网页展示会发生什么?
前端
诸葛韩信1 小时前
我们需要了解的Web Workers
前端
brzhang1 小时前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
yivifu2 小时前
JavaScript Selection API详解
java·前端·javascript
这儿有一堆花2 小时前
告别 Class 组件:拥抱 React Hooks 带来的函数式新范式
前端·javascript·react.js
十二春秋2 小时前
场景模拟:基础路由配置
前端
六月的可乐2 小时前
实战干货-Vue实现AI聊天助手全流程解析
前端·vue.js·ai编程