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

 


當頁面同時擠進成千上萬個元素,卡頓不是偶然,是記憶體真的被拖垮了。你可能把一堆重複不變的資料,一份不落地塞進每個物件裡:字型樣式、SVG 路徑、貼圖資訊……結果就是建立慢、GC 頻、捲動不順。

享元模式的想法很樸素:把「可共享且不會改變」的內在狀態抽出來集中快取;真正依情境變動的外在狀態,在使用時再帶進去。這篇文章會用 JavaScript 從概念到實作帶你走一遍:怎麼判斷何時需要享元、如何設計穩定的快取鍵、怎麼避免不小心把外在狀態塞回共享物件,以及用幾個貼近前端的例子(文字樣式、地圖上的樹)展示效益。最後也會談測試與量測,幫你分辨「真的省」還是「想像省」。希望本篇文章可以幫助到需要的您。


目錄

{tocify} $title={目錄} 


什麼是享元模式?為什麼需要它

享元模式(Flyweight) 是一種結構型設計模式。當系統中有大量重複物件,而這些物件內部有很大的比例是相同且不會改變的資料時,我們可以把這些可共享的部分抽出來「做成享元(flyweight)並快取」,讓所有實例共用,從而降低記憶體與建立/GC 成本。

典型場景:

    富文本編輯器:每個字元的樣式(字型、大小、粗細、顏色…)高度重複。

    地圖/遊戲:成千上萬的樹、石頭、建築,共享模型與貼圖,座標由外部決定。

    Icon 系統:同一個 SVG path 被反覆使用於不同位置與大小。

一句話版:把「相同又不會變」的部分做成共享物件,其他情境相關的細節在用的時候再給。


內在狀態 vs 外在狀態(設計拆分的關鍵)

內在狀態(Intrinsic State):與物件本質有關、可被共享、不可變,如:字型名稱、字重、SVG 路徑、3D 模型幾何形狀。

外在狀態(Extrinsic State):與情境/座標/當次渲染相關、不可共享、常變,如:文字位置、顯示座標、縮放比例、目前選取狀態。


設計時先問三個問題:

1.    這個屬性跨物件是否大量重複?

2.    這個屬性會不會改?

3.    拆出去共享之後,使用端能否在呼叫時提供外在狀態?


何時適用?一張快速檢核表

建議使用如果同時滿足多數條件:

        物件數量龐大(數萬級以上)。

        物件內部有高比例的相同資料。

        內在狀態可設計為不可變(immutable)。

        可接受在呼叫時額外傳入外在狀態(多一個參數)。

        記憶體壓力/啟動時間/GC 停頓是瓶頸。


謹慎或不建議:

        物件數量很少,省不了多少記憶體。

        內部狀態經常變動,難以共享。

        複雜度成本 > 實際效益。


基礎實作步驟(含關鍵設計選項)

1.    辨識內外在狀態:把可共享且不變的屬性抽出來。

2.    定義享元類別(只含內在狀態,設為不可變):Object.freeze()。

3.    設計享元工廠(Flyweight Factory):

            用 Map/WeakMap 快取;

            設計穩定鍵(stable key),避免 JSON.stringify 的鍵序問題。

4.    客戶端物件(大量):只保存外在狀態,使用時向工廠索取享元。

5.    加入監控/量測:方便驗證確實省到資源。

6.    必要時加上物件池(Object Pool):重複利用外在物件/中介物件,降低 GC 壓力。


範例一:文字樣式 TextStyle 享元

1.    需求情境

你在做一個富文本(或程式碼編輯器)元件,文件中可能有數十萬個字元。樣式(字型、大小、粗細、顏色、行高…)高度重複,不該為每個字元都新建一份。


2.    享元類與工廠

// utils/stableKey.js
export function stableKey(obj) {
  // 依 key 排序,避免鍵序影響快取命中
  const keys = Object.keys(obj).sort();
  return keys.map(k => `${k}:${String(obj[k])}`).join('|');
}

