Published on

享元模式详解

Authors
  • avatar
    Name
    青雲
    Twitter

享元模式(Flyweight Pattern)是一种结构型设计模式,关注于通过共享机制来有效地支持大量细粒度对象的复用,以减少应用程序的内存使用。这种模式尤其适用于那些对象数量众多,且对象状态大部分可以外部化的场景。

为什么需要享元模式?

一所图书馆拥有成千上万本书,如果每次读者想阅读一本书时,图书馆就购买一本新书供其阅读,那么图书馆将需要无比巨大的空间来存放这些书,并且这种做法在经济上也是不可行的。为了解决这个问题,图书馆采用了一种“共享”策略。也就是说,当一位读者借阅某本书之后,这本书并不是他/她个人的所有物,读者阅读完毕后需要归还,这样其他读者也可以借阅。

在这个例子中,每一本书可以看作是一个“享元”,它被多个读者共享阅读。图书馆只需维护一定数量的书籍——任何时候都足够满足读者的阅读需求,而不是为每个读者购买新书,从而显著降低了成本。

将这个概念应用到软件开发中,享元模式允许系统重用对象,减少系统创建对象的数量,节省资源。在前端开发中,享元模式可以用于优化DOM元素的使用,或是在处理大量具有相似属性和方法的对象时提升性能。例如,当你在一个页面上展示成千上万条评论时,通过享元模式重用DOM节点或者对象的属性,可以极大减少浏览器的负担,提升页面的性能。

基本概念

享元模式将对象的状态分为内部状态和外部状态:

  1. 内部状态:相同的对象之间共享的一部分状态,不会随上下文的变化而变化。
  2. 外部状态:依赖于上下文并随环境改变而改变的状态。

享元对象不可变,这意味着它们的内部状态在创建后不能改变。外部状态在每次方法调用时通过参数传递给享元对象。 核心组成:

  1. 享元对象(Flyweight):这是被共享的对象,它能够提供内部(不变的)状态和外部(变化的)状态。内部状态存储在享元对象内部,通常不会随环境的变化而变化;外部状态由客户端对象存储,并在需要的时候传递给享元对象。
  2. 享元工厂(Flyweight Factory):负责创建和管理享元对象。当客户端请求一个享元对象时,工厂检查是否已经创建过这样的对象,如果已经创建,就返回现有的享元对象;如果没有,则创建一个新的享元对象。
  3. 客户端(Client):持有对享元对象的引用。它负责计算或存储享元对象的外部状态。

实现示例

通过一个实际示例来展示享元模式的实现过程。假设我们要在一个图形编辑器中绘制不同颜色的圆形,我们可以通过享元模式来优化内存使用。

定义享元接口和具体享元类

// 享元接口,定义共享对象的方法
interface Shape {
  draw(x: number, y: number, color: string): void;
}

// 具体享元类,表示可以共享的圆形对象
class Circle implements Shape {
  private readonly radius: number;

  constructor() {
    this.radius = 5; // 共享的内部状态
  }

  draw(x: number, y: number, color: string): void {
    console.log(`Drawing a ${color} circle at (${x}, ${y}) with radius ${this.radius}`);
  }
}

定义享元工厂类

// 享元工厂类,用于创建和管理享元对象
class ShapeFactory {
  private circleMap: Map<string, Shape> = new Map();

  getCircle(color: string): Shape {
    let circle = this.circleMap.get(color);

    if (!circle) {
      circle = new Circle();
      this.circleMap.set(color, circle);
      console.log(`Created a new circle of color: ${color}`);
    }

    return circle;
  }
}

使用享元模式绘制图形

const shapeFactory = new ShapeFactory();

const colors = ["Red", "Green", "Blue", "Red", "Blue", "Green"];

for (let i = 0; i < colors.length; i++) {
  const circle = shapeFactory.getCircle(colors[i]);
  circle.draw(i * 10, i * 20, colors[i]);
}
// 输出
Created a new circle of color: Red
Drawing a Red circle at (0, 0) with radius 5
Created a new circle of color: Green
Drawing a Green circle at (10, 20) with radius 5
Created a new circle of color: Blue
Drawing a Blue circle at (20, 40) with radius 5
Drawing a Red circle at (30, 60) with radius 5
Drawing a Blue circle at (40, 80) with radius 5
Drawing a Green circle at (50, 100) with radius 5

