发布于 2026-01-06 0 阅读
0

实用类型:Redux 作为有限状态机

实用类型:Redux 作为有限状态机

本文假设读者对 Redux 和类型有一定的了解,但欢迎提问。

这是本系列的第三篇文章。本文的代码在这里。

  1. Redux 作为有限状态机
  2. Redux中的副作用
  3. 乐观的用户界面
  4. 我创造了一个怪物

我们想做什么

我们想要创建一个表单,用户可以在其中输入数据。当用户提交表单后,我们需要在 AJAX 请求运行时显示加载状态。AJAX 请求完成后,如果成功则显示结果,如果失败则显示错误。

让我们为这项任务创建一个“经典”reducer和一个“有限状态机”reducer,以便进行比较。完整的代码在这个仓库中。

“经典”减法器

这就是“经典”reducer 的形式:

export default (reduxState: State = defaultState, action: Actions): State => {
  switch (action.type) {
    case "SUBMIT_FRUIT":
      return {
        ...reduxState,
        state: "fruit_loading",
        form: action.form
      };
    case "SUBMIT_FRUIT_ERROR":
      return {
        ...reduxState,
        state: "fruit_error",
        error: action.error
      };
    case "SUBMIT_FRUIT_OK":
      return {
        ...reduxState,
        state: "fruit_ok",
        resonse: action.resonse
      };
    default:
      exhaustiveCheck(action.type);
      return reduxState;
  }
};
Enter fullscreen mode Exit fullscreen mode

SUBMIT_FRUIT这是响应表单提交而分发的 action。
SUBMIT_FRUIT_ERROR而其他 actionSUBMIT_FRUIT_OK则会响应副作用(例如 AJAX 请求)而分发。我们可以使用不同的解决方案来处理副作用,例如 redux-thunk、redux-saga、redux-observable 或 redux-loop。但我们暂且不讨论这些,而是​​通过 dispatch 显式地触发副作用。

以下是 AJAX 请求的示例:

export const fruitSubmitSideEffect = (dispatch: Dispatch, form: FruitForm) => {
  // uses fetch inside returns a Promise
  fruitRequest(form).then(
    resonse => {
      dispatch({
        type: "SUBMIT_FRUIT_OK",
        resonse
      });
    },
    error => {
      dispatch({
        type: "SUBMIT_FRUIT_ERROR",
        error
      });
    }
  );
};

// and later

export default connect(
  () => ({}),
  (dispatch: Dispatch) => ({
    submit: (form: FruitForm) => {
      dispatch({ type: "SUBMIT_FRUIT", form });
      fruitSubmitSideEffect(dispatch, form);
    }
  })
)(Component);
Enter fullscreen mode Exit fullscreen mode

之前的状态用于创建新状态,但并未显式检查:

return {
  ...reduxState,
  ...newPartsOfState
};
Enter fullscreen mode Exit fullscreen mode

类型State可能如下所示:

export type State = {
  state: "initial" | "fruit_loading" | "fruit_error" | "fruit_ok";
  form?: FruitForm;
  error?: mixed;
  resonse?: FruitResponse;
};
Enter fullscreen mode Exit fullscreen mode

其中一个后果是我们需要编写额外的类型检查:

