React Component裡的狀態機Pattern

uglee發表於2016-12-25

State Machine in React Component

React的工程實踐中大多數團隊都只關注了state該怎麼存放的問題,沒有意識到真正導致問題複雜的是組合狀態機,後面這句話對於UI而言是放之四海皆準的;

一個React Component物件作為UI層元素,在很多情況下我們並不希望在狀態遷移時建立新的例項替代舊的,這直接意味著UI元件和狀態機之間是binding關係而不是composition,所以React提供了一個this.state用於解耦,這是它很聰明的一個設計;但是這個this.state只有值成員,沒有方法成員;這意味著寫在Component上的方法裡面要switch/case狀態,這非常不方便。

其次React Component的setState方法是merge邏輯而不是replace邏輯,它意味著state下一級props之間必須是平行子狀態機而不是單一狀態機互斥狀態(除非你只有一個狀態機,其他狀態用值表示);或者換句話說,如果你把不同的互斥狀態下的資源和值都放在一個籃子裡時,你每次自己去手動倒空舊的,這一點是個坑。

第三,那些early binding語言的狀態機Pattern在js和immutable要求下並不適用,他們都是內部值狀態的遷移而不是物件本身被替代,而物件本身被替代這個問題製造了一個問題,就是該物件的方法並不能用於UI的行為binding,因為狀態遷移後這個舊狀態機物件就廢棄了,呼叫它的行為方法當然是不對的;

解決這個問題並不難,行為binding使用Component物件上的方法,它是穩定的,不會因為model的狀態機更迭而變化,但它是一個proxy,需要把方法分發到子狀態機上;這樣我們就得到了狀態機Pattern的最大優勢:每個狀態只關注屬於自己的子狀態,值,資源,和行為,不用在所有行為處理上都狂寫switch/case。

熟悉狀態機Pattern的開發者不難想像出滿足上述要求的程式碼結構;Component是穩定的,它即使一個子狀態機的容器,又是一個行為的Proxy層,向this.state下的子狀態機(例如命名為this.state.stm1)分發行為;邏輯上是下圖所示:

React Component

  this.state {
    stm1: // --------------------------------> stm1物件
  }

  this.handleToggleButton() {
    this.state.stm1.handleToggleButton() // -> stm1.handleToggleButton()
  }

同時分發的行為必須返回一個新的狀態機物件用於替代舊的,它可能導致一次狀態遷移,例如方法呼叫之前this.state.stm1是一個ListViewState物件,而呼叫後變成了ListEditState物件;如果是這樣,上述行為方法得加一個邏輯:

  this.handleToggleButton() {
    let newStm1 = this.state.stm1.handleToggleButton()
    if (newStm1)
      this.setState({ stm1: newStm1 })
  }

這個邏輯會反覆使用,我們不妨把它抽象出來

  this.dispatch = (name, method, ...args) => {
    if (this.state[name] &&
      typeof this.state[name] === `object` &&
      typeof this.state[name][method] === `function`) {
      let next = this.state[name][method](...args)
      if (next) {
        let obj = {}
        obj[name] = next
        this.setState(obj)
      }
    }
  }

這樣在控制元件的JSX程式碼中使用時:

  onToggle={e => this.dispatch(`stm1`, `handleToggleButton`)}

這不是唯一的寫法,也許你不喜歡這樣把所有的fallback都處理掉連錯誤通知也沒有;你可以自己新增,寫成自己喜歡的方式。

Immutable State Machine in JavaScript

剩下的問題回到如何在JS下書寫一個immutable的狀態機問題,基於Class仍然是直覺的方式,不同之處在於狀態遷移時是用舊的Class物件作為引數傳遞給新的Class物件,新物件的建構函式第一件事情是複製舊物件的全部自有屬性,這個行為可以寫在原型類的建構函式裡。

較為簡潔的寫法是狀態機自己實現一個setState方法(setState是狀態機Pattern的iconic方法,其次才是entry/exit);該方法只是用於狀態機自己的狀態遷移,和它的容器物件(React Component物件)上的setState方法無關;不要搞混了。(當然你應該想想為什麼React Component上有這個狀態機Pattern裡的標誌性方法)

簡明實現的關鍵點是setState接受兩個引數,第一個是下一狀態的Class名(即建構函式),第二個是…args用於傳參;所有子狀態機的constructor都是(obj, …args)的形式,obj是上一狀態機;這樣寫可以避免實現setState時寫switch/case。

