1. 前言

在 React 诞生之初,Facebook 宣传这是一个用于前端开发的界面库。在大型应用中,如何处理好 React 组件通信和状态管理就显得非常重要。
为了解决这一问题,Facebook 最先提出了单向数据流的 Flux 架构,弥补 React 开发大型网站的不足。后续社区里又出现了一系列的前端状态管理解决方案。
本文会对 Redux、Mobx、Recoil 等几个状态管理方案进行深入到原理的介绍,并会给每个库都配一个 todomvc 的例子来对比。

2. 趋势对比

2021-09-12-00-29-49-476772.png
从图上可以看到,Redux 一骑绝尘,这也是因为 Redux 出现比较早,对早期的 React 状态管理痛点冲击很大。
其次是 Mobx,它是使用响应式编程开发出来的状态管理库。很多人因为对 Redux 繁琐的写法深恶痛绝,Mobx 的出现让大家看到了另一种更优雅的状态管理方案。
最后是 Facebook 去年发布的 Recoil,目前还处于测试阶段,周边生态还不多,用户量也非常小。

3. Redux

Redux 依然是当前最火的状态管理库,它受到了 Elm 的启发,是从 Flux 单项数据流架构演变而来的。
在学习 Redux 之前需要先理解其大致工作流程,一般来说是这样的:

  1. 用户在页面上进行某些操作,通过 dispatch 发送一个 action。
  2. Redux 接收到这个 action 后通过 reducer 函数获取到下一个状态。
  3. 将新状态更新进 store,store 更新后通知页面进行重新渲染。

React状态管理对比和原理实现 - 图2
从这个流程中不难看出,Redux 的核心就是一个 「发布-订阅」 模式。view 订阅了 store 的变化,一旦 store 状态发生修改就会通知所有的订阅者,view 接收到通知之后会进行重新渲染。
这里在 codesandbox 上面写了一个 Redux 的 todomvc,可以作为参考:redux-todomvc-vzwps
PS:讨论 Redux 的时候,默认是 Redux + React-redux,后者是 React 和 Redux 的 binding,用于触发组件重新渲染。

3.1 三大原则

一般来说,Redux 遵守下面三大原则:

  • 单一数据源

在 Redux 中,所有的状态都放到一个 store 里面,一个应用中一般只有一个 store。

  • State 是只读的

在 Redux 中,唯一改变 state 的方法是通过 dispatch 触发 action,action 描述了这次修改行为的相关信息。只允许通过 action 修改可以避免一些 mutable 的操作,保证状态不会被随意修改

  • 通过纯函数来修改

为了描述 action 使状态如何修改,需要编写 reducer 函数来修改状态。reducer 函数接收前一次的 state 和 action,返回新的 state。无论被调用多少次,只要传入相同的 state 和 action,那么就一定返回同样的结果。<br />这三个原则使得 Redux 状态是可预测的,很容易实现时间旅行,但也带来了一些弊端,那就是上手难度比较高,模板代码太多,需要了解action、reducer、middleware 等概念。

3.2 action 和 reducer

action 是把数据传到 store 的载体,一般通过 dispatch 将 action 传给 reducer,reducer 来计算出新的值。一个 action 就是一个对象,类似这样:

  1. {
  2. type: "ADD_TODO",
  3. payload: {
  4. text: "今天要洗衣服"
  5. }
  6. }

action 也可以封装到函数里面,返回一个 action 对象:

  1. const addTodo = (text) => ({
  2. type: "ADD_TODO",
  3. payload: {
  4. text: "今天要洗衣服"
  5. }
  6. })

那么在发送一个 action 后,reducer 怎么知道当前发送的是哪个 action 呢?
所以这里的 action.type 就是作为一个唯一标志来和 reducer 匹配起来的。在 reducer 里面会拿到 action.type 和 传入的数据来进行处理。

  1. const reducer = (state, action) => {
  2. switch(action.type):
  3. case "ADD_TODO":
  4. state.todos = [...state.todos, action.payload];
  5. return { ...state };
  6. default:
  7. return state;
  8. }

需要注意的是,这里的 reducer 必须返回一个新的对象,那么返回旧的不行吗?
如果这里返回一个旧的对象,想要知道前后两次状态是否更新的成本就会很大。因为两次状态都是同一份引用,想要比较属性是否变化,只能通过深比较的形式。
但如果对对象进行深比较,性能上的消耗太大了。所以 Redux 每次只会进行一次浅比较,这样就需要在修改的地方返回一个新的对象。
所以 Redux 将这一职责交给了开发者来保障,给开发者带来了额外的心智负担。

3.3 Middleware 和 Store Enhancer

由于 reducer 是纯函数,所以 Redux 本身不会去处理一些副作用,比如网络请求、缓存等等,而是把这些副作用交给 middleware 来处理。
2021-09-12-14-22-25-635067.png
middleware 是在发起 action 之后,到 reducer 之前的扩展,它相当于对 dispatch 进行了一个增强,让其拥有更多的能力。
以 redux-thunk 为例子,只需要在创建 store 的时候通过 applyMiddleware 来注册中间件就可以了。

  1. import thunk from 'redux-thunk';
  2. const store = createStore(reducers, applyMiddleware(thunk));

这样就允许 action 作为一个函数来发送异步请求了。如下例子, FETCH_LIST 会在请求返回后发送出去。

  1. const fetchList = () =>{
  2. return async (dispatch) => {
  3. const list = await api.getList();
  4. dispatch({
  5. type: FETCH_LIST,
  6. payload: {
  7. list
  8. }
  9. });
  10. };
  11. };
  12. dispatch(fetchList());

这种对 store 进行增强的能力,称之为 Store Enhancer。它的结构一般是这样的,接收一个 createStore 参数,返回一个增强的 store。

  1. const enhancer = () => {
  2. return (createStore) => (reducer, initState, enhancer) => {
  3. // ...
  4. return {
  5. ...store,
  6. dispatch
  7. }
  8. }
  9. }

applyMiddleware 就是一个典型的 Store Enhancer。它的实现也很简单,核心在于一个 compose 函数,将中间件串起来:

  1. export default function applyMiddleware(...middlewares) {
  2. return (createStore) => (reducer, preloadedState) => {
  3. const store = createStore(reducer, preloadedState)
  4. let dispatch = () => {
  5. throw new Error(
  6. 'Dispatching while constructing your middleware is not allowed. ' +
  7. 'Other middleware would not be applied to this dispatch.'
  8. )
  9. }
  10. const middlewareAPI: MiddlewareAPI = {
  11. getState: store.getState,
  12. dispatch: (action, ...args) => dispatch(action, ...args)
  13. }
  14. const chain = middlewares.map(middleware => middleware(middlewareAPI))
  15. dispatch = compose(...chain)(store.dispatch)
  16. return {
  17. ...store,
  18. dispatch
  19. }
  20. }
  21. }

中间件的实现原理也很简单,可以理解为在 action 和 reducer 之间对 dispatch 做了一次增强。可以很简单的实现一个 logger 中间件:

  1. const logger = (middlewareAPI) => {
  2. return (next) => {
  3. return (action) => {
  4. console.log('dispatch 前:', middlewareAPI.getState());
  5. var returnValue = next(action);
  6. console.log('dispatch 后:', middlewareAPI.getState(), '\n');
  7. return returnValue;
  8. };
  9. };
  10. }

3.4 适用场景

相比在组件里面手动去管理 state,Redux 将散落在组件里面的状态聚拢起来,形成了一颗大的 store 树。
修改 state 的时候需要通过发送 action 的形式,这种单向数据流的架构让状态变得容易预测,非常方便调试和时间旅行。想象一下,如果 state 可以被到处修改,可能根本不知道这个 state 是哪里被修改的,后期维护起来直接爆炸。
但 Redux 并非银弹,它也有很多问题,尤其是为了这些优势做出了不少妥协。

  1. 将副作用扔给中间件来处理,导致社区一堆中间件,学习成本陡然增加。比如处理异步请求的 Redux-saga、计算衍生状态的 reselect。
  2. 需要书写太多的样板代码。比如只是修改一下按钮状态,就需要修改 actions、reducers、actionTypes 等文件,还要在 connect 的地方暴露给组件来使用。这对于后期维护也是一件很痛苦的事情。
  3. reducer 中需要返回一个新的对象会造成心智负担。如果不返回新的对象或者更新的值过于深层,经常会发现 action 发送出去了,但为什么组件没有更新呢?