// flyweight/TextStyle.js
export class TextStyle {
  constructor({ fontFamily, fontSize, fontWeight, color, lineHeight, italic }) {
    this.fontFamily = fontFamily;
    this.fontSize = fontSize;
    this.fontWeight = fontWeight;
    this.color = color;
    this.lineHeight = lineHeight;
    this.italic = !!italic;
    Object.freeze(this); // 享元應不可變
  }
}

// flyweight/TextStyleFactory.js
import { stableKey } from '../utils/stableKey.js';
import { TextStyle } from './TextStyle.js';

export class TextStyleFactory {
  constructor() {
    this.cache = new Map(); // key: stableKey(spec) -> value: TextStyle
  }

  get(spec) {
    // 可選:輸入正規化(消除等價但不同表示)
    const normalized = {
      fontFamily: spec.fontFamily || 'system-ui',
      fontSize: Number(spec.fontSize) || 14,
      fontWeight: spec.fontWeight || 'normal',
      color: spec.color || '#222',
      lineHeight: spec.lineHeight || 1.6,
      italic: !!spec.italic,
    };

    const key = stableKey(normalized);
    let flyweight = this.cache.get(key);
    if (!flyweight) {
      flyweight = new TextStyle(normalized);
      this.cache.set(key, flyweight);
    }
    return flyweight;
  }

  size() { return this.cache.size; }

  clear() { this.cache.clear(); }
}


3.    使用端:字元模型只帶外在狀態

// model/Char.js
export class Char {
  constructor({ ch, x, y, styleFlyweight }) {
    this.ch = ch;             // 外在:字元本身
    this.x = x;               // 外在:位置
    this.y = y;               // 外在:位置
    this.style = styleFlyweight; // 共享:TextStyle 享元
  }

  draw(ctx) {
    // 外在狀態(座標)與共享樣式合併使用
    const s = this.style;
    ctx.save();
    ctx.font = `
      ${s.italic ? 'italic ' : ''} 
      ${s.fontWeight} ${s.fontSize}px ${s.fontFamily}
    `;
    ctx.fillStyle = s.color;
    ctx.textBaseline = 'top';
    ctx.fillText(this.ch, this.x, this.y);
    ctx.restore();
  }
}


4.    建立大量字元(示意)

import { TextStyleFactory } from './flyweight/TextStyleFactory.js';
import { Char } from './model/Char.js';

const factory = new TextStyleFactory();

const styleNormal = factory.get({ fontFamily: 'Inter', fontSize: 14, color: '#333' });
const styleKeyword = factory.get({ fontFamily: 'Inter', fontSize: 14, color: '#C41A16', fontWeight: '600' });

const chars = [];
let x = 0, y = 0;
const lineHeightPx = Math.round(14 * 1.6);

const content = 'function add(a, b) { return a + b }'; // 假設長度很大
for (const ch of content.repeat(10000)) { // 大量字元
  const style = /[a-z]/i.test(ch) ? styleNormal : styleKeyword; // 簡化:字母/非字母
  chars.push(new Char({ ch, x, y, styleFlyweight: style }));
  x += 8;
  if (x > 800) { x = 0; y += lineHeightPx; }
}

console.log('享元數量(TextStyle instances)=', factory.size()); // 通常 < 10
console.log('字元數量(Char instances)=', chars.length);


5.    重點回顧

TextStyle 是內在狀態,不可變且共享。

Char 只記錄外在狀態(字元/位置),需要時使用共享樣式。

工廠用 stableKey 確保穩定快取命中。


範例二:「森林地圖」的樹 Tree 享元(結合工廠與物件池)

1.    需求情境

在地圖或遊戲場景中,數十萬棵樹共用相同的貼圖與模型,差別只在位置、朝向、縮放、當前可見性。這是享元模式的經典教科書案例。

2.    享元與外在狀態定義

