设计模式在前端开发中的实际应用(三)——享元模式

享元模式

享元模式是一种结构型设计模式。某些同学可能对这个设计模式比较陌生,这个设计模式需要结合对应的业务场景使用,属于不常见的设计模式,但是往往在用到的时候可以解决大问题。

1、基本概念

享元模式,是一种用于性能优化的设计模式,享元模式的英文叫fly-weight,其含义是蝇量级,主要用于减少创建对象的数量,以减少内存占用和提高性能。

享元模式的关键是划分内部和外部状态(变化和不变,不变的就是复用对象的内部状态,变化的内容则由外界传递进来,在某一刻得到执行(有点儿依赖注入:Dependencies Inject,简称DI的味道))来实现对象的复用的。另外,享元模式会有工厂模式的思想在其中,工厂实现对象的复用逻辑的控制。

我将其字面意思理解成"共享 -元数据 "(仅仅是我个人的理解,非官方解释,因为享元模式需要划分内部和外部的状态,内部的状态数据不就是复用对象的元数据嘛,而外部的数据是根据需求传递的,内部数据不就成了共享的嘛,哈哈哈)。

以下是享元模式的UML图:

上图的含义是,FlyWeight抽象了一个功能,通过ConcreteFlyWeightUnsharedConcreteFlyWeight去实现它,同时定义了一个享元工厂,FlyWeightFactory,其对外暴露一个获取FlyWeight对象实例的方法,当我们需用用到ConcreteFlyWeight的实例时,从FlyWeightFactory取,如果取不到,则初始化,并将其存储起来,如果已经初始化,则直接复用,这样就减少了对象的创建。

2、代码示例

ts 复制代码
abstract class FlyWeight {
  abstract notify(msg: string): void;
}

class ConcreteFlyWeight extends FlyWeight {
  notify(msg: string): void {
    console.log("我是享元对象输出消息:" + msg);
  }
}

class UnsharedConcreteFlyWeight extends FlyWeight {
  notify(msg: string): void {
    console.log("我是非享元对象输出消息:" + msg);
  }
}

class FlyWeightFactory {
  private static map: Map<string, FlyWeight> = new Map();

  static {
    // 系统初始化一些干活儿的对象
    this.map.set("A", new ConcreteFlyWeight());
    this.map.set("B", new ConcreteFlyWeight());
  }

  static getFlyWeight(type: string): FlyWeight {
    // 如果对象不存在,则创建,如果存在,直接复用对象
    let flyWeightInstance = this.map.get(type);
    if (!flyWeightInstance) {
      flyWeightInstance = new ConcreteFlyWeight();
      this.map.set(type, flyWeightInstance);
    }
    return flyWeightInstance;
  }
}

function bootstrap() {
  const flyA = FlyWeightFactory.getFlyWeight("A");
  const flyB = FlyWeightFactory.getFlyWeight("B");
  const flyC = FlyWeightFactory.getFlyWeight("C");
  const flyD = FlyWeightFactory.getFlyWeight("A");
  const normalObj = new UnsharedConcreteFlyWeight();
  flyA.notify("你好,比尔盖茨~");
  flyB.notify("你好,库克~");
  flyC.notify("你好,乔布斯~");
  flyD.notify("你好,马云~");
  normalObj.notify("你好,雷军~");
}

3、前端开发中的实践

由于现代Web前端开发一般都会使用VueReact等基于虚拟DOM的框架进行开发,再加上ESM语法的出现,享元模式的在实际的业务开发场景不常见。

像一些设计模式书上给出的例子,可能某些时候基于框架开发,就完全不会那样去写代码了,设计模式是为了降低我们代码的复杂度,增加软件的可维护性,所以不要为了设计模式而设计。

比如曾探老师所著的《JavaScript设计模式与开发实际》一书中,对于享元模式给的示例是一个文件上传的例子,但是如果基于Vue或者React框架编写代码,可能就直接让文件上传这个功能模块成为一个组件了(可以用虚拟滚动来处理上传文件的列表项过多;可以用一个任务管理器控制并发以防止一下并发过大,造成浏览器卡死,这个场景会限制并发,但也不是套用享元模式的代码范式)。

以下是一个我最近在一个实际开发中出现的例子,这个场景就是一个十分恰当的例子。

它是一个弹幕组件,系统需要支持有5000条弹幕推送,如果这5000条数据完全交给框架处理的话,将会建立超多的双向绑定(以Vue框架为例),此时肯定性能上是达不到要求的,另外,也不可能一下子把5000条数据一下渲染出来,否则就没法看了。

