前言

这是关于设计模式的系列文章,在每篇文章中将对常见设计模式进行讲解,因为针对前端方向,而且前端常用语言 JavaScript 本身是弱类型,面向对象(模拟面向对象)编程的实现相较于其他强类型语言实现更为繁琐,所以代码主要以 JavaScript 表现。

系列文章链接:

普通单例模式

“单例模式” 就是通过类创建实例后,每次创建和获取都返回同一个实例,下面是 “单例模式” 最基本的实现。


单例模式 UML 图
单例模式 UML 图


/* ES6 写法 */
class Person {
  constructor(name) {
    this.name = name;
  }
  static getInstance(name) {
    if (!this.instance) {
      this.instance = new Person(name);
    }
    return this.instance;
  }
}

const w1 = Person.getInstance('hello');
const w2 = Person.getInstance('world');

console.log(w1 === w2); // true
/* ES5 写法 */
function Person(name) {
  this.name = name;
}

Person.getInstance = (function () {
  let instance;
  return function (name) {
    if (!instance) {
      instance = new Person(name);
    }
    return instance;
  }
})();

const w1 = Person.getInstance('hello');
const w2 = Person.getInstance('world');

console.log(w1 === w2); // true

上面分别用 ES6ES5 的方式实现了一个基本的单例模式,创建 Person 的实例时需要通过 getInstance 静态方法,这样第一次会创建一个实例,再次调用时会将之前创建的实例返回,达到单例的目的。

上面单例模式的缺点:

  • 类的使用者必须要知道这是一个单例的类,创建和获取实例必须通过调用 getInstance 方法;
  • 并不能真正阻止类的使用者通过 new 关键字创建出新的实例。

透明单例模式

“透明单例模式” 可以解决上面普通 “单例模式” 的不足,希望可以直接使用 new 关键字来创建类的实例,如果已经创建,再次通过 new 创建,则会返回之前创建的实例。

/* 透明单例模式 */
const Person = (function () {
  let instance;

  return function (name) {
    if (instance) {
      return instance;
    } else {
      this.name = name;
      instance = this;
    }
  }
})();

const w1 = new Person('hello');
const w2 = new Person('world');

console.log(w1 === w2); // true

“透明单例模式” 的原理是创建一个自执行函数,内部创建一个私有变量 instance 用来存储创建的实例,并通过闭包返回一个构造函数,用变量 Person 接收,当使用 new 创建实例时,先检测私有变量 instance 是否有值,如果没值则创建实例,如果有值则直接返回 instance(利用 new 关键字和构造创建实例的原理实现)。

缺点:违反了单一职责原则(一个函数只做一件事),自执行函数返回的构造函数已经不止单纯用作构建实例,同时处理了单例的判断逻辑。

单例与构建分离

针对上面 “透明单例模式” 的缺点,下面将构造函数单例处理与构建逻辑进行分离。

/* 单例逻辑与构建逻辑分离 */
// 真正的构造函数
function Person(name) {
  this.name = name;
}

Person.prototype.getName = function () {
  console.log(this.name);
}

// 新的构造函数
const CreatePerson = (function () {
  let instance;

  return function (name) {
    if (!instance) {
      instance = new Person(name);
    }
    return instance;
  }
})();

const w1 = new CreatePerson('hello');
const w2 = new CreatePerson('world');

console.log(w1 === w2); // true

上面代码将单例的逻辑与构造函数的逻辑进行了分离,真正用于构造实例的类是 Person,用于处理单例逻辑的是自执行函数返回的函数,使用 CreatePerson 变量接收,这个函数也同时约定好被当做构造函数使用(通过 new 关键字调用和直接执行效果相同)。

缺点:生成的新构造函数名字(CreatePerson)是固定的,用来创建实例的这个类(Person)也是固定的,不够灵活。

封装变化

下面支持不同的构造函数创建实例,并且可以使用原本构造函数的对应方法,就是把上面案例不灵活的地方变得灵活。

/* 封装变化 */
const CreateSingle = function (Constructor) {
  let instance;

  const SingleConstructor = function () {
    if (!instance) {
      Constructor.apply(this, arguments);
      instance = this;
    }
    return instance;
  }

  // 实现原型继承
  SingleConstructor.prototype = Object.create(Constructor.prototype);
  return SingleConstructor;
}
/* 使用方式 */
// 构造函数
function Person(name, age) {
  this.name = name;
  this.age = age;
}

function Dailog(name) {
  this.name = name
}

// 原型方法
Person.prototype.sayHi = function () {
  console.log(this.name + ':' + this.age);
}

Dailog.prototype.getName = function () {
  console.log(this.name);
}

// 创建新的构造函数并生成实例
const CreatePerson = CreateSingle(Person);
const w1 = new CreatePerson('hello', 18);
const w2 = new CreatePerson('world', 20);

const CreateDailog = CreateSingle(Dailog);
const s1 = new CreateDailog('model');
const s2 = new CreateDailog('view');

