當頁面同時擠進成千上萬個元素,卡頓不是偶然,是記憶體真的被拖垮了。你可能把一堆重複不變的資料,一份不落地塞進每個物件裡:字型樣式、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設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
