本文作者:江水背景:Recoil是Facebook专门为React应用推出的状态管理库。在一定程度上,它代表了当前的一种发展趋势。使用的时候感觉有些概念很高级,可以很好的满足一个前端开发者的数据需求。本文总结了Recoil的这些特性。根据官网的介绍,Recoil的数据定义了一个有向图,通过改变图的根节点(atom)进行状态变化,然后通过一个纯函数(selector)流向React组件。同时,Recoil的状态定义是增量的、分布式的。增量意味着我们可以在使用的时候定义新的状态,而不是在消费之前必须提前定义所有的状态。分布式是指状态的定义可以放在任何地方,不必统一注册在一个文件中。这样做的好处是,一方面可以简化状态定义过程,另一方面也可以很好的应用在代码拆分的场景中。在应用程序中启用Recoil非常简单,只需要包装一个RecoilRoot。从'recoil'导入{RecoilRoot};ReactDOM.render(,root);状态定义、原子和选择器Recoil允许使用原子和选择器这两个函数来定义基础和派生状态。atom的基本用法,定义了相关的atom属性,需要用一个唯一的key来描述这个atom。Recoil中不允许有重复键,包括后文提到的选择器。constfirstNameAtom=atom({key:'firstnameatom',default:''});constlastNameAtom=atom({key:'lastnameatom',default:''});使用时通过useRecoilStatehooks获取状态,可以看到它和useState非常相似,所以可以很方便的将传统的React状态迁移到Recoil。functionUserProfile(){-const[firstName,setFirstName]=useState('');+const[firstName,setFirstName]=useRecoilState(firstNameAtom);return(
{firstName}
);}很多时候我们如果只想获取数据不想修改,或者反之,可以使用语法糖useRecoilValue和useSetRecoilStatefunctionUserProfile(){constfirstName=useRecoilValue(firstNameAtom);return(
{firstName}
);}Recoil会根据这些状态用到哪里,自动建立依赖关系。当发生变化时,Recoil只会通知相应的组件进行更新。选择器的用法和atom非常相似。要构造一个选择器,至少需要一个唯一键和一个get函数。constnameSelector({key:'mynameselector',get:({get})=>{returnget(firstNameAtom)+''+get(lastNameAtom);}});任何atom/selector都可以在selector中读写,没有任何限制。只有get方法的selector是只读的,如果需要可写也支持传入set方法。constnameSelector({key:'我的名字选择器',get:({get})=>{returnget(firstNameAtom)+''+get(lastNameAtom);},set:({get,set},value)=>{constnames=value.split('');set(firstNameAtom,names?.[0]);set(lastNameAtom,names?.[1]);}});值得一提的是,selector支持从网络异步获取数据。这就是乐趣的开始。这也是与其他状态管理的最大区别。Recoil的状态不仅是纯粹的状态,更是来自于网络的状态。constuserSelector=selector({name:'userselector',get:()=>{returnfetch('/api/user');}});使用selector时,可以像atomhook一样传递useRecoilState,useRecoilValue,useSetRecoilState。函数App(){constuser=useRecoilValue(userSelector);...}可以轻松重构我们的代码。如果一个属性一开始是一个原子,后来想变成一个计算属性,可以很方便的替换这部分逻辑,不需要修改业务层代码。后坐力也可以更厉害,大致可以概括为下图。可以作为统一的数据抽象层,通过http、ws、GraphQL等技术将后端数据映射到前端组件。atomFamilyselectorFamily批量创建状态解决方案在某些场景下,需要批量创建状态。我们会实例化多个相同的组件,每个组件都需要对应自己独立的状态元素。这时候就可以使用xxxFamilyapi了。constnodeAtom=atomFamily({key:'nodeatom',default:{}});functionNode({nodeId}){const[node,setNode]=useRecoilState(nodeAtom(nodeId));}如你所见,atomFamilyreturns是一个函数,而不是RecoilState对象。传入不同的nodeId会检查之前是否存在,如果存在则复用之前的nodeId,如果不存在则创建并初始化为默认值。同样,对于selectorFamily。constuserSelector=selectorFamily({key:'userselectorfamily',get:(userId)=>()=>{returnfetch(`/api/user/${userId}`);}});functionUserDetail({userId}){constuser=useRecoilValue(userSelector(userId));}由于批量创建可能会导致内存泄漏,Recoil还提供了缓存策略管理,分别是lru、keep-all、most-recent,可以根据实际自定义需要选择。Suspense和Hooks上面说到每个atom和selector可以是本地数据也可以是网络状态(是的,没错,atom也可以是异步数据,常用的atom初始化都是异步的,然后就变成了同步数据),你不需要在消费组件时关心其背后的实际来源,使用远程数据与使用本地数据一样简单。让我们看一个常见的获取数据和显示组件的例子。functiongetUser(){returnfetch('/api/user');}functionLocalUserStatus(){const[loading,setLoading]=useState(false);const[user,setUser]=useState(null);useEffect(()=>{setLoading(true);getUser().then((user)=>{setUser(user);setLoading(false);})},[]);如果(加载){返回空值;}return(
{user.name}
)}对于这种开发习惯(通常称为Fetch-on-Render):我们需要一个useEffect来获取数据,然后我们需要设置一些loading和errorstates来处理边界状态,如果这个数据不是全局的、顶级的数据,而是分散在子组件中进行消费,那么在每个使用的地方都要执行类似的逻辑。看一下Recoil的写法constlocalUserAtom=atom({key:'本地用户状态',default:selector({//<------默认值来自selectorkey:'user选择器',get:()=>{returnfetch('/api/user');}})});functionLocalUserStatus(){constlocalUser=useRecoilValue(localUserAtom);return(
{localUser.name}
)}这里,组件层并不关心数据来自哪里,Recoil会自动按需请求数据。相比之下,后者的代码就简洁多了(Render-as-You-Fetch),背后也没有发明新的概念,都是React原生的特性,也就是Suspense。如果使用异步原子或者选择器,外层需要一个Suspense来处理网络没有返回时的加载状态。也可以设置一层ReactErrorBoundary来处理网络异常。//UserProfile使用需要从网络加载的数据functionLocalUserStatus(){constuser=useRecoilValue(localUserAtom);...}functionApp(){return(
);}通过剥离掉常见的Loading和Error逻辑,使得通用组件中的条件分支减少了66%,最先呈现的是数据准备状态,减少了问题额外的处理逻辑和钩子的过早初始化。hooks过早初始化的问题可以参考我的文章:【状态管理库Recoil,可能是用起来最爽的】(https://zhuanlan.zhihu.com/p/...)useRecoilValueLoadable(state)读取数据,但返回的是一个Loadable,与useRecoilValue不同。useRecoilValueLoadable不需要外层的Suspense,相当于把边界条件交给了用户。Loadable的对象结构如下:它的作用是我们可以获取当前数据是否正在loading或者是否有Value,并手动处理这些状态,适用于页面渲染场景的灵活处理。constuserLoadable=useRecoilValueLoadable(userSelector);constisLoading=userLoadable.state==='loading';constisError=userLoadable.state==='hasError';constvalue=userLoadable.getValue();Recoil用于映射外部系统在某些场景下,我们希望Recoil能够与外部系统同步。典型的例子有react-router同步到atom的历史,原生js动画库的状态要同步到Recoil,atom要同步到远程mongodb。通过直接读写atom,可以直接读写外部系统,大大提高开发效率。在这种情况下,可以使用recoil-sync包。下面列举两个案例。使用sharedb+recoil-sync可以同步atom、mongodb/postgres等数据库的状态,让远程数据库修改和本地修改一样方便。//修改会实时同步到远程mongodbconst[name,setName]=useRecoilState(nameAtom);使用recoil-sync同步atom和pixi.js动画元素的状态https://codesandbox.io/s/nice...此时可以将画布上的部分精灵转为受控模式。由于同步过程中存在数据格式校验问题,recoil-sync使用@recoiljs/refine提供不同版本的数据校验和数据迁移功能。反冲状态快照具有更精细的状态粒度。对于需要批量设置RecoilState的场景,Recoil有Snapshot的概念,适用于ssr时注入首屏数据,创建快照回滚,批量更新等场景。填充SSR的数据函数initState(snapshot){snapshot.set(atoms.userAtom,{name:'foo',});snapshot.set(atoms.countAtom,0);}exportdefaultfunctionApp(){return(
...);}应用数据回滚函数TimeMachine(){constsnapshotRef=useRef(空);const[count,setCount]=useRecoilState(countAtom);constonSave=useRecoilCallback(({snapshot})=>()=>{snapshot.retain();snapshotRef.current=snapshot;},[]);constonRevoca=useRecoilCallback(({gotoSnapshot})=>()=>{if(snapshotRef.current){gotoSnapshot(snapshotRef.current);}},[]);返回(保存recovasetCount((v)=>v+1)}>add{count}
);}不使用async-await也可以实现异步转同步的代码。在React的世界里,一直有一种非常奇怪的代码技巧。这种技术可以在不使用生成器或异步的情况下实现异步到同步的功能。在了解了Recoil的一些用法之后我当时也注意到了这个现象,很有意思,这里介绍一下:如果userSelector是一个需要从网络获取的状态,那么读取它可以看作是一个异步操作,但是在写选择器的时候,我们可以用同步的方式写constuserNameSeletor=selector({key:'usernameselector',get:({get})=>{constuser=get(userSelector);<---这是一个网络请求returnuser.name;}});这种写法以前就出现过,我们在组件中使用选择器的时候并没有考虑到它的异步性。functionUserProfile(){constuser=useRecoilValue(userProfile);<----这里也是一个网络请求constuserId=user.id;returnuid:{userId}
;}在组件中使用当使用外层的Suspense执行时,在上面的get回调中隐含使用了类似的方法。当发生异步时,get方法会将Promise作为异常抛出。当异步结束后,函数会重新执行。所以这个函数本身会被执行两次,有点黑魔法的感觉,这也需要我们此时保证get是一个纯函数。如果一个selector的get回调中有网络请求,就不再是纯函数了。这个时候需要保证网络请求是在所有异步选择器都执行完之后调用的。//正确用法constnameSelector=selector({key:"nameselector",get:async({get})=>{get(async1Selector);get(async2Selector);awaitnewPromise((resolve)=>{setTimeout(resolve,0);});return1;}});//错误用法constnameSelector=selector({key:"nameselector",get:async({get})=>{get(async1Selector);awaitnewPromise((resolve)=>{setTimeout(resolve,0);});get(async2Selector);return1;}});最后,关于代码直觉,精神负担最近很多人会讨论一个库是否适合引入这两个词经常被提到。当我们不了解一个库时,我们很容易会说“这个库太复杂了”“API太多了,记不住”之类的话。在Recoil的世界里,如果我们接受atom、selector,那么atomFamily、selectorFamily也很容易理解。由于习惯了useState,所以useRecoilValue和useSetRecoilValue也比较容易接受,也很符合hook的直觉。Recoil的api与react自带的useState、useCallback、Suspense概念相同。两者的使用会加深对react框架本身的理解。它们在同一行,不引入其他编程概念。虽然api很多,但是精神负担并不大。作为一个反例,如果我在react中使用observable类型的状态管理,我可能会思考在某些场景下useEffect是否能达到预期的效果。有些功能虽然用起来舒服,却加深了精神负担。如果我错了,请纠正我。本文由网易云音乐技术团队发布。未经授权禁止任何形式的转载。我们常年招聘各种技术岗位。如果你要跳槽,又恰好喜欢云音乐,那就加入我们吧grp.music-fe(at)corp.netease.com!