React 下一代状态管理库 Recoil

前端 @ 北京字节跳动科技有限公司

起源

Recoil 的产生源于 Facebook 内部一个可视化数据分析相关的应用,在使用 React 的实现的过程中,因为现有状态管理工具不能很好的满足应用的需求,因此催生出了 Recoil 。

这个应用带有复杂的交互,可以被总结为以下特点:

  • 大量需要共享状态的场景
  • 大量需要派生状态(基于某些状态计算出一个新的状态)的场景
  • 状态可以被持久化,进而通过被持久化的状态恢复当时场景

Redux 的问题

  • 灵活性 Redux 的啰嗦代码达到了其保证的维持可预测性的目标,但自然而然在灵活性方面就有所欠缺。无法方便的进行状态共享,副作用的管理更需要引入种类繁多的中间件,且无法很好的对接 React Suspense 组件,以及将来的出现 Concurrent 模式。
  • 性能 Redux 共享数据的策略在面对复杂操作,大量组件共享数据的场景, 对性能是个挑战。
  • 有些场景下的代码不够健壮 面对派生状态场景,经常需要手动去同步各种状态,而无法做到响应式的自动更新,因此很容易出现状态不同的 Bug

Recoil 解决了什么

灵活且高性能的共享状态

面对共享状态的需求时,我们可以选择的方法有:

  • React自身
  • 使用 Redux
  • 使用 Context 想象这样一种场景,你有上百个矩形组件显示在屏幕上,你可以拖动任意一个矩形,同时左侧有个面板会实时显示被拖动矩形的位置。

这意味着矩形需要维持自己的坐标,并且共享给面板。以上三种方法在面对这种场景,都存在一些局限性:

React自身

React 本身解决数据共享是通过提升状态到父组件解决的,这自然就会因为某个组件的状态改变而导致所有子组件的都要重新渲染。尽管我们可以通过 memo 函数来进行优化,但是唤醒的问题依然存在,对比前后 Props 的操作无法避免。另一个问题在于,一旦又一个组件需要观察共享的数据,那么又需要继续提升数据,事情就会变得麻烦起来。

使用 Redux

Redux 是同样的道理,一个 Action 会唤醒所有订阅数据的组件,即使他们订阅的数据并没有发生变化,而且只能在数据变化后,通过浅比较(或深比较)的方式比对前后数据是否一致,来阻止无效渲染。当面对高频率,如拖动更新这样的场景,依旧会遇到性能问题。

使用 Context

这种仅在依赖数据变化时才更新的场景,使用 Context 可以完美的解决,每个矩形组件都拥有自己的 Context ,借助于 Context 的机制,当 Context 的数据变化时,只有监听了相关 Context 的组件才会重新渲染。但 Context 的问题在于, Context 无法应对过于动态性的场景,如果这些矩形组件是动态插入的,比如是用户通过点击按钮添加的,这意味着他们对应的 Context 也需要动态的插入到顶层组件,方便共享数据给其他组件,但是由于 React 的 diff 策略,如果在顶层组件动态插入 Context 或任何组件,会导致子组件树不断被 销毁重建 ,这同样是难以接受的。更别说,如果这种场景如果涉及到 code-spliting ,由于底部组件和顶层组件存在联系,这种联系使得很难,单独的将子组件单独分离出去,比如说你该如何在子组件异步加载之后,将其相关的 Context 插入到顶层。

Recoil 解决上述问题的方法是,独立于 React ,单独构建出一套自己的状态树,这个状态树平行于组件树存在。状态树由 Atom 和 Selector 构成。

状态树的基本单位被称为 Atom ,一个 Atom 表征着一份可变,可被订阅的状态,当 Atom 代表的状态改变时,只会重新渲染订阅了这个 Atom 的组件,而不会影响他其他组件。

1  const todoListState = atom({
2    key: 'todoListState',
3    default: [],
4  });
复制代码

派生状态

当我们在谈不健壮的代码时,其实指的是易于出 Bug 的代码,而手动同步状态的代码可以被认为就是易于出错的代码,状态越多越容易出错。响应式编程很好的解决了这个问题,例如 RxJS 的流,或者 Vue 的计算属性,他们共同的特点就是当 依赖(上游) 改变时,派生出的事物(下游) 也会同样 自动变化 ,避免手动维护状态同步的情况,自然也就减少产生 Bug 的几率。

对此,Recoil 提供了 Selector ,一个 Selector 同样表征着一份状态,只不过这个状态是基于 Atom 和其他 Selector 派生出来的 ,当 Selector 的依赖发生变化时, Selector 会根据变化后的依赖 响应式的计算出新的状态

1  const todoListFilterState = atom({
2    key: 'todoListFilterState',
3    default: 'Show All',
4  });
5
6  const filteredTodoListState = selector({
7    key: 'filteredTodoListState',
8    get: ({get}) => {
9      const filter = get(todoListFilterState);
10     const list = get(todoListState);
11
12     switch (filter) {
13       case 'Show Completed':
14         return list.filter((item) => item.isComplete);
15       case 'Show Uncompleted':
16         return list.filter((item) => !item.isComplete);
17       default:
18         return list;
19     }
20   },
21 })
复制代码

更强大的是 Selector 可以是异步的,这意味着你可以在从其他 Atom 或 Selector 获取想要的信息,然后发起一个异步查询,返回相应的数据,然后在组件中使用这个异步 Selector 。 同样的,异步 Selector 也会响应式的根据依赖变化

1  const currentUserNameQuery = selector({
2    key: 'CurrentUserName',
3    get: async ({get}) => {
4      const response = await myDBQuery({
5        userID: get(currentUserIDState),
6      });
7      return response.name;
8    },
9  });
10
11 function CurrentUserInfo() {
12   const userName = useRecoilValue(currentUserNameQuery);
13   return <div>{userName}</div>;
14 }
复制代码

你可能会好奇在这些代码之下发生了什么,这里是利用了 React < Suspense /> 组件的特性,也就说使用了异步 Selector 的 < CurrentUserInfo /> 组件必须被包裹在 < Suspense /> 下,否则 React 会直接报错。

尽管代码之下整个数据拉取是异步的,但是对于 < CurrentUserInfo /> 来说,你是在一种 同步的方式 使用数据,异步状态的管理则交给了 React ,这同样意味着更少的心智负担和耦合度。 Recoil 也保证了异步 Selector 不会出现竞态的问题,对于同样的输入(依赖) 始终会返回相同的结果,这同样意味着存在某种缓存策略。

应用维度的状态观察

Redux 的另一个特点是很容易做应用维度的时间旅行,理论上如果你将所有的状态都使用 Redux 管理,你只需要简单的在某一刻将整个 Redux 的状态树序列化,就相当于保存了当时应用的快照。基于这点,你很容易做一些草稿箱、编辑、时间旅行、分享等相关的需求。

为了应对这些需求, Recoil 同样支持应用维度的状态观察。比如说,我们可以将状态序列化到地址栏里,分享给其他人,当其他人访问时,通过地址栏的数据反序列化到应用中,恢复当时应用的状态。除此之外,这个特性也使得 debug 变得容易。

Concurrent 模式

Recoil 在实现的时候就已经考虑了 Concurrent 模式,因此在 Concurrent 模式稳定后,可以很快的进行支持。

参考

作者何云飞

分类:
前端