Recoil – React 状态管理库

时间:2021-1-8 作者:admin

引言

说到 React 状态管理库,大家已经习惯了使用 Redux,所以在开始介绍 Recoil 之前,我们先来看个使用场景,对比下两个库的设计思想和实现方式。

项目功能说明

  1. 左边为所有 Item 信息列表,并且可以增加新项。
  2. 中间画布展示所有 Item。Item 可以拖动,拖动时右边属性实时更新。
  3. 通过修改右边属性面板信息,Item 可以实时在画布中更新。

Redux 实现方式

为了保存所有的 Item,我们一般的做法是定义一个数组来存储:

import { createStore } from 'redux'
const initialState = { items: [] };
function todoApp(state = initialState, action) {
  switch (action.type) {
    case 'ADD':
      return { items: [...state.items, action.preload] }
    default:
      return state
  }}
export cosnt store = createStore(todoApp)

然后通过 map 来渲染所有的 Item:

function Canvas(props) {
  const { items } = props;
  return (
    <div>
      {items.map(item => {
        return <Item key={item.id} item={item} />
      })}
    </div>
  );
}

这种做法有什么不足?

新增一项和任何一个 Item 的更新都会触发所有 Item 组件的更新(原因是什么)。而对于上述项目,Item 的拖动又是一个那么高频的操作。所以如果我们能做到只有拖动的 Item 才更新,其他 Item 不更新,那对性能的提升会有一定的帮助。

Recoil 实现方式

要做到单个 Item 的更新不影响到其他 Item,那么每个 Item 的状态都应该是独立的。

这也正是 Recoil 的状态管理理念。Recoil 推崇状态的独立定义和管理,所以在上面的场景中,我们可以为每个 Item 定义自己的状态,这样,单个 Item 移动时,只有该 Item 的状态发生改变从而触发更新,其他 Item 的状态不变,则不会触发更新。

在 Recoil 中,我们使用 atom 函数定义一个状态。这样,我们就可以为每个 Item 定义一个独立的状态。

export const itemState = atom({
  key: 'itemState',
  default: {}
})

然后在 Item 组件内使用

import { useRecoilState } from 'recoil';
import { itemState } from '../state';
function Item() {
  const [item, setItem] = useRecoilState(itemState);
  // other
  return (
    <div>{{ item.title }}</div>
  );
}

上面的实现方式是显式定义一个 Item 的状态,在上述的场景中, Item 是可以增加和删除的,所以,我们需要具备动态创建和删除状态的能力。

所以我们可以提供个方法,来根据 Item id 来获取该 Item 的 state,并且实现缓存。

const stateCache = {};
export const getItemState = id => {
  if (!stateCache[id]) {
    stateCache[id] = atom({
      key: `item-${id}`,
      default: {}
    })
  }
  return stateCache[id]
}

组件内使用:

import { useRecoilState } from 'recoil';
import { getItemState } from '../state';
function Item({ id }) {
  const [item, setItem] = useRecoilState(getItemState(id));
  // other
  return (
    <div>{{ item.title }}</div>
  );
}

这样,每个 Item 都是独立的 state,其任何的变更都不会对其他 Item 造成影响。

状态集合和状态缓存

上述我们的 getItemState 其实就已经实现了状态的集合和缓存。而 Recoil 就原生提供了一个工具方法 atomFamily。不需要我们再进行一次封装了,同时从底层更好地实现了内存管理。

export const itemFamily = atomFamily({
  key: 'itemFamily',
  default: id => {}
})

这就是 Recoil 状态管理的核心理念,抽离独立的状态,独立管理,避免不必要的渲染,同时便于扩展和管理。而 Redux 是状态的集中管理,基本上整个应用只有一个全局的状态。

到这里,已经向大家介绍了 Recoil 的状态管理理念-独立定义,还有状态集合和状态缓存功能。接下来,将会地详细介绍 Recoil 。

Recoil

社区活跃度

我们先看下目前 Recoil 在社区中的使用情况。(截止2020.10.30)

NPM 包每周下载量曲线:

Github 仓库的 start 数:

我们可以看到,增长趋势是很快的,2020.5.30 发布的第一个正式版本。

通过上面两组数据,可以看出作为一个新的工具,在社区中的关注度还是挺高的。

Facebook 最新推出

Recoil 是由 Facebook 推出(官方血统)。官方说法现在还处于实验阶段,不过 Facebook 的一些内部产品已经用于生产环境了。

更新迭代快

2020年5月30号发布第一个版本(0.0.8),到现在最新版本(0.0.13),基本每个月一个版本。

Hook Only

