狀態機的興起

已發表: 2022-03-10
快速總結↬ UI 開發在過去幾年變得困難。 那是因為我們將狀態管理推送到了瀏覽器。 管理狀態使我們的工作充滿挑戰。 如果我們做得正確,我們將看到我們的應用程序如何輕鬆擴展而沒有錯誤。 在本文中,我們將看到如何使用狀態機概念來解決狀態管理問題。

已經是 2018 年了,無數前端開發人員仍在與復雜性和固定性作鬥爭。 一個月又一個月,他們一直在尋找聖杯:一個無錯誤的應用程序架構,可以幫助他們快速、高質量地交付。 我是這些開發人員之一,我發現了一些有趣的東西可能會有所幫助。

我們使用 React 和 Redux 等工具向前邁出了一大步。 然而,它們在大規模應用中是不夠的。 本文將在前端開發的背景下向大家介紹狀態機的概念。 您可能已經在沒有意識到的情況下構建了其中的幾個。

狀態機簡介

狀態機是計算的數學模型。 這是一個抽象的概念,機器可以有不同的狀態,但在給定的時間只滿足其中一個狀態。 有不同類型的狀態機。 我相信最著名的一個是圖靈機。 它是一個無限狀態機,這意味著它可以有無數個狀態。 圖靈機不適合當今的 UI 開發,因為在大多數情況下,我們的狀態數量是有限的。 這就是為什麼有限狀態機(例如 Mealy 和 Moore)更有意義的原因。

它們之間的區別在於,摩爾機器僅根據其先前的狀態更改其狀態。 不幸的是,我們有很多外部因素,比如用戶交互和網絡進程,這意味著摩爾機器對我們來說也不夠好。 我們正在尋找的是 Mealy 機器。 它有一個初始狀態,然後根據輸入及其當前狀態轉換到新狀態。

跳躍後更多! 繼續往下看↓

說明狀態機如何工作的最簡單方法之一是查看旋轉柵門。 它具有有限數量的狀態:鎖定和解鎖。 這是一個簡單的圖形,向我們展示了這些狀態,以及它們可能的輸入和轉換。

閘機

旋轉門的初始狀態是鎖定的。 無論我們推多少次,它都保持在鎖定狀態。 但是,如果我們將硬幣傳遞給它,它就會轉換到解鎖狀態。 此時另一枚硬幣將無濟於事。 它仍將處於解鎖狀態。 從另一邊推一下就行了,我們就可以通過了。 此操作還將機器轉換到初始鎖定狀態。

如果我們想實現一個控制旋轉門的函數,我們可能會得到兩個參數:當前狀態和一個動作。 如果你使用 Redux,這對你來說可能聽起來很熟悉。 它類似於著名的 reducer 函數,我們接收當前狀態,並根據動作的有效負載,決定下一個狀態是什麼。 reducer 是狀態機上下文中的轉換。 事實上,任何具有我們可以以某種方式改變的狀態的應用程序都可以稱為狀態機。 只是我們一遍又一遍地手動實現所有內容。

狀態機如何更好?

在工作中,我們使用 Redux,我對此非常滿意。 但是,我開始看到我不喜歡的模式。 我所說的“不喜歡”並不是說它們不起作用。 更多的是它們增加了複雜性並迫使我編寫更多代碼。 我不得不承擔一個我有空間進行實驗的副項目,我決定重新考慮我們的 React 和 Redux 開發實踐。 我開始對我關心的事情做筆記,我意識到狀態機抽象確實可以解決其中的一些問題。 讓我們跳進去看看如何在 JavaScript 中實現狀態機。

我們將解決一個簡單的問題。 我們希望從後端 API 獲取數據並將其顯示給用戶。 第一步是學習如何在狀態而不是轉換中思考。 在我們進入狀態機之前,我構建這樣一個特性的工作流程曾經看起來像這樣:

  • 我們顯示一個獲取數據按鈕。
  • 用戶單擊獲取數據按鈕。
  • 將請求發送到後端。
  • 檢索數據並解析它。
  • 將其展示給用戶。
  • 或者,如果出現錯誤,則顯示錯誤消息並顯示 fetch-data 按鈕,以便我們可以再次觸發該過程。
線性思維

我們正在線性思考,基本上試圖涵蓋最終結果的所有可能方向。 一個步驟導致另一個步驟,很快我們將開始分支我們的代碼。 用戶雙擊按鈕,或者在等待後端響應時用戶單擊按鈕,或者請求成功但數據損壞等問題。 在這些情況下,我們可能會有各種標誌來告訴我們發生了什麼。 擁有標誌意味著更多的if子句,並且在更複雜的應用程序中,更多的衝突。

