JavaScript框架设计
上QQ阅读APP看书,第一时间看更新

第5章 类工厂

类与继承在JavaScript的出现,说明JavaScript已经到达大规模开发的门槛了。在此之前的es4,就试图引入类、模块等东西,但由于过分引入太多特性,搞得JavaScript乌烟瘴气,导致被否决,也只不过把类延迟到es6。到目前为此,JavaScript还没有真正意义的类,不过我们可以模拟类。曾经一段时间,类工厂是框架的标配。本章将会介绍各种类实现,方便大家在自己框架中选择或现实自己喜欢的那一类风格。

5.1 JavaScript对类的支撑

在其他语言中,类的实例都要通过构造函数 new 出来。作为一个刻意模仿 Java 的语言, JavaScript 存在 new 操作符,并且它的所有函数都可以作为构造器。构造器与普通的方法没有什么区别。浏览器为了构建它繁花似锦的生态圈,比如 Node,Element、HTMLElement、HTMLParagraphElement,显然使用继承关系方便一些方法或属性的共享,于是JavaScript从其他语言借鉴了原型这种机制。prototype作为一个特殊的对象属性存在于每一个函数上。当一个函数通过new操作符“分娩”出其孩子——“实例”,这个名为实例的对象就拥有这个函数的prototype对象所有的一切成员,从而实现所有实例对象都共享一组方法或属性。而JavaScript所谓的“类”就是通过修改这个 prototype 对象,以区别原生对象及其他自定义“类”。在浏览器中,Node 这个类就是基于Object修改而来的,而Element则是基于Node,而HTMLElement又基于Element……相对于我们的工作业务,我们也可以创建自己的类来实现重用与共享。

      function A() {
      }
      A.prototype = {
          aa: "aa",
          method: function() {
          }
      };
      var a = new A;
      var b = new A;
      console.log(a.aa === b.aa);//true
      console.log(a.method === b.method);//true

一般地,我们把定义在原型上的方法叫原型方法,它为所有实例所共享。这有好有不好,为了实现差异化,JavaScript 允许我们直接在构造器内指定其方法,这叫做特权方法。如果是属性,就叫特权属性。它们每一个实例一个副本,各不影响。因此我们通常把共享的用于操作数据的方法放在原型,把私有的数据放在特权属性中。但放于this上,还是能让人任意访问到,那就放在函数体内的作用域内吧。这时它就成为名符其实的私有属性。

      function A() {
          var count = 0
          this.aa = "aa";
          this.method = function() {
              return count
          }
          this.obj = {}
      }
      A.prototype = {
          aa: "aa",
          method: function() {
          }
      };
      var a = new A;
      var b = new A;
      console.log(a.aa === b.aa);//true 由于aa的值为基本类型,比较值
      console.log(a.obj === b.obj);//false,引用类型,每次进入函数体都重新创建,因此都不一样
      console.log(a.method === b.method);//false

特权方法或属性只是遮住原型方法或属性,因此只要删掉特权方法,就又能访问到同名的原型方法或属性。

      delete a.method;
      console.log(a.method === A.prototype.method);//true

用Java的语言来说,原型方法与特权方法都属于实例方法,在Java中还有一种叫做类方法与类属性的东西。它们用JavaScript来模拟也非常简单,直接定义在函数上就行了。

      A.method2 = function(){};//类方法
      var c = new A;
      console.log(c.method2);//undefined

接下来,我们看一下继承的实现。上面说过,只要 prototype 有什么东西,它的实例就有什么东西,不论这个属性是后来添加的,还是整个prototype都是置换上去的。如果我们将这个prototype对象置换为另一个类的原型,那么它就轻而易举得到那个类的所有原型成员。

      function A(){}
      A.prototype = {
          aaa:1
      }
      function B(){}
      B.prototype = A.prototype;
      var b= new B;
      console.log(b.aaa);//1;
      A.prototype.bbb = 2;
      console.log(b.bbb);//2;

由于是引用着相同的一个对象,这意味着,如果我们修改A类的原型,也等同于修改了B类的原型。因此我们不能把一个对象赋给两个类。这有两种办法。方法一是,通过for in把父类的原型成员逐一赋给子类的原型,方法二是,子类的原型不是直接由父类获得,先将此父类的原型赋给一个函数,然后将这个函数的实例作为子类的原型。

方法一,我们通常要实现mixin这样的方法,亦有书称之为拷贝继承,好处是简单直接,坏处是无法通过instanceof验证。Prototype.js的extend方法就用来干这事。

      function extend(destination, source) {
        for(var property in source)
        destination[property] = source[property];
        return destination;
      }

