Recoil实现原理浅析-异步请求

2,140 阅读6分钟

本文主要分析 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 可以动态创建,代码分割和代码复用都非常方便。

流程图 1.jpg

React 组件之所以能以同步的方式获取异步的数据,是因为读的过程是同步的,写的过程,如果是异步的过程,先向 state 中写入 loading 的状态,在异步结束的时候,再向 state 中写入最终的值,数据变更时同步触发组件的更新,所以 React 组件读取异步数据的时候,首先会读取一个 loading 状态,异步结束时,再读取一个结束的状态(结束状态有两种数据,成功数据和错误数据)。

结合上面的 CurrentUserInfo 例子,我们来看一下 Recoil 是怎么实现的?

  1. 通过 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(),
  };
}
  1. 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;
  1. 组件 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,
  );
}
  1. 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);
    }
}
  1. 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 之外,有多种方式去处理数据变更。

  1. 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;
}
  1. 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,
  );
}

详细的数据流向参考下图

流程图.jpg

Loading 处理

在处理异步数据时,会有 Loading 的状态,Recoil 有两种处理方式

  1. 手动处理

通过 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;
  }
}
  1. 全局自动处理

通过 React.Suspense 自动消费 loading 状态,即组件获取 loading 状态数据,抛出异步错误(即 throw promise),Suspense 会展示 fallback,同时 Suspense 会在promise结束时,重新触发组件的渲染,获取最新的数据