2021年的React状态管理

·  阅读 10069
2021年的React状态管理

前言

众所周知,React是一个专注于UI层的库,不同于Vue、Angular等框架,React的各种状态管理方案一直是在百花齐放/群魔乱舞。除了热门库Redux、Mobx、Recoil、Zustand等之外,React的正式版也来到了17,useState、useReducer、useContext等状态管理相关hook的概念和应用也逐渐深入人心。

2021年又快过去了,是时候来盘点一下React社区最新的状态管理方案现状。

这里不深入原理,只介绍各个方案的特性和在如今的优劣势。

React状态管理

React的状态管理主要分三类:Local state、Context、第三方库

其中,热门第三方库近两年的npm下载量和Github仓库情况如下:

Npm Downloads

Stats

(详见: npm trends

可以看到,老大哥redux和他的小老弟(thunk、saga、observable)依然强势,mobx不温不火,后起之秀recoil势头凶猛,刚出来一年就已经有14k的stars。

Local State

React v16.8之后,functional component成为主流,local state的管理就是useStateuseReducer的天下了。

useState - 更细粒度的状态

不同于Class Component将所有的state都放在一个对象中,useState的思想是将组件内的状态再拆分,以更细的粒度维护:

import {useCallback, useState} from 'react';

const Foo = () => {
  const [stateA, setStateA] = useState(0);
  const [stateB, setStateB] = useState(0);

  const handleAdd = useCallback(
    () => { setStateA(prev => prev + 1); },
    []
  );
  const handleSubtract = useCallback(
    () => { setStateB(prev => prev - 1); },
    []
  );

  return (
    <>
      <Button onClick={handleAdd}>{stateA}</Button>
      <Button onClick={handleSubtract}>{stateB}</Button>
    </>
  );
};

useReducer - 复杂逻辑抽象和复用

熟悉Redux的同学可以很容易地理解useReducer。使用useReducer可以认为是在组件内生成一个独立的redux store,并且这个reducer的逻辑可以在不同的组件中复用。

当state的计算逻辑比较复杂,或者派生状态的变化存在共性,或者reducer逻辑可以复用时,可以优先考虑用useReducer。

例如常见的列表分页逻辑封装:

import {useReducer} from 'react';

const initialState = { pageNum: 1, pageSize: 15 };

const reducer = (state, action) => {
  switch (action.type) {
    case 'next':  // 下一页
      return { ...state, pageNum: state.pageNum + 1 };
    case 'prev':  // 前一页
      return { ...state, pageNum: state.pageNum - 1 };
    case 'changePage':  // 跳转到某页
      return { ...state, pageNum: action.payload };
    case 'changeSize':  // 更改每页展示条目数
      return { pageNum: 1, pageSize: action.payload };
    default:
      return state;
  }
};

const Page = () => {
  const [pager, dispatch] = useReducer(reducer, initialState);

  return (
    <Table
      pageNum={pager.pageNum}
      pageSize={pager.pageSize}
      onGoNext={() => dispatch({ type: 'next' })}
      onGoPrev={() => dispatch({ type: 'prev' })}
      onPageNumChange={(num) => dispatch({ type: 'changePage', payload: num })}
      onPageSizeChange={(size) => dispatch({ type: 'changeSize', payload: size })}
    />
  );
};

使用useReducer还有一个优点是可以优化深层子组件需要触发更新时的应用性能。

假设我们在父组件定义了一个state,在子组件中有要更改父组件state的需求,以往惯用的做法是在父组件定义相关的callback,然后一层层地透传给子组件。

在组件层级特别深和callback特别多的时候,就会回想起被prop透传支配的恐惧,写子组件prop的类型也要脱半层皮。并且使用useCallback封装的方法有可能因为依赖的变量更新和返回新的引用,而导致透传途径的子组件都可能触发更新。

使用useReducer的话,可以结合useContext,只把dispatch传到子组件中。并且dispatch生成之后引用恒定不变,不会触发context可能的force update。

import {createContext, useReducer, useContext} from 'react';

const ParentDispatch = createContext(null);

const Parent = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <ParentDispatch.Provider value={dispatch}>
      <DeepTree parentState={state} />
    </ParentDispatch.Provider>
  );
};

