xstate 初探: xstate + react & pixi.js 實作簡易遊戲模型
幾年前曾經在專案上用過 javascript-state-machine 作為專案狀態管理的核心流程,該 libray 也是以 有限狀態機 為架構核心,不過同樣以 FSM 為核心的 xstate 強大超過不止一個等級。去年也曾在 jsdc 2019 看作者本人來台 演講 xstate 的相關內容。
簡言之,xstate 是一個實作 有限狀態機 並使用 statecharts 將流程狀態視覺化的狀態管理 library.
react / redux 的 state 痛點
雖然在 react 有 setState / useState / useReducer 等各種設定狀態資料的方式, redux 有 reducer 合併的 store state . 但這兩個設計其實存在了幾個常見的問題:
- react state 的設計模式很難從概觀看出所有定義的 state 有哪些,開發者可輕易從 setState 塞進各種資料而造成各種日後 side effect 埋雷的可能,或者造成 refactor 的困難 — 尤其在越多的 state 變化需要判定的情況下。例如多個
isLoading
/isEnabled
/isInitialized
的 reducer, 在數個集合的狀態組合下很難看出與 UI 的狀態切換 (enabled / disabled )的關聯性。 - 較難直觀了解 state 之間關聯及順序 : 以 react /redux 來說,一但系統的複雜性越高, 就越難避免將狀態變化跟資料混雜在一起。也較難直接看出每一個 state 變化的相依性及順序。舉例來說,紅綠燈在一個 reducer function 像是如下範例:
假設這個紅綠燈的狀態變化正確順序為 紅 -> 黃 -> 綠 -> 紅 -> 黃 -> 綠 …在不熟規則的情況下就有可能在紅燈狀態 dispatch CHANGE_TO_YELLOW 的 action 而導致錯誤的順序更新。
使用 XState 的好處
- 透過 fsm 的核心設計強迫實作者把各種狀態流程及相關順序完整規劃出來藉以釐清各種狀態,並透過 states & context 達到狀態(state)與資料(context)分離。
- 透用 stateschart visualizer 產出狀態視覺互動 flow 方便與非程式人員討論。
- 已規劃好的 state 路徑設計完成後,可透過資料分析對路徑做統計及規劃,找出對使用者更常用更便捷的路徑,提高使用者體驗。
- 易於跟不同實作的 UI Layer 耦合或遷移,因為狀態跟資料都統一由 xstate fsm 控管,也能讓 UI component 內部大多專注於 UI 自己應有的行為而不需管理過多的 local state.
利用 xstate 實作簡單的抽獎遊戲
一個簡單的抽獎遊戲:使用者每次下注會扣除 10 元,隨機機率中獎 10 元。以 fsm 為狀態處理架構,並實作 react
跟 pixi.js
兩個版本的 UI Layer.
1. 建立一個基本 Machine,規劃 states / eventTypes 流程並產生 Visualizer Graph.
以一個簡易的抽獎機為例, 大致上會有以下幾個步驟:
- 初始階段等待 login 完成的
UNINITIALIZED
state. - 進入可以操作下注的的
IDLE
state. - 若下注檢查通過,玩家下注後送出 request 後等待 server response 的
BET_REQUESTING
state. 檢查不過則進入BET_NOT_ENOUGH
state, 然後再回到IDLE
state. - 拿到 server response 後演繹抽獎動畫的
TRANSITION_EFFECTS_PLAYING
state. - 中獎則演繹抽獎動畫的
WIN_EFFECTS_PLAYING
state,最後或直接回到IDLE
state.
要點:
- 定義相關 states & eventTypes.
- 設定 machine config 給
Machine
factory function 建立出 instance. - 使用
interpreter
來建立 service 發送事件或取得更新的 state transition. - state 切換依靠 send event: 從 state 切換至下一個 state,如果是同層的 state, 則發送 event target: eventType 即可, 但如果從 nested state 跳至上層 state, 則需指定 state id.
send event by type:
// UNINITIALIZED state -> IDLE state
// 透過 eventTypes.LOGIN_SUCCEED trigger[states.UNINITIALIZED]: {
on: {
[eventTypes.LOGIN_SUCCEED]: states.IDLE,
},
}
send event by state id:
// IDLE.NORMAL state -> PLATING state
// 透過 target state id trigger.[states.NORMAL]: {
on: {
[eventTypes.PLAY]: '#playing',
},
},
2. 定義 context,使用 invoking services 銜接 server 端資料來源,在 state on: event 被 trigger 時使用 assign actions 更新 context.
要點:
- 規劃 context 的一般資料: username / user balance / win balance, 並將所需的 socket service 也注入到 context.
....const machine = Machine({
id: 'lotteryMachine',
initial: states.UNINITIALIZED,
context: {
username: '',
balance: 0,
win: 0,
socketService: new SockerService(),
},....
- 使用 invoking services 串接後端資料來源:一般的 fetch / ajax call 可用 invoking promises, 不過這邊的範例資料對象是 socket , 改用 invoking callbacks 更為適合。
與 socket 串接的 invoking callbacks 放在 machine config 最上層,所以會在
interpreter
建立的 service 執行stop
時才會執行回收函數。
- 監聽 socker service 並透過呼叫 callback 送出 event 及更新 server 端資料到 context.
- 撰寫 actions, 並透過
assign action
更新 context 資料:
以 UNINITIALIZED -> IDLE state transition 為例:
- socket service 拿到的使用者資料會在送出
LOGIN_SUCCEED
事件時更新 username / balance context data. - state on property 送出 event -> state transition 的設定增加
assign action
update context.
// 原本的版本
[eventTypes.LOGIN_SUCCEED]: states.iDLE,//----------------------------------------------------------// 增加 action assign 後:// 定義 actions
const actions = {
updateUserLoginData: assign({
username: (ctx, event) => (event.username),
balance: (ctx, event) => (event.balance), })};....
// 設定 actions[eventTypes.LOGIN_SUCCEED]: {
target: states.iDLE,
actions: ['afterLoginSucceed']},
- 使用
guards condition
檢查玩家下注前餘額是否足夠:cond
能拿到當前的 context 跟 event data 並做檢查, 回傳 true | false 來決定是否滿足到下一個 transition.
3. 實作銜接 react UI 部分
使用 @xstate/react 所提供的 useService
hook 提供 current state 跟 send function. 每個 react component 藉由 current state 取的相關 state 跟 context 做相對應處理。
以 Bet Button 下注按鈕為例:
- 只開放在
IDLE
狀態可以點選。 - 玩家點選後 send play event 及下注金額資料切換 state.
以 User Panel 為例:
- 在從
UNINITIALIZE
到IDLE
狀態時更新一次 username 跟 balance. - 在
BET_REQUESTING
玩家下注時 balance 扣除下注金。 - 玩家若中獎, 在
WIN_EFFECTS_PLAYING
才會更新 balance.
要點:
- 判定多個 states 條件: 使用
array.some
+state.matches
if (['idle', 'playing']).some(current.matches)) { // do something..
}
4. 實作銜接 pixi.js UI 部分
pixi.js 與 xstate 銜接的方式其實跟 react class component 很類似,只要取得 service instance 則可以使用 onTransition
監聽每次 state 變動後的相關資料以及用 service.send
發送事件。
pixi 一樣使用 mediator pattern + closure function 切開與 view component 內部行為無關的邏輯。
example:
Reference: