專案做到一半,需求突然加了「新流程」或「例外規則」,你把檔案打開,滿眼都是 if/else、switch,你也知道該整理,卻不敢動,因為一改就爆。其實你遇到的不是技術力不足,而是少了能「安置變化」的結構。
狀態模式(State Pattern)做的事很單純:把「行為會因狀態不同而改變」這件事拆開,讓每個狀態管理自己的規則與下一步轉移。
本文用 JavaScript 從概念、骨架、到前後端常見場景(訂單流程、UI 非同步、表單導引)一路實作,還會把常見雷點(事件沒清、非同步競賽、過度設計)攤開講。讀完你不只會「用」,也會知道「何時不用」,讓程式碼更穩、更能撐住變動。希望本篇文章可以幫助到需要的您。
目錄
{tocify} $title={目錄}
為什麼需要「狀態模式」?
軟體在運行過程中常會隨「狀態」而改變行為:
訂單:待付款 → 已付款 → 已出貨 → 已送達/已取消
UI 按鈕:idle → loading → success/error
播放器:stopped → playing → paused
使用者驗證:未登入 → 驗證中 → 已登入 → 逾期
如果用大量 if/else 或 switch 處理,邏輯很快會變得難以維護(分支爆炸、彼此耦合、需求一變就全檔案搜索)。
狀態模式(State Pattern) 提供一種「以物件封裝狀態行為」的結構:把每種狀態寫成獨立類別(或模組/物件),Context(上下文)把工作委派給當前狀態,並在必要時切換狀態。這能讓變動集中在對應狀態,讓程式碼更清晰、可測、更容易擴充。
狀態模式是什麼?(定義與和其他模式的區別)
定義:
當物件的內部狀態改變時,允許其改變行為,使其看起來像改變了類別一樣。
State vs Strategy
Strategy:
同一工作不同「策略」可替換(例如不同排序法),通常由外部選擇策略,且策略彼此不管理狀態轉移。
State:
重點在「狀態轉移」與「狀態驅動行為」。每個狀態關注下一步應轉去哪個狀態,Context 內部依事件切換。
State vs 一堆 if/else
if/else 早期可行,狀態一多、轉移一變就難以維護。
狀態模式以類別/物件封裝,局部化變動,避免散落的條件分支。
基本結構(以 Class 風格示意)
flowchart LR
A[Context] -->|委派| S1((StateA))
A -->|委派| S2((StateB))
S1 <--->|切換| S2
Context:持有當前 state,對外提供操作(如 pay() / ship()),內部把呼叫委派給 state。
State:每個具體狀態封裝專屬行為,必要時呼叫 context.setState(nextState) 切換。
範例一(暖身):登入流程微型示範
class AuthContext {
constructor() {
this.setState(new LoggedOut(this));
}
setState(state) { this.state = state; }
login(credentials) { return this.state.login(credentials); }
logout() { return this.state.logout(); }
}
class LoggedOut {
constructor(ctx) { this.ctx = ctx; }
login(credentials) {
// ...驗證中(略)
this.ctx.setState(new LoggedIn(this.ctx));
console.log('已登入');
}
logout() { console.log('本來就未登入'); }
}
class LoggedIn {
constructor(ctx) { this.ctx = ctx; }
login() { console.log('已登入狀態,不需再次登入'); }
logout() {
this.ctx.setState(new LoggedOut(this.ctx));
console.log('已登出');
}
}
const auth = new AuthContext();
auth.login({ user: 'a' }); // 已登入
auth.logout(); // 已登出
重點:Context 不知道「怎麼」登入,只把請求交給當前狀態處理。
範例二(核心示範):訂單狀態機(Created → Paid → Shipped → Delivered / Canceled)
此範例展示完整的狀態切換、違規操作的防範,以及擴充性。
1. 基礎骨架
class OrderState {
constructor(order) { this.order = order; }
pay() { this._deny('pay'); }
ship() { this._deny('ship'); }
deliver() { this._deny('deliver'); }
cancel() { this._deny('cancel'); }
_deny(action) {
throw new Error(`${this.constructor.name} 狀態不允許執行 ${action}()`);
}
toString() { return this.constructor.name; }
}
class Order {
constructor({ id }) {
this.id = id;
this.setState(new Created(this));
}
setState(state) {
this.state = state;
// 可在此統一打點紀錄、事件通知
console.log(`Order#${this.id} → 狀態切換為:${state}`);
}
get status() { return this.state.toString(); }
pay() { this.state.pay(); }
ship() { this.state.ship(); }
deliver() { this.state.deliver(); }
cancel() { this.state.cancel(); }
}
2. 具體狀態
class Created extends OrderState {
pay() {
// ...金流驗證/扣款
this.order.setState(new Paid(this.order));
}
cancel() {
// ...可直接取消(無需手續)
this.order.setState(new Canceled(this.order));
}
}
class Paid extends OrderState {
ship() {
// ...建立物流單
this.order.setState(new Shipped(this.order));
}
cancel() {
// ...可能要退費,或收取取消手續費
this.order.setState(new Canceled(this.order));
}
}
class Shipped extends OrderState {
deliver() {
// ...物流回傳完成
this.order.setState(new Delivered(this.order));
}
}
class Delivered extends OrderState {
// 終態,通常不允許任何變動
}
class Canceled extends OrderState {
// 終態
}
3. 使用示例與結果
const order = new Order({ id: 1001 });
order.pay(); // 切到 Paid
order.ship(); // 切到 Shipped
order.deliver(); // 切到 Delivered
// order.cancel(); // 會丟錯:Delivered 狀態不允許 cancel()
優點:
想新增 Returned(退貨)狀態?寫一個 Returned 類別,定義允許的轉移,Context 幾乎不用動。
規則更改(例如 Paid 才能取消、或需要審核)只動對應狀態類別。
範例三:前端 UI「下載按鈕」的非同步狀態(Idle/Loading/Success/Error)
UI/非同步是前端最常見的狀態用例。這裡示範如何把「視覺與互動」交由狀態類別管理,避免事件監聽器滿天飛。
<button id="downloadBtn">下載</button>
<span id="hint"></span>
<script>
class ButtonContext {
constructor(buttonEl, hintEl) {
this.buttonEl = buttonEl;
this.hintEl = hintEl;
this.controller = null; // AbortController 用於取消舊請求
this.setState(new Idle(this));
}
setState(state) {
// 讓舊狀態有機會清理(移除事件、取消請求)
this.state?.exit?.();
this.state = state;
this.state?.enter?.();
}
click() { this.state.click?.(); }
}
class Idle {
constructor(ctx) { this.ctx = ctx; }
enter() {
const { buttonEl, hintEl } = this.ctx;
buttonEl.disabled = false;
buttonEl.textContent = '下載';
hintEl.textContent = '';
this.onClick = () => this.ctx.click();
buttonEl.addEventListener('click', this.onClick);
}
exit() {
this.ctx.buttonEl.removeEventListener('click', this.onClick);
}
click() {
this.ctx.setState(new Loading(this.ctx));
}
}
class Loading {
constructor(ctx) { this.ctx = ctx; }
async enter() {
const { buttonEl, hintEl } = this.ctx;
buttonEl.disabled = true;
buttonEl.textContent = '下載中...';
hintEl.textContent = '請稍候';
this.ctx.controller?.abort();
this.ctx.controller = new AbortController();
try {
// 模擬下載
await new Promise((res) => setTimeout(res, 1000));
if (this.ctx.controller.signal.aborted) return;
this.ctx.setState(new Success(this.ctx));
} catch (e) {
this.ctx.setState(new Failed(this.ctx, e));
}
}
}
class Success {
constructor(ctx) { this.ctx = ctx; }
enter() {
const { buttonEl, hintEl } = this.ctx;
buttonEl.disabled = false;
buttonEl.textContent = '重新下載';
hintEl.textContent = '完成 ✅';
// 回到 Idle 以允許再次點擊
this.timer = setTimeout(() => this.ctx.setState(new Idle(this.ctx)), 1500);
}
exit() { clearTimeout(this.timer); }
}
class Failed {
constructor(ctx, error) { this.ctx = ctx; this.error = error; }
enter() {
const { buttonEl, hintEl } = this.ctx;
buttonEl.disabled = false;
buttonEl.textContent = '重試';
hintEl.textContent = '失敗,請重試';
this.onClick = () => this.ctx.setState(new Loading(this.ctx));
buttonEl.addEventListener('click', this.onClick);
}
exit() { this.ctx.buttonEl.removeEventListener('click', this.onClick); }
}
const ctx = new ButtonContext(
document.getElementById('downloadBtn'),
document.getElementById('hint')
);
</script>
重點:
每個狀態可實作 enter()/exit(),集中管理事件與資源(避免記憶體外洩)。
切換狀態前先 exit(),切換後 enter(),維持 UI 正確性。
使用 AbortController 避免非同步競賽問題(舊請求完成覆蓋新狀態)。
範例四:用「函式/物件表」打造輕量化的有限狀態機(FSM)
不一定要用 class。對於小型邏輯,以純物件和對映表也很乾淨。
function createMachine({ initial, states }) {
let current = initial;
const listeners = new Set();
return {
state: () => current,
send(event, payload) {
const stateDef = states[current];
const transition = stateDef.on?.[event];
if (!transition) throw new Error(`狀態 ${current} 不支援事件 ${event}`);
const target = typeof transition === 'function'
? transition(payload)
: transition;
if (!states[target]) throw new Error(`未知狀態:${target}`);
const prev = current;
current = target;
listeners.forEach(fn => fn({ from: prev, to: current, event, payload }));
},
onTransition(fn) { listeners.add(fn); return () => listeners.delete(fn); }
};
}
const machine = createMachine({
initial: 'idle',
states: {
idle: { on: { CLICK: 'loading' } },
loading:{ on: { OK: 'success', FAIL: 'error' } },
success:{ on: { RESET: 'idle' } },
error: { on: { RETRY: 'loading', RESET: 'idle' } },
}
});
machine.onTransition(t => console.log(`${t.from} -> ${t.to}`));
machine.send('CLICK'); // idle -> loading
machine.send('OK'); // loading -> success
此風格簡潔、易序列化,適合表單流程、向導(wizard)、簡單 UI。
實戰設計要點與最佳實務
1. 把「允許的操作」留在各狀態
避免在 Context 內寫一堆 if (status === 'xx')。行為應由狀態決定,Context 只負責轉送與持有。
2. 集中轉移點,明確失敗即早拋錯
若某操作在此狀態不合法,要立即拋錯或回傳可判讀的結果,避免靜默失敗。
3. 為狀態提供 enter()/exit() 生命週期
事件監聽、計時器、非同步請求都應在狀態內管理並在 exit() 清理,避免記憶體外洩。
4. 為非同步轉移設計「防競賽」機制
使用 AbortController、時間戳記、或自增版本號來忽略過期結果。
5. 留監控點
在 setState() 或 onTransition() 統一打點(log/metrics),便於問題追查。
6. 避免狀態爆炸
若只是旗標(例如一個布林就能描述),不必上狀態模式。
把「變化小、關聯弱」的行為切開,別全往狀態塞。
7. 命名清楚
使用「動名詞/完成式」的狀態名(如 Created、Paid、Loading、Error),讓語意直覺。
8. 寫測試
對「轉移表」或「狀態行為」做單元測試,特別是非法路徑(應拋錯)與非同步競賽。
常見錯誤與雷點
1. Context 內到處 if (state === ...)
問題:回到條件分支地獄,狀態模式形同虛設。
修正:把操作搬進狀態類別,Context 只調用 this.state.xxx()。
2. 狀態切換時忘記清理事件/計時器
問題:反覆進出狀態導致監聽疊加、記憶體外洩。
修正:導入 enter()/exit();在 exit() 移除監聽與清除計時器。
class SomeState {
enter() { this.on = () => {}; window.addEventListener('resize', this.on); }
exit() { window.removeEventListener('resize', this.on); }
}
3. 非同步回來覆蓋新狀態(競賽)
問題:舊請求晚回來把 UI 改壞。
修正:Abort/版本號策略。
let version = 0;
async function load() {
const v = ++version;
const data = await fetch(...).then(r=>r.json());
if (v !== version) return; // 被新請求取代,丟棄
// 正常更新
}
4. 狀態之間的互相引用過多
問題:耦合過高、難以測試。
修正:狀態只持有 context 的最小介面(必要方法);將可共用邏輯抽出工具模組。
5. 「終態」仍允許隱性轉移
問題:如 Delivered 還能 cancel(),造成資料不一致。
修正:終態明確覆寫所有操作並拋錯;可考慮把終態設為不可變(不再外部可見的 setter)。
6. 過度設計
問題:只有兩三種狀態或極少轉移,卻硬上狀態模式。
修正:先用簡單 switch 或旗標;當需求成長再抽象化。
7. 錯把 Strategy 當 State
問題:需要的是「可替換算法」,不是「隨狀態轉移」。
修正:若沒有「狀態→狀態」轉換,就考慮 Strategy。
延伸:把「規則」抽到配置,做成可視化轉移表(進階思路)
當商業規則頻繁改動時,可考慮:
把狀態與轉移表存為 JSON/設定檔,由後端或管理後台驅動;
前端載入配置後,以「函式表/FSM」模式運行;
在 onTransition 中把每次轉移記錄到日誌,形成審計追蹤(audit trail)。
此作法能讓產品快速試錯,前端亦可共用同一套轉移表於多模組。
問題集
Q1:只有「開/關」兩種狀態也需要 State Pattern 嗎?
A:通常不用。布林或一個小 switch 就夠。當狀態/轉移變複雜、行為差異大時再上狀態模式。
Q2:我有 Redux/Vuex,還需要 State Pattern 嗎?
A:狀態管理庫是「資料狀態(store)」;狀態模式處理「行為隨狀態改變」。兩者不同層次,可互補。
Q3:能在同一 Context 放多個子狀態嗎?
A:可以。例如播放頁有「媒體狀態」與「網路狀態」,分別各一套狀態機,互相獨立或以事件溝通。
結語
狀態模式的核心價值不在「炫技」,而在把複雜度局部化:
行為由狀態決定,Context 保持乾淨;
新增/修改規則只動對應狀態,風險小;
UI/非同步場景透過 enter()/exit() 管理資源,性能與穩定度兼顧。
無論是訂單流程、前端互動、或後端工作流,只要「行為隨狀態變化」,就值得把狀態模式放進你的工具箱。
延伸閱讀推薦:
javaScript設計模式 : Factory Method (工廠方法)
javaScript設計模式 : Abstract Factory(抽象工廠)
javaScript設計模式 : Builder(建造者模式)
javaScript設計模式 : Prototype(原型)
javaScript設計模式 : Singleton (單例模式)
javaScript設計模式 : Adapter(轉接器模式)
javaScript設計模式 : Bridge( 橋接模式 )
javaScript設計模式 : Composite(組合模式)
javaScript設計模式 : Decorator(裝飾者)
javaScript設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