它的簡單實現可以是:

setState(NextState, ...args) {
  // 當前狀態機遷出
  this.exit()
  // 構造新物件,immutable,同時下一狀態機遷入,
  return new NextState(this, ...args)
}

原型類的建構函式可以看起來這樣:

constructor(obj) {
  Object.assign(this, obj)
}

用於複製上一狀態的所有屬性。

最後這個狀態機的基類需要一個exit方法,如果子類不需要實現,這是個fallback。

綜上所述這個基類看起來大概是這樣:

class STM {

  constructor(obj) {
    Object.assign(this, obj)
  }

  setState(NextState, ...args) {
    this.exit()
    return new NextState(this, ...args)
  }

  exit() {}
}

在實際使用的時候你可能需要自己的基類,因為

  • 你需要一些context,對所有狀態都需要的值、屬性、資源等

  • 你需要一些共同的方法,如果對某個行為的處理大部分狀態都是一樣的,那麼可以寫在這個原型類裡,具體某個狀態的行為不同,它可以去過載;所以一個真正的原型類和繼承類可能是這樣的:

class MySTM extends STM {

  constructor(obj) {
    super(obj)
  }

  this.handleToggleButton = () => {
    // ...
  }
}

class MySTMInitState extends MySTM {
  // ...
}

class MySTMAnotherState extends MySTM {
  // ...
}

需要注意的是不要在MySTM的建構函式裡寫其他邏輯,如果有其他邏輯,寫在React Component的constructor裡,相當於是這個狀態機原型物件的工廠。

在React Component的建構函式裡,可以這樣使用:


  // 如果props和進入時的上下文有關,在這裡處理
  let props = {
    ...
  }

  // 建立了一個原型
  let stm1 = new MySTMInitState(props)

這裡有兩個問題需要闡述一下。

第一,基於class語法構造物件的本質,其實只是在子類建構函式裡把父類建構函式全部調一遍,保證物件屬性完整,以及原型鏈正確;它是用起來最簡潔的方式,但不是唯一的方式;

JavaScript提供了另一種方式來構造物件,即Object.create()方法,兩者是有區別的。

基於class語法構造的物件,如果你嘗試:

let x = new MySTMInitState({})
let y = new MySTMAnotherState({})

console.log(x.__proto__ === y.__proto__)

你會得到一個false輸出,即這兩個狀態機的原型物件並非同一個物件,他們只是同一個建構函式(MySTM)構造過,因此具有同樣的properties(方法)。

但是如果你使用Object.create()來自己構造原型鏈,你可以有一個原型物件和React Component的生命週期一致,所有stm1狀態機都以它為原型。這在某些情況下是有益的,例如:

  1. 你可以在這個原型上放context,減少遷移時Object.assign()複製properties的效能負擔;

  2. 如果某些context是需要被子類修改的,可以提供setter方法達到這個目的。

事實上,這個方式更加符合JavaScript的原型化繼承的設計初衷,但是語言是這樣的一個東西,就是哪個語法簡單,那個寫法就被最廣泛的使用,就像C++/Java裡繼承是最簡單的語法,那麼它就被用的最廣泛,而寫Pattern是複雜實現,他就被用的少,即使很多時候更應該寫Pattern。

Anyway,這個區別在實踐上的意義很小。

第二,是個對傳統OO語言開發者來說比較難接受的地方,就是你可以這樣寫:

let x = new MySTM()
let y = new MySTMInitState(x)

這件事情幽默的地方是你可以用基類物件去構造繼承類物件,彷彿Class和Object的區別被抹平的,他們在平行世界之間穿越。

其實這正解釋了JavaScript的所謂類,只是建構函式,所謂繼承,就是把建構函式和原型物件串起來而已,類似Builder Pattern的思想;所以Build兩步還是三步都是可能的。

這樣寫有一點實踐上的意義,你可以先建立一個基類物件初始化所有的上下文,然後根據實際情況用它來構造繼承類物件,這樣能重用一下繼承類物件的enter邏輯(即constructor),不用重寫。

OK,這兩個都是小問題,細節。move on。

在所有子類中,constructor等價於狀態機Pattern的enter,用於建立所有資源,而exit中需要銷燬所有資源,尤其是那些出發但尚未完成的請求,以及尚未fire的timer。對付這種問題,狀態機是第一首選Pattern,簡直太容易寫出行為複雜且健壯的程式碼了。

