Day26 - 使用 Guard 来实作一个马克杯的状态机

还记得在 Day 15 马克杯 装水 Guard 的例子吗?

另外一组例子,一个水瓶或是马克杯的状态
一开始是『空的』,有「装水」这个事件,每次要装水时,都会当前水量 + 预计倒水量,有没有超过容器最大容积
「装水」可以一次装满,转移到『满水位』的状态;或是每次都装一点,转移到『有部份水量』的状态
但每次「装水」事件都会检查容量够不够。
最後『满水位』时,配上「封盖」的事件,可以把我们的水瓶或者是马克杯『盖上』

https://www.researchgate.net/profile/Peter-Padawitz/publication/225176063/figure/fig4/AS:670040647020553@1536761547209/A-state-machine-with-events-and-guards-but-no-actions-Figure-4-4-of-37.png

今天我们就来实作这个状态机吧!
TLDR CodeSandbox Demo
按理我们会先思考规划出上面的状态图,接着再将状态图实作成为程序码。

已经有状态图了所以来想想实作的部分!

1. States 状态

暂定命名为「空杯」、「半满」、「满水」、「盖上」来对应上述英文的状态

const containerMachineConfig = {
  id: "马克杯",
  initial: "空杯",
  states:{
    空杯:{},
    半满:{},
    满水:{},
    盖上:{}
  }
}

2. Events 事件

由上图可看出这个状态机满单纯的,只有两种事件!
就是『加水』跟『封盖』

const containerMachineConfig = {
  id: "马克杯",
  initial: "空杯",
  context: { 容量: 300, 当前水量: 0 },
  states:{
+   空杯:{on:{加水:...}},
+   半满:{on:{加水:...}},
+   满水:{on:{封盖:...}},
+   盖上:{type: "final"} // 最终状态
  }
}

为了实作事件,我们继续往下思考

3. Context 扩充状态、资料

这个马克杯的状态机有哪些资料需要被储存起来、共享,也就是 context 需要什麽?

  1. 马克杯的容量(因为要验证加水量有没有超过容量),也就是上图的 capacity
  2. 当前杯子内的水量(因为要让每个状态间都能知道现在水量多少,有可能不是一次加满、只加入一点点),也就是上图的 contents
const containerMachineConfig = {
  id: "马克杯",
  initial: "空杯",
+ context: { 容量: 300, 当前水量: 0 },
  ...

4. Transition 转移

4-1. 加水

如何实作加水这件事呢?想必『加水』这个事件,会将使用者倒入(输入)的水量做验证并且存进 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: "半满",
+         },
+       ]
+     }
+   }

4-2. 决定『加水』能转换到什麽状态!受保护的「半满」跟「满水」

为了决定该从「空杯」转换成「半满」还是「满水」或者是停留在「空杯」不动
我们需要透过在 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: "半满"
          }
        ]
      }
    },

最後最後,我们可以完成以下状态机

CodeSandbox Demo
https://ithelp.ithome.com.tw/upload/images/20211012/20130721Ahe7nTBd09.png

还可以加强什麽?

  1. 相信眼尖的读者应该可以发现 cond 中,有满多类似的 Guards Condition Functions!诚如官方推荐,我们可以将其抽象化、放入 extraOptions 中的 guards ,用 字串 在 machineConfig 里描述。
  2. 状态机加强,我们现在其实可以倒超过满水位的水,仍被储存(容量:300,当前水量:350?!?!),因此 assign 的逻辑可以在被调整、加强。

参考资料

https://xstate.js.org/docs/guides/guards.html


<<:  [Day28] - Django-REST-Framework API 期末专案实作 (三)

>>:  [番外] 一步一步实现购物车功能 [续]

Day 8: 人工智慧在音乐领域的应用 (有趣的AI演算法二)

今天我们接续昨天的话题,继续来聊聊AI领域里面比较有趣的一些演算法。 蚂蚁演算法 (Ant Colo...

[Day 15] ML 实验管理 — 翻开覆盖的陷阱卡~ 记帐小本本!

All life is an experiment. The more experiments y...

[Day14] 动画篇 - 伤害动画

从Sprite_Damage开始 写一个方法 接着是Sprite_Character 在Action...

Day12|【Git】档案管理 - 忽略档案 .gitignore

为何会需要 .gitignore ? 常用的情况如下: 是否常常在 commit 档案时,会发现有一...

[第30天]理财达人Mx. Ada-货柜运价指数FBX

前言 本文说明使用scrapy爬虫函式库抓取海运FBX指数。 波罗的海货柜运价指数[FBX] 波罗的...