react useSyncExternalStore源码

这篇文章发表于 阅读 8

useSyncExternalStore是React 18提供的Hook,作用是将外部store的状态强制同步到react组件。

例子

import React, {useSyncExternalStore} from 'react'; import ReactDOM from 'react-dom/client'; // store let states = { count: 0 }; let listeners = []; const myStore = { inc() { states = { ...states, count: states.count + 1, } emitChange(); }, subscribe(listener) { listeners = [...listeners, listener]; return () => { listeners = listeners.filter(l => l !== listener); }; }, getSnapshot() { return states; } }; function emitChange() { for (let listener of listeners) { listener(); } } // app function App() { const states = useSyncExternalStore(myStore.subscribe, myStore.getSnapshot); return ( <div className="App"> <button onClick={myStore.inc}>Inc</button> <hr /> <div>{states.count}</div> </div> ); } // root const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <React.StrictMode> <App /> </React.StrictMode> );

上面的代码中创建了一个myStore,然后在组件中通过const states = useSyncExternalStore(myStore.subscribe, myStore.getSnapshot)获取到了states,点击button按钮时调用myStore.inc()来更新数据。

效果如下,点击这里在线编辑

理解 useSyncExternalStore Hook

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

参数:

  1. subscribe: subscribe是一个函数,接收一个回调函数(callback)用于向store订阅,当store的数据发生变化时,就应该调用callback,使得React组件重新渲染;subscribe返回值一个用于清除callback的函数。
  2. getSnapshotgetSnapshot函数返回store的状态,如果store没有发生变化,重新调用getSnapshot应该返回相同的值;当store发生变化并返回的值不同时,React将重新渲染组件。
  3. getServerSnapshot(可选项): 返回store的数据的初始值。仅在服务器渲染和水合过程中使用,服务器快照在客户端和服务器之间必须相同,并且通常被序列化并从服务器传递到客户端。 如果省略此参数,在服务器上渲染组件将引发错误。

返回值: 返回store中当前状态

useSyncExternalStore 源码

ReactFiberHooks.js文件中找到mountSyncExternalStore函数,组件挂载阶段调用该函数,下面是mount阶段的代码。

  1. 首先通过getSnapshot获取store状态,放在hook上
  2. 通过pushStoreConsistencyCheck设置一致性检查数据,在fiber的更新队列上的stores上添加check数据;stores的数据会在render阶段执行(performConcurrentWorkOnRoot函数中执行)。
  3. 通过mountEffect订阅store的更新,其中subscribeToStore会向store.subscribe添加订阅函数,修改store数据时调用该函数即可触发组件更新。
  4. 通过pushEffect添加effect,在commit之后再次检查store状态的一致性。