// 深层子组件
const DeepChild = () => {
  const dispatch = useContext(ParentDispatch);

  const handleClick = () => {
    dispatch({ type: 'add', payload: 'hello' });
  };

  return <button onClick={handleClick}>Add</button>;
};

详见How to avoid passing callbacks down?

Context

在一个典型的 React 应用中,数据是通过 props 属性自上而下(由父及子)进行传递的,context提供了可以跨组件层级传递prop的API。React的context不是一个新东西,在这里对其该概念和用法不再过多赘述。

useContext

FC中结合createContextuseContext使用,可见上文useReducer中的例子。

Context的问题

Context存在的问题也是老生常谈。

在react里,context是个反模式的东西,不同于redux等的细粒度响应式更新,context的值一旦变化,所有依赖该context的组件全部都会force update,因为context API并不能细粒度地分析某个组件依赖了context里的哪个属性,并且它可以穿透React.memoshouldComponentUpdate的对比,把所有涉事组件强制刷新。

React官方文档在 When to Use Context 一节中写道:

Context 设计目的是为了共享那些对于一个组件树而言是“全局”的数据,例如当前认证的用户、主题或首选语言。

综上,在系统中跟业务相关、会频繁变动的数据在共享时,应谨慎使用context。

如果决定使用context,可以在一些场景中,将多个子组件依赖的不同context属性提升到一个父组件中,由父组件订阅context并以prop的方式下发,这样可以使用子组件的memo、shouldComponentUpdate生效。

此外,官方文档还提到了另外一个坑,使用的时候也应该注意。

状态管理库

如今最火的React状态管理库莫过于Redux、Mobx、Recoil,其中Redux和Mobx都是老牌强手代表,Recoil则是这两年最火的后起之秀。

Redux

Redux想必大家都已经很熟悉:

React-redux推出useDispatchuseSelector等hook之后,减少了大量以前使用高阶组件(container)来连接(connect)store和view的代码,极大降低了获取state和封装action的成本,用法也更加灵活。

Redux本身很纯净,心智模型也不复杂,但实际使用还得搭配redux-thunk、redux-saga、redux-observable这些中间件(middleware)和reselect、immer这样的辅助工具才能达到真正可用的状态,加大了学习成本的同时,中间件也会引入各种副作用和中间态,真正的状态流并没有理想中那么美好。

Redux最令人诟病的是重复的模板代码太多,但redux团队并不是不知道这一点。Dan Abramov(redux作者)在推特上多次强调过,redux的设计是为以下原则服务的:要让状态的变化可追踪,可重复,可维护,因此才会有 reducer, action, middleware 这些概念。为实现一个简单的状态更新操作,要改五六个文件写一整套的模板代码,这是成本浪费还是可维护性的代价,就见仁见智了。

模板代码多的这个问题,使用Redux Toolkit可以得到一些改善,它封装了reducer、action的写法,并附带了一些有用的工具包(但是学习成本好像又增加了呢)。

{
  "dependencies": {
    "immer": "^9.0.6",
    "redux": "^4.1.0",
    "redux-thunk": "^2.3.0",
    "reselect": "^4.0.0"
  },
}

Redux Middleware

Redux中间件的原理差不多,通过中间件的预处理,允许View(组件)中dispatch更灵活的action(可以是函数或者promise等),然后在中间件中处理各种副作用(接口请求等);或者是内置自定义的状态机(redux-saga)等。但最终都是将action转换为redux需要的plain object格式,dispatch到redux store中。

来看看redux中间件三巨头redux-thunkredux-sagaredux-observable这两年的使用趋势

Npm Downloads

Stats

Redux-thunk依然是最常用的,并且在稳定增长,逐渐拉大与其他中间件的差距;redux-saga这两年的使用量也增加了近一倍。Thunk和saga的最新一个版本都在两三年前,已经达到了比较稳定的状态。Redux-observable还在更新,但使用量增长似乎已经停滞。

