理解对象

创建自定义对象最简单的方式就是创建一个Object的实例,再为实例添加属性和方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 创建 Object 实例 person
// 第一种方式
const person = new Object();
person.name = 'zs'
person.age = 18
person.gender = '男'
person.sayHello = function() {
console.log(this.name)
}
// 第二种方式
const person = {
name: 'zs',
age: 29,
gender: '男',
sayName: function () {
console.log(this.name)
}
}

属性类型

ECMAScirpt 中有两种属性类型: 数据属性类型访问器属性

数据属性

数据属性包含一个数值的位置。在这个位置可以读取和写入值。数据属性有4个描述其行为的特征:

  • [[Configurable]]:表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。直接在对象上定义属性,这个属性的默认值为 true
  • [[Enumerable]]:表示能否通过 for-in 循环返回属性。直接在对象上定义属性,这个属性的默认值为 true
  • [[Writable]]:表示能否修改属性的值。直接在对象上定义属性,这个属性的默认值为 true
  • [[Value]]:包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性的时候,把新值保存在这个位置。这个属性的默认值为 undefined

要修改属性默认的特性,必须使用 ECMAScript 5Object.defineProperty() 方法。这个方法接收三个参数:属性所在的对象、属性的名字和一个描述符对象。描述符对象的属性必须是: configurableenumerablewritablevalue。设置其中的一个或多个值,可以修改对象的特性值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 修改
const person = {
}
Object.defineProperty(person, 'name', {
writable: false,
value: 'zs'
})
console.log(person.name) // zs
person.name = 'ls' // writable 为 false 不可修改
console.log(person.name) // zs
Object.defineProperty(person, 'gender', {
configurable: false,
value: '男'
})
console.log(person.gender) // 男
person.gender = '女'
console.log(person.gender) // 男

访问器属性

访问器属性不包含数据值:它们包含一对 getter 和 setter 函数(不过这两个函数不是必须的)。在读取访问器属性时,会调用 getter 函数,这个函数负责返回有效的值,在写入访问器属性时,会调用 setter 函数并传入新值,这个函数负责决定如何处理数据。

访问器的4个特性:

  • [[Configurable]]:表示能否通过 delete 删除属性,从而重新定义属性,能否修改属性的特性,或者能否把属性修改为数据属性。直接在对象上定义属性,这个属性的默认值为 true
  • [[Enumerable]]:表示能否通过 for-in 循环返回属性。直接在对象上定义属性,这个属性的默认值为 true
  • [[Get]]:在读取属性时调用的函数。默认值为 undefined。
  • [[Set]]:在写入属性时调用的函数。默认值为 undefined。

访问器属性不能直接定义使用,必须使用 Object.defineProperty() 来定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const person = {
age: 18,
_idcard: 123456789 // _idcard 前面的下划线是一种常用的记号,用于表示只能通过对象方法访问的属性。
}

Object.defineProperty(person, 'idcard', {
get: function() {
return this._idcard
},
set: function (newValue) {
this.age = newValue - this._idcard
this._idcard = newValue
}
})

person.idcard = 123456791
console.log(person.age) // 2

访问器属性的常见作用是设置一个属性的值会导致其他属性发送变化。

Object.defineProperty() 方法之前,要创造访问器属性一般都使用两个非标准方法:__defineGetter__()__definedSetter__()

定义多个属性

ECMAScript 5 中对象定义多个属性可以使用 Objetc.denfineProperties() 方法。利用这个方法可以通过描述符一次性定义多个属性。参数:第一个参数是需要修改对象,第二个需要的参数是第一个参数中需要添加或者修改的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 定义多个属性
const person = {}

Object.defineProperties(person, {
_idcard: { // 数据属性
value: 123456789
},
age: {
value: 18
},
idcard: { // 访问器属性
get: function () {
return this._idcard
},
set: function (newValue) {
this.age = newValue - this._idcard
this._idcard = newValue
}
}
})

读取属性的特性

ECMAScript 5Object.getOwnPropertyDescriptor() 方法,可以取得给定属性的描述符。参数:属性所在的对象和要读取其描述符的属性名称。返回值是一个对,如果是访问器属性,这个对象的属性有: configurableenumerablegetset 。如果是数据属性,这个对象的属性有: configurableenumerablewritablevalue

1
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
// 读取属性的特性
const person = {}

Object.defineProperties(person, {
_idcard: {
value: 123456789
},
age: {
value: 18
},
idcard: {
get: function () {
return this._idcard
},
set: function (newValue) {
this.age = newValue - this._idcard
this._idcard = newValue
}
}
})

const descriptor1 = Object.getOwnPropertyDescriptor(person, '_idcard')
console.log(descriptor1) // {value: 123456789, writable: false, enumerable: false, configurable: false}

