第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分支大胆使用它。