中后台页面状态管理方案选型

date
Oct 16, 2020
slug
b-system-state-management
status
Published
tags
State Management
summary
type
Post

为什么需要状态管理

首先回顾一下 React 其中两个特点:
  • React 的核心思想:UI = f(state),即「UI 是 state 的投影」,整个 React 树由 state 驱动
  • React 中的 state 自上而下流动
这两个特点保证了 React 的纯粹性,同时也引入了一个问题:当一个 React 应用足够复杂,组件嵌套足够深时,组件树中的状态流动会变得难以控制(例如你如何跟踪父节点的 state 流动到叶子节点时产生的变化)。
这时我们就需要对 state 进行管理。

状态管理方案的分类

Flux

notion image
Flux 本身是一个架构思想,它最重要的概念是「单向数据流」,将应用的 State 进行统一管理,通过发布/订阅模式来进行状态的更新与传递。
遵循该思想的状态管理方案主要有 Redux、Vuex、Zustand 以及 React 自带的 useReducer + Context

原子化

notion image
在原子类状态解决方案中,原子指的是 State 的独立单元,与 Flux 系不同,它们可以单独进行订阅。当原子被更新时,订阅这个原子的组件都将以更新后的值重渲染。
原子化模型的优势在于,不同状态单元相互隔离,某个原子更新时只会对订阅该原子的组件产生影响,避免了对其他组件手动做防重渲染的处理。
遵循该思想的状态管理方案主要有 Recoi、Jotail

响应式

notion image
响应式状态管理方案的代表是 Mobx
Mobx 遵循 Reactive Programming 的实现,它提供声明式的依赖关系,当关系链条中当某一环发生变更时,后续环节会自动触发变更。与 Redux 相比大大减少了样板代码,使用起来简洁明了。

中后台页面的特性

回到标题所提出的问题,中后台页面的状态管理方案该如何选型?
Ant Design 列表模板
Ant Design 列表模板
Ant Design  列表模板
Ant Design 列表模板
首先我们需要分析一下中后台页面的结构与交互特性:
  1. 微前端架构下,中后台页面不包含导航菜单等外层框架,只承载实际业务功能
  1. 中后台页面的交互与布局往往不复杂,但可能存在复杂的业务逻辑
  1. 部分交互是固定不变的,可抽象复用(例如列表页的查询)
  1. 原子组件内部的状态以及表单的状态由底层 UI 库进行管理,开发者无须关心
  1. 开发者主要面对的状态管理问题是:
    1. Props Drilling
    2. 无法对某一次状态变更进行追踪
    3. 存在较多与页面主链路无关的数据交互(例如某一下拉列表的选项值 dataSource 的获取)
由此可见:
  1. 中后台页面的部分场景无须状态管理工具的介入,固定的交互场景可由 React Hooks 进行抽象复用
  1. 剩余部分的状态往往是来自服务端的数据,没有原子化的特征,但却希望有全局管理能力

最终方案选型:Zustand + custom React Hooks

基于上述分析,我们排除了 Recoil 与 Jotai 这样的原子化解决方案,并决定将外部状态分为「全局」和「交互场景」复用两个类别进行管理。

全局状态管理工具:Zustand

我们回顾一下当前全局状态的主要问题:
  1. Props Drilling
  1. 无法对某一次状态变更进行追踪
同时我们希望这款工具是简单易用且轻量的,不要给业务代码增添复杂性。最终,我们团队选择了 Zustand 作为全局状态管理工具。
Zustand 是一个 Flux-like 的轻量状态管理方案,只有 1.58kb 大小。它提供了简单的 API,没有过多复杂的概念。与 Redux 一样,两者都是将状态保存在一个对象里进行管理。它的优势在于:
  1. 可以使用 Redux devtools
  1. 没有过多复杂的概念,API 简单易懂
  1. 相较于 Redux ,无需编写各种样板代码
  1. 在 React 之外的环境中也可以使用
  1. 支持中间件

交互场景复用: custom React Hooks

