你有没有遇到过这样的场景:页面上的登录浮窗,用户关掉再点,结果又弹出一个新的——两个一模一样的窗口傻傻分不清?或者你辛辛苦苦写了个全局配置对象,一不小心被覆盖得面目全非?这些让人抓狂的问题,单例模式都能帮你解决。

1. 什么是单例模式?

单例模式的定义简单粗暴:保证一个类只有一个实例,并提供一个全局的访问点

想象一下:你家的电视机遥控器,一个就够了,不需要第二个;公司的打印机,大家共用一台,没人想多买一台占地方。在程序里,线程池、全局缓存、浏览器里的window对象,都是单例的典型代表。

最常见的例子就是登录浮窗——你点一万次登录按钮,那个浮窗也只被创建一次,而不是每次都弹出个“克隆体”。这就是单例模式的魅力。

2. 传统实现:一个变量搞定

最简单的方式:用一个变量标记是否已经创建过对象,如果创建过,下次直接返回它。

var Singleton = function(name) {
    this.name = name;
    this.instance = null;   // 这个属性用来存实例?不,放这里不合适
};

// 把实例挂在构造函数上(作为静态属性)
Singleton.getInstance = function(name) {
    if (!this.instance) {
        this.instance = new Singleton(name);
    }
    return this.instance;
};

var a = Singleton.getInstance('sven1');
var b = Singleton.getInstance('sven2');
console.log(a === b); // true

或者用闭包把实例藏起来:

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

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

Singleton.getInstance = (function() {
    var instance = null;
    return function(name) {
        if (!instance) {
            instance = new Singleton(name);
        }
        return instance;
    }
})();

但是这种方式有一个缺点:使用者必须知道这是一个单例类,不能用new,只能用Singleton.getInstance来获取对象,增加了“不透明性”

3. 透明单例:像普通类一样使用

我们希望像平时一样用new来创建对象,但得到的永远是同一个实例。怎么做?可以用一个立即执行函数把真正的构造函数包起来,在里面用闭包保存实例。

var CreateDiv = (function() {
    var instance;

    var CreateDiv = function(html) {
        if (instance) {
            return instance;
        }
        this.html = html;
        this.init();
        return instance = this;
    };

    CreateDiv.prototype.init = function() {
        var div = document.createElement('div');
        div.innerHTML = this.html;
        document.body.appendChild(div);
    };

    return CreateDiv;
})();

var a = new CreateDiv('sven1');
var b = new CreateDiv('sven2');
console.log(a === b); // true

你们看出来这个构造函数的问题了吗:它既要创建对象、执行初始化,又要保证只有一个实例——违反了单一职责原则。如果哪天不需要单例了,要把它改成一个可以创建多个实例的类,就得修改构造函数内部,很麻烦。

4. 代理单例:把管理单例的职责分离

更好的办法是把管理单例的逻辑单独抽到一个代理类中。这样本体类就只是一个普通的类,代理类负责单例控制。

我们先写一个普通的CreateDiv类:

var CreateDiv = function(html) {
    this.html = html;
    this.init();
};

CreateDiv.prototype.init = function() {
    var div = document.createElement('div');
    div.innerHTML = this.html;
    document.body.appendChild(div);
};

然后引入代理类ProxySingletonCreateDiv

var ProxySingletonCreateDiv = (function() {
    var instance;
    return function(html) {
        if (!instance) {
            instance = new CreateDiv(html);
        }
        return instance;
    }
})();

var a = new ProxySingletonCreateDiv('sven1');
var b = new ProxySingletonCreateDiv('sven2');
console.log(a === b); // true

这样就很完美了!现在CreateDiv是一个普通类,和代理组合起来才具备单例效果。职责分明,各管各的。

5. JavaScript中的单例模式:没那么复杂

前面的例子都是从“”的角度出发的,但JavaScript其实没有真正的类,对象可以直接创建。既然我们只需要一个唯一的对象,为什么非要先定义一个“类”呢?直接创建一个对象不就行了?

var a = {};

这已经是一个独一无二的对象了。如果把它放在全局,就是全局变量,可以随处访问——这不就满足单例的两个条件了吗?

5.1 全局变量不是单例模式

虽然全局变量可以当成单例用,但它也有命名污染被意外覆盖的风险。Douglas Crockford甚至称全局变量是JavaScript中最糟糕的特性。所以我们得想办法减少全局变量的数量。

