深入理解JS设计模式 单例模式

2,126 阅读8分钟

公司项目因为不是一手项目,近期正好有页面和需求的改版,所以想着优化一些页面。看到很多的if...else...语句,所以思考准备如何用设计模式优化下。

前言

Javascript和其他语言一样,同样拥有着很多种设计模式,比如单例模式、代理模式、观察者模式,策略模式等,熟练运用设计模式可以使我们的代码逻辑更加清晰,并且更加易于维护和重构。

本文主要介绍JS的单例模式,涉及知识点较多,包括闭包,作用域链,立即执行函数等(如果有误欢迎指出)

正文

单例模式定义

单例模式也叫单体模式,核心思想是确保一个类只对应一个实例。虽然JS是弱类型的语言,但是同样也有类的概念。在ES5中通过构造函数模式、原型模式、组合模式等创建类,ES6可以通过Class关键字创建类。

实现一个单例模式

实现单例模式不简略说下JS的数值类型分为基本类型(Undefined、Null、Boolean、Number、String、)和引用类型(Object、Fucntion、Array)。也就是说基本类型存储在栈内存中,引用类型存储在堆内存中,并在栈中存储指向堆中内容的指针

所以在JS中实现单例模式核心思路是:每次调用构造函数时,返回指向同一个对象的指针。 也就是说,我们只在第一次调用构造函数时创建新对象,之后调用返回时返回该对象即可。所以重点变成了如何缓存初次创建的变量对象

当然全局变量首先被排除掉。因为全局变量容易被修改和污染,产生一些意料之外的情况。所以决定采用以下方法来缓存创建的对象。

1.构造函数

    //ES5实现
    function President(name) {
        // 如果已存在对应的实例
        if (typeof President.onlyPresident === 'object') {
            return President.onlyPresident
        }
        //否则正常创建实例
        this.name = name
        // 缓存
        President.onlyPresident = this
        return this
    }
    var president1 = new President("奥巴马")
    var president2 = new President("特朗普")
    console.log(president1)
    console.log(president2)
    //ES6实现
    class President {
        constructor(name) {
            if (!President.onlyPresident) {
                this.name = name
                President.onlyPresident = this
            }
            return President.onlyPresident
        }
    }
    var president3 = new President("奥巴马")
    var president4 = new President("特朗普")
    console.log(president3)
    console.log(president4)

可以看下两次打印的内容,在两个实例的构造函数中,都分别有一个onlyPresident的属性,打印出来都是第一次实例化对象所缓存的属性。

注意: 这种方法有一个缺点就是将静态属性暴露到了外面,可以被人为重写。

    //ES6修改静态属性
    class President {
        constructor(name) {
            if (!President.onlyPresident) {
                this.name = name
                President.onlyPresident = this
            }
            return President.onlyPresident
        }

    }
    var president3 = new President("奥巴马")
    var president4 = new President("特朗普")
    president4.constructor.onlyPresident = "小布什"//在此修改构造函数的静态属性
    console.log(president3)
    console.log(president4)

可以看到打印的结果:第一次实例化应该缓存的是 ‘奥巴马’,但是打印出的缓存实例却是‘小布什’。

2.借助闭包函数

