0%

JavaScript进阶笔记3

5. 对象

5.1 创建对象

在 JavaScript 中,对象是一系列无序属性的集合,属性值可以为基本数据类型,对象或者函数,因此,对象实际就是一组键值对的组合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 对象
let person = {
// 基本数据类型的属性
name: '张三',
age: 28,
// 函数类型的属性
sayName: function() {
console.log(this.name)
},
// 对象类型的属性
address: {
name: '上海',
code: '10000'
}
}

对象作为数据存储的最直接有效的方式,具有非常高的使用频率,接下来总结 JavaScript 中创建对象的 7 种方式。

5.1.1 基于 Object() 构造函数

通过 Object() 对象的构造函数生成一个实例,然后给他们增加需要的各种属性。

1
2
3
4
5
6
7
8
9
10
11
12
// Object() 构造函数生成实例
const person = new Object()
// 为实例新增各种属性
person.name = '张三'
person.age = 28
person.getName = function() {
return this.name
}
person.address = {
name: '上海',
code: '10000'
}
5.1.2 基于对象字面量

对象字面量本身就是一系列键值对的组合,每个属性直接通过逗号分隔。

1
2
3
4
5
6
7
8
9
10
11
let person = {
name: '张三',
age: 28,
sayName: function() {
console.log(this.name)
},
address: {
name: '上海',
code: '10000'
}
}

这个方法和 5.1.1 创建对象具有相同的优点:简单,容易理解,但是对象的属性值通过对象自身进行设置,如果需要同时创建若干个属性相同,而只是属性值不同的对象时,则会产生很多重复代码。因此不推荐使用批量创建对象。

5.1.3 基于工厂方法模式

工厂方法模式是一种比较重要的设计模式,用于创建对象,旨在抽象出创建对象和属性复制的过程,只对外暴露出需要设置的属性值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 工厂分啊,对外暴露接收 name age address 属性值
function createPerson(name, age, address) {
const o = new Object()
o.name = name
o.age = age
o.address = address
o.getName = function() {
return this.name
}
return o
}

const p = createPerson('张三', 28, {
name: '上海',
code: '10000'
})

使用工程方法可以减少很多重复的代码,但是创建的所有实例都是 Object 类型,无法更进一步区分具体类型。

5.1.4 基于构造函数模式

构造函数是通过 this 为对象添加属性的,属性值类型可以为基本类型,对象或者函数,然后通过 new 操作符创建对象的实例。

1
2
3
4
5
6
7
8
9
10
function Person(name, age, address) {
this.name = name
this.age = age
this.address = address
}

const person = new Person('张三', 28, {
name: '上海',
code: '10000'
})

使用构造函数创建的对像可以确定其所属类型,解决了 5.1.3 中的问题。

使用构造函数创建的对象存在一个问题,即相同实例的函数是不一样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Person(name, age, address) {
this.name = name
this.age = age
this.address = address
this.getName = function() {
return this.name
}
}

const person1 = new Person('张三', 28, {
name: '上海',
code: '10000'
})
const person2 = new Person('张三', 28, {
name: '上海',
code: '10000'
})
console.log(person1.getName === person2.getName) // false

这意味着每个实例的函数都会占据一定的内存空间,其实这是没必要的,会造成资源的浪费,另外函数也是没不要在代码执行前就绑定到对象上。

5.1.5 基于原型对象的模式

基于原型对象的模式是将所有的函数和属性都封装在对象的 prototype属性上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定义函数
function Person() {
// 通过 prototype 属性增加属性和函数
Person.prototype.name = '张三'
Person.prototype.age = 28
Person.prototype.address = {
name: '上海',
code: '10000'
}
Person.prototype.getName = function() {
return this.name
}
}
// 生成两个实例
const p1 = new Person()
const p2 = new Person()
console.log(p1.getName === p2.getName) // true
console.log(p1.name === p2.name) // true

通过上面代码可以发现,使用基于原型对象的模式创建的实例,其属性和函数都是相等的,不同的实例会共享原型上的属性和函数,解决了 5.1.4 中存在的问题。

1
2
3
4
5
6
const p1 = new Person()
const p2 = new Person()
console.log(p1.name) // 张三
p2.name = '李四'
console.log(p1.name) // 张三
console.log(p2.name) // 李四 (在自己的空间中找到,就不会去原型链中查找了)

但是这种方式也存在一个问题,因为所有的实例会共享相同的属性,如果修改了原型中的引用数据,原型链中的数据共享,这并不是我们期望的。

1
2
3
4
5
6
const p1 = new Person()
const p2 = new Person()
console.log(p1.address.name) // 上海
p2.address.name = '武汉'
console.log(p1.address.name) // 武汉 (引用数据被共享)
console.log(p2.address.name) // 武汉
5.1.6 构造函数和原型混合的模式

构造函数和原型混合的模式是目前最重建的创建自定义类型对象的方式。