const descriptor2 = Object.getOwnPropertyDescriptor(person, 'idcard')
console.log(descriptor2) // {enumerable: false, configurable: false, get: ƒ, set: ƒ}

创建对象

Object 构造函数和对象字面量都可以用来创建单个对象,但这些方式有个明显的缺点:使用同一个接口创建很多对象,会产生大量的重复代码。

工厂模式

ECMAScript 中无法创建类,开发人员发明了一种函数,用函数来封装以特定的接口创建对象的细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 工厂模式创建对象
function createPerson(name, age, gender) {
const object = new Object()
object.name = name
object.age = age
object.gender = gender
object.sayName = () => {
console.log(this.name)
}
return object
}
const zs = createPerson('zs', 18, '男')
console.log(zs) // { name: 'zs', age: 18, gender: '男', sayName: [Function (anonymous)] }
const ls = createPerson('ls', 18, '女')
console.log(ls) // { name: 'ls', age: 18, gender: '女', sayName: [Function (anonymous)] }

构造函数模式

构造函数创建特定类型的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 构造函数创建对象
function Person(name, age, gender) {
this.name = name
this.age = age
this.gender = gender
this.sayName = () => {
console.log(this.name)
}
}

const zs = new Person('zs', 18, '男')
console.log(zs) // { name: 'zs', age: 18, gender: '男', sayName: [Function (anonymous)] }
const ls = new Person('ls', 18, '女')
console.log(ls) // { name: 'ls', age: 18, gender: '女', sayName: [Function (anonymous)] }

与工厂模式不同的是:

  • 没有显示的创建对象
  • 直接将属性和方法赋值给了 this 对象
  • 没有 return 语句

构造函数的函数名的首字母最好大写,以区分其他函数,构造函数也是函数,只不过可以用来创建对象。

创建新实例必须使用 new 操作符。有以下四个步骤:

  • 创建一个新对象
  • 将构造函数的作用域赋给新对象(因此 this 就指向看这个新对象)
  • 执行构造函数中的代码(为新对象添加属性)
  • 返回新对象

person1person2 都分别保存着 Person 的不同实例。这两个对象都拥有 constructor(构造函数) 属性。

1
2
console.log(person1.constructor = Person) // true
console.log(person2.constructor = Person) // true

构造函数当作函数

构造函数和其他函数的不同在于调用的方式。构造函数也是函数的一种,不存在定义构造函数的特殊语法。任何函数只要通过 new 操作符来调用,那么她就可以作为沟站是;而任何函数,如果不通过 new 操作符来调用,那他跟普通函数没什么区别。

1
2
3
4
5
6
7
8
9
10
// 当作构造函数使用
const person = new Person('zs', 18, '男')
person.sayName() // zs
// 当作普通函数调用
Person('ls', 18, '女') // 在浏览器添加到 window 对象
window.sayName() // ls
// 在另一个对象的作用域中调用
const object = new Object()
Person.sayName.call(object, 'ww', 18, '男')
object.sayName() // ww

构造函数的问题

构造函数模式的问题在于:每个方法都要在实例上重新创建。在 ECMAScript 中的函数的对象,因此每定义一个函数,也就是实例化一个对象。

1
2
3
4
5
6
7
// 逻辑上构造函数的定义
function Person(name, age, gender) {
this.name = name
this.age = age
this.gender = gender
this.sayName = new Function('console.log(this.name)')
}

这种创建函数,会导致不同的作用域链和标识符解析,但创建 Function 新实例的机制仍然是先天的,不同的实例上的同名函数是不相等的。

1
2
// 证明不同实例上的同名函数是不相等的
console.log(person1.sayName == person.sayName) // false

可以通过将函数定义转移到构造函数外来解决这个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 将函数定义转移到构造函数解决重复创建函数的问题
function Person(name, age, gender) {
this.name = name
this.age = age
this.gender = gender
this.sayName = sayName
}
function sayName() { // 函数提升
console.sayName(this.name)
}
const zs = Person('zs', 18, '男')
console.log(zs) // { name: 'zs', age: 18, gender: '男', sayName: [Function (anonymous)] }
const ls = Person('ls', 18, '女')
console.log(ls) // { name: 'ls', age: 18, gender: '女', sayName: [Function (anonymous)] }

console.log(zs.sayName == ls.sayName) // true

这样做解决了重复创建函数的问题,但是又带来了一个新问题:在全局作用域中定义的函数只能被某个对象调用,这样让全局作用域很尴尬,而且更严重的问题是:如果需要定义很多方法,那么需要在全局作用域定义很多全局函数,这样引用类型没有封装性了。这个问题需要通过原型链模式解决。

原型链模式