方法二,就在原型上动脑筋,因此称之为原型继承。下面是个范本。

      A.prototype = {
        aa: function() {
          alert(1)
        }
      }
      function bridge() {};
      bridge.prototype = A.prototype;
      function B() {}
      B.prototype = new bridge();
      var a = new A;
      var b = new B;
      //false,说明成功分开它们的原型
      console.log(A.prototype == B.prototype);
      //true,子类共享父类的原型方法
      console.log(a.aa === b.aa);
      //为父类动态添加新的原型方法
      A.prototype.bb = function() {
        alert(2)
      }
      //true,孩子总会得到父亲的遗产
      console.log(a.bb === b.bb);
      B.prototype.cc = function() {
        alert(3)
      }
      //false,但父亲未必有机会看到孩子的新产业
      console.log(a.cc === b.cc);
      //并且它能正常通过JavaScript自带验证机制——instanceof
      console.log(b instanceof A);//true
      console.log(b instanceof B);//true

并且,方法二能通过 instanceof 验证。现在 es5 就内置了这种方法来实现原型继承,它就是Object.create,如果不考虑第二个参数,它约等于下面的代码。

      Object.create = function (o) {
          function F() {}
          F.prototype = o;
          return new F();
      }

上面方法,要求传入一个父类的原型作为参数,然后返回子类的原型。

不过,这样我们还是遗漏了一点东西——子类不只是继承父类的遗产,还拥有自己的东西。此外,原型继承并没有让子类继承父类的类成员与特权成员。这些我们还得手动添加,如类成员,我们可以通过上面的extend方法,特权成员我们可以在子类的构造器中,通过apply实现。

      function inherit(init, Parent, proto){
          function Son(){
              Parent.apply(this,argument);  //先继承父类的特权成员
              init.apply(this,argument);    //再执行自己的构造器
          }
          //由于Object.create可能是我们伪造的,因此避免使用第二个参数
          Son.prototype = Object.create(Parent.prototype,{});
          Son.prototype.toString = Parent.prototype.toString;    //处理IE BUG
          Son.prototype.valueOf = Parent.prototype.valueOf;      //处理IE BUG
          Son.prototype.constructor = Son; //确保构造器正常指向自身,而不是Object
          extend(Son.prototype, proto);    //添加子类特有的原型成员
          extend(Son, Parent);              //继承父类的类成员
          return Son;
      }

下面我们做一组实验,测试一下实例的回溯机制。许多资料都说——但总是语焉不详——当我们访问对象的一个属性,那么它先找其特权成员,如果有同名的就返回,没有就找原型,再没有,找父类的原型……我们尝试把它的原型临时修改一下,看它的属性会变成哪一个!

      function A() {}
      A.prototype = {
        aa: 1
      }
      var a = new A;
      console.log( a.aa);//1
      //把它整个原型对象都换掉
      A.prototype = {
        aa: 2
      }
      console.log(a.aa);//1,表示不受影响
      //于是我们想到实例都有一个constructor方法,指向其构造器,
      //而构造器上面正好有我们的原型,JavaScript引擎是不是通过该路线回溯属性呢
      function B(){}
      B.prototype = {
        aa: 3
      }
      a.constructor = B;
      console.log( a.aa );//1 表示不受影响

因此类的实例肯定通过另一条通道进行回溯,翻看ecma规范可知每一个对象都有一个内部属性[[Prototype]],它保存着当我们new它时构造器所引用的prototype对象。在标准浏览器与IE11里,它们暴露了一个叫__proto__属性来访问它。因此只要不动__proto__,上面的代码怎么动,a.aa始终坚定不移地返回1。

我们再来看一下new操作时发生了什么事。

(1)创建一个空对象instance。

(2)instance.__proto__ = instanceClass.prototype。

(3)将构造器函数里面的this = instance。

(4)执行构造器里面的代码。

(5)判定有没有返回值,没有返回值默认为 undefined,如果返回值为复合数据类型,则直接返回,否则返回this。

于是有了下面结果。

      function A() {
        console.log(this.__proto__.aa); //1
        this.aa = 2
      }
      A.prototype = {
        aa: 1
      }
      var a = new A;
      console.log(a.aa); //2
      a.__proto__ = {
        aa: 3
      }
      delete a.aa; //删掉特权属性,暴露原型链上的同名属性
      console.log(a.aa); //3

有了__proto__,我们可以将原型继承设计得更简洁。我们还是拿上面的例子改一下来进行实验。

      function A() {}
      A.prototype = {
        aa: 1
      }
      function bridge() {};
      bridge.prototype = A.prototype;
      function B() {}
      B.prototype = new bridge();
      B.prototype.constructor = B;
      var b = new B;
      B.prototype.cc = function() {
        alert(3)
      }
      console.log(b.__proto__ == B.prototype);
      //true 这个大家应该都没有疑问
      console.log(b.__proto__.__proto__ === A.prototype);
      //true  得到父类的原型对象

为什么呢?因为 b.__proto__.constructor 为 B,而 B 的原型是从 bridge 中得来的,而bridge.prototype = A.prototype。反过来,我们在定义时,让B.prototype.__proto__ = A.prototype,就能轻松实现两个类的继承。

目前,__proto__属性已列入es6,因此可以通过if分支大胆使用它。