前端狀態管理請三思

阿里雲前端發表於2017-11-04

最近我開始思考React應用的狀態管理。我已經取得一些有趣的結論,並且在這篇文章裡我會向你展示我們所謂的狀態管理並不是真的在管理狀態。

譯者:阿里雲前端-也樹

原文連結:managing-state-in-javascript-with-state-machines-stent

我們避而不談的是什麼(The elephant in the room)

我們來看一個簡單的例子。想象這是一個展示使用者名稱稱、密碼和一個按鈕的表單元件。使用者會在填寫表單後點選提交。如果一切順利,我們完成了登入,並且有必要展示歡迎資訊和一些連結:


我們假定這個元件有兩個展示狀態。一個是未登入狀態,另一個是使用者登入後的狀態。所以從管理這兩種狀態開始,我們用一個布林值的標誌位來描述使用者的狀態。

var isLoggedIn;
isLoggedIn = false; // 展示表單
isLoggedIn = true; // 展示歡迎資訊和連結複製程式碼

但是這樣還不夠。如果我們點選提交按鈕後觸發的HTTP請求需要一些時間來響應,我們不能把表單孤零零的放在螢幕上,而需要更多的UI元素來展示這樣的中間狀態,因此我們不得不在元件中引入另一個狀態。

現在我們有了第三種展示狀態,僅僅用一個 isLoggedIn 變數已經不能解決了。不走運的是我們不能設定變數值為 false-ish,它不是 true 也不是 false。當然,我們可以引入另一個變數比如說 isInProgress。一旦我們傳送請求就會把這個變數的值置為 true。這個變數會告訴我們是處於請求的過程中並且使用者應該看到載入中的展示狀態。

var isLoggedIn;
var isInProgress; 

// 展示表單
isLoggedIn = false;
isInProgress = false;

// 請求過程中
isLoggedIn = false;
isInProgress = true;

// 展示歡迎資訊和連結
isLoggedIn = true;
isInProgress = false;複製程式碼

非常棒!我們用到兩個變數並且需要記住這三種情況對應的變數值。看起來我們解決了問題。但另外的問題是,我們維護了太多狀態。如果我們需要展示一個請求成功的資訊,或者一切順利的時候我們需要告知使用者:“Yep, 你成功登入了”,並且兩秒後資訊伴隨著華麗的動畫隱藏起來,接著展示出最終的介面,要怎麼辦?


現在情況變得有些複雜。我們有了 isLoggedInisInProgress,但是看起來僅僅使用它們還不夠。isInProgress 在請求結束後確實是 false,但是他的預設值同樣是 false。我覺得我們需要第三個變數 - isSuccessful

var isLoggedIn, isInProgress, isSuccessful;

// 展示表單
isLoggedIn = false;
isInProgress = false;
isSuccessful = false;

// 請求過程中
isLoggedIn = false;
isInProgress = true;
isSuccessful = false;

// 展示成功狀態
isLoggedIn = true;
isInProgress = false;
isSuccessful = true;

// 展示歡迎資訊和連結
isLoggedIn = true;
isInProgress = false;
isSuccessful = false;複製程式碼

我們簡單的狀態管理一步步變成了由 if-else 組成的巨大的條件網,很難去理解和維護。

if (isInProgress) {
  // 請求過程中
} else if (isLoggedIn) {
  if (isSuccessful) {
    // 展示請求成功資訊
  } else {
    // 展示歡迎資訊和連結
  }
} else {
  // 等待輸入,展示表單
}複製程式碼

我們還有一個問題會讓這個情景變得更糟:如果請求失敗我們要怎麼做?我們需要展示一個錯誤資訊和一個重試連結,如果點選重試我們會重複一次請求的過程。

現在我們的程式碼已經沒有任何可維護性。我們有非常多的場景需要滿足,僅僅依賴引入新的變數是不可接受的。讓我們想想是否可以通過更好的命名方式來解決,同時可能還需要引入一個新的條件宣告。

isInProgress 僅僅在請求的過程中被用到。我們現在還關心請求結束之後的過程。

isLoggedIn 有一點誤導的含義,因為我們只要請求結束就把它置為 true。而如果請求出錯,使用者並沒有真正登入。所以我們把它重新命名為 isRequestFinished。雖然看起來好些了,但是它僅僅代表我們從伺服器獲得了響應,並不能用它來判斷響應是否為錯誤。

isSuccessful 是一個最終狀態合適的候選變數。如果請求出錯我們可以把它設定為 false,但是等等,它的預設值也是 false。所以它也不能作為代表錯誤狀態的變數。

我們需要第四個變數,isFailed 怎麼樣?

var isRequestFinished, isInProgress, isSuccessful, isFailed;

if (isInProgress) {
  // 請求過程中
} else if (isRequestFinished) {
  if (isSuccessful) {
    // 展示請求成功資訊
  } else if (isFailed) {
    // 展示請求失敗資訊和重試連結
  } else {
    // 展示歡迎資訊和連結
  }
} else {
  // 等待輸入,展示表單
}複製程式碼

這四個變數描述了一個看似簡單但實際並不簡單的過程,這個過程包含了許多邊界情況。當專案進一步迭代時,最終可能會由於已有變數的組合不能滿足新的需求,而定義更多的變數。這就是構建使用者介面十分困難的原因。

我們需要更好的狀態管理方式。也許可以使用更現代和更流行的概念。

Flux 或者 Redux 怎麼樣?

最近我在思考 Flux 架構和 Redux 庫在狀態管理中的定位。即使這些工具和狀態管理有關,但是它們本質上不是解決這類問題的。

Flux 是 Facebook 用來構建客戶端 web 應用的架構。它利用單向資料流補足了 React 的檢視元件的組織方式。

Redux 是一個可預測的狀態容器,用來構建 JavaScript 應用。

