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

前言 👀

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

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

image.png

所以当咱们构建一个复杂系统时,若是能够准确使用状态机描述,由状态机的设计驱动应用的开发,会不会使得应用逻辑流程更清晰,更可控,更稳健呢?html

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

正文开始!java

有限状态机(FSM)

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

咱们熟悉的 Promise 也是一个状态机,具备三个状态:pending、resolved、rejected。简单状态图以下:react

image.png

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

git

image.png

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

image.png

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

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

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

状态机驱动应用

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

咱们能够经过实现一个简单的需求来一步一步的进行了解。假设产品经理须要咱们作一个登陆功能:

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

相信大部分同窗们,看到这种需求就会露出自信的微信,内心默想“小 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 应用的一种流行状态管理范式。

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

最后,谢谢你们的阅读~

参考资料

相关文章
相关标签/搜索