构造函数中用于定义实例的属性,原型对象中用于定义实例共享的属性和函数,通过构造函数传递参数,这样每个实例都能拥有自己的属性值,同时实例还能共享函数的引用,最大限度的节约了内存的空间,混合模式可谓集二者之长。

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
// 构造函数中定义实例的属性
function Person(name, age, address) {
this.name = name
this.age = age
this.address = address
}
// 原型中添加实例共享的函数
Person.prototype.getName = function() {
return this.name
}
// 生成两个实例
const p1 = new Person('张三', 28, {
name: '上海',
code: '10000'
})
const p2 = new Person('李四', 28, {
name: '北京',
code: '10000'
})
// 输出两个实例初始化的 name 属性
console.log(p1.name) // 张三
console.log(p2.name) // 李四
// 改变一个实例的属性
p1.address.name = '广州'
p1.address.code = '10003'
console.log(p1) // Person {name: '张三', age: 28, address: {…}}
console.log(p2) // Person {name: '李四', age: 28, address: {…}}

推荐构造函数和原型混合的模式创建自定义对象。ES6 后推荐使用 class 语法。

5.2 对象的属性(了解)

ECMA-262 将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都有一个名称来标识,这个名称映射到一个值。其中的内容就是一组名/值对,值可以是数据或者函数。

5.2.1 理解对象

创建自定义对象的通常方式是创建 Object 的一个新实例,然后再给它添加属性和方法,如下例所示:

1
2
3
4
5
6
7
let person = new Object()
person.name = "张三"
person.age = 29
person.job = 'software engineer'
person.sayName = function() {
console.log(this.name)
}

这个例子创建了衣蛾名为 person 的对象,而且有三个属性(name、age和 job)和一个方法 sayName()。sayName() 方法会显示 this.name 的值,这个属性会解析为 person.name。早期 JavaScript 开发者频繁使用这种方式创建对象。几年后 ,对象字面量变成了更流行的方式。前面的例子如果使用对象字面量则可以这样写:

1
2
3
4
5
6
7
8
let person = {
name: "张三",
age: 29,
job: "software engineer",
sayName() { // 还可以这么写 sayName: function() {console.log(this.name)}
console.log(this.name)
}
}

这个例子中的 person 对象跟前面例子中的 person 对象是等价的,他们的属性和方法都一样。这些属性都有自己的特征,而这些特征决定了它们在 JavaScript 中的行为。

5.2.2 数据属性

【了解】数据属性有4个描述其行为的特性:

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

如果要修改属性的默认特性,就必须使用 Object.defineProperty()方法。这个方法接收3个参数:要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包含 configurable、enumerable、writable和 value,跟相关特性的名称一一对应。根据要修改的特性,可设置其中一个或多个值。比如:

1
2
3
4
5
6
7
8
let person = {}
Object.defineProperty(person, "name", {
writable: false, // name 属性的值不能修改
value: '张三'
})
console.log(person.name) // 张三
person.value = "李四"
console.log(person.name) // 还是张三,不能修改

这个例子创建了一个名为 name 的属性并赋值 “张三”。这个属性的值不能再修改了,在非严格模式下尝试给这个属性重新赋值会被忽略,在严格模式下,尝试修改只读属性的值会抛出错误。

5.2.3 访问器属性

访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必须的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。访问器属性有4个特性描述它们的行为。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let book = {
_year : 2004,
edition : 1
};
Object.defineProperty(book,"year",{
get : function () {
alert(this._year);
},
set : function (newValue) {
if (newValue > 2004) {
this._year = newValue;
this.edition += newValue - 2004;
}
}
});
book.year; // 弹出窗口,显示 2004
book.year = 2005;
console.log(book.edition); // 2

5.3 对象克隆

对象克隆是指通过一定的程序将某个变量的值复制到另一个变量的过程,根据复制后的变量与原始变量值的影响情况,克隆可以分为浅克隆(浅拷贝)和深克隆(深拷贝)两种方式。

针对不同的数据类型,浅克隆和深克隆会有不同的表现,主要表现于基本数据类型和引用数据类型在内存中存储的值不同。

对应基本数据类型的值,变量存储的是值本身,存放在内存的简单数据段中,可以直接进行访问。

对于引用数据类型的值,变量存储的是值在聂村中的地址,地址指向聂村中的某个位置,如果多个变量同时指向同一个内存地址,则其中一个变量对值进行修改时,会影响到其他的变量。

以下以数组为实例来看看效果:

1
2
3
4
5
const arr1 = [1, 2, 3]
const arr2 = arr1
arr2[1] = 4
console.log(arr1) // (3) [1, 4, 3]
console.log(arr2) // (3) [1, 4, 3]

正是由于数据类型的差异性导致了基本数据类型不管是浅克隆还是深克隆都是对值本身的克隆,对克隆后值的修改不会影响到值本身。

引用数据类型如果执行的是浅克隆,对克隆后的值的修改会影响到原始值,如果执行的是深克隆,则克隆对象和原始对象相互独立,不会彼此影响。

5.3.1 对象浅克隆

浅克隆由于值克隆对象最外层的属性,如果对象存在更深层的属性,则不进行处理,这就会导致克隆对象和原始对象的深层属性仍然执行同一块内存。

5.3.2 ES6 的 Object.assign() 函数

在 ES6 中,Object 对象新增了一个 assign() 函数,用于将源对象的可枚举属性赋值到目标对象中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 原始对象
const origin = {
a:1,
b:[2,3,4],
c: {
d: 'name',
f: 20
}
}
// 通过 assign 克隆对象
const result = Object.assign({}, origin)
result.c.d = 'new name'
console.log(origin)
console.log(result)

Object.assign()拷贝的是(可枚举)属性值。假如源值是一个对象的引用,它仅仅会复制其引用值。针对深拷贝,需要使用其他办法

5.3.3 对象深克隆
JSON序列号和反序列化