总结

  1. Shape是一个接口,声明了一个draw方法。这个接口定义了所有形状对象必须实现的功能,即在某个位置以某种颜色绘制自己。
  2. Circle是一个实现了Shape接口的类,表示圆形。它有一个私有属性radius,用于存储圆的半径大小。同时,它重写了draw方法,具体实现了如何绘制一个圆形。Circle作为一个具体的形状,是享元对象的一种实现。
  3. ShapeFactory是一个工厂类,它的主要职责是管理和提供Shape对象。它内部维护了一个名为circleMapMap,这个Map的键是颜色(string类型),值是对应颜色的Circle对象。getCircle方法接收一个颜色字符串作为参数,然后它检查circleMap中是否已经有了这个颜色的圆形。如果有,就直接返回现有的圆形对象;如果没有,就创建一个新的圆形对象,存储到circleMap中,然后返回这个新创建的对象。

这种方式体现了享元模式的本质——即通过共享来避免大量相似对象的重复创建,从而节省资源。

应用场景

享元模式适用于以下场景:

  1. 难以承受的大量对象:例如图形应用、游戏开发中的粒子系统等,需要创建大量相同或相似的对象。
  2. 性能要求较高:需要优化内存使用和提高运行效率的情况下。
  3. 共享状态:对象中有可以共享的部分状态,可以利用共享来减少内存占用。

DOM元素的重用

在动态生成大量类似的 DOM 元素(如列表项、表格行)时,频繁地创建和销毁这些元素可能会导致性能问题和内存泄漏。通过享元模式,可以重用 DOM 元素而不是频繁地创建和销毁,从而减少页面重绘和回流的次数,提高性能。

案例:虚拟滚动(Virtual Scrolling)

虚拟滚动是享元模式的一个典型应用。当用户在一个长列表中滚动时,只渲染可视区域内的元素,并重用这些元素来显示新的数据。这样无论列表有多长,DOM 中的元素数量保持不变,从而显著提高性能。

class VirtualScroll {
  private container: HTMLElement;
  private itemHeight: number;
  private items: HTMLElement[] = [];
  private totalItems: number;
  private viewportHeight: number;

  constructor(container: HTMLElement, itemHeight: number, totalItems: number) {
    this.container = container;
    this.itemHeight = itemHeight;
    this.totalItems = totalItems;
    this.viewportHeight = container.clientHeight;

    this.initialize();
  }

  initialize() {
    const itemCount = Math.ceil(this.viewportHeight / this.itemHeight);
    for (let i = 0; i < itemCount; i++) {
      const item = document.createElement('div');
      item.style.height = `${this.itemHeight}px`;
      this.items.push(item);
      this.container.appendChild(item);
    }

    this.container.addEventListener('scroll', this.onScroll.bind(this));
    this.updateItems(0);
  }

  onScroll() {
    const scrollTop = this.container.scrollTop;
    const firstVisibleIndex = Math.floor(scrollTop / this.itemHeight);

    this.updateItems(firstVisibleIndex);
  }

  updateItems(startIndex: number) {
    for (let i = 0; i < this.items.length; i++) {
      const item = this.items[i];
      const itemIndex = startIndex + i;

      if (itemIndex < this.totalItems) {
        item.textContent = `Item ${itemIndex + 1}`;
        item.style.display = 'block';
      } else {
        item.style.display = 'none';
      }
    }
  }
}

// 使用 VirtualScroll
const container = document.getElementById('scrollContainer');
const totalItems = 1000; // 假设列表有 1000 项
const itemHeight = 30;   // 每项的高度为 30px
new VirtualScroll(container, itemHeight, totalItems);

Canvas 绘图优化

在使用 Canvas 绘制复杂或大量图形(如游戏、数据可视化)时,绘图操作本身可能非常耗时。享元模式允许共享和重用相似图形的属性和状态,减少不必要的绘图操作。

案例:享元对象池

在一个简单的游戏中,可能有成百上千个相似的对象(如子弹、星星等)。通过享元模式,可以将这些对象的共享属性(如颜色、大小)提取出来,只在必要时绘制区别属性(如位置),减少了绘制开销。

const canvas = document.getElementById('gameCanvas') as HTMLCanvasElement;
const context = canvas.getContext('2d');