Redux-thunk

Redux-thunk允许应用在组件中dispatch一个function(这个function就被称为thunk),原本在组件中的异步代码被抽离到这个thunk中实现,从而在不同组件里复用。

Thunk是一类函数的别名,这类函数的主要用途是将任务延迟执行,或者给另一个函数执行前后添加一些额外的操作。具体关于Thunk的模式介绍可以看What the heck is a 'thunk'?(或者译文)。

如下的原生redux写法:

// 原生Redux用法
import { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';

const Demo = () => {
  const dispatch = useDispatch();

  const fetchUser = useCallback(
    async () => {
      const result = await getUserApi(params);
      dispatch({
        type: 'RECEIVE_USER_INFO',
        payload: result,
      });
    },
    [dispatch]
  );

  useEffect(
    () => {
      fetchUser();
    },
    [fetchUser]
  );
  
  const currentUser = useSelector(state => state?.context?.currentUser);

  return <div>{currentUser?.name}</div>;
};

使用Redux-thunk的写法改造上面的例子:

// Redux Thunk Creator
const fetchUser = (params) => {
  return async (dispatch, getState) => { // This is a Thunk
    const result = await getUserApi(params);
    dispatch({
      type: 'RECEIVE_USER_INFO',
      payload: result,
    });
  };
}

const Demo = () => {
  const dispatch = useDispatch();

  useEffect(
    () => {
      dispatch(fetchUser(params));
    },
    [dispatch]
  );

  const currentUser = useSelector(state => state?.context?.currentUser);

  return <div>{currentUser?.name}</div>;
};

有没有觉得上面的代码很眼熟?Redux-thunk的设计其实非常超前,在六年前高阶组件满街跑的时候,redux-thunk的思路跟如今的hook如出一辙。

我们用hook+原生redux改造上述代码:

const useFetchUser = () => {
  const dispatch = useDispatch();

  const fetchUser = useCallback(
    async (params) => {
      const result = await getUserApi(params);
      dispatch({
        type: 'RECEIVE_USER_INFO',
        payload: result,
      });
    },
    [dispatch]
  );

  return fetchUser;
};

const Demo = () => {
  const fetchUser = useFetchUser();

  useEffect(
    () => {
      fetchUser(params);
    },
    [fetchUser]
  );

  const currentUser = useSelector(state => state?.context?.currentUser);

  return <div>{currentUser?.name}</div>;
};

Redux-thunk由redux的作者Dan Abramov编写,源码只有14行,简单性感,看完即可一分钟精通redux中间件。

个人认为,有了hook之后,redux-thunk可能已经完成了它的历史使命,毕竟它能做的hook都能做。

Redux-saga

Redux-saga旨在使应用中的副作用更便于管理,基于ES generator特性实现。可以想像为,一个 saga 就像是应用程序中一个单独的线程,它独自负责处理副作用。

Saga高度封装了对Side effects的管理。我们在组件中dispatch的依然是redux的原生action(plain object),saga中监听action,并执行与action type相应的callback。这种写法相当于把组件内的异步代码都抽离到saga中管理。

Saga的一大优势是内置了racetakeLatesttakeEvery等策略,可以很方便地实现action的竞态(Racing Effects)和并发(Concurrency)场景下的支持。

同时saga由于自身的高度封装,概念和接口比较多,包括:

  • 和组件、redux的交互(take、select、put)
  • 阻塞调用和非阻塞调用(fork、call)
  • 竞态、并发(race、takeLatest、takeEvery)
  • ...

我们用saga实现一下上述thunk的例子:

// Demo.jsx
const Demo = () => {
  const dispatch = useDispatch();

  useEffect(
    () => {
      dispatch({ type: 'FETCH_USER_INFO', payload: {} });  // dispatch原生action
    },
    [dispatch]
  );

  const currentUser = useSelector(state => state?.context?.currentUser);

  return <div>{currentUser?.name}</div>;
};

// sagas.js
import { call, put, takeEvery } from 'redux-saga/effects';

function *fetchUser(action) {
  const result = yield call(getUserApi, action.payload);  // 发起请求
  
  yield put({  // 真正dispatch action到redux store
    type: 'RECEIVE_USER_INFO',
    payload: result,
  });
}

function *mySaga() {
  yield takeEvery('FETCH_USER_INFO', fetchUser);  // 监听每一个 FETCH_USER_INFO ,并调用 fetchUser
}

export default mySaga;

上例中saga监听的action是FETCH_USER_INFO,最后dispatch到redux store的action是RECEIVE_USER_INFO,这是saga理念的充分体现:View只管触发,store只管更新,这中间的异步/副作用代码由saga来处理。

但是这一顿 call、put、take 下来,就问你新人看得懵不懵。另外saga也会写大量模板代码,加上redux本身的高重复度,对开发人员可以说是雪上加霜。

再来看一个官网的阻塞调用异步接口并支持中途取消的例子:

import { take, put, call, fork, cancel, cancelled, delay } from 'redux-saga/effects'
import { someApi, actions } from 'somewhere'

function* bgSync() {
  try {
    while (true) {
      yield put(actions.requestStart())
      const result = yield call(someApi)
      yield put(actions.requestSuccess(result))
      yield delay(5000)
    }
  } finally {
    if (yield cancelled())
      yield put(actions.requestFailure('Sync cancelled!'))
  }
}

function* main() {
  while ( yield take('START_BACKGROUND_SYNC') ) {
    // starts the task in the background
    const bgSyncTask = yield fork(bgSync)

    // wait for the user stop action
    yield take('STOP_BACKGROUND_SYNC')
    // user clicked stop. cancel the background task
    // this will cause the forked bgSync task to jump into its finally block
    yield cancel(bgSyncTask)
  }
}

简直就是Redux Pro Max 1T。

Saga的优势是对竞态和并发的支持更好,没接触过的同学只要记住这点就行了,等到有了需求场景,再去看看文档考虑使用也不迟,毕竟多接入一个saga中间件和原生的redux以及thunk都不冲突。

Mobx - 在React里写Vue

和redux一样,mobx本身是一个UI无关的纯粹的状态管理库,通过mobx-react或更轻量的mobx-react-lite和react建立连接。

实例

先来看一个用mobx实现计数器的简单例子:

import { useEffect } from 'react'
import { autorun } from 'mobx';
import { Observer, useLocalObservable } from 'mobx-react-lite';

const Counter = () => {
    const counterStore = useLocalObservable(() => ({  // 创建一个局部的observable state,生命周期和组件一致
        value: 0,
        get doubleValue() {  // computed value
            return counterStore.value * 2;
        },
        increment() {
            counterStore.value += 1;
        },
        decrement() {
            counterStore.value -= 1;
        },
    }));

    useEffect(
        () => {
            autorun(() => {  // 收集依赖,并在依赖项变化时重新执行
                console.log(counterStore.value);
            });
        },
        []
    );

    return (
        /* Observer组件会收集dom节点对store的依赖项,并使渲染变成响应式 */
        <Observer>
            {() => (
                <div>
                    <div>Value: {counterStore.value}</div>
                    <div>Double value: {counterStore.doubleValue}</div>
                    <button onClick={counterStore.increment}>Add</button>
                    <button onClick={counterStore.decrement}>Decrement</button>
                </div>
            )}
        </Observer>
    );
};

例子中,也可以将state抽成全局的:

import { useEffect } from 'react'
import { autorun, makeAutoObservable } from 'mobx';
import { useObserver } from 'mobx-react-lite';

// 全局state,可以在多个组件中订阅
const counterStore = makeAutoObservable({  // 把对象变成observable
    value: 2,
    get doubleValue() { // computed value
        return counterStore.value * 2;
    },
    increment() {
        counterStore.value += 1;
    },
    decrement() {
        counterStore.value -= 1;
    },
});

const Counter = () => {
    return (
        /* useObserver 的作用和 <Observer> 一样 */
        useObserver(() => (
            <div>
                <div>Value: {counterStore.value}</div>
                <div>Double value: {counterStore.doubleValue}</div>
                <button onClick={counterStore.increment}>Add</button>
                <button onClick={counterStore.decrement}>Decrement</button>
            </div>
        ))
    );
};

心智模型

Mobx的心智模型和react很像,它区分了应用程序的三个概念:

  • State(状态)
  • Actions(动作)
  • Derivations(派生)

首先创建可观察的状态(Observable State),通过Action更新State,然后自动更新所有的派生(Derivations)。派生包括Computed value(类似useMemo或useSelector)、副作用函数(类似useEffect)和UI(render)。

Mobx虽然心智模型像react,但是实现却是完完全全的vue:mutable + proxy(为了兼容性,proxy实际上使用Object.defineProperty实现)。

使用反react的数据流模式,注定会有成本:

  • Mobx的响应式脱离了react自身的生命周期,就不得不显式声明其派生的作用时机和范围。比如副作用触发需要在useEffect里再跑一个autorun/reaction,要给DOM render包一层useObserver/Observer,都加大了开发成本。

  • Mobx会在组件挂载时收集依赖,和state建立联系,这个方式在即将到来的react 18的并发模式(Concurrent Mode)中,可能无法平滑地迁移。为此,react专门开发了create-subscription方法用于在组件中订阅外部源,但是实际的应用效果还未可知。

尤大本人也盖过章:React + MobX 本质上就是一个更繁琐的Vue。

既然这样,直接用Vue不香吗(狗头)。

Mobx vs Redux

Mobx和Redux的对比,实际上可以归结为 面向对象 vs 函数式Mutable vs Immutable

  • 相比于redux的广播遍历dispatch,然后遍历判断引用来决定组件是否更新,mobx基于proxy可以精确收集依赖、局部更新组件(类似vue),理论上会有更好的性能,但redux认为这可能不是一个问题(Won't calling “all my reducers” for each action be slow?

  • Mobx因为数据只有一份引用,没有回溯能力,不像redux每次更新都相当于打了一个快照,调试时搭配redux-logger这样的中间件,可以很直观地看到数据流变化历史。

  • Mobx的学习成本更低,没有全家桶。

  • Mobx在更新state中深层嵌套属性时更方便,直接赋值就好了,redux则需要更新所有途经层级的引用(当然搭配immer也不麻烦)。

Recoil

Recoil是在React Europe 2020 Conference上facebook官方推出的专为react打造的状态管理库,动机是解决react状态共享模式的局限性:

  • 以往只能将state提升到公共祖先来实现状态共享,并且一旦这么做了,基本就无法将组件树的顶层(state 必须存在的地方)与叶子组件 (使用 state 的地方) 进行代码分割
  • Context 只能存储单一值,无法存储多个各自拥有消费者的值的集合

Recoil有以下特性:

  • 状态原子化(atom),自由组合和订阅;并且状态定义是渐进式和分布式的,使代码分割成为可能
  • 没有模板代码,天然是hook模式,让react尽量保持原来的样子
  • 兼容并发模式(Concurrent Mode)
  • 提供对状态流的快照(snapshot)支持,可以轻松回溯应用状态,甚至将snopshot编码放进url,让任何人打开应用都能进入到同样的状态

Recoil有很多优秀特性,是个值得关注的库,但目前仍然处于试验阶段,版本只发布到了0.4.1(截至2021-10-13),而且有大量的待解决issue,在正式项目中应谨慎使用。

实例

实现一个带筛选的列表:

源码:

import {atom, selector, useRecoilState, useRecoilValue} from 'recoil';

const listState = atom({  // 列表
    key: 'listState',
    default: [
        {name: 'Tom', sex: 'male'},
        {name: 'Allen', sex: 'male'},
        {name: 'Lucy', sex: 'female'},
    ],
});

const filterState = atom({  // 筛选项的值
    key: 'filterState',
    default: 'all',
});

const filteredListState = selector({ // 筛选后的列表(selector定义派生状态)
    key: 'filteredListState',
    get: ({get}) => {
        const list = get(listState);  // 通过get方法获取其他state的值
        const filter = get(filterState);
        
        return filter === 'all' ? list : list.filter(item => item.sex === filter);
    },
});

const List = () => {
    const [filter, setFilter] = useRecoilState(filterState);
    const filteredList = useRecoilValue(filteredListState);

    return (
        <div>
            <Radio.Group value={filter} onChange={e => { setFilter(e.target.value) }}>
                <Radio value="all">all</Radio>
                <Radio value="male">male</Radio>
                <Radio value="female">female</Radio>
            </Radio.Group>
            <ul className="list">
                {filteredList.map(item => (
                    <li key={item.name}>{item.name}</li>
                ))}
            </ul>
        </div>
    );
};

Recoil中定义状态的两个核心方法:

  • atom: 定义原子状态,即组件的某个状态最小集,
  • selector: 定义派生状态,其实就是computed value

消费状态的方法有useRecoilStateuseRecoilValueuseSetRecoilState等,用法和react的useState类似,几乎没有上手成本。另外值得注意的是,recoil目前只支持FC的hook用法,Class组件想用的话可以通过HOC的方式获取状态并注入组件。

心智模型

Recoil的状态集是一个有向图 (directed graph),正交且天然连结于React组件树。状态的变化从该图的顶点(atom)开始,流经纯函数 (selector) 再传入组件。

正交:相互独立,相互间不可替代,并且可以组合起来实现其它功能

Snapshot

Recoil每一次状态变更都会生成一个不可变的快照,利用这个特性,可以快速实现应用导航相关的功能,例如状态回溯、跳转等。

来看一个上例中带筛选列表的拓展:增加一个按钮,点击时生成一个包含页面状态快照信息的url,其他人访问这个url时也能加载出相同状态的页面。

import {useRecoilSnapshot, useGotoRecoilSnapshot} from 'recoil';

const SharedList = () => {
    const snapshot = useRecoilSnapshot();  // 获取当前全局状态的snapshot,每次变化都会更新
    const gotoSnapshot = useGotoRecoilSnapshot();

    const handleGenerateUrl = () => {
        const url = mapSnapshotToUrl(snapshot);  // 将snapshot信息编码进url
        console.log(url);
    };

    useEffect(
        () => {
            // 组件加载时从url中获取snapshot信息并跳转状态
            const snapshot = getSnapshotFromUrl();
            snapshot && gotoSnapshot(snapshot);
        },
        []
    );

    return (
        <>
            <List />
            <button onClick={handleGenerateUrl}>Generate URL</button>
        </>
    );
};

注:mapSnapshotToUrl是自定义的状态编码方法,recoil未来会提供官方的helper实现。

总结

感谢看到这里,不知道大家现在更认同哪种方案?可以下结论的是,如今的react状态管理依然没有银弹,没有最好,只有最适合。

简单场景使用原生的useState、useReducer、useContext就能满足;还可以用hox这样小而美的库将hook的状态直接拓展成持久化状态,几乎没有额外的心智负担。

复杂场景的应用,redux、mobx都是经受过千锤百炼的库,社区生态也很完备。

Redux高度模板化、分层化,职责划分清晰,塑造了其状态在可回溯、可维护性方面的优势;搭配thunk、saga这些中间件几乎是无所不能。

Mobx的优势是写法简单和高性能,但状态的可维护性不如redux,在并发模式中的兼容性也有待观察。

Recoil还在玩具阶段,应谨慎使用,但在复杂度一般的项目里用来替代redux还是能在开发体验上有不小的提升。

以react的尿性,或许根本不会有状态管理大一统的一天(一个UI库的自我修养),无关自身的都交给社区,在发展实践中逐渐收敛,然后又在react的版本换代中新旧更迭。

随着hook和有官方背景的recoil的出现,状态管理似乎在朝原子化、组件化的方向发展,这也符合react的组件化哲学。Redux的暴力遍历和分发或许已经是逆潮流的解法。

最后附一份主流方案的多方面对比:

方案学习成本编码成本TS友好SSRCode Split并发模式兼容可调试性生态繁荣
Redux一般支持不支持支持
Mobx支持支持未知
Recoil实践较少支持支持

完。

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改