全部 API 都是以 hook 方式提供,只支持在 Function Component 中使用。不支持在 Class Component 中使用。所以,在你考虑是否将要 Recoil 引入你的项目时,你需要再次确认,应用中所有状态共享的组件是否都是 Function Component,如果不是,那你就要放弃 Recoil 了。不过随着 React Hook 的推进和广泛应用,这应该是 React 的一个趋势,而原理上,任何 Class Component 都可以以 Function Component 方式实现。

三大核心特性

Minimal and Reactish

Reocil 推崇 state 分散管理(类似 mobx),我们可以单独定义应用中各个独立的子状态,让我们的状态管理变得更加高效和可扩展。后面会详细介绍状态的原子化定义。

Recoil 是完全是为了 React 而生。使用风格和 React 完全一致,没有新的语法学习负担。所有的 API 都是以 React 方式提供,可以理解它是对 React 的扩展。

Data-Flow Graph

状态共享会有个数据流图,从共享状态到组件,组件到共享状态这样一个闭环。组件订阅状态,则就形成了从状态到组件的一个数据流,组件更新状态,则形成了从组件到状态的一个数据流。

而 Recoil 的数据流图中,可以很容易引入了派生状态、异步状态,不需要二次封装。Redux 是没有异步数据流的(redux-thunk、redux-saga 是基于同步数据流的封装)。

Cross-App Observation

Recoil 提供了状态快照功能 Snapshot 。通过它,我们可以实现状态的持久化,观察、监控和管理状态的变化。个人感觉,其实这个就是对 state 分散管理不足的一个弥补,像 Redux state 集中管理是很容易实现该功能的。

支持 Concurrent Mode

目前已发布的版本,已经提供了一些实验性的 api 以支持 React 该特性。会与 React 合作,以高效支持 Concurrent Model。而目前其他状态管理库都无法支持 Concurrent Mode

什么是 Concurrent Mode

对于 React 的复杂组件在执行一次 Render 时,耗时是会比较长的,由于 JS 是单线程的,这时候就出现了页面”假死”的情况。在 React 渲染过程中,是无法再去响应用户的操作的,比如按钮点击等。这就造成了很不好的用户体验。

为了解决该问题,React 引入分片处理机制,这就会出现在一次 rendering 过程中,不是所有的节点都是连续渲染完成的。

比如一次渲染需要 100ms,React 会将这次渲染分为 10 片,每次会渲染一片,当执行一片后,就会看一下有没有高优先的事情需要处理,如果有的话,就会交出控制权,停止渲染操作,先去响应该事件,从而避免页面”假死”。等该事件处理完成后,继续渲染。
这就会导致一些生命周期函数会被多次调用,引入 React Fiber 后对 React 生命周期钩子进行了调整。

虽然目前该模式为实验阶段,不过是 React 的未来之路。在最新的发布的 React 17 版本,其大部分更新跟该模式相关。所以该模式的正式推出只是时间问题。

更多了解:React Fiber 是什么理解 React Fiber & Concurrent Mode

Concurrent Mode 对状态管理的影响

目前的 Redux 和 mobx 是无法兼容 Concurrent Mode 的。这是因为在该模式下,渲染可以是随时触发的。如下图:在一个组件渲染一部分时,就被停止渲染,把执行权交出去,一段时间获取到执行权后,再从上次结束的地方继续渲染。

这会造成什么问题呢?状态不一致。

Recoil 如何支持 Concurrent Mode

在 Recoil 的最新版本,已经提供了一些实验性的 api 来支持该模式。Recoil 在渲染时如果检测到状态已经发生了改变,则会重新渲染整个节点树来避免状态的不一致。目前该机制是高效的,之后会继续优化性能。

持续提高性能

优化内存管理、更高效地支持大数据的状态等。

持续迭代

正在开发者工具、支持与外部数据同步等。

至此,以上就是 Recoil 目前所有的核心特性了。总结一下,核心点主要有:状态分散管理、只支持 hook 方式、支持 Concurrent Mode。

接下来将会简单演示下 Recoil 的一些核心 API 的使用。

基础使用

快速入门

正常操作,所有需要共享状态的组件都需要包含在 RecoilRoot 组件内。没错,其源码的实现就是使用了 Context。

import React from 'react';
import { RecoilRoot } from 'recoil';
function App() {
  return (
    <RecoilRoot>
      <CharacterCounter />
    </RecoilRoot>
  );
}

定义状态

Recoil 提供两个函数来定义一个共享状态,接下来,我们先举例最常用的 atom。

通过 atom 函数来定义一个状态。

import { atom } from 'recoil';
export const selectedIDs = atom({
  key: 'selectedIDs',
  default: []
})