內在狀態(共享):樹種名稱、樹幹/樹葉貼圖 URL、渲染用頂點資料(或 SVG path)、陰影設定。

外在狀態(情境):x, y, scale, rotation, highlighted, visible。

// flyweight/TreeType.js
export class TreeType {
  constructor({ name, trunkTextureUrl, leafTextureUrl, meshId }) {
    this.name = name;
    this.trunkTextureUrl = trunkTextureUrl;
    this.leafTextureUrl = leafTextureUrl;
    this.meshId = meshId; // 假設渲染引擎已載入的模型 ID
    Object.freeze(this);
  }

  // draw(ctx, extrinsic) 具體渲染略
}

// flyweight/TreeTypeFactory.js
import { stableKey } from '../utils/stableKey.js';
import { TreeType } from './TreeType.js';

export class TreeTypeFactory {
  constructor() {
    this.cache = new Map();
  }
  get(spec) {
    const key = stableKey(spec);
    let t = this.cache.get(key);
    if (!t) {
      t = new TreeType(spec);
      this.cache.set(key, t);
    }
    return t;
  }
  size() { return this.cache.size; }
}

// model/Tree.js
export class Tree {
  constructor({ x, y, scale = 1, rotation = 0, typeFlyweight }) {
    this.x = x;
    this.y = y;
    this.scale = scale;
    this.rotation = rotation;
    this.type = typeFlyweight; // 共享
  }

  draw(scene) {
    const t = this.type;
    // 利用 t.meshId 與貼圖資料 + 外在狀態完成渲染
    scene.drawMesh(t.meshId, { x: this.x, y: this.y, scale: this.scale, rotation: this.rotation });
  }
}


3.    加上「物件池」降低 GC 壓力(選用)

如果場景中樹會頻繁出入(卷軸切換、LOD),可以重複使用 Tree 實例,避免大量建立/銷毀:

// pool/ObjectPool.js
export class ObjectPool {
  constructor(createFn) {
    this.createFn = createFn;
    this.freeList = [];
  }
  acquire(initArgs) {
    const obj = this.freeList.pop() || this.createFn(initArgs);
    if (obj.__reset) obj.__reset(initArgs);
    return obj;
  }
  release(obj) { this.freeList.push(obj); }
  size() { return this.freeList.length; }
}

// model/PooledTree.js
export class PooledTree extends Tree {
  __reset({ x, y, scale = 1, rotation = 0, typeFlyweight }) {
    this.x = x; this.y = y; this.scale = scale; this.rotation = rotation; this.type = typeFlyweight;
  }
}


享元負責共享內在狀態,物件池負責重複利用外在狀態承載物件。兩者相輔相成。


進階技巧:用 WeakMap/FinalizationRegistry 幫忙回收

1.    WeakMap 放快取,讓 GC 可回收

若享元本身只該存在於有引用時,考慮:

// 僅在 key(規格物件)仍被外界持有時保留享元
const cache = new WeakMap();
function getFly(spec) {
  let fw = cache.get(spec);
  if (!fw) {
    fw = new Fly(spec);
    cache.set(spec, fw);
  }
  return fw;
}

但實務上我們通常以字串當 key(因為穩定、可序列化),此時就不能用 WeakMap(key 需是物件)。因此常見做法仍是 Map<string, Fly>,再加上:

        針對冷資料做 LRU(最少使用刪除)。

        提供 clear()/prune() 介面給上層管理生命週期。


2.    FinalizationRegistry(高階)

可在物件被回收時做善後(例如從輔助索引移除)。此 API 屬高階用法,且行為與時機不保證,使用時務必保守。


與其他模式的關係

Factory Method / Abstract Factory:

        常與享元一起出現。工廠負責「拿到或建立享元」,保證可共享。

Prototype:

        透過拷貝建立新物件;享元則是盡量不新建、重用現成。

