Day 28-30:JavaScript 設計模式:工廠函式與抽象工廠

影片資源

淺談 Javascript 設計模式 #2 創建:工廠函式與抽象工廠

影片來源:Alex 宅幹嘛 - 淺談 Javascript 設計模式 #2

前言

工廠函式 (Factory Function) 和抽象工廠 (Abstract Factory Pattern) 是創建型設計模式,核心目的是將「物件建立邏輯」與「物件使用邏輯」分離,常用於實踐 SOLID 原則中的開閉原則 (Open-Closed Principle) 和依賴反轉原則 (Dependency Inversion Principle)。

核心概念對照

工廠函式與抽象工廠的差異

特性

工廠函式 (Factory Function)

抽象工廠 (Abstract Factory)

定義

一個函式負責建立並回傳物件,封裝建立邏輯

建立一系列相關物件的工廠集合

實作方式

純函式,根據參數回傳不同配置的物件

先定義工廠介面,再根據條件回傳對應的具體工廠

生產對象

單一產品類型

產品族(一系列相關產品)

抽象層級

較低,抽象物件如何被創建

較高,抽象工廠如何被創建

使用時機

需要建立多個相似物件,且屬性需動態決定

需要支援多個平台/主題,每個平台都有一整套相關元件

範例情境

只生產漢堡

生產整套速食套餐(漢堡+飲料+點心)

工廠函式實作

工廠函式是一個回傳物件的普通函式,不需要使用 new 關鍵字。

基礎實作:從純函式到工廠函式

// 步驟 1:純函式建立格式化字串
function getBurgerInfo(bread, meat, price) {
  return `${bread} + ${meat} 漢堡, 價格 $${price}`;
}

const info = getBurgerInfo('白麵包', '牛肉', 89);
// "白麵包 + 牛肉 漢堡, 價格 $89"
// 步驟 2:解構參數,接收物件
function getBurgerInfo(burger) {
  const { bread, meat, price } = burger;
  return `${bread} + ${meat} 漢堡, 價格 $${price}`;
}

const cheeseBurger = {
  bread: '白麵包',
  meat: '牛肉',
  price: 89,
};

getBurgerInfo(cheeseBurger);
// "白麵包 + 牛肉 漢堡, 價格 $89"
// 步驟 3:使用閉包建立工廠函式
function createBurger(bread, meat, price) {
  return {
    getInfo() {
      return `${bread} + ${meat} 漢堡, 價格 $${price}`;
    },
  };
}

const cheeseBurger = createBurger('白麵包', '牛肉', 89);
const chickenBurger = createBurger('全麥麵包', '雞肉', 79);

console.log(cheeseBurger.getInfo()); // "白麵包 + 牛肉 漢堡, 價格 $89"
console.log(chickenBurger.getInfo()); // "全麥麵包 + 雞肉 漢堡, 價格 $79"

封裝固定配方:實踐開閉原則

// 基礎漢堡工廠
function createBurger(breadType, meatType, price) {
  return {
    bread: breadType,
    meat: meatType,
    price,
    getDescription() {
      return `${breadType}配${meatType}`;
    },
  };
}

// 大麥克專屬工廠(封裝固定配方)
function createBigMac() {
  return createBurger('白麵包', '雙層牛肉', 119);
}

// 無敵豬肉堡工廠
function createMcPork() {
  return createBurger('芝麻麵包', '豬肉排', 59);
}

const bigMac = createBigMac();
const mcPork = createMcPork();

console.log(bigMac.getDescription()); // "白麵包配雙層牛肉"

符合開閉原則:當需要新增「勁辣雞腿堡」時,只需新增 createSpicyChicken() 函式,無需修改既有的 createBurger 邏輯。

抽象工廠實作

抽象工廠用於建立一系列相關的產品物件。

建立產品工廠

// 產品工廠:只負責建立物件
function createBurger(breadType, meatType) {
  return {
    type: 'burger',
    bread: breadType,
    meat: meatType,
  };
}

function createDessert(name, calories) {
  return {
    type: 'dessert',
    name,
    calories,
  };
}

function createDrink(name, size) {
  return {
    type: 'drink',
    name,
    size,
  };
}

具體工廠:麥當勞套餐

function createMcDonaldsMeal(mealType) {
  if (mealType === 'classic') {
    const burger = createBurger('白麵包', '牛肉');
    const dessert = createDessert('蘋果派', 250);
    const drink = createDrink('可口可樂', '中杯');

    return {
      brand: 'McDonalds',
      mealType: 'classic',
      burger,
      dessert,
      drink,
      describe() {
        return `麥當勞經典套餐: ${burger.meat}堡 + ${dessert.name} + ${drink.name}`;
      },
    };
  }

  if (mealType === 'healthy') {
    const burger = createBurger('全麥麵包', '烤雞胸');
    const dessert = createDessert('水果切盤', 80);
    const drink = createDrink('無糖茶', '大杯');

    return {
      brand: 'McDonalds',
      mealType: 'healthy',
      burger,
      dessert,
      drink,
      describe() {
        return `麥當勞健康套餐: ${burger.meat}堡 + ${dessert.name} + ${drink.name}`;
      },
    };
  }

  return null;
}

