javaScript設計模式 : State(狀態模式)

 


專案做到一半,需求突然加了「新流程」或「例外規則」,你把檔案打開,滿眼都是 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設計模式 : Facade(外觀模式)

javaScript設計模式 : Flyweight(享元模式)

javaScript設計模式 : Proxy(代理模式)

javaScript設計模式 : Chain of Responsibility(責任鏈)

javaScript設計模式 : Command Pattern(命令模式)

javaScript設計模式 : Interpreter(直譯器)

javaScript設計模式 : Iterator(迭代器)

javaScript設計模式 : Mediator(仲裁者)

javaScript設計模式 : Memento(備忘錄)

javaScript設計模式 : Observer( 觀察者 )

javaScript設計模式 : Strategy(策略模式)

javaScript設計模式 : Template Method (模板方法)

javaScript設計模式 : Visitor(訪問者模式)

張貼留言 (0)
較新的 較舊