[JavaScript] 用狀態模式取代 if else

Lastor
Code 隨筆放置場
9 min readSep 4, 2020

最近開始接觸到了遊戲程式的撰寫,Google 了一些遊戲相關,程式架構設計的討論。蠻多文章中,都有人提 if else 難閱讀、難維護,建議使用「多型 (Polymorphism)」與「狀態模式 (State Pattern)」來改寫。

看到瞬間很訝異,是什麼魔法可以把 if else 變不見?! 於是就花了點時間研究這到底是個什麼概念。

if else 條件判斷,是寫程式很基本,也很常用的指令,可以幫助我們命令電腦「什麼情況,做什麼事」。例如今天要寫一個簡單的 greeting 程式,可能會像這樣。

function greeting() {
if 早上
say('早安')
if 中午
say('午安')
if 晚上
say('晚安')
}

其中,if 所判斷的東西,就可以視為一種狀態 (state)。實際進行專案時,常會碰到狀態很多,且每個狀態要執行的 action 都很不一樣的情況。雖然 if else 很方便,但狀態一直添加,最後可能會變成一個很恐怖的情景。

function action() {
if (state === 'A') {
// 很大包
} else if (state === 'B') {
// 很大包
} else if (state === 'C') {
// 很大包
}
...
}

如果每種狀態彼此之間還存在優先度與順序的問題,多人協作時,大家為了省事,紛紛搶著把自己負責的 if 區塊往頂部挪,最後這一包程式碼勢必會變成沒有人敢碰的黑暗鍋。

狀態模式

狀態模式是物件導向的一種設計模式 (Design Pattern)。概念是將「狀態」與「動作」打包成 Group,使其獨立出來,方便個別管理。

我們可以將每一塊 if 與對應的 action 視作一個包。

拉出來單獨做成一包包的 class。

class State {  // 父類,可設置共用內容
constructor(app) {
this.app = app
}
}
class StateA extends State { // 子類,定義該狀態的個別內容
action() {
// ...
}
}
class StateB extends State {
action() {
// ...
}
}
class StateC extends State {
action() {
// ...
}
}

原本是呼叫一個 function,然後讓該 function 自己去判斷 state,執行不同 action。現在反過來,變成讓每一個 state 都擁自己的 action,我們只要替換不同的 state,並讓 state 呼叫自己的 action 就可以了。

class Context {
stateA = new StateA(this)
stateB = new StateB(this)
stateC = new StateC(this)

currentState = this.stateA // set default

handle() {
this.currentState.action()
}
}

這樣的做法可以很方便的做擴增,當有新的需求要增加狀態,就再建立一個 Class,並在 Context Class 的頭部做引入即可。各狀態都是獨立的,維護會變得容易很多。

簡單實作

接下來實際在 Web 上做一個簡單的例子。

假設有一個 monster,牠有三種狀態,state0、state1、state2。我們每攻擊牠一次,牠就會切換到下一個狀態。當狀態是 state2 時,再攻擊就會回到 state0,形成一個 loop。

onAttack
state0 -> state1 -> state2 -> state0

在頁面上建立一個 button,用來當作 attack 鍵。簡單的用 text 來顯示 monster 的當前狀態。

<button id="btn">attack</button>
<p>
<span>monster is</span>
<span id="viewer">state0</span>
</p>

當按下 attack 鍵攻擊怪物後,怪物會改變狀態,並且執行一個動作。這邊使用瀏覽器 pop-up 指令來模擬,大概這種感覺。

if state0
confirm('to state1')
if state1
alert('to state2')
if state2
prompt('to state0')

因為是物件導向的體系,所以我們用 Class 來實作。先做基本款 if else 的版本。

if else Pattern

裡面另外做了一個 $ function,用來簡化選取 DOM 的語法。

以 init() 作為進入點,對 attack button 掛上監聽,並執行 onClick()。讓 onClick() 自己用條件判斷 state,決定該做什麼 action。

onClick() {
if (this.state === 'state0') {
confirm('to state1')
this.state = 'state1'
$('#viewer').innerHTML = this.state
} else if (this.state === 'state1') {
alert('to state2')
this.state = 'state2'
$('#viewer').innerHTML = this.state
} else {
prompt('to state0')
this.state = 'state0'
$('#viewer').innerHTML = this.state
}
}

接下來就是把這包 if else 重構成狀態模式,讓 State Class 接管。

先來修改頭部 App Class 的內容,引用 State Class 的邏輯大概會是這樣。

class App {
// 建立狀態
state0 = new State0(this)
state1 = new State1(this)
state2 = new State2(this)
currentState = this.state0
init() {
$('#btn').addEventListener('click', () => {
// 改為調用狀態自身的 method
this.currentState.onClick()
})
}
// 增設切換狀態的 method
setState(stateName) {
this.currentState = this[stateName]
$('#viewer').innerHTML = stateName
}
}

將各個 State 給 new 出來儲存成 prop。主要的 handler 改成呼叫 State 各自的 method,並且添加切換狀態的 method。

然後來建立各個 State。

class State0 {
constructor(app) {
this.app = app
}
nextState = 'state1'
onClick() {
confirm(`to ${this.nextState}`)
this.app.setState(this.nextState)
}
}
class State1 {
constructor(app) {
this.app = app
}
nextState = 'state2'
onClick() {
alert(`to ${this.nextState}`)
this.app.setState(this.nextState)
}
}
class State2 {
constructor(app) {
this.app = app
}
nextState = 'state0'
onClick() {
prompt(`to ${this.nextState}`)
this.app.setState(this.nextState)
}
}

可以看到,各個 State 裡面,有很多可共用的程式碼出現,日後如果要添加新狀態,每次都得寫一遍相同的東西,是很煩人的事。

這邊可以利用「繼承」,製作一個作為父類的 super class,就可以把共用的程式碼寫在裡面。

class State {
constructor(app) {
this.app = app
}
nextState = '' // set by subclass
onClick() {
throw new Error('onClick method is required.')
}
next() {
this.app.setState(this.nextState)
}
}
class State0 extends State {
nextState = 'state1'
onClick() {
confirm(`to ${this.nextState}`)
this.next()
}
}
...(後略)

其中,雖然 onClick 的內容不共用。但有可能別人在加入新狀態時,會忘記寫 onClick。所以可以在父類寫一個拋錯的 onClick 做為預設,當作防呆機制。

這樣就大功告成啦,if else 不見了!!

結語

這樣改寫一次之後,可以很明顯感受到,事情其實是變複雜了。為了製作 State Class,程式碼的量比原本用 if else 多出不少。

而且這對於比較少接觸物件導向的人來說,還得先搞懂 JavaScript 的原型鏈、繼承機制,以及 Class 語法糖那些東西。學習成本比起單純的 if else 高出不少。

雖然目前個人沒有在專案中實作過狀態模式,但大概可以看出這比較適合大型專案,而非小型專案。如果需求不是很多,也不太有擴建、刪減的情況,應該也沒必要特地用狀態模式來處理。

這類設計模式,雖然都是物件導向的設計,但由於 JS 並非純物件導向的語言,本身不會受到 class 體制的限制。如果想的話,也可以將那些 State 改寫成 Object + Object method 的形式。

個人目前經手的都是小專案居多,感覺實戰好像很難用上啊。(苦笑)

--

--

Lastor
Code 隨筆放置場

Web Frontend / 3D Modeling / Game and Animation. 設計本科生,前遊戲業 3D Artist,專擅日本動畫與遊戲相關領域。現在轉職為前端工程師,以專業遊戲美術的角度涉足 Web 前端開發。