帶有@wordpress/data 和 TypeScript 的有限狀態機

已發表: 2021-09-16

假設我們想在我們的應用程序中設計一個登錄屏幕。 當用戶第一次看到它時,有一個空表單。 用戶填寫字段,一旦設置完成,他們按下登錄按鈕來驗證憑據,然後登錄。如果驗證成功,他們將進入下一個屏幕。 但如果沒有,他們會收到一條錯誤消息,並被要求重試。

如果你要實現這樣一個屏幕的狀態並且你閱讀了我關於如何使用 TypeScript 和@wordpress/data定義應用程序狀態的帖子,我敢打賭你會做這樣的事情:

 type State = { readonly username: string; readonly password: string; readonly isLoggingIn: boolean; readonly errorMessage: string; };

它具有管理狀態的所有必要字段,但是……這是我們能做的最好的嗎?

有限狀態機

從廣義上講,有限狀態機(FSM) 是一種數學模型,它允許我們定義一組有限的狀態以及它們之間可能的轉換。 (有關維基百科的更多信息)。 我喜歡這種抽象,因為它完全符合我們對應用程序狀態建模的需求。 例如,我們登錄屏幕的狀態圖可能如下所示:

登錄表單的狀態圖
登錄表單的狀態圖。

如您所見,它以清晰簡潔的方式捕獲了我們想要實現的行為。

使用@wordpress/data定義狀態

在我們的 React 入門系列中,我們看到瞭如何使用@wordpress/data包來創建和管理應用程序的狀態。 我們通過定義數據存儲的主要組件來做到這一點:

  • 一組查詢狀態的選擇器
  • 觸發更新請求的一組動作
  • 一個reducer 函數,用於在給定當前狀態和更新操作的情況下更新狀態

本質上, @wordpress/data中的存儲已經表現得像有限狀態機,因為 reducer 函數使用一個動作從一個狀態“轉換”到另一個狀態:

 ( state: State, action: Action ) => State

如何在@wordpress/data中使用 TypeScript 定義有限狀態機

看起來@wordpress/data存儲非常接近我們在上面幾行介紹的 FSM 模型:我們只是缺少我們可以處於的特定狀態以及它們之間的轉換......所以讓我們仔細看看我們如何能夠一步一步解決這些問題。

顯式狀態

