场景
如果需要实现一个全局的 loading
遮罩层,正常展示是这样的:

但如果用户连续调用 loaing
两次,第二个遮罩层就会覆盖掉第一个:

看起来就像出了 bug
一样,因此我们需要采用单例模式,限制用户同一时刻只能调用一个全局 loading
。
单例模式
看下 维基百科 给的定义:
In software engineering, the singleton pattern is a software design pattern that restricts the instantiation of a class to one "single" instance. This is useful when exactly one object is needed to coordinate actions across the system.
可以说是最简单的设计模式了,就是保证类的实例只有一个即可。

看一下 java
的示例:
1public class Singleton {
2
3 private static final Singleton INSTANCE = new Singleton();
4
5 private Singleton() {}
6
7 public static Singleton getInstance() {
8 return INSTANCE;
9 }
10}
上边在初始化类的时候就进行了创建对象,并且将构造函数设置为 private
不允许外界调用,提供 getInstance
方法获取对象。
还有一种 Lazy initialization
的模式,也就是延迟到调用 getInstance
的时候才去创建对象。但如果多个线程中同时调用 getInstance
可能会导致创建多个对象,所以还需要进行加锁。
1public class Singleton {
2 private static volatile Singleton instance = null;
3 private Singleton() {}
4 public static Singleton getInstance() {
5 if (instance == null) {
6 synchronized(Singleton.class) {
7 if (instance == null) {
8 instance = new Singleton();
9 }
10 }
11 }
12 return instance;
13 }
14}
但单例模式存在很多争议,比如可测试性不强、对抽象、继承、多态都支持得不友好等等,但我感觉主要是基于 class
这类语言引起的问题,这里就不讨论了。
回到 js
,模拟上边实现一下:
1const Singleton = function () {
2 this.instance = null
3}
4Singleton.getInstance = function (name) {
5 if (!this.instance)
6 this.instance = new Singleton()
7
8 return this.instance
9}
10const a = Singleton.getInstance()
11const b = Singleton.getInstance()
12console.log(a === b) // true
但上边就真的是邯郸学步一样的模仿了 java
的实现,事实上,js
创建对象并不一定需要通过 new
的方式,下边我们详细讨论下。
js 的单例模式
首先单例模式产生的对象一般都是工具对象等,比如 jQuery
。它不需要我们通过构造函数去传参数,所以就不需要去 new
一个构造函数去生成对象。
我们只需要通过字面量对象, var a = {}
,a
就可以看成一个单例对象了。
通常的单例对象可能会是下边的样子,暴露几个方法供外界使用。
1const Singleton = {
2 method1() {
3 // ...
4 },
5 method2() {
6 // ...
7 },
8}
但如果Singleton
有私有属性,可以写成下边的样子:
1const Singleton = {
2 privateVar: '我是私有属性',
3 method1() {
4 // ...
5 },
6 method2() {
7 // ...
8 },
9}
但此时外界就可以通过 Singleton
随意修改 privateVar
的值。
为了解决这个问题,我们可以借助闭包,通过 IIFE (Immediately Invoked Function Expression)
将一些属性和方法私有化。
1const myInstance = (function () {
2 const privateVar = ''
3
4 function privateMethod() {
5 // ...
6 }
7
8 return {
9 method1() {},
10 method2() {},
11 }
12})()
但随着 ES6
、Webpack
的出现,我们很少像上边那样去定义一个模块了,而是通过单文件,一个文件就是一个模块,同时也可以看成一个单例对象。
1// singleton.js
2const somePrivateState = []
3
4function privateMethod() {
5 // ...
6}
7
8export default {
9 method1() {
10 // ...
11 },
12 method2() {
13 // ...
14 },
15}
然后使用的时候 import
即可。
1// main.js
2import Singleton from './singleton.js'
3// ...
即使有另一个文件也 import
了同一个文件。
1// main2.js
2import Singleton from './singleton.js'
但这两个不同文件的 Singleton
仍旧是同一个对象,这是 ES Moudule
的特性。
那如果通过 Webpack
将 ES6
转成 ES5
以后呢,这种方式还会是单例对象吗?
答案当然是肯定的,可以看一下 Webpack
打包的产物,其实就是使用了 IIFE
,同时将第一次 import
的模块进行了缓存,第二次 import
的时候会使用之前的缓存。可以看下 __webpack_require__
的实现,和单例模式的逻辑是一样的。
1function __webpack_require__(moduleId) {
2 const cachedModule = __webpack_module_cache__[moduleId]
3
4 // 单例模式的应用
5 if (cachedModule !== undefined)
6 return cachedModule.exports
7
8 const module = (__webpack_module_cache__[moduleId] = {
9 exports: {},
10 })
11 __webpack_modules__[moduleId](module, module.exports, __webpack_require__)
12 return module.exports
13}
代码实现
回头开头我们说的全局 loading
的问题,解决起来也很简单,同样的,如果已经有了 loading
的实例,我们只需要直接返回即可。
这里直接看一下 ElementUI
对于全局 loading
的处理。
1// ~/packages/loading/src/index.js
2
3let fullscreenLoading;
4
5const Loading = (options = {}) => {
6 ...
7 // options 不传的话默认是 fullscreen
8 options = merge({}, defaults, options);
9 if (options.fullscreen && fullscreenLoading) {
10 return fullscreenLoading; // 存在直接 return
11 }
12
13 let parent = options.body ? document.body : options.target;
14 let instance = new LoadingConstructor({
15 el: document.createElement('div'),
16 data: options
17 });
18
19 ...
20 if (options.fullscreen) {
21 fullscreenLoading = instance;
22 }
23 return instance;
24};
这样在使用 Element
的 loading
的时候,如果同时调用两次,其实只会有一个 loading
的遮罩层,第二个并不会显示。
1mounted() {
2 const first = this.$loading({
3 text: '我是第一个全屏loading',
4 })
5
6 const second = this.$loading({
7 text: '我是第二个'
8 })
9
10 console.log(first === second); // true
11}
更多场景
如果使用了 ES6
的模块,其实就不用考虑单不单例的问题了,但如果我们使用的第三方库,它没有 export
一个实例对象,而是 export
一个 function/class
呢?
比如之前介绍的 发布-订阅模式 的 Event
对象,这个肯定需要是全局单例的,如果我们使用 eventemitter3
这个 node
包,看一下它的导出:
1'use strict';
2
3var has = Object.prototype.hasOwnProperty
4 , prefix = '~';
5
6/**
7 * Constructor to create a storage for our `EE` objects.
8 * An `Events` instance is a plain object whose properties are event names.
9 *
10 * @constructor
11 * @private
12 */
13function Events() {}
14
15//
16// We try to not inherit from `Object.prototype`. In some engines creating an
17// instance in this way is faster than calling `Object.create(null)` directly.
18// If `Object.create(null)` is not supported we prefix the event names with a
19// character to make sure that the built-in object properties are not
20// overridden or used as an attack vector.
21//
22if (Object.create) {
23 Events.prototype = Object.create(null);
24
25 //
26 // This hack is needed because the `__proto__` property is still inherited in
27 // some old browsers like Android 4, iPhone 5.1, Opera 11 and Safari 5.
28 //
29 if (!new Events().__proto__) prefix = false;
30}
31
32/**
33 * Representation of a single event listener.
34 *
35 * @param {Function} fn The listener function.
36 * @param {*} context The context to invoke the listener with.
37 * @param {Boolean} [once=false] Specify if the listener is a one-time listener.
38 * @constructor
39 * @private
40 */
41function EE(fn, context, once) {
42 this.fn = fn;
43 this.context = context;
44 this.once = once || false;
45}
46
47/**
48 * Add a listener for a given event.
49 *
50 * @param {EventEmitter} emitter Reference to the `EventEmitter` instance.
51 * @param {(String|Symbol)} event The event name.
52 * @param {Function} fn The listener function.
53 * @param {*} context The context to invoke the listener with.
54 * @param {Boolean} once Specify if the listener is a one-time listener.
55 * @returns {EventEmitter}
56 * @private
57 */
58function addListener(emitter, event, fn, context, once) {
59 if (typeof fn !== 'function') {
60 throw new TypeError('The listener must be a function');
61 }
62
63 var listener = new EE(fn, context || emitter, once)
64 , evt = prefix ? prefix + event : event;
65
66 if (!emitter._events[evt]) emitter._events[evt] = listener, emitter._eventsCount++;
67 else if (!emitter._events[evt].fn) emitter._events[evt].push(listener);
68 else emitter._events[evt] = [emitter._events[evt], listener];
69
70 return emitter;
71}
72
73/**
74 * Clear event by name.
75 *
76 * @param {EventEmitter} emitter Reference to the `EventEmitter` instance.
77 * @param {(String|Symbol)} evt The Event name.
78 * @private
79 */
80function clearEvent(emitter, evt) {
81 if (--emitter._eventsCount === 0) emitter._events = new Events();
82 else delete emitter._events[evt];
83}
84
85/**
86 * Minimal `EventEmitter` interface that is molded against the Node.js
87 * `EventEmitter` interface.
88 *
89 * @constructor
90 * @public
91 */
92function EventEmitter() {
93 this._events = new Events();
94 this._eventsCount = 0;
95}
96
97...
98
99/**
100 * Add a listener for a given event.
101 *
102 * @param {(String|Symbol)} event The event name.
103 * @param {Function} fn The listener function.
104 * @param {*} [context=this] The context to invoke the listener with.
105 * @returns {EventEmitter} `this`.
106 * @public
107 */
108EventEmitter.prototype.on = function on(event, fn, context) {
109 return addListener(this, event, fn, context, false);
110};
111
112...
113
114...
115// Alias methods names because people roll like that.
116//
117EventEmitter.prototype.off = EventEmitter.prototype.removeListener;
118EventEmitter.prototype.addListener = EventEmitter.prototype.on;
119
120//
121// Expose the prefix.
122//
123EventEmitter.prefixed = prefix;
124
125//
126// Allow `EventEmitter` to be imported as module namespace.
127//
128EventEmitter.EventEmitter = EventEmitter;
129
130//
131// Expose the module.
132//
133if ('undefined' !== typeof module) {
134 module.exports = EventEmitter;
135}
可以看到它直接将 EventEmitter
这个函数导出了,如果每个页面都各自 import
它,然后通过 new EventEmitter()
来生成对象,那发布订阅就乱套了,因为它们不是同一个对象了。
此时,我们可以新建一个模块,然后 export
一个实例化对象,其他页面去使用这个对象就实现单例模式了。
1import EventEmitter from 'eventemitter3'
2// 全局唯一的事件总线
3const event = new EventEmitter()
4export default event
总
单例模式比较简单,主要是保证全局对象唯一,但相对于通过 class
生成对象的单例模式,js
就很特殊了。
因为在 js
中我们可以直接生成对象,并且这个对象就是全局唯一,所以在 js
中,单例模式是浑然天成的,我们平常并不会感知到。
尤其是现在开发使用 ES6
模块,每个模块也同样是一个单例对象,平常业务开发中也很少去应用单例模式,为了举出上边的例子真的是脑细胞耗尽了,哈哈。