谈谈 React 状态管理

1,777 阅读8分钟

目前流行的前端框架(如 React,Vue,Angular)都是基于 MVVM,数据经过 ViewModel 转化成视图。当数据变化时,视图也会自动更新。前端开发由过程式开发转为数据驱动式开发,随着应用的复杂度提高,状态管理成为必不可少的一部分。

状态管理主要解决两个问题:

  • 组件间共享数据
  • 抽离数据相关逻辑

在众多 React 状态管理框架中,Redux 最为流行,在 Redux 基础上又衍生出更多的框架。本文将从原生的 React 开始,逐步介绍基于 Redux 的主流状态管理方案,最后探讨其它可能性。

为便于对比,本文分别使用了三种方式( Redux / Redux Toolkit / Rematch )来实现同样的需求,即「查看 Reddit 信息」,详见各部分示例。

1. React - 自上而下的数据流

局部状态 Component State

React 组件内部的 state 是局部的,只有该组件可以设置和访问,其它组件无法直接访问。当 state 发生变化时,React 会重新渲染,使 UI 呈现与数据保持一致。组件可以把 state 作为子组件的 props 向下传递,形成自上而下的数据流,也称单向数据流。

在实际应用中要保证 Single-Source-of-Truth,即任何可变数据只有「单一数据源」。 如果多个组件需要共享相同的状态,通常将共享状态提升到最近的共同父组件中。由某个组件 state 派生的数据只能影响本身及其子孙组件。这样简单的设计理念,让数据的流向非常清晰。

全局状态 Context

如果组件间的嵌套层级较多,使用上述共享局部状态的方式,需要为中间组件手动添加 props,逐层传递数据。如果应用中许多组件都需要共享某种状态(如 UI 主题),这种做法就非常繁琐。

Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法,是一种类似「广播」的数据传递方式。每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化,当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染。

由于 Context 共享数据依赖外部的 Provider,组件复用性变差,所以要谨慎使用。使用 context 的通用的场景包括管理当前的 locale,theme,或者一些缓存数据。如果只是想避免数据在组件间层层传递,可以考虑「组合」的模式。

2. Redux - 经典模式

Redux 的灵感来源于 2014 年 Facebook 推出的 Flux。Redux 的架构如下图所示 Redux 的三大原则:

  1. 单一数据源
  2. State 是只读的
  3. 使用纯函数来执行修改

源码分析

Redux 的源码清晰易懂,是对 Redux 核心理念和 API 最好的解释。虽然直接使用 Redux 的情况越来越少,但许多框架都是在 Redux 基础上衍生出来的,本质上还是 Redux。因此,对 Redux 的实现了然于心十分有必要。

下面的代码描述了 Redux 的基本工作方式。20 行代码实现简易版的 Redux

const createStore = reducer => {
  let state = reducer(undefined, { type: "@INIT" });
  let listeners = [];

  const getState = () => state;

  const dispatch = action => {
    state = reducer(state, action); // line 8
    listeners.forEach(listener => listener());
  };

  const subscribe = listener => {
    listeners = [...listeners, listener];
    return () => {
      listeners = listeners.filter(item => item !== listener);
    };
  };

  return { getState, dispatch, subscribe };
};

const reducer = (state = 1, action) => state + 1
console.log(createStore(reducer).getState())

这个简易版实现并未考虑到 combineReducers 和 应用中间件,即applyMiddleware。官方源码参见 github.com/reduxjs/red…

示例 - 查看 Reddit 信息

codesandbox.io/s/basic-red…

该示例的文件目录结构如下图所示。文件按 Redux 中对应结构依次归入 action,reducers,components,containers 中。 Redux 的早期贡献者 Pete Hunt 这样说:

You'll know when you need Flux. If you aren't sure if you need it, you don't need it.

使用 Redux 的典型场景包括:

  1. 在顶层组件 state 中维护所有内容过于繁琐
  2. 数据随时间变化,需要保证单一数据源
  3. 需要支持数据的 Time-travel

3. Redux Toolkit - 减少样板代码

在实践中,Redux 有以下几点问题:

  • 配置 Redux store 太麻烦
  • 最佳实践中依赖很多其它的包(redux-thunk、immer)
  • 样板代码太多(constants、actions、reducers 有许多重复逻辑)

Redux toolkit,是一个 lib 而非 framework,减少使用 Redux 时的样板代码,并且把 Redux 的常用工具稍微整合了下。

configureStore() 包裹createStore,并集成了redux-thunk、Redux DevTools Extension,默认开启