我们创建的每一个函数都有 peototype(原型) 属性,这个属性是一个指针,指向一个对象,这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。简单的说: peototype 通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处在于让所有对象共享它的属性和方法。不需要再构造方法总定义对象的实例信息,而将信息直接添加到原型对象中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 原型模式的使用
function Person() {

}
Person.prototype.name = 'zs'
Person.prototype.age = 29
Person.prototype.gender = '男'
Person.prototype.sayName = function () {
console.log(this.name)
}
const person1 = new Person()
person1.sayName() // zs

const person2 = new Person()
person2.sayName() // zs

理解原型对象

只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个 peototype 属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个 constructor(构造函数) 属性,这个属性指向 peototype 属性所在函数的指针。对于前一个例子, Person.peototypeconstructor 指向 Person 。而通过这个构造函数,我们还可以继续为原型对象添加属性和方法。

创造自定义的构造函数之后,其原型对象默认只会取得 constructor 属性;至于其他方法,则都是从 Object 继承而来。调用构造函数创建一个新实例后,该实例内部包含一个指针(内部属性),指向构造函数的原型对象。在脚本中没有标准的方式访问 [[Prototype]]
,在各大浏览器中在每个对象上都支持 __proto__;在其他实现中,这个属性对脚本是完全不可见的。需要明确的是:连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。

实例与原型之间的关系图,图片来自于书本

从图中可以看出实例构造函数没有直接的关系,虽然两个实例都不包含属性和方法,但我们可以调用 person1.sayName() 。这个是通过查找对象属性的过程来实现的。

虽然在所有实现中都无法访问到 [[Prototype]] , 但可以通过 isPrototypeOf() 方法来确定对象之间是否存在这种关系。

1
2
// 判断实例和对象是否存在关系
console.log(Person.prototype.isPrototypeOf(person1)) // true 表示存在关系,false 表示不存在关系

调用原型对象的 isPrototypeOf() 方法测试 person1 。因为它们内部都有一个指向 Person.prototype 的指针,因此都返回了 true

ECMAScript 5 中有 Object.getPrototypeOf() 方法能够返回 [[Prototype]]

1
2
3
// 通过 Object.getPrototypeOf() 获取原型对象
console.log(Object.getPrototypeOf(person1) == Person.prototype) // true
console.log(Object.getPrototypeOf(person1).name) // zs

使用 Object.getPrototypeOf() 可以方便的获取一个对象的原型,这在利用原型实现继承的情况下是非常重要的。

当代码读取某个对象的属性时,会执行一次搜索,目标是给定名字的属性。搜索先从对象实例本身开始,通过实例中找到了给定名称的属性,则返回属性值,如果没有找到,则继续搜索实例的原型对象,在原型对象中查找具有给定名称的属性,在原型中找到则返回原型中该属性名称的值。

原型最初只包含 constructor 属性,而该属性也是共享的,因此可以通过对象实例访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 证明搜索属性的存在
function Person() {

}
Person.prototype.name = 'zs'
Person.prototype.age = 29
Person.prototype.gender = '男'
Person.prototype.sayName = function () {
console.log(this.name)
}
const person1 = new Person()
person1.name = 'ww'
person1.sayName() // ww 来着实例的属性

const person2 = new Person()
person2.sayName() // zs 来着原型的属性

上述例子造成一个问题,当我们设置了与原型相同属性名的属性,会阻止访问原型中的属性,就算将实例的中的属性赋值为 null 仍然会阻止访问原型中的属性,如果我们仍然需要访问原型中的属性,我们可以通过 delete 操作符删除实例中的属性,再访问原型中的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 实现实例属性已存在访问原型的属性
function Person() {

}
Person.prototype.name = 'zs'
Person.prototype.age = 29
Person.prototype.gender = '男'
Person.prototype.sayName = function () {
console.log(this.name)
}
const person1 = new Person()
person1.name = 'ww'
person1.sayName() // ww 来着实例的属性

const person2 = new Person()
person2.sayName() // zs 来着原型的属性

person1.name = null
person1.sayName() // null 实例中的属性

delete person1.name
person1.sayName() // zs 原型中的属性

使用 hasOwnProperty() 方法可以检测一个属性是存在于实例中,还是存在于原型中。这个方法只在给定属性存在于对象实例中,才会返回 true

1
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
function Person() {

}
Person.prototype.name = 'zs'
Person.prototype.age = 29
Person.prototype.gender = '男'
Person.prototype.sayName = function () {
console.log(this.name)
}
const person1 = new Person()
console.log(person1.hasOwnProperty('name')) // false
person1.name = 'ww'
person1.sayName() // ww 来着实例的属性
console.log(person1.hasOwnProperty('name')) // true

const person2 = new Person()
console.log(person2.hasOwnProperty('name')) // false
person2.sayName() // zs 来着原型的属性

person1.name = null
console.log(person1.hasOwnProperty('name')) // true
person1.sayName() // null 实例中的属性