5.2 用命名空间减少污染

用对象字面量把相关属性和方法包起来:

var namespace = {
    a: function() { alert(1); },
    b: function() { alert(2); }
};

还可以动态创建命名空间:

var MyApp = {};
MyApp.namespace = function(name) {
    var parts = name.split('.');
    var current = MyApp;
    for (var i in parts) {
        if (!current[parts[i]]) {
            current[parts[i]] = {};
        }
        current = current[parts[i]];
    }
};

MyApp.namespace('event');
MyApp.namespace('dom.style');
console.dir(MyApp); // 现在 MyApp 下面有了 event 和 dom.style

5.3 用闭包封装私有变量

把一些变量藏在闭包里,只暴露接口:

var user = (function() {
    var name = 'sven', age = 29;
    return {
        getUserInfo: function() {
            return name + '·' + age;
        }
    };
})();

6. 惰性单例:需要时才创建

很多时候,单例对象并不需要一开始就创建。比如登录浮窗,用户可能根本不会点击登录按钮,那我们就没必要在页面加载时就创建它。

下面这个例子,只有在点击按钮时才创建浮窗,但每次点击都会创建新的——没有单例效果:

var createLoginLayer = function() {
    var div = document.createElement('div');
    div.innerHTML = '我是登录浮窗';
    div.style.display = 'none';
    document.body.appendChild(div);
    return div;
};

document.getElementById('loginBtn').onclick = function() {
    var loginLayer = createLoginLayer();
    loginLayer.style.display = 'block';
};

改进一下,用闭包保存实例,实现惰性单例:

var createLoginLayer = (function() {
    var div;
    return function() {
        if (!div) {
            div = document.createElement('div');
            div.innerHTML = '我是登录浮窗';
            div.style.display = 'none';
            document.body.appendChild(div);
        }
        return div;
    }
})();

document.getElementById('loginBtn').onclick = function() {
    var loginLayer = createLoginLayer();
    loginLayer.style.display = 'block';
};

这样既做到了惰性加载(第一次点击才创建),又保证了唯一性。

7. 通用惰性单例:把管理逻辑抽出来

上面的代码还有一点不完美:创建对象和管理单例的逻辑搅在了一起。如果以后需要创建唯一的iframescript标签,又得把同样的单例管理代码抄一遍。

我们可以把管理单例的逻辑抽象成一个函数getSingle,它接受一个创建对象的方法,返回一个单例化的函数:

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

现在创建登录浮窗的方法和单例逻辑分离了:

var createLoginLayer = function() {
    var div = document.createElement('div');
    div.innerHTML = '我是登录浮窗';
    div.style.display = 'none';
    document.body.appendChild(div);
    return div;
};

var createSingleLoginLayer = getSingle(createLoginLayer);

document.getElementById('loginBtn').onclick = function() {
    var loginLayer = createSingleLoginLayer();
    loginLayer.style.display = 'block';
};

创建唯一的iframe也同样简单:

var createSingleIframe = getSingle(function() {
    var iframe = document.createElement('iframe');
    document.body.appendChild(iframe);
    return iframe;
});

document.getElementById('loginBtn').onclick = function() {
    var loginLayer = createSingleIframe();
    loginLayer.src = 'http://baidu.com';
};

这个getSingle不仅适用于创建对象,还可以用来绑定只执行一次的事件:

var bindEvent = getSingle(function() {
    document.getElementById('div1').onclick = function() {
        alert('click');
    };
    return true;
});

var render = function() {
    console.log('开始渲染列表');
    bindEvent(); // 多次调用,事件也只绑定一次
};

render();
render();
render(); // 只弹一次

8. 单例模式的应用场景

  • 全局缓存:比如存储用户信息、配置数据。

  • 线程池、连接池:避免重复创建消耗资源。

  • 登录浮窗、消息提示:页面中只应该出现一个。

  • 第三方库的单例:比如axios实例、jQuery$本身就是一个单例。

9. 小结

单例模式是我们学习的第一个设计模式,虽然简单,但在实际开发中非常实用。通过传统的类式实现,我们了解了它的核心思想;通过JavaScript的灵活特性,我们找到了更轻量的实现方式。

特别是惰性单例通用惰性单例的组合,把创建对象和管理单例的职责分开,既符合单一职责原则,又让代码变得优雅可复用。下次遇到需要确保唯一实例的场景,不妨试试这个模式,保证让你省心不少!

更多推荐