事實上,任何其他形態的維護態的程式碼都可以看作是狀態機Pattern的退化,所以對那些如果一開始就預見到未來會變得複雜的元件,應該一開始就寫狀態機;狀態機犧牲的是程式碼量,但是對於行為定義的變化(遷移路徑的增加,減少,改變,狀態增減),它維護起來是無出其右的,是對付複雜多型行為的首選。

本質上,狀態機幫你拿掉在所有方法裡的第一層switch/case,代之以dispatch,或者是OO裡說的多型;但是如果狀態層疊呢?

通常我們不在狀態機裡套狀態機,一般只有在寫複雜協議棧的時候這麼寫;一般而言,狀態機兩層最多了,內層的狀態用值來表示狀態,而不是用類來表示狀態,足夠了。

舉個例子看看你理解了沒有:

你的UI裡有一個行為是操作一個列表中的單一物件;如果有一個物件被選中,然後按鈕被點選,這是一種行為,另一種是使用者先建立一個新物件,這是另一種行為;那麼需要把Editing和EditingNew作為兩種互斥狀態處理嗎?

如果沒有UI的顛覆性變化大多數情況不這樣做,而是把Editing作為頂層狀態機(superstate)處理,而New可以用一個props的值來表示,例如狀態機物件裡有一個叫做creating的prop,它是boolean型別。即頂層狀態機用類物件表示,底層狀態機回到土辦法,用值表示。

這樣設計的好處是:

  1. Editing和EditingNew有大量狀態是重用的和persistent的,即從一個遷移到另一個,他們仍然是有效的,不應該被一個exit銷燬,另一個enter重建。

  2. 他們作為父子狀態設計可以共用大量方法,而不是每個都提供自己的副本;

  3. 如果從父狀態遷出或者從外部狀態向父狀態遷入,銷燬和構建資源的邏輯也大部分是相同的;

實際上的狀態圖上往往是有superstate(父狀態)遷出的事件邏輯;那麼執行方式是

  1. 直接呼叫父狀態的exit

  2. 父狀態的exit先dispatch子狀態的exit

  3. 父狀態的exit再呼叫自己的邏輯,即清理子狀態的共享資源。

如果是外部遷入父狀態機,要有一個決策依據決定應該遷向那個子狀態機作為初始狀態,因為在runtime,組合狀態機構成的tree結構,實際的狀態機例項只能在leaf node上,superstate節點的存在是為了抽象子節點的共同行為,減少遷移路徑和重用行為邏輯;

因此遷入父狀態機時(enter)的邏輯和遷出(exit)剛好相反:

  1. 直接呼叫父狀態機的enter

  2. 父狀態機先構造對所有子狀態都適用的資源

  3. 呼叫具體某個子狀態機的enter(就是一個if / then來區分子狀態機即可)

在OO領域,很多開發者信奉UML圖;UML圖對OO語言中最重要的類圖,在JavaScript裡毛用沒有了,但是State Machine圖,結合上述狀態機設計,絕對是對付複雜UI的利器;尤其是對於初學者而言,在前端的狀態邏輯上,你能掌握這一把刀就能砍倒所有的樹;如果還不能砍倒,那其實問題本身不是UI構建域的,可能是其他問題,例如排程等等。

很多寫JavaScript的朋友,為了向世人證明自己根骨奇佳、習得真傳,到處宣揚OO裡的種種不是,以各種言辭抨擊OO實踐的方方面面。

他們不懂OO。

OO裡在語言層面可能有一些設計問題,但是OO裡的封裝思想是絕對正確的;

為什麼會有物件這個概念被提出來?就是因為一些態的生命週期超過函式呼叫的執行時間,你需要一種方式來管理這些態。

封裝的本質是:在內部有一個state space,在外部看,只看到內部的state space的superstate。物理學上稱之為簡併,degeneration。

這是我們對付所有複雜狀態的唯一手段,不管態放在花盆裡、銀行裡、還是藏在自己的內褲裡,他們都是客觀存在,你不可能去消滅態,你只能organize他們;而且你同時需要organize應用在態上過程(function)。

狀態機把這個organization完完全全一覽無遺的展露出來,無論你用class寫,用閉包寫,用c語言寫,行為和狀態的structure都不會變,想成為一個合格的程式設計師,尤其是寫ui的程式設計師,state machine pattern是必修課。

~~~~~~~~~~~~~~~~~~~

先寫這麼多,我得按照上述邏輯扣程式碼去了。

祝大家聖誕節快樂。

歡迎探討。

相關文章