XState 状态管理

我们在用 react 写 ui 界面的时候,总会掺杂许多的 js 表达式,这是 jsx 强大的地方,但是这让描述界面和控制混合起来,而降低了可读性和编写体验,我们既要去管理样式结构, 又要去管理各种状态。同时如果遇到注册登录或者发帖之类的地方我们需要编写许多的模板代码来对现在所处的状态进行判断 (eg. isLoading isLogined isCaptched ...) 然后他们之间又有许多的关联关系,比如在向后端提交注册登录时要先去判断用户是否通过验证码,是否填写了正确的内容之类的事情。然后社区中涌现了 XState 这种有限状态机的解决方案,下面就简单介绍一下他。

🔍 如何定义状态

一个按钮,有两种状态:

  1. 启用
  2. 禁用

我们在 redux 下建模时通常会赋予它一个真假值的属性,比如 isEnabled

{
  isEnabled: true; // true 为启用状态,false 为禁用状态
}

假设点击按钮后,浏览器会从服务器加载一些数据回来,则在启用、禁用状态之外,按钮还应该有一个 loading 状态。于是它又多出一个属性,且叫它 isLoading

{
  isEnabled: true,
  isLoading: false, // 新增 isLoading
}

然而这个模型有个非常明显的缺陷:isLoading 的状态其实从属于 isEnabled: true 的,只有在按钮启用状态下,我们才有可能进入 isLoading: true 状态,但上述模型并不能看出这个关系,我们只看到四种可能的组合:

// 4 种可能的状态组件
[
  {
    isEnabled: true,
    isLoading: true
  },
  {
    isEnabled: true,
    isLoading: false
  },
  {
    isEnabled: false,
    isLoading: true
  },
  {
    isEnabled: false,
    isLoading: true
  }
];

再来几个 isXyz?则上述组合会指数级增长。

我们另有一种建模方案:

{
  initial: 'enabled', // 按钮默认启用
  states: {
    enabled: {},
    disabled: {}
  }
}

这里我们给按钮定义了两种状态,分别是 enableddisabled,初始状态为 enabled

接着再引入 loading 状态:

{
  initial: 'enabled',
  states: {
    enabled: {
      initial: 'idle',
      states: {
        idle: {},
        loading: {} // loading 状态现在从属于 enabled 状态
      }
    },
    disabled: {}
  }
}

这里,在 enabled 状态下,我们细化了状态,分离出 idleloading 两种子状态,于是 loadingenabled 间的从属关系一目了然。甚至,我们可以借由这样的数据绘制出谁都看得懂的图表

按钮 statecharts

这第二种建模方法,正是这一篇想要介绍的 statecharts - 如果你了解状态机,则不妨将 statecharts 理解为状态机的扩展,它解决了状态机状态易于爆炸的缺陷,使之真正可用。而 XState 则是 statecharts 的一个 JavaScript 实现。

对比两种建模方法,我们可以看到:

  1. 第一种建模方法里,所有的状态都是平行的,没有应有的从属关系,十分混乱,且每个新状态的引入都会迅速增加状态管理的难度;
  2. 借由 statecharts ,我们可以不断细分、深化状态,只要有需要,我们就可以源源不断引入新状态,不用担心状态失控。

💽 状态切换

在 statecharts 中,状态是有限的(finite)。拿上述按钮来说,在最外层,它只有两种状态,并且只能在这两种状态间切换。进入 enabled 状态后,则有两个子状态,idleloading,默认为 idle,可以通过点击切换至 loading 状态:

import React from "react";
import { useMachine } from "@xstate/react";
import { Machine } from "xstate";

const toggleMachine = Machine({
  id: "button",
  // 初始化进入 enabled 状态
  initial: "enabled",
  states: {
    enabled: {
      initial: "idle",
      states: {
        idle: {
          on: {
            CLICK: {
              // CLICK 事件发生时,切换至 loading 状态
              target: "loading"
            }
          }
        },
        loading: {}
        // loading 状态不接收 **CLICK** 动作
      }
    },
    disabled: {}
  }
});

export default function App() {
  const [state, send] = useMachine(toggleMachine);

  return (
    <button onClick={() => send("CLICK")}>{JSON.stringify(state.value)}</button>
  );
}

你会发现按钮的状态从默认的 {enabled: 'idle'} 切换至 {enabled: 'loading'},随后我们不管怎么点击,按钮都不再响应,这是因为 loading 状态不接受 click 动作。

这就能避免用户多次点击而造成的重复发帖,而不会出现四处出没的 isLoading 相比之下,statecharts 的代码则十分优雅,因为状态与状态之间有道天然屏障。

💊 副作用

进入 loading 状态后,就要启动 window.fetch 从 API 读取数据了。可是这异步操作的代码要写在哪儿?

我们知道,fetch 是一个 Promise,它启动时是 pending 状态,最后会进入 fulfilled 或 rejected 状态。没错,Promise 也是一个状态机:

{
  initial: 'pending',
  states: {
    pending: {
      RESOLVE: {
        target: 'fulfilled'
      },
      REJECT: {
        target: 'rejected'
      }
    },
    fulfilled: {},
    rejected: {}
  }
}