delete person1.name
console.log(person1.hasOwnProperty('name')) // false
person1.sayName() // zs 原型中的属性

下图可以便于大家理解(由于自己敲了一遍验证,因此与原文不太一样了,原理是相同的):
属性来自于实例还是原型,图片来自于书本

Object.getOwnPropertyDescriptor() 方法只能用于实例属性,要取得原型的描述符,必须直接在原型对象调用 Object.getOwnPropertyDescriptor() 方法。

原型与 in 操作符

使用 in 操作符的两种方式:单独使用和在 for-in 循环中使用。 单独使用 in 操作符会在通过对象能够访问给定属性是返回 true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Person() {

}
Person.prototype.name = 'zs'
Person.prototype.age = 29
Person.prototype.gender = '男'
Person.prototype.sayName = function () {
console.log(this.name)
}
const person1 = new Person()

console.log(person1.hasOwnProperty('name')) // false
console.log("name" in person1) // true
person1.name = 'ww'
person1.sayName() // ww 来着实例的属性
console.log(person1.hasOwnProperty('name')) // true
console.log("name" in person1) // true

const person2 = new Person()
console.log(person2.hasOwnProperty('name')) // false
console.log("name" in person1) // true
person2.sayName() // zs 来着原型的属性

同时使用 hasOwnProperty() 和 in 操作符可以判断属性存在于对象还是原型。

1
2
3
4
5
6
7
8
9
/**
* 判断属性存在于对象还是原型的函数
* @param {Object} object 需要判断的对象
* @param {String} name 需要判断的属性名
* @returns {Boolean}
*/
function hasPrototypeProperty(object, name) {
return object.hasOwnProperty(name) && name in object
}

使用 for-in 循环时,返回的是所有能够通过对象访问的,可枚举的(enumerated)属性,包括存在于对象实例和原型中的属性。屏蔽了原型中不可枚举的属性(**[[Enumerable]]**为 false 的属性)的实例属性也会在 for-in 循环中返回。开发人员自己定义的属性属于可枚举属性。

1
2
3
4
5
6
7
8
// for-in 枚举属性
const object = {
name: 'zs',
toString: () => 'I am object'
}
for (const prop in object) {
console.log(prop); // name, toString
}

在上述例子中, objecttoString() 方法屏蔽了原型中不可枚举的 toString() 方法。除了 toString() 方法, hasOwnProperty()propertyIsEnumerable()toLocalString()valueOf() 方法也是不可枚举的。

ECMAScript 5 也将 constructorprototype 属性的 [[Enumerable]] 特性特性设置为 false ,但不是所有的浏览器都是这样实现的。

要取得对象上所有可枚举的实例属性,可以使用 ECMAScript 5Object.keys() 方法,参数: 需要枚举属性的对象, 返回:一个包含所有可枚举属性的字符串数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 使用 Object.keys() 获取属性名
function Person() {

}
Person.prototype.name = 'zs'
Person.prototype.age = 18
Person.prototype.gender = '男'
Person.prototype.sayName = () => console.log(this.name)

const perKeys = Object.keys(Person)
console.log(perKeys) // []

const perProKeys = Object.keys(Person.prototype)
console.log(perProKeys) // [ 'name', 'age', 'gender', 'sayName' ]

const ls = new Person()
ls.name = 'ls'
ls.age = 18
const lsKeys = Object.keys(ls)
console.log(lsKeys) // [ 'name', 'age' ]

获取所有的实例属性(包括不可枚举属性),可以使用 Object.getOwnPropertyNmaes() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Person() {

}
Person.prototype.name = 'zs'
Person.prototype.age = 18
Person.prototype.gender = '男'
Person.prototype.sayName = () => console.log(this.name)

const allPerKeys = Object.getOwnPropertyNames(Person)
console.log(allPerKeys) // [ 'length', 'name', 'arguments', 'caller', 'prototype' ]

const allPerProKeys = Object.getOwnPropertyNames(Person.prototype)
console.log(allPerProKeys) // [ 'constructor', 'name', 'age', 'gender', 'sayName' ]

const ls = new Person()
ls.name = 'ls'
ls.age = 18

const allLsProKeys = Object.getOwnPropertyNames(ls)
console.log(allLsProKeys) // [ 'name', 'age' ]

更简单的原型语法

为了简化减少输入和更好的封装原型变量,可以使用字面量的方式重写整个原型对象

1
2
3
4
5
6
7
8
9
10
// 字面量的方式重写整个原型对象
function Person() {

}
Person.prototype = {
name: 'zs',
age: 18,
gender: '男',
sayName: () => console.log(this.name)
}

