再见了 Redux、Recoil、MobX、Zustand、Jotai 还有 Valtio,状态管理还可以这样做?

析构涟漪
• 阅读 2101

坚持在一线写前端代码大概有七八年了,写过一些项目,有过一些反思,越来越确信平日里一直用得心安理得某些的东西也许存在着问题,比如:在 状态管理 上一直比较流行的实践 🙏,所以试着分享出来探讨一下。

为什么要告别 Redux、Recoil、MobX、Zustand、Jotai 还有 Valtio

今天流行的状态管理库有很多,尤其在 React 中。为了把问题说得清晰一些,我想以 React 中的几个主流库切入聊起。

首先看一下 Redux。对于单个状态的变化,可以 dispatch 简单 action。想知道这个简单 action 会改变什么状态,根据 Redux 的设计,检查它声明在哪个 slice 里就可以了:

const checkboxSlice = createSlice({
  name: 'checkbox',
  initialState: {
    checked: false,
  },
  reducers: {
    check(state) {
      // ...
    },
  },
});

const { check } = checkboxSlice.actions;

// ...

dispatch(check());

// 因为 `check` 声明在 `checkboxSlice` 里,根据 Redux 的设计可以知道 `check` 改变的是 `checkboxSlice` 代表的状态。

而对于多个状态的变化,需要 dispatch 复杂 action。想知道这个复杂 action 会改变什么状态,只检查它声明在哪里是不够的:

const checkboxSlice = createSlice({
  name: 'checkbox',
  initialState: {
    checked: false,
  },
  reducers: {
    check(state) {
      // ...
    },
+
+    uncheck(state) {
+      // ...
+    }
  },
});

-const { check } = checkboxSlice.actions;
+const { check, uncheck } = checkboxSlice.actions;

// 先构建复杂 action `uncheckWithTextCleaned` 要调用的底层简单 action `uncheck`,而这个简单 action 大概率不会在别的地方用到了。
const textareaSlice = createSlice({
  name: 'textarea',
  initialState: {
    text: '',
  },
  reducers: {
    setText(state, action: PayloadAction<string>) {
      // ...
    },
  },
});

const { setText } = textareaSlice.actions;

function uncheckWithTextCleaned(): AppThunk {
  return (dispatch) => {
    // ...
  };
}

// ...

dispatch(uncheckWithTextCleaned());

// 在只检查 `uncheckWithTextCleaned` 的函数声明的情况下,无法知道这个复杂 action 会改变什么状态。

如果不追踪函数体,就无法知道复杂 action 会改变什么状态,那么状态变化就变得不可预测了。如果追踪了函数体,尽管可以知道会改变什么状态,但使用上的总体开发成本也就随着加重了:

function uncheckWithTextCleaned(): AppThunk {
  return (dispatch) => {
    dispatch(uncheck());
    dispatch(setText(''));
  };
}

// ...

dispatch(uncheckWithTextCleaned());

// 通过追踪函数体发现 `uncheckWithTextCleaned` 调用了 `uncheck` 和 `setText`,由于 `uncheck` 声明在 `checkboxSlice` 里,`setText` 声明在 `textareaSlice`,可以知道 `uncheckWithTextCleaned` 改变的是 `checkboxSlice` 和 `textareaSlice` 代表的状态。

此外,在复杂 action 要调用的底层简单 action 还没准备好的时候,就要先构建这些要调用的简单 action,而这些简单 action 大概率不会在别的地方用到了。这样,复杂 action 就与底层 slice 高耦合了,会导致开发困难,也就使成本进一步加重了。

再看一下 Recoil 和 MobX。在 Recoil 中是通过自定义 hook 来封装状态变化的:

const checkboxState = atom({
  key: 'checkbox',
  default: {
    checked: false,
  },
});

const textareaState = atom({
  key: 'textarea',
  default: {
    text: '',
  },
});

// ...

function useSetText() {
  return useRecoilCallback(
    ({ set }) =>
      (text: string) => {
        // ...
      },
    []
  );
}

