第一章-面向对象的JavaScript
# 动态类型语言和鸭子类型
编程语言按照数据类型可以大致分为两类:静态类型语言和动态类型语言。
静态类型语言在编译时就已确定变量的类型,而动态类型语言的变量类型要等到程序运行的时候,待变量被赋予某个值之后,才会具有某种类型。
优点 | 缺点 | |
---|---|---|
静态类型语言 | 1. 编译时就能发现类型不匹配的错误; 2. 规定好数据类型后,编译器可以利用这些信息进行优化; | 1. 编码时需要考虑每个变量的类型,耗费更多精力; |
动态类型语言 | 1. 编写的代码数量更少,更简洁; | 1. 无法保证变量的类型,在程序的运行期有可能发生更类型相关的错误 |
在 JavaScript 中,对一个变量进行赋值时,不需要考虑它的类,因此,JavaScript 是一门典型的动态类型语言。
# 鸭子类型
鸭子类型的通俗理解就是:“如果它走起路来像鸭子,叫起来也像鸭子,那么它就是鸭子”。
例如:国王喜欢听鸭子的叫声,于是要组建一个 1000 只鸭子的合唱团,但是大臣们找遍全国只找到了 999 只,始终还差一只,最后大臣发现一只非常特别的鸡,它的叫声和鸭子一模一样,于是这只鸡就成为了合唱团的最后一员。
鸭子类型指导我们只关注对象的行为,而不关注对象本身,也就是关注 HAS-A,而不是 IS-A。
var duck = {
duckSinging: function () {
console.log('嘎嘎嘎');
},
};
var chicken = {
duckSinging: function () {
console.log('嘎嘎嘎');
},
};
var choir = []; // 合唱团
var joinChoir = function (animal) {
if (animal && typeof animal.duckSinging === 'function') {
choir.push(animal);
console.log('恭喜加入合唱团');
console.log('合唱团已有成员数量:', choir.length);
}
};
joinChoir(duck);
joinChoir(chicken);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 多态
多态的实际含义:“同一操作作用域不同的对象上面,可以产生不同的解释和不同的执行结果。也就是说,给不同的对象发送同一个消息的时候,这些对象会根据这个消息分别给出不同的反馈”。
var makeSound = function (animal) {
if (animal instanceof Duck) {
console.log('嘎嘎嘎');
} else if (animal instanceof Chicken) {
console.log('咯咯咯');
}
};
var Duck = function () {};
var Chicken = function () {};
makeSound(new Duck());
makeSound(new Chicken());
2
3
4
5
6
7
8
9
10
11
12
13
多态背后的思想是将"做什么"和"谁来做以及怎样去做"分离开,也就是将"不可变的事物"与"可能改变的事物"分离开。上述实例中,如果要增加一只动物,比如狗,那么就必须改动 makeSound 函数,当动物种类很多时,makeSound 就可能变成一个非常巨大的函数
# 对象的多态性
下面是改写后的代码,把不变的部分隔离出来,即所有的动物都会叫:
var makeSound = function (animal) {
animal.sound();
};
var Duck = function () {};
Duck.prototype.sound = function () {
console.log('嘎嘎嘎');
};
var Chicken = function () {};
Chicken.prototype.sound = function () {
console.log('咯咯咯');
};
makeSound(new Duck());
makeSound(new Chicken());
/**
* 增加一只狗,只需要追加一些代码,而不需要改变 makeSound 函数
*/
var Dog = function () {};
Dog.prototype.sound = function () {
console.log('汪汪汪');
};
makeSound(new Dog());
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 多态在面向对象程序设计中的作用
假设我们现在要编写一个地图应用,现在有两家可选的地图 API 提供商供我们接入自己的应用,目前我们选择的是谷歌地图,谷歌地图的 API 中提供了 show 方法,负责在页面上展示整个地图,示例代码如下:
var googleMap = {
show: function () {
console.log('开始渲染谷歌地图');
}
};
var renderMap = function () {
googleMap.show();
};
renderMap();
2
3
4
5
6
7
8
9
10
11
后来由于某些原因,要把谷歌地图换成百度地图,为了让 renderMap 函数保持一定的弹性,我们加上一些条件判断来让 renderMap 函数同时支持谷歌地图和百度地图。
var googleMap = {
show: function () {
console.log('开始渲染谷歌地图');
},
};
var baiduMap = {
show: function () {
console.log('开始渲染百度地图');
},
};
var renderMap = function (type) {
if (type === 'google') {
googleMap.show();
} else if (type === 'baidu') {
baiduMap.show();
}
};
renderMap('google');
renderMap('baidu');
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
可以看到,虽然 renderMap 函数保持了一定弹性,但这种弹性是很脆弱的,一旦需要替换成搜搜地图,那么无疑又需要改动 renderMap 函数,继续增加条件分支语句。
我们将程序中相同的部分抽象出来,那就是显示某个地图:
var renderMap = function (map) {
if (map.show instanceof Function) {
map.show();
}
};
renderMap(googleMap);
renderMap(baiduMap);
var sosoMap = {
show: function () {
console.log('开始渲染搜搜地图');
},
};
renderMap(sosoMap);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
上述示例代码中利用多态将"做什么"和"怎么做"分离开,即使以后增加了搜搜地图,renderMap 函数仍然不需要做任何改变。
# 封装
封装的目的是将信息隐藏,这里我们对封装数据、封装实现、封装类型、封装变化进行一一讨论。
# 封装数据
通过函数创建作用域,实现不同的访问权限。
var myObject = (function () {
var __name = 'jack';
return {
getName: function () {
return __name;
},
};
})();
console.log(myObject.getName()); // jack
console.log(myObject.__name); // undefined
2
3
4
5
6
7
8
9
10
11
# 封装实现
封装让对象之间的耦合变得松散,对象之间只通过暴露的 API 接口来通信。当我们修改一个对象时,可以随意的修改它的内部实现,只要对外的接口没有变化,就不会影响程序的其他功能。
# 封装类型
一般而言,封装类型是通过抽象类和接口来进行的,把对象的真正类型隐藏在抽象类或者接口之后,相比对象的类型,客户更关心对象的行为。当然在 JavaScript 中,并没有对抽象类和接口的支持。JavaScript 本身也是一门类型模糊的语言。在封装类型方面。JavaScript 没有能力,有没有必要做的更多。
# 封装变化
把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已经封装好的,替换起来也相对容易。这样可以最大程度的保证程序的稳定性和可扩展性。
# 原型模式和基于原型模式继承的 JavaScript 对象系统
从设计模式的角度讲,原型模式是用于创建对象的一种模式,如果我们想要创建一个对象,一种方法是先指定它的类型,然后通过类来创建这个对象。原型模式选择了另一个方法,我们不再关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象。
var Plane = function () {
this.blood = 100;
this.attackLevel = 1;
this.defenseLevel = 1;
};
var plane = new Plane();
plane.blood = 500;
plane.attackLevel = 10;
plane.defenseLevel = 7;
var clonePlane = Object.create(plane);
console.log(clonePlane);
2
3
4
5
6
7
8
9
10
11
12
13
在不支持 Object.create 方法的浏览器中,可以使用以下 hook 代码:
Object.create =
Object.create ||
function (obj) {
var F = function () {};
F.prototype = obj;
return new F();
};
2
3
4
5
6
7
# 原型编程范型的一些规则
- 所有的数据都是对象
- 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它
- 对象会记住它的原型
- 如果对象无法响应某个请求,它会把这个请求委托给自己的原型
# JavaScript 中的原型继承
所有的数据都是对象
事实上,JavaScript 中的根对象是 Object.prototype 对象,Object.prototype 对象是一个空的对象。我们在 JavaScript 中遇到的每个对象,实际上都是从 Object.prototype 对象克隆而来的,Object.prototype 对象就是它们的原型。比如下面的 obj1 对象和 obj2 对象。
var obj1 = new Object(); var obj2 = {}; console.log(Object.getPrototypeOf(obj1) === Object.prototype); // true console.log(Object.getPrototypeOf(obj2) === Object.prototype); // true
1
2
3
4
5要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它
在 JavaScript 语言中我们并不关心克隆的细节,因为这是引擎内部负责实现的, 我们所需要做的只是显式的调用 var obj1 = new Object() 或者 var obj2 = {}。此时,引擎内部会从 Object.protoype 上面克隆一个对象出来,我们最终得到的就是这个对象。
下面我们来看看如何使用 new 运算符从构造器中得到一个对象。
function Person(name) { this.name = name; } Person.prototype.getName = function () { return this.name; }; var a = new Person('jack'); console.log(a.name); // jack console.log(a.getName()); // jack console.log(Object.getPrototypeOf(a) === Object.prototype); // true
1
2
3
4
5
6
7
8
9
10
11
12注意,这里的 Person 并不是类,而是函数构造器。当使用 new 运算符来调用函数时,此时的函数就是一个构造器,用 new 运算符来创建对象的过程,实际上也只是先克隆 Object.prototype 对象,在进行一些其他额外操作的过程。
我们可以通过下面这段代码来理解 new 运算的过程:
function Person(name) { this.name = name; } Person.prototype.getName = function () { return this.name; }; var objectFactory = function () { var obj = new Object(), Constructor = [].shift.call(arguments); obj.__proto__ = Constructor.prototype; var ret = Constructor.apply(obj, arguments); return typeof ret === 'object' ? ret : obj; }; var a = objectFactory(Person, 'jack'); console.log(a.name); // jack console.log(a.getName()); // jack console.log(Object.getPrototypeOf(a) === Person.prototype); // true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20new 运算经过了以下几个步骤
- 从 Object.prototype 上克隆一个空的对象;
- 取得外部传入的构造器,如上述示例中的 Person;
- 指向正确的原型;
- 借用外部传入的构造器给 obj 设置属性;
- 确保构造器总是返回一个对象;
# 对象会记住它的原型
JavaScript 给对象提供了一个名为 __ proto __ 的隐藏属性,某个对象的 __ proto __ 属性默认指向它的构造器的原型对象,即 Constructor.prototype。在一些浏览器中,__ proto __ 被公开出来,我们可以在 chrome 或者 firefox 中进行验证:
var a = new Object(); console.log(a.__proto__ === Object.prototype); // true
1
2实际上,__ proto __ 就是对象跟"对象构造器的原型"联系起来的纽带。正是因为对象要通过 __ proto __ 属性来记住它的构造器的原型,所有我们用上面的 objectFactory 函数来模拟用 new 创建对象时,需要手动给 obj 对象设置正确的 __ proto __ 指向。
obj.__proto__ = Constructor.prototype;
1通过这句代码,我们让 obj.__ proto __ 指向 Person.prototype,而不是原来的 Obejct.prototype。
# 如果对象无法响应某个请求,它会把这个请求委托给自己的原型
在 JavaScript 中,每个对象都是从 Object.prototype 对象克隆而来的,这样一来,我们只能得到单一的继承关系,即每个对象都继承自 Object.prototype 对象,这样的对象系统显然是非常受限的。
实际上,虽然 JavaScript 的对象最初都是由 Object.prototype 对象克隆而来的,但是对象构造器的原型并不仅限于 Object.prototype 上,而是可以动态指向其他对象。因此,当对象 a 需要借用对象 b 的能力时,可以有选择性的把对象 a 的构造器原型指向对象 b,从而达到继承的效果,如下示例:
var obj = {name: 'jack'}; var A = function(){}; A.prototype = obj; var a = new A(); console.log(a.name); // jack
1
2
3
4
5
6
7我们来分析一下这段代码执行时,引擎做了什么
- 首先,尝试遍历对象 a 中的所有属性,但没有找到 name 这个属性;
- 查找 name 属性的这个请求被委托给对象 a 的构造器的原型,它被 a.__ proto __ 记录着并且指向 A.prototype,而 A.prototype 被设置为 obj;
- 在对象 obj 中找到了 name 属性,并返回它的值。
当我们期望得到一个"类"继承自另外一个"类"的效果时,往往会使用下面的代码来模拟实现:
var A = function () {}; A.prototype = { name: 'jack' }; var B = function () {}; B.prototype = new A(); var b = new B(); console.log(b.name); // jack
1
2
3
4
5
6
7
8再来看这段代码执行的时候,引擎做了什么
- 首先,尝试遍历对象 b 中的所有属性,但没有找到 name 这个属性;
- 查找 name 属性的请求被委托给对象 b 的构造器的原型,它被 b.__ proto __ 记录着并且指向 B.prototype,而 B.prototype 被设置为一个通过 new A() 创建出来的对象;
- 在该对象上依然没有找到 name 属性,于是请求被委托给这个对象的构造器的原型 A.prototype;
- 在 A.prototype 中找到了 name 属性,并返回它的值;
最后还要注意一点,原型链并不是无限长的,最顶层就是 Object.prototype,而 Object.prototype 的原型是 null,说明这时候原型的后面已经没有别的节点了。