如果一个雕像中的全部实数都是可以序列化的,那么我们可以使用 JSON.stringify()函数将原始对象序列化为字符串,再使用JSON.parse()函数将字符串反序列化为一个对象。这里得到的就是深克隆后的对象。

1
2
3
4
5
6
7
8
9
10
11
12
const origin = {
a:1,
b:[2,3,4],
c: {
d: 'name',
f: 20
}
}
const result = JSON.parse(JSON.stringify(origin))
result.c.d = 'new name'
console.log(origin)
console.log(result)

这种方式能解决大部分 JSON 类型对象的深克隆问题,但是对于以下几个问题不能很好解决。

  • 无法实现对函数,RegExp等特殊对象。
  • 对象的 constructor 会被抛弃,所有的构造函数会指向 Object,原型链关系会破裂。
  • 对象中如果存在循环引用,会抛出异常。
$.深克隆的第三方库

函数库lodash的_.cloneDeep方法

该函数库也有提供_.cloneDeep用来做深克隆。

1
2
3
4
5
6
7
8
var _ = require('lodash');
var obj1 = {
a: 1,
b: { f: { g: 1 } },
c: [1, 2, 3]
};
var obj2 = _.cloneDeep(obj1);
console.log(obj1.b.f === obj2.b.f);// false

jQuery

jQuery 有提供一个$.extend可以用来做深克隆。

1
2
3
4
5
6
7
8
var $ = require('jquery');
var obj1 = {
a: 1,
b: { f: { g: 1 } },
c: [1, 2, 3]
};
var obj2 = $.extend(true, {}, obj1);
console.log(obj1.b.f === obj2.b.f); // false

使用Object.create()方法

直接使用var newObj = Object.create(oldObj),可以达到深拷贝的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function deepClone(initalObj, finalObj) {    
var obj = finalObj || {};
for (var i in initalObj) {
var prop = initalObj[i]; // 避免相互引用对象导致死循环,如initalObj.a = initalObj的情况
if(prop === obj) {
continue;
}
if (typeof prop === 'object') {
obj[i] = (prop.constructor === Array) ? [] : Object.create(prop);
} else {
obj[i] = prop;
}
}
return obj;
}

5.4 原型对象

每一个函数在创建时都会被赋予一个 prototype属性。它指向函数的原型对象,这个对象可以包含所有实例的共享的属性和函数,因此在使用 prototype 属性后,就可以将实例共享的属性和函数抽离出构造函数,将它添加在 prototype 属性中。

1
2
3
4
5
6
7
8
9
10
function Person(name, age) {
this.name = name
this.age = age
}
Person.prototype.sayName = function() {
console.log(this.name)
}
const p1 = new Person()
const p2 = new Person()
console.log(p2.sayName === p2.sayName) // true

实例共享的 sayName() 函数就被添加在了 Person.prototype 属性上,通过测试我们会发现不同的实例中的 sayName 属性是相同的。

因此使用 prototype 属性就很好的解决了,单纯通过构造函数创建实例会导致函数在不同实例中重复创建的问题。

5.4.1 原型对象,构造函数和实例

通过前面我们知道,构造函数的 prototype 属性会指向它的原型对象,而通过构造函数可以生成具体的实例,这里会涉及3个概念,分别是构造函数原型对象实例

  1. 原型对象,构造函数和实例之间的关系是什么样的?
  2. 使用原型对象创建了对象的实例后,实例的属性读取顺序是什么样的?
  3. 假如重写了原型对象,会带来什么样的问题?
5.4.2 原型对象、构造函数和实例之间的关系

每一个函数在创建时都会被赋予一个 prototype 属性,它指向函数的原型对象。在默认情况下,所有的原型对象都会增加一个 constructor 属性,指向 prototype 属性所在的函数,即构造函数。

当我们通过 new 操作符调用构造函数创建一个实例时,实例具有一个__prop__属性,指向构造函数的原型对象,因此__prop__属性可以看做是一个连接实例与构造函数的原型对象的桥梁。

1
2
3
4
5
6
7
8
9
function Person(){}
Person.prototype.name = '张三'
Person.prototype.age = 28
Person.prototype.jon = 'teacher'
Person.prototype.sayName = function() {
console.log(this.name)
}
const p1 = new Person()
const p2 = new Person()

接下来我们以构造函数 Person 为例看看构造函数,原型和实例之间的关系。

js原型

构造函数 Person 有个 prototype 属性,指向的是 Person 的原型对象,在原型对象中有 constructor 属性和另外的4个原型对象上的属性,其中 constructor 属性指向构造函数本身。

通过 new 操作符创建的两个实例 p1 和 p2,都具有一个__prop__属性指向的是 Person 原型对象。

5.4.3 实例的属性读取顺序

当我们通过对象的实例读取某个属性时,是有一个搜索的过程的。它贤惠在实例本书去找指定的属性,如果找到了,则直接返回该属性的值,如果没找到,则会沿着原型对象去寻找,如果在原型对象中找到了该属性,则返回该属性的值。

按照前面的实例,假如我们需要输出 p1.name 属性,会在 p1 实例本身中寻找 name 属性,而 p1 本身并没有该属性,因此会继续沿着原型对象寻找,在 prototype 原型对象上寻找到了 name 属性值“张三”。

如果我们对上面代码进行简单修改,得到的结果又会不一样。

