享元模式:共享的艺术,内存的救星

说实话,我第一次接触享元模式时,心里想的是:「这不就是对象池吗?」后来踩了坑才发现,两者有本质区别。享元模式的核心,说白了就是「共享细粒度对象,避免重复创建」。你想想看,如果一个系统里有成千上万个相似的对象,每个都独立占用内存,那再大的服务器也扛不住。

我个人习惯把享元模式理解为「对象的轻量化复用」。它不是为了省创建对象的开销,而是为了减少内存中对象的数量。嗯,这里要注意:享元模式只适用于那些「内在状态」可以共享的场景。

什么是享元模式?

享元模式(Flyweight Pattern)是一种结构型设计模式。它通过共享技术,让大量细粒度的对象复用同一份内存。我曾在游戏开发项目中遇到过类似场景——一个地图上有几万棵树,每棵树都有位置、颜色、纹理。如果每棵树都独立存储纹理数据,内存直接爆炸。

享元模式把对象的状态分为两类:

  • 内在状态(Intrinsic State):可共享的、不变的部分。比如树的纹理、颜色。
  • 外在状态(Extrinsic State):不可共享的、变化的部分。比如树的位置、朝向。

说白了,就是把「变」和「不变」拆开。不变的部分大家共用,变的部分由外部传入。

一个简单的例子:字符渲染

我记得有一次做文本编辑器,需要渲染大量字符。每个字符都有字体、大小、颜色、位置。如果每个字符都存一份字体对象,那一段1000字的文章就要创建1000个字体对象。太浪费了。

用享元模式改造后:

// 享元对象:字体
class Font {
    constructor(family, size, color) {
        this.family = family;
        this.size = size;
        this.color = color;
    }
}

// 享元工厂
class FontFactory {
    constructor() {
        this.fonts = {};
    }

    getFont(family, size, color) {
        const key = `${family}_${size}_${color}`;
        if (!this.fonts[key]) {
            this.fonts[key] = new Font(family, size, color);
            console.log(`创建新字体: ${key}`);
        }
        return this.fonts[key];
    }

    getTotalFonts() {
        return Object.keys(this.fonts).length;
    }
}

// 使用
const factory = new FontFactory();

// 渲染1000个字符,但只有3种字体组合
for (let i = 0; i < 1000; i++) {
    const font = factory.getFont('Arial', 12, 'black');
    // 渲染字符...
}

console.log(`实际创建的字体对象数: ${factory.getTotalFonts()}`); // 输出: 1

你看,1000个字符只创建了1个字体对象。这就是享元模式的威力。

享元模式的结构

享元模式通常包含以下几个角色:

角色 说明
Flyweight(享元) 定义共享对象的接口,包含内在状态的操作
ConcreteFlyweight(具体享元) 实现享元接口,存储内在状态
FlyweightFactory(享元工厂) 创建和管理享元对象,确保共享
Client(客户端) 维护外在状态,调用享元对象

下面我用一张图来展示它们之间的关系:

享元模式结构图 Client(客户端) FlyweightFactory Flyweight(接口) ConcreteFlyweight 请求享元 调用操作 创建/返回 实现 内在状态(共享) 外在状态(外部传入) 客户端持有外在状态,享元对象持有内在状态 两者组合使用,完成完整功能

什么时候用享元模式?

不是所有场景都适合用享元模式。我个人总结了几个判断标准:

  • 对象数量巨大:内存占用成为瓶颈时,考虑享元。
  • 对象有大量重复状态:比如很多对象共享相同的纹理、字体、颜色。
  • 内在状态可以分离:不变的部分能独立出来,变化的部分由外部管理。

核心原则:享元模式不是「省创建时间」,而是「省内存空间」。如果你只是觉得 new 对象慢,那应该用对象池,而不是享元。

避坑指南

我曾经在一个项目中过度使用享元模式,结果代码变得非常复杂。外在状态满天飞,调试起来简直噩梦。后来我总结了几条经验:

我曾经踩过的坑:

  • 不要为了用享元而用享元。如果对象数量不大,强行拆分内在/外在状态只会增加复杂度。
  • 外在状态的管理要集中。我见过有人把外在状态散落在各个地方,结果改一个地方忘了改另一个,bug 频出。
  • 享元对象必须是不可变的。如果共享对象被修改,所有引用它的地方都会受影响。嗯,这很危险。

实际应用场景

享元模式在现实项目中很常见。我举几个例子:

  • 文本编辑器:字符的字体、大小、颜色作为内在状态共享,位置作为外在状态。
  • 游戏开发:粒子系统中的粒子纹理、形状共享,位置、速度由外部管理。
  • 数据库连接池:虽然更偏向对象池,但连接配置(URL、用户名)可以作为内在状态共享。

小技巧:在实现享元工厂时,可以用 WeakMap 来存储享元对象。这样当享元对象不再被使用时,可以自动被垃圾回收,避免内存泄漏。

总结

享元模式的核心思想就一句话:把不变的部分共享,把变的部分外移。它不是什么高深的技术,但用好了能大幅降低内存占用。我个人建议你在设计系统时,先问问自己:「这些对象真的需要各自独立吗?有没有可以共享的部分?」

记住,享元模式不是银弹。它适合「大量细粒度对象」的场景,不适合「少量大对象」的场景。用对了是神器,用错了是累赘。

公众号:蓝海资料掘金营,微信deep3321