上述分析描述到,中后台页面还存在固定交互非主链路交互这两个场景,这这两个场景的的特点是 State 无需被全局共享,我们要做的只是把它们的逻辑进行抽象与复用。例如,我们使用 useTable 这个 hooks 来抽象最常见的列表查询场景(这里使用 ahooks 中的 useFusionTable 举例):
import React from 'react';
import { Table, Pagination, Field, Form, Input, Button, Select, Icon } from '@alifd/next';
import { useFusionTable } from 'ahooks';

interface Item {
  name: {
    last: string;
  };
  email: string;
  phone: string;
  gender: 'male' | 'female';
}

interface Result {
  total: number;
  list: Item[];
}

const getTableData = ({ current, pageSize }, formData: Object): Promise<Result> => {
  let query = `page=${current}&size=${pageSize}`;
  Object.entries(formData).forEach(([key, value]) => {
    if (value) {
      query += `&${key}=${value}`;
    }
  });

  return fetch(`https://randomuser.me/api?results=${pageSize}&${query}`)
    .then((res) => res.json())
    .then((res) => ({
      total: 55,
      list: res.results.slice(0, 10),
    }));
};

const AppList = () => {
  const field = Field.useField([]);
  const { paginationProps, tableProps, search, loading } = useFusionTable(getTableData, {
    field,
  });
  const { type, changeType, submit, reset } = search;

  const advanceSearchForm = (
    <div>
      <Form
        inline
        style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}
        field={field}
      >
        <Form.Item label="name:">
          <Input name="name" placeholder="name" />
        </Form.Item>
        <Form.Item label="email:">
          <Input name="email" placeholder="email" />
        </Form.Item>
        <Form.Item label="phone:">
          <Input name="phone" placeholder="phone" />
        </Form.Item>

        <Form.Item label=" ">
          <Form.Submit loading={loading} type="primary" onClick={submit}>
            Search
          </Form.Submit>
        </Form.Item>

        <Form.Item label=" ">
          <Button onClick={reset}>reset</Button>
        </Form.Item>

        <Form.Item label=" ">
          <Button text type="primary" onClick={changeType}>
            Simple Search
          </Button>
        </Form.Item>
      </Form>
    </div>
  );

  const searchForm = (
    <div>
      <Form
        inline
        style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}
        field={field}
      >
        <Form.Item label=" ">
          <Select name="gender" defaultValue="all" onChange={submit}>
            <Select.Option value="all">all</Select.Option>
            <Select.Option value="male">male</Select.Option>
            <Select.Option value="female">female</Select.Option>
          </Select>
        </Form.Item>

        <Form.Item label=" ">
          <Input
            name="name"
            innerAfter={<Icon type="search" size="xs" onClick={submit} style={{ margin: 4 }} />}
            placeholder="enter name"
            onPressEnter={submit}
          />
        </Form.Item>

        <Form.Item label=" ">
          <Button text type="primary" onClick={changeType}>
            Advanced Search
          </Button>
        </Form.Item>
      </Form>
    </div>
  );

  return (
    <>
      {type === 'simple' ? searchForm : advanceSearchForm}
      <Table {...tableProps} primaryKey="email">
        <Table.Column title="name" dataIndex="name.last" width={140} />
        <Table.Column title="email" dataIndex="email" width={500} />
        <Table.Column title="phone" dataIndex="phone" width={500} />
        <Table.Column title="gender" dataIndex="gender" width={500} />
      </Table>
      <Pagination style={{ marginTop: 16 }} {...paginationProps} />
    </>
  );
};

export default AppList;

总结

中后台页面的结构并不是十分复杂,从代码层面来讲也是希望越简洁越好,所以我们对状态管理工具的期望是不要给业务代码带来过多的复杂性。Zustand 刚好是这样一款功能齐全但又大道至简的工具,使用起来十分顺畅;另外,对 React Hooks 的运用也能进一步对页面 State 进行拆分,对重复逻辑进行抽象复用。我们并没有使用一个方案 All in one 来管理 State,程序世界里没有最好的技术,只有最合适的技术。
 

参考资料:
 

© Sytone 2021