在上面的方法中虽然以字面量的方式创建了新对象,但是 constructor 属性不再指向 Person 。当我们每创建一个函数,就会同时创建它的 prototype 对象,并且 prototype 对象的 constructor 属性也会自动指向这个函数(Person)。使用字面量的形式等同于重写了默认的 prototype 对象,因此原型对象的 constructor 属性指向了 Object的构造函数 , 不指向 Person 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 字面量形式原型的 constructor 指向问题
function Person() {

}
Person.prototype = {
name: 'zs',
age: 18,
gender: '男',
sayName: () => console.log(this.name)
}

const zs = new Person()
console.log(zs instanceof Object) // true
console.log(zs instanceof Person) // true
console.log(zs.prototype == Object) // true
console.log(zs.prototype == Person) // false

如果 constructor 属性的值非常重要,不可缺失,可以使用 Object.defineProperty() 设置 constructor 属性的指向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 恢复字面量方式原型的 constructor 属性指向问题
function Person() {

}
Person.prototype = {
name: 'zs',
age: 18,
gender: '男',
sayName: () => console.log(this.name)
}
Object.defineProperty(Person.prototype, 'constructor', {
enumerable: false,
value: Person
})

不要在使用字面量时直接指定原型的 constructor 属性的指向,这样会造成 constructor 属性可枚举([[Enumerable]] 的值为 true )

原型的动态性

由于在原型中查找值的过程是一次搜索,因此我们对原型对象所作的任何修改都能够立即从实例上反映。

1
2
3
4
5
6
7
// 证明原型的动态性
function Person() {

}
const person = new Person();
Person.prototype.sayHi = () => console.log('hi!')
person.sayHi() // hi!
1
2
3
4
5
6
7
8
9
// 重写已经有实例的对象的原型
function Person() {

}
const person = new Person();
Person.prototype = {
sayHi: () => console.log('hi!')
}
person.sayHi() // 报错

实例中的指针仅指向原型,而不指向构造函数。重写原型等于切断已有实例和原型之间的关系

重写原型对象示意图

原生对象的原型

原型模式的重要性不仅体现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。所有的原生引用类型(ObjectArrayString 等)都在其构造函数的原型上定义了方法。

通过原生对象的原型,可以获得所有默认方法的引用,并且可以定义新方法。

1
2
3
4
5
6
// 定义原生对象的方法
String.prototype.addLogo = function(){ // 这里不可以使用箭头函数,this的指向会有问题
return 'MyString:' + this
}
let msg = 'you are best!'
console.log(msg.addLogo()) // MyString:you are best!

尽管可以对原生对象增加新的方法,但是不推荐这样做,因这么做可能会导致明明冲突。并且有可能会意外的重写原生方法。

原型对像的问题

原型对象的问题在于:它省略了为构造函数传递初始化参数这一环节,结果所有实例在默认情况下将取得相同的属性值。 原型模式最大的问题是由其共享的本性导致的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 
function Person() {

}
Person.prototype = {
constructor: Person, // 这里constructor可枚举
name: 'zs',
age: 18,
gender: '女',
friend: ['ls', 'ww'],
sayName: () => {
this.name
}
}

const p1 = new Person()
const p2 = new Person()
p1.friend.push("yxl")
console.log(p1.friend) // [ 'ls', 'ww', 'yxl' ]
console.log(p2.friend) // [ 'ls', 'ww', 'yxl' ]
console.log(p1.friend == p2.friend) // true

上面的例子造成了 p1p2 共有 friend 的情况。而真实情况下应该是 p1 有自己的朋友, p2 也有自己的朋友。这就是单独原型模式所带来的问题。

组合使用构造函数模式和原型模式

创建自定义类型的常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性,而原型模式用于定义方法和共享属性。最终的结果就是:每个实例都会有这就的一份实例属性的副本,但同时又共享着对方法的引用,最大限度的节省了内存。这两种混成模式还支持向构造函数传递参数,结合了两种模式的长处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 构造函数模式和原型模式创建对象实例
function Person(name, age, gender) {
this.name = name
this.age = age
this.gender = gender
this.friends = ['ls', 'ww']
}

Person.prototype = {
constructor: Person, // 可枚举
sayName: function () {
console.log(this.name)
}
}
const zs = new Person('zs', 18, '男')
const atm = new Person('ls', 18, '女')

zs.friends.push('yxl')
console.log(zs.friends) // [ 'ls', 'ww', 'yxl' ]
console.log(atm.friends) // [ 'ls', 'ww' ]
console.log(zs.friends == atm.friends) // false 不同的实例的属性具有不同的指向
console.log(zs.sayName == atm.sayName) // true

这种构造函数和原型混成的模式,是目前在 ECMAScript 中最广泛使用、认同度最高的一种创建自定义类型的方法。

动态原型模式

