注册

React下一代状态管理库——recoil

引言


对于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的开源的库。官方宣称有三个主要的特性:




  1. Minimal andReactish:最小化和react风格的api。




  2. Data-Flow Graph:数据流图。支持派生数据和异步查询都是纯函数,内部都是高效的订阅。




  3. Cross-App Observation: 跨应用监听,能够实现整体状态监听。




基本设计思想


假如有这么一个场景,相应状态改变我们 仅仅需要 更新list中的第二个节点和canvas的第二个节点。

如果没有使用第三外部状态管理库,使用context API可能是这样的:



我们可能需要很多个单独的provider,对应仅仅需要更新的节点,这样实际上使用状态的子节点的和Provider实际上是 耦合 的,我们使用状态的时候需要关心是否有相应的provider。
又假如我们使用的redux,其实如果只是某一个状态更新,其实所有的订阅函数都会重新运行,即使我们最后通过selector浅对比两次状态一样的,阻止更新react树,但是一旦订阅的节点数量非常多,实际上是会有性能问题的。


recoil把状态分为了一个个原子,react组件树只会订阅他们需要的状态。在这个场景中,组件树左边和右边的item订阅了不同的原子,当原子改变,他们只会更新相应的订阅的节点。

同时recoil也支持“派生状态”,也就是说已有的原子组合成一个新的状态(selector),并且新的状态也可以成为其他状态的依赖。

不仅支持同步的selector,recoil也支持异步的selector,recoil对selector的唯一要求就是他们必须是一个纯函数。

Recoil的设计思想就是我们把状态拆分一个一个的原子atom,再由selector派生出更多状态,最后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也有一个Provider——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;
}

另外注意默认异步的结果是会被缓存下来,其实所有的selector上游没有改变的结果都会被缓存下来。也就是说如果异步的依赖没有发生改变,那么不会重新执行异步函数,直接返回缓存的值。这也是为什么一直强调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 图相关的逻辑




  • Nodeatom和selector在内部统一抽象为node




  • RecoilRoot 主要是就是外部用的一些recoilRoot,




  • RecoilValue 对外部暴露的类型。也就说atom、selector的返回值。




  • 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的解决办法


整体数据结构



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的一些思想还是很先进,社区里面对这个新轮子也很多挂关注,目前githubstar14k。因为recoil目前还不是稳定版本,所以npm下载量并不高,也不建议大家在生产环境中使用。不过相信随着react18的发布,recoil也会更新为稳定版本,它的使用将会越来越多,到时候大家可以尝试一下。


链接:https://juejin.cn/post/7006253866610229256

0 个评论

要回复文章请先登录注册