探索使用有限状态机(FSM)构建 Web 应用

3,691 阅读14分钟

前言 👀

笔者最近发现了 xstate 这个状态机的库,查阅了相关资料,发现业界有一种趋势是使用状态机构建前端应用,十分有趣。其实,应用本身已经是状态机,但是在我们平常编写时,并没有显示的抽象出来,而只是在脑海里构建一个流程,比如,点击主页上的这个按钮,就打开一个弹窗,点击弹窗关闭按钮,就关闭弹窗并回到主页面等等。

但是,当我们细细想想,把上述流程,在草稿纸上画个草图,是不是会出现和下图类似的流程图?当然下图专业名称是叫状态图。其实它就是一个再简单不过了的状态机!

image.png

因此当我们构建一个复杂系统时,如果可以准确使用状态机描述,由状态机的设计驱动应用的开发,会不会使得应用逻辑流程更清晰,更可控,更稳健呢?

当然,这种场景的特殊性在哪里?优势又在哪里?又当如何实践?本文将结合相关资料,一起探讨学习一下~

正文开始!

有限状态机(FSM)

有限状态机简称 FSM(Finite State Machine)。FSM 是一个构建在多个状态上的抽象机器,在相同的时间内,只会有一个特定的状态被激活。状态机需要通过触发行为从一个状态过渡到另一个状态。

我们熟悉的 Promise 也是一个状态机,具有三个状态:pending、resolved、rejected。简单状态图如下:

image.png

当我们新建一个 Promise 时,默认处于 pending 状态,当对其执行 resolve 行为操作的时候,Promise 从 pending 状态过渡到了 resolved 状态。如果被 reject 的话,自然就会过渡到 rejected 状态。留意下图的** [[PromiseStatus]]**

image.png

现实生活中也有许多实际例子。比如地铁站的旋转闸门,投币进去,则会打开闸门,当人经过闸门,推动旋转机械臂的时候,则会关闭闸门。如下:

image.png

则如上状态机存在两个状态:locked、unlocked,两个变换行为:POINT_COIN、PUSH_ARM。POINT_COIN 行为将旋转闸门状态机从 locked 状态过渡到 unlocked 状态。PUSH_ARM 行为同理,将 unlocked 状态过渡到 locked 状态。

类似这样的状态机的例子数不胜数,比如家里的每个电器其实也是,复杂如电视机、电磁炉,简单如灯的开关,马桶的冲水控制等等。走到大街上,交通指示灯也是一个状态机,任意时间内,只会存在红、绿、黄三种状态之一,并且状态之间符合特定的交通规则进行变换。

甚至于,人也是一种极其复杂的状态机,给定一种刺激或多种刺激组合,也会触发人从某种状态过渡到另一种状态。只不过复杂程度极高,以至于现代科学完全无法解密这种状态机。

状态机驱动应用

概念了解的差不多了。那么状态机这种概念如何显式的应用到前端开发中呢?

我们可以通过实现一个简单的需求来一步一步的进行了解。假设产品经理需要我们做一个登录功能:

进入应用中,默认处于未登录状态默认显示登录表单,输入用户账号、密码后提交,会有一个登录进行中的状态。登录成功后,表单消失,显示一句欢迎文案,同时显示登出按钮;登录若失败,保持原登录表单不变,显示一句友好的登录异常提示文案,允许用户重新尝试登录。

相信大部分同学们,看到这种需求就会露出自信的微信,心里默想“小 case”,然后就直接开撸。

但是,今天且慢!让我们先进行思考一下,打个草稿!

先思考所有可能存在的状态。这个登录需求,应用此时显然至少存在以下三个状态:

  • loggedOut 状态,表示未登录、或退出登录的情况;
  • loading 状态,表示登录请求发生,加载进行中的情况;
  • loggedIn 状态:,表示登录成功的情况下;

再思考应用的行为。应用在任意时刻,只会处于其中任意一个状态,但不同状态的转换,需要用行为触发状态的过渡:

  • loggedOut 在用户提交信息(SUBMIT)后,会进入 loading 状态;
  • 进入 loading 状态时,先检查表单是否合法,若非法,则回滚到 loggedOut 状态;(称为 conds)
  • 进入 loading 状态后,执行 login 请求(也称作 services),此时触发两种分支:done/error。done 代表 login 成功,因此进入 loggedIn 状态;error 代表 login 失败,回到 loggedOut 状态。
  • 在 loggedIn 状态中,用户点击登出按钮(LOGOUT),则会退回到 loggedOut 状态。

