⛰ JavaScript 原型继承
当已经存在一个 user
对象以及其属性与方法,想要将 admin
和 guest
作为基于 user
稍加修改的变体,在 user
基础之上构建一个新的对象。
JavaScript 的 原型继承(Prototype inheritance) 特性帮助实现这个需求。
# [[Prototype]]
属性
在 JavaScript 中,对象有一个特殊的 隐藏属性 [[Prototype]]
:
要么为
null
;要么就是对另一个对象的引用。该对象被称为「原型」。
当尝试从一个对象 object
中获取一个 缺失的属性 时, JavaScript 会自动从 原型 中获取该属性。这个过程为「原型继承」。
对象的 [[Prototype]]
属性是 在内部的 并且是 隐藏的。
🌰 例子:
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal;
2
3
4
5
6
7
8
当从 rabbit
中读取一个它不存在的属性,JavaScript 会从 animal
中获取:
console.log(rabbit.eats) // true
这个例子中的
rabbit__proto__ = animal
将animal
设置为rabbit
的 原型。原来的rabbit
对象并不存在eats
属性,JavaScript 会顺着[[Prototype]]
引用 在animal
中查找(自下而上)。这个例子可以称 「
animal
是rabbit
的原型」 或者 「rabbit
的原型是从animal
继承而来的 」。当animal
中有的属性和方法,都会自动变为在rabbit
中可以使用(继承属性和方法)。
当 animal
中有方法:
let animal = {
eats: true,
walk() {
console.log('animal walk')
}
}
2
3
4
5
6
rabbit.walk() // animal walk
# 原型链
🌰 例子:
let animal = {
eats: true,
walk() {
alert("Animal walk");
}
};
let rabbit = {
jumps: true,
__proto__: animal
};
let longEar = {
earLength: 10,
__proto__: rabbit
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
现在,如果从
longEar
中读取一些它不存在的内容,JavaScript 会先在rabbit
中查找,然后在animal
中查找。但是要遵循以下规则:
- 引用不能形成闭环。试图在闭环内分配
__prototype__
会报错。__proto__
的值可以是 对象,也可以是null
。而其他的类型都会被忽略。- 显然,一个对象只能有一个
[[prototype]]
。一个对象不能从其他两个对象中即成。
__proto__
是[[prototype]]
的访问器(getter
/setter
)(__proto__
与[[prototype]]
要区别开):
__proto__
属性有点过时了,它的存在是出于历史的原因。现代编程语言建议应该使用函数Object.getPrototypeOf
/Object.setPrototypeOf
来取代__proto__
去 get/set 原型。
# 原型仅用于读取属性
🌰 例子:
let animal = {
eats: true,
walk() {
// ...
}
};
let rabbit = {
__proto__: animal
};
rabbit.walk = function() {
alert("Rabbit! Bounce-bounce!");
};
rabbit.walk() // Rabbit! Bounce-bounce!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这个例子中,
rabbit
中分配了自己的walk
方法。尽管
rabbit
的原型是animal
。但是因为rabbit
本身就有walk
方法,所以可以在对象中找到该方法无需使用原型。
尽管设置了原型在先,但是后来的 添加方法 操作,是在对象子身上进行的,不会再原型上进行(对于删除属性同理)。
可以看出,原型仅用于读取属性。
🌰 例子 / 访问器属性是 例外。因为分配操作是通过 setter
函数处理的,所以写入此属性的操作相当于 读取该属性。
let user = {
name: "John",
surname: "Smith",
set fullName(value) {
[this.name, this.surname] = value.split(" ");
},
get fullName() {
return `${this.name} ${this.surname}`;
}
};
let admin = {
__proto__: user,
isAdmin: true
};
console.log(admin.fullName) // John Smith
admin.fullName = "Alice Cooper"
console.log(admin.fullName) // Alice Cooper
console.log(user.fullName) // John Smith
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
admin.fullName = "Alice Cooper"
可以正常运行,因为它的原型对象中有fullName
的setter
函数。并且修改的是
admin
原型对象的内容,而不是 原来的对象(被保护)。
# this
的值
this
不受原型的影响:无论在哪里找到该方法(在一个对象或者在原型中),在方法调用时, this
始终指向的是 .
前面的对象(调用的该方法的对象)。
从上面的例子中可以知道,在 setter
被调用时, admin.fullName = '...'
是 admin
调用的方法,所以 this
指向的是 admin
。
提示
清楚 this
的值。有可能有一个带有很多方法的大对象,并且还有从其继承的对象。当继承的对象运行方法时,它们应该只修改自身的状态而不修改大对象的状态。
🌰 例子 / 有一个方法是存储属性:
let animal = {
walk() {
if (!this.isSleeping) {
alert(`I walk`);
}
},
sleep() {
this.isSleeping = true;
}
};
let rabbit = {
name: "White Rabbit",
__proto__: animal
};
rabbit.sleep();
console.log(rabbit.isSleeping) // true
console.log(animal.isSleeping) // undefined
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
原型方法共享,但是对象状态不是。
# 循环
🌰 例子:
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
// 对于 Object.keys
console.log(Object.keys(rabbit)) // jumps
// 对于 for...in
for(let prop in rabbit) console.log(prop) // jumps, eats
2
3
4
5
6
7
8
9
10
11
12
13
14
可以看出
for ... in
循环会迭代 继承的属性。
如果想 排除继承的属性 或者利用它们进行其他操作, 可以使用 obj.hasOwnProperty(key)
判断:如果 obj
具有自己的(非继承的) key
属性则返回 true
。
for(let prop in rabbit) {
let isOwn = rabbit.hasOwnProperty(prop)
if(isOwn) {
console.log(`our: ${prop}`)
} else {
console.log(`inherited: ${prop}`)
}
}
2
3
4
5
6
7
8
9
hasOwnProperty
方法是rabbit
从Object.prototype
中继承的(该方法是Object.prototype.hasOwnProperty
提供的)。并且该方法是不可枚举的,所以for ... in
没有列出。
提示
几乎所有的 其他键 / 值获取方法 都忽略继承的属性(例如 Object.keys
和 Object.values
等)。它们只会对对象 自身 进行操作。不考虑 继承自原型的属性。
# 总结
[[prototype]]
是 对象 的隐藏属性,要么是另一个对象,要么为null
。可以通过obj.__proto__
访问。- 通过
[[Prototype]]
引用的对象被称为「原型」。 - 当读取
obj
的一个属性或者调用一个方法,并且它不存在,那么 JavaScript 就会尝试在原型中查找它。- 写 / 删除操作直接在 对象自身 上进行,不使用原型。
this
的值是 谁调用该方法,就是哪个对象。
for ... in
循环时,除了自身的属性,继承的属性也会迭代列出。所有其他的键 / 值获取方法仅对对象本身起作用。
# 实例
# 搜索链
let head = { glasses: 1 }; let table = { pen: 3 }; let bed = { sheet: 1, pillow: 2 }; let pockets = { money: 2000 };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- 使用
__proto__
来分配原型,以使得任何属性的查找都遵循以下路径:pockets
→bed
→table
→head
。例如,pockets.pen
应该是3
(在table
中找到),bed.glasses
应该是1
(在head
中找到)。- 通过
pockets.glasses
或head.glasses
获取glasses
,哪个更快?必要时需要进行基准测试。
点击查看
- 在现代引擎中,从性能的角度来看,是从对象还是从原型链获取属性都是没区别的。它们(引擎)会记住在哪里找到的该属性,并在下一次请求中重用它。
- 对于
pockets.glasses
来说,它们(引擎)会记得在哪里找到的glasses
(在head
中),这样下次就会 直接在这个位置进行搜索。并且引擎足够聪明,一旦有内容更改,它们就会自动更新内部缓存,因此,该优化是安全的。
# 仓鼠例子
有两只仓鼠:
speedy
和lazy
都继承自普通的hamster
对象。当我们喂其中一只的时候,另一只也吃饱了。为什么?如何修复它?
let hamster = { stomach: [], eat(food) { this.stomach.push(food); } }; let speedy = { __proto__: hamster }; let lazy = { __proto__: hamster }; speedy.eat("apple"); console.log(speedy.stomach) // apple console.log(lazy.stomach) // apple
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
点击查看
由于每只仓鼠没有自身的 stomach
,在 push
操作时,只能顺着原型链找到 hamster
的 stomach
。
push
操作的是在原型链找到的stomach
。但是 简单赋值this.stomach
时不会出现这个种情况:// ... eat(food) { this.stomach = [food] } // ...
1
2
3
4
5因为
this.stomach =。
不会执行对stomach
的查找。该值会被直接写入this
对象。
可以通过确保每只仓鼠都有自己的 stomach
解决:
let hamster = {
stomach: [],
eat(food) {
this.stomach.push(food);
}
};
let speedy = {
__proto__: hamster,
stomach: []
};
let lazy = {
__proto__: hamster,
stomach: []
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
所有描述特定对象状态的属性,例如上面的 stomach
,都应该被写入该对象中。这样可以避免此类问题。