logo陈三

XState 状态管理

by 陈三 on 
主题:  XStateReact.js

写 React 的人,大概是绕不过去 Redux。远在 React hooks 出现以前,我也用过好一阵 Redux 及它的周边,比如 redux saga。不过最终我还是放弃了它们,逐渐回归 React 本身提供的状态管理工具,比如 state、props、context。具体原因?大概是因为 redux 写着写着,发现我的代码们正在失控,则引入 redux 的意义也就不存在了。

至今我使用 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 状态:

{
  id: 'button',
  initial: 'enabled',
  states: {
    enabled: {
      initial: 'idle',
      states: {
        idle: {
          on: {
            CLICK: { // CLICK 事件发生时,切换至 loading 状态
              target: 'loading'
            }
          }
        },
        loading: {}
      }
    },
    disabled: {}
  }
}

下方是一个示例:

示例代码:

var Machine = XState.Machine;
var interpret = XState.interpret;
var buttonMachine = Machine({
  id: 'button',
  initial: 'enabled',
  states: {
    enabled: {
      initial: 'idle',
      states: {
        idle: {
          on: {
            CLICK: {
              target: 'loading'
            }
          }
        },
        loading: {}
      }
    },
    disabled: {}
  }
});
var btn = document.getElementById('btn');
var service = interpret(buttonMachine)
  .onTransition(function(state) {
    // 状态切换时执行此函数
    btn.innerHTML = '按钮(' + JSON.stringify(state.value) + ')';
  })
  .start();
btn.addEventListener('click', function() {
  service.send('CLICK');
});

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

我相信你为了阻止产品经理或测试人员疯狂的点击,曾写过类似的如下代码:

var isLoading = false;
document
  .getElementById('fetchUserButton')
  .addEventListener('click', function() {
    if (isLoading) return; // 你已经点击过了,且请求尚未完成,请再等等
    isLoading = true;
    window.fetch(userApi).then(
      () => {
        isLoading = false; // 请求结束,重置 isLoading
      },
      () => {
        isLoading = false; // 请求结束,重置 isLoading
      }
    );
  });

这四处出没的 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 中:

var 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)),
            // 演示作用,所以从结果中只取 5 个数据
            onDone: {
              // fulfilled 情况下,切换入 idle 状态
              target: 'idle'
            },
            onError: {
              // rejected 情况下,切换入 idle 状态
              target: 'idle'
            }
          }
        }
      }
    },
    disabled: {}
  }
});

提示:以下示例需要互联网连接,如果你在贵国境内无法访问 reddit API ,则可能妨碍你理解以下内容。

Context

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

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

var buttonMachine = Machine({
  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)状态:

    var 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: {}
      }
    });
    

    这太酷了!不是吗?

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

      parallel states

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

      Actor 模式

      我们的 statecharts 终究是会越来越大的。context 里的数据也会越来越多,揉合着各种临时变量,最终变得难以维护。

      在 React、Vue 等现代前端框架下,我们通过组件来隔离 states,同理,statecharts 也可以通过 Actor 模式来隔离 context 及状态。

      如果你熟悉 Erlang 或 Elixir,你可能已经了解 Actor 模式

      简单说,XState 下,我们可以借由 Actor 模式组件化状态机,这样化整为零、更方便我们解决问题 - 是了,这个思路与 React 等框架的组件化是一致的。

      我们在前面一个例子的基础上继续补充功能。这一次,我想在用户点击链接时弹出一个提示框,选择确认后才打开链接。等链接打开后,我们要从列表中移除链接。(仅为示例用途,请勿模仿)

      我们来定义一个新的状态机组件:

      import { sendParent } from 'xstate';
      var anchorMachine = Machine({
        initial: 'viewing', // 初始状态为 viewing
        context: {
          title: '', // 链接文本
          url: '' // 链接
        },
        states: {
          viewing: {
            initial: 'idle',
            states: {
              idle: {
                on: {
                  CLICK: {
                    // 点击时触发 CLICK 并进入 confirming 状态
                    target: 'confirming'
                  }
                }
              },
              confirming: {
                // 进入后调用 window.confirm 要求确认
                invoke: {
                  id: 'confirmOpen',
                  src: (context, event) =>
                    new Promise((resolve, reject) => {
                      if (window.confirm('确定打开链接 ' + context.title)) {
                        resolve();
                      } else {
                        reject();
                      }
                    }),
                  onDone: 'opening', // 确认则进入 opening 状态
                  onError: 'idle' // 取消则回到 idle 状态
                }
              },
              opening: {
                // 进入后打开链接
                invoke: {
                  id: 'openUrl',
                  src: (context, event) =>
                    new Promise(resolve => {
                      window.open(context.url);
                      resolve();
                    }),
                  onDone: {
                    target: 'idle',
                    actions: sendParent('URL_OPENED')
                    // 调用 sendParent 通知父状态机
                  },
                  onError: 'idle'
                }
              }
            }
          }
        }
      });
      

      然后在 buttonMachine 中调用 spawn 孵化 anchorMachine

      import { spawn } from 'xstate
      var buttonMachine = Machine({
        id: 'button',
        initial: 'enabled',
        context: {
          list: [],
          timer: 0
        },
        states: {
          enabled: {
            initial: 'idle',
            states: {
              idle: {
                entry: assign({ // 进入 idle 时触发
                  list: (context, event) =>
                    context.list.map(listItem => {
                      return {
                        ...listItem,
                        actorRef: spawn(
                          anchorMachine.withContext({ ...listItem.data })
                        ) // 将孵化的 actor 保存到 context.actorRef 里
                        // 这里我们还调用了 withContext
                        // 初始化 anchorMachine 的 context
                      };
                    })
                }),
                on: {
                  CLICK: {
                    target: 'loading'
                  },
                  URL_OPENED: { // 接收到子状态机发送的 URL_OPENED 事件
                    actions: [
                      assign({
                        list: (context, event) => {
                          return ([...context.list.filter(function(l) {
                            // 从 list 中移除 id 为 event.id 的链接
                            return l.data.id !== event.id
                          })])
                        }
                      })
                    ]
                  }
                }
              },
              // ...
            }
          },
          disabled: {}
        }
      });
      

      接下来,每个链接在渲染时可以直接从 actorRef 中获取它当前的状态及 context:

      const service = actorRef;
      var a = document.createElement('a');
      a.addEventListener('click', function(e) {
        e.preventDefault();
        service.send('CLICK');
      });
      service.onTransition(function(state) {
        a.setAttribute('href', service.state.context.url);
        a.innerHTML = service.state.context.title;
      });
      

      如果有需要,一些临时变量也可以维护在 actor 自己的 context 里,而非一股脑塞到父状态机的 context 下。

      至于它们之间的通信:

      1. 父 -> 子:在 send 函数中指定 to,譬如:send('EVENT', {to: context.list[0].actorRef})
      2. 子 -> 父:通过 sendParent,actor 可以发送事件给父状态机。

      下方是示例成果:

        安装 XState

        好了,你可能已经等不及想试试 XState 了:

        $ npm install xstate
        

        如果你用 React,则还可以安装 @xstate/react

        $ npm install @xstate/react
        

        Vue 用户?

        $ npm install @xstate/vue
        

        不过,XState 其实没有限定任何前端框架,你可以在 React、Vue、Ember 甚至原生 JavaScript 中使用它,@xstate/react@xstate/vue 只是提供了些小帮手,让你更快地将 XState 集成到你熟悉的前端框架中。