最后就是进入状态时应该触发的事件,比如设置用户 token,更新提示消息等等,这些不属于状态变化,我们可以暂时不关注。

根据上述的描述,我们借助 XState Visualizer 生成状态图如下:

image.png

因此,将上述逻辑翻译成状态机,则代码如下:

import { postUserAuthData } from './util';
import { Machine, assign } from "xstate";

const setUserToken = assign({
  token: (_ctx, evt) => {
    return evt.data.data.token;
  },
});

const clearUserToken = assign({
  token: (_ctx, _evt) => {
    return null;
  },
});

const updateTipMsg = assign({
  tipMsg: (ctx, evt) => {
    if (formIsInvalid(ctx)) {
      return "form invalid";
    }
    if (evt.type === "LOGOUT") {
      return "logout ok";
    }
    return evt.data.msg;
  },
});

const updateUserFormData = assign({
  account: (_ctx, evt) => {
    return evt.account;
  },
  password: (_ctx, evt) => {
    return evt.password;
  },
});

function formIsInvalid(ctx, _evt) {
  return !(ctx.account && ctx.password);
}

export default Machine(
  {
    id: "auth",
    initial: "loggedOut",
    context: {
      account: null,
      password: null,
      token: null,
      tipMsg: "",
    },
    states: {
      loggedOut: {
        on: {
          SUBMIT: {
            target: "loading",
            actions: "updateUserFormData",
          },
        },
      },
      loading: {
        on: {
          "": {
            target: "loggedOut",
            actions: "updateTipMsg",
            cond: "formIsInvalid",
          },
        },
        invoke: {
          src: "login",
          onDone: {
            target: "loggedIn",
            actions: ["setUserToken", "updateTipMsg"],
          },
          onError: {
            target: "loggedOut",
            actions: ["clearUserToken", "updateTipMsg"],
          },
        },
      },
      loggedIn: {
        on: {
          LOGOUT: {
            target: "loggedOut",
            actions: ["clearUserToken", "updateTipMsg"],
          },
        },
      },
    },
  },
  {
    services: {
      login: (ctx, _evt) => {
        return postUserAuthData({
          account: ctx.account,
          password: ctx.password,
        });
      },
    },
    actions: {
      setUserToken,
      clearUserToken,
      updateTipMsg,
      updateUserFormData,
    },
    guards: {
      formIsInvalid,
    },
  }
);

基于上述状态机代码,可以使用 @xstate/react 使用 useMachine 应用 authMachine,将状态机应用到实际生产中,也即是关联到 React 组件,代码如下:

import React, { useRef } from "react";
import { curry } from "lodash";
import { useMachine } from "@xstate/react";
import { Modal, Button, Input, Divider, Row, Col } from "antd";
import authMachine from "./authMachine";

export default function AuthModal() {
  const [state, send] = useMachine(authMachine);
  const authContext = state.context;
  const userMsg = useRef({
    account: "",
    password: "",
  });

  function submit() {
    send("SUBMIT", userMsg.current);
  }
  function loggout() {
    send("LOGOUT");
  }

  function updateUserMsg(type, e) {
    userMsg.current = {
      ...userMsg.current,
      [type]: e.target.value,
    };
  }

  const updateAccount = curry(updateUserMsg)("account");
  const updatePassword = curry(updateUserMsg)("password");

  return (
    <>
      <h1 className="state">Machine state: {state.value}</h1>
      {authContext.tipMsg && <p className="tip-msg">tips: {authContext.tipMsg}</p>}
      {state.value === "loggedIn" && <Button onClick={loggout}>Logout</Button>}
      <Modal
        title="Login"
        closable={false}
        mask={false}
        width={400}
        visible={state.value === "loggedOut"}
        footer={<Button onClick={submit}>Submit</Button>}
      >
        <Row>
          <Col span={6}>
            <span className="sub-title">Account:</span>
          </Col>
          <Col span={18}>
            <Input placeholder="please enter account" defaultValue={userMsg.account} onChange={updateAccount} />
          </Col>
        </Row>
        <Divider orientation="left" style={{ color: "#333", fontWeight: "normal", fontSize: "12px" }}>
          Fill Password
        </Divider>
        <Row>
          <Col span={6}>
            <span className="sub-title">Password:</span>
          </Col>
          <Col span={18}>
            <Input.Password placeholder="please enter password" defaultValue={userMsg.pwd} onChange={updatePassword} />
          </Col>
        </Row>
      </Modal>
    </>
  );
}