其他有面向对象语言经验的开发人员看到独立的构造函数和原型时,会感到很疑惑。动态原型模式正是致力于解决这个问题的方案,它把所有的信息都封装在构造函数里面,通过在构造函数中初始化原型(仅在必要的情况下),又保持了同时使用构造函数和原型的优点。可以检查某个应该存在的方法是否有效来决定是否需要初始化原型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 动态原型模式
function Person(name, age, gender) {
this.name = name
this.age = age
this.gender = gender
this.friends = ['ls', 'ww']
if (typeof this.sayName != 'function') {
Person.prototype.sayName = function () {
console.log(this.name)
}
}
}

const zs = new Person('zs', 18, '男')
zs.sayName() // zs

使用动态原型模式时,不能使用对象字面量重写原型。如果在已经创建了实例的情况下重写原型,那么就会切断所有实例与新原型之间的联想(原型的地址变化)。

寄生构造函数模式

在前面几种模式的不适合的情况下,可以使用寄生(parasitic)构造函数模式。这种模式的思想是:创建应该函数,函数的作用是封装对象的代码,再返回新建的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 寄生构造函数模式
function Person(name, age, gender) {
const object = new Object()
object.name = name
object.age = age
object.gender = gender
object.sayName = function() { // 不可以使用箭头函数
console.log(this.name)
}
return object
}

const zs = new Person('zs', 18, '男')
zs.sayName() // zs

这个模式可以在特殊情况下用来为对象创建构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 创建特殊在数组
function SpecialArray() {
// 创建数组
const values = new Array()
// 添加元素
values.push.apply(values, arguments)
// 添加方法
values.toPipedString = function () {
return this.join('|')
}
// 返回数组
return values
}

const names = new SpecialArray('zs', 'ls', 'ww')
console.log(names.toPipedString()) // zs|ls|ww

关于寄生构造函数模式的注意点: 返回对象与构造函数或者与构造函数的原型属性之间没有关系。因此不能使用 instanceof 操作符来确定对象类型。

稳妥构造函数模式

稳妥对象是指没有公共属性,而且其方法也不引用 this 的对象。稳妥构造函数模式和寄生构造函数类似,但是有两个不同点:

  • 新创建对象的实例方法不引用 this
  • 不使用 new 操作符调用构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 稳妥构造函数模式
function Person(name, age, gender) {
// 创建对象
const object = new Object()
// 定义私有变量和函数
// ...
// 添加方法
object.sayName = function() {
console.log(name)
}
// 返回对象
return object
}
const p1 = new Person('zs', 18, '男')
p1.sayName() // zs

与寄生构造函数模式类似,使用稳妥构造函数模式创建的对象与构造函数之间也没有关系,因此 instanceof 操作符对这种对象也没用意义

继承

继承是面向对象语言的一个最为人津津乐道的概念。许多面向对象语言都支持两种继承方式:接口继承和实现继承。接口继承只继承方法签名,而实现继承则继承实际的方法。由于函数没有签名,在 ECMAScript 种无法实现接口继承。 ECMAScript 只支持实现继承,而且实现继承主要是依靠原型链来实现的。

原型链

ECMAScript 中描述了原型链的概念,并将原型链作为实现继承的主要方法。其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。

构造函数、原型和实例的关系: 每个构造函数都有一个原型对象,原型对象包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。

如果我们让原型对象等于另一个类型的实例,结果会怎么样?此时的原型对象将包含一个指向另一个原型的指针,相应的,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上诉关系依然成立,如此层层递进,就构成了实例与原型的链条。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 原型链体验
function SuperType() {
this.property = true
}

SuperType.prototype.getSuperValue = function () {
return this.property
}

function SubType() {
this.subproperty = false
}
// 继承了 SuperType
SubType.prototype = new SuperType();

SubType.prototype.getSubValue = function () {
return this.subproperty
}

const instance = new SubType()
console.log(instance.getSuperValue()) // true

上面的代码中定义了两个类型: SuperTypeSubType 。每个类型分别有一个属性和一个方法。主要的区别在于 SubType 继承了 SuperType , 而继承是通过创建 SuperType 的实例,并将该实例给 SubType.prototype 实现的。实现的本质是重写原型对象,用一个新类型的实例替代,也就是原来存在于 SuperType 的实例中的所有方法和属性也存在与 SubType.prototype 中。在确立了继承关系之后,我们给 SubType.prototype 添加了一个方法,主要就在继承了 SuperType 的属性和方法的基础上又添加了一个新方法。

原型链案例图

对于上面代码,调用 instance.getSuperValue() 的三个步骤:

  • 搜索实例
  • 搜索 SubType.prototype
  • 搜索 SuperType.prototype (在这一步找到了方法)

别忘记默认的原型

所有的引用类型都默认继承了 Object , 而这个继承也是通过原型链实现的。所有函数的默认原型都是 Object 的实例,因此默认原型都会包含一个内部指针,指向 Object.prototype 。 这也是所有自定义类型都会继承 toString()valueOf() 等默认方法的根本原因。