打印该返回值,我们可以看到 是一个 RecoilState 类型的对象。这时候我们就已经定义了一个状态了。 该状态可以任意组件订阅和修改,该状态值改变时,所有订阅该状态的组件都会触发重新渲染。

订阅/更新状态:

Recoil 提供了四个 Hook,让我们可以在组件内订阅和更新状态:

useRecoilState: 返回一个元组,我们可以拿到该状态的引用和更新状态的方法,其使用和 useState 一样。

import { useRecoilState } from 'recoil';
import { selectedIDs } from '../state';
const App = props => {
  const [sIds, setSIds] = useRecoilState(selectedIDs);
}

useRecoilValue: 当我们只需要订阅该状态,不需要更新时,可以使用该方法。

import { useRecoilValue } from 'recoil';
import { selectedIDs } from '../state';
const App = props => {
  const sIds= useRecoilValue(selectedIDs);
}

useSetRecoilState: 当我们只需要更新,不需要订阅时,可以使用该方法。状态更新时,该组件则不会重新渲染。

import { useSetRecoilState } from 'recoil';
import { selectedIDs } from '../state';
const App = props => {
  const setSIds = useSetRecoilState(selectedIDs);
}

useResetRecoilState: 该方法可以让我们重置该状态到默认值。状态更新时,该组件则不会重新渲染。

import { useResetRecoilState } from 'recoil';
import { selectedIDs } from '../state';
const App = props => {
  const resetSIds = useResetRecoilState(selectedIDs);
  // do something
  resetSIds();
}

进阶使用

派生状态

在状态划分时,Recoil 推崇的是 Minimal,也就是最小粒度化。这样不仅避免了不必要的订阅和组件更新,而且让我们的状态不会冗余。

比如上述的项目中,选择框的状态就不需要独立定义,因为选择框的位置是有选中的 Item 决定的,通过 Item 的选中状态,我们是可以计算得到选择框的位置。这就是状态派生数据。

这也是 Recoil 的第二个核心 API: selector。

通过 selector 函数,我们可以依赖已有的状态(atom 或者 selector 定义的),来定义一个派生的状态,当依赖的状态更新时,会自动重新计算新值,并触发订阅组件的更新。

selector 和 atom 一样,都会得到一个 Recoil state,组件可以订阅该状态。

// state.js
export const selectBorderInfo = selector({
  key: 'selectBorderInfo',
  get: ({get}) => {
    const sIds = get(selectedIDs);
    const items = sIds.map(id => get(itemFamily(id)));
    return calSelector(items)
  }
})
// selector.js
import { selectBorderInfo } from '../state';
import { useRecoilValue } from 'recoil';
export default (props) => {
  const selectBorder = useRecoilValue(selectBorderInfo);
  // render
}

派生数据也可以是双向的

当 selector 的 option 只提供了 get 函数,那得到的是一个只读的状态,组件内只能使用 useRecoilValue 来订阅。
如果有场景需要重新给派生状态设置新值,并且其依赖的状态也跟随变更,就可以在初始的 option 中设置 set 函数。当设置了 set 函数后,就可以得到一个可写的状态。

const proxySelector = selector({
  key: 'ProxySelector',
  get: ({get}) => ({...get(myAtom), extraField: 'hi'}),
  set: ({set}, newValue) => set(myAtom, newValue),
});

异步状态

在实际的业务开发中,往往一个状态的初始值应该是从服务端同步过来的,但是 HTTP 请求是有延迟的,所以我们一般会采用下面的做法:在组件创建后去拉取数据,获取数据后再重新渲染。

useEffect(() => {
  (async () => {
    const list = await getGameList()
    setGames(list)
  })()
}, [])

前面我们介绍了 selector,它可以定义派生的状态。除此之外,它还有一个强大的特性,它还可以定义异步状态,在 option 的 get 参数中只要返回一个 Promise 或者使用 async 语法。则可以定义异步状态了,所以说异步状态的支持很方便。

export const gameList = selector<game.Game[]>({
  key: 'gameList',
  get: async () => {
    return await getGameList()
  }
})

const [games, setGames] = useRecoilState<game.Game[]>(gameList)

由于请求是异步,所以组件第一次渲染时,数据是还没有请求回来的。如果直接用于 React 渲染,是会报错的(还是个 Promise 对象)。

有两种方法可以处理这个请求过程:

  1. 配合 React 原生特性 React.SuspenseErrorBoundary(错误处理)
  2. Recoil 的 Loadable
return (
    <RecoilRoot>
      <ErrorBoundary>
        <React.Suspense fallback={<div>Loading...</div>}>
          <UserInfo userID={1}/>
          <UserInfo userID={2}/>
          <UserInfo userID={3}/>
        </React.Suspense>
      </ErrorBoundary>
    </RecoilRoot>
  );
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;
  }
}