基于 useMachine 钩子,我们可以 send('SUBMIT') 来将状态从 loggedOut 过渡到 loading,然后再由里面的 guards 和 services 来确定过渡到 loggedIn 或 loggedOut;send('LOGOUT') 来将状态从 loggedIn 过渡到 loggedOut。

在状态机设计下,应用绝对不会同时处于两个状态,也不会有 isLoading、isFetch、isLoggedIn、isLoggedOut、isModalShow 等等一大堆我们平常会不自觉使用的布尔值。应用的逻辑链路变得更加清晰。当然,不了解 xstate 的情况下,可能上述代码看的会比较懵。因此上述代码笔者都已经放上了 Github。建议大家可以戳:react-state-machine-demo 拉取,本地运行体验效果。

大家可以参考上述代码和状态图,进行思考。

状态机实现原理

这里想通过带尝试编写实现最简单的状态机,从而加深对状态机的理解。

根据状态机的定义:

  • 当状态机开始执行时,它会自动进入初始化状态(initial state)。
  • 每个状态都可以定义,在进入(onEnter)或退出(onExit)该状态时发生的行为事件(actions),通常这些行为事件会携带副作用(side effect)。
  • 每个状态都可以定义触发转换(transition)的事件。
  • 转换定义了在退出一个状态并进入另一个状态时,状态机该如何处理这种事件。
  • 在状态转换发生时,可以定义可以触发的行为事件,从而一般用来表达其副作用。

我们先定义一个简单的开关状态机(togglerMachine),该状态机仅有两个状态:inactive、active,以及有 TOGGLE 的 transition 进行状态转换、还有该状态的进入、离开的钩子(onEnter、onExit)。具体如下:

const togglerMachine = createMachine({
  initial: "inactive",
  inactive: {
    on: {
      TOGGLE: {
        target: "active",
        action() {
          console.log('transition action for "TOGGLE" in "active" state');
        },
      },
    },
    actions: {
      onEnter() {
        console.log("inactive: onEnter");
      },
      onExit() {
        console.log("inactive: onExit");
      },
    },
  },
  active: {
    on: {
      TOGGLE: {
        target: "inactive",
        action() {
          console.log('transition action for "TOGGLE" in "inactive" state');
        },
      },
    },
    actions: {
      onEnter() {
        console.log("active: onEnter");
      },
      onExit() {
        console.log("active: onExit");
      },
    },
  },
});


因此我们的目标,实现一个函数 createMachine 简单实现如下:

function createMachine(machineDef) {
  function transition(state, type) {
    const stateDef = machineDef[state];
    const nextStateDef = stateDef.on[type];
    const value = nextStateDef.target;

    nextStateDef.action();
    machineDef[state].actions.onExit();
    machineDef[value].actions.onEnter();
    machine.value = value;

    return value;
  }

  const machine = {
    value: machineDef.initial,
    transition,
  };

  return machine;
}

通过根据定义,实现了上述 createMachine,然后执行以下代码:

let state = togglerMachine.value;
console.log(`current state: ${state}`); // current state: inactive
state = togglerMachine.transition(state, "TOGGLE");
console.log(`current state: ${state}`); // current state: active
state = togglerMachine.transition(state, "TOGGLE");
console.log(`current state: ${state}`); // current state: off


可以得到以下输出:

current state: inactive
transition action for "TOGGLE" in "active" state
inactive: onExit
active: onEnter
current state: active
transition action for "TOGGLE" in "inactive" state
active: onExit
inactive: onEnter
current state: inactive


当然上述的状态机实现可以简单的表达其基本原理,但其实在 xstate 中,状态机是纯净不可变的,想要真正进行应用开发,需要 interpret(togglerMachine) 解析出一个服务(service),通过向服务发送事件进行状态转换,同时监听状态转换来表达副作用。

示例如下:

const toggleService = interpret(togglerMachine)
  .onTransition((state) => console.log(state.value))
  .start();

toggleService.send("TOGGLE");
// => 'active'

toggleService.send("TOGGLE");
// => 'inactive'


当然就这么看,这个实现可以说很简陋的,但也是通过这个实现,从而让我们认识到状态机并不是黑魔法!对吧。

基于模型的测试、统计

当然状态机有一个很诱人的优点,就是可以进行基于模型的自动化测试。我们可以理解为状态机是一个很高级、复杂的数据结构,而这个数据结构和“图”有点类似。每一个用户行为就相当于是图中某个端点到另一个端点的路径,也就是说,相当于是状态机的从某个状态到另一个状态的所有转换行为。

