使用指南
@ue-too/being 是一個型別安全的 TypeScript 有限狀態機函式庫。它提供以類別為基礎的方式來定義狀態、事件、轉換和守衛(guards),並在整個過程中提供完整的 TypeScript 型別推斷。
安裝
npm install @ue-too/being核心概念
@ue-too/being 中的狀態機由三個主要部分組成:
- 事件酬載映射(Event Payload Mapping) — 一個 TypeScript 型別,將事件名稱映射到其酬載型別
- 狀態(States) — 一個字串字面量聯合型別,代表所有可能的狀態
- 上下文(Context) — 一個共享物件(繼承自
BaseContext),所有狀態都可以存取
定義好這些之後,你需要建立狀態類別(繼承自 TemplateState)來定義每個狀態如何回應事件,然後將它們組裝成一個 TemplateStateMachine。
基本用法
以下是一個最小範例 — 一個具有三個狀態的放置工具:
第一步:定義事件、狀態和上下文
import {
BaseContext,
EventReactions,
NO_OP,
TemplateState,
TemplateStateMachine,
} from '@ue-too/being';
// 事件及其酬載型別
type PlacementEvents = {
startPlacement: {}; // 不需要酬載
pointerMove: { x: number; y: number };
leftPointerUp: { x: number; y: number };
escapeKey: {};
endPlacement: {};
};
// 所有可能的狀態
type PlacementStates = 'IDLE' | 'HOVER_FOR_START' | 'HOVER_FOR_END';
// 所有狀態共享的上下文
interface PlacementContext extends BaseContext {
startDrag: (position: { x: number; y: number }) => void;
updateDrag: (position: { x: number; y: number }) => void;
finishDrag: (position: { x: number; y: number }) => void;
cancelPlacement: () => void;
}重點:
- 酬載為
{}的事件在觸發時不需要傳入參數 - 具有特定型別(如
{ x: number; y: number })的事件需要傳入該酬載 - 上下文必須實作
BaseContext的setup()和cleanup()方法
第二步:定義狀態類別
每個狀態繼承自 TemplateState,並定義其 _eventReactions:
class IdleState extends TemplateState<
PlacementEvents,
PlacementContext,
PlacementStates
> {
protected _eventReactions: EventReactions<
PlacementEvents,
PlacementContext,
PlacementStates
> = {
startPlacement: {
action: NO_OP, // 無副作用
defaultTargetState: 'HOVER_FOR_START', // 轉換到此狀態
},
};
}
class HoverForStartState extends TemplateState<
PlacementEvents,
PlacementContext,
PlacementStates
> {
protected _eventReactions: EventReactions<
PlacementEvents,
PlacementContext,
PlacementStates
> = {
leftPointerUp: {
action: (context, event) => {
context.startDrag({ x: event.x, y: event.y });
},
defaultTargetState: 'HOVER_FOR_END',
},
escapeKey: {
action: (context) => context.cancelPlacement(),
defaultTargetState: 'IDLE',
},
endPlacement: {
action: (context) => context.cancelPlacement(),
defaultTargetState: 'IDLE',
},
};
}
class HoverForEndState extends TemplateState<
PlacementEvents,
PlacementContext,
PlacementStates
> {
protected _eventReactions: EventReactions<
PlacementEvents,
PlacementContext,
PlacementStates
> = {
pointerMove: {
action: (context, event) => {
context.updateDrag({ x: event.x, y: event.y });
},
// 沒有 defaultTargetState — 維持在 HOVER_FOR_END
},
leftPointerUp: {
action: (context, event) => {
context.finishDrag({ x: event.x, y: event.y });
},
defaultTargetState: 'HOVER_FOR_START',
},
escapeKey: {
action: (context) => context.cancelPlacement(),
defaultTargetState: 'HOVER_FOR_START',
},
};
}事件反應結構:
action— 事件發生時被呼叫的函式。接收(context, eventPayload, stateMachine)參數。不需要副作用時可使用NO_OP。defaultTargetState— (選填)action 執行後要轉換到的狀態。如果省略,狀態機會維持在目前的狀態。
第三步:組裝狀態機
const context: PlacementContext = {
startDrag(pos) { /* ... */ },
updateDrag(pos) { /* ... */ },
finishDrag(pos) { /* ... */ },
cancelPlacement() { /* ... */ },
setup() { /* 初始化資源 */ },
cleanup() { /* 釋放資源 */ },
};
const machine = new TemplateStateMachine<
PlacementEvents,
PlacementContext,
PlacementStates
>(
{
IDLE: new IdleState(),
HOVER_FOR_START: new HoverForStartState(),
HOVER_FOR_END: new HoverForEndState(),
},
'IDLE', // 初始狀態
context,
);建構子接受:
- 一個將每個狀態名稱映射到其狀態實例的 record
- 初始狀態名稱
- 上下文物件
- (選填)
autoStart— 預設為true。為true時會自動呼叫start(),會呼叫context.setup()並進入初始狀態。
第四步:觸發事件
machine.happens('startPlacement');
// 現在在 HOVER_FOR_START
machine.happens('leftPointerUp', { x: 100, y: 200 });
// 現在在 HOVER_FOR_END
machine.happens('pointerMove', { x: 150, y: 250 });
// 維持在 HOVER_FOR_END(沒有定義目標狀態)
machine.happens('leftPointerUp', { x: 200, y: 300 });
// 現在在 HOVER_FOR_STARThappens() 會回傳一個 EventResult:
const result = machine.happens('startPlacement');
if (result.handled) {
console.log('轉換到:', result.nextState);
} else {
console.log('事件未被目前的狀態處理');
}生命週期鉤子
狀態可以定義 uponEnter 和 beforeExit 方法,在轉換時執行邏輯:
class LayoutState extends TemplateState<Events, MyContext, States> {
private _subMachine: SomeStateMachine;
constructor(subMachine: SomeStateMachine) {
super();
this._subMachine = subMachine;
}
uponEnter(context: MyContext, stateMachine: StateMachine<...>, from: States | 'INITIAL') {
// 進入此狀態時被呼叫
this._subMachine.happens('startLayout');
}
beforeExit(context: MyContext, stateMachine: StateMachine<...>, to: States | 'TERMINAL') {
// 離開此狀態時被呼叫
this._subMachine.happens('endLayout');
}
protected _eventReactions = { /* ... */ };
}uponEnter接收轉換來源的狀態(首次進入時為'INITIAL')beforeExit接收即將轉換目標的狀態(wrapup 時為'TERMINAL')- 當狀態機維持在同一狀態時,這些鉤子不會被呼叫
狀態機生命週期
machine.start(); // 呼叫 context.setup(),進入初始狀態,觸發 uponEnter
machine.wrapup(); // 觸發目前狀態的 beforeExit,呼叫 context.cleanup()
machine.reset(); // 呼叫 wrapup(),然後 start() — 完全重新啟動你也可以監聽狀態變化:
machine.onStateChange((from, to) => {
console.log(`從 ${from} 轉換到 ${to}`);
});守衛:條件式轉換
守衛(Guards)讓你根據目前的上下文,有條件地選擇要轉換到哪個狀態。當同一事件根據執行時條件需要導向不同狀態時非常有用。
定義守衛
import {
EventGuards,
EventReactions,
Guard,
TemplateState,
} from '@ue-too/being';
type EditStates = 'INACTIVE' | 'IDLE' | 'DRAGGING';
type EditEvents = {
startEditing: {};
endEditing: {};
leftPointerDown: { x: number; y: number };
leftPointerUp: { x: number; y: number };
leftPointerMove: { x: number; y: number };
};
interface EditContext extends BaseContext {
projectOnTarget: (position: { x: number; y: number }) => boolean;
getCurrentPosition: () => { x: number; y: number };
setCurrentPosition: (position: { x: number; y: number }) => void;
}
class IdleState extends TemplateState<EditEvents, EditContext, EditStates> {
// 1. 定義守衛函式
protected _guards: Guard<EditContext, 'projectOnTarget'> = {
projectOnTarget: (context) => {
return context.projectOnTarget(context.getCurrentPosition());
},
};
// 2. 定義事件反應(action 先執行)
protected _eventReactions = {
leftPointerDown: {
action: (context: EditContext, payload: { x: number; y: number }) => {
context.setCurrentPosition(payload);
},
// 沒有 defaultTargetState — 如果沒有守衛匹配,維持在目前狀態
},
endEditing: {
action: NO_OP,
defaultTargetState: 'INACTIVE' as const,
},
} as EventReactions<EditEvents, EditContext, EditStates>;
// 3. 將守衛映射到事件和目標狀態
protected _eventGuards: Partial<
EventGuards<EditEvents, EditStates, EditContext, typeof this._guards>
> = {
leftPointerDown: [
{ guard: 'projectOnTarget', target: 'DRAGGING' },
],
};
}守衛的運作方式
當事件被處理時:
action函式先執行(例如,更新上下文中的位置)- 狀態機檢查該事件的
_eventGuards - 守衛按順序評估 — 第一個回傳
true的守衛決定目標狀態 - 如果沒有守衛匹配,使用
defaultTargetState(如果有定義的話) - 如果既沒有守衛匹配也沒有設定
defaultTargetState,狀態機維持在目前狀態
這對於拖放等場景非常強大:指標按下(pointer-down)的 action 總是記錄位置,但只有當指標在有效目標上時才會轉換到 DRAGGING 狀態。
事件委派與 Defer
Defer 允許狀態在其正常的 _eventReactions 被評估之前攔截事件。這通常用於將事件委派給子狀態機。
運作方式
當帶有 _defer 定義的狀態接收到事件時:
- 首先呼叫
_defer.action函式 - 如果回傳
{ handled: true },則事件被視為已處理,_eventReactions會被跳過 - 如果回傳
{ handled: false },事件會照常傳遞給_eventReactions
範例:委派給子狀態機
class LayoutToolState extends TemplateState<
ToolEvents,
ToolContext,
ToolStates
> {
private _layoutMachine: LayoutStateMachine;
constructor(layoutMachine: LayoutStateMachine) {
super();
this._layoutMachine = layoutMachine;
}
// Defer:先嘗試子狀態機
protected _defer: Defer<ToolContext, ToolEvents, ToolStates> = {
action: (context, event, eventKey, stateMachine) => {
const result = this._layoutMachine.happens(eventKey, event);
if (result.handled) {
return { handled: true, output: result.output };
}
return { handled: false };
},
};
// 生命週期:啟動/停止子狀態機
uponEnter(context: ToolContext, sm: any, from: ToolStates | 'INITIAL') {
this._layoutMachine.happens('startLayout');
}
beforeExit(context: ToolContext, sm: any, to: ToolStates | 'TERMINAL') {
this._layoutMachine.happens('endLayout');
}
// 自身的事件反應(只有在 defer 未處理事件時才會被觸及)
protected _eventReactions: EventReactions<ToolEvents, ToolContext, ToolStates> = {
switchToOtherTool: {
action: NO_OP,
defaultTargetState: 'OTHER_TOOL',
},
};
}這個模式實現了層次式組合 — 父狀態機將特定領域的事件委派給子狀態機,同時正常處理自己的事件(如工具切換)。
事件輸出映射
預設情況下,事件的 action 回傳 void。你可以定義事件輸出映射(Event Output Mapping),讓事件處理器回傳具有型別的值:
type MyEvents = {
calculate: { value: number };
reset: {};
};
// 定義每個事件的回傳值
type MyOutputMapping = {
calculate: number; // calculate 回傳數字
reset: void; // reset 不回傳任何值
};
type MyStates = 'IDLE' | 'ACTIVE';
class ActiveState extends TemplateState<
MyEvents,
MyContext,
MyStates,
MyOutputMapping // 第四個型別參數
> {
protected _eventReactions = {
calculate: {
action: (context, event) => {
return event.value * 2; // 回傳值的型別為 number
},
},
} as EventReactions<MyEvents, MyContext, MyStates, MyOutputMapping>;
}
// 使用方式:
const result = machine.happens('calculate', { value: 5 });
if (result.handled && 'output' in result) {
console.log(result.output); // 10,型別為 number
}EventOutputMapping 是 TemplateState、TemplateStateMachine 和相關型別的第四個泛型參數。
擴展現有的狀態機
@ue-too/being 中一個強大的模式是擴展現有的狀態機以添加新功能,而不修改原始程式碼。這在程式庫中被廣泛使用,用於取得基礎輸入狀態機並在其上層疊加特定工具的行為。
模式概述
這個方法包含:
- 合併事件型別 — 使用
&交集型別將原始事件與新事件合併 - 合併上下文型別 — 對上下文使用相同的方法
- 建立新的狀態類別,包裝原始狀態並透過
Defer添加新行為 - 使用型別轉換工具函式將現有狀態適配到擴展的型別系統
- 使用擴展型別組裝新的狀態機
逐步說明
假設你有一個來自函式庫的基礎輸入狀態機:
// 來自函式庫 — 你無法修改這些
type BaseEvents = {
leftPointerDown: { x: number; y: number };
leftPointerUp: { x: number; y: number };
pointerMove: { x: number; y: number };
scrollWheel: { deltaY: number };
};
type BaseStates = 'IDLE' | 'PAN' | 'DISABLED';
interface BaseInputContext extends BaseContext {
// ... 基礎上下文方法
}
// 原始狀態類別:BaseIdleState, PanState, DisabledState
// 原始工廠函式:createBaseInputStateMachine(context)1. 定義擴展型別
// 應用程式特定的新事件
type ToolSwitcherEvents = {
switchToLayout: {};
switchToTrain: {};
switchToIdle: {};
};
// 合併事件型別
type ExpandedEvents = BaseEvents & ToolSwitcherEvents;
// 合併上下文型別
type ExpandedContext = BaseInputContext & ToolSwitcherContext;2. 建立擴展的 Idle 狀態
關鍵是包裝原始的 IDLE 狀態,並透過 Defer 添加子狀態機委派:
class ExpandedIdleState extends TemplateState<
ExpandedEvents,
ExpandedContext,
BaseStates,
BaseOutputMapping
> {
private _toolSwitcherMachine: ToolSwitcherStateMachine;
constructor(toolSwitcherMachine: ToolSwitcherStateMachine) {
super();
// 實例化原始狀態
const originalIdle = new BaseIdleState();
// 複製其事件反應(使用型別轉換以適配擴展型別)
this._eventReactions = {
...originalIdle.eventReactions as unknown as EventReactions<
ExpandedEvents, ExpandedContext, BaseStates, BaseOutputMapping
>,
};
// 複製生命週期鉤子
this.uponEnter = originalIdle.uponEnter as unknown as (
context: ExpandedContext,
sm: TemplateStateMachine<ExpandedEvents, ExpandedContext, BaseStates, BaseOutputMapping>,
from: BaseStates | 'INITIAL'
) => void;
this.beforeExit = originalIdle.beforeExit as unknown as (
context: ExpandedContext,
sm: TemplateStateMachine<ExpandedEvents, ExpandedContext, BaseStates, BaseOutputMapping>,
to: BaseStates | 'TERMINAL'
) => void;
// 複製守衛
this._guards = originalIdle.guards as unknown as Guard<ExpandedContext>;
this._toolSwitcherMachine = toolSwitcherMachine;
}
// Defer:工具切換事件由子狀態機處理
protected _defer: Defer<ExpandedEvents, ExpandedContext, BaseStates, BaseOutputMapping> = {
action: (context, event, eventKey, stateMachine) => {
const result = this._toolSwitcherMachine.happens(
eventKey as keyof ToolSwitcherEvents,
event as ToolSwitcherEvents[keyof ToolSwitcherEvents]
);
if (result.handled) {
return { handled: true, output: result.output };
}
return { handled: false };
},
};
}3. 建立既有狀態的型別適配器
不需要修改的狀態仍然需要被轉換到擴展的型別系統:
import { State } from '@ue-too/being';
const createAdaptedState = <
OldState extends State<any, any, any, any>,
NewState extends State<any, any, any, any>,
>() => {
return (state: OldState): NewState => {
return state as unknown as NewState;
};
};
// 建立我們特定擴展的適配器
const expandState = createAdaptedState<
State<BaseEvents, BaseInputContext, BaseStates, BaseOutputMapping>,
State<ExpandedEvents, ExpandedContext, BaseStates, BaseOutputMapping>
>();4. 組裝擴展的狀態機
function createExpandedInputStateMachine(
toolSwitcherMachine: ToolSwitcherStateMachine,
context: ExpandedContext,
) {
const states = {
// 帶有子狀態機委派的擴展 IDLE 狀態
IDLE: new ExpandedIdleState(toolSwitcherMachine),
// 適配到擴展型別的原始狀態
PAN: expandState(new PanState()),
DISABLED: expandState(new DisabledState()),
};
return new TemplateStateMachine<
ExpandedEvents,
ExpandedContext,
BaseStates,
BaseOutputMapping
>(states, 'IDLE', context);
}執行時的運作方式
- 當
switchToLayout事件到達時,IDLE 狀態的_defer攔截它並委派給工具切換子狀態機 - 當
leftPointerDown事件到達時,_defer先將它傳給子狀態機。如果子狀態機不處理,它會傳遞給原始 IDLE 狀態的_eventReactions - 非 IDLE 狀態(PAN、DISABLED)的運作方式與之前完全相同 — 它們只看到它們處理的事件,其餘的則忽略
這個模式保留了原始狀態機的行為,同時在其上層疊加新功能,而不修改任何函式庫程式碼。
工具函式:CreateStateType
一個便利型別,從 readonly 陣列推導出字串字面量聯合型別:
import { CreateStateType } from '@ue-too/being';
const TOOL_STATES = ['LAYOUT', 'TRAIN', 'STATION', 'IDLE'] as const;
type ToolStates = CreateStateType<typeof TOOL_STATES>;
// 結果:'LAYOUT' | 'TRAIN' | 'STATION' | 'IDLE'當你在執行時也需要陣列(例如用於迭代或驗證),同時保持型別同步時非常有用。
API 參考
完整的 API 細節(所有類別、介面、型別和函式),請參閱 API 參考。