具體工廠:肯德基套餐

function createKFCMeal(mealType) {
  if (mealType === 'classic') {
    const burger = createBurger('香酥麵包', '炸雞排');
    const dessert = createDessert('蛋塔', 200);
    const drink = createDrink('百事可樂', '中杯');

    return {
      brand: 'KFC',
      mealType: 'classic',
      burger,
      dessert,
      drink,
      describe() {
        return `肯德基經典餐: ${burger.meat}堡 + ${dessert.name} + ${drink.name}`;
      },
    };
  }

  if (mealType === 'spicy') {
    const burger = createBurger('辣味麵包', '香辣雞腿');
    const dessert = createDessert('霜淇淋', 180);
    const drink = createDrink('七喜', '大杯');

    return {
      brand: 'KFC',
      mealType: 'spicy',
      burger,
      dessert,
      drink,
      describe() {
        return `肯德基辣味餐: ${burger.meat}堡 + ${dessert.name} + ${drink.name}`;
      },
    };
  }

  return null;
}

抽象工廠:統一介面

function createRestaurantMeal(brand, mealType) {
  if (brand === 'McDonalds') {
    return createMcDonaldsMeal(mealType);
  }

  if (brand === 'KFC') {
    return createKFCMeal(mealType);
  }

  // 未來可擴充其他品牌
  return null;
}

// 使用範例
const mcClassic = createRestaurantMeal('McDonalds', 'classic');
const kfcSpicy = createRestaurantMeal('KFC', 'spicy');

console.log(mcClassic.describe());
// "麥當勞經典套餐: 牛肉堡 + 蘋果派 + 可口可樂"

console.log(kfcSpicy.describe());
// "肯德基辣味餐: 香辣雞腿堡 + 霜淇淋 + 七喜"

在此架構中:

  • createRestaurantMeal 是抽象工廠

  • createMcDonaldsMeal 和 createKFCMeal 是具體工廠

  • 每個具體工廠生產一個產品家族(漢堡 + 甜點 + 飲料)

工廠函式與閉包

閉包的角色

工廠函式不一定需要閉包,但當需要私有變數時,閉包成為核心機制。

概念

定義

在工廠函式中的作用

閉包 (Closure)

函式與其詞法環境的組合,內層函式可記住外層變數

讓工廠函式建立的物件擁有私有狀態

工廠函式 (Factory Function)

負責建立並回傳物件的函式

提供統一的物件建立介面

IIFE 與閉包的結合

立即呼叫函式表達式 (Immediately Invoked Function Expression, IIFE) 常與閉包結合,用於建立私有作用域。

const counter = (function () {
  let privateCounter = 0;

  function changeBy(val) {
    privateCounter += val;
  }

  return {
    increment() {
      changeBy(1);
    },
    decrement() {
      changeBy(-1);
    },
    value() {
      return privateCounter;
    },
  };
})();

console.log(counter.value()); // 0
counter.increment();
counter.increment();
console.log(counter.value()); // 2
counter.decrement();
console.log(counter.value()); // 1

運作原理

當 IIFE 執行時:

  1. 建立執行上下文:JavaScript 引擎建立新的 Execution Context

  2. 建立環境記錄:建立 Declarative Environment Record 儲存 privateCounter 和 changeBy

  3. 回傳物件:IIFE 回傳包含三個方法的物件

  4. 閉包保留參照:三個方法持有對環境記錄的參照,privateCounter 不會被垃圾回收

關鍵特性:

  • counter 儲存的是 IIFE 回傳的物件(包含三個方法)

  • privateCounter 存在於 IIFE 建立的詞法環境中

  • 三個方法透過閉包共享同一個 privateCounter

使用場景

前端應用

防止全域變數污染

// 使用 IIFE + 閉包封裝模組
const myApp = (function () {
  let count = 0; // 私有變數

  return {
    increment() {
      count += 1;
    },
    getCount() {
      return count;
    },
  };
})();

myApp.increment();
console.log(myApp.getCount()); // 1
console.log(myApp.count); // undefined

建立私有狀態的元件

const ShoppingCart = (function () {
  let items = [];
  let total = 0;

  function calculateTotal() {
    total = items.reduce((sum, item) => sum + item.price, 0);
  }

  return {
    addItem(item) {
      items.push(item);
      calculateTotal();
      console.log(`已加入 ${item.name},總價: ${total}`);
    },
    getTotal() {
      return total;
    },
    clearCart() {
      items = [];
      total = 0;
    },
  };
})();

ShoppingCart.addItem({ name: '漢堡', price: 100 });
ShoppingCart.addItem({ name: '可樂', price: 30 });
console.log(ShoppingCart.getTotal()); // 130

修正迴圈閉包陷阱

// 問題:使用 var 在迴圈中綁定事件
for (var i = 0; i < 3; i += 1) {
  document.getElementById(`btn${i}`).addEventListener('click', function () {
    console.log(i); // 全部都會印出 3
  });
}