所以基于这种模式下,可以简单理解为,有了状态机的指导,所有用户行为路径将会自动枚举完成,从而可以覆盖到所有可能会触发应用异常的边界条件。而且在应用更新后,也无需重写测试,只想在原测试基础上补充即可。

除了测试,我们还可以监听应用状态变化,从而用作用户行为统计分析,从而得出整个应用的用户行为轨迹。这个比我们常见的埋点更加全面、也更加智能。

当然篇幅所致,笔者计划下一篇博客再详细探讨一下状态机的模型测试,这里就不继续展开了。

提问与思考 🤔

构建大型应用时,应用中可能存在许多平行的状态机,或者具有层次的状态机(可以理解为有点像兄弟组件、父子组件)。意味着每个组件都可以拥有自己独有的状态机,同时整体上也可以与应用的核心状态机相关联,两者之间并无影响。

那同学们也许会问:学习状态机有用么?状态机什么时候使用,又该在什么场景适合使用?和已有的 redux、dva、mobx 等状态管理工具是不是会有冲突?如何运用到实际项目?

笔者也是刚刚了解学习,在状态机上并无实际项目经验,但笔者搜集相关资料以及思考后,或许可以尝试解答,给出以下几个合理的建议:

学习状态机有用么?第一,正如前文所示,状态机其实无所不在,我们开发者只是“不知庐山真面目,只缘身在此山中”而已。应用中必然,或许严谨来说,大概率存在使用了状态机的情况,只是我们不自知而已,因此学习状态机的概念有助于我们加深对应用设计的理解。第二,设计状态机的过程,本质上也是设计应用的过程。应用状态应该是可枚举的,如果一个应用的所有状态能用状态图设计清楚,成为一个条理清晰的状态机,那么应用的 bug 应该会相应减少不少。

状态机适合在什么时候、什么场景使用?状态机不应该滥用,毕竟设计、构建应用没有一劳永逸的方法,状态机也有使用成本问题。第一,针对较简单的交互场景,无需使用 xstate 这种大型的状态机管理库,否则反而会提升复杂度,但是我们可以使用状态机的思想去重构小型的交互逻辑。第二,当组件代码中出现大量的 isLoading、isFetching、isModalShow、isVisible 等等布尔值状态时,此时很有可能适合用状态机重构。(就算不使用状态机,也适合用 enum 将状态枚举进行重构),关于原因可以参考阅读这篇文章:Stop using isLoading booleans | Kent C. Dodds

状态机和已有状态管理库会有冲突么?理论上没有冲突,状态机概念甚于技术实现本身。同时,笔者个人觉得状态机不一定适用于管理整个大型应用的状态(从成本和复杂度上考虑),但是二者的融合或许是一个有趣的话题。

如何运用到实际项目?行动起来,发现应用中存在有价值重构的地方,就去使用状态机的概念重构即可。走出第一步,也比停在原地犹豫更好。笔者或许也会在下一个项目中局部应用此技术。

小结

xstate 的作者 David Khourshid 在介绍状态机时,有一个有趣的比喻让笔者很印象深刻,他将状态机比喻为五线谱,将开发者们比喻为作曲家,因此应用设计应该和作曲一样,都是逻辑化、抽象化的。什么意思呢?比如作曲家将自己的灵思写成了曲谱,而在曲谱中设计了旋律、节奏、和声(音乐三要素),但却没有局限表达形式,因此任何音乐家拿到曲谱,都可以用自己的方式表达这一首曲子(比如交响乐队,或者人声,或者电子)。而类比过来,我们开发者设计应用,将应用的状态、行为、副作用勾勒出骨架,也即是状态机,但却不局限用任何语言、框架去表达,在此基础上,我们可以用 React、Vue 或者原生去实现应用。

简而言之,状态机是优雅的、抽象的、同时也是强大的,每一个应用都有内在的状态机(大多是隐含的),而将其抽象出来是很有价值的。笔者认为状态机的应用,极有可能会成为接下来几年内编写 Web 应用的一种流行状态管理范式。

关于状态机,笔者阅读了大量资料,本文很多内容也参考了很多优秀的博客、文档。大家如果感兴趣,更推荐直接阅读下述参考资料进行深入学习!毕竟这篇博文也不过是笔者学习了下述资料后“反刍”出来的知识而已,当然,其中部分代码也是参考自以下资料。

最后,谢谢大家的阅读~

参考资料