線性思維

這是因為我們正在考慮過渡。 我們專注於這些轉變是如何發生的以及以什麼順序發生的。 相反,關注應用程序的各種狀態會簡單得多。 我們有多少個狀態,它們可能的輸入是什麼? 使用相同的示例:

  • 閒置的
    在這種狀態下,我們顯示 fetch-data 按鈕,坐等。 可能的行動是:
    • 點擊
      當用戶單擊按鈕時,我們將請求發送到後端,然後將機器轉換為“獲取”狀態。
  • 獲取
    請求正在進行中,我們坐下來等待。 行動是:
    • 成功
      數據成功到達並且沒有損壞。 我們以某種方式使用數據並轉換回“空閒”狀態。
    • 失敗
      如果在發出請求或解析數據時出現錯誤,我們將轉換到“錯誤”狀態。
  • 錯誤
    我們顯示一條錯誤消息並顯示 fetch-data 按鈕。 這個狀態接受一個動作:
    • 重試
      當用戶單擊重試按鈕時,我們再次觸發請求並將機器轉換為“獲取”狀態。

我們已經描述了大致相同的過程,但帶有狀態和輸入。

狀態機

這簡化了邏輯並使其更具可預測性。 它還解決了上面提到的一些問題。 請注意,當我們處於“獲取”狀態時,我們不接受任何點擊。 因此,即使用戶單擊按鈕,也不會發生任何事情,因為機器未配置為在該狀態下響應該操作。 這種方法自動消除了我們代碼邏輯的不可預測的分支。 這意味著我們將在測試時覆蓋更少的代碼。 此外,某些類型的測試,例如集成測試,可以自動化。 想想我們如何對我們的應用程序做什麼有一個非常清晰的想法,我們可以創建一個腳本來遍歷定義的狀態和轉換並生成斷言。 這些斷言可以證明我們已經達到了每一個可能的狀態或經歷了一段特定的旅程。

事實上,寫下所有可能的狀態比寫下所有可能的轉換更容易,因為我們知道我們需要或擁有哪些狀態。 順便說一句,在大多數情況下,狀態將描述我們應用程序的業務邏輯,而轉換在開始時通常是未知的。 我們軟件中的錯誤是在錯誤狀態和/或在錯誤時間調度的操作的結果。 他們讓我們的應用程序處於我們不知道的狀態,這會破壞我們的程序或使其行為不正確。 當然,我們不希望處於這樣的境地。 狀態機是很好的防火牆。 它們保護我們免於到達未知狀態,因為我們為可能發生的事情和時間設定了界限,而沒有明確說明如何發生。 狀態機的概念非常適合單向數據流。 它們共同降低了代碼的複雜性,並解開了狀態起源之謎。

在 JavaScript 中創建狀態機

說得夠多了——讓我們看看一些代碼。 我們將使用相同的示例。 根據上面的列表,我們將從以下內容開始:

 const machine = { 'idle': { click: function () { ... } }, 'fetching': { success: function () { ... }, failure: function () { ... } }, 'error': { 'retry': function () { ... } } }

我們將狀態作為對象,將它們可能的輸入作為函數。 但是,初始狀態丟失了。 讓我們把上面的代碼改成這樣:

 const machine = { state: 'idle', transitions: { 'idle': { click: function() { ... } }, 'fetching': { success: function() { ... }, failure: function() { ... } }, 'error': { 'retry': function() { ... } } } }

一旦我們定義了所有對我們有意義的狀態,我們就可以發送輸入並更改狀態。 我們將通過使用下面的兩個輔助方法來做到這一點:

 const machine = { dispatch(actionName, ...payload) { const actions = this.transitions[this.state]; const action = this.transitions[this.state][actionName]; if (action) { action.apply(machine, ...payload); } }, changeStateTo(newState) { this.state = newState; }, ... }

dispatch函數檢查在當前狀態的轉換中是否存在具有給定名稱的動作。 如果是這樣,它會使用給定的有效負載觸發它。 我們還將machine作為上下文調用action處理程序,以便我們可以使用this.dispatch(<action>)調度其他動作或使用this.changeStateTo(<new state>)更改狀態。

按照我們示例的用戶旅程,我們必須調度的第一個操作是click 。 這是該操作的處理程序的樣子:

 transitions: { 'idle': { click: function () { this.changeStateTo('fetching'); service.getData().then( data => { try { this.dispatch('success', JSON.parse(data)); } catch (error) { this.dispatch('failure', error) } }, error => this.dispatch('failure', error) ); } }, ... } machine.dispatch('click');

