探索使用有限狀態機(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 應用的一種流行狀態管理範式。

關於狀態機,筆者閱讀了大量資料,本文不少內容也參考了不少優秀的博客、文檔。你們若是感興趣,更推薦直接閱讀下述參考資料進行深刻學習!畢竟這篇博文也不過是筆者學習了下述資料後「反芻」出來的知識而已,固然,其中部分代碼也是參考自如下資料。

最後,謝謝你們的閱讀~

參考資料

相關文章
相關標籤/搜索