基于上面的优劣势,Redux 不适合用在小型项目中,开发成本往往比带来的收益还要更高。况且,最新的 React 已经支持了 useReducer 和 useContext 等 api,完全可以实现一个小型的 Redux 出来,就更加不需要 Redux 了。
总结,Redux 比较适合用于大型 Web 项目,尤其是一些交互足够复杂、组件通信频繁的场景,状态可预测和回溯是非常有价值的。

3.5 原理

Redux 的实现原理非常简单,不考虑中间件的情况下,甚至可以说短短几十行就够了。核心源码都在 createStore 和 combineReducers 里面。
在 createStore 里面,最终会返回一个 store,它主要拥有 getState、dispatch、subscribe、unsubscribe 等几个方法。
这里是简化了 Redux 源码后 createStore 的一个简单实现,它的核心就是一个 「发布-订阅」 模式。

  1. const createStore = (reducer, initialState, enhancer) => { // 如果传入了 applyMiddleware,那就调用它
  2. if (enhancer && typeof enhancer === 'function') {
  3. return enhancer(createStore)(reducer, initialState)
  4. }
  5. let state = initialState, listeners = [], isDispatch = false; // 获取 store
  6. const getState = () => state; // 发送一个 action
  7. const dispatch = (action) => { // action 不能同时发送
  8. if (isDispatch) return action;
  9. isDispatch = true;
  10. state = reducer(state, action);
  11. isDispatch = false; // 执行注册的事件
  12. listeners.forEach(listener => listener(state));
  13. return action;
  14. }
  15. // 监听 store 变化,注册事件
  16. const subscribe = (listener) => {
  17. if (typeof listener === "function") {
  18. listeners.push(listener);
  19. }
  20. return () => unsubscribe(listener);
  21. }
  22. // 移除注册的事件
  23. const unsubscribe = (listener) => {
  24. const index = listeners.indexOf(listener);
  25. listeners.splice(index, 1);
  26. }
  27. return {getState, dispatch, subscribe, unsubscribe}
  28. }

看到 subscribe 就会明白 React-redux 是怎么做的 bind。connect 本身也是一个高阶组件,通过 Provider 将 store 传给子孙组件。在 connect 里面通过 subscribe 监听了 store,一旦 store 变化,它就让 React 组件重新渲染。

  1. const connect = (mapStateToProps, mapDispathToProps) => (WrappedComponent) => {
  2. return class extends React.Component {
  3. static contextType = ReactReduxContext;
  4. constructor(props) {
  5. super(props);
  6. this.store = this.context.store;
  7. this.state = {
  8. state: this.store.getState()
  9. };
  10. }
  11. componentDidMount() {
  12. this.store.subscribe((nextState) => {
  13. // 浅比较
  14. if (!shadowCompare(nextState, this.state.state)) {
  15. this.setState({ state: nextState });
  16. }
  17. });
  18. }
  19. render() {
  20. const props = {
  21. ...mapStateToProps(this.state.state),
  22. ...mapDispathToProps(this.state.state),
  23. ...this.props
  24. }
  25. return <WrappedComponent {...props} />
  26. }
  27. }
  28. }

而另一部分的 combineReducers,则是在每次更新的时候去遍历执行最初传入的 reducer。

  1. const combineReducers = reducers => {
  2. const finalReducers = {},
  3. nativeKeys = Object.keys;
  4. nativeKeys(reducers).forEach(reducerKey => {
  5. // 过滤掉不是函数的 reducer
  6. if(typeof reducers[reducerKey] === "function") {
  7. finalReducers[reducerKey] = reducers[reducerKey];
  8. }
  9. })
  10. // 返回了一个新的函数
  11. return (state, action) => {
  12. let hasChanged = false;
  13. let nextState = {};
  14. // 遍历所有的 reducer 函数并执行
  15. nativeKeys(finalReducers).forEach(key => {
  16. const reducer = finalReducers[key];
  17. nextState[key] = reducer(state[key], action);
  18. hasChanged = hasChanged || nextState[key] !== state[key]
  19. })
  20. return hasChanged ? nextState : state;
  21. }
  22. }

从上面的源码也可以看出来,Redux 存在一个很明显的问题,那就是需要通过遍历 reducer 来匹配到对应的 action.type。
那么这里有没有优化空间呢?为什么 action 和 reducer 必须手写 switch…case 来匹配呢?如果将 action.type 作为函数名,这样是否就能减少心智负担呢?
这些很多人都想到了,所以 Rematch 和 Dva 就在这之上做了一系列优化,Redux 也吸取了他们的经验,重新造了 @reduxjs/toolkit。

4. 重新设计 Redux

4.1 简化 reducer

前面讲了,Redux 的一个缺点就是需要写大量繁琐的 reducer 和 action 模板代码。除此之外,每次发送 action 都需要在 reducer 里面手动匹配。

  1. const reducer = (state, action) => {
  2. switch (action.type) {
  3. case INCREMENT:
  4. return state + action.payload;
  5. case DECREMENT:
  6. return state - action.payload;
  7. default:
  8. return state;
  9. }
  10. }

这是 Redux 设计上的一个问题。对于 Haskell 等函数式语言来说,它们天然支持模式匹配,天然的 immutable,完全不需要手写这些麻烦的 switch…case,但在 JavaScript 里面还不支持这种语法。
所以考虑一下,如果不是手写 switch…case,而是将 action.type 作为函数名,直接去调用 reducer 呢?

  1. const reducer = {
  2. INCREMENT: (state, action) => state + action.payload,
  3. DECREMENT: (state, action) => state - action.payload
  4. }

4.2 immutable

由于 reducer 是个纯函数,每次都要求返回一个新的对象,这里也会给开发者造成一些心智负担。每次更新一个属性的时候,一定要在修改的地方返回一个新的对象。
这种场景下非常适合 immutable ,immutable 只会拷贝改变的节点,保留不变的节点,从而避免深拷贝带来大量的性能消耗。
2021-09-12-14-22-26-488116.gif
immer 是 Mobx 作者写的一个 immutable 库,它利用了 Proxy 以最小成本实现了不可变数据结构。可以看个例子:

  1. import produce from 'immer';
  2. const state = {todos: [], date: {value: "2021-10-01"},};
  3. const nextState = produce(state, (draftState) => {
  4. draftState.todos.push({text: "今天要洗衣服"});
  5. });
  6. state.date === nextState.date;
  7. // true
  8. state.todos === nextState.todos;
  9. // false

可以看出来,在未修改的数据上面,两者是共享的。而在修改的部分上面,nextState 和 state 是不一样的。
如果在 reducer 底层就内置了 immerjs 呢?可以将 reducer 的执行放到 produce 里面,这样就不需要手动去设置一个新对象了。

  1. // 结合 immerjs 使用
  2. const reducers = {
  3. addTo: (state, action) => {
  4. state.todos.push(action.payload);
  5. }
  6. toggleComplete: (state, action) => {
  7. const index = action.payload.index;
  8. state.todos[index].isComplete = !state.todos[index].isComplete;
  9. }
  10. }
  11. // 实现思路
  12. const newReducers = (reducers) => (state, action) => {
  13. Object.keys(reducers).forEach(key => {
  14. const reducer = reducer[key];
  15. reducers[key] = (state, action) => {
  16. return produce(state, draftState => {
  17. reducer(state, action);
  18. });
  19. }
  20. });
  21. }

4.3 namespace

