前端狀態管理請三思
最近我開始思考React應用的狀態管理。我已經取得一些有趣的結論,並且在這篇文章里我會向你展示我們所謂的狀態管理並不是真的在管理狀態。
譯者:阿里雲前端-也樹
原文鏈接:managing-state-in-javascript-with-state-machines-stent
我們避而不談的是什麼(The elephant in the room)
我們來看一個簡單的例子。想像這是一個展示用戶名稱、密碼和一個按鈕的表單組件。用戶會在填寫表單後點擊提交。如果一切順利,我們完成了登錄,並且有必要展示歡迎信息和一些鏈接:
我們假定這個組件有兩個展示狀態。一個是未登錄狀態,另一個是用戶登錄後的狀態。所以從管理這兩種狀態開始,我們用一個布爾值的標誌位來描述用戶的狀態。
var isLoggedIn;nisLoggedIn = false; // 展示表單nisLoggedIn = true; // 展示歡迎信息和鏈接n
但是這樣還不夠。如果我們點擊提交按鈕後觸發的HTTP請求需要一些時間來響應,我們不能把表單孤零零的放在屏幕上,而需要更多的UI元素來展示這樣的中間狀態,因此我們不得不在組件中引入另一個狀態。
現在我們有了第三種展示狀態,僅僅用一個 isLoggedIn
變數已經不能解決了。不走運的是我們不能設置變數值為 false-ish
,它不是 true
也不是 false
。當然,我們可以引入另一個變數比如說 isInProgress
。一旦我們發送請求就會把這個變數的值置為 true
。這個變數會告訴我們是處於請求的過程中並且用戶應該看到載入中的展示狀態。
var isLoggedIn;nvar isInProgress; nn// 展示表單nisLoggedIn = false;nisInProgress = false;nn// 請求過程中nisLoggedIn = false;nisInProgress = true;nn// 展示歡迎信息和鏈接nisLoggedIn = true;nisInProgress = false;n
非常棒!我們用到兩個變數並且需要記住這三種情況對應的變數值。看起來我們解決了問題。但另外的問題是,我們維護了太多狀態。如果我們需要展示一個請求成功的信息,或者一切順利的時候我們需要告知用戶:「Yep, 你成功登錄了」,並且兩秒後信息伴隨著華麗的動畫隱藏起來,接著展示出最終的界面,要怎麼辦?
現在情況變得有些複雜。我們有了 isLoggedIn
和 isInProgress
,但是看起來僅僅使用它們還不夠。isInProgress
在請求結束後確實是 false
,但是他的默認值同樣是 false
。我覺得我們需要第三個變數 - isSuccessful
。
var isLoggedIn, isInProgress, isSuccessful;nn// 展示表單nisLoggedIn = false;nisInProgress = false;nisSuccessful = false;nn// 請求過程中nisLoggedIn = false;nisInProgress = true;nisSuccessful = false;nn// 展示成功狀態nisLoggedIn = true;nisInProgress = false;nisSuccessful = true;nn// 展示歡迎信息和鏈接nisLoggedIn = true;nisInProgress = false;nisSuccessful = false;n
我們簡單的狀態管理一步步變成了由 if-else 組成的巨大的條件網,很難去理解和維護。
if (isInProgress) {n // 請求過程中n} else if (isLoggedIn) {n if (isSuccessful) {n // 展示請求成功信息n } else {n // 展示歡迎信息和鏈接n }n} else {n // 等待輸入,展示表單n}n
我們還有一個問題會讓這個情景變得更糟:如果請求失敗我們要怎麼做?我們需要展示一個錯誤信息和一個重試鏈接,如果點擊重試我們會重複一次請求的過程。
現在我們的代碼已經沒有任何可維護性。我們有非常多的場景需要滿足,僅僅依賴引入新的變數是不可接受的。讓我們想想是否可以通過更好的命名方式來解決,同時可能還需要引入一個新的條件聲明。
isInProgress
僅僅在請求的過程中被用到。我們現在還關心請求結束之後的過程。
isLoggedIn
有一點誤導的含義,因為我們只要請求結束就把它置為 true
。而如果請求出錯,用戶並沒有真正登入。所以我們把它重命名為 isRequestFinished
。雖然看起來好些了,但是它僅僅代表我們從伺服器獲得了響應,並不能用它來判斷響應是否為錯誤。
isSuccessful
是一個最終狀態合適的候選變數。如果請求出錯我們可以把它設置為 false
,但是等等,它的默認值也是 false
。所以它也不能作為代表錯誤狀態的變數。
我們需要第四個變數,isFailed
怎麼樣?
var isRequestFinished, isInProgress, isSuccessful, isFailed;nnif (isInProgress) {n // 請求過程中n} else if (isRequestFinished) {n if (isSuccessful) {n // 展示請求成功信息n } else if (isFailed) {n // 展示請求失敗信息和重試鏈接n } else {n // 展示歡迎信息和鏈接n }n} else {n // 等待輸入,展示表單n}n
這四個變數描述了一個看似簡單但實際並不簡單的過程,這個過程包含了許多邊界情況。當項目進一步迭代時,最終可能會由於已有變數的組合不能滿足新的需求,而定義更多的變數。這就是構建用戶界面十分困難的原因。
我們需要更好的狀態管理方式。也許可以使用更現代和更流行的概念。
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 = {n currentState: login form,n states: {n login form: {n submit: loadingn },n loading: {n success: profile,n failure: errorn },n profile: {n viewProfile: profile,n logout: login formn },n error: {n tryAgain: loadingn }n }n}n
這個狀態機對象使用我們上面表格中的內容定義了狀態。像示例中那樣,當我們在 login form
狀態時,我們用 submit
作為一個輸入並且應該以 loading
狀態結束。現在我們需要一個接收輸入的函數。
const input = function (name) {n const state = machine.currentState;nn if (machine.states[state][name]) {n machine.currentState = machine.states[state][name];n }n console.log(`${ state } + ${ name } --> ${ machine.currentState }`);n}n
我們獲得了當前狀態並且檢查提供的input是否合法,如果通過檢查,我們就改變當前的狀態,或者換句話說,將狀態機過渡到一個新的狀態。我們提供了一個日誌輸出用來輸入、當前狀態和新的狀態(如果有變化的話)。下面是如何去使用我們的狀態機:
input(tryAgain);n// login form + tryAgain --> login formnninput(submit);n// login form + submit --> loadingnninput(submit);n// loading + submit --> loadingnninput(failure);n// loading + failure --> errornninput(submit);n// error + submit --> errornninput(tryAgain);n// error + tryAgain --> loadingnninput(success);n// loading + success --> profilenninput(viewProfile);n// profile + viewProfile --> profilenninput(logout);n// profile + logout --> login formn
注意我們嘗試通過在 login form
狀態的時候發送 tryAgain
狀態來打破狀態機的運轉或者是重複發送提交請求。在這些場景下,當前的狀態沒有被改變並且狀態機會忽略這些輸入。
最後的話
我不知道狀態機的概念是否適用於你自己的場景,但是對我來說非常適用。我僅僅改變了我處理狀態管理的方式。我建議去嘗試一下,絕對是值得的。
推薦閱讀: