美文网首页
JavaScript中的设计模式(1)——单例模式

JavaScript中的设计模式(1)——单例模式

作者: TingsLee | 来源:发表于2017-07-07 00:34 被阅读0次

    转自:http://dickeylth.github.io/2013/10/11/JavaScriptDesignPatterns-SingletonPattern/
    最近开始系统学习设计模式,虽然以前偶尔有接触,但总感觉不够系统,正好需要做这方面的分享,遂决定来系统学习和记录一下。
    设计模式是程序设计中老生常谈的话题了,简单说就是针对某些可抽象为类似问题的通用的解决方案。虽然方案的思路是死的,但在不同语言中的实现由于语言间的特性会有些差异,尤其对于像 JavaScript 这样的动态语言而言,可能某些设计模式实现起来相比静态语言更为灵活。
    当然,除了提供通用的解决方案,个人感觉更重要的是设计模式的出现提供了各种模块解耦的思路,为什么需要模块解耦?因为大多数时候我们不太可能总是推倒从来,而往往是在现有的系统基础之上做进一步的优化完善,系统中模块之间的耦合度越低,可扩展性就会越强,从而可以支撑更为复杂的业务场景和需求。另外,设计模式也是为了应对驾驭复杂系统的代码组织架构,熟练掌握之后会对于系统的架构有更进一步的认识,从而提升自己在业务上独当一面的能力。
    其实,设计模式并不是很遥远的东西,很可能很多时候自己已经在用了而没有感觉出来,比如 JavaScript 中的全局唯一变量就可以看作是一种单例模式。更宏观一点来看,其实设计模式在社会中早已存在,在计算机被创造出来,人类已经在应用它了,比如“烽火戏诸侯”不就是一种观察者模式(也叫 pub-sub 注册发布模式)?所以在这个系列中,我会尽可能从贴近生活的角度来阐释每种模式在身边的例子,从而更易于理解模式的思想。
    因此,基于 JavaScript 的设计模式,更多地应该考虑从语言特性、场景和环境出发,不求形似但求神似,重要是模式中传达出的解决思路,领悟了这一点比死记硬背要有用得多,当然还离不开最重要的熟练应用。在这个系列中让我们一起来看一下设计模式与 JavaScript 会碰撞出什么样的火花。

    参考数目:
    Learning JavaScript Design Patterns
    JavaScript Patterns

    一、要解决的问题

    单例模式主要目的是确保系统中某个类只存在唯一一个实例,也就是说对于这个类的重复实例的创建始终只返回同一个实例。它和工厂模式一样主要是为了解决对象的创建问题。从前面的描述我们可以看出单例模式的几大特点:

    1. 这个类只有一个实例;
    2. 该类需要负责实例的初始化工作;
    3. 对外需提供这个唯一实例的访问接口。

    生活中有单例模式存在吗?有,比如大家都知道的12306 是唯一购票网站,所有人要网上订票都得访问这个单例。再比如,法律规定,每个中国男人都只能有一个合法妻子,当然现实之中还有离婚再婚,单例模式更像是理想状况下的白头偕老了。
    单例模式带来的好处?除了减少不必要的重复的实例创建、减少内存占用外,更重要的是避免多个实例的存在造成逻辑上的错误。比如超级马里奥的游戏中,虽然各种小怪的实例会不断创建多个,但当前的玩家肯定只有一个,如果游戏运行过程中创建出新的马里奥的实例了,显然就出 bug 了。

    二、单例模式的实现方法及分析

    2.1对象字面量

    对于 Java 之类的静态语言而言,实现单例模式常见的方法就是将构造函数私有化,对外提供一个比如名为getInstance方法的静态接口来访问初始化好的私有静态的类自身实例。但对于 JavaScript 这样的动态语言而言,单例模式的实现其实可以很灵活,因为 JavaScript 语言中并不存在严格意义上的类的概念,只有对象。每次创建出的新对象都和其他的对象在逻辑上不相等,即使它们是具有完全相同的成员属性的同构造器创造出的对象。所以,在 JavaScript 中,最常见的单例模式莫过于对象字面量(object literal)了:

    var x = {
      attr: 'value'
    };
    var y = {
      attr: 'value'
    };
    
    x == y;     // false
    x === y;    // false
    

    可见,对象字面量就是一种最简单最常见的单例模式了。在全局的其他地方要获得这个单例的对象,其实就是获得这个唯一的全局变量就可以保证访问的是同一实例了。


    上面的对象字面量仅仅是一个简单的键值对,但很多时候对象可能还涉及到初始化的工作,可能需要实现按需加载(懒加载),对象中还会存在内部私有成员,对外需以门面模式(Facade)提供可访问的接口。所以我们还可以把这个简单的对象字面量再扩充一下:

    var SuperMario = (function(){
      var instance = null;
    
      // 初始化函数
      function init(){        
          var gener = 'male',
              age = 12,
              height = 120;
        
          // 门面模式返回成员属性
          return { 
            name: 'Mario',
            getAge: function(){
                return age;
            },
            getHeight: function(){
                return height;
            },            
            jump: function(){
                console.log("I'm jumping!");
            },
            run: function(){
                console.log("I'm running!");
            }
          };
        }
    
      return {
        
        getInstance: function(){
            if(!instance){
                instance = init();
            }
            return instance;
        } 
      };
    })();
    console.log(SuperMario.getInstance());
    

    在 Chrome 控制台下运行可以得到如下结果:


    result

    让我们来简单分析一下这段代码。首先依然是给对象赋值,但是采用的是即时函数的方式,从而创建出一个闭包,里面存放着 SuperMario 的真身——instance,在结尾时暴露一个getInstance方法向外提供该实例的引用,有点像静态语言中的单例模式了吧?
    在这个闭包之内,创建了一个内部私有的init初始化函数,完成 SuperMario 对象的初始化工作。注意到这里再一次使用了闭包,将ageheight这些私有成员的值保护起来,对外只提供getter访问器,不允许外部代码对其修改。除此之外,还向外提供了可公开的runjump方法。
    为了实现懒加载,init初始函数并不是自动执行的,而是调用getInstance方法时检查到当前instance还没有被初始化过时才会去执行init,而在下次再次getInstance时就直接返回之前已初始化好的实例了,这样就不至于给页面的初始化工作带来太大的负担,而是需要使用的时候按需完成初始化。

    2.2 使用new创建对象

    虽然 JavaScript中没有类,但是却也具有new这个关键字来利用构造函数创建对象。对于这种形式创建的对象,要实现单例模式的思想,就需要保证每次new出来的对象都是对同一对象的指针。也就是说预期应该像下面的代码这样:

    var x = new SuperMario();
    var y = new SuperMario();
    x == y;     // true
    

    因此需要保证 xy 指向的是同一个SuperMario 构造函数构造出的对象,即第二次调用SuperMario构造函数返回的是第一次调用时构造出的实例的引用,同样以后每次调用该构造函数返回的应该都是这同一实例的引用。那么实现上主要就是要解决这个实例的存放位置问题,有几种选择方案:

    • 使用全局变量来存储。当然这种方案一般都不值得推荐;
    • 缓存到SuperMario构造函数的静态属性中,实现起来也比较简洁,但缺点是不能避免该静态属性被外部代码
      修改,毕竟 JavaScript 不像静态语言能做到对静态属性的写保护;
    • 借助闭包实现。这样可以确保实例的内部私有性,缺点是额外的开销,这是引入闭包必然会带来的弊端。

    下面分别看看后两种方案的具体实现。

    2.2.1 静态属性中的实例

    采用静态属性的方式代码比较简单易懂,基本的结构类似这样:

    // 定义
    function SuperMario(){
      // 判断当前静态属性是否已存在
      if(typeof SuperMario.instance === "object"){
        return SuperMario.instance;
      }
      // 定义属性值
      this.name = "Mario";
      this.age = 12;
      this.gener = "male";
      // 缓存到静态属性中
      SuperMario.instance = this;
      // 可要可不要,默认隐式返回 this
      return this;  
    }
    
    // 执行
    var x = new SuperMario();
    var y = new SuperMario();
    x == y; // true
    

    看上去很简单对吧?不过问题来了:
    如果在执行部分添加一行代码:

    // 执行
    var x = new SuperMario();
    SuperMario.instance = null;
    var y = new SuperMario();
    x == y;         // ?
    console.log(y); // ?
    

    你肯定已经猜到了此时 x == y结果是false,而对于下一行呢?console.log(y)将输出什么呢?
    更进一步地,如果我们在SuperMario的构造函数中再加一行:

    // 定义
    function SuperMario(){
      this.attr = 'value';
    
      // 判断当前静态属性是否已存在
      if(typeof SuperMario.instance === "object"){
          return SuperMario.instance;
      }
      ...   
    }
    

    此时console.log(y)又会返回什么呢?
    其实这里涉及到的是一个构造函数返回值的问题,大多数情况下我们都不会在构造函数中显式返回值,因为默认的 this 会自动隐式返回。说到这里,你可能需要先深入了解下当以new操作符调用构造函数时到底发生了什么?

    当以new操作符调用构造函数时,函数内部将会发生以下情况:

    1. 创建一个空对象并且 this变量引用了该对象,同时还继承了该函数的原型;
    2. 属性和方法被加入到this引用的对象中;
    3. 新创建的对象由this所引用,并且最后隐式地返回this(如果没有显式地返回其他对象)
      JavaScript PatternsStoyan Stefanov(中文版 P45)

    那么在构造函数中定义了 return时,以new调用的结果是怎样的呢?
    stackoverflow 上也有类似的问题:What values can a constructor return to avoid returning this?,第一个回答的引用,也就是ECMA-262 中定义了返回策略
    我们也可以把结论简单记为两条:
    return一个引用对象(数组、函数、对象等)时,直接覆盖内部的隐式this对象,返回值就是该引用对象;当return5 种基本类型(undefinednullBooleanNumberString)之一时(无return时其实就是返回undefined),返回内部隐式this对象。

    还需要注意一点,基本类型可以以包装器包装成对象,所以:

    function SuperMario(){
      ...
      return new String('mario');
      return 'mario';
    }
    

    两者的返回值就不一样了。
    现在你应该可以得出上面的问题的答案了吧?

    2.2.2 闭包中的实例

    采用闭包的方式一般将初始化后的实例用闭包保护起来,而后重写构造函数直接返回该实例,于是我们可以简单得到以下代码:

    function Person(){  
      var instance = this;
    
      this.attr = "Attribute";
      Person = function(){
        return instance;
      };
    }
    var p1 = new Person();
    var p2 = new Person();
    

    但这样会有什么潜在的问题呢?我们来稍作一点变化:

    function Person(){
        var instance = this;
    
      this.attr = "Attribute";
    
      Person = function(){
          console.log(this);
          return instance;
      };
    }
    
    Person.prototype.job = 'FE';
    
    var p1 = new Person();
    
    Person.prototype.city = 'Beijing';
    
    var p2 = new Person();
    
    console.log(p1);
    console.log(p2);
    console.log(p1.constructor === Person);
    //console.log(Person);
    

    出现什么问题了?之后给Person类添加的prototype属性被丢失了,这却是为何?因为我们重写了构造函数,结果月亮还是那个月亮,Person却不再是那个Person了。在第二次new Person()时我们可以打印出此时的this,可以看到它是继承了后面挂载的city原型属性的,但因为原来的Person已经被覆盖了,所以原来的job属性就找不到了。而后我们return instance的执行,根据上文中的结论,就会直接覆盖构造函数中的隐式this,结果就丢掉了后面增加的原型属性city了。


    有没有改进的方法呢?经过了上面的分析,我们就可以知道,要解决这个问题,关键是除了重写构造函数之外,还需要修复继承链和构造函数,于是可以得到下面的代码:

    function Person(){
    
      var instance;
    
    
      Person = function Person(){
        return instance;
      };
    
    
      Person.prototype = this;  // this 
    
      instance = new Person();
    
      instance.constructor = Person;
    
      instance.attr = "Attribute";
    
      return instance;
    }
    
    Person.prototype.job = 'FE';
    
    var p1 = new Person();
    
    Person.prototype.city = 'Beijing';
    
    var p2 = new Person();
    
    console.log(p1);
    console.log(p2);
    console.log(p1.constructor === Person);
    

    其实这个时候重写后的Person类实质上变成了之前老的Person类的子类了,它们之间就是通过这句Person.prototype = this;联系起来的。我们也可以在控制台看看Person展开后的样子来认识一下这个新的Person

    最后,留一个小问题:

    ...
    // 重写该构造函数
    Person = function Person(){
        return instance;
    };
    

    这里的·function Person中的Person是干嘛用的呢?

    三、在开源框架和类库中的应用

    单例模式在开源框架中应用其实很广泛,细数一下我们熟悉的前端开源框架和类库:jQueryYUIunderscoreKISSY 等,大多都有一个全局变量,比如 jQuery 中的jQuery(或$)、YUI 中的YUIunderscore 中的_KISSY 中的KISSY,这就是一种单例。让我们来看看 jQuery

    (function( window, undefined ) { 
      var jQuery = (function() {
       // 构建 jQuery 对象
       var jQuery = function( selector, context ) {
           return new jQuery.fn.init( selector, context, rootjQuery );
       }
    
       // jQuery 对象原型
       jQuery.fn = jQuery.prototype = {
           constructor: jQuery,
           init: function( selector, context, rootjQuery ) {
              // selector 有以下 7 种分支情况:
              // DOM 元素
              // body(优化)
              // 字符串:HTML 标签、HTML 字符串、#id、选择器表达式
              // 函数(作为 ready 回调函数)
              // 最后返回伪数组
           }
       };
    
       // 猜猜这句是干什么呢?
       jQuery.fn.init.prototype = jQuery.fn;
    
       // 合并内容到第一个参数中,后续大部分功能都通过该函数扩展
       // 通过 jQuery.fn.extend 扩展的函数,大部分都会调用通过 jQuery.extend 扩展的同名函数
       jQuery.extend = jQuery.fn.extend = function() {};
      
       // 在 jQuery 上扩展静态方法
       jQuery.extend({
           // ready bindReady
           // isPlainObject isEmptyObject
           // parseJSON parseXML
           // globalEval
           // each makeArray inArray merge grep map
           // proxy
           // access
           // uaMatch
           // sub
           // browser
       });
    
        // 到这里,jQuery 对象构造完成,后边的代码都是对 jQuery 或 jQuery 对象的扩展
       return jQuery;
    
      })();
      window.jQuery = window.$ = jQuery;
    })(window);
    

    通过上面的 jQuery 代码的总体结构,可见它同样是采用的是类似上面对象字面量形式创建全局的 jQuery 对象,在其中又重定义了构造函数,完成初始化工作,最后返回新的 jQuery 对象。

    四、总结

    通过上面的源码简析,个人觉得,在 JavaScript 中应用单例模式采用对象字面量的方式更易读易懂,应用也更为广泛,而从理论角度采用闭包模拟类似静态语言的单例的概念的方式,虽然也可以实现,但失掉了 JavaScript 作为一门动态语言的优势,而且代码相比之下可维护性差了些。当然采用对象字面量方式需要与使用者达成约定,即直接调用该实例而非通过构造函数来获得实例,这种调用方式也很自然。

    相关文章

      网友评论

          本文标题:JavaScript中的设计模式(1)——单例模式

          本文链接:https://www.haomeiwen.com/subject/asgkhxtx.html