对于页面数据结构复杂的情况下,为了更细粒度的更新,往往需要将 reducer 拆分的非常细粒度,再通过 combineReducers 来聚拢成一个大的 reducer。
然而 action.type 是全局匹配的,这样会造成一个问题,如果多个 action.type 一样,就会造成冲突。
在 Vuex 里面就提供了 namespace 属性,它允许用命名空间来划分整个 store,可以借鉴这个思路。

  1. const todos = createReducers({
  2. namespace: true,
  3. initialState: [],
  4. reducers: {
  5. addTodo(state, action) {
  6. state.push(action.payload);
  7. }
  8. }
  9. });
  10. const user = createReducers({
  11. namespace: true,
  12. initialState: {},
  13. reducers: {
  14. updateAvater(state, action) {
  15. state.avater = action.payload;
  16. },
  17. updateNickName(state, action) {
  18. state.nick = action.payload;
  19. }
  20. }
  21. });
  22. const reducer = combineReducers({
  23. todos: todos.reducers,
  24. user: user.reducers
  25. });
  26. // 发送 action 的时候自带了命名空间
  27. dispatch({ type: "user/updateAvater" })

4.4 处理副作用

在 Redux 中,为了处理网络请求等副作用,将这部分交给了中间件来处理。社区里面的解决方案层出不穷,从 redux-thunk 到 redux-promise,再到 redux-saga,学习成本大大增加。
在这里完全可以选择封装 thunk 或者 saga,将纯函数的 reducer 和处理副作用的 reducer 进行区分,让后者支持 async/await。

  1. const reducers = createReducers({
  2. initialState: {todos: []}, reducers: {
  3. addTodo(state, action) {
  4. state.todos.push(action.payload);
  5. }
  6. }, effects: {
  7. async fetchTodos(state, action) {
  8. const todos = await fetchTodos();
  9. state.todos = todos;
  10. }
  11. }
  12. });

4.5 总结

上面的这些优化点,社区早就有人想到了,rematch 已经支持了 immutable 之外的几项优化。而 Redux 最新出的 @reduxjs/toolkit 也已经支持了全部的优化点。
此外,@reduxjs/toolkit 还内置了 selector 等功能,感兴趣的可以去体验一下:redux-toolkit

5. Mobx

Mobx 是 React 的另一种经过战火洗礼的状态管理方案,和 Redux 不同的地方是 Mobx 是一个响应式编程(Reactive Programming)库,在一定程度上可以看做没有模板的 Vue。
这里也在 codesandbox 上实现了一个 todomvc 的例子,大家可以参考一下:mobx-todomvc-3nuw3
Mobx 借助于装饰器的实现,使得代码更加简洁易懂。由于使用了可观察对象,所以 Mobx 可以做到直接修改状态,而不必像 Redux 一样编写繁琐的 actions 和 reducers。
这里的 action 不是必须的,但为了保证状态不会被随意修改,还是建议开启严格模式,只允许在 action 里面修改状态。

  1. import {action, observable} from 'mobx';
  2. class Store {
  3. @observable count = 0;
  4. @action increment() {
  5. this.count++;
  6. }
  7. @action decrement() {
  8. this.count--;
  9. }
  10. }

Mobx 的执行流程和 Redux 有一些相似。这里借用 Mobx 官网的一张图:
2021-09-12-14-22-26-820017.png
简单的概括一下,一共有这么几个步骤:

  1. 页面事件(生命周期、点击事件等等)触发 action 的执行。
  2. 通过 action 来修改状态。
  3. 状态更新后,computed 计算属性也会根据依赖的状态重新计算属性值。
  4. 状态更新后会触发 reaction,从而响应这次状态变化来进行一些操作(渲染组件、打印日志等等)。

    5.1 observable

    observable 可以将接收到的值包装成可观察对象,这个值可以是 JS 基本数据类型、引用类型、普通对象、类实例、数组和映射等等等。 ```jsx const list = observable([1, 2, 4]); list[2] = 3;

const person = observable({ firstName: “Clive Staples”, lastName: “Lewis” }); person.firstName = “C.S.”;

  1. 如果在对象里面使用 get,那就是计算属性了。计算属性一般使用 get 来实现,当依赖的属性发生变化的时候,就会重新计算出新的值,常用于一些计算衍生状态。
  2. ```jsx
  3. const todoStore = observable({
  4. // observable 属性:
  5. todos: [],
  6. // 计算属性:
  7. get completedCount() {
  8. return (this.todos.filter(todo => todo.isCompleted) || []).length
  9. }
  10. });

更多时候,会配合装饰器一起使用来使用 observable 方法。

  1. class Store {
  2. @observable count = 0;
  3. }

在最新的 Mobx 中,推荐使用 makeAutoObservable 来批量设置成员属性为 observable,也可以将方法设置为 action。

  1. import {makeAutoObservable} from "mobx"
  2. class Store {
  3. constructor() {
  4. makeAutoObservable(this);
  5. }
  6. count = 0;
  7. increment() {
  8. this.count++;
  9. }
  10. }

5.2 computed

想像一下,在 Redux 中,如果一个值 A 是由另外几个值 B、C、D 衍生计算出来的,类似于 Vue 的 computed,这个该怎么实现?
最麻烦的做法是在所有 B、C、D 变化的地方重新计算得出 A,最后存入 store。
当然也可以在组件渲染 A 的地方根据 B、C、D 计算出 A,但是这样会把逻辑和组件耦合到一起,如果需要在其他地方用到 A 怎么办?
所以在 Redux 中就需要额外的 reselect 库来实现 computed 这个功能。
但是 Mobx 中提供了和 Vue 类似的 computed 来解决这个问题。正如 Mobx 官方介绍的一样,computed 是基于现有状态或计算值衍生出的值,如下面 todoList 的例子,一旦已完成事项数量改变,那么 completedCount 会自动更新。

  1. class TodoStore {
  2. @observable todos = []
  3. @computed get completedCount() {
  4. return (this.todos.filter(todo => todo.isCompleted) || []).length
  5. }
  6. }

5.3 reaction 和 autorun

autorun 接收一个函数,当这个函数中依赖的可观察属性发生变化的时候,autorun 里面的函数就会被触发。除此之外,autorun 里面的函数在第一次会立即执行一次。

  1. const person = observable({ age: 20}) // autorun 里面的函数会立即执行一次,当 age 变化的时候会再次执行一次
  2. autorun(() => {
  3. console.log("age", person.age);
  4. })
  5. person.age = 21;
  6. // 输出:
  7. // age 20
  8. // age 21

但是很多人经常会用错 autorun,导致属性修改了也没有触发重新执行。常见的几种错误用法如下:

  1. 错误的修改了可观察对象的引用

    1. let person = observable({ age: 20})// 不会起作用
    2. autorun(() => {
    3. console.log("age", person.age);
    4. })
    5. person = observable({
    6. age: 21
    7. })
  2. 在追踪函数外进行间接引用

    1. const age = person.age;// 不会起作用
    2. autorun(() => {
    3. console.log('age', age)
    4. })
    5. person.age = 21

    reaction 则是和 autorun 功能类似,但是 autorun 会立即执行一次,而 reaction 不会,使用 reaction 可以在监听到指定数据变化的时候执行一些操作,和 Vue 中的 watch 非常像。

    1. // 当todos改变的时候将其存入缓存
    2. reaction(
    3. () => toJS(this.todos),
    4. (todos) => localStorage.setItem('mobx-react-todomvc-todos', JSON.stringify({ todos }))
    5. )

    5.4 observer 和 inject

    mobx-react 中提供了一个 observer 方法,这个方法主要是改写了 React 的 render 函数,当监听到 render 中依赖属性变化的时候就会重新渲染组件,这样就可以做到高性能更新。 ```jsx import {observable, computed, action} from ‘mobx’; import {inject, observer, Provider} from ‘mobx-react’;

class Store { @observable todos = [];

@computed get unCompleted() { return this.todos.filter(todo => !todo.isComplete); }

@action async fetchTodos() { const todos = await fetchTodos(); this.todos = todos; }

@action toggleComplete(id) { const todo = findById(id); todo.isComplete = !todo.isComplete; } }

const store = new Store(); ReactDOM.render( , root); const App = inject(“store”)(observer((props) => { useEffect(() => { props.store.fetchTodos(); }, []); return (<>

    {props.store.todos.map(todo => { return
  • {todo.text}
  • })}
未完成的数量: {props.store.unCompleted.length}
</>); });

  1. observer 还可以配合 observable class 组件里面代替 state 来使用。
  2. ```jsx
  3. import { observable, action } from 'mobx';
  4. import { observer } from 'mobx-react';
  5. @observer
  6. class App extends React.Component {
  7. @observable count = 0;
  8. @action increment = () => {
  9. this.count++;
  10. }
  11. render() {
  12. <div className="app">
  13. <div onClick={this.increment}>+</div>
  14. <div className="counter">{ this.count }</div>
  15. </div>
  16. }
  17. }