它們是 “單向資料流” 和 “狀態容器”,而不是 “狀態管理”。Flux 和 Redux 背後的概念是非常實用和討巧的。我認為它們是適合構建使用者介面的方式。單向資料流讓資料擁有可預測性,改進了前端開發。Redux 中的 reducer 擁有的不可變特性,提供了一種可以減少 bug 的資料傳送方式。
就我的感受來說,這些模式更適用於資料管理和資料流管理。它們提供了完善的 API 來交換改變我們應用資料的資訊,但是並不能解決我們狀態管理的問題。這也因為這些問題是跟專案強相關的,問題的上下文取決於我們正在做的事情。
當然像處理 HTTP 請求我們可以通過某個庫來解決,但是對其它相關的業務邏輯我們仍然需要自己編寫程式碼來實現。問題在於我們如何用一種合適的方式去組織這些程式碼,而不至於每兩年就把整個應用重寫一遍。

幾個月之前我開始尋找可以解決狀態管理問題的模式,最終我發現了狀態機的概念。事實上我們一直都在構建狀態機,只不過我們不知道。

什麼是狀態機?

狀態機的數學定義是一個計算模型,我的理解是:狀態機就是儲存你的狀態和狀態變化的一個盒子。這裡有一些不同種類的狀態機,適用於我們這個案例的是有限狀態機。像它的名字一樣,有限狀態機包含有限的幾種狀態。它接收一個輸入並且基於這個輸入和當前的狀態決定下一個狀態,可能會有多種情況輸出。當狀態機改變了狀態,我們就稱為它過渡到一個新的狀態。

實戰狀態機

為了使用狀態機我們或多或少需要定義兩件事 - 狀態和可能的過渡方法。讓我們來嘗試實現上面提到的表單需求。

在這個表格中我們可以清楚的看到所有狀態和他們可能的輸出情況。我們同樣定義瞭如果輸入被傳遞進狀態機後的下一個狀態。編寫這樣的表格對你的開發週期大有裨益,因為他會回答你以下問題:

  • 使用者介面可能出現的所有狀態有哪些?
  • 每種狀態之間會發生什麼?
  • 如果某種狀態改變,結果是什麼?

這三個問題可以解決非常多的難題。想象一下當我們改變內容展示的時候有一個動畫效果,當動畫開始時,UI 仍然處於之前的狀態並且使用者仍然可以產生互動。舉個例子,使用者非常快速地點選了兩次提交按鈕。如果不適用狀態機,我們需要使用if語句通過標誌變數來防止程式碼的執行。但是如果回到上面那個表格,我們會看到 loading 狀態不接受 Submit 狀態的輸入。所以如果我們在第一次點選按鈕後把狀態機轉變為 loading 狀態,我們就會處於一個安全的位置。即使 Submit 輸入/動作被分發過來,狀態機也會忽略它,當然也不會再向後端發出一個請求。

狀態機模式對我來說是適用的。以下有三個理由支撐我在我的應用中使用狀態機:

  • 狀態機模式免去了很多可能出現的 bug 和奇怪的清潔,因為它不會讓 UI 變化為我們不知道的狀態。
  • 狀態機不接受沒有明確定義的輸入作為當前的狀態。這會免去我們對其它程式碼執行的部分容錯處理。
  • 狀態機強制開發者以宣告式的方式思考。因為我們大部分的邏輯需要提前定義。

在 JavaScript 裡實現狀態機

現在,既然我們知道什麼是狀態機,那就讓我們來實現一個並且解決我們一開始的問題。用一些巢狀的屬性定義一個簡單的物件字面量。

const machine = {
  currentState: 'login form',
  states: {
    'login form': {
      submit: 'loading'
    },
    'loading': {
      success: 'profile',
      failure: 'error'
    },
    'profile': {
      viewProfile: 'profile',
      logout: 'login form'
    },
    'error': {
      tryAgain: 'loading'
    }
  }
}複製程式碼

這個狀態機物件使用我們上面表格中的內容定義了狀態。像示例中那樣,當我們在 login form 狀態時,我們用 submit 作為一個輸入並且應該以 loading 狀態結束。現在我們需要一個接收輸入的函式。

const input = function (name) {
  const state = machine.currentState;

  if (machine.states[state][name]) {
    machine.currentState = machine.states[state][name];
  }
  console.log(`${ state } + ${ name } --> ${ machine.currentState }`);
}複製程式碼

我們獲得了當前狀態並且檢查提供的input是否合法,如果通過檢查,我們就改變當前的狀態,或者換句話說,將狀態機過渡到一個新的狀態。我們提供了一個日誌輸出用來輸入、當前狀態和新的狀態(如果有變化的話)。下面是如何去使用我們的狀態機:

input('tryAgain');
// login form + tryAgain --> login form

input('submit');
// login form + submit --> loading

input('submit');
// loading + submit --> loading

input('failure');
// loading + failure --> error

input('submit');
// error + submit --> error

input('tryAgain');
// error + tryAgain --> loading

input('success');
// loading + success --> profile

input('viewProfile');
// profile + viewProfile --> profile

input('logout');
// profile + logout --> login form複製程式碼

注意我們嘗試通過在 login form 狀態的時候傳送 tryAgain 狀態來打破狀態機的運轉或者是重複傳送提交請求。在這些場景下,當前的狀態沒有被改變並且狀態機會忽略這些輸入。

最後的話

我不知道狀態機的概念是否適用於你自己的場景,但是對我來說非常適用。我僅僅改變了我處理狀態管理的方式。我建議去嘗試一下,絕對是值得的。

ps:一如既往的打個廣告: 阿里雲求前端工程師,base 北京or杭州,請聯絡:xiaoming.dxm@alibaba-inc.com

相關文章