本文系统梳理什么是“撕裂”(tearing)、为什么在并发渲染更容易出现、一个会产生撕裂的错误用法示例、如何用 useSyncExternalStore 正确对接外部可变状态源,以及在实际工程中的取舍与最佳实践。
一、什么是“撕裂”(Tearing)
-
定义:在一次渲染提交中,多个依赖同一外部可变数据源的组件读到了不同版本的快照,导致同一屏 UI 展示互相矛盾的状态。
-
直观现象:
- 同一页面里两个组件,一个显示旧值,一个显示新值。
- 界面看到的是 A,事件回调/副作用里用到的却是 B。
-
前提:组件在渲染时直接读取“React 之外的可变数据源”(例如全局单例对象、第三方 store、浏览器环境状态等),而不是 React 的 state/props/context。
二、为什么并发渲染更容易发生撕裂
React 18 的并发渲染允许渲染过程被打断、暂停、恢复或丢弃。当渲染尚未提交时,外部数据源可能发生变化,如果组件在渲染阶段直接读外部源:
- 组件 A 在较早的时间片读取“旧快照”;
- 渲染尚未提交,外部源更新为“新快照”;
- 组件 B 在稍后的时间片读取“新快照”;
- 一次提交落地后,A 与 B 同屏呈现不同版本,出现撕裂。
时间线示意:
- t0:外部 store 的 count=0,React 开始一次渲染。
- t1:组件 FastPane 渲染,读取到 count=0。
- t2:未提交时,外部 store 更新为 count=1。
- t3:组件 SlowPane 渲染,读取到 count=1。
- t4:React 提交这次渲染,FastPane 显示 0、SlowPane 显示 1。
三、一个会产生撕裂的“错误用法”示例
下面的自建全局 store 与天真的订阅 hook,在并发渲染下存在撕裂风险(示例中的忙等仅用于模拟慢组件,不要在生产中使用)。
// 简易外部 store(树外,可变)
type State = { count: number }
let state: State = { count: 0 }
const listeners = new Set<() => void>()
const store = {
getSnapshot: () => state,
set(partial: Partial<State>) {
state = { ...state, ...partial }
listeners.forEach(l => l())
},
subscribe(l: () => void) {
listeners.add(l)
return () => listeners.delete(l)
}
}
// 天真 hook:并发场景不安全
function useStoreNaive() {
const [snap, setSnap] = React.useState(store.getSnapshot())
React.useEffect(() => store.subscribe(() => setSnap(store.getSnapshot())), [])
return snap
}
function FastPane() {
const { count } = useStoreNaive()
return <div>Fast: {count}</div>
}
function SlowPane() {
const { count } = useStoreNaive()
// 模拟耗时渲染(仅演示)
const start = performance.now()
while (performance.now() - start < 100) {}
return <div>Slow: {count}</div>
}
function App() {
React.useEffect(() => {
const id = setInterval(
() => store.set({ count: store.getSnapshot().count + 1 }),
200
)
return () => clearInterval(id)
}, [])
return (
<>
<FastPane />
<SlowPane />
</>
)
}
在并发渲染与中途外部更新的组合下,FastPane 和 SlowPane 可能读到不同快照并一起提交,发生撕裂。
四、useSyncExternalStore 如何解决撕裂
核心思想:让 React 成为“快照的协调者”。你提供订阅与读取快照的方法,React 在渲染时统一读取快照;若渲染中途快照变化,React 会在提交前中止并重试,确保一次提交内所有消费者看到的是同一版本。
- 第一个参数 subscribe:告诉 React 如何订阅外部源的变更。
- 第二个参数 getSnapshot:告诉 React 当前快照是什么,必须是同步、可比较的值。
- 第三个参数 getServerSnapshot:SSR 备用快照获取(CSR 可与第二个相同)。
正确写法:
function useStoreSafe() {
return React.useSyncExternalStore(
store.subscribe, // 订阅外部源
store.getSnapshot, // 客户端快照
store.getSnapshot // SSR 快照(示例沿用)
)
}
function FastPane() {
const { count } = useStoreSafe()
return <div>Fast: {count}</div>
}
function SlowPane() {
const { count } = useStoreSafe()
const start = performance.now()
while (performance.now() - start < 100) {}
return <div>Slow: {count}</div>
}
这样即使渲染过程中外部 store 更新,React 也会检测到快照变化并重新渲染,避免“同一轮提交中的不一致”。
五、工程实践与注意事项
-
优先级选择
- 如果状态可以放进 React(state/props/context),优先这么做,React 自带一致性保证,天然无撕裂。
- 必须读“外部可变源”时,使用 useSyncExternalStore,或选用已适配它的状态库(如 React-Redux v8+、Zustand、Jotai)。
-
选择器与细粒度订阅
-
useSyncExternalStore 不自带 selector 参数。若你只关心状态的一部分,可封装一个 useStoreSelector:
- 在 getSnapshot 中返回“被选择的那一片段”,确保该片段在未变化时引用稳定(React 按 Object.is 比较)。
- 避免每次 getSnapshot 都创建新对象,否则每次都会触发重渲染。
-
简例(要点是 getSnapshot 返回可比较的片段,必要时做结构性缓存):
function useStoreSelector<T>(selector: (s: State) => T) { const getSnap = React.useCallback(() => selector(store.getSnapshot()), [selector]) return React.useSyncExternalStore(store.subscribe, getSnap, getSnap) } // 用法:只订阅 count const count = useStoreSelector(s => s.count)
-
-
订阅契约
- subscribe 必须在“变更完成并可观察到”后通知监听器,避免在渲染中途异步穿插导致时序问题。
- getSnapshot 应该是纯函数、同步返回当前快照,不做副作用。
-
SSR/Hydration
- 提供稳定的 getServerSnapshot,确保服务端与客户端的初始快照一致,避免水合不匹配。
-
性能
- 外部 store 内部可做浅比较/结构共享,确保未变化的切片保持引用不变,减少不必要的重渲染。
六、哪些场景需要(或不需要)useSyncExternalStore
-
需要:
- 自建或第三方的“树外”可变 store(全局单例状态、实时数据源)。
- 依赖浏览器环境状态并希望在并发/过渡/Suspense/SSR 中保持一致(如媒体查询、网络状态、可见性等)。
-
不需要:
- 完全使用 React 的 state/props/context 传递数据。
- 来自已适配 React 18 的成熟状态库(React-Redux v8、Zustand、Jotai 等)——它们内部已用 uSES。
七、与 Context/Hooks 的关系与取舍
-
Context 适合“树内作用域化”的配置(主题、语言、尺寸等),配合“拆分多个 Context + useMemo”即可避免大多数无关更新。
-
useSyncExternalStore 解决的是“树外可变源 + 并发渲染”的一致性问题,两者面向的问题域不同。
-
在一个项目中,常见组合是:
- UI 配置/主题用 Context;
- 业务状态用外部 store(Redux/Zustand)+ uSES;
- 组件内部局部状态用 useState/useReducer。
八、一句话总结
“撕裂”是指同一次提交中,不同组件读取到了同一外部状态的不同版本。React 18 引入 useSyncExternalStore,用“统一订阅 + 快照协调”的方式确保并发渲染下的快照一致性,从根本上避免这一类不一致。工程上优先把状态放进 React;当必须读取树外可变源时,使用 useSyncExternalStore 或选用已适配它的状态库,就是正确的做法。