export default ({ state }: { state: State }) => {
  switch (state.state) {
    case "fruit_ok":
      return (
        state.resonse && // additional type check, that it is not undefined
        state.resonse.map(item => {}))
  }
Enter fullscreen mode Exit fullscreen mode

有限状态机

{||}有限状态机 (FSM) 应该只有有限个状态。让我们用类型系统来强制实现这一点。这是 Flow 类型,但 TypeScript 看起来也类似(在TypeScript中不需要)。

export type State =
  | {|
      state: "initial"
    |}
  | {|
      state: "fruit_loading",
      form: FruitForm
    |}
  | {|
      state: "fruit_error",
      form: FruitForm,
      error: mixed
    |}
  | {|
      state: "fruit_ok",
      form: FruitForm,
      resonse: FruitResponse
    |};
Enter fullscreen mode Exit fullscreen mode

现在我们不能在不进行检查的情况下使用之前的状态。如果我们这样做的话

return {
  ...reduxState,
  state: "fruit_loading",
  form: action.form
};
Enter fullscreen mode Exit fullscreen mode

Flow会报错:

Could not decide which case to select. Since case 2 [1] may work but if it doesn't case 3 [2] looks promising too. To fix add a type annotation to .form [3] or to .state [3].

     src/redux-fsm/state.js
 [1] 12│   | {|
     13│       state: "fruit_loading",
     14│       form: FruitForm
     15│     |}
 [2] 16│   | {|
     17│       state: "fruit_error",
     18│       form: FruitForm,
     19│       error: mixed
     20│     |}
Enter fullscreen mode Exit fullscreen mode

所以现在我们需要这样做:

switch (action.type) {
  case "SUBMIT_FRUIT":
    switch (reduxState.state) {
      case "initial":
        return {
          state: "fruit_loading",
          form: action.form
        };
      default:
        throw new Error("Inavlid transition");
    }
}
Enter fullscreen mode Exit fullscreen mode

我们会检查即将发生的动作、之前的状态,然后决定下一步该怎么做。这种方法迫使我们明确地考虑系统中的所有转换。

initial
  SUBMIT_FRUIT       -> fruit_loading (1)
  SUBMIT_FRUIT_ERROR -> ?             (2)
  SUBMIT_FRUIT_OK    -> ?             (2)
fruit_loading
  SUBMIT_FRUIT       -> fruit_loading (3)
  SUBMIT_FRUIT_ERROR -> fruit_error   (4)
  SUBMIT_FRUIT_OK    -> fruit_ok      (5)
fruit_error
  SUBMIT_FRUIT       -> fruit_loading (6)
  SUBMIT_FRUIT_ERROR -> ?             (7)
  SUBMIT_FRUIT_OK    -> ?             (7)
fruit_ok
  SUBMIT_FRUIT       -> fruit_loading (6)
  SUBMIT_FRUIT_ERROR -> ?             (7)
  SUBMIT_FRUIT_OK    -> ?             (7)
Enter fullscreen mode Exit fullscreen mode

附注:为什么要这样做?为了正式定义用户界面,证明用户界面逻辑中没有错误。例如:

附注 2:我在 reducer 中实现了“反向”有限状态机,它先检查 action,再检查 state。

(1,5)“正常”路径 - 用户提交表单并收到响应。
(1,4)错误路径 - 用户提交表单并收到错误。
(6)重复搜索 - 已有错误或成功响应,用户重复搜索。
(2)永远不会发生 - 我们可以假设这种情况永远不会发生,并在这种情况下抛出异常。(
7)竞态条件 - 我们已经有一个响应(或错误),但又收到一个新的响应,这种情况只有在我们允许同时存在多个副作用时才会发生。
(3)重复搜索 - 有一个搜索正在处理中,但用户请求不同的搜索,或者可能不耐烦地点击。这是一个有趣的情况。我们应该怎么做?我们可以:

  • 忽略它(也可以通过禁用按钮的视觉效果来传达此信息)。
  • 取消之前的请求并发起新的请求。
  • 启动一个新的进程,并忽略之前的进程。这基本上就是我们在“传统”方法中所做的,但这也会导致情况 (7),即竞态条件。此外,这种方法在 (1, 5) 和 (1, 4) 场景中也会引入竞态条件。

对于这篇文章,我选择忽略它,因为这是最简单的解决方案,也许我会在下一篇文章中实现取消功能。

这就是为什么要使用有限状态机(FSM)的原因,这种方法有助于发现逻辑中的“漏洞”。系统中的状态越多,隐藏的潜在漏洞就越多。

如果你觉得查找这类漏洞太麻烦,想想IT支持人员常问的问题:“你试过重启吗?”。没错,某个地方肯定隐藏着与系统状态相关的漏洞,而解决办法就是重启系统,将状态重置为初始状态。

来自IT圈的人说

另一方面,我也同意 JS(或者 Flow 或 TS)的语法对于这类任务来说有点笨拙。使用 switch 语句进行模式匹配不够简洁。Redux 需要的样板代码比传统方法更多。您怎么看?如果它需要的样板代码更少,您会使用它吗?

照片由 Dan Lohmar 在 Unsplash 上拍摄


本文是系列文章之一。请在推特GitHub上关注我

文章来源:https://dev.to/stereobooster/pragmatic-types-how-to-turn-redux-to-finite-state-machine-with-the-help-of-types-5f08