通过 XState 提供的 invoke,我们可以将它无缝接入 statecharts 中:

import React from "react";
import { useMachine } from "@xstate/react";
import { Machine } from "xstate";

const buttonMachine = Machine({
  id: "button",
  initial: "enabled",
  states: {
    enabled: {
      initial: "idle",
      states: {
        idle: {
          on: {
            CLICK: {
              target: "loading"
            }
          }
        },
        loading: {
          invoke: {
            id: "fetchData",
            src: (context, event) =>
              window
                .fetch("https://www.reddit.com/r/javascript.json")
                .then(resp => resp.json())
                .then(json => json.data.children.slice(0, 5))
                .then(r => console.log(r)),
            // 演示作用,所以从结果中只取 5 个数据
            onDone: {
              // fulfilled 情况下,切换入 idle 状态
              target: "idle"
            },
            onError: {
              // rejected 情况下,切换入 idle 状态
              target: "idle"
            }
          }
        }
      }
    },
    disabled: {}
  }
});

export default function App() {
  const [state, send] = useMachine(buttonMachine);

  return (
    <button onClick={() => send("CLICK")}>{JSON.stringify(state.value)}</button>
  );
}

📃 Context

显然,你要问了,加载回来的数据呢?存放在哪?怎么存?

我们在前面曾说过,statecharts 里,状态(state)是有限的(finite),这与 React 或是 Vue 不一样,它们仅区分 state 与 props,state 本身并不区分有限或无限。而 XState 下,除开有限的状态外,我们还可能拥有无限的数据,譬如前面我们从 reddit 加载回来的数据,它们将归入 context 中:

{
  id: 'button',
  initial: 'enabled',
  context: {
    list: [] // 数据存在 context 下
  },
  states: {
    enabled: {
      initial: 'idle',
      states: {
        idle: {
          on: {
            CLICK: {
              target: 'loading'
            }
          }
        },
        loading: {
          invoke: {
            id: 'fetchData',
            src: (context, event) =>
              window
                .fetch('https://www.reddit.com/r/javascript.json')
                .then(resp => resp.json())
                .then(json => json.data.children.slice(0, 5)),
            onDone: {
              target: 'idle',
              actions: assign({
                // 获取到数据后调用 XState.assign 将数据存入 context.list
                list: (context, event) => event.data
              })
            },
            onError: {
              target: 'idle'
            }
          }
        }
      }
    },
    disabled: {}
  }
}

invokeassign?这都哪里来的?不不不,这些并非 XState 作者异想天开或是拍一下脑袋出来的,实际上,Statecharts 有一份推荐状态的 w3c 规范 SCXML,invoke、assign 正是该规范中定义的。

🍴 并行状态

现在我们要给上述按钮加个读秒的功能,以便了解 API 响应的速度。显然,这个状态跟 fetchData 应该是同时启动的,因此我们可以定义一个并行(parallel)状态:

import React from "react";
import { useMachine } from "@xstate/react";
import { Machine, assign } from "xstate";

const buttonMachine = Machine({
  id: "button",
  initial: "enabled",
  context: {
    list: [],
    timer: 0 // 在 context 中保存计时
  },
  states: {
    enabled: {
      initial: "idle",
      states: {
        idle: {
          on: {
            CLICK: {
              target: "loading"
            }
          }
        },
        loading: {
          entry: assign({
            // 进入 loading 状态时重置 timer
            timer: 0
          }),
          type: "parallel", // 注意这个 parallel
          // 表明 loading 下所有 states 是并行的
          states: {
            fetching: {
              // 一方面我们启动数据读取
              invoke: {
                id: "fetchData",
                src: (context, event) =>
                  window
                    .fetch("https://www.reddit.com/r/javascript.json")
                    .then(resp => resp.json())
                    .then(json => json.data.children.slice(0, 5)),
                onDone: {
                  target: "#button.enabled.idle",
                  actions: assign({
                    list: (context, event) => event.data
                  })
                },
                onError: {
                  target: "#button.enabled.idle"
                }
              }
            },
            counting: {
              // 另一方面我们启动读秒
              after: {
                // 延时 1ms 后的状态切换
                1: {
                  target: "counting",
                  actions: assign({
                    timer: (context, event) => context.timer + 1 // 续 1ms
                  })
                }
              }
            }
          }
        }
      }
    },
    disabled: {}
  }
});

export default function App() {
  const [state, send] = useMachine(buttonMachine);

  return (
    <>
      <button onClick={() => send("CLICK")}>
        {JSON.stringify(state.value.enabled)}
      </button>
      <ol>
        {state.context.list.map(item => (
          <li key={item.data.id}>{item.data.title}</li>
        ))}
      </ol>
      <span>{state.context.timer}</span>
    </>
  );
}

这太酷了!不是吗?

如果你一头雾水,Don't panic,正如前面我说过,statecharts 是能够绘图的:

parallel states

现在是不是一目了然?甚至你还可以在可视化图例上点击触发各类事件,以查看状态切换的情况。

📕 参考

https://blog.zfanw.com/xstate-state-management/ (没有做太多改动)