function useUncheckWithTextCleaned() {
  const setText = useSetText();

  return useRecoilCallback(
    ({ set }) =>
      () => {
        // ...
      },
    []
  );
}

// ...

const uncheckWithTextCleaned = useUncheckWithTextCleaned();

// ...

uncheckWithTextCleaned();

// 在只检查 `uncheckWithTextCleaned` 或 `useUncheckWithTextCleaned` 的函数声明的情况下,无法知道这个 hook 会改变什么状态。需要通过追踪函数体发现直接或间接发起的 `set` 调用才能知道会改变什么状态。

在 MobX 中是通过类的方法来封装状态变化的:

class CheckboxStore {
  private textareaStore: TextareaStore;

  checked: boolean;

  constructor(textareaStore: TextareaStore) {
    makeAutoObservable(this);
    this.textareaStore = textareaStore;
    this.checked = false;
  }

  uncheckWithTextCleaned(): void {
    // ...
  }
}

class TextareaStore {
  text: string;

  constructor() {
    makeAutoObservable(this);
    this.text = '';
  }

  setText(text): void {
    // ...
  }
}

// ...

checkboxStore.uncheckWithTextCleaned();

// 在只检查 `checkboxStore.uncheckWithTextCleaned` 的函数声明的情况下,无法知道这个方法会改变什么状态。需要通过追踪函数体发现直接或间接改变的 property 才能知道会改变什么状态。

与 Redux 类似地,如果不追踪函数体,状态变化就不可预测了。如果追踪了函数体,使用成本就加重了。

此外,由于 Recoil 较多地考虑了异步状态变化,在自定义 hook 中获取状态会比较麻烦,由于 MobX 有独立的订阅机制,妥当使用需要准确理解。这些,都使成本进一步加重了。

而余下的三个库,Zustand、Jotai 和 Valtio,它们用起来分别非常像 Redux、Recoil 和 MobX。或许可以说,前几者基本上是后几者的简化版。

小结一下,React 中的主流状态管理库在 (1) 状态变化的可预测性 和 (2) 使用上的总体开发成本 上存在着问题。如果稍微看一下其他框架中最主流的状态管理库,会发现它们也有类似问题。所以可以说,这两个问题是普遍存在的。

可预测性与副作用

当函数在输出返回值之外还产生了其他效果,那这个函数就是有副作用(side effect)的。像上面例子中的函数,副作用都是改变状态。

而函数有副作用不等同于函数行为是不可预测的。只要副作用是可控的,函数行为就是可预测的。像 Redux 例子中的简单 action,根据 Redux 的设计只能改变声明各自的 slice 所代表的状态。但是,对函数的副作用不加以控制的话,随着函数体的复杂度上升副作用的可控性就会下降,最终,不可控的副作用就会让函数行为变得不可预测。

而函数没有副作用的话,函数行为就自然而然的可预测了。

这么想一想,要解决状态变化的可预测性问题,要么一直保持改变状态的函数的副作用可控,要么彻底去除改变状态的函数的副作用。

使用上的总体开发成本与偏好

除了可预测性问题对使用上的总体开发成本的影响,状态管理库自身的偏好也会较大程度地影响使用上的总体开发成本。像 Redux 中创建一个新的 store、像 Recoil 中自定义 hook 访问状态、像 MobX 中妥当使用订阅机制,都受到库自身的偏好影响变得有些费时费力。

当由于状态管理库自身的偏好加重了最基本的状态管理功能上的使用成本时,就会对这个状态管理库方方面面的使用产生负面影响,这是应该避免的。

状态管理的新做法

分析好了问题,接下来就可以想一下状态管理的新做法了,也就是,如何设计一个能够解决以上两个问题的新状态管理库。对于解决状态变化的可预测性问题,上面提到的两种做法尽管都可行,但出于对简洁性的追求,先尝试一下 “彻底去除改变状态的函数的副作用” 的做法。

对于单个状态的变化,可以引用以单个状态为入参、以新的单个状态为返回值的纯函数来完成:

function check(checkboxState: CheckboxState): CheckboxState {
  return {
    /* ... */
  };
}

对于多个状态的变化,可以引用以多个状态为入参、以新的多个状态为返回值的纯函数来完成:

function uncheckWithTextCleaned([checkboxState, textareaState]: [
  CheckboxState,
  TextareaState
]): [CheckboxState, TextareaState] {
  return [
    /* ... */
  ];
}

同时这些函数还应该能够接受除了状态之外的更多参数:

function setText(textarea: TextareaState, text: string): TextareaState {
  return {
    /* ... */
  };
}

由于纯函数没有副作用,引用这些函数改变不论单个还是多个状态都只会改变函数声明中的状态,也就是,可以在不追踪函数体的情况下知道会改变什么状态。

然后,引用这些函数改变状态的过程大致是这样的,(1) 读取状态、(2) 将状态传入函数计算新的状态 和 (3) 写入新的状态:

const oldCheckboxState = getState(keyOfCheckboxState);
const newCheckboxState = check(oldCheckboxState);
setState(keyOfCheckboxState, newCheckboxState);

这个过程可以进一步封装成一个通用的函数 operate

operate(keyOfCheckboxState, check);
operate(keyOfTextareaState, setText, '');
operate([keyOfCheckboxState, keyOfTextareaState], uncheckWithTextCleaned);

这样,新状态管理库的雏形就有了。

接下来,再从减轻使用成本的角度试着做一做优化。

稍微看一下 operate 的第一个参数 keyOf...,作用是 (1) 唯一标识状态。但是单独为了唯一标识状态,就声明一系列唯一的字符串常量,成本是比较高的。而完整定义状态,还需要 (2) 状态的默认值 和 (3) 状态的类型。如果把这三点关联在一起的话,就会发现对应到了 JS 中的一个常用概念,Plain Old JavaScript Object(POJO)。那么,通过 POJO 来定义状态的话,使用成本就进一步减轻了:

interface CheckboxState {
  checked: boolean;
}

const defOfCheckboxState: CheckboxState = {
  checked: false,
};

interface TextareaState {
  text: string;
}

const defOfTextareaState: TextareaState = {
  text: '',
};

// ...

operate(defOfCheckboxState, check);
operate(defOfTextareaState, setText, '');
operate([defOfCheckboxState, defOfTextareaState], uncheckWithTextCleaned);

然后,再以无偏好地方式加上其他最基本的状态管理功能,(1) 获取状态、(2) 订阅状态变化 和 (3) 取消订阅:

const checkboxState1 = snapshot(defOfCheckboxState);
const textareaState1 = snapshot(defOfTextareaState);
const [checkboxState2, textareaState2] = snapshot([
  defOfCheckboxState,
  defOfTextareaState,
]);

const unsubscribeCheckboxStateChanges = subscribe(
  defOfCheckboxState,
  onCheckboxStateChange
);
const unsubscribeTextareaStateChanges = subscribe(
  defOfTextareaState,
  onTextareaStateChange
);
const unsubscribeCheckboxTextareaStatesChanges = subscribe(
  [defOfCheckboxState, defOfTextareaState],
  onCheckboxTextareaStatesChange
);

这样,能够解决 (1) 状态变化的可预测性 和 (2) 使用上的总体开发成本 上问题的新状态管理库就大致做好了。

展望

在前端开发中状态管理是非常基础但极其重要的部分,而今天恰恰缺少了一个状态变化可预测、使用总体成本低的状态管理库,这给前端开发带来了许多挑战。

好的前端应用需要好的状态管理,作为前端开发者的我们也许都可以想一想怎么做状态管理才是好的。

此外,我也试着按照上面的思路写了一个状态管理库 https://github.com/statofu/statofu ,方便一起进一步尝试。

欢迎大家留言进一步探讨。

点赞
收藏
评论区
推荐文章
blmius blmius
4年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
美凌格栋栋酱 美凌格栋栋酱
10个月前
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
Wesley13 Wesley13
4年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Wesley13 Wesley13
4年前
FLV文件格式
1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.  
Wesley13 Wesley13
4年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
4年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Wesley13 Wesley13
4年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0
Wesley13 Wesley13
4年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
4年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Python进阶者 Python进阶者
1年前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这