// 享元对象池
const starPool: Star[] = [];

function getStar(color: string, size: number): Star {
  let star = starPool.find(s => s.color === color && s.size === size);
  if (!star) {
    star = new Star(color, size);
    starPool.push(star);
    console.log(`Created new star: ${color}, ${size}`);
  }
  return star;
}

// 初始化并绘制星星
function drawStars() {
  const colors = ["Red", "Green", "Blue"];
  for (let i = 0; i < 1000; i++) {
    const color = colors[Math.floor(Math.random() * colors.length)];
    const size = Math.floor(Math.random() * 5) + 5;
    const x = Math.floor(Math.random() * canvas.width);
    const y = Math.floor(Math.random() * canvas.height);
    const star = getStar(color, size);
    star.draw(context, x, y);
  }
}

drawStars();

数据共享与内存管理

在数据密集型的前端应用中,如大型表格或报表,享元模式可以减少数据对象的创建,优化内存使用。

案例:股票市场的仪表盘应用

在一个股票市场的仪表盘应用中,可能需要显示成千上万个股票的实时数据。如果每个股票都创建一个完整的数据对象,会消耗大量内存。通过享元模式,可以共享那些相同的数据字段,比如市场类型、股票代码等,只为每个股票创建必要的独特数据属性,显著减少了内存使用。

  1. 定义享元接口和具体享元类
// 享元接口,定义共享对象的方法
interface Stock {
  display(uniqueData: StockUniqueData): void;
}

// 表示股票独特数据的接口
interface StockUniqueData {
  price: number;
  volume: number;
}

// 具体享元类,表示共享的股票信息
class StockShared implements Stock {
  constructor(private readonly marketType: string, private readonly code: string) {}
  
  display(uniqueData: StockUniqueData): void {
    console.log(`Stock [Market: ${this.marketType}, Code: ${this.code}, Price: ${uniqueData.price}, Volume: ${uniqueData.volume}]`);
  }
}
  1. 定义享元工厂类
// 享元工厂类,用于创建和管理享元对象
class StockFactory {
  private stockMap: Map<string, Stock> = new Map();

  getStock(marketType: string, code: string): Stock {
    const key = `${marketType}-${code}`;
    if (!this.stockMap.has(key)) {
      const stock = new StockShared(marketType, code);
      this.stockMap.set(key, stock);
      console.log(`Created new shared stock: [Market: ${marketType}, Code: ${code}]`);
    }
    return this.stockMap.get(key);
  }
}
  1. 使用享元模式管理股票数据
const stockFactory = new StockFactory();

function displayStocks() {
  const stocksData = [
    { marketType: "NYSE", code: "AAPL", price: 150, volume: 1000 },
    { marketType: "NYSE", code: "AAPL", price: 151, volume: 1200 },
    { marketType: "NASDAQ", code: "GOOG", price: 2800, volume: 300 },
    { marketType: "NASDAQ", code: "GOOG", price: 2820, volume: 400 },
    { marketType: "NYSE", code: "TSLA", price: 700, volume: 500 },
    { marketType: "NYSE", code: "TSLA", price: 710, volume: 450 }
  ];

  stocksData.forEach(data => {
    const stock = stockFactory.getStock(data.marketType, data.code);
    const uniqueData: StockUniqueData = { price: data.price, volume: data.volume };
    stock.display(uniqueData);
  });
}

displayStocks();

享元模式优缺点

优点

  1. 减少内存消耗:通过共享相同的对象,显著减少内存消耗,提高系统性能。
  2. 提高效率:减少对象创建的开销,提高系统响应速度。
  3. 灵活性:享元模式将对象状态分为内部状态和外部状态,提高了对象管理的灵活性。

缺点

  1. 增加代码复杂性:通过引入享元工厂和享元对象,增加了代码的复杂性和维护难度。
  2. 不适用于所有场景:享元模式适用于具有大量相同或相似对象的场景,如果对象之间差异较大,享元模式的效果会大打折扣。

总结

享元模式通过共享相同的对象来减少内存消耗和对象创建的开销,提升系统性能。它特别适合于需要大量细粒度对象的场景,如图形应用、游戏开发中的粒子系统、以及前端开发中的虚拟滚动和数据密集型应用等。在实际应用中,合理使用享元模式可以显著提升系统性能,但也需注意其代码复杂性和适用场景。