要使用闭包,首先要理解javascript的特殊的变量作用域。 变量的作用域无非就两种:全局变量和局部变量。 javascript语言的特别之处就在于:函数内部可以直接读取全局变量,但是在函数外部无法读取函数内部的局部变量。 注意点:在函数内部声明变量的时候,一定要使用var命令。如果不用的话,你实际上声明的是一个全局变量!

  • 闭包函数的定义

    在MDN的解释是:函数与对其状态即词法环境(lexical environment)的引用共同构成闭包(closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。

    我的理解是:一个函数A返回函数B,函数B返回函数A中的变量,也可以说闭包是函数内部和函数外部连接起来的桥梁。

    下面是个简单的闭包例子:

      var Outer = function () {
          var countX = 0;
          return function () {
              console.log("这是闭包的块级作用域")
              return countX
          }
      }
      console.log(countX) //Uncaught ReferenceError: countX is not defined
      let x = Outer()()
      console.log(x) // 0
    

    上面例子可以看出,匿名函数中变量countX在外部是无法访问的。但是通过匿名函数中嵌套一个函数并返回出来就可以在外面接收 这个变量。这样就可以起到函数内部和外面联通的桥梁的作用。也起到了隔离函数作用域,防止变量污染。

  • 根据闭包实现一个单例

    function President(name) {
        this.name = name
        President.onlyPresident = this
        President = function () {
            return President.onlyPresident
        }
    }
    President.prototype.getName=function(){
        console.log(this.name)
    }
    var president1 = new President("奥巴马")
    var president2 = new President("特朗普")
    var president3 = new President("小布什")
    console.log(president1) //President {name: "奥巴马"}
    president1.getName() // 奥巴马
    console.log(president2) //President {}
    president2.getName() // Uncaught TypeError: president2.getName is not a function
    console.log(president3) //President {}

通过闭包的方式来实现的核心思路是,当对象第一次被创建以后,重写构造函数,在重写后的构造函数里面访问私有变量。 但是这么做有一个缺陷就是:在第二次new一个实例的时候,因为构造函数已经被重写,所以之前添加的getName方法就会丢失,当调用president2.getName()的时候就会报错。下面可以通过调试器看到参数的引用情况:

可以看到第一次重写构造函数的时候,president1的构造函数还包含了onlyPresident属性,当第二次实例化的时候,President构造函数实际已经变为了return President.onlyPresident这样的新函数。可以做个简单测测试

 console.log(president1.constructor === President) //false
 console.log(president2.constructor === President) //true
  • 优化闭包单例

既然知道了上面的问题出现在哪里,那就可以优化解决问题,优化他们原型实例之间的关系

    function President(name) {
        this.name = name
        var instance = this
        President = function () {
            // President.prototype = this
            return instance
        }
        // 当第一次修改完构造函数后,重新更改构造函数的原型链
        President.prototype = this
        //第二种写法,直接指向旧的原型
        President.prototype = this.constructor.prototype
        instance = new President()
        return instance
    }
    President.prototype.getName = function () {
        console.log(this.name)
    }
    var president1 = new President("奥巴马")
    var president2 = new President("特朗普")
    var president3 = new President("小布什")
    president1.getName() //奥巴马
    president2.getName() //奥巴马
    president3.getName() //奥巴马

这是第一种写法更改原型链后,在调试器中看到的构造函数的形式,可以发现President的原型函数包含整个Presidnet构造函数

这是第二种写法更改原型链后,在调试器中看到的构造函数的形式,可以发现President的原型函数就是旧的原型

3.借助立即执行函数

通过闭包和立即执行函数结合,来保存私有变量。立即执行函数只是函数的一种调用方式,只是声明完之后立即执行,这类函数一般都只是调用一次(可用于单例对象上),调用完之后会立即销毁,不会占用内存。但是和闭包结合使用后,但因为被引用的内部变量不能被销毁,所以会常驻内存。每次访问的时候都要校验是否存在实例,防止重复创建实例。

下面是一个立即执行函数和闭包结合的单例的例子:

    //第一步
    function President(name) {
        this.presidentName = name;
    }
    President.prototype.getName = function () {
        return this.presidentName
    };
    //第二步
    var SinglePrisident = (function () {
        var instance = null;
        return function (name) {
            if (!instance) {
                instance = new President(name);
            }
            return instance;
        }
    })();
    第三步
    var prisident1 = SinglePrisident('奥巴马').getName()
    var prisident2 = SinglePrisident('特朗普').getName()
    var prisident3 = SinglePrisident('小布什').getName()
    //第四步
    console.log(prisident1)
    console.log(prisident2)
    console.log(prisident3)

为了方便描述把代码分为四步走。 当代码第一次自上而下执行完第一步到第二步SinglePrisident,声明完函数分配完内存空间,会立即执行函数里面的内容,创建函数作用域,使变量manager为函数内部变量。可以看下调试器,在执行到第二步的时候各参数的情况。

可以看到,这时候立即执行函数已经在global中存在了。而且在它的[[Scopes]]中还包含了一个闭包。其他的参数还是undefined。

当执行完第三步中的var prisident1 = onlyPrisident('奥巴马').getName()的时候,这时候,instance还为null值,然后创建新的Prisident实例并被保存在闭包中,并通过return返回出来,被变量prisident1接收到。

但是这时候同时变量SinglePrisident也已经变为一个新的函数(暂时称为函数A)就是里面所return的函数,下面就是SinglePrisident的新函数

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

所以当运行第二次var prisident2 = SinglePrisident('特朗普').getName()的时候,在函数SinglePrisident内部,并没有声明新的var instance = null;而是通过新的返回函数(上文中的函数A)来取到闭包中instance的值,这时候闭包中instance已经缓存了第一次创建的实例。所以第二次仍返回第一次实例化的对象。

根据调试的情况可以看出,SinglePrisident已经传入的参数 '特朗普' ,但是判断到instance已经存在实例,所以仍是返回第一次所缓存的实例,所以prisident2的打印仍是**'奥巴马'**

当然第三次运行结果和第二次一样,在这里不再叙述。有兴趣的小伙伴可以把代码复制下来,自己debugger下看看。

结语

也是近期偶然对设计模式感兴趣,本来是想深入了解策略模式的,但是当我看文章对单例模式都不熟悉的时候,所以在此深入了解了js单例模式的前因后果,以及创建的方法和原理。如果对你有帮助请记得点赞~如果有不对的地方,也请指正,共同探讨和进步。