console.log(w1 === w2); // true
console.log(s1 === s2); // true

上面我们把创建单例的逻辑进行了封装变成了一个通用的逻辑,对于不同构造函数所创建实例,只需要传入这个构造函数并生成新的构造函数,需要注意的是,新的构造函数无法继承原构造函数的原型方法,所以通过继承实现的。

单例模式的应用

命名空间

在编写代码时,我们有时候需要人为的创建命名空间,以防止变量的相互污染,这是可以使用 “单例模式” 来实现。

/* 创建命名空间的方法 */
// 存储工具方法
const utils = {};

// 定义命名空间
utils.define = function (namespace, fn) {
  // 获取命名空间的数组
  const namespaces = namespace.split('.');

  // 最后一项为设定方法的属性名
  const methodName = namespaces.pop();

  // 定义变量存储当前命名空间的引用,默认为 utils(根命名空间)
  let current = utils;

  for (let i = 0; i < namespaces.length; i++) {
    const currentNamespace = namespaces[i];

    // 当某一个命名空间没有时,则创建这个命名空间(单例模式)
    if (!current[currentNamespace]) {
      current[currentNamespace] = {};
    }

    // 否则让当前命名空间指向已有的命名空间
    current = current[currentNamespace];
  }

  // 将传入的函数设定给最后一级命名空间的属性上
  current[methodName] = fn;
}
/* 命名空间的创建和使用 */
// 通过命名空间定义方法
utils.define('dom.class.addClass', function () {
  console.log('dom.class.addClass');
});

utils.define('string.trim', function () {
  console.log('string.trim');
});

utils.define('event.prevent', function () {
  console.log('event.prevent');
});

// 使用方法
utils.dom.class.addClass('title'); // dom.class.addClass
utils.string.trim(' hello '); // string.trim
utils.event.prevent(); // event.prevent

上面代码的设计希望通过 utils 对象的 define 方法按照传入的表示命名空间的字符串去创建方法,基本实现思路和逻辑是,当一个属性是第一次出现时,创建一个对象作为该命名空间,当再次出现时则不会重复创建命名空间(因为会出现覆盖的问题),而是沿用之前创建的命名空间。

LRU 缓存

LRU 全称为 Least Recently Used,为最近使用的意思,缓存的方式为访问一个元素时,则将其标记为活跃,当存储时,如果超出容量则删除最不常用的元素。

/* 创建 LRU 缓存类 */
class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.members = [];
  }
  put(key, val) {
    let oldestIndex = -1; // 最不活跃项的索引
    let oldestAge = -1; // 最不活跃项的活跃数值
    let found = false;

    for (let i = 0; i < this.members.length; i++) {
      const member = this.members[i];

      // 如果找到当前最不活跃的项,将 oldestAge 和 oldestIndex 更新为该项对应值
      if (member.age > oldestAge) {
        oldestAge = member.age;
        oldestIndex = i;
      }

      // 如果添加项在原本 members 中已经存在,则更新 age 的值为 0
      if (member.key === key) {
        this.members[i] = { key, val, age: 0 };
        found = true; // 为了跳过 push 新增的环节
      } else {
        // 否则其他所有项 age 自增
        member.age++;
      }
    }

    if (!found) {
      if (this.members.length >= this.capacity) {
        this.members.splice(oldestIndex, 1);
      }
      this.members.push({ key, val, age: 0 });
    }
  }
  get(key) {
    for (let i = 0; i < this.members.length; i++) {
      const member = this.members[i];

      if (member.key === key) {
        member.age = 0;
        return member.val;
      }
    }
    return -1;
  }
}

上面是一个创建 LRU 缓存的类,用数组管理成员,put 方法用于新增成员,get 方法用于访问成员,当访问成员时,成员的 age 清零,代表最近活跃,当新增元素时,如果该元素已存在,则做覆盖操作,如果不存在,则推入数组中,age 设置为零,其他成员 age 自增,若数组超出容量时,先找到 age 最大的元素删除,再将新的元素推入数组,上面是一个直观但性能较差的实现,如果有兴趣可以使用链表进行优化。

/* 使用 LRU 缓存 */
const cache = new LRUCache(2);

cache.put('1', 1);
console.log(cache.members);
// [ { key: '1', val: 1, age: 0 } ]

cache.put('2', 2);
console.log(cache.members);
// [ { key: '1', val: 1, age: 1 }, { key: '2', val: 2, age: 0 } ]

cache.put('3', 3);
console.log(cache.members);
// [ { key: '2', val: 2, age: 1 }, { key: '3', val: 3, age: 0 } ]

cache.put('2', 'hello');
console.log(cache.members);
// [ { key: '2', val: 'hello', age: 0 }, { key: '3', val: 3, age: 1 } ]

总结

“单例模式” 是设计模式中非常好理解的一个,使用还是非常广泛的,在 Redux 等众多的第三方库中也有所体现,最后附上 案例地址