1
2
3
4
5
6
7
8
9
10
11
function Person(){
this.name = "李四"
}
Person.prototype.name = '张三'
Person.prototype.age = 28
Person.prototype.jon = 'teacher'
Person.prototype.sayName = function() {
console.log(this.name)
}
const p1 = new Person()
console.log(p1.name) // 李四

我们在 Person() 构造函数中新增了一个 name 属性,它是一个实例属性,当我们需要输出 p1.name 属性时,会先在 p1 实例本身中寻找 name 属性,能够找到该属性值为“李四”,因此输出“李四”。

同样,假如 Person() 构造函数同时具有相同名称的实例属性和原型对象上的属性,在生成实例后,删除了实例的实例属性,那么会输出原型对象上的属性值。

1
2
3
4
5
6
7
8
9
10
11
function Person(name) {
// 这里的 name 属性是实例属性
this.name = name
}
// 这里的 name 是原型对象上的属性
Person.prototype.name = '张三'
const p1 = new Person("王五")
console.log(p1.name) // 王五
// 删除实例的属性
delete p1.name
console.log(p1.name) // 张三
5.4.4 重写原型对象

在之前的代码中,每次为原型对象添加一个属性或者函数时,都需要手动写上 Person.prototype ,除此之外,我们可以将所有需要绑定在原型对象上的属性,写成一个对象字面量的形式,并赋值给 prototype。注意不要忘记 constructor 属性

1
2
3
4
5
6
7
8
9
10
function Person() {}
Person.prototype = {
constructor: Person, // 不要忘记指定构造函数
name: "张三",
age: 28,
job: "teacher",
sayName: function() {
console.log(this.name)
}
}

当我们创建 Person 对象的实例时,仍然可以正常的访问各个原型对象上的属性。将一个对象字面量赋值给 prototype 属性的方式,应该在对象字面量中增加一个 constructor 属性,指向构造函数本身,否则原型的 constructor 属性会指向 Object 类型的构造函数,从而导致 constructor 属性与构造函数的脱离。

1
2
3
4
5
6
7
8
9
function Person() {}
Person.prototype = {
name: "张三",
sayName: function() {
console.log(this.name)
}
}
console.log(Person.prototype.constructor === Object) // true
console.log(Person.prototype.constructor === Person) // false

通过结果,我们发现 Person 的原型对象的 constructor 属性不再指向 Person() 构造函数,而是指向 Object 类型的构造函数了。

由于重写原型对象会切断构造函数和最初原型之间的关系,因此会带来一个隐患,那就是如果在重写原型对象之前,已经生成了对象实例,则该实例将无法访问到新的原型对象中的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person() {}
// 先生成一个实例 p1
const p1 = new Person()
// 重写对象的原型
Person.prototype = {
name: "张三",
sayName: function() {
console.log(this.name)
}
}
// 再生成一个实例
const p2 = new Person()
p1.sayName() // TypeError: p1.sayName is not a function
p2.sayName() // 张三

在上面的代码中,生成的实例 p1 实际指向的事最初的原型,而将原型对象手动重写以后,就脱离了最初的原型关系,最初原型中没有对应的属性和函数,因此在执行 p1.sayName() 函数时,会抛出异常,表示 p1 中不存在 sayName() 函数。

上面的实例就是在提醒我们,如果想要重写原型对象,需要保证不要在重写完成之前生成实例对象,否则会出现异常。

不推荐重写原型对象。

5.5 原型链

对象的每个实例都具有一个__prop__属性,指向的事构造函数的原型对象,而原型对象同样存在一个__prop__属性指向上一级构造函数的原型对象,就这样层层往上,直到最上层某个原型对象为 null。

在 JavaScript 中几乎所有的对象都具有__prop__属性,由__prop__属性链接而成的链路构成了 JavaScript 的原型链,原型链的顶端 Object.prototype,它的__prop__属性为 null。

我们通过一个实例来看看一个简单的原型链过程,首先定义一个构造函数,并生成一个实例。

1
2
function Person() {}
const person = new Person()

然后 person 实例沿着原型链第一次追溯,__prop__属性指向 Person() 构造函数的原型对象。

1
console.log(person.__prop__ === Person.prototype) // true

person 实例沿着原型链第二次追溯,Person 原型对象的__prop__属性指向 Object 类型的原型对象。

1
console.log(person.__prop__.__prop__ === Person.prototype__prop__ === Object.prototype)  // true

person 实例沿着原型链第三次追溯,Object 类型的原型对象的 __prop__属性为 null。

1
console.log(person.__prop__.__prop__.__prop__ === Person.prototype__prop__.__prop__ === Object.prototype.__prop__ === null)  // true
5.5.1 原型链的特点

原型链的特点主要有以下两个:

  1. 由于原型链的存在,属性查找的过程不再是查找自身的原型对象,而是会沿着整个原型链一直向上,知道追溯到 Object.prototype,如果 Object.prototype 上也找不到该属性,则返回‘undefined’,如果期间在实例本身或者某个原型对象上找到该属性,则会直接返回结果,因此会存在属性覆盖的问题。

由于特点1的存在,我们在生成自定义对象实例时,也可以调用到某些未在自定义构造函数上的函数,例如 toString() 函数。

1
2
3
function Person() {}
const p = new Person()
p.toString() // Obbject 实际调用的是 Object.toString() 函数
  1. 由于属性查找会经历整个原型链,因此查找的链路越长,对性能的影响越大。
