0. 前言
这篇文章是《你所不知道的JavaScript》读书笔记系列的第五篇文章。在这篇文章中,我们接着上一篇文章的内容来聊一聊JS对象的原型链和行为委托机制是如何模拟类这种设计模式的,以及类设计模式和基于对象的设计方法有何异同。
- 《你所不知道的JavaScript》读书笔记(一):作用域和闭包(上)
- 《你所不知道的JavaScript》读书笔记(一):作用域和闭包(下)
- 《你所不知道的JavaScript》读书笔记(二):this指向问题
- 《你所不知道的JavaScript》读书笔记(三):类和对象(上)
1. [[Prototype]]原型链
[[prototype]],也叫做原型链,是JS对象的一个特殊的内置属性,表示该对象与其他对象的关联关系。这种关联关系是一个对象引用其他对象。这些话听起来很绕口,我们通过一个例子来解释一下:
var anotherObject = {
a: 2
}
// 创建一个关联到 anotherObject 的对象
var myObject = Object.create( anotherObject )
console.log(myObject.a) // 2
在这段代码中,我们创建了两个对象,myObject是通过Object对象的静态方法Object.create()创建的。该方法的作用是创建一个新的对象,并且新创建的对象通过[[prototype]]关联到现有的对象上。在这个例子中,新创建的对象是myObject,现有的对象是anotherObject。并且anotherObject在myObject的[[prototype]]中。这样关联的结果是,myObject对象并没有直接包含a这个属性,而是在它关联的对象anotherObject中,但是我们访问myObject.a这个属性可以访问到。
通过这个例子,我们可以得出结论:当我们访问一个对象的属性时,如果对象中不存在该属性,那么则会在通过[[prototype]]关联的对象中查找。但是,如果在关联的对象中找不到属性,并且[[prototype]]不为空的话,就会继续查找下去。换句话说,[[prototype]]机制使得对象与对象之间关联起来,使得关联对象之间的属性可以共用。
关于原型链,有两个需要注意的点:
- [[prototype]]的尽头: 由于所有的“普通” (内置,不是特定主机的扩展) 对象都“源于”(或者说把[[prototype]]链的顶端设置为)这个Object.prototype对象,所以普通的[[prototype]]链最终都会指向内置的Object.prototype
- 属性屏蔽: 假设原型链的最上层是Object.prototype,并且在原型链上关联的新对象分别位于上一个关联对象下层。即原型链有一个层级关系。如果上层的属性和位于原型链最底层的属性重名,则会发生屏蔽现象,即位于原型链下层的对象属性会屏蔽掉原型链上层的所有同名属性。
2. 行为委托
我们首先来看一段代码:
Task = {
setID: function( ID ) { this.id = ID},
outputID: function() { console.log(this.id) }
}
XYZ = Object.create( Task )
XYZ.prepareTask = function(ID, label){
this.setID( ID )
this.label = label
}
XYZ.outputTaskDetails = function(){
this.outputID()
console.log( this.label )
}
显然,在这段代码中Task和XYZ都是对象,并且XYZ是通过Object.create()创建的。因此,XYZ处于Task原型链中Task的下层。这意味着,XYZ对象可以使用Task原型链上的任何一个对象的方法。用JS术语来讲,XYZ的[[prototype]]委托了Task对象。 这种机制类似于类的继承,但又不完全一样:
- 相似点:
- 位于下层的可以使用位于上层的方法
- 从数据结构的观点来看,它们都是树
- 不同点:
- 类的继承的两端都是类,而行为委托的两端都是对象
- 类的继承是对父类行为的复制,而行为委托则是引用和关联,并不是复制
- 类的继承中,我们让父类和子类中都有相同名称的方法,这样可以使用多态。而在行为委托中相反,我们应尽量避免重名 总结一下:委托行为意味着某些对象再找不到属性或方法引用时会把这个请求委托给另一个对象。因此,使用行为委托这种思路进行设计时,我们只需要关心对象和对象之间的关联即可。
3. 实现 “类”
通过前面的讨论,我们知道JavaScript是不通过类直接创建对象的语言,他根本不存在类这个概念,对象可以直接定义自己的行为。因此,我们只能利用对象之间的关联,来模拟类的行为。
在JS中没有类这个概念。因此,我们要想办法给他创造一个类出来。要创造类就必须知道类干了哪些事情。类中比较关键的特性有:
- 实例化
- 继承
- 多态
3.1 实例化
在类理论中,在类实例化为对象的语法一般是a = new Foo()。其中,a是我们实例化创建的对象,Foo是一个类。在将类实例化为对象的过程中,一般需要调用和类名称相同的构造函数。此外,我们还可以从这个语法中看到实例化还需要使用一个关键字new。在这里,new的含义是创建一个新的对象,并且该对象所属的类是Foo.
在JS中,也有一个new关键字。我们来回顾一下,使用new关键字创建一个对象的结果:
- 创建一个全新的对象
- 这个新对象会被执行[[prototype]]连接:将新对象的
_proto_成员指向原有函数对象的prototype成员对象 - 这个新对象会绑定到函数调用的this:现有对象的
this指针绑定到新对象 - 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象:如果函数的返回值为对象,则返回函数返回的对象;如果函数的返回值为空,则返回
new创建的空对象
在JS中,我们可以同样利用new关键字来模拟类的实例化。实例化还需要有构造函数。构造函数的话,刚好函数的prototype就提供constructor这个方法。下面,我们举个栗子来说明。先看代码:
function People(name) {
this.name = name
this.sayName = function() {
console.log('my name is' + this.name)
}
}
People.prototype.walk = function() {
console.log(this.name + 'is walking')
}
var p1 = new People('前端')
var p2 = new People('椿湫')
这段代码的原型关系如下:
在这个类实例化的例子中,People()是一个构造函数,通过People.prototype向类中添加属性,构造函数的原型即People.prototype是一个对象,它通过_proto_关联到Object对象的原型上。在代码的最后两行,我们实例化了两个对象,并且这两个对象的原型链关联到People.prototype上。
在实例化“类”的这个过程中,有几个点需要注意:
._proto_和.prototype是两个不同的东西:前者和原型链的关联有关,是所有JS对象都拥有的属性;后者和构造函数有关,只有函数才会有。当函数被当作构造函数使用时,函数会同时关联一个与之相关的prototype对象。 - 函数本身并不是构造函数,然而,当在不同函数调用前面加上new关键字之后,就会把这个函数的调用编程构造函数的调用- 对象的
.construcor会默认指向一个函数,这个函数可以通过对象的.prototype引用。 - 原型链在代码中的表现形式是
_proto_ - 在上述例子中,
People()是一个函数,当我们用new关键字来调用它时,它变成了一个构造函数。People.prototype是一个原型对象,这个原型对象有一个叫做.constructor的属性,叫做构造函数,它指向People()函数。 - 我们只要定义了构造函数,就相当于定义了面向类语言的类。
3.2 继承
类的继承在JS中,使用函数的.call()方法来实现。举个栗子:
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 = Object.create(Foo.prototype)
Bar.prototype.myLabel = function(){
return this.label
}
var a = new Bar('a', 'obj a')
a.myName() // a
a.myLabel() // obj a
在这段代码中,完成类的继承有两步操作:
- 在子类构造函数中,利用
.call()方法调用父类构造函数,完成this的绑定 - 利用
Object.create()方法,完成子类和父类的原型绑定 为了区分类继承和JS中的对象关联的继承,在JS的继承被称为原型继承。这种原型继承在使用上和类继承是相同的,但是在原理上有很大区别。由于JS中没有类的概念,所有的操作都是通过对象之间的关联来完成的。类继承中,继承完成了父类属性的复制,而在JS的原型继承中,继承完成的是对对象属性的行为委托。
3.3 多态
在类理论中,多态的定义为弗雷德通用行为可以被子类用更特殊的行为重写。然而,《你所不知道的JavaScript》中提到:
JavaScript(在 ES6 之前)并没有相对多态的机制。 但是,JS中实现多态的例子,我还是在一篇博客中找到了。废话不多说,上代码:
var makeSound = function(animal) {
animal.sound();
}
var Duck = function(){}
Duck.prototype.sound = function() {
console.log('嘎嘎嘎')\
}
var Chiken = function() {};
Chiken.prototype.sound = function() {
console.log('咯咯咯')
}
makeSound(new Chicken());
makeSound(new Duck());
4. 比较两种思维模型
在这一部分,我们比较的双方是:类风格代码的思维模型和对象关联风格代码的思维模型。先说结论:
- 类风格代码的思维模型强调实体以及实体间的关系
- 对象关联风格的代码只关注一件事情:对象之间的关联关系
接下来,我们用两段代码来直观的体会一下这两种风格的代码:
- 类风格:
function Foo(who) {
this.me = who;
}
Foo.prototype.identify = function() {
return "I am " + this.me;
};
function Bar(who) {
Foo.call( this, who );
}
Bar.prototype = Object.create( Foo.prototype );
Bar.prototype.speak = function() {
alert( "Hello, " + this.identify() + "." );
};
var b1 = new Bar( "b1" );
var b2 = new Bar( "b2" );
b1.speak();
b2.speak();
- 对象关联风格:
Foo = {
init: function(who) {
this.me = who;
},
identify: function() {
return "I am " + this.me;\
}
};
Bar = Object.create( Foo );
Bar.speak = function() {
alert( "Hello, " + this.identify() + "." );
};
var b1 = Object.create( Bar );
b1.init( "b1" );
var b2 = Object.create( Bar );
b2.init( "b2" );
b1.speak();
b2.speak();
第一段代码是典型的原型继承风格的代码。我们用书上的一张图来表示这段代码的逻辑关系:
在这张图中很明显能够看到,各种原型、构造函数、原型链、巴拉巴拉。乱七八糟一大堆,看的头都要疼死了。然后,我们在用另一张图来表示对象关联风格代码风格:
这张图就看的舒服多了,十分简洁明了的表示了存在四个对象,然后它们之间的关系,即挂载在原型链上的位置也很清晰的展现出来。
总结:JS是面向对象的语言。在这门语言中,只有对象和对象之间的关系。利用它来编写程序,有两种编写代码的风格:原型继承的类风格代码和对象行为委托风格的代码。这两种风格虽然都可以表示一种逻辑关系,但是使用对象行为委托风格来编写JS代码会让代码变得更加简洁明了,也更加符合JS的特色。