什么是React Tearing?
普通情况下,当用户触发更新的时候,整个 React 渲染过程是不可被打断的,直到渲染流程结束之后才可以继续执行其他任务。
比如 React 现在正在渲染下面的组件树,其中子组件 Cpn4、Cpn5、Cpn6 依赖了外部的状态。React 会以 DFS (深度优先遍历)的方式去遍历整棵树,也就是说会以 Cpn1 -> Cpn2 -> Cpn4 -> Cpn5 -> Cpn3 -> Cpn6
这样的顺序来去遍历:
当渲染到 Cpn4 的时候,用户执行一个操作,从而去触发 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
来解决这个问题。
useSyncExternalStore
社区的各个状态管理库并没有直接使用 useSyncExternalStore
API,而是使用 use-sync-external-store 这个库。因为 useSyncExternalStore
是 React18 提供的一个 API,如果项目是 React17 会拿不到这个 API。而 use-sync-external-store 会根据 React 是否暴露这个 API,如果暴露了,就直接使用,否则会使用该库自己实现的一套。也就是说 useSyncExternalStore
分为两个版本,一个是 React18 内置的,一个是自己实现的一套。
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;
我们重点关注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;
}
}
让我们逐步分析这段代码:
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}});
这里定义了一个Hook,它接收三个参数:
-
subscribe
是一个订阅函数,返回一个取消订阅的函数。 -
getSnapshot
是一个获取当前快照值的函数。 -
getServerSnapshot
是一个可选函数,用于服务端渲染时获取初始快照值。
useState
创建了一个名为 inst
的状态对象,它包含了当前的值 value
和获取快照的函数 getSnapshot
。forceUpdate
是一个函数,用于强制更新状态,从而触发组件重新渲染。
useLayoutEffect(() => {
inst.value = value;
inst.getSnapshot = getSnapshot;
if (checkIfSnapshotChanged(inst)) {
forceUpdate({inst});
}
}, [subscribe, value, getSnapshot]);
useLayoutEffect
在每次DOM更新之后,浏览器重新绘制之前,同步执行,它用于设置 inst
的值和快照获取函数,并检查快照是否发生了变化。如果快照发生了变化,则通过 forceUpdate
触发组件重新渲染。请注意,useLayoutEffect
的依赖数组中包含 subscribe
函数,这可能不是必要的,因为 subscribe
函数本身不会改变。
useEffect(() => {
if (checkIfSnapshotChanged(inst)) {
forceUpdate({inst});
}
const handleStoreChange = () => {
// 这里做了性能优化,会判断前后状态是否变化,如果没有变化则不会re-render
if (checkIfSnapshotChanged(inst)) {
forceUpdate({inst});
}
};
// 订阅,把handleStoreChange传入到订阅函数subscribe中,最终在状态管理库中会调用handleStoreChange来触发re-render
return subscribe(handleStoreChange);
}, [subscribe]);
useEffect
在浏览器完成绘制之后异步执行。它首先检查快照是否发生变化,并且如果变化则强制更新。然后,它创建一个 handleStoreChange
函数,该函数在外部状态改变时被调用。如果状态确实发生了变化,那么它也会触发组件重新渲染。subscribe
函数被用来订阅外部状态的变化,并返回一个取消订阅的函数。
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;
}
}
checkIfSnapshotChanged
函数用于比较新旧快照是否相同。如果不同,则返回 true
,指示需要重新渲染。这里使用了 Object.is
方法来比较两个值是否严格相等,这可以处理NaN等特殊情况。
总结:
-
useLayoutEffect
负责在每次在每次DOM更新之后,浏览器重新绘制之前,更新inst
的值,并在快照发生变化时触发重新渲染。 -
useEffect
负责订阅外部状态的变化,并在状态发生变化时触发重新渲染。它也负责在组件卸载时取消订阅。
这样设计可以确保外部状态的变化能够及时反映在组件上,并且尽量减少不必要的渲染。同时,由于使用了 useLayoutEffect
和 useEffect
,可以保证在正确的时机执行相应的副作用。
疑问
useLayoutEffect中forceUpdate({inst});这段逻辑可以去掉吗?去掉会有什么影响?
分析
-
目的:
useLayoutEffect
在DOM更新后立即执行,确保inst
的value
和getSnapshot
函数是最新的。这样可以确保当外部状态发生改变时,inst
能够立即反映这种变化。 -
forceUpdate**** 的作用: 如果外部状态发生了变化(通过
checkIfSnapshotChanged
检测),则使用forceUpdate
强制组件重新渲染。这一步骤确保了组件能够立即响应外部状态的变化。
如果去掉 forceUpdate({inst})
如果我们去掉 forceUpdate({inst})
,那么以下情况可能发生:
- 组件可能不会立即响应外部状态变化:
- 当外部状态变化时,如果
checkIfSnapshotChanged
检测到状态确实发生了变化,但是没有forceUpdate
的话,组件不会立即重新渲染。 - 这意味着用户界面可能不会立即反映最新的状态,直到其他原因触发重新渲染为止(例如用户交互或其他副作用)。2. 性能影响:
- 移除
forceUpdate
可能会略微提高性能,因为它减少了不必要的重新渲染。 - 但是,这也可能导致用户界面更新不及时,影响用户体验。3. 与 ****useEffect**** 的关系:
- 如果外部状态变化发生在
useEffect
中订阅的事件处理程序中,那么useEffect
中的forceUpdate
将确保组件重新渲染。 - 但是,如果外部状态变化发生在
useEffect
之外的地方(例如,由其他组件或库触发的状态变化),那么移除useLayoutEffect
中的forceUpdate
将导致组件无法立即响应这些变化。
结论
- 如果你确定外部状态变化总是由
useEffect
中订阅的事件触发,并且这些事件能够确保组件始终重新渲染,那么你可以考虑去掉useLayoutEffect
中的forceUpdate
。 - 但是,如果你希望组件能够立即响应所有外部状态变化,无论这些变化是由什么触发的,那么保留
useLayoutEffect
中的forceUpdate
是必要的。
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;
}
这段代码定义了一个自定义Hook useSyncExternalStoreWithSelector
,它扩展了 useSyncExternalStore
,增加了对选择器(selector)的支持。这个Hook可以用于从外部状态管理库(如Redux)中选择特定的数据片段,并且可以根据选择器的结果来决定是否触发组件的重新渲染。同样,让我们逐步分析这段代码:
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 {}
-
参数:
-
subscribe
: 一个订阅函数,返回一个取消订阅的函数。 -
getSnapshot
: 一个函数,用于获取当前的快照值。 -
getServerSnapshot
: 一个可选函数,用于服务端渲染时获取初始快照值;也可以是null
或void
。 -
selector
: 一个函数,用于从快照中选择特定的数据片段。 -
isEqual
: 一个可选函数,用于比较两个选择器的结果是否相等。
-
const instRef = useRef(null);
let inst;
if (instRef.current === null) {
inst = {
hasValue: false,
value: (null: Selection | null),
};
instRef.current = inst;
} else {
inst = instRef.current;
}
-
初始化状态:
- 使用
useRef
创建一个引用instRef
,用于存储inst
对象。 - 如果
instRef.current
为null
,则初始化inst
对象,其中包含hasValue
和value
属性。 - 否则,直接使用已有的
inst
对象。
- 使用
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]);
-
使用 ****useMemo**** 缓存选择器:
- 创建
memoizedSelector
函数,用于缓存选择器的结果。 -
getSnapshotWithSelector
和getServerSnapshotWithSelector
是两个函数,分别用于获取客户端和服务器端的选择器结果。 - 如果提供了
isEqual
函数,并且上次的选择器结果与当前结果相同,则直接返回上次的结果。 - 如果没有提供
isEqual
或者结果不同,则调用selector
函数获取新的选择器结果,并将其缓存。
- 创建
const value = useSyncExternalStore(
subscribe,
getSnapshotWithSelector,
getServerSelection,
);
return value;
-
计算状态并返回:
- 使用
useSyncExternalStore
Hook 来订阅外部状态的变化,并获取经过选择器处理后的值。 -
getSnapshotWithSelector
和getServerSelection
作为参数传递给useSyncExternalStore
。 - 返回经过选择器处理后的值
value
。
- 使用
总结
- 这个Hook扩展了
useSyncExternalStore
,增加了对选择器的支持,允许开发者根据外部状态的变化选择特定的数据片段,并通过选择器的结果来决定是否触发组件的重新渲染。 - 使用
useMemo
来缓存选择器的结果,提高性能。 - 如果提供了
isEqual
函数,Hook 会比较选择器的结果,只有当结果发生变化时才会触发重新渲染,从而进一步提高性能。
不难发现,通过 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;
};
解释
- 初始化缓存:
- 如果
hasMemo
为false
,表示这是第一次调用memoizedSelector
函数。 - 设置
hasMemo
为true
。 - 将
nextSnapshot
保存到memoizedSnapshot
中。 - 通过
selector
函数计算选择器的结果,并将结果保存到memoizedSelection
中。 - 返回计算出的选择器结果。2. 检查缓存:
- 如果
hasMemo
为true
,表示已经有缓存的选择器结果。 - 获取缓存的快照
prevSnapshot
和选择器结果prevSelection
。 - 检查当前快照
nextSnapshot
是否与缓存的快照prevSnapshot
相同: - 如果相同,则直接返回缓存的选择器结果
prevSelection
。3. 计算新的选择器结果: - 如果快照发生了变化,使用
selector
函数计算新的选择器结果nextSelection
。 - 如果提供了
isEqual
函数,并且新的选择器结果nextSelection
与缓存的选择器结果prevSelection
相等: - 则直接返回缓存的选择器结果
prevSelection
。* 如果选择器的结果不相等或没有提供isEqual
函数: - 更新缓存的快照
memoizedSnapshot
和选择器结果memoizedSelection
为新的值。 - 返回新的选择器结果
nextSelection
。
总结
-
memoizedSelector
函数的主要目的是缓存选择器的结果,以避免重复计算。 - 当快照没有变化时,直接返回缓存的结果。
- 当快照发生变化时,计算新的选择器结果。
- 如果提供了
isEqual
函数并且新的结果与缓存的结果相同,则直接返回缓存的结果。 - 如果结果不同,则更新缓存,并返回新的结果。* 这种缓存机制有助于提高性能,特别是在外部状态频繁变化但实际选择器的结果不变的情况下。
通过这种方式,memoizedSelector
可以确保只有当选择器的结果确实发生变化时才更新组件,从而提高了组件的性能。