5.5.2 属性的区分

对象的属性的寻找往往会涉及这个原型链,那么该怎么区分属性是实例自身还是从原型链中继承的呢?

Object() 构造函数的原型对象中提供了一个 hasOwnProperty() 函数,用于判断属性是否为自身用于的。

1
2
3
4
5
6
7
8
9
function Person(name) {
// 实例属性 name
this.name = name
}
// 原型对象上的属性 age
Person.prototype.age = 18
const person = new Person('张三')
console.log(person.hasOwnProperty('name')) // true
console.log(person.hasOwnProperty('age')) // false
5.5.3 内置构造函数

JavaScript 中有一些特定的内置构造函数,如 String() 构造函数,Array() 构造函数,Object() 构造函数等。

它们本身的__proto__属性都统一指向Function.prototype

1
2
3
4
5
6
console.log(String.__proto__ === Function.prototype)
console.log(Number.__proto__ === Function.prototype)
console.log(Array.__proto__ === Function.prototype)
console.log(Object.__proto__ === Function.prototype)
console.log(Date.__proto__ === Function.prototype)
console.log(Function.__proto__ === Function.prototype)
5.5.4 __prooto__属性

在 JavaScript 的原型链体系中,最重要的莫过于 __proto__属性,只有通过它才能将原型链串联起来。

已废弃: 该特性已经从 Web 标准中删除,虽然一些浏览器目前仍然支持它,但也许会在未来的某个时间停止支持,请尽量不要使用该特性。

警告:Object.prototype.__proto__ 已被大多数浏览器厂商所支持的今天,其存在和确切行为仅在ECMAScript 2015规范中被标准化为传统功能,以确保Web浏览器的兼容性。为了更好的支持,建议只使用 Object.getPrototypeOf()

我们先实例化一个字符串,然后输出字符串的值,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
const str = new String("张三")
console.log(str)

/*
String {'张三'}
0: "张"
1: "三"
length: 2
[[Prototype]]: String
[[PrimitiveValue]]: "张三
*/

str 的值包含2个字符和一个 length 属性。

但是我们在调用 str.substring(1, 2) 时,却不会报错,这是为什么呢?

因为__proto__属性([[Prototype]])可以沿着原型链找到 String.prototype 中的函数,而 substring() 函数就在其中。在控制台展开__proto__属性:

1
2
3
4
5
6
7
8
9
...
split: ƒ split()
startsWith: ƒ startsWith()
substr: ƒ substr()
substring: ƒ substring()
toLocaleUpperCase: ƒ toLocaleUpperCase()
toLowerCase: ƒ toLowerCase()
toString: ƒ toString()
...

所以就可以通过 str 正常调用 substring() 函数了。

1
2
3
4
5
6
Function.prototype.a = 'a'
Object.prototype.b = 'b'
function Person() { }
const p = new Person()
console.log('p.a', p.a); // p.a undefined
console.log('p.b', p.b); // p.b b

上面的代码要输出实例 p 的 a 属性和 b 属性的值,所以我们需要先了解实例 p 的属性查找过程,属性查找是根据 __proto__属性沿着原型链来完成的,因此我们需要先梳理出 实例 p 的原型链。

1
2
3
4
// 实例 p 的原型链
console.log(p.__proto__ === Person.prototype)
// Person 原型对象的原型
console.log(Person,prototype.__proto__ === Object.prototype)

因此实例输出 p 的属性是,最终会找到 Object.prototype 中去,根据一开始定义的值可以得到以上的结果。

5.6 继承

继承作为面向对象语言的三大特性之一,三大特性是继承、封装、多态。可以在不影响父类对象的情况下,使得子类对象具有父类对象的特性,同时还能在不影响父类对象行为的情况下扩展紫烈对象特有的特性,为编码带来了极大的遍历。

虽然 JavaScript 并不是一门面向对象的语言,不直接具备继承的特性,但是我们可以通过某些方式间接实现继承,从而利用继承的优势,增强代码的复用性和扩展性。

5.6.1 原型链继承

原型链继承的主要思想是:重写子类的 prototype 属性,将其指向父类的实例。、

我们定义一个子类 Cat 用于继承父类 Animal,子类 Cat 的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 定义一个父类 Animal
function Animal(name) {
// 属性
this.type = 'Animal'
this.name = name || '动物'
// 方法
this.sleep = function() {
console.log(this.name + '正在睡觉')
}
}
// 原型函数
Animal.prototype.eat = function(food) {
console.log(this.name + '正在吃'+ food)
}

定义子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 子类 Cat
function Cat(name) {
this.name = name
}
// 原型继承
Cat.prototype = new Animal()
// 很关键的一句,将 Cat 的构造函数指向自身
Cat.prototype.constructor = Cat

var cat = new Cat("加菲猫")
console.log(cat.type) // Animal
console.log(cat.name) // 加菲猫
cat.sleep() // 加菲猫正在睡觉
cat.eat("猫粮") // 加菲猫正在吃猫粮

在子类 Cat 中,我们没有增加 type 属性,因此会直接继承父类 Animal 的 type 属性,输出字符串 ‘Animal’。

在子类 Cat 中,,我们增加了 name 属性,在声称子类 Cat 实例时,name 属性值会覆盖父类 Animal 的 name 属性值,因此输出字符串‘加菲猫’,并不会输出父类 Animal 的 name 属性“动物”。

