本文主要分析 Recoil 异步数据流是怎么实现的
简介
Recoil 是一个 React 状态管理库,提供多个独立的、更细粒度的数据源,用于跨组件的状态管理。Recoil 通过数据流图(如下图)将状态和衍生状态映射到 React 组件中,在这个数据流图中可以使用异步函数(即图中的依赖可以是异步关系),比如 Selector 可以通过异步的方式依赖服务端数据或者其他 Selector/Atom,这样可以在 React 组件中以同步的方式获取异步的数据。
下面这个例子展示了这个流程,CurrentUserInfo 组件直接以同步的方式获取异步数据,异步数据加载状态由 React.Suepense 组件消费处理,整个流程是非常简洁的。
const currentUserNameState = atom({
key: 'CurrentUserName',
get: async () => {
const userName = await getUserName();
return userName;
},
});
function CurrentUserInfo() {
const userName = useRecoilValue(currentUserNameState);
return <div>{userName}</div>;
}
function App() {
return <RecoilRoot ><CurrentUserInfo /</RecoilRoot>;
}
复制代码
原理
Recoil 底层的数据流实现,是非常类似于 Redux 的,通过一个全局集中的 state 去管理数据,但并没有像 redux 那样将状态管理和组件库绑定进行分开,而是将状态管理和 React 深度绑定,主要原因是 recoil 的很多工作是怎么去处理状态到组件的映射,concurrent 模式适配等,提供了很多 hook api 去读写数据。
下面的流程图是一个简化的 Recoil 底层数据流向,React 组件通过读 api获取数据,读 api 做了两件事,通过 key 从 state (map 数据结构)中读取数据,并给组件订阅数据的变化;数据变更时,如组件通过写更改数据或者异步结束之后更改数据,直接更改 state 中的数据,然后触发组件的更新。Recoil 的一个创新之处是,全局的 state 和 atom 是解耦的,atom 的初始化是在第一次读的时候进行初始化,这使得 atom 可以动态创建,代码分割和代码复用都非常方便。
React 组件之所以能以同步的方式获取异步的数据,是因为读的过程是同步的,写的过程,如果是异步的过程,先向 state 中写入 loading 的状态,在异步结束的时候,再向 state 中写入最终的值,数据变更时同步触发组件的更新,所以 React 组件读取异步数据的时候,首先会读取一个 loading 状态,异步结束时,再读取一个结束的状态(结束状态有两种数据,成功数据和错误数据)。
结合上面的 CurrentUserInfo 例子,我们来看一下 Recoil 是怎么实现的?
- 通过 RecoilRoot 组件,将需要使用 recoil 状态管理的组件进行包裹。RecoilRoot 组件通过 Context 创建了全局对象,用于存储状态、监听回调等,其中 atomValues 用于保存所有的 atom/selector 状态,nodeToComponentSubscriptions 保存了组件对 atom/selector 的订阅回调
function makeEmptyTreeState(): TreeState {
const version = getNextTreeStateVersion();
return {
version,
stateID: version,
transactionMetadata: {},
dirtyAtoms: new Set(),
atomValues: persistentMap(),
nonvalidatedAtoms: persistentMap(),
};
}
function makeEmptyStoreState(): StoreState {
const currentTree = makeEmptyTreeState();
return {
currentTree,
nextTree: null,
previousTree: null,
commitDepth: 0,
knownAtoms: new Set(),
knownSelectors: new Set(),
transactionSubscriptions: new Map(),
nodeTransactionSubscriptions: new Map(),
nodeToComponentSubscriptions: new Map(),
queuedComponentCallbacks_DEPRECATED: [],
suspendedComponentResolvers: new Set(),
graphsByVersion: new Map().set(currentTree.version, graph()),
versionsUsedByComponent: new Map(),
retention: {
referenceCounts: new Map(),
nodesRetainedByZone: new Map(),
retainablesToCheckForRelease: new Set(),
},
nodeCleanupFunctions: new Map(),
};
}
复制代码
- atom 函数创建了 node 节点,该步骤没有其他操作,node 节点的初始化在读数据时进行,所以可以动态创建,recoil 内部通过 key 来读取节点, currentUserNameState 就是通过 atom 创建的一个 node 节点。
const node = registerNode(
({
key,
nodeType: 'atom',
peek: peekAtom,
get: getAtom,
set: setAtom,
init: initAtom,
invalidate: invalidateAtom,
shouldDeleteConfigOnRelease: shouldDeleteConfigOnReleaseAtom,
dangerouslyAllowMutability: options.dangerouslyAllowMutability,
persistence_UNSTABLE: options.persistence_UNSTABLE
? {
type: options.persistence_UNSTABLE.type,
backButton: options.persistence_UNSTABLE.backButton,
}
: undefined,
shouldRestoreFromSnapshots: true,
retainedBy,
}: ReadWriteNodeOptions<T>),
);
return node;
复制代码
- 组件 CurrentUserInfo 通过 useRecoilValue hook 读取数据,通过 key 找到上一步的 node ,调用 init 方法进行初始化,再调用 get 方法获取状态数据,可以看到在获取数据时是直接从 state.atomValues 中读取的
function getAtom(_store: Store, state: TreeState): Loadable<T> {
if (state.atomValues.has(key)) {
// Atom value is stored in state:
return nullthrows(state.atomValues.get(key));
} else {
return defaultLoadable;
}
}
复制代码
读取完数据,将如下的回调函数设置到全局的 nodeToComponentSubscriptions 中,监听数据的变化,触发组件更新,此处触发组件更新采用的是 useState 实现的,重新设置一个新对象。另外,React 18 提供了一个新的 api useSyncExternalStore,可以让 React 组件订阅外部数据源的变化,触发组件的重新更新,Recoil 也进行了实现,此处暂不演示
const [, forceUpdate] = useState([]);
useEffect(() => {
const store = storeRef.current;
const storeState = store.getState();
const subscription = subscribeToRecoilValue(
store,
recoilValue,
_state => {
if (!gkx('recoil_suppress_rerender_in_callback')) {
return forceUpdate([]);
}
const newLoadable = getLoadable();
if (!prevLoadableRef.current?.is(newLoadable)) {
forceUpdate(newLoadable);
}
prevLoadableRef.current = newLoadable;
},
componentName,
);
}
复制代码
- Atom 在初始化时,如果 default 是一个异步数据,会在异步数据结束时,触发组件的更新,即通过 markRecoilValueModified 触发组件更新,重新拉取最新数据。currentUserNameState 会在第一次读取数据时进行初始化,异步初始化完成会执行下面的 notifyDefaultSubscribers 回调。
function initAtom(
store: Store,
initState: TreeState,
trigger: Trigger,
): () => void {
liveStoresCount++;
const cleanupAtom = () => {
liveStoresCount--;
cleanupEffectsByStore.get(store)?.forEach(cleanup => cleanup());
cleanupEffectsByStore.delete(store);
};
store.getState().knownAtoms.add(key);
// Setup async defaults to notify subscribers when they resolve
if (defaultLoadable.state === 'loading') {
const notifyDefaultSubscribers = () => {
const state = store.getState().nextTree ?? store.getState().currentTree;
if (!state.atomValues.has(key)) {
markRecoilValueModified(store, node);
}
};
defaultLoadable.contents.finally(notifyDefaultSubscribers);
}
}
复制代码
- markRecoilValueModified 会调用 RecoilRoot 组件上的 store.replaceState 进行数据更新,更新完成数据之后,通过 notifyBatcherOfChange.current 触发组件更新
const replaceState = replacer => {
startNextTreeIfNeeded(storeRef.current);
// Use replacer to get the next state:
const nextTree = nullthrows(storeStateRef.current.nextTree);
let replaced;
try {
stateReplacerIsBeingExecuted = true;
replaced = replacer(nextTree);
} finally {
stateReplacerIsBeingExecuted = false;
}
if (replaced === nextTree) {
return;
}
// Save changes to nextTree and schedule a React update:
storeStateRef.current.nextTree = replaced;
if (reactMode().early) {
notifyComponents(storeRef.current, storeStateRef.current, replaced);
}
nullthrows(notifyBatcherOfChange.current)();
};
复制代码
replacer(nextTree) 通过回调的方式传入,将数据更新交给 RecoilRoot 之外,有多种方式去处理数据变更。
- notifyBatcherOfChange.current 是通过 Batcher 组件的 setNotifyBatcherOfChange 设置的,Batcher 是 RecoilRoot 的子组件,目的是:在下一次组件渲染完强制触发更新,即调用 endBatch 方法。
function Batcher({
setNotifyBatcherOfChange,
}: {
setNotifyBatcherOfChange: (() => void) => void,
}) {
const storeRef = useStoreRef();
const [, setState] = useState([]);
setNotifyBatcherOfChange(() => setState({}));
useEffect(() => {
setNotifyBatcherOfChange(() => setState({}));
// If an asynchronous selector resolves after the Batcher is unmounted,
// notifyBatcherOfChange will still be called. An error gets thrown whenever
// setState is called after a component is already unmounted, so this sets
// notifyBatcherOfChange to be a no-op.
return () => {
setNotifyBatcherOfChange(() => {});
};
}, [setNotifyBatcherOfChange]);
useEffect(() => {
// enqueueExecution runs this function immediately; it is only used to
// manipulate the order of useEffects during tests, since React seems to
// call useEffect in an unpredictable order sometimes.
Queue.enqueueExecution('Batcher', () => {
endBatch(storeRef);
});
});
return null;
}
复制代码
- endBatch 方法将监听的组件回调(nodeToComponentSubscriptions)执行,触发组件更新
function notifyComponents(
store: Store,
storeState: StoreState,
treeState: TreeState,
): void {
const dependentNodes = getDownstreamNodes(
store,
treeState,
treeState.dirtyAtoms,
);
for (const key of dependentNodes) {
const comps = storeState.nodeToComponentSubscriptions.get(key);
if (comps) {
for (const [_subID, [_debugName, callback]] of comps) {
callback(treeState);
}
}
}
}
function sendEndOfBatchNotifications(store: Store) {
const storeState = store.getState();
const treeState = storeState.currentTree;
// Inform transaction subscribers of the transaction:
const dirtyAtoms = treeState.dirtyAtoms;
if (dirtyAtoms.size) {
if (!reactMode().early || storeState.suspendedComponentResolvers.size > 0) {
// Notifying components is needed to wake from suspense, even when using
// early rendering.
notifyComponents(store, storeState, treeState);
// Wake all suspended components so the right one(s) can try to re-render.
// We need to wake up components not just when some asynchronous selector
// resolved, but also when changing synchronous values because this may cause
// a selector to change from asynchronous to synchronous, in which case there
// would be no follow-up asynchronous resolution to wake us up.
// TODO OPTIMIZATION Only wake up related downstream components
storeState.suspendedComponentResolvers.forEach(cb => cb());
storeState.suspendedComponentResolvers.clear();
}
}
// Special behavior ONLY invoked by useInterface.
// FIXME delete queuedComponentCallbacks_DEPRECATED when deleting useInterface.
storeState.queuedComponentCallbacks_DEPRECATED.forEach(cb => cb(treeState));
storeState.queuedComponentCallbacks_DEPRECATED.splice(
0,
storeState.queuedComponentCallbacks_DEPRECATED.length,
);
}
复制代码
详细的数据流向参考下图
Loading 处理
在处理异步数据时,会有 Loading 的状态,Recoil 有两种处理方式
- 手动处理
通过 useRecoilValueLoadable hook 手动消费 loading 状态
function UserInfo({userID}) {
const userNameLoadable = useRecoilValueLoadable(userNameQuery(userID));
switch (userNameLoadable.state) {
case 'hasValue':
return <div>{userNameLoadable.contents}</div>;
case 'loading':
return <div>Loading...</div>;
case 'hasError':
throw userNameLoadable.contents;
}
}
复制代码
- 全局自动处理
通过 React.Suspense 自动消费 loading 状态,即组件获取 loading 状态数据,抛出异步错误(即 throw promise),Suspense 会展示 fallback,同时 Suspense 会在promise结束时,重新触发组件的渲染,获取最新的数据