5.5 useLocalObservable

useLocalObservable 是 mobx-react-lite 里面提供的一个 Hook,可以用来代替 useState,将分散的 state 聚拢成一个 localStore。

  1. import { observer, useLocalObservable } from 'mobx-react-lite';
  2. const Measurement = observer(({ unit }) => {
  3. const state = useLocalObservable(() => ({
  4. unit,
  5. length: 0,
  6. get lengthWithUnit() {
  7. return this.unit === "inch" ? `${this.length * 2.54} inch` : `${this.length} cm`
  8. }
  9. }))
  10. useEffect(() => {
  11. state.unit = unit
  12. }, [unit])
  13. return <h1>{state.lengthWithUnit}</h1>
  14. })

5.6 适用场景

Mobx 的优势在于上手简单,可以直接修改状态,不需要编写繁琐的 Action 和 Reducer,也不需要引入各种复杂的中间件。
它支持面向对象编程,而面向对象往往很适合业务模型。支持响应式编程,通过依赖收集可以做到非常精确的局部更新,而 Redux 需要手动去控制更新。
但没有约束也会造成不同开发的代码风格不一致,给后期维护带来困难。除此之外,还需要花时间去弄清楚 Mobx 到底是怎么响应的?不然很容易出现修改了状态却没有更新的情况。
所以 Mobx 也很适合一些中大型项目,但前提是约束好团队的编码风格。

5.7 源码分析