// 解法 1:使用 IIFE
for (var i = 0; i < 3; i += 1) {
  (function (index) {
    document.getElementById(`btn${index}`).addEventListener('click', function () {
      console.log(index); // 正確印出 0, 1, 2
    });
  })(i);
}

// 解法 2:使用 let(推薦)
for (let i = 0; i < 3; i += 1) {
  document.getElementById(`btn${i}`).addEventListener('click', function () {
    console.log(i); // 正確印出 0, 1, 2
  });
}

React 中的條件渲染

function UserProfile({ user }) {
  return (
    <div>
      {(() => {
        if (!user) return <p>載入中...</p>;
        if (user.isVIP) return <h2>VIP 會員:{user.name}</h2>;
        return <p>一般會員:{user.name}</p>;
      })()}
    </div>
  );
}

後端應用

資料庫連線池管理

const dbConnection = (function () {
  let pool = null;
  let isConnected = false;

  function createPool() {
    pool = require('pg').Pool({
      host: 'localhost',
      database: 'mydb',
    });
    isConnected = true;
  }

  return {
    getConnection() {
      if (!isConnected) {
        createPool();
      }
      return pool;
    },
    close() {
      if (pool) {
        pool.end();
        isConnected = false;
      }
    },
  };
})();

const conn = dbConnection.getConnection();
conn.query('SELECT * FROM users', (err, result) => {
  console.log(result.rows);
});

環境變數封裝

const config = (function () {
  const env = process.env.NODE_ENV || 'development';
  const secrets = {
    apiKey: process.env.API_KEY,
    dbPassword: process.env.DB_PASSWORD,
  };

  return {
    isDevelopment() {
      return env === 'development';
    },
    getApiKey() {
      return secrets.apiKey;
    },
  };
})();

if (config.isDevelopment()) {
  console.log('開發模式');
}

注意事項

記憶體管理

// 危險:閉包持有大量資料
const createHugeClosures = (function () {
  const hugeData = new Array(1000000).fill('x'); // 1MB 資料

  return {
    getData() {
      return hugeData; // 閉包持續持有這 1MB
    },
  };
})();

// 改善:釋放不需要的資料
const safe = (function () {
  let tempData = fetchHugeData();
  const result = processData(tempData);
  tempData = null; // 釋放記憶體

  return {
    getResult() {
      return result;
    },
  };
})();

測試考量

私有方法無法直接測試,建議策略:

  1. 只測試公開 API 的行為(黑箱測試)

  2. 將複雜邏輯拆成獨立的純函式

// helper.js - 可測試的純函式
export function complexCalculation(input) {
  return input * 2 + 1;
}

// module.js
import { complexCalculation } from './helper.js';

const myModule = (function () {
  return {
    publicAPI(input) {
      return complexCalculation(input);
    },
  };
})();

現代替代方案

使用情境

IIFE + 閉包

ES6 Module

新專案模組化

優先使用

維護舊專案

-

單檔案內封裝

-

立即執行邏輯

-

實踐練習

會員管理系統

const membershipSystem = (function () {
  const members = {};

  return {
    createMember(name) {
      if (members[name]) {
        throw new Error('會員已存在');
      }
      members[name] = { totalPeriods: 0 };
    },
    addPeriods(name, periods) {
      if (!members[name]) {
        throw new Error('會員不存在');
      }
      members[name].totalPeriods += periods;
      return this.calculateFee(name);
    },
    calculateFee(name) {
      const { totalPeriods } = members[name];
      const basePrice = 500;
      let total = totalPeriods * basePrice * 0.79;
      const discounts = Math.floor(totalPeriods / 5) * 200;
      return total - discounts;
    },
  };
})();

membershipSystem.createMember('Alex');
membershipSystem.addPeriods('Alex', 3);
membershipSystem.addPeriods('Alex', 2);
console.log(membershipSystem.members); // undefined

Class 語法對照

class Burger {
  constructor(breadType, meatType, price) {
    this.bread = breadType;
    this.meat = meatType;
    this.price = price;
  }

  getInfo() {
    return `${this.bread} + ${this.meat} 漢堡, 價格 $${this.price}`;
  }
}

class BurgerFactory {
  static createBigMac() {
    return new Burger('白麵包', '雙層牛肉', 119);
  }

  static createMcPork() {
    return new Burger('芝麻麵包', '豬肉排', 59);
  }
}

const bigMac = BurgerFactory.createBigMac();
console.log(bigMac.getInfo());

與函式版本的差異:

  • Class 使用 this 存取實例屬性

  • static 方法無需實例化,直接呼叫類別方法

  • 適合需要繼承關係的大型專案

設計原則整合

依賴反轉原則 (DIP)

當呼叫方只認得工廠暴露的 API,而不依賴具體實作時,即達成 DIP。

// 不好:直接依賴具體實作
const burger = new Burger('白麵包', '牛肉', 89);

// 好:依賴工廠抽象
const burger = BurgerFactory.createBigMac();

測試優勢

在測試時可輕鬆替換成 mock 工廠:

// test.js
const mockBurgerFactory = {
  createBigMac() {
    return { getInfo: () => 'Mock Burger' };
  },
};

參考資源