引言
对于react状态管理库,大家比较熟悉得可能是Redux,但是redux虽然设计的比较简洁,但是他却有一些问题,比如需要写大量的模板代码;需要约定新的状态对象是全新的,如果我们不用全新的对象,可能会导致不更新,这是常见的redux状态不更新问题,所以需要开发者自己去保证,所以不得不引入例如immer这类的库;另外,redux本身是框架无关的库,他需要和redux-react结合才能在react中使用。使用我们不得不借助redux toolkit或者rematch这种内置了很多最佳实践的库以及重新设计接口的库,但与此同时也增加了开发者的学习成本。
所以react的状态管理的轮子层出不穷,下面将会介绍面向未来设计的react状态管理库——recoil。
简介
recoil 的 slogan 十分简单:一个react状态管理库(A state management library for React)。它不是一个框架无关的状态库,它是专门为react而生的。
和react一样,recoil也是facebook的开源的库。官方宣称有三个主要的特性:
- Minimal and Reactish: 最小化和react风格的api。
- Data-Flow Graph: 数据流图。支持派生数据和异步查询都是纯函数,内部都是高效的订阅。
- Cross-App Observation: 跨应用监听,能够实现整体状态监听。
基本设计思想
假如有这么一个场景,相应状态改变我们仅仅需要更新list中的第二个节点和canvas的第二个节点。
如果没有使用第三外部状态管理库,使用context API可能是这样的:
我们可能需要很多个单独的provider,对应仅仅需要更新的节点,这样实际上使用状态的子节点的和Provider实际上是耦合的,我们使用状态的时候需要关心是否有相应的provider。
又假如我们使用的redux,其实如果只是某一个状态更新,其实所有的订阅函数都会重新运行,即使我们最后通过selctor浅对比两次状态一样的,阻止更新react树,但是一旦订阅的节点数量非常多,实际上是会有性能问题的。
recoil把状态分为了一个个原子,react组件树只会订阅他们需要的状态。在这个场景中,组件树左边和右边的item订阅了不同的原子,当原子改变,他们只会更新相应的订阅的节点。
同时recoil也支持“派生状态”,也就是说已有的原子组合成一个新的状态(selector),并且新的状态也可以成为其他状态的依赖。
不仅支持同步的selctor,recoil也支持异步的selector,recoil对selctor的唯一要求就是他们必须是一个纯函数。
Recoil的设计思想就是我们把状态拆分一个一个的原子atom,再由selctor派生出更多状态,最后React的组件树订阅自己需要的状态,当有原子状态更新,只有改变的原子及其下游节点有订阅他们的组件才会更新。也就是说,recoil其实构建了一个有向无环图,这个图和react组件树正交,他的状态和react组件树是完全解耦的。
简单用法
吹了这么多先来看看简单的用法吧。
区别于redux是与框架无关的状态管理库,既然Recoil是专门为React设计的状态管理库,那么他的API满满的“react风格”。 Recoil 只支持hooks API,在使用上来说可以说十分简洁了。
下面看看Demo:
import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue
} from "recoil";
export default function App() {
return (
<RecoilRoot>
<Demo />
</RecoilRoot>
);
}
const textState = atom({
key: "textState",
default: ""
});
const charCountState = selector({
key:'charCountState',
get: ({get}) => {
// 要求是纯函数
const text = get(textState)
return text.length
}
})
function Demo() {
const [text, setText] = useRecoilState(textState);
const count = useRecoilValue(charCountState)
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<br />
Echo: {text}
<br />
charCount: {count}
</>
);
}
- 类似于React Redux,recoil也有一个Providar——RecoilRoot,用于全局共享一些方法和状态。
- atom(原子)是recoil中最小的状态单元,atom表示一个值可以被读、写、订阅,它必须有一个区别于其他atom保持唯一性和不变性的key。通过atom可以定义一个数据。
- Selector 有点像React-Redux中的selector,同样是用来“派生”状态的,不过和React-Redux中不同是:
- React-redux的selector是一个纯函数,当全局唯一的状态改变,它总是会运行,从全局唯一的状态运算出新的状态。
- 而在recoil中,selector的 options.的get也要求是一个纯函数,其中传入其中的get方法用来获取其他atom。 当且仅当依赖的atom发生改变且有组件订阅selector,它其实才会重新运算,这意味着计算的值是会被缓存下来的,当依赖没有发生改变,其实直接会从缓存中读取并返回。而selector返回的也是一个atom,这意味着派生状态其实也是一个原子,其实也可以作为其他selector的依赖。
很明显,recoil是通过get函数中的get入参来收集依赖的,recoil支持动态收集依赖,也就是说get可以在条件中调用:
const toggleState = atom({key: 'Toggle', default: false});
const mySelector = selector({
key: 'MySelector',
get: ({get}) => {
const toggle = get(toggleState);
if (toggle) {
return get(selectorA);
} else {
return get(selectorB);
}
},
});
异步
recoil天然支持异步,用法也十分简单,也不需要配置什么异步插件,看看Demo:
const asyncDataState = selector({
key: "asyncData",
get: async ({get}) => {
// 要求是纯函数
return await getAsyncData();
}
});
function AsyncComp() {
const asyncData = useRecoilValue(asyncDataState);
return <>{asyncData}</>;
}
function Demo() {
return (
<React.Suspense fallback={<>loading...</>}>
<AsyncComp />
</React.Suspense>
);
}
由于recoil天然支持react suspense的特性,所以使用useRecoilValue获取数据的时候,如果异步状态pending,那么默认将会抛出该promise,使用时需要外层使用React.Suspense,那么react就会显示fallback里面的内容;如果报错,也会抛出里面的内容,被外层的ErrorBoundary捕获。
如果你不想使用该特性,可以使用useRecoilValueLoadable直接获取异步状态,demo:
function AsyncComp() {
const asyncState = useRecoilValueLoadable(asyncDataState);
if (asyncState.state === "loading") {
return <>loading...</>;
}
if (asyncState.state === "hasError") {
return <>has error....</>;
}
if (asyncState.state === "hasValue") {
return <>{asyncState.contents}</>;
}
return null;
}
另外注意默认异步的结果是会被缓存下来,其实所有的selctor上游没有改变的结果都会被缓存下来。也就是说如果异步的依赖没有发生改变,那么不会重新执行异步函数,直接返回缓存的值。这也是为什么一直强调selector配置项get是纯函数的原因。
依赖外部变量
我们常常会遇到状态不存粹的问题,如果状态其实是依赖外部的变量,recoil有selectorFamily支持:
const getUserInfoState = selectorFamily({
key: "userInfo",
get: (userId) => ({ get }) => {
return queryUserState({userId: id, xxx: get(xxx) });
},
});
function MyComponent({ userID }) {
const number = useRecoilValue(getUserInfoState(userID));
//...
}
这里外部的参数和key,会同时生成一个全局唯一的key,用于标识状态,也就是说如果外部变量没有变化或者依赖没有发生变化,不会重新计算状态,而是直接返回缓存值。
源码解析
如果说看到这里,仅仅实现上面那些简单例子的话,大家可能会说“就这”?实现起来应该不太难,这里有一个简单的实现的版本,虽然功能差不多,但是架构完全不一样,recoil的源码继承了react源码的优良传统,就是十分难读。。。
其源码核心功能分为几个部分:
- Graph 图相关的逻辑
- Node atom和selctor在内部统一抽象为node
- RecoilRoot 主要是就是外部用的一些recoilRoot,
- RecoilValue 对外部暴露的类型。也就说atom、selctor的返回值。
- hooks 使用的hooks相关的。
- Snapshot 状态快照,提供状态记录和回滚。
- 一些其他读不懂的代码。。。
下面就谈谈自己这几天看源码粗浅的认识,欢迎大佬们指正。
Concurrent mode 支持
为了防止把大家绕晕,先讲讲我最关心的问题,recoil是如何支持conccurent的思路,可能不太正确(网上没有资料参考,欢迎讨论)。
Cocurrent mode
先讲一讲什么是react的Cocurrent mode,官网的介绍是,一系列新的特性帮助ract应用保持响应式和并优雅的使用用户设备能力和网络速度。
react迁移到fiber架构就是为了concurrent mode的实现,React 在新的架构下实际上有两个阶段:
- 渲染(rendering)阶段
- 提交(commit)阶段
在渲染阶段,react 可以根据任务优先级对组件树进行渲染,所以当前渲染任务可能会因为优先级不够或者当前帧没有剩余时间而被中断。后续调度会重新执行当前任务渲染。
ui和state不一致的问题
因为react现在会放弃控制流,在渲染开始到渲染结束,任何事情都可能发生,一些的钩子被取消就是因为这个原因。而对于第三方状态库来说,比如说有一个异步请求在这段时间把外部的状态改变了,react会继续上一次打断的地方重新渲染,就会读到新的状态值。 就会发生状态和UI不一致的情况。
recoil的解决办法
useMutableSource 是react的实验API。它主要用于解决Redux这种外部状态库的状态UI不同步的问题(tearing),这个API主要是库作者使用的比较多。
看看一个简单例子:
// May be created in module scope, like context:
const locationSource = createMutableSource(
window,
// 第二个参数,返回外部状态的版本。
// react通过这个参数知道外部状态的版本,
// 一旦状态改变,那么react就会完全重新render, 而不是继续上一次render。
() => window.location.href
);
// 相当于seletor,从store中提出我们需要的状态
const getSnapshot = window => window.location.pathname;
// 用于hooks中的订阅,相比于我们传统的手写forceupdate,它帮我们内置了cb,用于组件重刷。
const subscribe = (window, callback) => {
window.addEventListener("popstate", callback);
return () => window.removeEventListener("popstate", callback);
};
function Example() {
const pathName = useMutableSource(locationSource, getSnapshot, subscribe);
// ...
}
由于本身这个API就是unstable,不过我看reactwg最近讨论要把这个API改为useSyncExternalStore。其痛点是:selector函数必须要用useCallback包一层,对redux用户来说,迁移成本太高。感兴趣可以看看这个讨论,这里就不展开了。
const mutableSource = createMutableSource(
reduxStore,
() => reduxStore.getState()
);
const MutableSourceContext = createContext(mutableSource);
const subscribe = (store, callback) => store.subscribe(callback);
function useSelector(selector) {
const mutableSource = useContext(MutableSourceContext);
const getSnapshot = useCallback(store => selector(store.getState()), [
selector
]);
return useMutableSource(mutableSource, getSnapshot, subscribe);
}
整体数据结构
Node& RecoilValue
const nodes: Map<string, Node<any>> = new Map();
const recoilValues: Map<string, RecoilValue<any>> = new Map();
recoil全局维护了两个Map,其实Node是内部使用的,而RecoilValue是用户初始化拿到的值,例如:
const testState = atom({
key:'uniqKey',
default: 1,
})
testState的类型就是一个RecoilValue。而RecoilValue类型目前只有key一个属性:
class AbstractRecoilValue<+T> {
key: NodeKey;
constructor(newKey: NodeKey) {
this.key = newKey;
}
}
而Node类型属性就有很多:
export type ReadOnlyNodeOptions<T> = $ReadOnly<{
key: NodeKey,
nodeType: NodeType,
get: (Store, TreeState) => Loadable<T>, //返回这次计算的数据以及其依赖。
init: (Store, TreeState, Trigger) => () => void, // 初始化时调用,返回的函数将在release时调用
invalidate?: TreeState => void, //通知节点缓存失效,一般是他的上游依赖调用。
//...
}>;
export type ReadWriteNodeOptions<T> = $ReadOnly<{
...ReadOnlyNodeOptions<T>,
set: (
store: Store,
state: TreeState,
newValue: T | DefaultValue,
) => AtomWrites,
}>;
type Node<T> = ReadOnlyNodeOptions<T> | ReadWriteNodeOptions<T>;
Node类型上定义都是一些方法,selector和atom在内部通过定义不同的方法,统一为Node类型,而用户拿到的是只有一个key属性的RecoilValue类型。他们之间的关系有点像react中的Fiber和Component之间的关系,Fiber用于react内部使用,Component是用户自己定义的。Recoil里selector和atom是用户自己定义的,但是recoil里面方法调用都是使用Node。
StoreState & TreeState & Graph
StoreState通过RecoilRoot暴露的方法,全局共享。主要关心的就是内部状态维护三颗树,其中nextTree用于当前更新的修改,currentTree代表当前状态的树,previousTree用于内部一些条件判断。
// StoreState代表着recoil的上下文,他是全局且可变的。
// 他在atom值改变的时候、异步请求resolve、suspense组件resolve的时候更新。
export type StoreState = {
// The "current" TreeState being either directly read from (legacy) or passed
// to useMutableSource (when in use). It is replaced with nextTree when
// a transaction is completed or async request finishes:
// 当一次更新完成或者异步请求完成,被nextTree取代。
currentTree: TreeState,
// 更新的时候,改变的树其实在这颗树上。
nextTree: null | TreeState,
// 只存在于组件或者observer更新业务新的状态需要更新的时候。
previousTree: null | TreeState,
// 完成一次更新自增
commitDepth: number,
// 已知的原子们
knownAtoms: Set<NodeKey>,
knownSelectors: Set<NodeKey>,
graphsByVersion: Map<Version, Graph>, // 里面维护着版本 --> 图关系的数据结构。
};
或许大家会奇怪为什么是tree,而不是graph呢?
看看treeState的属性:
// 每一个treeState 都代表渲染的React树,
// 在concurrent渲染下,可能出现多个treeState,且每一个都是不可变的。
export type TreeState = $ReadOnly<{
version: Version, //自增的id
stateID: StateID,// 自增id,但是当应用之前的snapshot,stateID可能会复用。
dirtyAtoms: Set<NodeKey>, // 表示改变的atom。
atomValues: AtomValues, // Atom值的map: PersistentMap<NodeKey, Loadable<any>>
nonvalidatedAtoms: PersistentMap<NodeKey, mixed>, // 需要通知他们上游改变的Atoms集合
}>;
AtomValues实际上就是NodeKey到node缓存值得映射。 dirtyAtoms表示当前状态已经发生变化的node。
至于graph的数据的维护专门放在了graphsByVersion里面,下面看看graph数据结构:
export type Graph = $ReadOnly<{
nodeDeps: Map<NodeKey, $ReadOnlySet<NodeKey>>, // 上游的依赖映射
nodeToNodeSubscriptions: Map<NodeKey, Set<NodeKey>>, // 下游的依赖映射
}>;
记录了所有上游和下游的依赖映射。
atom
atom实际上是调用baseAtom,baseAtom内部有闭包变量defaultLoadable一个用于记录当前的默认值。声明了getAtom函数和setAtom函数等,最后传给registerNode,完成注册。
function baseAtom(options){
// 默认值
let defaultLoadable = isPromise(options.default) ? xxxx : options.default
function getAtom(store,state){
if(state.atomValues.has(key)){
// 如果当前state里有这个key的值,直接返回。
return state.atomValues.get(key)
}else if(state.novalidtedAtoms.has(key)){
//.. 一些逻辑
}else{
return defaultLoadable;
}
}
function setAtom(store, state, newValue){
if (state.atomValues.has(key)) {
const existing = nullthrows(state.atomValues.get(key));
if (existing.state === 'hasValue' && newValue === existing.contents) {
// 如果相等就返回空map
return new Map();
}
}
//...
// 返回的的是key --> 新的loadableValue的Map
return new Map().set(key, loadableWithValue(newValue));
}
function invalidateAtom(){
//...
}
const node = registerNode(
({
key,
nodeType: 'atom',
get: getAtom,
set: setAtom,
init: initAtom,
invalidate: invalidateAtom,
// 忽略其他配置。。。
}),
);
return node;
}
function registerNode(){
if (nodes.has(node.key)) {
//...
}
nodes.set(node.key, node);
const recoilValue =
node.set == null
? new RecoilValueClasses.RecoilValueReadOnly(node.key)
: new RecoilValueClasses.RecoilState(node.key);
recoilValues.set(node.key, recoilValue);
return recoilValue;
}
selector
由于selector也可以传入set配置项,这里就不分析了。
function selector(options){
const {key, get} = options
const deps = new Set();
function selectorGet(){
// 检测是否有循环依赖
return detectCircularDependencies(() =>
getSelectorValAndUpdatedDeps(store, state),
);
}
function getSelectorValAndUpdatedDeps(){
const cachedVal = getValFromCacheAndUpdatedDownstreamDeps(store, state);
if (cachedVal != null) {
setExecutionInfo(cachedVal, store);
// 如果有缓存值直接返回
return cachedVal;
}
// 解析getter
const [loadable, newDepValues] = evaluateSelectorGetter(
store,
state,
newExecutionId,
);
// 缓存结果
maybeSetCacheWithLoadable(
state,
depValuesToDepRoute(newDepValues),
loadable,
);
//...
return lodable
}
function evaluateSelectorGetter(){
function getRecoilValue(recoilValue){
const { key: depKey } = recoilValue
dpes.add(key);
// 存入graph
setDepsInStore(store, state, deps, executionId);
const depLoadable = getCachedNodeLoadable(store, state, depKey);
if (depLoadable.state === 'hasValue') {
return depLoadable.contents;
}
throw depLoadable.contents;
}
const result = get({get: getRecoilValue});
const lodable = getLodable(result);
//...
return [loadable, depValues];
}
return registerNode<T>({
key,
nodeType: 'selector',
peek: selectorPeek,
get: selectorGet,
init: selectorInit,
invalidate: invalidateSelector,
//...
});
}
}
hooks
useRecoilValue && useRecoilValueLoadable
- useRecoilValue底层实际上就是依赖useRecoilValueLoadable,如果useRecoilValueLoadable的返回值是promise,那么就把他抛出来。
- useRecoilValueLoadable 首先是在useEffect里订阅RecoilValue的变化,如果发现变化不太一样,调用forceupdate重新渲染。返回值则是通过调用node的get方法拿到值为lodable类型的,返回出来。
function useRecoilValue<T>(recoilValue: RecoilValue<T>): T {
const storeRef = useStoreRef();
const loadable = useRecoilValueLoadable(recoilValue);
// 如果是promise就是throw出去。
return handleLoadable(loadable, recoilValue, storeRef);
}
function useRecoilValueLoadable_LEGACY(recoilValue){
const storeRef = useStoreRef();
const [_, forceUpdate] = useState([]);
const componentName = useComponentName();
useEffect(() => {
const store = storeRef.current;
const storeState = store.getState();
// 实际上就是在storeState.nodeToComponentSubscriptions里面建立 node --> 订阅函数的映射
const subscription = subscribeToRecoilValue(
store,
recoilValue,
_state => {
// 在代码里通过gkx开启一些特性,方便单元测试和代码迭代。
if (!gkx('recoil_suppress_rerender_in_callback')) {
return forceUpdate([]);
}
const newLoadable = getRecoilValueAsLoadable(
store,
recoilValue,
store.getState().currentTree,
);
// 小小的优化
if (!prevLoadableRef.current?.is(newLoadable)) {
forceUpdate(newLoadable);
}
prevLoadableRef.current = newLoadable;
},
componentName,
);
//...
// release
return subscription.release;
})
// 实际上就是调用node.get方法。然后做一些其他处理
const loadable = getRecoilValueAsLoadable(storeRef.current, recoilValue);
const prevLoadableRef = useRef(loadable);
useEffect(() => {
prevLoadableRef.current = loadable;
});
return loadable;
}
这里一个有意思的点事useComponentName的实现有一点点hack:由于我们通常会约定hooks的命名是use开头,所以可以通过调用栈去找第一个调用函数不是use开头的函数名,就是组件的名称。当然生产环境,由于代码混淆是不可用的。
function useComponentName(): string {
const nameRef = useRef();
if (__DEV__) {
if (nameRef.current === undefined) {
const frames = stackTraceParser(new Error().stack);
for (const {methodName} of frames) {
if (!methodName.match(/\buse[^\b]+$/)) {
return (nameRef.current = methodName);
}
}
nameRef.current = null;
}
return nameRef.current ?? '<unable to determine component name>';
}
return '<component name not available>';
}
useRecoilValueLoadable_MUTABLESOURCE基本上是一样的,除了订阅函数里我们从手动调用foceupdate变成了调用参数callback。
function useRecoilValueLoadable_MUTABLESOURCE(){
//...
const getLoadable = useCallback(() => {
const store = storeRef.current;
const storeState = store.getState();
//...
const treeState = storeState.currentTree;
return getRecoilValueAsLoadable(store, recoilValue, treeState);
}, [storeRef, recoilValue]);
const subscribe = useCallback(
(_storeState, callback) => {
const store = storeRef.current;
const subscription = subscribeToRecoilValue(
store,
recoilValue,
() => {
if (!gkx('recoil_suppress_rerender_in_callback')) {
return callback();
}
const newLoadable = getLoadable();
if (!prevLoadableRef.current.is(newLoadable)) {
callback();
}
prevLoadableRef.current = newLoadable;
},
componentName,
);
return subscription.release;
},
[storeRef, recoilValue, componentName, getLoadable],
);
const source = useRecoilMutableSource();
const loadable = useMutableSource(source, getLoadableWithTesting, subscribe);
const prevLoadableRef = useRef(loadable);
useEffect(() => {
prevLoadableRef.current = loadable;
});
return loadable;
}
useSetRecoilState & setRecoilValue
useSetRecoilState最终其实就是调用queueOrPerformStateUpdate,把更新放入更新队列里面等待时机调用
function useSetRecoilState(recoilState){
const storeRef = useStoreRef();
return useCallback(
(newValueOrUpdater) => {
setRecoilValue(storeRef.current, recoilState, newValueOrUpdater);
},
[storeRef, recoilState],
);
}
function setRecoilValue<T>(
store,
recoilValue,
valueOrUpdater,
) {
queueOrPerformStateUpdate(store, {
type: 'set',
recoilValue,
valueOrUpdater,
});
}
queueOrPerformStateUpdate,之后的操作比较复杂这里做简化为三步,如下;
function queueOrPerformStateUpdate(){
//...
//atomValues中设置值
state.atomValues.set(key, loadable);
// dirtyAtoms 中添加key。
state.dirtyAtoms.add(key);
//通过storeRef拿到。
notifyBatcherOfChange.current()
}
Batcher
recoil内部自己实现了一个批量更新的机制。
function Batcher({
setNotifyBatcherOfChange,
}: {
setNotifyBatcherOfChange: (() => void) => void,
}) {
const storeRef = useStoreRef();
const [_, setState] = useState([]);
setNotifyBatcherOfChange(() => setState({}));
useEffect(() => {
endBatch(storeRef);
});
return null;
}
function endBatch(storeRef) {
const storeState = storeRef.current.getState();
const {nextTree} = storeState;
if (nextTree === null) {
return;
}
// 树交换
storeState.previousTree = storeState.currentTree;
storeState.currentTree = nextTree;
storeState.nextTree = null;
sendEndOfBatchNotifications(storeRef.current);
}
function sendEndOfBatchNotifications(store: Store) {
const storeState = store.getState();
const treeState = storeState.currentTree;
const dirtyAtoms = treeState.dirtyAtoms;
// 拿到所有下游的节点。
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);
}
}
}
}
//...
}
总结
虽然关于react的状态管理库很多,但是recoil的一些思想还是很先进,社区里面对这个新轮子也很多挂关注,目前github star 14k。因为recoil目前还不是稳定版本,所以npm下载量并不高,也不建议大家在生产环境中使用。不过相信随着react18的发布,recoil也会更新为稳定版本,它的使用将会越来越多,到时候大家可以尝试一下。