function mountSyncExternalStore<T>( subscribe: (() => void) => () => void, getSnapshot: () => T, getServerSnapshot?: () => T, ): T { const fiber = currentlyRenderingFiber; const hook = mountWorkInProgressHook(); let nextSnapshot; const isHydrating = getIsHydrating(); if (isHydrating) { if (getServerSnapshot === undefined) { throw new Error( 'Missing getServerSnapshot, which is required for ' + 'server-rendered content. Will revert to client rendering.', ); } nextSnapshot = getServerSnapshot(); // xxxxxx } else { // 获取store状态 nextSnapshot = getSnapshot(); // xxxxxx const root: FiberRoot | null = getWorkInProgressRoot(); if (root === null) { throw new Error( 'Expected a work-in-progress root. This is a bug in React. Please file an issue.', ); } const rootRenderLanes = getWorkInProgressRootRenderLanes(); if (!includesBlockingLane(root, rootRenderLanes)) { // 设置一致性检查数据,在render阶段执行 pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot); } } hook.memoizedState = nextSnapshot; const inst: StoreInstance<T> = { value: nextSnapshot, getSnapshot, }; hook.queue = inst; // 通过useEffect 添加订阅函数 mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]); fiber.flags |= PassiveEffect; // 添加effect, 用于在commit之后校验store的一致性 pushEffect( HookHasEffect | HookPassive, updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot), createEffectInstance(), null, ); return nextSnapshot; }

mountEffect中又一个subscribeToStore参数,用于向store中添加订阅函数,就是上面例子中的listener。执行listener时强制组件重新渲染。

function subscribeToStore<T>( fiber: Fiber, inst: StoreInstance<T>, subscribe: (() => void) => () => void, ): any { // const handleStoreChange = () => { if (checkIfSnapshotChanged(inst)) { // 如果状态发生变化就强制组件渲染,这里采用同步优先级`SyncLane` forceStoreRerender(fiber); } }; return subscribe(handleStoreChange); }

handleStoreChange 调用时机

最上面的例子中点击按钮调用myStore.inc函数中调用了emitChangeemitChange调用所有订阅的函数(handleStoreChange),触发组件的更新。

handleStoreChange会比较状态是否变化,所以更新states时一定要创建新的对象,否则不会出发更新。

pushStoreConsistencyCheck 添加一致性check

就是在fiber的updateQueue上的stores里添加check对象,在协调完成后遍历fiber节点,判断store状态的一致性,如果不一致就同步更新节点状态。 在下面提到的performConcurrentWorkOnRoot函数中使用。

function pushStoreConsistencyCheck<T>( fiber: Fiber, getSnapshot: () => T, renderedSnapshot: T, ): void { fiber.flags |= StoreConsistency; const check: StoreConsistencyCheck<T> = { getSnapshot, value: renderedSnapshot, }; let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any); if (componentUpdateQueue === null) { componentUpdateQueue = createFunctionComponentUpdateQueue(); currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any); // 添加check componentUpdateQueue.stores = [check]; } else { const stores = componentUpdateQueue.stores; // 添加check if (stores === null) { componentUpdateQueue.stores = [check]; } else { stores.push(check); } } }

performConcurrentWorkOnRoot

export function performConcurrentWorkOnRoot( root: FiberRoot, didTimeout: boolean, ): RenderTaskFn | null { // ... const shouldTimeSlice = !includesBlockingLane(root, lanes) && !includesExpiredLane(root, lanes) && (disableSchedulerTimeoutInWorkLoop || !didTimeout); // 开始协调 Concurrent / Sync let exitStatus = shouldTimeSlice ? renderRootConcurrent(root, lanes) : renderRootSync(root, lanes); // 协调完成后一致性检查,在commit之后还会通过 updateStoreInstance 校验一次 if (exitStatus !== RootInProgress) { let renderWasConcurrent = shouldTimeSlice; do { if (exitStatus === RootDidNotComplete) { markRootSuspended(root, lanes, NoLane); } else { const finishedWork: Fiber = (root.current.alternate: any); if ( renderWasConcurrent && !isRenderConsistentWithExternalStores(finishedWork) ) { // 如果状态不一致,就同步更新节点 exitStatus = renderRootSync(root, lanes); renderWasConcurrent = false; // Need to check the exit status again. continue; } if (exitStatus === RootErrored) { // ... } if (exitStatus === RootFatalErrored) { // ... throw fatalError; } root.finishedWork = finishedWork; root.finishedLanes = lanes; finishConcurrentRender(root, exitStatus, finishedWork, lanes); } break; } while (true); } ensureRootIsScheduled(root); return getContinuationForRoot(root, originalCallbackNode); }

useSyncExternalStore 解决的问题

在上面的例子中可以看到多次的进行一致性检查,那为什么为需要一致性检查呢,其实跟react18的并发有关,比如使用startTransitionSuspense时,react可以停止来响应其他工作,如果在这期间发生了数据更新,就会造成使用同一数据渲染的UI结果不一样,就造成了撕裂 (tearing)。 这里是react18关于撕裂的讨论

翻译其中一部分:

同步渲染

看下图。

在第一个面板中,我们开始渲染 React 树。 一个需要访问某些外部存储并获取颜色值的组件。 外部存储的颜色是蓝色,因此该组件呈现蓝色。

在第二个面板中,由于我们没有并发渲染,React 会继续渲染所有组件而不会停止。 由于没有停止,因此外部存储不可能改变。 因此所有组件在外部存储中都获得相同的值。

在第三个面板中,我们看到所有组件都渲染为蓝色,并且它们看起来都一样。 UI 始终以一致的状态显示,因为您看到的所有内容在屏幕上的任何位置都以相同的值呈现。

最后,在第四个面板中,store能够更新。 这是因为 React 完成了,并允许其他工作发生。 如果在 React 未渲染时存储更新,那么下次 React 渲染树时,我们将从第一个面板开始,并且所有组件都将获得相同的值。 这就是为什么在并发渲染之前以及在大多数其他 UI 框架中,UI 始终呈现一致的原因。 当您不使用并发功能时,这就是 React 在 React 17 中以及在 React 18 中默认的工作方式。

//file.vwood.xyz/2024/03/12/upload_s1hcqsk2usdgj4m9wd8a78l5syzzacga.png

并发渲染

大多数时候,并发渲染会产生一致的 UI,但有一种边缘情况可能会在适当的条件下导致问题。 要了解会发生什么,请参见下图。

我们像以前一样启动第一个面板,并将组件渲染为蓝色。

这就是我们可以开始分歧的地方。

由于我们使用并发功能,因此我们使用并发渲染,并且 React 可以在完成之前停止工作,让步给其他工作。 这对于响应能力来说是一个巨大的好处,因为用户能够与页面交互,而不会被 React 阻塞。 在这种情况下,假设用户单击一下按钮,将商店从蓝色更改为红色。 在并发渲染之前,这甚至是不可能处理的。 对于用户来说,该页面似乎暂停了,他们无法单击任何内容。 但通过并发渲染,React 可以让点击发生,让用户感觉页面流畅且具有交互性。

此功能的结果是,用户交互(或其他工作,如网络请求或超时)可以更改用于呈现您在屏幕上看到的内容的外部状态中的值。 这是可能导致问题的边缘情况。 在第二个面板中,我们看到 React 已经屈服,并且外部存储已经根据用户交互发生了变化。

问题是第一个组件已经渲染为蓝色(因为这是当时存储的值),但是在此之后渲染的任何组件都将获得当前值,现在为红色。 在第三个面板中,这正是发生的情况。 现在,访问外部状态的组件呈现并获取当前值,该值是红色的。

最后,在最后一个面板中,我们可以看到以前始终为蓝色的组件现在是红色和蓝色的混合。 他们为相同的数据显示两个不同的值。 这种边缘情况是“撕裂”的。

//file.vwood.xyz/2024/03/12/upload_5ggvcp9w8xsa5u7vffzeuvrsbri3oscl.png

最后

useSyncExternalStore主要用于解决数据的一致性问题,在react源码中添加一致性检查,在调和时、commit后进行检查,不一致时进行同步更新,这与React的并发有点矛盾的感觉,毕竟一致性检查也会消耗一定资源。

zustand状态管理工具就使用了useSyncExternalStore,对于react17及以前的版本使用use-sync-external-store 进行兼容。

参考文章

What is tearing?