类的设计模式:实例化(instantiation)、继承(inheritance)和(相对)多态(polymorphism)
但是,这些概念实际上无法直接对应到JavaScript的对象机制
类理论强烈建议父类和子类使用相同的方法名来表示特定的行为,从而让子类重写父类。 但在JavaScript中这样会降低代码的可读性和健壮性。
类是一种设计模式,是一种可选的代码抽象,但在有些语言中是必选的,比如Java——万物皆是类;其他语言(比如C/C++或者PHP)会提供过程化和面向类这两种语法,开发者可以选择其中一种风格或者混用两种风格。
ES6之前,JavaScript只有一些近似类的语法元素(比如new和instanceof),后来的ES6中新增了比如class关键字,这才使JavaScript有了近似类的语法,但JavaScript的机制其实和类完全不同!
类是对所有实例化对象的抽象描述,类必须实例化之后才能直接使用
类的概念来源于建造,类就相当于建造中的蓝图(类似现在的3d模型),是抽象的,而依据这个蓝图建造的过程就是类实例化的过程。
类构造函数通常和类同名,且大多需要用new来调用。它的任务就是初始化实例需要的所有信息(状态)
JavaScript并不支持多重继承,因为使用多重继承的代价太大
// 非常简单的mixin(..)例子: let mixin = (sourceObj, targetObj) => { for (let key in sourceObj) { // 只会在不存在的情况下复制 if (! (key in targetObj)) { targetObj[key] = sourceObj[key]; } } return targetObj; } let Chicken = { name: 'Chicken', fly: false, say: function() { console.log(`I am ${this.name}, I can ${this.fly?'':'not'}fly!`) }, } let FlyingFish = mixin(Chicken , { name: 'FlyingFish', fly: true, say: function() { Chicken.say.call(this) }, }) Chicken.say() // I am Chicken, I can not fly! FlyingFish.say() // I am FlyingFish, I can fly!
代码中:Chicken.say.call(this)
,就是显式多态。
在JavaScript中使用显式伪多态会在所有需要使用(伪)多态引用的地方创建一个函数关联,这会极大地增加维护成本。此外,由于显式伪多态可以模拟多重继承,所以它会进一步增加代码的复杂度和维护难度。
使用伪多态通常会导致代码变得更加复杂、难以阅读并且难以维护,因此应当尽量避免使用显式伪多态,因为这样做往往得不偿失。
若向目标对象中显式混入超过一个对象,就可以部分模仿多重继承行为,但是仍没有直接的方式来处理函数和属性的同名问题。即使是“晚绑定”技术,从根本上来说,在性能上还是得不偿失。
显式混入模式的一种变体被称为“寄生继承”,它既是显式的又是隐式的。
function Chicken() { this.name = 'Chicken' } Chicken.prototype.fly = false Chicken.prototype.say = function() { console.log(`I am ${this.name}, I can${this.fly?'':'not'} fly!`) } function FlyingFish(){ let flyingFish = new Chicken() flyingFish.name = 'FlyingFish' flyingFish.fly = true let fishSay = flyingFish.say flyingFish.say = function() { fishSay.call(this) } return flyingFish } let myChicken = new Chicken() let myFlyingFish = new FlyingFish() myChicken.say() // I am Chicken, I can not fly! myFlyingFish.say() // I am FlyingFish, I can fly!
隐式混入和之前提到的显式伪多态很像,因此也具备同样的问题。
let Something = { cool: function() { this.greeting = "Hello World"; this.count = this.count ? this.count + 1 : 1; } }; Something.cool(); Something.greeting; // "Hello World" Something.count; // 1 let Another = { cool: function() { // 隐式把Something混入Another Something.cool.call(this); } }; Another.cool(); Another.greeting; // "Hello World" Another.count; // 1(count不是共享状态)
利用了this的重新绑定功能,但是Something.cool.call(this)仍然无法变成相对(而且更灵活的)引用,所以使用时千万要小心。通常来说,尽量避免使用这样的结构,以保证代码的整洁和可维护性。
此外,显式混入实际上无法完全模拟类的复制行为,因为对象(和函数!别忘了函数也是对象)只能复制引用,无法复制被引用的对象或者函数本身。忽视这一点会导致许多问题。总地来说,在JavaScript中模拟类是得不偿失的,虽然能解决当前的问题,但是可能会埋下更多的隐患。
var anotherObject = { a:2 }; // 创建一个关联到anotherObject的对象 var myObject = Object.create(anotherObject); myObject.a; // 2
给一个对象设置属性并不仅仅是添加一个新属性或者修改已有的属性值:
在原型链上找到的情况比较特殊,分三种情况:
[[Prototype]]
链上层存在名为foo
的普通数据访问属性并且writable: true
,那就会直接在myObject
中添加一个名为foo
的新属性,它是 屏蔽属性。[[Prototype]]
链上层存在foo
,但是它 writable:false
,那么无法修改已有属性或者在myObject
上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。[[Prototype]]
链上层存在foo
并且它是一个setter
,那就一定会调用这个setter
。foo
不会被添加到(或者说屏蔽于)myObject
,也不会重新定义foo
这个setter
。若想在第二种和第三种情况下也屏蔽foo,那就不能使用=操作符来赋值,而是使用Object.defineProperty(…)
有些情况下会隐式产生屏蔽,一定要当心:
let anotherObject = { a:2 }; let myObject = Object.create(anotherObject); anotherObject.a; // 2 myObject.a; // 2 anotherObject.hasOwnProperty("a"); // true myObject.hasOwnProperty("a"); // false anotherObject.a+=1; // 隐式屏蔽! anotherObject.a; // 2 myObject.a; // 3 myObject.hasOwnProperty("a"); // true
++
操作首先会通过[[Prototype]]
查找属性a
并从anotherObject.a
获取当前属性值2
,然后给这个值加1
,接着用[[Put]]
将值3
赋给myObject
中新建的屏蔽属性a
。。。myObject.a
操作之前,对anotherObject.a
进行操作的话,影响依旧会体现在myObject.a
上在JavaScript中,不能创建一个类的多个实例,只能创建多个对象,它们[[Prototype]]关联的是同一个对象。但是在默认情况下并不会进行复制,因此这些对象之间并不会完全失去联系,它们是互相关联的。
function Foo() { } console.log(Foo.prototype) // { constructor: Foo() } let foo = new Foo() console.log(Object.getPrototypeOf(foo) === Foo.prototype) // true
new 关键字只是创建了一个关联到其他对象的新对象。
prototype
默认有一个公有并且不可枚举的属性constructor
new
的函数调用new
时,函数调用会变成“构造函数调用”function Foo(name) { this.name = name; // 像类实例封装的数据值 } Foo.prototype.myName = function() { return this.name; }; var a = new Foo("a"); var b = new Foo("b"); a.myName(); // "a" b.myName(); // "b"
function Foo() { /* .. */ } Foo.prototype = { /* .. */ }; // 创建一个新原型对象(相应constructor也随之改变) var a1 = new Foo(); a1.constructor === Foo; // false! a1.constructor === Object; // true!
.constructor
是一个非常不可靠并且不安全的引用。通常来说要尽量避免使用这些引用
如果在使用过程中,不小心或是必须修改默认prototype,后续要用到constructor 时,记得修复:
Object.defineProperty(Foo.prototype, "constructor" , { enumerable: false, writable: true, configurable: true, value: Foo // 让.constructor指向Foo } );
function Foo(name) { this.name = name; } Foo.prototype.myName = function() { return this.name; }; function Bar(name, label) { Foo.call(this, name); this.label = label; } // 创建了一个新的Bar.prototype对象并关联到Foo.prototype Bar.prototype = Object.create(Foo.prototype); // 注意!现在没有Bar.prototype.constructor了 // 如果需要这个属性的话可能需要手动修复一下它 Bar.prototype.myLabel = function() { return this.label; }; let a = new Bar("a", "obj a"); a.myName(); // "a" a.myLabel(); // "obj a"
这段代码的核心部分就是:Bar.prototype = Object.create(Foo.prototype)
创建一个新的Bar.prototype
对象并把它关联到Foo. prototype
注意,下面这两种方式是常见的错误做法,实际上它们都存在一些问题:
// 和想要的机制不一样! Bar.prototype = Foo.prototype; // 基本上满足需求,但是可能会产生一些副作用:( Bar.prototype = new Foo();
Bar.prototype = Foo.prototype
,这个操作很危险,这只是对象的软拷贝,Bar.prototype
的修改会附加到Foo.prototype
。。。Bar.prototype = new Foo()
的确会关联到Foo.prototype
,除此之外Foo的其他Bar并不需要的属性或功能也会附加到Bar,后果不堪设想。。。ES6添加了辅助函数Object.setPrototypeOf(…),可以用标准并且可靠的方法来附加关联:
// ES6之前需要抛弃默认的Bar.prototype Bar.ptototype = Object.create(Foo.prototype); // ES6开始可以直接修改现有的Bar.prototype Object.setPrototypeOf(Bar.prototype, Foo.prototype);
在传统的面向类环境中,检查一个实例的继承祖先通常被称为内省(或者反射)。
function Foo() { // ... } Foo.prototype.blah = ...; var a = new Foo();
方法一:
a instanceof Foo; // true
instanceof回答的问题是:在a的整条[[Prototype]]链中是否有指向Foo.prototype的对象?
// 用来判断o1是否关联到(委托)o2的辅助函数 function isRelatedTo(o1, o2) { function F(){} F.prototype = o2; return o1 instanceof F; } var a = {}; var b = Object.create(a); isRelatedTo(b, a); // true
在
isRelatedTo(..)
内部我们声明了一个一次性函数F,把它的.prototype
重新赋值并指向对象o2
,然后判断o1
是否是F
的一个“实例”。显而易见,o1
实际上并没有继承F
也不是由F
构造,所以这种方法非常愚蠢并且容易造成误解。
第二种判断[[Prototype]]
反射的方法,它更加简洁:
Foo.prototype.isPrototypeOf(a); // true
isPrototypeOf(..)
回答的问题是:在a
的整条[[Prototype]]
链中是否出现过Foo.prototype
?
可以直接获取一个对象的[[Prototype]]链:
Object.getPrototypeOf(a);
所以还可以这么干:
Object.getPrototypeOf(a) === Foo.prototype; // true
绝大多数(不是所有!)浏览器也支持一种非标准的方法来访问内部[[Prototype]]
属性:
a.__proto__ === Foo.prototype; // true
这个奇怪的.__proto__
(在ES6之前并不是标准!)属性“神奇地”引用了内部的[[Prototype]]
对象,甚至可以通过.__proto__.__ptoto__..
来遍历
和.constructor
一样,.__proto__
实际上并不存在于对象中。实际上,它和其他的常用函数(.toString()
、.isPrototypeOf(..)
,等等)一样,存在于内置的Object.prototype
中。(它们是不可枚举的)
此外,.__proto__
看起来很像一个属性,但是实际上它更像一个getter/setter
。.__proto__
的实现大致上是这样的:
Object.defineProperty(Object.prototype, " __proto__", { get: function() { return Object.getPrototypeOf(this); }, set: function(o) { // ES6中的setPrototypeOf(..) Object.setPrototypeOf(this, o); return o; } } );
Object.create(..)
会创建一个新对象并通过赋值的方式把它关联到指定的对象,这样就可以充分发挥[[Prototype]]
机制的威力(委托)并且避免不必要的麻烦(比如使用new
的构造函数调用会生成.prototype
和.constructor
引用)
Object.create(null)
会创建一个拥有空[[Prototype]]
链接的对象,这个对象无法进行委托。由于这个对象没有原型链,所以instanceof
操作符无法进行判断,因此总是会返回false
。这些特殊的空[[Prototype]]
对象通常被称作“字典”,它们完全不会受到原型链的干扰,因此非常适合用来存储数据。
Object.create(..)
是在ES5中新增的函数,所以在ES5
之前的环境中(比如旧IE)如果要支持这个功能的话就需要使用一段简单的polyfill
(补丁)代码,它部分实现了Object. create(..)
的功能:
if (! Object.create) { Object.create = function(o) { function F(){} F.prototype = o; return new F(); }; }
标准ES5中内置的Object.create(..)
函数还提供了一系列附加功能:
var anotherObject = { a:2 }; var myObject = Object.create(anotherObject, { b: { enumerable: false, writable: true, configurable: false, value: 3 }, c: { enumerable: true, writable: false, configurable: false, value: 4 } }); myObject.hasOwnProperty("a"); // false myObject.hasOwnProperty("b"); // true myObject.hasOwnProperty("c"); // true myObject.a; // 2 myObject.b; // 3 myObject.c; // 4
Object.create(..)
的第二个参数指定了需要添加到新对象中的属性名以及这些属性的属性描述符。因为ES5之前的版本无法模拟属性操作符,所以polyfill
(补丁)代码无法实现这个附加功能。
var anotherObject = { cool: function() { console.log("cool! "); } }; var myObject = Object.create(anotherObject); myObject.cool(); // "cool! "
只是为了让在无法处理属性或者方法时可以有备用使用,那么这段程序就会变得有点“神奇”,而且很难理解和维护。
在ES6中有一个被称为“代理”(
Proxy
)的高端功能,它实现的就是“方法无法找到”时的行为
这个功能完全可以使用 内部委托机制 实现:
var anotherObject = { cool: function() { console.log("cool! "); } }; var myObject = Object.create(anotherObject); myObject.doCool = function() { this.cool(); // 内部委托! }; myObject.doCool(); // "cool! "
[[Get]]
操作就会查找对象内部[[Prototype]]
关联的对象。这个关联关系实际上定义了一条“原型链”(有点像嵌套的作用域链),在查找属性时会对它进行遍历。Object.prototype
,指向原型链的顶端(比如说全局作用域),如果在原型链中找不到指定的属性就会停止。toString()
、valueOf()
和其他一些通用的功能都存在于Object.prototype
对象上,因此语言中所有的对象都可以使用它们。new
关键词进行函数调用,在调用过程中会创建一个关联其他对象的新对象。.prototype
属性关联到“其他对象”。