完整原型链案例

确定原型和实例的关系

有两种方式:

  • 第一种使用 instanceof 操作符,这个操作符能够测试实例与原型链中出现过的构造函数。结果: 如果出现了就会返回 true 否在会返回 false
1
2
3
4
5
// instanceof 操作符确定实例是否由构造方法派生
console.log(instance instanceof Object) // true
console.log(instance instanceof SuperType) // true
console.log(instance instanceof SubType) // true
console.log(SubType instanceof Function) // true 构造函数的原型的 constructor 指向 Function
  • 使用 isPrototypeOf(object) 方法。只要是原型链中出现过的原型,都是该原型链所派生的实例的原型。参数: object : 需要检测的对象, 返回值: 如果出现了就会返回 true 否在会返回 false
1
2
3
4
// isPrototypeOf(object) 方法确定实例是否由构造函数派生出
console.log(Object.prototype.isPrototypeOf(instance)) // true
console.log(SuperType.prototype.isPrototypeOf(instance)) // true
console.log(SubType.prototype.isPrototypeOf(instance)) // true

谨慎地定义方法

子类型有时候需要重写超类型的某个方法,或者需要添加超类型中不存在的某个方法。但是不管是哪种情况,给原型添加方法的代码一定要放在替换原型的语句之后。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 重写或者添加超类型的方法
function SuperType() {
this.property = true
}

SuperType.prototype.getSuperValue = function () {
return this.property
}

function SubType() {
this.subproperty = false
}
// 继承了 SuperType
SubType.prototype = new SuperType();

SubType.prototype.getSubValue = function () {
return this.subproperty
}
// 重写超类型中的方法
SubType.prototype.getSuperValue = function () {
return false
}

const instance = new SubType()
console.log(instance.getSuperValue()) // false

在上面的代码中,SubType 重写了 SuperType 中的 getSuperValue() 方法,这样根据原型的搜索原则,会屏蔽SuperTypegetSuperValue() 方法。

注意: 一定要在 SuperType 的实例替换原型之后,再定义两个方法,先定义的话会被 SuperType 的实例覆盖掉。使用字面量的方式也是产生类似的效果,会破坏原型链

原型链的问题

原型链最主要的问题来自包含引用类型的值的原型。

  • 引用型值的原型属性会被所有实例共享,在通过原型来继承时,原型实际上会变成另一个类型的实例,这样原先的实例属性就变成了现在的原型属性了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 原型链的问题
function SuperType() {
this.friends = ['zs', 'ls', 'ww']
}

function SubType() {

}
// 继承了 SuperType
SubType.prototype = new SuperType()

const instance1 = new SubType()
instance1.friends.push('atm')
console.log(instance1.friends) // [ 'zs', 'ls', 'ww', 'atm' ]

const instance2 = new friends()
console.log(instance2.names) // [ 'zs', 'ls', 'ww', 'atm' ] 共享了 friends 属性
  • 在创建子类型实例时,不能在不影响所有对象实例的情况下向超类型的构造函数传递参数。

实际使用中很少单独使用原型链。

借用构造函数

借用构造函数: 在子类型构造函数的内部调用超类型构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 借用构造函数
function SuperType() {
this.friends = ['zs', 'ls', 'ww']
}
function SubType() {
// 继承 SuperType
SuperType.call(this)
}

const instance1 = new SubType()
instance1.friends.push('atm')
console.log(instance1.friends) // [ 'zs', 'ls', 'ww', 'atm' ]

const instance2 = new SubType()
console.log(instance2.friends) // [ 'zs', 'ls', 'ww' ]

通过使用 call() 方法(或 apply() 方法也可以)调用超类型的构造函数,这样会在新的 SubType 对象上执行初始化代码,因此每个实例都会拥有自己的 friends 属性。

传递参数

构造函数可以在子类型构造函数中向超类型构造函数传递参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 构造函数 传参
function SuperType(name) {
this.name = name
}

function SubType() {
// 继承 SuperType 并传递参数
SuperType.call(this, 'zs')
// 实例属性
this.age = 18
}

const instance = new SubType()
console.log(instance.name) // zs
console.log(instance.age) // 18

借用构造函数的问题

借用构造函数的问题

  • 方法都在构造函数中定义,函数的复用就没有意义的
  • 超类型的原型中定义的方法,对子类而已是不可见的,所有类型都只能使用构造函数模式。

组合继承

组合继承(经典继承)是将原型链借用构造函数的技术组合到一块。使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。这样保证了原型上定义方法实现了服用,有保证每个实例都有它自己的属性。

1
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
30
31
// 组合继承
function SuperType(name) {
this.name = name
this.friends = ['zs', 'ls', 'ww']
}