同时,这些消息还有优先级,比如如果是付费用户的消息,它的优先级要求比普通用户优先展示,直接一下子渲染出来,这个逻辑也不太好写。

根据享元模式的思想启发,实际上我们可以创造一定的弹幕内容节点(搬运工),首先为它准备好它要展示的内容(外部状态),然后事先让两者进行绑定,让它从屏幕的右边滚动到左边,然后它可以再回到右边,解绑。然后重新为它绑定新的弹幕信息展示,重复这个过程,直到把所有的消息都展示完毕。这样就可以使得我们在有限的DOM上展示很多的信息,同时,处理消息的优先级就变成了处理队列或者处理了,也将核心业务逻辑解放了出来。

下图是我编写的组件的运行效果:

以下是上述逻辑的vue编码实现。

vue 复制代码
<template>
  <div class="danmu">
    <!-- 只渲染有限的DOM节点,进行数据的展示 -->
    <div
      class="danmu-wrapper"
      v-for="(ctx, idx) in worker"
      :key="idx"
      :style="{
        transform: `translateX(${worker[idx].offset}%)`,
        top: `${20 * idx}px`,
      }"
    >
      <div class="danmu-item">
        <template v-if="ctx.body">
          <div class="danmu-user">
            <div class="danmu-user__wrapper u1">
              <img
                class="danmu-user__wrapper-img"
                :src="ctx.body.user1.avatar"
                alt="用户头像"
              />
            </div>
            <div class="danmu-user__wrapper u2">
              <img
                class="danmu-user__wrapper-img"
                :src="ctx.body.user2.avatar"
                alt="用户头像"
              />
            </div>
          </div>
          <p class="danmu-body">{{ ctx.body.message }}</p>
        </template>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "Danmu",
  data() {
    return {
      initPosition: [140, 100, 120, 170, 110],
      // 用来从右到左负责搬运弹幕的工人们
      worker: [
        {
          offset: 0,
          body: null,
        },
        {
          offset: 0,
          body: null,
        },
        {
          offset: 0,
          body: null,
        },
        {
          offset: 0,
          body: null,
        },
        {
          offset: 0,
          body: null,
        },
      ],
      // 用来记录消息过多时,还没有播放的弹幕信息
      taskQueue: [],
      // 用来记录消息是否是优先级较高的
      map: new WeakMap(),
    };
  },
  mounted() {
    // 在组件渲染后,开始搬运弹幕信息
    this.start();
  },
  methods: {
    addTask(task, emergency) {
      const available = this.worker.filter((v) => v.body === null);
      // 随机取一个能用的工人
      const worker = available[Math.floor(Math.random() * available.length)];
      // 暂时没有可用的搬运工人,加入等待队列
      if (!worker) {
        console.log("请稍后,暂时没有工人能够干活啦");
        if (emergency) {
          // 找到第一个紧急任务,因为紧急任务也有先来后到的优先级,紧急任务不能直接插入到待处理消息队列的头部
          let idx = 0;
          let existTask = this.taskQueue[idx];
          while (existTask && this.map.get(existTask)) {
            existTask = this.taskQueue[idx];
            idx++;
          }
          // 将当前任务标记为紧急任务
          this.map.set(task, true);
          // 如果当前任务队列里面没有紧急任务,可以直接插入任务
          if (idx === 0) {
            this.taskQueue.unshift(task);
          } else {
            // 否则,插在紧急任务之后,idx是第一个非紧急任务的下标
            this.taskQueue.splice(idx - 1, 0, task);
          }
        } else {
          // 非紧急任务,可以直接放在临时消息队列的尾部
          this.taskQueue.push(task);
        }
      } else {
        // 为当前工人绑定它需要搬运的信息
        worker.body = task;
        // 重新开启动画
        this.startMove(this.worker.findIndex((v) => v === worker));
      }
    },
    start() {
      // 初始化开启搬运弹幕
      let idx = 0;
      this.worker.forEach(() => {
        this.worker[idx].offset = this.initPosition[idx];
        this.startMove(idx);
        idx++;
      });
    },
    startMove(target) {
      // 如果当前搬运工没有任务需要处理,则不需要后续的流程
      if (!this.worker[target].body) {
        return;
      }
      // 如果当前搬运工已经将弹幕搬运到了最左边
      if (this.worker[target].offset <= -100) {
        this.worker[target].offset = this.initPosition[target];
        // 卸载当前搬运的内容
        this.worker[target].body = null;
        // 如果已经没有资源了,结束当前搬运工的任务
        if (this.taskQueue.length <= 0) {
          return;
        }
        // 如果已经处理完成了一个内容,发现消息队列里面还有内容,继续搬运
        if (this.taskQueue.length) {
          const task = this.taskQueue.shift();
          this.worker[target].body = task;
        }
      }
      requestAnimationFrame(() => {
        // 动画处理
        this.worker[target].offset = (
          this.worker[target].offset - 0.45
        ).toFixed(2);
        this.startMove(target);
      });
    },
  },
};
</script>

