1. 对象
对象是包括 JavaScript 在内的所有面向对象的编程语言的一个重要组成部分。尤其在 JavaScript 中,对象更是扮演了一个极其重要的角色。JavaScript 号称,事实上也是,一切皆对象。因此,要想学好JavaScript,了解对象是非常必要的。
1.1 创建对象
了解对象的起点是了解他如何被创建出来的。基于 JavaScript 语言,创建对象的方式有以下三种:工厂模式,构造函数,原型函数。
1. 工厂模式
工厂模式创建 JavaScript 对象基于一个工厂函数。该函数规定了基于该函数创建的对象的模板,比如说对象有哪些属性,哪些方法等等。并且返回由该工厂函数创建的对象。
代码示例:
function createPreson(name, age, job) {
let o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function() {
console.log(this.name);
}
return o;
}
上述示例的函数,类似一个按照模板制造对象的工厂,提供一组相关参数返回一个被制造好的对象。如果用工厂生产产品来作类比,那么一组参数就是原材料,对象就是产品。产品之间相互是独立的,意味着利用工厂函数制造出来的对象是互相没有关联的。这样子会有什么问题呢,就是新对象的类型无法确定。
2. 构造函数
构造函数利用 js 中的 new
关键字来创建对象。我们知道,使用 new
关键字调用构造函数会做五件事情:
- 在内存中创建一个新对象
- 在新对象的
._proto_
属性赋值为构造函数的prototype
属性 - 构造函数内部的 this 指针指向新创建的对象
- 执行构造函数内部的代码
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚刚创建的新对象。
代码示例:
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() {
console.log(this.name);
}
}
从上述示例中,我们可以发现,相较于工厂函数而言,构造函数没有直接创建对象,而是把属性和方法直接赋值给了 this,并且它没有 return。在写法上,更加的清新。
此外,关于构造函数,这里有一个约定俗成的规则:构造函数的首字母都是要大写的,非构造函数则以小写字母开头。
关于构造函数,最大的优点就是可以标识新创建的对象的类型。JavaScript 提供了 instanceof
操作符,用来检验对象是根据哪个构造函数创建的。
和工厂函数一样,构造函数也存在它自己的问题。对于构造函数新构造的对象而言,在 this 上的所有方法和属性都是独立的。换句话说,新建一个对象,就会有相应的属性和方法被新建出来。然而,在实际的应用当中,一些属性和方法是这些对象所共有的,不需要再重新新建一份,只需要找到他存在什么地方,然后大家一起共用就可以了。
3. 原型模式
在了解原型模式之前,需要了解一下原型是什么?
每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。这个对象就是通过调用构造函数创建的对象的原型。
从原型的定义中不难发现,原型模式解决了构造函数存在的问题。就拿上述提到的示例而言,sayName
这个属性,在所有的对象创建中是一模一样的内容。因此,对于它而言使用构造函数复制多份就显得没有必要,而且会占内存空间。于是,我们把它放到原型上。具体实现代码如下:
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
}
Person.prototype.sayName = function() {
console.log(this.name);
}
综上所述,最好的对象创建方案是将构造函数和原型模式结合起来。将一些会变的参数放到构造函数中,不变的属性和方法通过原型的方式添加。
1.2 对象的属性相关
JavaScript 对象的属性分为两种:数据属性和访问器属性。
-
数据属性:数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入这个位置。对于数据属性而言,有 4 个特性描述它们的行为:
[[Configurable]]
:表示属性是否可以通过 delete 删除并重新定义,是否可以修改他的特性,以及是否可以把它改为访问器属性。默认值为 true[[Enumerable]]
:表示属性是否可以通过 for-in 循环返回。默认值为 true[[Writable]]
:表示属性值是否可以被修改。默认值为true[[Value]]
:表示属性实际的值,默认为 undefined
-
访问器属性:访问器属性不包含数据值。相反,他们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。同样有 4 个特性描述他们的行为:
[[Configurable]]
:表示属性是否可以通过 delete 删除并重新定义,是否可以修改他的特性,以及是否可以把它改为数值属性。默认值为 true[[Enumerable]]
:表示属性是否可以通过 for-in 循环返回。默认值为 true[[Get]]
:获取函数,在读取属性时调用。默认值为 undefined[[Set]]
:设置函数,在写入属性时调用。默认值为 undefined
我们可以通过直接在对象中添加 .
操作符来定义属性;也可以使用Object.defineProperties
来定义对象的属性。两者之间的区别是后者可以通过参数设置属性的特性。
代码示例:
let book = {};
Object.defineProperties(book, {
year_: {
value: 2017
},
edition: {
value: 1
}
})
2. 类
在面向对象编程的语言中,类是很重要的一个概念。类之所以为类,是因为其三大特性:封装、继承和多态。
2.1 实例化
关于类的实例化问题,前面对象创建的内容已经描述过,这里就不再赘述了。这里需要提到的是,前文的对象创建的模版就是我们提到的类,根据该模板创建的对象就是该类的实例化。类的实例化有两种方法:
- 构造函数
- 工厂函数
2.2 继承
2.2.1 原理
JS 中的继承通常被叫做原型继承。原型编程范型遵循以下基本规则:
- 所有的数据都是对象
- 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它
- 对象会记住他的原型
- 如果对象无法响应这个请求,则会将该请求委托给它的原型
原型继承的基本思想就是通过原型继承多个引用类型的属性和方法。在原型继承中,有三个概念是至关重要的:构造函数、原型和实例。其关系为:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。
如果原型是另一个类型的实例,那么这个原型本身有一个内部指针指向另一个实例,同时另一个实例也有一个指向另一个构造函数的实例。在这种情况下,原型和实例之间构成了一条原型链。
由于我们的原型链不能无限的往前面指,原型链总会有一个头,也就是说 js 中所有的对象应该都是以某一个对象为原型制作出来的。那么,原型链的所谓顶端是什么样的呢?
如图所示,所有的 js 对象的原型链都指向 Object.prototype
这个原型对象,也就是说,所有的 JS 对象都是由 Object.prototype
这个原型对象生成的。按照常理来说,Object.prototype
应该是 Object 对象的原型对象,Function.prototype
应该是 Function 对象的原型对象。它们分别指向对应的构造函数。这幅图的意思是 Function 是由 Object 构造出来的。也就是说 Function instanceof Object
的结果应该为 true。我们来检验一下:
console.log(Function instanceof Object) //true
console.log(Object instanceof Function) //true
实际的运行结果和我们的猜想是应证的,但是奇怪的是反过来也成立。这是什么原因呢?
从上图中,我们看到,这几者之间的关系比较复杂。但原型链的指向遵循以下两个准则:
- 除了 Object 其他所有内置函数的原型对象的内置属性
._proto_
都指向Object.prototype
, Object 构造函数的内置属性._proto_
为 null。(即所有函数的原型对象都是由Object.prototype
构造出来的) - 所有的内置函数(包括 Function,其实应该是所有的构造函数)的内置属性
._proto_
都指向Function.prototype
对象(即所有内置函数都是由 Function 构造出来的)
这样我们就不难理解了,Function 也是一个对象,所有他的 ._prpto_
属性指向了 Object.prototype
;Object 也是一个函数,所以它的 ._proto_
属性指向 Function.prototype
.
2.2.2 实现
1. 构造函数继承
最简单的一种继承方式:
function SuberType() {
this.colors = ["red", "blue", "green"];
}
function SubType() {
// 继承 SuperType
SuperType.call(this);
}
这种继承方式使用 .call
方法,将 this 绑定到实例的对象上,完成对于父类的继承。这种方式的缺点是:子类不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。
2. 组合继承
组合继承综合了原型链和构造函数,将两种方式的有点集中起来。基本思路是:使用原型链继承原型上的属性和方法,而通过构造函数继承实例属性。
function SuberType(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
}
function SubType(name, age) {
// 继承 SuperType 的属性
SuperType.call(this, name);
this.age = age;
}
// 继承方法
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function() {
console.log(this.age);
}
3. 原型式继承
原型继承的基本原理是创建一个临时的构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时构造函数的实例。本质上,是对传入的对象进行了一次浅复制:
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
原型式继承的适用情况:你有一个对象,想在他的基础上再创建一个新对象。你需要把这个对象先传给 object(), 然后再对返回的对象进行适当修改。
在 ECMAScript 5 中,增加了 Object.create()
方法,将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象的原型对象,以及给新对象定义额外的属性对象。在只有一个参数是,Object.create()
与这里的 object()
方法效果相同。
原型式继承非常适合不需要独立创建构造寒素,但仍然需要在对象间共享信息的场合。
4. 寄生式继承
寄生式继承是一种与原型式继承比较接近的一种继承方式,其基本思路为:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。基本的代码如下:
function createAnother(original) {
let clone = object(original);
clone.sayHi = function() {
console.log("hi");
}
return clone;
}
寄生式继承同样适用主要关注对象,二不在乎类型和构造函数的场景。
5. 组合寄生式继承
废话不多说,直接上代码:
function inheritPrototype(subType, superType) {
let prototype = object(superType.prototype);
prototype.constructor = subType;
subType.prototype = prototype;
}
function SuperType(name) {
this.name = name;
this.color = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
}
function SubType(name, age) {
SuperType.call(this, name); // 第二次调用 SuperType,绑定 this
this.age = age;
}
inheritPrototype(subType, superType);
SubType.prototype.sayAge = function() {
console.log(this.age);
}
下图描述了这段代码发生的事情:
组合寄生式继承通过盗用构造函数继承属性,但使用混合式原型链集成方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。
在笔者看来,组合寄生式继承是最可以模拟类继承的一种继承方式。