我們通過展示我們應用程序狀態的可能實現來開始這篇文章。 我們的第一個建議是一組完成工作的屬性,但它看起來根本不像一個有限狀態機。 因此,讓我們首先確保我們的狀態在我們的模型中明確定義:

 type State = | Form | LoggingIn | Success | Error; type Form = { readonly status: 'form'; readonly username: string; readonly password: string; }; type LoggingIn = { readonly status: 'logging-in'; readonly username: string; readonly password: string; }; type Success = { readonly status: 'success'; // ... }; type Error = { readonly status: 'error'; readonly message: string; };

很明顯吧? 每個狀態有一種類型,應用程序的整體狀態被定義為類型的可區分聯合。 之所以這麼稱呼它是因為 (a) 狀態是“不同類型的聯合”(也就是說,它要么是Form ,要么是LoggingIn ,要么是任何東西)並且 (b) 我們有一個屬性(在這種情況下, status ),它允許我們區分我們在每個時刻擁有的特定狀態。

我認為這個解決方案比我們一開始的解決方案要好得多。 在我們最初的解決方案中,我們能夠定義“無效狀態”,因為一個人可能正在登錄(只需將isLoggingIn設置為true ),同時處於錯誤狀態(只需將errorMessage設置為空以外的值細繩)。 但這顯然沒有意義! 哪一個? 我們是在登錄還是應該顯示錯誤?

另一方面,新的解決方案在表示狀態時要精確得多:它使“無效”狀態“不可能”。 如果我們在LoggingIn中,則無法設置錯誤消息(它沒有屬性!)。 如果我們顯示Error ,您將無法登錄。好多了,您不覺得嗎?

行動

@wordpress/data中,操作會更新我們狀態下的存儲。 由於我們將狀態建模為 FSM,因此我們的動作將是原始圖中的箭頭。 您所要做的就是將它們建模為類型的可區分聯合(按照慣例,鑑別器通常命名為type ),您就可以開始了:

 type SetCredentials = { readonly type: 'SET_CREDENTIALS'; readonly username: string; readonly password: string; }; type Login = { readonly type: 'LOGIN'; }; type ShowApp = { readonly type: 'SHOW_APP'; // ... }; type ShowError = { readonly type: 'SHOW_ERROR'; readonly message: string; }; type BackToLoginForm = { readonly type: 'BACK_TO_LOGIN_FORM'; };

我們的動作應該代表我們原始圖表中的箭頭,對嗎? 好吧,我不了解你,但我認為這些動作類型根本不像箭頭! 箭頭有一個方向(它們從一個狀態到另一個狀態); 行動沒有。

我們需要一種方法在我們的代碼中清楚地表明某些操作僅對從一種狀態移動到另一種狀態有用。 到目前為止,我發現的最佳解決方案是使用減速器本身:

 function reducer( state: State, action: Action ): State { switch ( state.status ) { case 'form': switch ( action.type ) { case 'SET_CREDENTIALS': return { status: 'ready', username: action.username, password: action.password, }; case 'LOGIN': return { ...state, status: 'logging-in' }; } case ...: ... } return state; }

如果我們的 reducer 從過濾state status ,我們就知道箭頭的“來源”。 然後,我們查看當前動作,如果它是“向外箭頭”,我們生成新的“目標”狀態。

您可以在上面的代碼段中清楚地看到它是如何工作的。 如果當前statusform ,則SET_CREDENTIALS操作會將我們帶到與我們所處的相同狀態(更新憑據,duh),並且LOGIN操作將狀態更改為logging-in 。 此狀態下的任何其他操作都將被忽略,因此狀態不變。

這一切都很好,很完美,但是……我不知道,我不太喜歡代碼,是嗎? 我想確保我的代碼清晰、簡潔、不言自明且類型安全。 我們可以這樣做嗎?

強類型減速器

為了解決我們剛剛陷入的混亂,我們只需要重構我們的reducer ,以便 FSM 中的每個可能狀態都有自己的減速器。 如果我們正確地做到了這一點,它們將是強類型的,並且會很清楚什麼是允許的,什麼是不允許的。

讓我們首先為應用程序中的每個狀態創建一個類型。 每種類型都會對可以將我們從給定狀態帶到其他地方的操作進行分組。 例如,我們的Form狀態有兩個向外的箭頭( SetCredentialsLogin ),所以讓我們用兩個動作的聯合創建FormAction類型。 對每個狀態重複該過程,您將得到:

 type FormAction = SetCredentials | Login; type LoggingInAction = ShowError | ShowApp; type SuccessAction = ...; type ErrorAction = BackToLoginForm; type Action = | FormAction | LoggingInAction | SuccessAction | ErrorAction;

接下來,定義我們需要的所有 reducer。 同樣,每個州一個:

 function reduceForm( state: Form, action: FormAction ): Form | LoggingIn { switch ( action.type ) { switch ( action.type ) { case 'SET_CREDENTIALS': return { status: 'ready', username: action.username, password: action.password, }; case 'LOGIN': return { ...state, status: 'logging-in' }; } } reduceLoggingIn: ( state: LoggingIn, action: LoggingInAction ) => Success | Error reduceError: ( state: Error, action: ErrorAction ) => Form reduceSuccess: ( state: Success, action: SuccessAction ) => ...

多虧了 TypeScript 和我們剛剛定義的類型,我們現在可以非常精確地了解源狀態和每個 reducer 期望的操作集,以及我們可以獲得的目標狀態。

最後,我們只需要用一個獨特的reducer函數將它們綁定起來:

 function reducer( state: State, action: Action ): State { switch ( state.status ) { case 'form': return reduceForm( state, action as FormActions ) ?? state; case 'logging-in': return reduceLoggingIn( state, action as LoggingInActions ) ?? state; case 'error': return reduceError( state, action as ErrorActions ) ?? state; case 'success': return reduceSuccess( state, action as SuccessActions ) ?? state; } }

在我看來,生成的代碼更清晰,更易於維護。 而且,最重要的是,TypeScript 現在能夠驗證我們正在做什麼並捕捉任何缺失的場景。 驚人的!

結論

在這篇文章中,我們了解了有限狀態機是什麼,以及我們如何使用 TypeScript 在@wordpress/data存儲中實現它。 我們的最終解決方案非常簡單,但它提供了很多優勢:它更好地捕獲了我們應用程序的狀態和意圖,並且利用了 TypeScript 的強大功能。

我希望你喜歡這篇文章! 如果你這樣做了,請與你的朋友和同事分享。 請在下面的評論部分告訴我你是如何解決這些問題的。

帕特里克·亨德利 (Patrick Hendry) 在 Unsplash 上的特色圖片。