同样因为 Cat 的 prototype 属性指向了 Animal 类型的实例,因此在生成实例 Cat 时,会继承实例函数和原型函数,在调用 sleep() 函数和 eat() 函数时,this 指向了实例 cat,从而输出‘加菲猫正在睡觉’和‘加菲猫正在吃猫粮’。

需要注意其中很关键的一句代码:

1
2
// 很关键的一句,将 Cat 的构造函数指向自身
Cat.prototype.constructor = Cat

这是因为如果不将 Cat 原型对象的 constructor 属性指向自身的构造函数的话,那将会指向父类 Animal 的构造函数。

原型链继承优缺点

优点

  1. 简单,很容易实现

​ 只需要设置子类的 prototype 属性为父类的实例即可,实现起来简单。

  1. 继承关系纯粹

​ 生成的实例即是子类的实例,也是父类的实例。

1
2
console.log(cat instanceof Cat) // true
console.log(cat instanceof Animal) // true
  1. 可以通过子类直接访问父类原型链属性和函数

​ 通过原型链继承的资料,可以直接访问到父类原型链上新增的函数和属性。

​ 继续沿用前面的代码,我们通过在父类原型链上添加属性和函数进行测试,代码如下:

1
2
3
4
5
6
7
8
9
// 父类原型链上增加属性
Animal.prototype.bodyType = 'small'
// 父类原型链上增加函数
Animal.prototype.run = function() {
return this.name + '正在奔跑'
}
// 结果验证
console.log(cat.bodyType) // small
console.log(cat.run()) // 加菲猫正在奔跑

缺点

  1. 子类的所有实例将共享父类的属性。
1
Cat.prototype = new Animal()

在使用原型链继承是,直接改写了子类 Cat 的 prototype 属性,将其指向一个 Animal 的实例,那么所有生成 Cat 对象的实例都将会共享 Animal 实例的属性。

这将会带来一个很验证的问题,如果父类 Animal中有个值为引用类型,那么改变 Cat 某个实例的属性值将会影响其他实例的属性值。

  1. 在创建子类实现时,无法想父类的构造函数传递参数。

    在通过 new 操作符创建子类的实例时,会调用子类的构造函数,而在子类的构造函数中并没有设置与父类的关联,从而导致无法想父类的构造函数传递参数。

  2. 为子类增加原型链对象上的属性和函数时,必须放在 new Animal() 函数之后。

5.6.2 构造继承

构造继承的思想是在子类的构造函数中通过 call() 函数改变 this 的指向,调用父类的构造函数,从而能将父类的实例的属性和函数绑定到子类的 this 上。

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
// 定义一个父类 Animal
function Animal(age) {
// 属性
this.name = 'Animal'
this.age = age
// 方法
this.sleep = function() {
console.log(this.name + '正在睡觉')
}
}
// 父类原型函数
Animal.prototype.eat = function(food) {
console.log(this.name + '正在吃'+ food)
}

// 子类
function Cat(name) {
// 核心通过 call()函数实现 Animal 的实例的属性和函数的继承
Animal.call(this)
this.name = name || 'Tom'
}
// 生成子类的实例
const cat = new Cat('tony')
// 可以正常调用父类实例函数
cat.sleep() // tony正在睡觉
// 不能调用父类原型函数
cat.eat() // TypeError: cat.eat is not a function

通过代码可以发现,子类可以正常调用父类的实例函数,而无法调用父类原型对象上的函数,这是因为子类并没有通过某种方式来调用父类原型对象上的函数。

构造继承的优缺点

优点

  1. 可以解决子类实例共享父类属性的问题。

    call() 函数实际是改变了父类 Animal构造函数中 this 的指向,调用后 this 指向了子类 Cat,相当于父类的 type,age和 sleep等属性和函数直接绑定到子类的 this 中,成了子类实例的属性和函数,因此生成的子类实例中是各自拥有自己的type,age和 sleep,不会互相影响。

  2. 创建子类的实例时,可以想父类传递参数

    在 call() 函数中,我们可以传递参数,我们就可以对父类的属性进行设置,同时由子类继承下来。

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
// 定义一个父类 Animal
function Animal(age) {
// 属性
this.name = 'Animal'
this.age = age
// 方法
this.sleep = function() {
console.log(this.name + '正在睡觉')
}
}
// 父类原型函数
Animal.prototype.eat = function(food) {
console.log(this.name + '正在吃'+ food)
}
// 子类
function Cat(name, parentAge) {
// 传递参数给 call() 函数,间接地传递给父类,然后被子类继承
Animal.call(this, parentAge)
this.name = name || 'Tom'
}
// 生成子类的实例
const cat = new Cat('tony', 11)
// 可以正常调用父类实例函数
cat.sleep() // tony正在睡觉
console.log(cat.age)
  1. 可以实现多继承

    在子类构造函数中,可以通哦该多次调用 call() 函数来继承多个父对象,每调用一次 call() 函数就会将父类的实例属性和函数绑定到子类的 this 中。

缺点

  1. 实例只是子类的实例,并不是父类的实例

    因为我没并没有通过原型对象将子类与父类进行串联,所以生成的实例与父类并没有关系,这样就失去了继承的意义。

  2. 只能继承父类实例的属性和函数,并不能继承原型对象上的属性和函数。

  3. 无法复用父类的实例函数。

