这是进击的前端项目的设计模式系列基础子系列的第一篇

进击的前端这个项目目的在于构建大前端的体系化知识,感兴趣的话可以给个Star关注一下这个项目

写在前面

设计模式是软件工程的基石。在工程实践中我们会遇到各种各样的方案需求,软件领域的大神将这些各种各样的工程场景加以凝练总结出了大概23套开发的最佳实践,它们被称为设计模式。软件领域就像是一个江湖,有各种各样的门派、武功秘籍、剑法,而设计模式就像是剑圣将毕生所学总结出来形成23套破万法的剑技。既然是工程中的最佳实践,所以这23种设计模式只是通过如今软件领域总结出来的,说不定在未来的某天哪个大神又总结出了某个设计模式。所以学习设计模式一定不要死记硬背,而是要理解后多尝试在工程中复现、运用。也可以多多揣摩如今的各种各样的技术实现,透过现象看本质,看看这些实现都是用到了什么设计模式。各位剑客们,确定不与俺一起冲一冲吗?

单例模式的思想核心

设计模式被分为了三大类型,创建型、结构型、行为型。这篇文章讲解的便是创建型中的一种:单例模式(也称单态模式)

学习一个招式,我们往往得学明确这个招式能够产生什么样的威力或者效果。同理,学习设计模式我们得先明确其效果或者使用目的,也就是所谓的核心思想。

单例模式的定义是:保证一个类仅有一个实例,并提供一个访问它的全局访问点

保证一个类仅有一个实例这句话非常地好理解,无非就是一个类只能实例化一个对象,或者多次实例都是指向同一个对象。我们通过代码实现一下:

class Single {

    test() {
        console.log('ok');
    }

    static createInstance() {
        if (!Single.instance) {
            Single.instance = new Single();
        }

        return Single.instance;
    }
}

const s1 = Single.createInstance();
const s2 = Single.createInstance();


console.log(s1 === s2);   // true
s1.test();    // ok

可以看到我们已经很轻松地将这个类单例化了。其实就是对一个需要实例化的类加入一个判断逻辑,如果已经实例化后就返回已实例化的对象拿来复用,如果没有实例化就实例化一下。

所以我们可以看到单例模式的一大优点就是能够达到一个数据共享的目的,普通的实例化对象都是开辟不同的堆内存,然后讲各自的引用赋给我们的变量,从而对象之间互不影响,但是单例模式便是共用同一个引用,从而数据间是共享的,是会互相影响的。

我们给上面的代码更改下逻辑,用单例模式实现对象间的数据共享这个需求:

class Single {

    constructor() {
        this.data = 10;
    }

    showData() {
        console.log(this.data);
    }

    dataChange(data) {
        this.data = data;
    }

    // 单例核心
    static createInstance() {
        if (!Single.instance) {
            Single.instance = new Single();
        }

        return Single.instance;
    }

}

const s1 = Single.createInstance();
const s2 = Single.createInstance();

s1.showData();    // 10
s2.showData();    // 10

s1.dataChange(20);

s1.showData();    // 20
s2.showData();    // 20

善于思考的同学可能会问,既然我多个对象之间数据共享,那我就只用一个对象不就完事了?为什么还要创建多个?

笔者认为这是一个非常优秀的问题(嘿嘿不好意思我有点不要脸了:),对于跟我有同样困惑的同学我们要先知道有这么几点软件设计的最佳实践或者规范:

  • 尽量不要污染全局变量
  • 封装、模块化
  • 透明

如果有研究过JQuery源码的同学应该是会知道在整个JQuery的外层有类似这样一段代码(只是类似嗷,笔者忘了它现在的具体写法,可能已经采用ES6并且已经添加新的入参了):

(function(window, document) {
    // JQuery code...
    
    
    window.$ = new JQuery(...args);
})(window, document);

我们可以看到这其实是一个自执行的匿名函数,其作用就是构建一个作用域,使得外部的变量无法访问内部变量,这样可以防止JQuery中的变量命名跟使用者代码中的变量名冲突,对于使用者来说,JQuery透明的。在计算机领域中,透明的意思是不可见,无法访问。这一思想最出名的体现便是我们计算机网络中的协议层,每层协议之间都是透明的,仅暴露特定接口供信息传递。

