還記得在 Day 15 馬克杯 裝水 Guard 的例子嗎?
另外一組例子,一個水瓶或是馬克杯的狀態
一開始是『空的』,有「裝水」這個事件,每次要裝水時,都會當前水量 + 預計倒水量,有沒有超過容器最大容積
「裝水」可以一次裝滿,轉移到『滿水位』的狀態;或是每次都裝一點,轉移到『有部份水量』的狀態
但每次「裝水」事件都會檢查容量夠不夠。
最後『滿水位』時,配上「封蓋」的事件,可以把我們的水瓶或者是馬克杯『蓋上』
今天我們就來實作這個狀態機吧!
TLDR CodeSandbox Demo
按理我們會先思考規劃出上面的狀態圖,接著再將狀態圖實作成為程式碼。
已經有狀態圖了所以來想想實作的部分!
暫定命名為「空杯」、「半滿」、「滿水」、「蓋上」來對應上述英文的狀態
const containerMachineConfig = {
id: "馬克杯",
initial: "空杯",
states:{
空杯:{},
半滿:{},
滿水:{},
蓋上:{}
}
}
由上圖可看出這個狀態機滿單純的,只有兩種事件!
就是『加水』跟『封蓋』
const containerMachineConfig = {
id: "馬克杯",
initial: "空杯",
context: { 容量: 300, 當前水量: 0 },
states:{
+ 空杯:{on:{加水:...}},
+ 半滿:{on:{加水:...}},
+ 滿水:{on:{封蓋:...}},
+ 蓋上:{type: "final"} // 最終狀態
}
}
為了實作事件,我們繼續往下思考
這個馬克杯的狀態機有哪些資料需要被儲存起來、共享,也就是 context 需要什麼?
const containerMachineConfig = {
id: "馬克杯",
initial: "空杯",
+ context: { 容量: 300, 當前水量: 0 },
...
如何實作加水這件事呢?想必『加水』這個事件,會將使用者倒入(輸入)的水量做驗證並且存進 context。
如何將使用者輸入帶進來狀態機呢?以 redux 假設,我們可以發起一個 redux action ,這個 action 會指名 type ,讓 reducer 判別進行什麼狀態處理,並透過 payload 將額外的資料。
// 自己手寫 action
{ type: 'ADD_TODO', text: 'Go to swimming pool' }
{ type: 'TOGGLE_TODO', index: 1 }
{ type: 'SET_VISIBILITY_FILTER', filter: 'SHOW_ALL' }
或
// 使用 Redux Toolkit 的 action creator 及 其他附加功能
{ type: 'ADD_TODO', payload: { text: 'Go to swimming pool' } }
在 XState 當中,想將使用者額外的資料帶進去 Machine 裡,也可以一起放在 event object 內。
- service.send({ type: "加水"});
+ service.send({ type: "加水", 加水量: 30 });
這樣子在撰寫 Machine config 時,就能透過這個額外的 key 決定要對使用者額外的資料做什麼處理!
在這邊也跟讀者說聲不好意思,我忽然發現前面解釋 XState 中的「事件」沒有說得很仔細。
接著我們回去處理『加水』事件本身。依照狀態圖,「空杯」+『加水』可能轉換到兩種狀態 「半滿」or「滿水」,所以『加水』事件的轉移要使用 Array [],儲存多種轉移的可能!
- 空杯:{on:{加水:...}},
+ 空杯:{on:{加水:[]}},
「空杯」+『加水』可能轉換到兩種狀態 「半滿」or「滿水」
- 空杯:{on:{加水:[]}},
+ 空杯:{on:{加水:[{ target: "滿水" }, { target: "半滿" }]}},
「半滿」or「滿水」都要更新 context 裡的 當前水量。
為了更新 context ,我們必須使用 XState 的 side effect -> actions,並透過 assign
API 來更新 context
- 空杯:{on:{加水:[{ target: "滿水" }, { target: "半滿" }]}},
+ 空杯: {
+ on: {
+ 加水: [
+ { // 初始的 當前水量 是 0 ,直接拿 event 的 加水量 即可
+ actions: [
+ assign({ 當前水量: (context, event) => event["加水量"] })
+ ],
+ target: "滿水",
+ },
+ { // 初始的 當前水量 是 0 ,直接拿 event 的 加水量 即可
+ actions: [
+ assign({ 當前水量: (context, event) => event["加水量"] })
+ ],
+ target: "半滿",
+ },
+ ]
+ }
+ }
為了決定該從「空杯」轉換成「半滿」還是「滿水」或者是停留在「空杯」不動
我們需要透過在 event 的轉換描述中使用 key cond
,保護轉換的進行
空杯: {
on: {
加水: [
{ // 初始的 當前水量 是 0 ,直接拿 event 的 加水量 即可
actions: [
assign({ 當前水量: (context, event) => event["加水量"] })
],
target: "滿水",
+ // 加超過杯子的容量,直接進入滿水位
+ cond: (context, event) => event["加水量"] >= context["容量"],
},
{ // 初始的 當前水量 是 0 ,直接拿 event 的 加水量 即可
actions: [
assign({ 當前水量: (context, event) => event["加水量"] })
],
target: "半滿",
+ // 未超過杯子的容量,進入半滿水位
+ cond: (context, event) => event["加水量"] < context["容量"],
},
]
}
}
這邊直接在 cond
後面寫入 Guards Condition Functions (Predicate callback) ,而不是外掛在 createMachine(machineConfig,{ guards:{ guard1,guard2} })
是比較偷懶的方法。
XState 也提供這樣的方式,讓我們能快速測試原型、驗證想法,但等到開發中後期,還是建議將 Guards Condition Functions ( Predicate callback ) 寫進 const someMachine = createMachine(machineConfig,extraOptions)
的 extraOptions 當中。這樣子我們比較好 除錯、測試以及更漂亮的呈現在 Visualizer 上。
Refactoring inline guard implementations in the guards property of the machine options makes it easier to debug, serialize, test, and accurately visualize guards.
XState - Guards Condition Functions
由於 cond
的判別式都已經呈現在一開始的狀態圖了,因此我們就同理類推「半滿」的狀態轉換
半滿: {
on: {
加水: [
{ // 把杯子已有的 當前水量 加上 使用者倒入的 加水量
actions: [
assign({
當前水量: (context, event) =>
context["當前水量"] + event["加水量"]
})
],
target: "滿水",
// 加超過杯子的容量,直接進入滿水位
cond: (context, event) =>
event["加水量"] + context["當前水量"] >= context["容量"]
},
{ // 把杯子已有的 當前水量 加上 使用者倒入的 加水量
actions: [
assign({
當前水量: (context, event) =>
context["當前水量"] + event["加水量"]
})
],
target: "半滿"
}
]
}
},
最後最後,我們可以完成以下狀態機
cond
中,有滿多類似的 Guards Condition Functions!誠如官方推薦,我們可以將其抽象化、放入 extraOptions 中的 guards ,用 字串 在 machineConfig 裡描述。https://xstate.js.org/docs/guides/guards.html