React 18 的并发挑战:如何解决状态 Tearing 问题?
我们在上一章提到 React18 最主要的特性就属并发更新了,同时由于并发更新的特性也带来了 Tearing 的问题。
本章让我们来深入研究一下什么是 React Tearing,以及 React 给状态管理库生态提供了什么新的 API 来解决这个问题。
什么是 React Tearing
普通情况下,当用户触发更新的时候,整个 React 渲染过程是不可被打断的,直到渲染流程结束之后才可以继续执行其他任务。
比如 React 现在正在渲染下面的组件树,其中子组件 Cpn4、Cpn5、Cpn6 依赖了外部的状态。React 会以 DFS (深度优先遍历)的方式去遍历整棵树,也就是说会以 Cpn1 -> Cpn2 -> Cpn4 -> Cpn5 -> Cpn3 -> Cpn6
这样的顺序来去遍历:
当渲染到 Cpn4 的时候,用户执行一个操作,从而去触发 Store 状态的变化,但是由于渲染并没有结束,所以会继续遍历剩余组件:
可以看到,虽然用户执行改变 Store 的状态的操作,但此时需要等待渲染结束后才能真正更新 Store 状态。当整个过程结束,接下来会改变外部 Store 的状态:
可以看到整个渲染过程不会被打断,因此引用外部 Store 的各个组件获取的状态是一致的。
不过 React18 增加了并发更新机制,本质上是时间切片,并且高优先级会打断低优先级的任务
。在渲染的过程中,由于整个连续不断的渲染过程拆分成了一个个分片的渲染片段,因此在渲染的间隙时就有机会去响应用户的操作:
我们来看一下上面的过程在 React18 之后是怎么样的:
可以看到当渲染到 Cpn4 时,拿到的是 Store V1 的状态,这时候用户的操作(例如点击事件)改变了外部的状态。在恢复继续渲染时就发生了状态不一致的现象,即 Cpn4 引用的是 Store V1 的状态,而 Cpn5 和 Cpn6 引用的是 Store V2 的状态。这就是 **React Tearing(撕裂)问题**
,即各个组件展示的状态不一致的问题。可以看到,虽然 React18 并发更新带来了诸多优势,但也给状态管理社区带来了新的问题和挑战。
举个实际的 🌰,在 react-redux 7 中,用 startTransition
来开启并发更新,并用 while (performance.now() - start < 20) {}
延长每个组件 render 的时间,模拟真实的 render 过程:
export default function Counter() {
const value = useSelector((state) => state);
const start = performance.now();
while (performance.now() - start < 20) {}
return <div>{value}</div>;
}
可以看到,当连续点击按钮的时候状态发生了不一致的情况,那最终为什么状态一致了呢?这是因为 Tearing 的问题是发生在点击的过程中的。在用户的操作改变外部 Store 的状态后会触发 re-render(重新渲染),最后一次的 re-render 每个组件所引用 store 状态都是最新的状态,所以最终还是会趋于一致。
react-redux 8 引入了 useSyncExternalStore
来解决这个问题,我们将 react-redux 版本升级到 8,再来看下效果:
可以戳这里,直接在线查看 Demo, 可以手动切换 react-redux 版本查看效果:codesandbox.io/s/react-tea…
可以看到不会有之前的问题了,各个组件的状态保持了一致,但是渲染变得卡顿了,打开 performance 面板,整个过程如下:
原先:
现在:
React 提供了 useSyncExternalStore
来解决这个问题,核心原理就是将这次的并发更新变为同步更新(也就是不可中断) 。整个并发更新过程变回同步不可被中断了,自然也就不会有这个问题了。
useSyncExternalStore 与 use-sync-external-store
useSyncExternalStore
React18 提供了一个新的 API useSyncExternalStore
,在我们日常开发中不会用到,但对于状态管理库来说则非常重要。useSyncExternalStore
提供了一种标准化的方式来共享外部状态,并保证组件与这些外部状态源的同步,简化了跨组件状态共享的复杂度,也解决了我们在上文提到的 React Tearing 的问题。
useSyncExternalStore
的基本用法如下:
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
subscribe
:一个函数,接受一个监听器(listener)回调。当外部数据源更新时,这个监听器应该被调用,以通知 React 组件需要重新渲染。这个函数应该返回一个取消订阅的函数。getSnapshot
:一个函数,返回当前的外部状态快照。React 会在订阅外部数据源时调用它来获取最初的状态,之后每当外部数据源通知 React 更新时,也会调用它来获取最新状态。getServerSnapshot
(可选):在服务端渲染(SSR)中使用,返回当前外部状态的快照,类似于getSnapshot
,但专门用于 SSR 使用。
我们来实现一个例子帮助大家更深入理解这个 Api,理解这个例子非常重要,我们在接下来的章节学习 Zustand 源码时你会发现和这个 Demo 非常相似。
点击查看完整 Demo:codesandbox.io/p/sandbox/u…
import { useSyncExternalStore } from "react";
// 外部状态管理器
const store = {
state: { count: 0 },
listeners: new Set(),
setState(newState) {
this.state = newState;
// 触发订阅该store的组件re-render
this.listeners.forEach((listener) => listener());
},
subscribe(listener) {
this.listeners.add(listener);
// 取消订阅
return () => this.listeners.delete(listener);
},
getState() {
return this.state;
},
};
function useStore() {
const state = useSyncExternalStore(
// 订阅函数
(listener) => store.subscribe(listener),
// 获取当前快照
() => store.getState()
);
return state;
}
export default function App() {
const { count } = useStore();
return (
<div>
Count: {count}
<button onClick={() => store.setState({ count: count + 1 })}>
Increment
</button>
</div>
);
}
在这个例子中我们封装了一个自定义 Hook —— useStore
,通过 useSyncExternalStore
订阅了外部的 store
状态源。当 store
的状态改变时(setState
),会遍历 listeners
中的全部 listener
来 re-render 订阅该 store
的全部组件,也就是说导致使用了 useStore
的组件重新渲染。
use-sync-external-store 库
社区的各个状态管理库并没有直接使用 useSyncExternalStore
API,而是使用 use-sync-external-store 这个库。因为 useSyncExternalStore
是 React18 提供的一个 API,如果项目是 React17 会拿不到这个 API。而 use-sync-external-store 会根据 React 是否暴露这个 API,如果暴露了,就直接使用,否则会使用该库自己实现的一套。也就是说 useSyncExternalStore
分为两个版本,一个是 React18 内置的,一个是自己实现的一套。
use-sync-external-store 库是在 React 仓库中实现的,并独立发布于 npm 上。
npm 地址:www.npmjs.com/package/use…
这里的 “垫片(shim)” 的意思是 use-sync-external-store 关于 useSyncExternalStore
的内置实现,用于兼容 React18 以下的版本。
而社区的各个状态管理库也并没有直接用 useSyncExternalStore
,而是会使用 useSyncExternalStoreWithSelector
。useSyncExternalStoreWithSelector
相较于useSyncExternalStore
会增加两个额外的参数传入:
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot: undefined | null | (() => Snapshot),
selector: (snapshot: Snapshot) => Selection,
isEqual?: (a: Selection, b: Selection) => boolean,
): Selection;
selector
:用于从整个状态中选择一个子集。isEqual
:(可选):一个函数,用于比较前后两次选择的状态是否相等,如果相等则说明这次状态没有发生变化,组件不需要 re-render,从而避免不必要的重渲染。
也就是说,useSyncExternalStoreWithSelector
是带 selector
(选择器)版本的 useSyncExternalStore
,各个状态管理库可以基于这个 API 更轻松地实现状态的订阅。各个组件只关心选取出来的状态(或者说只关心组件自己关心的状态),其他的状态发生变化不会导致组件发生 re-render,从而进一步优化性能和开发体验。
use-sync-external-store 源码解读
接下来我们就讲解下 use-sync-external-store
库的原理,首先是 useSyncExternalStore
Api 的实现:
useSyncExternalStore
我们在前面提到 useSyncExternalStore
会区分 React 是否支持(即 React 是否导出了这个 Api)来选择使用 React 原生实现还是 use-sync-external-store 的实现版本:
// 原生实现
import {useSyncExternalStore as builtInAPI} from 'react';
export const useSyncExternalStore: <T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
) => T = builtInAPI !== undefined ? builtInAPI : shim;
我们在这里重点讲解 use-sync-external-store 关于 useSyncExternalStore
的实现版本,理解 useSyncExternalStore
的内部逻辑对于日后开发属于我们自己的状态管理库非常重要。useSyncExternalStore
分为 client 端和 server 端两个实现,React 会根据 canUseDOM
来区分不同环境:
import {useSyncExternalStore as client} from './useSyncExternalStoreShimClient';
import {useSyncExternalStore as server} from './useSyncExternalStoreShimServer';
const canUseDOM: boolean = !!(
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined'
);
const shim = canUseDOM ? client : server;
server 端(给 SSR 用的)的实现如下:
export function useSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
return getSnapshot();
}
可以看到,server 端只是简单调用了一下传入的 getSnapshot
并返回取得的状态。
client 端实现如下:
export function useSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
const value = getSnapshot();
// forceUpdate用来触发组件re-render
const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});
useLayoutEffect(() => {
inst.value = value;
inst.getSnapshot = getSnapshot;
if (checkIfSnapshotChanged(inst)) {
forceUpdate({inst});
}
}, [subscribe, value, getSnapshot]);
useEffect(() => {
if (checkIfSnapshotChanged(inst)) {
forceUpdate({inst});
}
const handleStoreChange = () => {
// 这里做了性能优化,会判断前后状态是否变化,如果没有变化则不会re-render
if (checkIfSnapshotChanged(inst)) {
forceUpdate({inst});
}
};
// 订阅,把handleStoreChange传入到订阅函数subscribe中,最终在状态管理库中会调用handleStoreChange来触发re-render
return subscribe(handleStoreChange);
}, [subscribe]);
return value;
}
// 工具函数,判断状态是否变化
function checkIfSnapshotChanged<T>(inst: {
value: T,
getSnapshot: () => T,
}): boolean {
const latestGetSnapshot = inst.getSnapshot;
const prevValue = inst.value;
try {
const nextValue = latestGetSnapshot();
return !Object.is(prevValue, nextValue);
} catch (error) {
return true;
}
}
我们来回顾一下前面在学习 useSyncExternalStore
时举的例子,结合这个例子帮助我们更好的理解 React 源码:
// 外部状态管理器
const store = {
state: { count: 0 },
listeners: new Set(),
setState(newState) {
this.state = newState;
// 触发订阅该store的组件re-render
this.listeners.forEach((listener) => listener());
},
subscribe(listener) {
this.listeners.add(listener);
// 取消订阅
return () => this.listeners.delete(listener);
},
getState() {
return this.state;
},
};
function useStore() {
const state = useSyncExternalStore(
// 订阅函数
(listener) => store.subscribe(listener),
// 获取当前快照
() => store.getState()
);
return state;
}
export default function App() {
const { count } = useStore();
return (
<div>
Count: {count}
<button onClick={() => store.setState({ count: count + 1 })}>
Increment
</button>
</div>
);
}
这里可以分为两块来看:
- 订阅 Store:即这里向
useSyncExternalStore
传入的订阅函数(listener) => store.subscribe(listener)
,结合源码来看我们可以知道,这里传入的listener
其实就对应源码里的handleStoreChange
,如果状态变化,handleStoreChange
就会调用forceUpdate
来完成组件的重新渲染。同时我们把listener
保存到了store
里的listeners
中。 - 更新状态:即调用
store.setState
,store.setState
会依次调用listeners
中的全部listener
,来完成组件的重新渲染。
useSyncExternalStoreWithSelector
然后是 useSyncExternalStoreWithSelector
的源码,useSyncExternalStoreWithSelector
内部会调用 useSyncExternalStore
,相比于 useSyncExternalStore
增加了两个额外的参数 selector
与 isEqual
:
import * as React from 'react';
import is from 'shared/objectIs';
import {useSyncExternalStore} from 'use-sync-external-store/src/useSyncExternalStore';
const {useRef, useEffect, useMemo, useDebugValue} = React;
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
subscribe: (() => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot: void | null | (() => Snapshot),
selector: (snapshot: Snapshot) => Selection,
isEqual?: (a: Selection, b: Selection) => boolean,
): Selection {
// 初始化变量
const instRef = useRef(null);
let inst;
if (instRef.current === null) {
inst = {
hasValue: false,
value: (null: Selection | null),
};
instRef.current = inst;
} else {
inst = instRef.current;
}
// 实现selector版的getSelection、getServerSelection
const [getSelection, getServerSelection] = useMemo(() => {
let hasMemo = false;
let memoizedSnapshot;
let memoizedSelection: Selection;
const memoizedSelector = (nextSnapshot: Snapshot) => {
// ...
};
const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
const getServerSnapshotWithSelector =
maybeGetServerSnapshot === null
? undefined
: () => memoizedSelector(maybeGetServerSnapshot());
return [getSnapshotWithSelector, getServerSnapshotWithSelector];
}, [getSnapshot, getServerSnapshot, selector, isEqual]);
// 通过useSyncExternalStore计算状态
const value = useSyncExternalStore(
subscribe,
getSelection,
getServerSelection,
);
// 返回状态
return value;
}
可以看到,useSyncExternalStoreWithSelector
实现包含了三个部分:
- 实现 selector 版的
getSelection
、getServerSelection
; - 通过
useSyncExternalStore
计算状态; - 最终返回状态;
通过 useMemo
返回的 getSelection
和 getServerSelection
分别对应 getSnapshotWithSelector
、getServerSnapshotWithSelector
。核心在于 memoizedSelector
的实现,getSnapshotWithSelector
和 getServerSnapshotWithSelector
仅仅是用 memoizedSelector
包了一下 getSnapshot
和 getServerSnapshot
而已。
我们来看 memoizedSelector
的实现:
const memoizedSelector = (nextSnapshot: Snapshot) => {
if (!hasMemo) {
hasMemo = true;
memoizedSnapshot = nextSnapshot;
const nextSelection = selector(nextSnapshot);
memoizedSelection = nextSelection;
return nextSelection;
}
const prevSnapshot: Snapshot = (memoizedSnapshot: any);
const prevSelection: Selection = (memoizedSelection: any);
if (Objectis(prevSnapshot, nextSnapshot)) {
return prevSelection;
}
const nextSelection = selector(nextSnapshot);
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
return prevSelection;
}
memoizedSnapshot = nextSnapshot;
memoizedSelection = nextSelection;
return nextSelection;
};
还记得 selector
的作用是什么嘛?selector
会从当前 store
的状态选取你需要的状态。比如,现在 store 的状态是 { count1: 10, count2: 100, count3: 1000 }
,如果你只关心 count1
和 count2
的值,selector
就可以写为:
const snapshot = (state) => ({
count1: state.count1,
count2: state.count2
})
最终 { count1: 10, count2: 100 }
这个对象会作为 useSyncExternalStoreWithSelector
的返回结果。
回到 memoizedSelector
源码,首先当第一次调用这个 Hook 时,即 hasMemo
为 false,此时没有上一次的保存的状态,这时候只需要计算一下最新的状态并更新 memoizedSelection
即可。
nextSnapshot
就是通过传入的 getSnapshot
计算得到,也就是当前 store 的状态。接下来调用 selector(nextSnapshot)
得到选取的状态,然后和上一次选取的状态进行比较:isEqual(prevSelection, nextSelection)
,如果一致则直接返回 prevSelection
,从而来保证引用的一致。可以看到这里是通过你传入的 isEqual
调用的,因为每次都会重新调用 selector
,以上面返回 { count1: 10, count2: 100 }
这个为例,每次调用都会返回一个新的对象,即使 count1
和 count2
都没有变。因此,isEqual
一般都会传入一个 shallowEqual
函数,即浅层比较,会对对象的第一层属性进行比较,如果没有变则会返回 true
,否则返回 false
。
总结
本文我们一起剖析了在 React18 下状态管理库的 Tearing 问题,以及 React 的 useSyncExternalStore
是如何解决这种问题的。之后深入讲解了 use-sync-external-store
库的实现原理。
通过本章节,你可以学习到:
- 什么是 React Tearing。
useSyncExternal
Api。use-sync-external-store
库的实现原理。
理解这一章对于理解状态管理库的实现至关重要,因为 React18 开始就进入了并发更新时代,同时带来了 React Tearing 的问题,React 通过 useSyncExternalStore
来帮助社区状态管理库解决这个问题,同时简化了实现的流程,因此整个社区状态管理库几乎已经转向了使用这个 Api 来实现。在下一章节我们将正式开始 Zustand 的学习。