Mobx 的实现原理很简单,整体上和 Vue 比较像,简单来说就是这么几步:

  1. 用 Object.defineProperty 或者 Proxy 来拦截 observable 包装的对象属性的 get/set 。
  2. 在 autorun 或者 reaction 执行的时候,会触发依赖状态的 get,此时将 autorun 里面的函数和依赖的状态关联起来。也就是常说的依赖收集。
  3. 当修改状态的时候会触发 set,此时会通知前面关联的函数,重新执行他们。

    1. // 使用 `Object.defineProperty` 或者 `Proxy` 来代理这个对象
    2. let person = observable({
    3. age: 20
    4. });
    5. autorun(function F () {
    6. console.log("age", person.age); // 收集 person.age 的依赖,将 F 放到一个观察队列里面
    7. });
    8. person.age = 21; // 修改 age 时触发 set,从队列里面取出 F,重新执行

    5.7.1 observable

    observable 的源码实现在 api/observable.ts 文件中,主要是在 createObservable 函数里面。

    1. function createObservable(v: any, arg2?: any, arg3?: any) {
    2. // @observable someProp;
    3. if(isStringish(arg2)) {
    4. storeAnnotation(v, arg2, observableAnnotation)
    5. return
    6. }
    7. // already observable - ignore
    8. if (isObservable(v)) return v
    9. // plain object
    10. if (isPlainObject(v)) return observable.object(v, arg2, arg3)
    11. // Array
    12. if (Array.isArray(v)) return observable.array(v, arg2)
    13. // Map
    14. if (isES6Map(v)) return observable.map(v, arg2)
    15. // Set
    16. if (isES6Set(v)) return observable.set(v, arg2)
    17. // other object - ignore
    18. if (typeof v === "object" && v !== null) return v
    19. // anything else
    20. return observable.box(v, arg2)
    21. }

    这段代码里面对数据类型进行了判断,调用不同的函数,这里主要以 object 的情况为例,返回的是 observable.object(v, arg2, arg3)。
    observable.object 的实现在 observableFactories 里面,这里有判断是否使用 Proxy,如果用 Proxy,就走 asDynamicObservableObject 这个方法。

    1. const observableFactories: IObservableFactory = {
    2. object<T = any>(
    3. props: T,
    4. decorators?: AnnotationsMap<T, never>,
    5. options?: CreateObservableOptions
    6. ): T {
    7. return extendObservable(
    8. globalState.useProxies === false || options?.proxy === false
    9. ? asObservableObject({}, options)
    10. : asDynamicObservableObject({}, options),
    11. props,
    12. decorators
    13. )
    14. },
    15. } as any

    这里主要看 extendObservable 方法,它在 extendobservable.ts 文件里面。

    1. export function extendObservable<A extends Object, B extends Object>(
    2. target: A,
    3. properties: B,
    4. annotations?: AnnotationsMap<B, never>,
    5. options?: CreateObservableOptions
    6. ): A & B {
    7. const descriptors = getOwnPropertyDescriptors(properties)
    8. const adm: ObservableObjectAdministration = asObservableObject(target, options)[$mobx]
    9. startBatch()
    10. try {
    11. ownKeys(descriptors).forEach(key => {
    12. adm.extend_(
    13. key,
    14. descriptors[key as any],
    15. // must pass "undefined" for { key: undefined }
    16. !annotations ? true : key in annotations ? annotations[key] : true
    17. )
    18. })
    19. } finally {
    20. endBatch()
    21. }
    22. return target as any
    23. }

    关键代码在 adm.extend_ 里面,传入了对象的 key、descriptors[key]。这里的 adm 是根据 target 创建的一个 ObservableObjectAdministration 实例。

    1. extend_(
    2. key: PropertyKey,
    3. descriptor: PropertyDescriptor,
    4. annotation: Annotation | boolean,
    5. proxyTrap: boolean = false
    6. ): boolean | null {
    7. if (annotation === true) {
    8. annotation = this.defaultAnnotation_
    9. }
    10. if (annotation === false) {
    11. return this.defineProperty_(key, descriptor, proxyTrap)
    12. }
    13. assertAnnotable(this, annotation, key)
    14. const outcome = annotation.extend_(this, key, descriptor, proxyTrap)
    15. if (outcome) {
    16. recordAnnotationApplied(this, annotation, key)
    17. }
    18. return outcome
    19. }

    extend 里面调用了 annotation.extend 方法,这个 annotation 比较关键,可以看到 annotation = this.defaultAnnotation 这句,按照 defaultAnnotation -> getAnnotationFromOptions -> createAutoAnnotation -> observableAnnotation.extend 这个链路找下去发现最后调用的是 observableAnnotation.extend

    1. function extend_(
    2. adm: ObservableObjectAdministration,
    3. key: PropertyKey,
    4. descriptor: PropertyDescriptor,
    5. proxyTrap: boolean
    6. ): boolean | null {
    7. assertObservableDescriptor(adm, this, key, descriptor)
    8. return adm.defineObservableProperty_(
    9. key,
    10. descriptor.value,
    11. this.options_?.enhancer ?? deepEnhancer,
    12. proxyTrap
    13. )
    14. }

    这里就是根据 key 和 value 来定义了 observable 的属性,看下 defineObservableProperty_ 做了些什么。

    1. defineObservableProperty_(
    2. key: PropertyKey,
    3. value: any,
    4. enhancer: IEnhancer<any>,
    5. proxyTrap: boolean = false
    6. ): boolean | null {
    7. try {
    8. startBatch();
    9. const cachedDescriptor = getCachedObservablePropDescriptor(key)
    10. const descriptor = {
    11. configurable: globalState.safeDescriptors ? this.isPlainObject_ : true,
    12. enumerable: true,
    13. get: cachedDescriptor.get,
    14. set: cachedDescriptor.set
    15. }
    16. // Define
    17. if (proxyTrap) {
    18. if (!Reflect.defineProperty(this.target_, key, descriptor)) {
    19. return false
    20. }
    21. } else {
    22. defineProperty(this.target_, key, descriptor)
    23. }
    24. const observable = new ObservableValue(
    25. value,
    26. enhancer,
    27. __DEV__ ? `${this.name_}.${key.toString()}` : "ObservableObject.key",
    28. false
    29. )
    30. this.values_.set(key, observable)
    31. // Notify (value possibly changed by ObservableValue)
    32. this.notifyPropertyAddition_(key, observable.value_)
    33. } finally {
    34. endBatch()
    35. }
    36. return true
    37. }

    主要有两部分,一个是根据 key 来获取 cachedDescriptor,将其设置为 defineProperty 的 descriptor,这里的 get/set 就是之后 Mobx 的拦截规则。
    另一个是创建一个 ObservableValue 实例,将其存入 this.values 里面。
    这里的 cachedDescriptor.get 最终也是调用了 this.values
    .get(key)!.get(),也就是 ObservableValue 里面的 get 方法。

    1. public get(): T {
    2. this.reportObserved()
    3. return this.dehanceValue(this.value_)
    4. }

    这个 reportObserved 最终会调到 observable.ts 文件里面,它将当前的这个 ObservableValue 实例放到了 derivation.newObserving 上面,通过 derivation.unboundDepsCount 进行了映射。

    1. export function reportObserved(observable: IObservable): boolean {
    2. checkIfStateReadsAreAllowed(observable)
    3. const derivation = globalState.trackingDerivation
    4. if (derivation !== null) {
    5. /**
    6. * Simple optimization, give each derivation run an unique id (runId)
    7. * Check if last time this observable was accessed the same runId is used
    8. * if this is the case, the relation is already known
    9. */
    10. if (derivation.runId_ !== observable.lastAccessedBy_) {
    11. observable.lastAccessedBy_ = derivation.runId_
    12. // get 的时候将 observable 存到 newObserving_ 上面
    13. derivation.newObserving_![derivation.unboundDepsCount_++] = observable
    14. if (!observable.isBeingObserved_ && globalState.trackingContext) {
    15. observable.isBeingObserved_ = true
    16. observable.onBO()
    17. }
    18. }
    19. return true
    20. } else if (observable.observers_.size === 0 && globalState.inBatch > 0) {
    21. queueForUnobservation(observable)
    22. }
    23. return false
    24. }

    至此,整个 observable 的流程就分析清楚了,如下图所示:
    2021-09-12-14-22-27-199116.png

    5.7.2 autorun

    autorun 是触发 get 的地方,它里面的函数会在依赖的数据发生变化的时候执行。它的源码在 autorun.ts 文件里面。

    1. function autorun(
    2. view: (r: IReactionPublic) => any,
    3. opts: IAutorunOptions = EMPTY_OBJECT
    4. ): IReactionDisposer {
    5. const name: string =
    6. opts?.name ?? (__DEV__ ? (view as any).name || "Autorun@" + getNextId() : "Autorun")
    7. const runSync = !opts.scheduler && !opts.delay
    8. let reaction: Reaction
    9. if (runSync) {
    10. // normal autorun
    11. reaction = new Reaction(
    12. name,
    13. function (this: Reaction) {
    14. this.track(reactionRunner)
    15. },
    16. opts.onError,
    17. opts.requiresObservable
    18. )
    19. } else {
    20. const scheduler = createSchedulerFromOptions(opts)
    21. // debounced autorun
    22. let isScheduled = false
    23. reaction = new Reaction(
    24. name,
    25. () => {
    26. if (!isScheduled) {
    27. isScheduled = true
    28. scheduler(() => {
    29. isScheduled = false
    30. if (!reaction.isDisposed_) reaction.track(reactionRunner)
    31. })
    32. }
    33. },
    34. opts.onError,
    35. opts.requiresObservable
    36. )
    37. }
    38. function reactionRunner() {
    39. view(reaction)
    40. }
    41. reaction.schedule_()
    42. return reaction.getDisposer_()
    43. }

    可以看到,这里会创建一个 Reaction 的实例,将函数 view 传到 reaction.track 里面,然后一起传给 Reaction 的构造函数,等待合适的时机再去执行这个函数。在 Mobx 里面其实都是通过 reaction.schedule_ 来调度执行的。

    1. schedule_() {
    2. if (!this.isScheduled_) {
    3. this.isScheduled_ = true
    4. globalState.pendingReactions.push(this)
    5. runReactions()
    6. }
    7. }

    这里将这个 Reaction 实例放到 pendingReactions 里面,然后执行了 runReactions。这里的 reactionScheduler可能是为了以后实现其他功能留下的一个口子。

    1. let reactionScheduler: (fn: () => void) => void = f => f()
    2. export function runReactions() {
    3. // Trampolining, if runReactions are already running, new reactions will be picked up
    4. if (globalState.inBatch > 0 || globalState.isRunningReactions)
    5. return reactionScheduler(runReactionsHelper)
    6. }
    7. function runReactionsHelper() {
    8. globalState.isRunningReactions = true
    9. const allReactions = globalState.pendingReactions
    10. let iterations = 0
    11. while (allReactions.length > 0) {
    12. if (++iterations === MAX_REACTION_ITERATIONS) {
    13. allReactions.splice(0) // clear reactions
    14. }
    15. let remainingReactions = allReactions.splice(0)
    16. for (let i = 0, l = remainingReactions.length; i < l; i++)
    17. remainingReactions[i].runReaction_()
    18. }
    19. globalState.isRunningReactions = false
    20. }

    在 runReactionsHelper 里面会遍历 pendingReactions 数组,执行里面的 reaction 实例的 runReaction_ 方法。

    1. runReaction_() {
    2. if (!this.isDisposed_) {
    3. startBatch()
    4. this.isScheduled_ = false
    5. const prev = globalState.trackingContext
    6. globalState.trackingContext = this
    7. if (shouldCompute(this)) {
    8. this.isTrackPending_ = true
    9. try {
    10. this.onInvalidate_()
    11. } catch (e) {
    12. this.reportExceptionInDerivation_(e)
    13. }
    14. }
    15. globalState.trackingContext = prev
    16. endBatch()
    17. }
    18. }

    这里面的 onInvalidate_ 其实就是刚刚的 reaction.track 方法。然后来看下 reaction.track 的实现。

    1. track(fn: () => void) {
    2. if (this.isDisposed_) {
    3. return
    4. // console.warn("Reaction already disposed") // Note: Not a warning / error in mobx 4 either
    5. }
    6. startBatch()
    7. const notify = isSpyEnabled()
    8. let startTime
    9. this.isRunning_ = true
    10. const prevReaction = globalState.trackingContext // reactions could create reactions...
    11. globalState.trackingContext = this
    12. const result = trackDerivedFunction(this, fn, undefined)
    13. globalState.trackingContext = prevReaction
    14. this.isRunning_ = false
    15. this.isTrackPending_ = false
    16. if (this.isDisposed_) {
    17. clearObserving(this)
    18. }
    19. if (isCaughtException(result)) this.reportExceptionInDerivation_(result.cause)
    20. if (__DEV__ && notify) {
    21. spyReportEnd({
    22. time: Date.now() - startTime
    23. })
    24. }
    25. endBatch()
    26. }

    它会在 trackDerivedFunction 里面调用刚刚的 view 函数(autorun 包裹的那个函数),在执行 view 函数的时候,如果里面依赖了被 observable 包裹对象的属性,那么就会触发属性的 get 方法,也就回到了刚刚分析 observable 的 reportObserved 里面。它会将 observable 挂载到 derivation.newObserving_ 上面。

    1. function trackDerivedFunction<T>(derivation: IDerivation, f: () => T, context: any) {
    2. const prevAllowStateReads = allowStateReadsStart(true)
    3. changeDependenciesStateTo0(derivation)
    4. // 初始化 derivation.newObserving_
    5. derivation.newObserving_ = new Array(derivation.observing_.length + 100)
    6. derivation.unboundDepsCount_ = 0
    7. derivation.runId_ = ++globalState.runId
    8. const prevTracking = globalState.trackingDerivation
    9. globalState.trackingDerivation = derivation
    10. globalState.inBatch++
    11. let result
    12. if (globalState.disableErrorBoundaries === true) {
    13. // 这里触发了 observableValue.get,继而执行了 reportObserved
    14. // derivation.newObserving_[derivation.unboundDepsCount_++] = observer;
    15. result = f.call(context)
    16. } else {
    17. try {
    18. result = f.call(context)
    19. } catch (e) {
    20. result = new CaughtException(e)
    21. }
    22. }
    23. globalState.inBatch--
    24. globalState.trackingDerivation = prevTracking
    25. bindDependencies(derivation)
    26. warnAboutDerivationWithoutDependencies(derivation)
    27. allowStateReadsEnd(prevAllowStateReads)
    28. return result
    29. }

    到了这里,可以发现在 newObserving 上面已经收集到了 view 函数依赖的属性,这里的 derivation 实际上就是前面的那个 Reaction 实例。
    然后又执行了 bindDependencies 函数,它就是将 Reaction 实例和 observable 关联起来的。

    1. function addObserver(observable: IObservable, node: IDerivation) {
    2. observable.observers_.add(node)
    3. if (observable.lowestObserverState_ > node.dependenciesState_)
    4. observable.lowestObserverState_ = node.dependenciesState_
    5. }

    到了这一步,已经可以从每个对象属性的 observers 上面获取到需要通知变更的函数了。只要在 set 的时候从 observers 取出来、遍历、执行就行了。
    在 set 阶段依然走的是 reaction.schedule_ 这个方法去调度的,重复最开始执行 autorun 的那一步。

    5.7.3 总结

    如果用一段简短的代码来描述上面行为的话,可以参考下面这段代码: ```jsx // Observable.js let observableId = 0; // 用一个唯一 id 来存 observableValue class Observable { id = 0 constructor(v) {

    1. this.id = observableId++;
    2. this.value = v;

    } // set 阶段发送通知 set(v) {

    1. this.value = v;
    2. dependenceManager.trigger(this.id);

    } // get 阶段收集当前的 observableValue get() {

    1. dependenceManager.collect(this.id);
    2. return this.value;

    } }

// observable.js export const observable = (obj) => { Object.keys(obj).forEach(key => { const o = new Observable(obj[key]); // 劫持对象的所有属性,拦截它的 get/set,交给 Observable 里面的方法处理 Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function() { return o.get(); }, set: function(v) { return o.set(v); } }); }); return obj; }

// autorun.js export function autorun(handler) { dependenceManager.beginCollect(handler); // 开始收集当前的 handler handler(); // 触发 Observable.get,执行了 dependenceManager.collect() dependenceManager.endCollect(); // 收集完成 }

// dependenceManager.js class DependenceManager { static Dep = null; _store = {}; // 将autorun里面收集的handler放到 Dep 上面 beginCollect(handler) { DependenceManager.Dep = handler } // 收集属性依赖,将 Dep 放到 watchers 队列里面 collect(id) { if (DependenceManager.Dep) { this._store[id] = this._store[id] || {} this._store[id].watchers = this._store[id].watchers || [] this._store[id].watchers.push(DependenceManager.Dep); } } // 收集结束后销毁 Dep endCollect() { DependenceManager.Dep = null } // 触发set的时候取出 watchers 执行 trigger(id) { const store = this._store[id]; if(store && store.watchers) { store.watchers.forEach(s => { s.call(this); }); } } } export default new DependenceManager();

  1. <a name="rOAFF"></a>
  2. ## 6. Recoil
  3. <a name="Rf8P8"></a>
  4. ### 6.1 背景
  5. Facebook 的软件工程师 Dave McCabe 在 2020 年 5 月做了一个有趣的演讲,他在演讲中介绍了 Facebook 内部创建的 Recoil 状态管理库。<br />在演讲中,他抛出了这么一个场景。更新 List 里面第二个节点,然后希望 Canvas 的第二个节点也跟着更新。<br />![2021-09-12-14-22-27-358108.png](https://cdn.nlark.com/yuque/0/2021/png/396745/1631427980868-a9e25f59-c89e-4b5d-9e50-f9bc5c5f3d57.png#clientId=ueb06ab06-713e-4&from=ui&id=u1b807874&margin=%5Bobject%20Object%5D&name=2021-09-12-14-22-27-358108.png&originHeight=417&originWidth=672&originalType=binary&ratio=1&size=89342&status=done&style=shadow&taskId=u7726effa-1928-4f99-9494-b1e3b56ad0a)<br />最简单的方式就是把 state 放到父组件里面,通过父子组件通信来更新子组件,但带来的问题是父组件下面的子组件都会更新,除非使用 memo 或者 PureComponent。<br />另一种方式则是借助 Context API,将状态从父组件传给子组件。但这样带来的问题也很明显,如果共享的状态越来越多,就需要越来越多的 Provider,又变成了套娃。<br />![2021-09-12-14-22-27-630795.jpeg](https://cdn.nlark.com/yuque/0/2021/jpeg/396745/1631427980868-6a52a088-d32f-483b-8f3b-6031e1699298.jpeg#clientId=ueb06ab06-713e-4&from=ui&id=TrGT7&margin=%5Bobject%20Object%5D&name=2021-09-12-14-22-27-630795.jpeg&originHeight=668&originWidth=880&originalType=binary&ratio=1&size=26890&status=done&style=shadow&taskId=ua66a1f2e-ed09-4037-934a-5ac63275aad)<br />那是否有一种可以精准更新节点,同时又不需要嵌套太多层级的方案呢?Dave 给出了自己的答案,那就是 Recoil。它通过创建正交的 tree,将每个 state 和组件对应起来,从而实现精准更新。<br />Recoil 将这些 state 称之为 Atom,顾名思义,Atom 是 Recoil 里面最小的数据单元,它支持更新和订阅。![2021-09-12-14-22-28-344882.png](https://cdn.nlark.com/yuque/0/2021/png/396745/1631427981154-2d66bb97-d6ec-461d-8f87-be88e427853a.png#clientId=ueb06ab06-713e-4&from=ui&id=JpnaN&margin=%5Bobject%20Object%5D&name=2021-09-12-14-22-28-344882.png&originHeight=608&originWidth=1080&originalType=binary&ratio=1&size=530953&status=done&style=shadow&taskId=uda27944c-d6e3-48c8-aa12-e88c1d891e3)<br />这里也实现了一个 todomvc 的例子给大家参考:recoil-todomvc-5gbxg
  6. <a name="hjvQN"></a>
  7. ### 6.2 Atom
  8. 从上图可以看出来,相比 Redux 维护的全局 Store,Recoil 则是使用了分散式的 Atom 来管理,方便进行代码分割。<br />定义一个 Atom 很简单,使用 atom 函数可以返回一个可写可订阅的 RecoilState 对象。它接收一个唯一标致的 key,和一个默认值 default。
  9. ```jsx
  10. import { atom } from 'recoil';
  11. const counterState = atom({
  12. key: 'counter',
  13. default: 0,
  14. });