​ 由于父类的实例函数将通过 call() 函数绑定到子类的 this 中,因此子类生成的每个实例都会拥有父类实例函数的引用,这会造成不必要的内存消耗,影响性能。

5.6.3 复制继承

复制继承的主要思想是首先生成父类的实例,然后通过 for...in遍历父类实例的属性和函数,并将其一次设置为子类实例的属性和函数或者原型对象上的属性和函数。

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
33
34
35
36
37
38
39
40
41
42
// 定义一个父类 Animal
function Animal(age) {
// 属性
this.name = 'Animal'
this.age = age
// 方法
this.sleep = function () {
console.log(this.name + '正在睡觉')
}
}
// 父类原型函数
Animal.prototype.eat = function (food) {
console.log(this.name + '正在吃' + food)
}

// 子类
function Cat(name, age) {
const animal = new Animal(age)
// 父类的属性和函数添加到子类中
for(let key in animal) {
// 实例属性和函数
if(animal.hasOwnProperty(key)) {
this[key] = animal[key]
} else {
// 原型对象上的属性和函数
Cat.prototype[key]= animal[key]
}
}
// 子类自身的属性
this.name = name
}

// 子类自身原型函数
Cat.prototype.eat= function (food) {
console.log(this.name + '正在吃' + food)
}

// 生成子类的实例
const cat = new Cat('tony', 12)
console.log(cat.age) // 12
cat.sleep() // tony正在睡觉
cat.eat('猫粮') // tony正在吃猫粮

在子类的构造函数中,对父实例的所有属性进行 for…in 遍历,如果 animal.hasOwnProperty(key) 返回 true,则表示实例的属性和函数,则直接绑定到子类的 this 上,成为子类实例的属性和函数。如果为 false,则表示是原型对象上的属性和函数,则将其添加到子类的 prototype 属性上,成为子类的原型对象上的属性和函数。

复制继承的优缺点

优点

  1. 能同时继承实例的属性和函数与原型对象上的属性和函数。
  2. 可以向父类构造函数传递值。
  3. 支持多继承。

缺点

  1. 父类的锁边属性都需要复制,消耗内存。

  2. 实例只是子类的实例,并不是父类的实例。

    实际上我们只是通过遍历父类的属性和函数并将其复制到子类上,并没有通过原型对象串联其父类和子类,因此子类的实例不是父类的实例。

5.6.4 组合继承

组合继承的主要思想是组合了构造继承和原型继承两种方法,一方面在子类构造函数中通过 call() 函数调用父类的构造函数,将父类的实例的属性和函数绑定到子类的 this 中,另一方面,通过改变子类的 prototype 属性,继承父类的原型对象上的属性和函数。

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
// 定义一个父类 Animal
function Animal(parentAge) {
// 属性
this.name = 'Animal'
this.age = parentAge
// 方法
this.sleep = function () {
console.log(this.name + '正在睡觉')
}
this.feature = ['fat', 'thin', 'tall']
}
// 父类原型函数
Animal.prototype.eat = function (food) {
console.log(this.name + '正在吃' + food)
}

// 子类
function Cat(name) {
// 通过构造函数继承实例的属性和函数
Animal.call(this)
// 子类自身的属性
this.name = name
}

// 通过原型链继承原型对象上的属性和函数
Cat.prototype = new Animal()
Cat.prototype.constructor = Cat

const cat = new Cat('tony')
console.log(cat.name) // tony
cat.sleep() // tony正在睡觉
cat.eat('猫粮') // tony正在吃猫粮

组合继承优缺点

优点

  1. 既能继承父类实例的属性和函数,又能继承原型对象的属性和函数。

  2. 即是子类实例,又是父类实例。

  3. 不存在引用属性共享的问题。

    因为在子类的构造函数中已经将父类的实例属性指向了子类的 this,所以即是后面将父类的实例属性绑定到子类的 prototype 属性中,也会因为构造函数作用域优先级比原型链优先级高,所以不会出现引用属性共享的问题。

  4. 可以向父类的构造函数中传递参数。

    通过 call() 函数可以向父类的构造函数中传递参数。

缺点

组合继承的缺点为父类的实例属性会绑定两次。

在子类构造函数中,通过 call() 函数调用了一次父类的构造函数,在改写了类的 prototype 属性,生成父类的实例时调用了一次父类的构造函数。

5.6.5 寄生组合继承

事实上组合继承的方法已经够好了,但是我们针对它的缺点再进行一下优化。

在进行子类的 prototype 属性的设置时,可以去掉父类实例的属性和函数。

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
33
34
35
36
37
// 定义一个父类 Animal
function Animal(parentAge) {
// 属性
this.name = 'Animal'
this.age = parentAge
// 方法
this.sleep = function () {
console.log(this.name + '正在睡觉')
}
this.feature = ['fat', 'thin', 'tall']
}
// 父类原型函数
Animal.prototype.eat = function (food) {
console.log(this.name + '正在吃' + food)
}

// 子类
function Cat(name) {
// 通过构造函数继承实例的属性和函数
Animal.call(this)
// 子类自身的属性
this.name = name
}

// 立即执行函数
(function() {
// 设置任意函数 Super()
const Super = function() {}
Super.prototype = Animal.prototype
Cat.prototype = new Super()
Cat.prototype.constructor = Cat
})()

const cat = new Cat('tony')
console.log(cat.name) // tony
cat.sleep() // tony正在睡觉
cat.eat('猫粮') // tony正在吃猫粮