Singleton:

        一種只有一個實例的享元極端情況;但享元通常是多個類別/多組規格的共享集合。


效能與測試:量測、陷阱與實務建議

量測方向:

1.    記憶體:performance.memory(瀏覽器,需旗標)、或 Node.js process.memoryUsage()。

2.    建立時間:大量建立前/後的時間戳。

3.    GC 行為:觀察尖峰、卡頓。

陷阱:

    微基準(micro-benchmark)容易失真;以實際頁面/場景衡量。

    若物件數目不到萬級,常常看不到明顯效益。

    快取鍵設計錯誤(鍵序、精度)會導致快取命中率低,白做工。

實務建議:

    先重構出「內在/外在」清晰界線。

    內在狀態務必不可變(Object.freeze、或凍結後拷貝)。

    鍵值正規化:字串大小寫、數字精度、預設值一致化。

    加入監控點:快取命中率、享元數量、LRU 命中。


常見錯誤與雷點

1.    把可共享狀態設計成可變

後果:一個地方改樣式,所有使用該享元的字元全被污染。

解法:享元不可變;更新 = 取用新的享元。


2.    快取鍵不穩定(JSON.stringify 鍵序)

後果:明明規格相同卻產生不同鍵,命中率崩潰。

解法:自行建立 stableKey(排序後串接)。


3.    未做輸入正規化

例如 color 可能是 #333 與 #333333,或 fontWeight '600' vs 600。

解法:在工廠 get(spec) 內先「同型化」。


4.    快取無上限,造成記憶體外洩

解法:提供 clear()、週期性 prune()、或導入 LRU。


5.    為小數量也硬上享元,反讓維護變複雜

解法:先以資料量級衡量,必要時再導入。


6.    把外在狀態塞回享元(偷懶)

後果:享元變得不可共享,整個模式失效。

解法:外在狀態只在呼叫/渲染時傳入。


7.    以浮點數直接當鍵

後果:0.1 + 0.2 精度問題導致鍵不一致。

解法:以字串化並固定小數位或用整數刻度(e.g., 像素放大 100 倍存整數)。


8.    把大型 Blob/ArrayBuffer 放在外在狀態

後果:共享價值被稀釋,還增加傳遞成本。

解法:大資料應該是內在狀態的一部分,成為享元。


9.    誤用 WeakMap 期望自動回收所有情境

後果:鍵不是物件或仍有強引用,回收期望落空。

解法:理解 WeakMap 限制,搭配生命週期 API。


10.    想著「享元=提早最佳化」

解法:以量測導向;先找出真正瓶頸,再導入。



問題集

Q1:JavaScript 引擎會自動幫我「字串去重」嗎?

有些引擎可能做各式最佳化,但規格不保證、且行為/時機不可依賴。若業務上需要明確的共享策略,請自行設計享元工廠。

Q2:React/Vue 本身是不是享元?

它們是 UI 狀態管理與虛擬 DOM 的框架/做法,不等同享元,但概念上都在降低重複與不必要成本。你依然可以在 React/Vue 應用程式內部,對資料層套用享元模式(例如樣式、資源模型)。

Q3:享元會不會讓程式變難懂?

是的,抽象提高了複雜度。只在必要的高量級重複場景使用,並用清楚的命名與測試守護。


延伸閱讀推薦:

javaScript設計模式 : Factory Method   (工廠方法)

javaScript設計模式 : Abstract Factory(抽象工廠)

javaScript設計模式 : Builder(建造者模式)

javaScript設計模式 : Prototype(原型)

javaScript設計模式 : Singleton (單例模式)

javaScript設計模式 : Adapter(轉接器模式)

javaScript設計模式 : Bridge( 橋接模式 )

javaScript設計模式 : Composite(組合模式)

 javaScript設計模式 : Decorator(裝飾者)

javaScript設計模式 : Facade(外觀模式)

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

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

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

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

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

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

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

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

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

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

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

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


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