SuperType.prototype.sayName = function() {
console.log(this.name)
}

function SubType(name, age) {
SuperType.call(this, name)
this.age = age
}

SubType.prototype = new SuperType()

SubType.prototype.sayAge = function () {
console.log(this.age)
}

const instance1 = new SubType('atm', 18)
instance1.friends.push('zsf')
console.log(instance1.friends) // [ 'zs', 'ls', 'ww', 'zsf' ]
instance1.sayName() // atm
instance1.sayAge() // 18

const instance2 = new SubType('gx', 18)
console.log(instance2.friends) // [ 'zs', 'ls', 'ww' ]
instance2.sayName() // gx
instance2.sayAge() // 18

原型式继承

原型式继承并没有使用严格意义上的构造函数,他的想法是借助原型可以基于已有对象创建新对象,并且不必因此创建自定义类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 原型式继承
function object(o) {
function F() {
}
F.prototype = o
return new F()
}

const person = {
name: 'zs',
friends: ['ls', 'ww']
}

const anotherPerson = object(person)
anotherPerson.name = 'atm'
anotherPerson.friends.push('zsf')

const yetAnotherPerson = object(person)
yetAnotherPerson.name = 'gx'
yetAnotherPerson.friends.push('yg')

console.log(person.friends) // [ 'ls', 'ww', 'zsf', 'yg' ]

上述代码实际上相当于又创建了 person 对象的两个副本

ECMAScript 5 通过新增 Object.create() 方法规范化了原型式继承,这个方法接收两个参数:

  • 用作新对象原型的对象
  • 新对象定义额外属性的对象(可选)

传入一个参数时, Object.create() 与上述 object() 方法的行为相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Object.create() 方法的使用
const person = {
name: 'zs',
friends: ['ls', 'ww']
}

const anotherPerson = Object.create(person)
anotherPerson.name = 'atm'
anotherPerson.friends.push('zsf')

const yetAnotherPerson = Object.create(person)
yetAnotherPerson.name = 'gx'
yetAnotherPerson.friends.push('yg')

console.log(person.friends) // [ 'ls', 'ww', 'zsf', 'yg' ]

Object.create() 的第二个参数格式与 Object.defineProperties() 方法的第二个参数格式相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Object.create() 传递第二个参数
const person = {
name: 'zs',
friends: ['ls', 'ww']
}

const anotherPerson = Object.create(person, {
name: {
value: 'atm'
}
})

console.log(anotherPerson.name) // atm

寄生式继承

寄生式继承是创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真的是它做了所有工作拥有返回对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 寄生式继承
function object(o) {
function F() {
}
F.prototype = o
return new F()
}

function createAnother(original) {
const clone = object(original)
clone.sayHi = function () {
console.log('h1');
}
return clone
}

const person = {
name: 'zs',
friends: ['ls', 'ww']
}
const anotherPerson = createAnother(person)
anotherPerson.sayHi() // hi

使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率,这一点与构造函数模式类似。

寄生组合式继承

组合式继承的问题在于:会调用两次超类型构造函数。

  • 在创建子类型原型的时候
  • 子类型构造函数内部
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 组合式继承的问题
function SuperType(name) {
this.name = name
this.friends = ['zs', 'ls']
}

SuperType.prototype.sayName = function () {
console.log(this.name);
}

function SubType(name, age) {
SuperType.call(this, name) // 第二次调用 SuperType()
this.age = age
}

SubType.prototype = new SuperType() // 第一次调用 SuperType()
SubType.prototype.constructor = SubType
SubType.prototype.sayAge = function () {
console.log(this.age);
}

寄生组合式继承:通过借用构造函数来继承属性,通过原型链混成形式来继承方法。

基本思路:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的是超类型原型的一个副本。本质上,就算使用寄生式继承来继承超类型的原型,然后将结果指定给子类型的原型

inheritPrototype() 方法接收两个参数:

  • 子类型的构造函数
  • 超类型的构造函数
1
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
30
31
32
// 寄生组合式继承的基本模式
function object(o) {
function F() {
}
F.prototype = o
return new F()
}

function inheritPrototype(subType, superType) {
const prototype = object(superType.prototype) // 创建超类型的副本
prototype.constructor = subType // 为创建的副本添加 constructor 属性,弥补因重写原型而失去默认的 constructor 属性
subType.prototype = prototype // 将新创建的对象赋值给子类型的原型
}

function SuperType(name) {
this.name = name
this.friends = ['zs', 'ls']
}

SuperType.prototype.sayName = function () {
console.log(this.name);
}

function SubType(name, age) {
SuperType.call(this, name) // 第二次调用 SuperType()
this.age = age
}
inheritPrototype(SubType, SuperType)

SubType.prototype.sayAge = function() {
console.log(this.age)
}