筆者最近發現了 xstate 這個狀態機的庫,查閱了相關資料,發現業界有一種趨勢是使用狀態機構建前端應用,十分有趣。其實,應用自己已是狀態機,可是在咱們日常編寫時,並無顯示的抽象出來,而只是在腦海裏構建一個流程,好比,點擊主頁上的這個按鈕,就打開一個彈窗,點擊彈窗關閉按鈕,就關閉彈窗並回到主頁面等等。javascript
可是,當咱們細細想一想,把上述流程,在草稿紙上畫個草圖,是否是會出現和下圖相似的流程圖?固然下圖專業名稱是叫狀態圖。其實它就是一個再簡單不過了的狀態機!css
所以當咱們構建一個複雜系統時,若是能夠準確使用狀態機描述,由狀態機的設計驅動應用的開發,會不會使得應用邏輯流程更清晰,更可控,更穩健呢?html
固然,這種場景的特殊性在哪裏?優點又在哪裏?又當如何實踐?本文將結合相關資料,一塊兒探討學習一下~前端
正文開始!java
有限狀態機簡稱 FSM(Finite State Machine)。FSM 是一個構建在多個狀態上的抽象機器,在相同的時間內,只會有一個特定的狀態被激活。狀態機須要經過觸發行爲從一個狀態過渡到另外一個狀態。
咱們熟悉的 Promise 也是一個狀態機,具備三個狀態:pending、resolved、rejected。簡單狀態圖以下:react
當咱們新建一個 Promise 時,默認處於 pending 狀態,當對其執行 resolve 行爲操做的時候,Promise 從 pending 狀態過渡到了 resolved 狀態。若是被 reject 的話,天然就會過渡到 rejected 狀態。留意下圖的** [[PromiseStatus]]**
git
現實生活中也有許多實際例子。好比地鐵站的旋轉閘門,投幣進去,則會打開閘門,當人通過閘門,推進旋起色械臂的時候,則會關閉閘門。以下:github
則如上狀態機存在兩個狀態:locked、unlocked,兩個變換行爲:POINT_COIN、PUSH_ARM。POINT_COIN 行爲將旋轉閘門狀態機從 locked 狀態過渡到 unlocked 狀態。PUSH_ARM 行爲同理,將 unlocked 狀態過渡到 locked 狀態。redux
相似這樣的狀態機的例子數不勝數,好比家裏的每一個電器其實也是,複雜如電視機、電磁爐,簡單如燈的開關,馬桶的沖水控制等等。走到大街上,交通指示燈也是一個狀態機,任意時間內,只會存在紅、綠、黃三種狀態之一,而且狀態之間符合特定的交通規則進行變換。promise
甚至於,人也是一種極其複雜的狀態機,給定一種刺激或多種刺激組合,也會觸發人從某種狀態過渡到另外一種狀態。只不過複雜程度極高,以致於現代科學徹底沒法解密這種狀態機。
概念瞭解的差很少了。那麼狀態機這種概念如何顯式的應用到前端開發中呢?
咱們能夠經過實現一個簡單的需求來一步一步的進行了解。假設產品經理須要咱們作一個登陸功能:
進入應用中,默認處於未登陸狀態默認顯示登陸表單,輸入用戶帳號、密碼後提交,會有一個登陸進行中的狀態。登陸成功後,表單消失,顯示一句歡迎文案,同時顯示登出按鈕;登陸若失敗,保持原登陸表單不變,顯示一句友好的登陸異常提示文案,容許用戶從新嘗試登陸。
相信大部分同窗們,看到這種需求就會露出自信的微信,內心默想「小 case」,而後就直接開擼。
可是,今天且慢!讓咱們先進行思考一下,打個草稿!
先思考全部可能存在的狀態。這個登陸需求,應用此時顯然至少存在如下三個狀態:
再思考應用的行爲。應用在任意時刻,只會處於其中任意一個狀態,但不一樣狀態的轉換,須要用行爲觸發狀態的過渡:
最後就是進入狀態時應該觸發的事件,好比設置用戶 token,更新提示消息等等,這些不屬於狀態變化,咱們能夠暫時不關注。
根據上述的描述,咱們藉助 XState Visualizer 生成狀態圖以下:
所以,將上述邏輯翻譯成狀態機,則代碼以下:
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 拉取,本地運行體驗效果。
你們能夠參考上述代碼和狀態圖,進行思考。
這裏想經過帶嘗試編寫實現最簡單的狀態機,從而加深對狀態機的理解。
根據狀態機的定義:
咱們先定義一個簡單的開關狀態機(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 應用的一種流行狀態管理範式。
關於狀態機,筆者閱讀了大量資料,本文不少內容也參考了不少優秀的博客、文檔。你們若是感興趣,更推薦直接閱讀下述參考資料進行深刻學習!畢竟這篇博文也不過是筆者學習了下述資料後「反芻」出來的知識而已,固然,其中部分代碼也是參考自如下資料。