我們首先將機器的狀態更改為fetching 。 然後,我們將請求觸發到後端。 假設我們有一個服務,其方法getData返回一個承諾。 一旦解決並且數據解析OK,我們發送success ,如果不是failure

到目前為止,一切都很好。 接下來,我們要在fetching狀態下實現successfailure的動作和輸入:

 transitions: { 'idle': { ... }, 'fetching': { success: function (data) { // render the data this.changeStateTo('idle'); }, failure: function (error) { this.changeStateTo('error'); } }, ... }

請注意我們是如何讓我們的大腦從不得不考慮之前的過程中解放出來的。 我們不關心用戶點擊或 HTTP 請求發生了什麼。 我們知道應用程序處於fetching狀態,我們只需要這兩個操作。 這有點像孤立地編寫新邏輯。

最後一位是error狀態。 如果我們提供重試邏輯以便應用程序可以從故障中恢復,那就太好了。

 transitions: { 'error': { retry: function () { this.changeStateTo('idle'); this.dispatch('click'); } } }

在這裡,我們必須複製我們在click處理程序中編寫的邏輯。 為了避免這種情況,我們應該將處理程序定義為兩個操作都可以訪問的函數,或者我們首先轉換到idle狀態,然後手動調度click操作。

工作狀態機的完整示例可以在我的 Codepen 中找到。

使用庫管理狀態機

無論我們使用 React、Vue 還是 Angular,有限狀態機模式都有效。 正如我們在上一節中看到的,我們可以輕鬆地實現狀態機而沒有太多麻煩。 但是,有時庫提供了更大的靈活性。 其中一些不錯的是 Machina.js 和 XState。 然而,在本文中,我們將討論 Stent,這是我的類似 Redux 的庫,它包含了有限狀態機的概念。

Stent 是狀態機容器的實現。 它遵循 Redux 和 Redux-Saga 項目中的一些想法,但在我看來,它提供了更簡單且無樣板的流程。 它是使用自述驅動的開發方式開發的,而我實際上只在 API 設計上花費了數週時間。 因為我正在編寫庫,所以我有機會解決我在使用 Redux 和 Flux 架構時遇到的問題。

創建機器

在大多數情況下,我們的應用程序涵蓋多個領域。 我們不能只用一台機器。 因此,Stent 允許創建許多機器:

 import { Machine } from 'stent'; const machineA = Machine.create('A', { state: ..., transitions: ... }); const machineB = Machine.create('B', { state: ..., transitions: ... });

稍後,我們可以使用Machine.get方法訪問這些機器:

 const machineA = Machine.get('A'); const machineB = Machine.get('B');

將機器連接到渲染邏輯

在我的例子中,渲染是通過 React 完成的,但我們可以使用任何其他庫。 歸結為觸發我們觸發渲染的回調。 我開發的第一個功能是connect功能:

 import { connect } from 'stent/lib/helpers'; Machine.create('MachineA', ...); Machine.create('MachineB', ...); connect() .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { ... rendering here });

我們說哪些機器對我們很重要並給出它們的名字。 我們傳遞給map的回調最初會觸發一次,然後每次某些機器的狀態發生變化時都會觸發一次。 這是我們觸發渲染的地方。 此時,我們可以直接訪問連接的機器,因此我們可以檢索當前狀態和方法。 還有mapOnce ,用於只觸發一次回調,還有mapSilent ,用於跳過初始執行。

為方便起見,專門為 React 集成導出了一個幫助程序。 它與 Redux 的connect(mapStateToProps)非常相似。

 import React from 'react'; import { connect } from 'stent/lib/react'; class TodoList extends React.Component { render() { const { isIdle, todos } = this.props; ... } } // MachineA and MachineB are machines defined // using Machine.create function export default connect(TodoList) .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { isIdle: MachineA.isIdle, todos: MachineB.state.todos });

Stent 運行我們的映射回調並期望接收一個對象——一個作為props發送到我們的 React 組件的對象。

支架背景下的狀態是什麼?

到目前為止,我們的狀態一直是簡單的字符串。 不幸的是,在現實世界中,我們必須保持不止一個字符串的狀態。 這就是為什麼 Stent 的 state 實際上是一個內部有屬性的對象。 唯一的一個保留屬性是name 。 其他一切都是特定於應用程序的數據。 例如:

 { name: 'idle' } { name: 'fetching', todos: [] } { name: 'forward', speed: 120, gear: 4 }

到目前為止,我使用 Stent 的經驗告訴我,如果狀態對像變大,我們可能需要另一台機器來處理這些附加屬性。 識別各種狀態需要一些時間,但我相信這是在編寫更易於管理的應用程序方面向前邁出的一大步。 這有點像預測未來和繪製可能行動的框架。

使用狀態機

與開始的示例類似,我們必須定義機器的可能(有限)狀態並描述可能的輸入:

 import { Machine } from 'stent'; const machine = Machine.create('sprinter', { state: { name: 'idle' }, // initial state transitions: { 'idle': { 'run please': function () { return { name: 'running' }; } }, 'running': { 'stop now': function () { return { name: 'idle' }; } } } });

我們有初始狀態idle ,它接受run動作。 一旦機器處於running狀態,我們就可以觸發stop動作,這使我們回到idle狀態。

您可能還記得我們之前實現中的dispatchchangeStateTo助手。 這個庫提供了相同的邏輯,但它是隱藏在內部的,我們不必考慮它。 為方便起見,基於transitions屬性,Stent 生成以下內容:

  • 檢查機器是否處於特定狀態的輔助方法—— idle狀態產生isIdle()方法,而running我們有isRunning()
  • 調度操作的輔助方法: runPlease()stopNow()

所以,在上面的例子中,我們可以使用這個:

 machine.isIdle(); // boolean machine.isRunning(); // boolean machine.runPlease(); // fires action machine.stopNow(); // fires action

將自動生成的方法與connect實用程序功能相結合,我們能夠閉合圓圈。 用戶交互觸發機器輸入和動作,從而更新狀態。 由於該更新,傳遞給connect的映射函數被觸發,並且我們被告知狀態更改。 然後,我們重新渲染。

輸入和動作處理程序

可能最重要的一點是動作處理程序。 這是我們編寫大部分應用程序邏輯的地方,因為我們正在響應輸入和更改的狀態。 我在 Redux 中真正喜歡的東西也集成在這裡:reducer 函數的不變性和簡單性。 Stent 的動作處理器的本質是一樣的。 它接收當前狀態和動作負載,它必須返回新狀態。 如果處理程序不返回任何內容( undefined ),則機器的狀態保持不變。

 transitions: { 'fetching': { 'success': function (state, payload) { const todos = [ ...state.todos, payload ]; return { name: 'idle', todos }; } } }

假設我們需要從遠程服務器獲取數據。 我們觸發請求並將機器轉換為fetching狀態。 一旦數據來自後端,我們就會觸發一個success操作,如下所示:

 machine.success({ label: '...' });

然後,我們回到idle狀態,並以todos數組的形式保存一些數據。 還有幾個其他可能的值可以設置為操作處理程序。 第一個也是最簡單的情況是我們只傳遞一個成為新狀態的字符串。

 transitions: { 'idle': { 'run': 'running' } }

這是使用run()操作從{ name: 'idle' }{ name: 'running' }的轉換。 當我們有同步狀態轉換並且沒有任何元數據時,這種方法很有用。 因此,如果我們將其他東西保持在狀態中,那麼這種類型的轉換會將其清除。 同樣,我們可以直接傳遞一個狀態對象:

 transitions: { 'editing': { 'delete all todos': { name: 'idle', todos: [] } } }

我們正在使用deleteAllTodos操作從editing過渡到idle

我們已經看到了函數處理程序,動作處理程序的最後一個變體是生成器函數。 它的靈感來自 Redux-Saga 項目,它看起來像這樣:

 import { call } from 'stent/lib/helpers'; Machine.create('app', { 'idle': { 'fetch data': function * (state, payload) { yield { name: 'fetching' } try { const data = yield call(requestToBackend, '/api/todos/', 'POST'); return { name: 'idle', data }; } catch (error) { return { name: 'error', error }; } } } });

如果您沒有生成器的經驗,這可能看起來有點神秘。 但是 JavaScript 中的生成器是一個強大的工具。 我們可以暫停我們的動作處理程序,多次更改狀態並處理異步邏輯。

發電機的樂趣

當我第一次接觸 Redux-Saga 時,我認為這是一種處理異步操作的過於復雜的方式。 事實上,它是命令設計模式的一個非常聰明的實現。 這種模式的主要好處是它將邏輯的調用和它的實際實現分開。

換句話說,我們說的是我們想要的,而不是它應該如何發生。 Matt Hink 的博客系列幫助我了解了 sagas 是如何實現的,我強烈推薦閱讀它。 我將相同的想法帶入了 Stent,為了本文的目的,我們將說,通過產生東西,我們在沒有實際執行的情況下給出了關於我們想要什麼的說明。 一旦執行了操作,我們就會收到控制權。

目前,可能會發送(生成)一些東西:

  • 用於更改機器狀態的狀態對象(或字符串);
  • call helper 的調用(它接受一個同步函數,這是一個返回 promise 或另一個生成器函數的函數)——我們基本上是在說,“為我運行這個,如果它是異步的,請等待。 完成後,給我結果。”;
  • wait助手的調用(它接受代表另一個動作的字符串); 如果我們使用這個實用函數,我們會暫停處理程序並等待另一個動作被調度。

這是一個說明變體的函數:

 const fireHTTPRequest = function () { return new Promise((resolve, reject) => { // ... }); } ... transitions: { 'idle': { 'fetch data': function * () { yield 'fetching'; // sets the state to { name: 'fetching' } yield { name: 'fetching' }; // same as above // wait for getTheData and checkForErrors actions // to be dispatched const [ data, isError ] = yield wait('get the data', 'check for errors'); // wait for the promise returned by fireHTTPRequest // to be resolved const result = yield call(fireHTTPRequest, '/api/data/users'); return { name: 'finish', users: result }; } } }

正如我們所見,代碼看起來是同步的,但實際上並非如此。 只是 Stent 完成了等待已解決的承諾或迭代另一個生成器的無聊部分。

Stent 如何解決我對 Redux 的擔憂

太多的樣板代碼

Redux(和 Flux)架構依賴於在我們系統中循環的動作。 當應用程序增長時,我們通常會擁有很多常量和動作創建者。 這兩件事通常位於不同的文件夾中,跟踪代碼的執行有時需要時間。 此外,在添加新功能時,我們總是要處理一整套動作,這意味著定義更多的動作名稱和動作創建者。

在 Stent 中,我們沒有動作名稱,庫會自動為我們創建動作創建者:

 const machine = Machine.create('todo-app', { state: { name: 'idle', todos: [] }, transitions: { 'idle': { 'add todo': function (state, todo) { ... } } } }); machine.addTodo({ title: 'Fix that bug' });

我們將machine.addTodo動作創建者直接定義為機器的方法。 這種方法還解決了我面臨的另一個問題:找到響應特定操作的減速器。 通常,在 React 組件中,我們會看到動作創建者的名稱,例如addTodo ; 然而,在 reducer 中,我們使用一種恆定的動作。 有時我必須跳轉到動作創建者代碼,這樣我才能看到確切的類型。 在這裡,我們根本沒有類型。

不可預測的狀態變化

一般來說,Redux 在以不可變的方式管理狀態方面做得很好。 問題不在於 Redux 本身,而在於允許開發人員隨時調度任何操作。 如果我們說我們有一個打開燈的動作,那麼連續兩次觸發該動作是否可以? 如果不是,那麼我們應該如何用 Redux 解決這個問題? 好吧,我們可能會在 reducer 中放置一些代碼來保護邏輯並檢查燈是否已經打開——也許是一個檢查當前狀態的if子句。 現在的問題是,這不是超出了reducer的範圍嗎? 減速器應該知道這種邊緣情況嗎?

我在 Redux 中缺少的是一種根據應用程序的當前狀態停止分派操作的方法,而不會用條件邏輯污染 reducer。 而且我也不想將這個決定交給視圖層,動作創建者被觸發的地方。 使用 Stent,這會自動發生,因為機器不會響應當前狀態下未聲明的操作。 例如:

 const machine = Machine.create('app', { state: { name: 'idle' }, transitions: { 'idle': { 'run': 'running', 'jump': 'jumping' }, 'running': { 'stop': 'idle' } } }); // this is fine machine.run(); // This will do nothing because at this point // the machine is in a 'running' state and there is // only 'stop' action there. machine.jump();

機器在給定時間只接受特定輸入的事實可以保護我們免受奇怪的錯誤的影響,並使我們的應用程序更可預測。

狀態,而不是轉換

Redux 和 Flux 一樣,讓我們從轉換的角度進行思考。 使用 Redux 進行開發的心智模型很大程度上是由動作以及這些動作如何轉換我們的 reducer 中的狀態來驅動的。 這還不錯,但我發現用狀態來思考更有意義——應用程序可能處於什麼狀態以及這些狀態如何代表業務需求。

結論

編程中的狀態機概念,尤其是在 UI 開發中,讓我大開眼界。 我開始隨處看到狀態機,我有一些願望總是轉向這種範式。 我絕對看到了更嚴格定義的狀態和它們之間的轉換的好處。 我一直在尋找使我的應用程序簡單易讀的方法。 我相信狀態機是朝這個方向邁出的一步。 這個概念很簡單,同時也很強大。 它有可能消除很多錯誤。