在这里,window.$ = new JQuery(...arge)的作用便是JQuery向全局中暴露的唯一接口。因为将$绑定在了window上,所以其他模块或者全局便能访问(或使用)$了。

对作用域链熟悉的同学可能会问,那为什么要传windowdocument这两个变量?直接沿着作用域链去找不就好了吗?正是因为要通过作用域链去找这两个变量,所以才要传进去,因为这样可以缩短作用域链减少性能开销。

所以讲到这里,对单例模式的使用有疑惑的同学应该会清楚一下了,我们在工程是会本着封装思想,对单一、统一功能进行封装,也就是说我们一个工程中,不同的功能是分模块的甚至是分层的,所以让不同的模块去调用我们同一个对象是非常糟糕的事情。

那同学你可能会想,那我把这个对象放在全局咯,这样不是每个模块都能访问了?对的!是这样的,这就是种一个单例思想。我们还可以将这个实例对象的类放到全局或者说让它提供一个全局访问点,使得我们能够在不同的模块中调用,实例化对象,也可很大程度规避命名冲突问题。

所以现在能理解单例模式的思想了吗?保证一个类仅有一个实例,并提供一个访问它的全局访问点

可以注意到,我们刚刚的单例模式实现是采用了ES6新引入的语法糖class实现的。众所周知,JavaScript的面向原型系统是由两个原型语言进行编写的,selfsmalltalk。所以我们通过原型来写一下我们的单例实现:

function Single() {
    this.data = 10;
}

Single.prototype.showData = function () {
    console.log(this.data);
}

Single.prototype.changeData = function(data) {
    this.data = data;
}   

Single.createInstance = (function() {

    let instance = null;
    
    return function() {
        if (!instance) {
            instance = new Single();
        }

        return instance;
    }

})();

const s1 = Single.createInstance();
const s2 = Single.createInstance();

s1.showData();     // 10
s2.showData();       // 10

s2.changeData(7);


s1.showData();     // 7
s2.showData();     // 7

可以看到,笔者这里使用闭包实现了一个instance的长久存储,从而达到返回单一实例的作用。

单例模式的简单实现其实就这么简单,但是单例模式还可以衍生出很多其他实现机制或者方式(比如惰性单例,用代理实现单例等等),毕竟单例只是一个思想。

单例模式工程实践

单例模式在前端最经典的一个实现便是全局模态框,我们可以通过单例模式创建一个个性化的类似于alert的消息弹窗。

<!--基本样式-->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .button {
            height: 30px;
            width: 70px;
            border: 1px solid #f8f8f8;
            box-shadow: 0px 0px 5px rgba(0, 0, 0, .8);
            line-height: 30px;
            text-align: center;
            cursor: pointer;
        }
        .alert {
            height: 30px;
            width: 170px;
            text-align: center;
            line-height: 30px;
            font-size: 16px;
            border: 1px solid rgba(0, 0, 0, .8);
            box-shadow: 0px 3px 3px rgba(0, 0, 0, .9);
            position: fixed;
            top: 20px;
            left: 50%;
            transform: translate(-50%,0);
        }
    </style>
</head>
<body>
    
    <div class="button">alert</div>

    <script src="./alert.js"></script>
</body>
</html>

再来看看我们的逻辑实现:

const createAlert = (function() {
    let div = null;
    const attribute = document.createAttribute('class');
    attribute.value = 'alert';
    return function() {
        if (!div) {
            div = document.createElement('div');
            div.innerHTML = 'coco&cola'
            div.setAttributeNode(attribute);
            div.style.display = 'none';
            document.body.append(div);
        }


        return div;
    }
})();


// 点击功能模块
((window, document) => {

    const alert = document.querySelector('.button');

    alert.onclick = function() {
        const box = createAlert();
        box.style.display = 'block';
    }

})(window, document);

点击alert我们的窗体上就会出现唯一一个消息框,大大节省了性能。甚至我们可以将模态框在各个功能模块式,然后加入指定的文字,这都是可以的。这大大节省了DOM消耗,也将变化封装了起来,提高了复用性和可维护性。