6.3 RecoilRoot

RecoilRoot 是一个高阶组件,有点儿类似于 Redux 的 Provider 函数,它初始化了一个 Store,将 Store 通过 Context 传下去。
一般是放到根组件里面,一个项目可以允许有多个 RecoilRoot,它的用法比较简单:

  1. const rootElement = document.getElementById("root");
  2. ReactDOM.render(
  3. <RecoilRoot>
  4. <App />
  5. </RecoilRoot>,
  6. rootElement
  7. );

6.4 useRecoilState

useRecoilState 是 Recoil 提供的一个对 atom 进行读写的 hook,使用这个 hook 的组件都将会订阅这个 atom。它的用法和 useState 有些类似,接收一个 Atom 对象,返回一个值和 set 方法。

  1. import { useRecoilState } from 'recoil';
  2. const Counter = () => {
  3. const [count, setCount] = useRecoilState(counterState);
  4. const increment = () => setCount(count + 1);
  5. return (
  6. <div onClick={increment}>{ count }</div>
  7. );
  8. };

在组件第一次渲染的时候,Recoil 会通过 useRecoilState 来对 counterState 进行订阅,一旦它被手动修改,那就会通知所有订阅的组件都重新渲染。
这里需要注意一点,set 方法需要接收一个新的对象,虽然这点儿和 Redux 一样,但 Redux 里面还是可以直接修改状态的,只是它不会触发更新,如果下次更新,就会把上次修改的一起带上去。

  1. const App = props => {
  2. const handleToggleComplete = (id) => {
  3. // 直接修改了 store 里面的 todos
  4. // 虽然这次不会触发更新,但下面的toggleComplete触发了更新
  5. // 最后页面上会多出来一条666
  6. props.todos.push({ text: 666, id: 7777 });
  7. props.actions.toggleComplete(id);
  8. };
  9. return (
  10. <>
  11. <ul className="todoList">
  12. {props.list.map((item) => {
  13. return (
  14. <div style={{ display: "flex", alignItems: "center", height: 50 }}>
  15. <li key={item.id}>{item.text}</li>
  16. <input
  17. type="checkbox"
  18. checked={Boolean(item.isComplete)}
  19. onChange={() => handleToggleComplete(item.id)}
  20. />
  21. <button
  22. style={{ marginLeft: 20 }}
  23. onClick={() => deleteItem(item.id)}
  24. >
  25. 删除
  26. </button>
  27. </div>
  28. );
  29. })}
  30. </ul>
  31. </>
  32. )
  33. }
  34. export default connect(
  35. (state) => ({
  36. todos: state.todos,
  37. loading: state.loading,
  38. }),
  39. (dispatch) => {
  40. return {
  41. actions: bindActionCreators(actions, dispatch)
  42. };
  43. }
  44. )(App);

