概述
在React项目中说到状态管理,我们第一时间想到的就是使用useState、useReducer这种Hooks来进行状态管理。但是这种是针对React内部的状态,如果有时候我们需要订阅外部的状态并影响React组件的更新的话,那通过这种内部状态管理API显然不能满足了,这时候就需要使用本文的主角useSyncExternalStore
这个Hook了。这个API是React18提供的一个内置API,赋予了React能订阅外部状态的能力,当订阅的外部状态发生改变时,会触发React的重新渲染。本文将会从一下几个方面来逐步介绍该API的使用和原理,有需求的同学自行跳转至感兴趣的部分。
- 基础使用
- 源码解析
- Mount首次渲染时
- Update 更新渲染时
基础使用
函数定义
先看定义,useSyncExternalStore
接受三个参数:subscribe
、getSnapshot
、getServerSnapshot
然后返回一个数据快照。
javascript
export function useSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
const dispatcher = resolveDispatcher();
return dispatcher.useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
}
详细说明如下:
- subscribe: 接收一个订阅函数,并返回一个取消订阅函数,该函数主要用于订阅外部store状态,订阅外部store的哪些状态取决于subscribe函数的实现。当订阅的状态值变化时,会触发组件的重现渲染。
- getSnapshot:一个用于获取当前状态快照的函数,React组件每次渲染都会执行该函数,然后获得当前订阅状态的快照,并将这个快照作为
useSyncExternalStore
函数返回值 - getServerSnapshot: 返回订阅状态的初始快照,它只会在服务端渲染(SSR)时,以及在客户端(浏览器端)进行服务端渲染内容的 hydration 时被用到。
- 返回值是一个数据快照,可以理解为就是
getSnapshot
的执行结果
PS: 由于getSnapshot
会在每次组件渲染时都会执行,所以一般将这个函数定义在组件外部或者使用useCallback
包裹,避免不必要的渲染。
想了解为什么使用
useCallback
包裹能避免重复渲染的可以查看这篇文章:【React Hooks原理 - useCallback、useMemo】
由于服务端渲染的是静态页面,不能进行动态交互,所以在React项目中会通过hydration(水合阶段),将其赋予动态交互能力,具体流程本文最后题外话会简单介绍。
语法使用
上面主要介绍了useSyncExternalStore
函数定义,这小节主要通过举例Demo的形式让我们初步了解该函数的运用,并方便下面源码解析的理解。(由于第三个参数getServerSnapshot
用于SSR,所以本文不做介绍,有兴趣的同学可以查看其他文章了解)
外部第三方Store定义:
- 这里要注意
subscribe
、getSnapshot
这两个函数会在useSyncExternalStore
Hook中调用并注入React自己的listener函数,所以这两个函数必须在第三方Store中暴露,否则不能正确使用useSyncExternalStore
功能。
javascript
const store = {
// 外部状态
state: {
todos: [],
user: { name: 'Alice', age: 30 }
},
// 监听状态变化
listeners: new Set(),
// 订阅状态函数
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
},
// 获取快照函数
getSnapshot() {
return this.state;
},
// 状态更新后,通知所有注入的监听器并执行
notify() {
this.listeners.forEach(listener => listener());
},
// 更新todos状态
updateTodos(newTodos) {
this.state.todos = newTodos;
this.notify();
},
// 更新user状态
updateUser(newUser) {
this.state.user = newUser;
this.notify();
}
};
React项目代码:
javascript
import React from 'react';
import { useSyncExternalStore } from 'react';
const todosStore = {
subscribe(listener) {
return store.subscribe(() => {
const { todos } = store.getSnapshot();
listener(todos);
});
},
getSnapshot() {
const { todos } = store.getSnapshot();
return todos;
}
};
const TodosComponent = () => {
const todos = useSyncExternalStore(
todosStore.subscribe.bind(todosStore),
todosStore.getSnapshot.bind(todosStore)
);
return (
<div>
<h1>Todos</h1>
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo}</li>
))}
</ul>
</div>
);
};
由于外部状态暴露的subscribe
只注入了监听器并返回一个销毁注册的函数,默认是订阅所有外部状态,在这里我们demo主要是订阅其中部分状态即todos,所以在React项目中自定义了subscribe并解析todos进行订阅,以及自定义getSnapshot
函数只获取todos的状态信息,即useSyncExternalStore
Hook返回的数据。
可以理解为我们在React项目 - 外部状态
的连接中添加了一个节点用于处理自定义订阅和消费状态,即React项目 - CustomFn(todosStore) - 外部状态
。
至此基本使用我们已经介绍差不多了,接下来进入源码解析阶段,由于下面会引用这个Demo例子帮助理解,所以还请务必先浏览器下Demo。
源码解析
同大多数Hooks一样,该Hook也主要从首次挂载Mount、以及更新渲染Update两个阶段来介绍。
Mount首次渲染
通过上面介绍我们知道useSyncExternalStore
Hook接收三个参数并当前当前的数据快照,下面从mountSyncExternalStore
这个入口函数出发:
mountSyncExternalStore
函数:
javascript
function mountSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T
): T {
// 获取当前fiber即workInProgress中的fiber节点
const fiber = currentlyRenderingFiber;
// 创建Hook并挂载在fiber.memoizedState上
const hook = mountWorkInProgressHook();
// 保存数据快照
let nextSnapshot;
// 是否是水合阶段SSR
const isHydrating = getIsHydrating();
if (isHydrating) {
// 如果是SSR的水合阶段,则getServerSnapshot参数必传,否则报错
if (getServerSnapshot === undefined) {
throw new Error(
"Missing getServerSnapshot, which is required for " +
"server-rendered content. Will revert to client rendering."
);
}
// 由于此时是首次挂载,SSR时使用getServerSnapshot服务器的初始值作为返回状态
nextSnapshot = getServerSnapshot();
} else {
// 客户端渲染时,执行第二个参数获取当前外部状态快照
nextSnapshot = getSnapshot();
// 获取当前渲染fiber的根root节点
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."
);
}
// 获取root节点的优先级
const rootRenderLanes = getWorkInProgressRootRenderLanes();
// 判断当前构建的fiber树是否是阻断性渲染
// 当阻塞渲染时跳过一致性校验是处于性能考虑,避免后续复杂的对比计算
if (!includesBlockingLane(root, rootRenderLanes)) {
// 一致性检查是为了保证当前渲染的状态和外部状态是一致的,如果外部状态更新,当一致性检查不一致时会重新渲染组件
// 这个函数只是将新旧状态保存在了updateQueue.stores中,后续在协调阶段的isRenderConsistentWithExternalStores函数中进行处理
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}
}
// 保存当前的数据快照,更新渲染是会通过memoizedState获取上一次的值
hook.memoizedState = nextSnapshot;
// 记录本次渲染的状态值和获取最新状态的函数,用于后面新旧值对比更新
const inst: StoreInstance<T> = {
value: nextSnapshot,
getSnapshot,
};
// 将状态信息保存到hook链表中,后续更新
hook.queue = inst;
// subscribeToStore处理subscribe逻辑
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
// 添加flag
fiber.flags |= PassiveEffect;
// 将副作用添加到fiber的更新队列updateQueue中
pushEffect(
HookHasEffect | HookPassive,
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
createEffectInstance(),
null
);
// 返回当前快照
return nextSnapshot;
}
从上面代码及注释中,相比我们初步了解了mountSyncExternalStore
的作用: 主要就是创建了hook、effect、updateQueue等初始化工作。在其中引入了其他几个函数,这里我们展开说说:
-
mountWorkInProgressHook
函数在其他介绍其他Hook的时候已经介绍,所以这里不再赘述,作用就是创建一个初始Hook并绑定到fiber上 -
getIsHydrating
函数主要是判断当前是否是SSR的水合阶段,这里也不做介绍 -
getWorkInProgressRoot
函数用于返回当前渲染的workInProgress树的根节点javascriptexport function getWorkInProgressRoot(): FiberRoot | null { return workInProgressRoot; }
-
getWorkInProgressRootRenderLanes
函数返回当前渲染树的渲染优先级javascriptexport function getWorkInProgressRootRenderLanes(): Lanes { return workInProgressRootRenderLanes; }
Scheduler关于优先级设置,可以查看这篇文章:【React源码 - 调度任务循环EventLoop】
React 使用"lanes"来表示不同的优先级,它们分为以下几种类型:
- Blocking Lane:代表需要尽快完成的任务,但可以稍微延迟的更新。
- Urgent Lane:需要立即处理的任务,如用户输入。
- Normal Lane:普通优先级的任务。
- Idle Lane:低优先级的任务,可以在空闲时间处理。
-
通过
if (!includesBlockingLane(root, rootRenderLanes))
根据优先级判断是否需要进行一致性,主要是为了优化性能,出于以下考虑:- 性能考虑:阻塞优先级的任务需要尽快完成,跳过一致性检查可以减少渲染过程中的开销。
- 状态一致性:非阻塞优先级的任务需要确保渲染的状态与外部状态一致,所以需要进行一致性检查。
-
pushStoreConsistencyCheck
函数用于对比新旧状态的一致性校验,当前渲染会非阻塞级渲染时则会进行一致性校验,如果外部状态更新,则会重新渲染组件以便显示最新的状态。javascriptfunction pushStoreConsistencyCheck<T>( fiber: Fiber, getSnapshot: () => T, renderedSnapshot: T ): void { // 添加一致性校验Flag fiber.flags |= StoreConsistency; // 保存当前状态快照以及获取快照的函数 const check: StoreConsistencyCheck<T> = { getSnapshot, value: renderedSnapshot, }; // 获取fiber.updateQueue更新队列 let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any); // 当前没有更新任务时,创建更新队列并将保存值的对象check以数组形式绑定在stores if (componentUpdateQueue === null) { componentUpdateQueue = createFunctionComponentUpdateQueue(); currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any); componentUpdateQueue.stores = [check]; } else { // 有更新任务时,直接绑定stores const stores = componentUpdateQueue.stores; if (stores === null) { componentUpdateQueue.stores = [check]; } else { stores.push(check); } } }
这个函数只是将新旧状态保存在了updateQueue.stores中,后续在协调阶段的
isRenderConsistentWithExternalStores
函数中会进行处理。协调阶段中进入fiber构造前:
performConcurrentWorkOnRoot -> isRenderConsistentWithExternalStores
,在isRenderConsistentWithExternalStores中会循环遍历每个fiber节点并根据flag是否包含StoreConsistency
进行一致性校验,如果两次对比不一致则调用scheduleUpdateOnFiber
函数发起更新调度申请javascriptif (node.flags & StoreConsistency) { // 获取更新任务 const updateQueue: FunctionComponentUpdateQueue | null = (node.updateQueue: any); if (updateQueue !== null) { // 获取上面保存的新旧外部状态 const checks = updateQueue.stores; if (checks !== null) { for (let i = 0; i < checks.length; i++) { const check = checks[i]; const getSnapshot = check.getSnapshot; const renderedValue = check.value; try { // 执行getSnapshot函数获取最新的外部状态,并和组件内的状态对比 if (!is(getSnapshot(), renderedValue)) { // Found an inconsistent store. return false; } } catch (error) { // If `getSnapshot` throws, return `false`. This will schedule // a re-render, and the error will be rethrown during render. return false; } } } } }
-
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
这个代码就是处理subscribe
来传入React自身的listener用于状态更新时执行触发组件更新的函数。在这里通过bind
绑定了subscribeToStore
对subscribe
进行处理.使用mountEffect订阅是因为其能保证只在挂载和依赖更新时触发,并且会在组件卸载时执行清除函数,mountEffect其实是useEffect这个Hook在首次挂载时执行的函数,具体关于mountEffect函数请查看这篇文章:【React Hooks原理 - useEffect、useLayoutEffect】
-
subscribeToStore
函数主要就是当状态改变之后,强制组件重新渲染javascript// 对比外部状态和组件内部状态是否一致 function checkIfSnapshotChanged<T>(inst: StoreInstance<T>): boolean { const latestGetSnapshot = inst.getSnapshot; const prevValue = inst.value; try { const nextValue = latestGetSnapshot(); return !is(prevValue, nextValue); } catch (error) { return true; } } // 当状态不一致时,强制组件更新 function subscribeToStore<T>( fiber: Fiber, inst: StoreInstance<T>, subscribe: (() => void) => () => void ): any { const handleStoreChange = () => { // 对比外部状态是否改变 if (checkIfSnapshotChanged(inst)) { // 外部状态改变和组件内不一致则强制刷新,类似我们使用的forceUpdate() forceStoreRerender(fiber); } }; // Subscribe to the store and return a clean-up function. return subscribe(handleStoreChange); } // 直接调用scheduleUpdateOnFiber发起调度 function forceStoreRerender(fiber: Fiber) { const root = enqueueConcurrentRenderForLane(fiber, SyncLane); if (root !== null) { scheduleUpdateOnFiber(root, fiber, SyncLane); // 发送更新调度申请,scheduleUpdateOnFiber 负责标记需要更新的 Fiber 节点,并将其放入调度队列中,等待调度器(Scheduler)来确定何时执行这些更新。 } }
至此在首次挂载阶段的所有源码流程我们都介绍完了,在这里简单小节一下:在使用useSyncExternalStore
时,React就会通过subscribe
来订阅外部状态,并对subscribe
进行处理,传递一个对比状态更新组件的函数handleStoreChange
,一旦外部状态更新(对比组件内部和通过getSnapshot获取最新外部状态
),React中通过一致性判断不一致时就会发起更新调度。
Update更新渲染
当外部状态改变时,比如通过store.updateTodos(newValue)
修改订阅的数据之后,外部Store会通知执行所有添加的listener,即在Mount阶段注入的handleStoreChange
函数,并触发组件更新进入更新渲染阶段。在更新阶段是从updateSyncExternalStore
入口函数开始的,下面看代码:
javascript
function updateSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
// 获取渲染的fiber
const fiber = currentlyRenderingFiber;
// 复用现有链表,性能优化,减少重复创建Hook链表的性能消耗,复用workInProgress中的链表或者克隆current当前页面显示的fiber链表
// 并将本次更新对象添加到更新队列中
const hook = updateWorkInProgressHook();
let nextSnapshot;
const isHydrating = getIsHydrating();
// 是否是SSR水合阶段
if (isHydrating) {
// getServerSnapshot不传则报错
if (getServerSnapshot === undefined) {
throw new Error(
'Missing getServerSnapshot, which is required for ' +
'server-rendered content. Will revert to client rendering.',
);
}
nextSnapshot = getServerSnapshot();
} else {
nextSnapshot = getSnapshot();
}
// 通过hook.memoizedState获取上一次渲染缓存的值
const prevSnapshot = (currentHook || hook).memoizedState;
// 通过Object.is判断状态是否改变
const snapshotChanged = !is(prevSnapshot, nextSnapshot);
if (snapshotChanged) {
// 如果改变就更新换成值,然后触发组件更新
hook.memoizedState = nextSnapshot;
markWorkInProgressReceivedUpdate();
}
// 获取hook的更新队列(链表的头节点)
const inst = hook.queue;
// 组件更新渲染时执行
updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
subscribe,
]);
// 当getSnapshot or subscribe改变时,就需要提交更新,这里getSnapshot比较的是函数引用,对应上面提到了避免多次渲染,将其放在组件外或者使用useCallback包裹
if (
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// 过滤一些异常情况,性能优化
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
// 添加flag
fiber.flags |= PassiveEffect;
// 添加副作用到fiber中,后续在协调时会遍历fiber并处理其副作用列表
pushEffect(
HookHasEffect | HookPassive,
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
createEffectInstance(),
null,
);
// 获取root节点
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.',
);
}
// 对于非阻塞渲染需要进行一致性校验
if (!isHydrating && !includesBlockingLane(root, renderLanes)) {
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}
}
// 返回当前数据快照
return nextSnapshot;
}
从代码能看出其中有一些代码和首次渲染时mountSyncExternalStore
函数一致,相信经过上面的介绍和代码注释,相比已经能理解更新过程了,
所以在这里小节一下,虽然 Mount 和 Update 阶段的逻辑非常相似,但它们的主要区别在于:
- 初始化 vs 更新:
Mount 阶段初始化各种 Hook 对象和状态,Update 阶段复用现有的 Hook 对象和状态。 - 订阅和副作用:
Mount 阶段使用 mountEffect 注册订阅外部状态的副作用,Update 阶段使用 updateEffect 更新订阅副作用。 - 一致性检查:
两个阶段都会进行一致性检查,但在 Update 阶段会优先比较当前快照与之前的快照,并在不一致时进行副作用更新、一致性校验、更新缓存值等操作,否则会跳过更新阶段直接返回快照。
总之就是一句话: Mount主要初始化创建Hook、updateQueue、Effect,Update主要更新、复用和性能优化。
总结
一句话说明useSyncExternalStore就是赋予React具有访问外部状态并触发更新的能力
。具体流程就是通过subscribe
来注入React自身的监听器(listener)并订阅外部状态(全部或者部分state),当订阅状态更新时会通过注入的listener来触发React重新渲染,每次渲染之后会执行getSnapshot
获取订阅的数据快照并将其结果返回。
其中React主要就是将自身的listener传递给外部状态,然后当更新外部状态时。在外部状态中当监听到状态更新时会执行所有注入的listener,然后React中的listener会获取最新的状态快照并发送更新调度进而实现组件的重现渲染。
题外话
什么是React的hydration阶段?
这个阶段主要指的是当页面使用服务器渲染(SSR)有服务端生成HTML并传回给客户端(浏览器端)显示时,由于SSR传递到客户端是静态的HTML文件,不能进行用户交互(没有事件绑定等),所以React的hydration阶段就是将静态页面转换为可交付的页面的过程。
流程介绍:
- 服务端渲染 (SSR):
○ 服务器生成 HTML 内容并将其发送到客户端。这一步骤确保页面在浏览器中快速呈现,即使在 JavaScript 尚未完全加载和执行之前,用户也可以看到页面内容。
○ 生成的 HTML 包含了所有需要的内容,但这些内容是静态的,无法进行任何交互操作。 - 客户端接收 HTML:
○ 浏览器接收到服务器发送的 HTML 并立即显示给用户。这时用户看到的是一个完整但静态的页面。
○ 由于 HTML 是静态的,用户暂时无法进行诸如点击按钮、填写表单等交互操作。 - 加载和执行 JavaScript:
○ 浏览器开始加载和执行页面中的 JavaScript 文件。React 的 JavaScript 代码被下载并执行。
○ 在这一步,React 会初始化并尝试将组件树与已经存在的 HTML 进行对比和合并。 - Hydration 过程:
○ React 使用 ReactDOM.hydrate 方法,将客户端的组件树与服务端渲染的 HTML 进行对比。
○ 这个过程包括将事件监听器绑定到已有的 DOM 元素上,使得这些元素变得可交互。 - 页面变为可交互:
○ 一旦 hydration 过程完成,页面就变成了一个完整的、可交互的 React 应用。此时,用户可以正常进行各种交互操作(如点击按钮、输入文本等)。
举例说明
假设有一个简单的计数器应用,服务端渲染了初始计数值为 0 的 HTML:
javascript
<div id="root">
<div>
<p>0</p>
<button>Increment</button>
</div>
</div>
在客户端,React 会执行以下操作:
- React 组件初始化: React 使用相同的组件树进行初始化:
javascript
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
- Hydration 过程: React 将组件树与服务端的 HTML 进行对比,确保二者一致:
javascript
ReactDOM.hydrate(<Counter />, document.getElementById('root'));
- 绑定事件监听器: React 将 button 元素的点击事件绑定到 Increment 按钮上:
javascript
<button onClick={() => setCount(count + 1)}>Increment</button>
- 应用变为可交互: 现在,用户点击按钮时,计数值会增加,页面内容会更新。
总结:通过SSR传递到客户端的HTML虽然包含所有的内容,立即显示在页面,但是没有绑定事件无法交互,只是单纯的静态页面,需要执行Js文件经过hydration将绑定事件转换为可交互。而hydration 是 React 在客户端将服务端生成的静态 HTML 转变为可交互应用的过程。这个过程确保了初始页面的快速加载,同时通过绑定事件监听器和合并状态,使得页面能够响应用户的交互。