讲解
围棋比赛时,三百多颗棋子如果每颗都单独浇铸一份「棋子的形状数据」,纯粹是浪费——所有白子的形状一模一样,不同的只是「摆在哪儿」。棋具厂的做法是:形状数据只存两份(黑、白),每颗棋子只额外记自己的坐标。享元模式就是这个思路:把对象的状态拆成内在状态(大量对象共享、不变)与外在状态(每个对象独有、随场景变化),内在状态做成共享的「享元」,用工厂缓存复用,内存占用随「种类数」而不是「对象数」增长。
GoF 的定义:运用共享技术有效地支持大量细粒度的对象。关键词是「大量」和「细粒度」——渲染一百万个粒子、地图上十万个图钉、富文本里每个字符的字体样式。单个对象省 100 字节不起眼,一百万个就是 100MB。
享元有两个硬性前提。第一,内在状态必须不可变:它被成百上千个对象共享,任何一处改动都会污染所有共享者(这也是示例里 PieceStyle 的字段全部 readonly 的原因)。第二,外在状态必须由客户端在调用时传入或持有——享元本身不记得「我是谁、我在哪」,这正是它能被共享的代价。
工程上享元常常隐形出现:字符串驻留(string interning)、数据库连接池、前端组件的「实例共享配置对象」、浏览器的字体缓存,背后都是同一个思想。判断是否值得用的标尺很朴素:对象数量级 × 单个对象可共享部分的大小,乘出来够可观才值得引入工厂和状态拆分的复杂度。
示例
一盘棋 22 颗棋子,但样式对象只有 6 个——用工厂池共享内在状态,并断言内存按「种类」增长:
import assert from 'node:assert/strict';
// 内在状态:所有同款棋子共享,创建后不可变
class PieceStyle {
constructor(
readonly color: string,
readonly glyph: string,
) {}
}
// 享元工厂:按 key 缓存,同样的款式只造一次
class PieceStyleFactory {
private static readonly pool = new Map<string, PieceStyle>();
static get(color: string, glyph: string): PieceStyle {
const key = color + ':' + glyph;
let style = this.pool.get(key);
if (style === undefined) {
style = new PieceStyle(color, glyph);
this.pool.set(key, style);
}
return style;
}
static count(): number {
return this.pool.size;
}
}
// 外在状态:每颗棋子自己的坐标
interface Piece {
x: number;
y: number;
style: PieceStyle;
}
function placePiece(x: number, y: number, color: string, glyph: string): Piece {
return { x, y, style: PieceStyleFactory.get(color, glyph) };
}
// 摆上一盘棋
const pieces: Piece[] = [];
for (let i = 0; i < 8; i++) {
pieces.push(placePiece(i, 1, '黑', '兵'));
pieces.push(placePiece(i, 6, '红', '兵'));
}
pieces.push(placePiece(0, 0, '黑', '车'), placePiece(7, 0, '黑', '车'));
pieces.push(placePiece(0, 7, '红', '车'), placePiece(7, 7, '红', '车'));
pieces.push(placePiece(4, 0, '黑', '将'), placePiece(4, 7, '红', '帅'));
assert.equal(pieces.length, 22);
assert.equal(PieceStyleFactory.count(), 6); // 22 颗棋子只共享 6 个样式对象
assert.equal(pieces[0].style, pieces[2].style); // 两颗黑兵共享同一个实例
console.log('棋子 ' + pieces.length + ' 颗,样式对象仅 ' + PieceStyleFactory.count() + ' 个 —— 内存随种类而非数量增长');
第三条断言用的是引用相等:两颗黑兵的 style 指向内存里同一个 PieceStyle 对象。把这个结构放大到十万级(比如渲染一片草地,每棵草共享「草的网格 + 贴图」,只存自己的坐标和缩放),节省的内存就是从「撑爆浏览器」到「流畅运行」的差别。
常见坑
- 内在状态可变:共享对象被人改了一个字段,几百个引用者集体出错且极难排查;享元字段一律 readonly/冻结。
- 状态拆分错位:把「其实会变的」塞进内在状态(被迫改共享对象),或把「其实不变的」留在外在状态(内存白省)——拆之前先列出每个字段的变化频率。
- 对象数量不大也用享元:几百个对象的总占用可能不到 1MB,引入工厂和拆分只会让代码难读;享元是为「十万级」准备的。
- 工厂池只进不出:长寿命应用里享元池无限增长也会内存泄漏;种类有生命周期时(如按局清理),池要提供清理通道。
- 忽略 key 的构造:颜色 + 字形拼 key 时漏了一个维度,不同款式被错误共享——key 必须覆盖内在状态的全部字段。
小结
享元把状态拆成共享的内在部分(不可变、工厂缓存)和独有的外在部分,让内存随种类数增长;它是为海量细粒度对象准备的优化,小场景不要提前使用。下一章看结构型收官的代理模式。