前端常见设计模式之单例模式

单例模式的定义是产生一个类的唯一实例,但 js 本身是一种“无类”语言。很多讲 js 设计模式的文章把{}当成一个单例来使用也勉强说得通。因为 js 生成对象的方式有很多种,我们来看下另一种更有意义的单例。 有这样一个常见的需求,点击某个按钮的时候需要在页面弹出一个遮罩层。比如 web.qq.com 点击登录的时候.

这个生成灰色背景遮罩层的代码是很好写的.

var createMask = function() {
    return document.body.appendChild(document.createElement(div));
}

$('button').click(function() {

    Var mask = createMask();

    mask.show();

})

问题是, 这个遮罩层是全局唯一的, 那么每次调用 createMask 都会创建一个新的 div, 虽然可以在隐藏遮罩层的把它 remove 掉. 但显然这样做不合理. 再看下第二种方案, 在页面的一开始就创建好这个 div. 然后用一个变量引用它.

var mask = document.body.appendChild(document.createElement('div'))
  $('button').click( function(){
    mask.show();
  })

这样确实在页面只会创建一个遮罩层 div, 但是另外一个问题随之而来, 也许我们永远都不需要这个遮罩层, 那又浪费掉一个 div, 对 dom 节点的任何操作都应该非常吝啬. 如果可以借助一个变量. 来判断是否已经创建过 div 呢?

var mask;

var createMask = function() {

    if (mask) return mask;

    else {
        mask = document.body.appendChild(document.createElement(div));
        return mask;
    }
}

看起来不错, 到这里的确完成了一个产生单例对象的函数. 我们再仔细看这段代码有什么不妥. 首先这个函数是存在一定副作用的, 函数体内改变了外界变量 mask 的引用, 在多人协作的项目中, createMask 是个不安全的函数. 另一方面, mask 这个全局变量并不是非需不可. 再来改进一下.

var createMask = function() {
    var mask;
    return function() {
        return mask || (mask = document.body.appendChild(document.createElement('div')))
    }
}()

用了个简单的闭包把变量 mask 包起来, 至少对于 createMask 函数来讲, 它是封闭的.

再回来正题, 前面那个单例还是有缺点. 它只能用于创建遮罩层. 假如我又需要写一个函数, 用来创建一个唯一的 xhr 对象呢? 能不能找到一个通用的 singleton 包装器.js 中函数是第一公民, 意味着函数也可以当参数传递. 看看最终的代码.

var singleton = function(fn) {
    var result;
    return function() {
        return result || (result = fn.apply(this, arguments));
    }
}

var createMask = singleton(function() {
    return document.body.appendChild(document.createElement('div'));
})

用一个变量来保存第一次的返回值, 如果它已经被赋值过, 那么在以后的调用中优先返回该变量. 而真正创建遮罩层的代码是通过回调函数的方式传入到 singleton 包装器中的. 这种方式其实叫桥接模式. 关于桥接模式, 放在后面一点点来说. 然而 singleton 函数也不是完美的, 它始终还是需要一个变量 result 来寄存 div 的引用. 遗憾的是 js 的函数式特性还不足以完全的消除声明和语句.

将单例与音视频结合起来

function singletonAudio = (function () {
    class Audio {
        constructor(options) {
            if (!options.src) throw new Error('播放地址不允许为空');

            this.audioNode = document.createElement('audio');
            this.audioNode.src = options.src;

        }

        play(playOptions) {
        }

        // 其他对单个音频的控制逻辑...
    }

    let audio;

    const _static = {
        getInstance(options) {
            // 若 audio 实例还未被创建,则创建并返回
            if (audio === undefined) {
                audio = new Audio(options);
            }

            return audio;
        }
    };

    return _static;
})();

我们才用了一个 iife 来构造闭包, 仅返回了一个 _static 对象, 该对象提供了 getinstance 方法, 封装和创建、获取的步骤.以便获取全局只存在一个 audio 实例. 我们可以注意到音频实例并没有直接暴露给使用者,而是通过一个公有方法 getInstance 让使用者创建、获取音频实例。这么做的目的是禁止使用者主动实例化 Audio,在公共组件的层面上保证全局只存在一个 audio 实例。

类仅允许有一个实例,且该实例在用户侧有一个访问点。

实例必须能通过子类的形式进行扩展,且用户侧能在不修改代码的前提下使用该扩展实例。

单例模式的确会返回具有单例性质的结构,但单例这一性质体现在这些结构上,单例模式本身完全可以返回多个具有单例性质的对象(这是结构的一种)

实现音轨这个功能, 我们定义 tracks 类

class Tracks {
  constrcutor() {
    this.tracks = {};
  }

  set(key, options) {
    this.tracks[key] = singletonAudio.getInstance(key, options);
  }

  get(key) {
    return this.tracks[key];
  }

  // 所有音轨音量调节
  volumeUp(options) {
    // 这里的 options 直接原样传入了,实际情况下可能会对 options 作额外的处理
    // 例如,我们想调节所有音轨的整体音量,options 传入 overallVolume
    // 综合考虑所有 audio 的音量,给每个 audio 的 volumeUp 方法传入合适的参数
    Object.keys(this.tracks).forEach((key) => {
      const audio = this.tracks[key];
      audio.volumeUp(options);
    });
  }
}

纯单例模式

function Singleton(name) {
  this.name = name;
  this.instance = null;
}

// 原型扩展类的一个方法
Singleton.prototype.getName = function () {
  console.log(this.name);
};

Singleton.getInstance = function (name) {
  if (!this.instance) {
    this.instance = new Singleton(name);
  }

  return this.instance;
};

// 获取对象
var a = Singleton.getInstance("a");
var b = Singleton.getInstance("b");

// 进行比较
console.log(a === b);

或者采用闭包的方式

function Singleton(name) {
  this.name = name;
}

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

Singleton.getInstance = (function () {
  var instance;
  return function (name) {
    if (!instance) {
      this.instance = new Singleton(name);
    }
    return this.instance;
  };
})();
// 获取对象1
var a = Singleton.getInstance("a");
// 获取对象2
var b = Singleton.getInstance("b");
// 进行比较
console.log(a === b);

以上两种方法都不太透明, 我们需要通过 Singleton.getInstance() 对象, 不知道的需要研究代码的实现 与我们常见的用 new 关键字来获取对象有出入,实际意义不大。

// 单例构造函数
function CreateSingleton(name) {
  this.name = name;
  this.getName();
}

// 获取实例的名字
CreateSingleton.prototype.getName = function () {
  console.log(this.name);
};

// 单例对象
var Singleton = (function () {
  var instance;
  return function (name) {
    if (!instance) {
      instance = new CreateSingleton(name);
    }
    return instance;
  };
})();

// 创建实例对象1
var a = new Singleton("a");
// 创建实例对象2
var b = new Singleton("b");

console.log(a === b);

我们通常是采用这种方式。

JavaScript 单例模式

而在开发中我们避免全局变量污染的通常做法如下:

  • 全局命名空间

  • 使用闭包

它们的共同点是都可以定义自己的成员、存储数据。区别是全局命名空间的所有方法和属性都是公共的,而闭包可以实现方法和属性的私有化。

惰性单例模式

而惰性单例模式的原理也是这样的,只有当触发创建实例对象时,实例对象才会被创建。这样的实例对象创建方式在开发中很有必要的。

var singleton = function (fn) {
  var instance;
  return function () {
    return instance || (instance = fn.apply(this, arguments));
  };
};

最后更新于

这有帮助吗?