createReducer() 创建一个reducer,action type 映射到 case reducer 函数中,不用写switch-case,并集成immer

createAction() 创建一个action,传入动作类型字符串,返回动作函数

createSlice() 创建一个slice,包含 createReducer、createAction的所有功能

createAsyncThunk() 创建一个thunk,接受一个动作类型字符串和一个Promise的函数

示例 - 查看 Reddit 信息

codesandbox.io/s/cold-left… 与上一个 Redux 示例相比,使用 Redux Toolkit 后代码精简了不少。需要注意的是,此时文件目录的组织结构也发生了变化,action、reducer 等功能按业务聚合到同一个文件或文件夹中,更利于开发维护。但它仍然没有一个类似于 model 的整体性包装,不能动态新增 model。

4. Rematch - 实践中打磨

Rematch is Redux best practices without the boilerplate. No more action types, action creators, switch statements or thunks.

Rematch 的灵感来自于 Dva 和 Mirror。它借鉴了 Dva 中业务模型的概念,通过 model 把数据及相关的同步/异步操作集中管理起来,形成一个领域模型,符合「高内聚,低耦合」的设计原则。对于异步操作,常用的 redux-thunk 不太适合复杂一点的情况,而 Dva 中引入的 redux-saga 的方案显得过于精细。Rematch 舍弃了 redux-saga,直接使用 async/await,写起来更加自然。Rematch 可以支持各种插件,如 @rematch/select、@rematch/immer 等。

示例 - 查看 Reddit 信息

codesandbox.io/s/billowing…

和 Redux Toolkit 相比,Rematch 像是一个删减过的作品,它是一种架构模式,遇到适应的业务,用起来会很流畅。Redux Toolkit 作为官方出品,需要适应各种业务,难以做到精简,短期内不太可能完全取代 Rematch。

5. 不止是 Redux

Mobx

Anything that can be derived from the application state, should be derived. Automatically.

以 Redux 为基础的状态管理库都强调要区分同步操作和异步操作,这样可以带来一些好处,比如状态可追溯、抽象数据操作层等。但是对于发起者来说,action 就是抽象出来的某个动作,并不会去区分这个动作是同步的还是异步的。而 Mobx 使用了观察者模式,当对象发生变化时,观察者会自动响应这个变化,作出事先定义好的行为,数据流动一气呵成。这就是为什么许多项目倾向于 Mobx 的原因,其流程图如下: 看看用 Mobx 写的计时器: Mobx 是状态管理库中代码侵入性最小的之一,最大的好处是简单直接,还具有细粒度控制、样板代码少、易扩展等优势,一般适用于中小型前端项目。如果是大型前端项目,当数据流非常复杂时,Mobx 又显得过于自由,Redux 的约束反而体现出团队协作和后期维护方面的优势。

Global Hooks

Hook 是 React 16.8 的新增特性,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。UseGlobalHook 是基于 hooks 的简单状态管理工具,它的核心思想如下图所示(源代码 仅 60 行): 我们再通过下面的例子来简单感受下 useGlobalHook 的使用方式: 乍一看,useGlobalHook 似乎就是实现了 useReducer 的功能,但它和 useRecuder 有本质的差别。UseGlobalHook 是在一个 setState 触发事件,让所有 setState 跑一遍同步状态。实际上各个 component 访问的还是自己的 state,所引用的公共 store 是用于中转的。但使用 useReducer 时,所有组件是直接访问的公共store。

对于使用者来说,最关键的还是使用姿势的不同。在涉及到多个 components 时,useReducer 需要通过 context 传入 state 和 dispatch,不利于解耦,而 useGlobalHook可以直接使用。

6. 你可能不需要状态管理

随着前端的发展,状态管理方案层出不穷,不免让人眼花缭乱。Redux 不一定适合你的项目,甚至你的项目还不到需要认真思考状态管理的程度。想想十几年前,大家都在 window.xxx 上裸奔,也能应付大多数情况。就算是近几年,许多功能复杂的单页面应用,Backbone 就可以轻松搞定。

回顾状态管理解决的两个问题:共享数据和逻辑分离。逻辑分离方面,React 提供的 hooks API 正好提供了一整套完备逻辑抽离方案。使用 hooks,我们可以很好地把组件内部状态和共享状态组织起来,只需要关心数据被谁用到了,以及是否需要在多个组件间共享。

显然,「没有银弹」。我们需要根据实际情况做出选择,但也不必过于纠结。