- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
享元模式(Flyweight)属于结构型设计模式,核心思想是运用共享技术有效支持大量细粒度对象。本文结合富文本编辑器、网盘秒传、大型多人游戏三个贴近前端与后端实战的场景,完整讲解内部状态与外部状态的分离、FlyweightFactory 的缓存实现、角色划分与适用边界,并给出可直接落地的 TypeScript 代码,帮助你判断"何时该用、何时不该用"享元模式。
先看三个真实场景:什么时候会想到享元
设计模式必须在日常工作里用起来才有价值。原文围绕三个例子展开,它们共同揭示了享元模式的触发条件。
富文本编辑器的字母对象
在英文环境下,富文本编辑器中的文本由大量字母组成。为了便于统一格式化、统一计算(比如统计、排版),我们需要把每个字母都存储为对象,但这样的存储代价非常大。
关键在于:英文字母一共只有 26 个,一篇文档里存在大量重复使用的字母。每个字母除了"位置信息"不同以外,其他信息都是相同且只读的。那么,能不能把文档里成千上万个字母对象的数量降下来?
这正是享元模式的第一个适用信号:对象数量巨大,但可去重后的种类极少。
网盘存储的"秒传"
当我们上传一部几十 GB 的电影时,有时候不到一秒就上传完成了,网盘会提示"已采用极速技术秒传"。为什么不是每次都生效?原因就是:同一部电影可能已经存在于网盘的不同用户、不同文件夹中,服务器只需记录"这部电影已经存在,新用户只是多了一个引用",而无需再存一份完整文件。
和富文本类似,电影文件也是只有"存放位置"不同,其余内容都特别巨大且只读。不同用户看到的是各自文件夹里的电影,但底层共享的是同一份数据。这就是"秒传"背后的原理——共享存储。
大型多人游戏
玩多人游戏时,为了防止外挂,对象创建与计算通常在服务器完成。那如何保证一个玩家拾取物品后,另一个玩家看到的物品会消失?
答案不言而喻:虽然在不同客户端之间,游戏对象看起来是相互独立的,但在同一局游戏中,所有玩家的对象在服务器端是共享的。服务器上同一个武器、同一个怪物对象,被多个客户端共同引用;当一个玩家改变了它的状态(拾取、开火、死亡),其他客户端通过共享引用观察到一致的变化。
意图解释:共享 + 内部状态与外部状态分离
享元模式的意图是:
运用共享技术有效地支持大量细粒度的对象。
"共享"可以理解为缓存:当一个对象创建后,再次访问相同对象时不再创建新的,而只有在访问没有被缓存过的对象时才创建,并立即缓存起来。
这三个例子中的细粒度对象分别是:富文本中的无数字母、网盘中的电影文件、每局游戏中的大量游戏对象。它们都有共同特征:
- 量特别大,这个很容易理解。
- 具有大量内部状态,且不随客户端的不同而改变:
- 富文本的字母,不因为展示到不同语句中而发生变化,变化的只有位置(状态);
- 电影文件,不因为放在不同用户的文件夹中而对内容产生任何变化,变化的只有归属(属于哪些用户、放在哪些文件夹);
- 多人游戏中同一把武器对象,不因为有多个人的电脑独立运行而拥有更多弹药,变化的只有"在哪些客户端被访问"。
- 具有少量外部状态,甚至没有外部状态:字母的位置、电影的位置、游戏对象的客户端归属,都属于外部状态。它们与内部状态相比,体量微乎其微,且方便分离存储。
遇到这种情况,就可以将对象内部状态共享,外部状态独立存储,从而节省大量空间。
原文特别强调了一个容易被忽视的事实:享元模式的价值是全局性的。对网盘公司来说价值巨大(承诺 2TB 空间,用户"下载"了别人分享的 100 部电影,实际可能只增加了 1kb 的存储来记录位置);对整个富文本编辑器来说减少了巨量字母对象;但对于每一个字母对象而言,并没有任何优化。所以享元模式的价值体现在全局,而非单个对象。
结构图:五个角色的职责划分
享元模式由以下角色构成(原文档配图无法在本仓库中展示,此处以文字还原其结构):
- Flyweight(享元接口):共享接口,通过这个接口可以操作对象的外部状态。
- ConcreteFlyweight(具体享元):实现 Flyweight 接口的对象,这个对象是可被共享的,内部状态被固化在其中。
- UnsharedConcreteFlyweight(非共享具体享元):不被共享的对象。享元模式中并不是所有对象都可以被共享,某些对象依然需要独立创建。
- FlyweightFactory(享元工厂):创建并管理 Flyweight 对象。通过它获取 Flyweight 时,如果已创建,则返回之前创建的那个;没有才创建新的。工厂是享元模式的核心枢纽。
- Client(客户端):使用 Flyweight 的客户端。
结构图中最关键的一点是:两个不同的 Client 持有的是同一个aConcreteFlyweight引用。这意味着客户端看到的"各自的对象",在内存中实际上是同一个实例——这正是共享带来的空间收益,也是多人游戏"一个玩家拾取后另一个玩家看到消失"的底层原因。
代码例子:从最小实现到完整可运行版本
原文档给出的核心代码用 TypeScript 编写,是享元工厂的最小骨架:
class FlyweightFactory { public getFlyWeight(key) { if (this.flyweight[key]) { return this.flyweight[key] } const flyweight = new Flyweight() this.flyweight[key] = flyweight return flyweight } }FlyweightFactory提供的getFlyWeight方法,实际上是按照key对flyweight实例进行缓存:相同key下只存储一个flyweight实例。命中缓存直接返回,未命中则创建并立即写入缓存。
为了让这段骨架真正可运行,我们可以补全类型与初始化逻辑,使其具备实际使用价值:
// 享元接口:外部状态通过方法传入,内部状态固化在实例中 interface Flyweight { // 传入外部状态(如字母位置、文件归属),执行共享实例上的操作 render(x: number, y: number): string } // 具体享元:内部状态(如字母字符本身)是只读且共享的 class Character implements Flyweight { constructor(private readonly char: string) {} public render(x: number, y: number): string { // 同一个字符实例被反复复用,位置作为外部状态每次传入 return `<text x="${x}" y="${y}">${this.char}</text>` } } // 享元工厂:以 key 为维度的实例缓存 class FlyweightFactory { private readonly pool: Record<string, Flyweight> = {} public getFlyWeight(key: string): Flyweight { if (this.pool[key]) { return this.pool[key] } const flyweight = new Character(key) this.pool[key] = flyweight return flyweight } public get poolSize(): number { return Object.keys(this.pool).length } } // 使用:渲染 1 万字文章,但内存中只有 26 个字母实例 const factory = new FlyweightFactory() const text = "a quick brown fox jumps over the lazy dog..." const nodes = text.split("").map((char, index) => factory.getFlyWeight(char).render(index * 10, 0) ) console.log(factory.poolSize) // 远小于 text.length,重复字母被共享可以看到,享元工厂本质是一个"以 key 为索引的对象缓存池",与 设计模式/171.精读《设计模式 - Singleton 单例模式》.md 中"保证一个类仅有一个实例"的单一性不同,享元模式是按 key 维度保证每组 key 只有一个实例,从而在"对象种类有限、使用次数海量"的场景下把实例数从 O(n) 压缩到 O(k)(k 为去重后的种类数)。
前端实战中的享元:对象池与缓存思想
从上面的最小实现可以自然延伸到前端开发中常见的享元应用,这些都属于"共享技术"的具体落地:
- DOM 虚拟节点 / 组件实例复用:长列表滚动渲染时,只维护窗口内的若干行节点,滚出视口的行被回收并复用给新进入的行——行的"内部状态"(DOM 结构)共享,行号、内容等外部状态单独维护。
- 资源缓存(图片、字体、图标):同一张图片、同一个 SVG 图标在页面多处使用时,浏览器与框架层面只保留一份资源实例,各处持有引用。
- 对象池(Object Pool):粒子特效、游戏子弹、动画帧对象等高频创建销毁的对象,使用前从池中取、用完归还,避免频繁 GC。
- 编译器 / 解析器的 Token 复用:本仓库 编译原理 系列中的手写 SQL 编译器大量使用缓存来避免重复解析计算,其"缓存"思想与享元工厂同源。
需要说明的是,上述应用是享元"共享技术"思想在不同领域的延伸,具体到某一框架是否真正采用享元模式,需要看其源码实现;本文仅从模式思想层面说明其普适性。
弊端:什么时候不要用享元模式
享元模式并非万能,原文明确指出三个不适用场景:
- 细粒度对象不多,没必要使用享元模式,引入工厂与缓存只会增加复杂度。
- 对象内部状态并不多,主要都是外部状态时,享元模式起不到作用。因为享元模式通过共享对象,只能节省内部状态,而不能节省外部状态——外部状态必须独立存储,共享无法消除它们。
- 共享对象数量没有比原始对象少出数量级关系时,意义不大。原文给出了一个非常直观的对比:富文本编辑器对英文文章,1 万字只有 26 个字母,优化比例约 10000:26;而对中文文章,1 万字去重后可能仍有 3000 个汉字,优化比例仅 10000:3000,享元模式的意义就大打折扣。
这三点共同指向一个判断准则:收益 = 共享节约的内部状态体量 × 可去重比例。只有两者都足够大时,享元模式才值得投入。
总结
享元模式的本质就是尽可能共享对象,特别适用于存在大量细粒度对象、而这些对象内部状态特别多、外部状态较少的场景。它的落地方式是 FlyweightFactory 缓存池 + 内部状态共享、外部状态独立传入。
对于云存储来说,享元模式几乎是必须使用的:云存储场景决定了存在大量细粒度文件对象与大量只读文件,非常适合共享一个对象,每个用户存储的只是引用。同理,富文本编辑器、大型多人游戏、长列表渲染,都是享元模式发挥价值的典型领域。
判断是否使用享元模式,请记住原文给出的三个反问:对象量够大吗?内部状态占比够高吗?共享后数量能降一个数量级吗?三个答案都是"是",才值得引入。
本文基于 设计模式/177.精读《设计模式 - Flyweight 享元模式》.md 展开,属于 readme.md 所收录"前端精读"周刊的设计模式系列之一。同系列还可延伸阅读单例模式(关注唯一性)、代理模式(关注访问控制)等结构型与创建型模式,组合理解更能把握模式之间的取舍。
- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
相关推荐
CS-Notes 设计模式指南:享元模式(Flyweight)如何用"共享内部状态"支撑海量细粒度对象
CS Notes 设计模式指南:享元模式(Flyweight)如何用"共享内部状态"支撑海量细粒度对象 享元模式(Flyweight)是一种用于减少内存开销的结
知识库文档教程Unity3DTraining 设计模式实战:享元模式(Flyweight)源码级解析——用共享技术支持大量细粒度对象
Unity3DTraining 设计模式实战:享元模式(Flyweight)源码级解析——用共享技术支持大量细粒度对象 在游戏与图形密集型应用中,"对象太多导致
示例工程Composio享元模式:共享细粒度对象的技术
Composio享元模式:共享细粒度对象的技术 什么是享元模式(Flyweight Pattern) 享元模式是一种结构型设计模式,它通过共享细粒度对象来有效支
人工智能AI Agent工具调用MCP 服务MCP Clients
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考