这里是 Redux 实现上的一个问题,它将风险抛给了开发者来处理。所以经常看到有人这样写代码:

  1. case TOGGLE_COMPLETE: {
  2. const { id } = payload;
  3. const index = findIndex(state.todos, id);
  4. if (index < 0) return state;
  5. state.todos[index].isComplete = !state.todos[index].isComplete;
  6. state.todos = [...state.todos];
  7. return { ...state };
  8. }

但在 Recoil 里面同样的写法,反而不会更新了,这是为什么呢?

  1. const [todos, setTodos] = useRecoilState(todosState);
  2. const handleToggleComplete = (id) => {
  3. const index = findIndex(todos, id);
  4. if (index < 0) return;
  5. const todo = todos[index];
  6. todo.isComplete = !todo.isComplete;
  7. todos[index] = { ...todo };
  8. setTodos([ ...todos ]);
  9. };

因为 Recoil 对状态做了冻结,主要是 Object.freezeObject.seal。Recoil 将状态设置为只读,它希望可以通过 merge 的形式来修改状态,从而来保证数据的不可变更。
这段代码正确的写法应该是这样的:

  1. const [todos, setTodos] = useRecoilState(todosState);
  2. const handleToggleComplete = (id) => {
  3. const index = findIndex(todos, id);
  4. if (index < 0) return;
  5. const todo = todos[index];
  6. setTodos([...todos.slice(0, index), { ...todo, isComplete: !todo.isComplete }, ...todos.slice(index + 1)]);
  7. };

也可以通过 immerjs 来实现 mutable 的操作。

  1. import produce from 'immer';
  2. const handleToggleComplete = (id) => {
  3. const index = findIndex(todos, id);
  4. if (index < 0) return;
  5. const todo = todos[index];
  6. const newTodos = produce(todos, draftState => {
  7. draftState[index].isComplete = !draftState[index].isComplete
  8. });
  9. setTodos(newTodos);
  10. };

所以写 Redux 的时候虽然结果对了,但可能写法上就有问题。如果搭配 immutablejs 或者 immerjs,那就不会有这种问题了。

6.5 useRecoilValue

useRecoilValue 是 useRecoilState 的只订阅版本,它只返回 state 的值,不提供修改方法。

  1. import { useRecoilValue } from 'recoil';
  2. const Counter = () => {
  3. const count = useRecoilValue(counterState);
  4. return (
  5. <div>{ count }</div>
  6. );
  7. };

6.6 useSetRecoilState

useSetRecoilState 则是 useRecoilState 的只写版本,它提供了一个写的方法,但不会返回 state 的值,使用不需要订阅重新渲染的场景。

  1. import { useSetRecoilState } from 'recoil';
  2. const Counter = () => {
  3. const setCount = useSetRecoilState(counterState);
  4. const increment = () => setCount(count + 1);
  5. return (
  6. <div onClick={increment}></div>
  7. );
  8. };

6.7 selector

类似于 Vue 和 Mobx 中的 computed 计算属性,Recoil 也提供了 selector 衍生值。它可以从 atom 或者其他 selector 里面来获取,selector 也可以被组件订阅,在变化的时候通知它们重新渲染。
2021-09-12-14-22-29-443698.png
参考下面的例子:

  1. const todoState = atom({
  2. key: 'todos',
  3. default: []
  4. });
  5. const completeCountSelector = selector({
  6. key: 'completeCount',
  7. get({ get }) {
  8. const todos = get(todoState);
  9. return todos.filter(todo => todo.isComplete).length;
  10. }
  11. });

selector 还支持异步函数,可以将一个 Promise 作为返回值:

  1. const myQuery = selector({
  2. key: 'MyQuery',
  3. get: async ({get}) => {
  4. return await myAsyncQuery(get(queryParamState));
  5. }
  6. });

6.8 使用场景

目前 Recoil 还处于测试阶段,它希望能够兼容未来的 Concurrent 模式。除了 Facebook,暂时还没有看到有哪些网站已经用了 Recoil,所以目前可以等它稳定后再到大型项目里面使用。如果在中小型项目里面,可以用来代替 Context/useReducer 来管理状态。
相比 Redux 的优势就是,Recoil 严格区分了读和写,通过构建依赖图,可以实现类似 Mobx 那样的精准更新,这点儿是 Redux 很难做到的。
同时,它的核心概念都很简单,没有 Redux 那么绕的概念,也不需要写一堆像 action、reducer 之类的模板文件,让开发更加简单。

6.9 原理