其中最关键的是如下所示的代码:

1
Super.prototype = Animal.prototype

5.7 Object 类型和实例

5.7.1 new 运算符

Object 类型是目前 JavaScript 中使用最多的一个类型,目前大家使用的大部分引用数据类型都是 Object 类型,使用频率搞的原因是其对于数据存储和传输是非常理想的。由于引用数据类型的实例都需要通过 new 操作符来生成。

new 操作符在执行过程中会改变 this 的指向,所以了解 new 操作符之前,我们先了解一下 this 的用法。

1
2
3
4
5
function Cat(name, age) {
this.name = name
this.age = age
}
console.log(new Cat('miao', 8)) // Cat {name: 'miao', age: 8}

输出的结果包含了 name 和 age 的信息,事实上我们并未通过 return 返回任何值,为什么输出的信息中会包含 name 和 age 属性呢?其中起作用的就是 this 这个关键字了。

1
2
3
4
5
6
function Cat(name, age) {
console.log(this)
this.name = name
this.age = age
}
new Cat('miao', 8) // Cat {}

我们可以发现 this 的实际值是 Cat 的空对象,后两句就相当于给 Cat 对象添加 name 和 age 属性,结果真的是这样吗?不如我们改写一下 Cat 函数。

1
2
3
4
5
6
function Cat(name, age) {
let Cat = {}
Cat.name = name
Cat.age = age
}
console.log(new Cat('miao', 8)) // Cat {}

我们可以发现输出的结果中并没有包含 name 和 age 属性,这是为什么呢?

因为在 JavaScript 中,如果函数没有 return 值,则默认 return this。而上面代码中的 this 实际是一个 Cat 空对象, name 和 age 属性只是被添加到了临时变量 Cat 中,为了呢个让输出结果包含 name 和 age 属性,我们将临时变量 Cat 进行 return 就行了。

1
2
3
4
5
6
7
function Cat(name, age) {
let Cat = {}
Cat.name = name
Cat.age = age
return Cat
}
console.log(new Cat('miao', 8)) // {name: 'miao', age: 8}

最后的返回值包含了 name 和 age 属性,通过以上的分析,我们了解了构造函数中 this 的用法,那么它与 new 操作符之间有什么关系呢?

我们先来看看下面这行简单的代码,该代码的作用是通过 new 操作符生成一个 Cat 对象的实例。

1
const cat = new Cat()

从表面上看这行代码的主要作用是创建一个车 Cat 对象的实例,并将这个实例值赋予 cat 变量, cat 变量就会包含 Cat 对象的属性和函数。

其实使用 new 操作符做了三件事情,如下代码所示:

1
2
3
const cat = {}
cat.__proto__ = Cat.prototype
Cat.call(cat)

第一行:创建一个空对象。

第二行:将空对象的__proto__属性指向 Cat 对象的 prototype 属性。

第三行:将 Cat() 函数中的 this 指向 cat变量。

于是 cat 变量就是 Call 对象的一个实例。

我们自定义一个类似 new 功能的函数,来具体讲解上面的三行代码。

1
2
3
4
5
6
7
8
9
10
11
function Cat(name, age) {
this.name = name
this.age = age
}

function New() {
const obj = {}
const res = Cat.apply(obj, arguments)
return typeof res === 'object' ? res : obj
}
console.log(New(('miao', 8))) // {name: 'miao', age: 8}

返回的结果中包含 name 和 age 属性,这就证明了 new 运算符对 this 指向的改变。Cat.apply(obj, arguments) 执行后 Cat 对象中的 this 就指向了 obj 对象,这样 obj 对象就具有了 name 和 age 属性。

因此, 不仅要关注 new 操作符函数本身,也要关注他的原型属性。

我们对上面的代码进行改动,在 Cat 对象的原型链上增加一个 sayHi() 函数,然后通过 New() 函数返回对象,去调用 sayHi() 函数,看看执行情况如何。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Cat(name, age) {
this.name = name
this.age = age
}

Cat.prototype.sayHi = function() {
console.log('Hi')
}

function New() {
const obj = {}
const res = Cat.apply(obj, arguments)
return typeof res === 'object' ? res : obj
}
console.log(New('miao', 8)) // {name: 'miao', age: 8}
New('miao', 8).sayHi() // TypeError: New(...).sayHi is not a function

我们发现执行报错了,New() 函数返回的对象并没有调用 sayHi() 函数,这是因为 sayHi() 函数是属于 Cat 原型的函数,只有 Cat 原型链上的对象才能继承 sayHi() 函数,那么我们应该怎么做呢?

这里需要用到的就是__pro__ 属性,实例的__pro__ 属性指向的事创建实例对象时,对应的函数的原型。设置 obj 对象的__pro__ 值为 Cat 对象的 prototype 属性,那么 obj 对象就继承了 Cat 原型上的 sayHi() 函数,这样就可以调用 sayHi() 函数了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Cat(name, age) {
this.name = name
this.age = age
}

Cat.prototype.sayHi = function() {
console.log('Hi')
}

function New() {
const obj = {}
obj.__proto__ = Cat.prototype // 核心代码,用于继承
const res = Cat.apply(obj, arguments)
return typeof res === 'object' ? res : obj
}
console.log(New('miao', 8)) // {name: 'miao', age: 8}
New('miao', 8).sayHi() // hi
赞赏是最好的支持