主要注意的是,selector 可能会被重复执行多次,所以其结果会被缓存,它应该是一个纯函数,相同的输入参数和依赖项,其得到的值应该是一样的。

同样,在使用异步状态时,也需要相同的输入,得到的值是相同的。相同的查询参数,只会执行一次异步查询。

状态快照

Redux 是状态集中管理的,一般只有一个 state,然后通过 reducer 去更新,所以状态的更新的可预测、可监控、可以 log 的。

而 Recoil 采用的是状态分散管理,这样就无法对整个应用的状态进行记录和监控。

所以,Recoil 提供了 Snapshot。通过它,我们可以拿到当前应用中所有状态的一个快照。我们就可以实现状态的持久化,观察、监控和管理状态的变化。

使用场景比如有:分享和撤销重做。通过状态快照,可以同步和分享各个时间点的状态。通过对快照的使用,可以很方便地实现撤销重做功能。

Recoil 总结

Recoil 可以说是一个以面向未来而开发的轮子。随着 React 的更新迭代,特别是 Concurrent Mode 的推进,Recoil 将来的发展具有很大的潜力。

可能大家短时间内都不会在实际项目开发中使用到它。小项目也是可以尝试下的。

不过 Recoil 提供的一些功能特性和采用的思想是不错和值得学习的,比如:状态原子化、状态集合和状态缓存等。

工具终究是工具,没有好坏,只有适合,了解下新的东西,学习其原理和思想,视场景而使用,如果只是简单的数据共享,不需要其他花里胡哨的功能,使用原生功能即可。

不可避免,Recoil 也存在一些缺点:新的工具,目前还不稳定,性能上也不是最优。另外,提供的 api 很多,功能零散和细小,大型项目开发可能需要二次开发等等。

不过 Recoil 一直在发展和迭代,而且还拥抱 hook 和 Concurrent Mode,还是可以期待的。

(扩展阅读)React 原生状态共享方式

在 React 的 Function Component 中,我们可以使用 useState 来管理内部状态。但是如果想在组件之间的共享数据就变得没那么方便了。基于 React 提供的原生功能,我们有两种方法来实现组件间的数据共享:state 提升和 Context。

state 提升。不仅需要层层传递 props,导致难以维护和扩展,增加组件的耦合性;中间的组件不需要共享数据,但是也需要接收并向下传递;每当有个子 state 更新,都会导致所有节点的更新,牵一发而动全身,增加很多不必要的渲染。所以多于两层嵌套时基本不考虑使用该方式。

Context 。Context 提供了一种在组件之间共享值的方式,而不必显式地通过组件树的逐层传递 props。Context 采用生产-消费者模式,需要共享状态的组件订阅该全部状态,在 context 数据变化时,订阅的组件就会触发更新,并能获取到最新的值。在 16.8 版本以后,React 也提供了相关 hook: useContext,使用 Context 变得更加简单。

context 使用示例

context.js

// context.js
export const state = {
  items: []
}
export const Context  = React.createContext();

app.js

// app.js
import { Context, state } from './context';

function App() {
  const [state, setState] = useState(state);
  return (
    <Context.Provider value={{ state, setState }}>
      <div style={{ display: 'flex' }}>
        <List />
        <Canvas />
        <Property />
      </div>
    </Context.Provider >
  )
}

export default App;

List.js

// List.js
import React, { useContext } from 'react';
import { Context, state } from './context';

function List() {
  const { state, setState } = useContext(Context);
  return (
    <div>
      {state.items.map(i => {
          return <div key={id}>{`title: ${title}  X: ${x}  Y: ${y}`}</div>             })}
    </div>
  );
}
export default List;

使用 Context 时, 我们一般需要把共享数据提升到最上层,其数据更新其实最后依赖了最上层组件的 state 更新;组件通过订阅(useContext) 获取共享的数据。

我们可以发现,这跟第三方状态管理关键很是相似,其实所有的第三方库 Redux、mobx,今天的 Recoil 也不例外,源码上都是使用 Context 来实现状态的共享的。

Context 完全可以实现跨组件的数据共享和统一管理,使用上也比较简单。然而我们在实际项目开发时,往往会选择第三方库比如:redux/mobx,而很少直接使用 Context。
个人的认为,主要有以下几点考虑:

  1. Content 的正式推出时,第三方库比如 redux 已经用得很火了。
  2. Context 比较自由不可控。各个订阅组件都可以随意修改数据。
  3. 避免重复造轮子。第三方库其实就是基于一种思想对 React 进行封装,提供简单的API供开发者使用,以提高开发效率和代码可维护性。
  4. 第三方库提供一些扩展功能。比如日志、快照等。

了解更多 Context

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。