Recoil 的大致原理是这样的:

  1. 创建一个 atom 对象
  2. 使用 selector 的时候,会通过 get 来获取到依赖的 atom,生成一个 Map 映射关系
  3. 使用 useRecoilState Hook 的时候,会将当前 atom/selector 和组件的 forceUpdate 方法进行映射
  4. 当对状态进行修改的时候,会从映射关系里面取出来对应的组件 forceUpdate 方法,进行精准更新

    6.9.1 RecoilRoot

    RecoilRoot 的源码位置在 Recoil_RecoilRoot.react.js 文件里面,它主要用于初始化一个 Store,从 Store 里面一样可以从getState 里面获取最新的状态。
    2021-09-12-14-22-29-699490.png
    再看一下 storeState 是个什么结构,可以参考 makeEmptyStoreState方法,发现里面有很多属性,其中的 knownAtoms、knownSelectors、nodeToComponentSubscriptions 比较关键。
    前两个是声明的 Atom 和 Selector,后一个是则是保持了 Atom/Selector 和 Component 的映射关系。
    2021-09-12-14-22-30-296064.png
    RecoilRoot 最终返回了一个下面这个,里面的这个 Batcher,是用于状态修改时通知组件更新的。
    2021-09-12-14-22-30-556855.png

    6.9.2 atom

    atom 是 Recoil 里面的原子状态,它是分散的状态,但在底层实现上还是会聚拢在一个 Store 里面,只是从写法上是分散的。
    源码在 Recoil_atom.js 文件里面,返回了一个 baseAtom。在这个 baseAtom 里面,主要是定义了一些方法,其中的 initAtom 是在组件里面 get 的时候调用,用于将 atom 注册到 knownAtom 上面。这样 atom 就会被聚拢到一个大的 Store 里面。
    2021-09-12-14-22-33-901341.png
    atom 方法最后返回了一个用 registerNode 生成的 node 节点,这里就是能获取到的真实的 atom。
    registerNode 的实现比较简单,主要是 new 了一个类,然后将其放到 recoilValues 里面。这两个类只有一个属性 key,所以最后拿到的 recoilValue 只是一个 key 值。
    2021-09-12-14-22-34-239620.png

    6.9.3 selector

    先来回顾一下 selector的用法。它接收一个 get 成员方法,在这个 get 成员方法的参数里面还提供了一个 get 方法,通过这个 get 来获取到 state 的值。

    1. const completeCount = selector({
    2. key: 'completeCount',
    3. get({ get }) {
    4. const todos = get(todoState);
    5. return todos.filter(todo => todo.isComplete).length;
    6. }
    7. });

    selector 的实现和 atom 类似,最后也是返回了一个 registerNode,它的 init 方法里面也会将 key 加入到 knownSelectors 字段里面。
    在实现上有个很重要的 evaluateSelectorGetter 方法,这个方法主要是执行传给 selector 的 get 方法。它将一个 getRecoilValue 当做 get 传给了 get 方法。
    2021-09-12-14-22-37-769704.png
    可以看到在 try…catch 里面调用了传进来的 get,同时将 getRecoilValue 作为参数传给这个 get。getRecoilValue 做了什么事情呢?
    他调用了 setDepsInStore 来设置 selector 和 atom 的依赖关系。将 atom 的 key 加入到 deps 里面,从而映射了一个从 selector key 到多个 atom key 的 Map 关系。最后将映射关系存入到 storeState 的 nodeToNodeSubscriptions 字段里面。
    2021-09-12-14-22-38-112704.png

    6.9.4 useRecoilValue

    useRecoilValue 是个 Hook,它用于组件订阅 atom 变化。它的源码位置在 Recoil_Hooks.js 文件里面。主要看 useRecoilValueLoadable 这个方法。
    2021-09-12-14-22-38-527133.png
    mutableSource 是 React18 的一个 API,它是用于 「外部数据源」 流向 React 组件的,支持 Concurrent Mode。
    这里不对这个 API 做讲解,主要讲解不使用这个 API 的实现。感兴趣的可以参考这篇文章:react@18: useMutableSource
    在 useRecoilValueLoadable_LEGACY 里面的实现非常巧妙。它是一个基于 useEffect 的 Hook,它通过每次给 useState 传入一个新的数组来实现组件的强制更新,并且把这个更新的逻辑存了起来。
    2021-09-12-14-22-38-998483.png
    这个方法主要做了两件事情,一个是在 subscribeToRecoilValue 里面,将 key 和更新组件函数(红括号里面的)的映射关系存到 storeState 的 nodeToComponentSubscriptions 字段里面。
    另一个就是在 getRecoilValueAsLoadable 里面,执行在 atom 里面传给 registerNode 的 init 方法,这个 init 用于将 atom/selector 放到 storeState 的 knownAtom/knownSelector上面。

    6.9.5 useSetRecoilState

    useSetRecoilState 是用于修改 atom 状态的 Hook,它和 useRecoilValue 被集成到了 useRecoilState 里面。它的主要源码在 Recoil_RecoilValueInterface.js 的 setRecoilValue 里面。
    2021-09-12-14-22-39-769654.png
    可以看到,在 queueOrPerformStateUpdate 里面支持批量操作,可以将多个 set 操作合并。
    如果不走批量更新的逻辑,它就会执行 applyActionsToStore 方法。
    2021-09-12-14-22-40-131237.png
    这里主要还是调用了 replaceState 方法,这个是在 RecoilRoot 里面定义的。
    2021-09-12-14-22-40-732462.png
    replaceState 会调用 notifyBatcherOfChange.current,这个 notifyBatcherOfChange.current 是什么呢?它其实就是一个 setState,用于通知 Batcher 组件更新的。
    2021-09-12-14-22-41-548421.png
    在 endBatch 里面又调用了 sendEndOfBatchNotifications 方法,这个方法里面会从 nodeToComponentSubscriptions 里面根据 key 来获取到前面说的 forceUpdate 的方法。调用 forceUpdate 方法来实现组件的精准更新。
    至此,一个完整的更新流程就一目了然。

    6.9.6 简单实现

    也可以简单来实现一个 Recoil,主要是在 useRecoilValue 里面对组件进行订阅,在 set 的时候进行通知。参考了这个库的实现:recoil-clone
    首先,需要实现一个发布订阅的类,这个类作为 atom 和 selector 的基类,实现上很简单: ```javascript class Stateful {

    listeners = new Set();

    constructor(value) { this.value = value; }

    snapshot() { return this.value; }

    emit() { for (const listener of Array.from(this.listeners)) { listener(this.snapshot()); } }

    update(value) { if (this.value !== value) { this.value = value; this.emit(); } }

    subscribe(callback) { this.listeners.add(callback); return { disconnect: () => {

    1. this.listeners.delete(callback);

    }, }; } }

  1. 实现一个 Atom 类,只需要简单继承这个类就行了:
  2. ```javascript
  3. export class Atom extends Stateful {
  4. setState(value) {
  5. super.update(value)
  6. }
  7. }

然后来实现 useRecoilValue,它需要将 forceUpdate 方法注册到 Atom 里面。这里可以使用 setState 来模拟 forceUpdate

  1. function useRecoilValue(value) {
  2. const [, setState] = useState({});
  3. useEffect(() => {
  4. const { disconnect } = value.subscribe(() => setState({}));
  5. return () => disconnect();
  6. }, [value]);
  7. return value.snapshot();
  8. }

接着,需要在修改 atom 的时候通知组件更新。

  1. function useSetRecoilState(atom) {
  2. return useCallback(value => atom.setState(value), [atom])
  3. }

到这里,已经实现了一个简单的 atom。由于比较简单,甚至它都不需要 key。

  1. function atom(value) {
  2. return new Atom(value.default);
  3. }

对于 selector 来说,它和 atom 实现类似,只是除了给组件订阅之外,它还需要订阅 atom 的变化。

  1. class Selector extends Stateful {
  2. registeredDeps = new Set();
  3. // 订阅依赖的 atom 变化,触发更新
  4. addDep(dep) {
  5. if (!this.registeredDeps.has(dep)) {
  6. dep.subscribe(() => this.updateSelector());
  7. this.registeredDeps.add(dep);
  8. }
  9. return dep.snapshot();
  10. }
  11. // 重新执行 get 方法,获取新的值
  12. updateSelector() {
  13. this.update(this.generate({ get: dep => this.addDep(dep) }));
  14. }
  15. constructor(generate) {
  16. super(undefined);
  17. this.generate = generate;
  18. this.value = this.generate({ get: dep => this.addDep(dep) });
  19. }
  20. }

7. 总结

7.1 适用场景

在复杂度很低的场景下,完全可以用 Context 和 useReducer 来实现简单的状态管理,但它需要配合 memo 或者 PureComponent 来控制更新粒度。
在复杂度一般的场景下,可以尝试用 Recoil 来管理分散的状态,提高重渲染的性能(理论上 Recoil 适合比较复杂的场景,鉴于社区实践不多就放到这里)。
在复杂度比较高的场景下,可以考虑用 Mobx 来管理状态,不管在性能还是社区生态方面都非常出色。
在复杂度很高的场景下,使用 Redux 提高状态的可预测性,约束性的写法也方便后期的维护。

7.2 原理差别

在实现原理上,三者都比较巧妙,但又各种有不同。
在 Redux 中,实现了一个发布订阅,组件去监听 store 变化,一旦 store 变化,就会通知组件重新渲染。但是 Redux 不会根据组件使用的状态来定向通知,它会粗暴地通知所有 connect 过的组件。如果在不做浅比较的情况下,整体性能损耗严重。
在 Mobx 中,将状态变成可观察数据,通过数据劫持,拦截其 get 来做依赖收集,知道每个组件依赖哪个状态。在状态的 set 阶段,通知依赖的每个组件重新渲染,做到了精准更新。
在 Recoil 中,通过 useRecoilValue/useRecoilState 两个 Hook API,在组件第一次执行的时候,构建 Atom 和组件的依赖图,将组件 setState 存入到 Atom 的监听队列里面。一旦 Atom 更新,就从监听队列里面取出来执行,这样每个组件的 setState 就会触发组件的更新,同样做到了精准更新。