網站首頁 編程語言 正文
在 React 中使用有限狀態機,似乎不是一個尋常的話題。因為有限狀態機通常和前端關系不大。 但是最近我發現了一個非常棒的技巧,可以在復雜的 React 項目中發揮有限狀態機的作用。可以很好的提高程序的安全性。 下面我們就來看看吧。
什么是有限狀態機?
有限狀態機,英文是 Finite State Machine,簡稱 FSM,有時候也被稱為有限狀態自動機(Finite State AutoMation)。它是一種描述系統行為的數學計算模型,也就是一種抽象機。它可以替代圖靈機等其他類型的模型。 有限狀態機由給定對象的所有可能狀態以及它們之間的轉換組成。和圖靈機相比,它的計算能力較低。這種計算能力的區別意味著 FSM 的計算能力更加有限,因為它具有有限數量的狀態,不如的話就是無限狀態機了。 更重要的是:狀態機的一條規則是:它在任何時候都只處于一種狀態。 由于有限狀態機會根據適當的組合或預定的時間順序產生某些動作,因此我們可以在現代社會的任何地方找到有限狀態機的影子。這些包括自動售貨機、電梯,甚至是紅綠燈。
有限狀態機的示例
一個現實中完美匹配有限狀態機的例子是紅綠燈。我們來分析一下紅綠燈的工作方式: 它有四種狀態:
- 停車-紅燈。
- 準備開車-紅燈和黃燈。
- 開車-綠燈。
- 準備停車-黃燈。
它有四種狀態的轉換:
- 停車->準備開車。
- 準備開車->開車
- 開車->準備停車。
- 準備停車->停車。
我們可以看到,我們有有限數量的狀態和狀態的轉換。另外,紅綠燈在任何時候都只能處于一種狀態。這意味著我們在這處理的是有限狀態機。 更重要的是,通過實現有限狀態機,我們可以保證,模型不會發生意外。以紅綠燈為例,紅綠燈絕對不會出現直接從綠燈轉換成紅燈的情況。
有限狀態機和軟件開發、計算機科學有什么關系?
其實有很多關系。特別是游戲開發,很多游戲中都會大量使用有限狀態機。 舉個例子,大家應該都玩過超級馬里奧這款 2D 游戲。馬里奧在游戲里可以做什么呢? 他可以: 靜止、朝右走、朝左走、跳躍。 從代碼的角度來看,它對應的就是搖桿事件。
- 什么都不按-默認設置靜止狀態。
- 按左鍵-觸發設置朝左走狀態的朝左走事件。
- 按右鍵-觸發設置朝右走狀態的朝右走事件。
- 按跳躍鍵-觸發設置跳躍狀態的跳躍事件。
- 松開按鍵-觸發設置靜止狀態的靜止事件。
舉了那么多例子,但我該怎么展示在前端開發中使用狀態機呢?
無論是從上面的概念還是具體的場景,我都是想保證你對有限狀態機有一個了解。 接下來我來講講有限狀態機在前端的應用場景。 首先我得承認,在前端開發中有限狀態機并不是那么常見。我認為這個現象的主要原因是因為它不是實現功能最簡單也不是最快的方法。 這有點像 TypeScript,它會讓你慢一點,它會帶來一些復雜性。但最終每個人都會從中受益。 為了證明我的這種觀點并非毫無根據,我會展示一個我曾經開發的 React 項目中使用有限狀態機的示例。 這是一個很簡單的注冊表單,分為三個部分。每個部分都會根據當前填寫的進度進行渲染。
React 應用程序中的注冊表單的傳統實現方式
我先快速演示一下我實現上述表單功能的方法。 首先,我要定義所有的組件以及初始狀態。
const Step = {
Company: 0,
Director: 1,
Contact: 2,
} as const;
const Views = [<CompanyDataFormPart />, <DirectorDataFormPart />, <ContactDataFormPart />];
const initialStep = Step.Account
接下來我們定義狀態:
const [currentStep, setCurrentStep] = useState<number>(initialStep)
最后是組件本身:
<>
<div className="stepsContainer">
<Steps current={currentStep} labelPlacement="vertical" size="small">
{Object.keys(Step).map(s => (
<Steps.Step title={s} />
))}
</Steps>
</div>
<Spacer />
<FormPart
onPrevious={() => {
setCurrentStep(prev => prev - 1);
}}
onNext={() => {
setCurrentStep(prev => prev + 1);
}}
>
{Views[currentStep]}
</FormPart>
</>
這一切看上去似乎很正常,表單可以切換到下一個和上一個步驟。但這里存在一個很明顯的錯誤。 那就是程序沒有考慮邊界問題。這意味著 currentStep 的值可能超過最大步驟,也就是 2,也可能低于 0。 如果要修復它,我們會寫出下列代碼:
onPrevious={() => {
setCurrentStep(prev => Math.max(prev - 1, 0))
}}
onNext={()=>{
setCurrentStep(prev => Math.min(prev + 1, Views.length - 1))
}}
還有其他風險
這個代碼運行起來確實沒有問題,但還是會有一些潛在風險。 在軟件開發中,很少會出現你一個人負責整個項目的情況,一般來說是一整個團隊在協作,這也就意味著有許多其他開發人員會檢查你的代碼,并且會視圖理解它并可能會修改它。 我們假設有個人在表單頂部寫了一個方法,直接跳到了第三步。
const functionWithBadLogic = () => {
setCurrentStep(3);
}
這是一個很好的反面教材,第三步在我們的表單中壓根就不存在。 另外一個例子是下面這樣的:
onNext={() => {
setCurrentStep(prev => Math.min(prev + 2, Views.length -1))
}}
在這個代碼中有什么問題嗎?如果給定順序中需要所有的步驟,為什么會有人跳過一個步驟? 這是最后一個例子:
const Step = {
Company: 0,
Director: 1,
Contact: 2,
} as const;
const Views = [
<CompanyDataFormPart />,
<DirectorDataFormPart />,
<ContactDataFormPart />,
<div>I should not be there!</div>
]
這些錯誤中的任何一個投入生產都可能會出現下面這種情況:
這似乎看上去不是什么大問題
也許確實不是什么大問題。但是你要知道,我的例子是很簡單的一個流程。在真實的項目中,會有更大更復雜的惡項目,例如用于銀行和匯款的金融類程序,用于審批和工作流的辦公類程序。 如果我們在一個地方定義所有可能出現的狀態和狀態之間的轉換,會不會更容易?類似于某種約定,我們可以很容易的查看其中的整個邏輯,并且確保不會發生其他任何約定之外的事情。 實現這種模式的那個東西就是有限狀態機。
把表單重構為有限狀態機
首先,我們先來只關注 onNext 和 onPrevious 函數。我們想要制造一臺機器,我們用下面的狀態和事件來描述它的行為,也就是為這臺機器的特性設計一個模型。
狀態
- company
- director
- contact
事件
- next:按順序切換到下一個狀態。
- prev:按順序切換到上一個狀態。
實現起來像下面這樣:
const formMachine = createMachine({
id: 'formState',
initial: 'company',
states: {
company: {
on: {
next: { target: 'director' }
}
},
director: {
on: {
previous: { target: 'company' },
next: { target: 'contact' },
},
},
contact: {
on: {
previous: { target: 'director' },
},
},
}
})
現在讓我們來分析一下這段代碼。 createMachine 方法接受由 id、initial 和 states 共同組成的對象,這三個字端的作用分別是:
- id:唯一標識符。
- initial:初始狀態
- states:所有狀態,其中的鍵是狀態的名稱,值是描述狀態的對象。
接下來我們再分析一下 director 這個狀態:
- 它有一個名字,叫做 director。
- 它可以對兩個事件做出反應:previous 事件,將狀態轉換為 company。next 事件,將狀態設置為 contacct。
使用 xstate 將有限狀態機可視化
感謝 xstate 的開發人員,我們可以將上面的代碼粘貼到 xstate 的在線可視化編輯器中。這個工具可以展示出有限狀態機的所有可能的狀態和事件。 我們的狀態機是這個樣子:
我承認為了實現這樣一個簡單的小功能編寫這么多代碼似乎有些過度設計,但是我們繼續往下看,我保證你會相信使用有限狀態機是值得的。
通過 9 個步驟完成重構
我們實現了有限狀態機,但是我們還沒有做最重要的事情。我們必須重構渲染邏輯。 接下來我要為有限狀態機實現一些上下文。
步驟1: 為上下文添加類型定義
type Context = {
currentView: ReactNode;
}
步驟2: 添加將狀態映射到組件的函數
const mapStateToComponent: Record<string, ReactNode> = {
company: <CompanyDataFormPart />,
director: <DirectorDataFormPart />,
contact: <ContactDataFormPart />,
}
步驟3: 將上下文添加到有限狀態機的定義中
context: {
currentView: <CompanyDataFormPart />,
}
步驟4: 定義一個將改變上下文的函數
const changeComponent = assign<Context>({
currentViewe: (context, event, { action }) => {
return mapStateToComponent[action.payload as string];
}
})
步驟5: 將這個函數添加到有限狀態機的 actions 中
{
actions: {
changeComponent,
}
}
步驟6: 將由 previous 和 next 事件觸發這個操作
{
director: {
on: {
previous: {
target: 'company',
actions: {
type: 'changeComponent',
payload: 'company'
}
},
next: {
target: 'contact',
actions: {
type: 'changeComponent',
payload: 'contact'
}
}
}
}
}
步驟7: 向組件添加 useMachine Hook
const [current, send] = useMachine(formMachine)
步驟8: 通過 onPrevious 和 onNext 函數將事件發送到有限狀態機
onPrevious={() => {
send('previous');
}}
onNext={() => {
send('next');
}}
步驟9: 渲染當前狀態對應的組件
{current.context.currentView}
我們馬上就要完成了!
有限狀態機的安全性,使得我們的表單同樣安全
我們再回來看看之前舉的最后一個反例。
const Step = {
Company: 0,
Director: 1,
Contact: 2,
} as const;
const Views = [
<CompanyDataFormPart />,
<DirectorDataFormPart />,
<ContactDataFormPart />,
<div>I should not be there!</div>
]
可以看到,Step 和 Views 是解耦的。我們通過逐步渲染分頁進度面板的值來進行映射,并且使用當前索引來渲染 Views 數組中的元素。 在我們的有限狀態機中如何用更好的方式來實現這一點? 我們首先來稍微改變一下上下文。
export type View = {
Component: ReactNode;
step: number;
}
export type Context = {
currentView: View;
}
接下來修改一下 mapStateToComponent 這個函數,順便把函數名也改掉。
const mapStateToView: Record<string, View> = {
company: {
Component: <CompanyDataFormPart />,
step: 0,
},
director: {
Component: <DirectorDataFormPart />,
step: 1,
},
contact: {
Component: <ContactDataFormPart />,
step: 2,
},
};
最后為我們的有限狀態機添加一些類型,將類型和 actions 移到不同的文件里。 現在我們的代碼像下面這樣: formMachine.types.ts
import { ReactNode } from 'react';
import { StateNode } from 'xstate';
export type Event = { type: 'NEXT' } | { type: 'PREVIOUS' };
export type View = {
Component: ReactNode;
step: number;
};
export type Context = {
currentView: View;
};
export type State = {
states: {
company: StateNode;
director: StateNode;
contact: StateNode;
};
};
formMachine.actions.ts
import { assign } from 'xstate';
import { CompanyDataFormPart } from '../components/CompanyDataFormPart/CompanyDataFormPart';
import { ContactDataFormPart } from '../components/ContactDataFormPart/ContactDataFormPart';
import { DirectorDataFormPart } from '../components/DirectorDataFormPartt/ContactDataFormPart';
import { Context, View } from './formMachine.types';
export const mapNameToView: Record<string, View> = {
company: {
Component: <CompanyDataFormPart />,
step: 0,
},
director: {
Component: <DirectorDataFormPart />,
step: 1,
},
contact: {
Component: <ContactDataFormPart />,
step: 2,
},
};
export const changeView = assign<Context, Event>({
currentView: (_context, _event, { action }) => {
if (typeof action.payload !== 'string') {
throw new Error('Action payload should be string');
}
return mapNameToView[action.payload];
},
});
formMachine.ts
import { MachineConfig, MachineOptions, createMachine } from 'xstate';
import { mapNameToView, changeView } from './formMachine.actions';
import { State, Context } from './formMachine.types';
const initialStateName = 'company';
const formMachineConfig: MachineConfig<Context, State, Event> = {
id: 'formState',
initial: initialStateName,
context: {
currentView: mapNameToView[initialStateName],
},
states: {
company: {
on: {
NEXT: { target: 'director', actions: { type: 'changeView', payload: 'director' } },
},
},
director: {
on: {
PREVIOUS: { target: 'company', actions: { type: 'changeView', payload: 'company' } },
NEXT: { target: 'contact', actions: { type: 'changeView', payload: 'contact' } },
},
},
contact: {
on: {
PREVIOUS: { target: 'director', actions: { type: 'changeView', payload: 'director' } },
},
},
},
};
const formMachineOptions: Partial<MachineOptions<Context, Event>> = {
actions: { changeView },
};
export const formMachine = createMachine(formMachineConfig, formMachineOptions);
export const formMachineStates = Object.keys(formMachine.states);
App.tsx
import React from 'react';
import { useMachine } from '@xstate/react';
import Steps from 'rc-steps';
import { FormPart } from './components/FormPart/FormPart';
import { Spacer } from './components/Spacer/Spacer';
import { formMachine, formMachineStates } from './formMachine/formMachine';
function App() {
const [current, send] = useMachine(formMachine);
return (
<div className="app">
<div className="stepsContainer">
<Steps current={current.context.currentView.step} labelPlacement="vertical" size="small">
{formMachineStates.map(s => (
<Steps.Step title={s} key={s} />
))}
</Steps>
</div>
<Spacer />
<FormPart
onPrevious={() => {
send('PREVIOUS');
}}
onNext={() => {
send('NEXT');
}}
>
{current.context.currentView.Component}
</FormPart>
</div>
);
}
在 React 中使用有限狀態機的概括
可能你會說,“它看起來仍然很復雜。”。但是請你記住,如果你正在為一家大公司研發一個非常重要的項目,那其中一點微小的錯誤都可能會導致非常嚴重的資金損失。 最后我總結一下使用有限狀態機的幾個優勢:
- 類型安全。我們永遠都不會使用在類型定義中的狀態以外的狀態,否則會編譯錯誤。
- 不會有錯誤的狀態和錯誤的轉換。如果不改變有限狀態機的定義,那么不可能有人能夠做到從第1步直接跳轉到第3步。
- 所有的邏輯都在一個位置進行描述。
原文鏈接:https://juejin.cn/post/7099666477208305671
相關推薦
- 2022-04-10 Blazor組件事件處理功能_基礎應用
- 2022-07-13 淺談Redis中的自動過期機制_Redis
- 2022-12-29 React使用公共文件夾public問題_React
- 2022-11-14 Redis主從復制分步講解使用_Redis
- 2023-03-26 WPF使用觸發器需要注意優先級問題解決_C#教程
- 2022-10-31 .Net中的Http請求調用詳解(Post與Get)_實用技巧
- 2022-07-04 Android自定義view利用PathEffect實現動態效果_Android
- 2023-03-26 spring?boot整合redis中間件與熱部署實現代碼_Redis
- 最近更新
-
- window11 系統安裝 yarn
- 超詳細win安裝深度學習環境2025年最新版(
- Linux 中運行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎操作-- 運算符,流程控制 Flo
- 1. Int 和Integer 的區別,Jav
- spring @retryable不生效的一種
- Spring Security之認證信息的處理
- Spring Security之認證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權
- redisson分布式鎖中waittime的設
- maven:解決release錯誤:Artif
- restTemplate使用總結
- Spring Security之安全異常處理
- MybatisPlus優雅實現加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務發現-Nac
- Spring Security之基于HttpR
- Redis 底層數據結構-簡單動態字符串(SD
- arthas操作spring被代理目標對象命令
- Spring中的單例模式應用詳解
- 聊聊消息隊列,發送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠程分支