<style lang="scss" scoped>
.danmu {
  width: 100%;
  height: 500px;

  &-wrapper {
    width: 100%;
    position: relative;
  }

  &-item {
    max-width: 100%;
    height: 52px;
    background-image: linear-gradient(180deg, #3b73ff 0%, #2fc7ff 100%);
    box-shadow: inset 0 -2px 7px 0 rgba(255, 255, 255, 0.71);
    border-radius: 30px;
    border: 4px solid azure;
    font-size: 24px;
    font-family: PingFangSC-Regular, PingFang SC;
    font-weight: 400;
    color: #ffffff;
    display: inline-block;
    text-align: right;
    line-height: 44px;
    padding-right: 28px;
    padding-left: 130px;
    position: relative;
  }

  &-body {
    max-width: 360px;
    white-space: nowrap;
    overflow-x: hidden;
    text-overflow: ellipsis;
  }

  &-user {
    position: absolute;
    left: 0;
    display: flex;
    align-items: center;
    top: 50%;
    transform: translateY(-50%);

    &__wrapper {
      width: 64px;
      height: 64px;
      background: linear-gradient(180deg, #1558fc 0%, #3bb1ff 100%);
      box-shadow: inset 0 -2px 7px 0 rgba(255, 255, 255, 0.71);
      border: 4px solid rgba(198, 237, 255, 1);
      border-radius: 50%;
      position: relative;

      &-img {
        width: 100%;
        height: 100%;
        border-radius: 50%;
      }
    }

    .u1 {
      z-index: 2;
    }

    .u2 {
      transform: translateX(-20px);
      z-index: 1;
    }
  }
}
</style>

在这个实践中,弹幕信息节点的位置信息就是它的内部状态,弹幕信息节点展示的内容就是它的外部状态。

另外,在这个例子中,任务的优先级只有两个判别依据,所以我们可以简单的用一个WeakMap(是用WeakMap的优势是不用关心消息对象什么时候销毁)来标记,假设您的业务需求可能有好几种优先级判别依据,那么你就只能用来解决这个问题了(比如医院急诊科医生处理任务,一个病人已经休克,一个病人的伤口血流不止,一个病人发着高烧,另外一个病人只是感冒了,那么医生肯定会病情的轻重缓急决定先救治哪个病人)。

总结

享元模式最核心的思想就是对象的复用,因此,在前端开发中可以用来优化性能和内存使用,尤其是在处理大量的DOM元素时。

像我之前举的弹幕组件的例子是一种应用场景,还有像Vue或者React框架中的虚拟滚动也是属于享元模式的应用实践,只不过我们没有意识到而已。或者,再说的远一些,像css的background-repeat,也是一种享元模式的思想,因为我们实际上只提供了很小的一部分内容,而浏览器可以根据我们的内容渲染任意大小的区域。

总结一下:

  • 在实施享元模式时,区分内部状态和外部状态是关键,内部状态是可以共享的不变数据,而外部状态是每个对象特有的,不能共享
  • 可以显著减少内存的使用,但可能会增加计算的复杂性。因此,在应用享元模式之前,应该评估其对性能的影响,我们需要在性能和代码的可维护性及复杂度上做一些权衡。
  • 适用于那些有大量相似对象的场景,其中对象的大部分状态可以被外部化和共享。如果每个对象的状态差异很大,享元模式可能就不是最佳选择。
相关推荐
Nan_Shu_61410 分钟前
学习: Threejs (2)
前端·javascript·学习
G_G#18 分钟前
纯前端js插件实现同一浏览器控制只允许打开一个标签,处理session变更问题
前端·javascript·浏览器标签页通信·只允许一个标签页
@大迁世界34 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路43 分钟前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug1 小时前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu121381 小时前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中1 小时前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路1 小时前
GDAL 实现矢量合并
前端
hxjhnct1 小时前
React useContext的缺陷
前端·react.js·前端框架
冰暮流星1 小时前
javascript逻辑运算符
开发语言·javascript·ecmascript