写代码时,我们通常有扩展某些对象的需求。

例如,我们有一个对象变量 user ,它有一些属性和方法。对象 adminguest 与它基本相同,可以看成是 user 的轻度变体。我们想要重用 user 里的属性和方法,不想通过复制已有代码的方式去实现,只是想在 user 的基础上构建出这两个对象。

原型继承就是用来解决这个问题的语言特性。

[[Prototype]]

JavaScript 中,每次对象都有一个隐藏的属性 [[Prototype]](规范中的名称),它的值为 null 或是另一个对象的引用。这个对象称为“原型(prototype)”。

原型继承 - 图1

[[Prototype]] 有个“神奇”的地方。当我们从一个 object 里读属性的时候,如果这个属性在不存在,JavaScript 就会自动从原型里查找。在编程世界里,这叫“原型继承(prototypal inheritance)”。许多酷酷的语言是特性和编程技巧都是基于此的。

[[Prototype]] 虽是一个内部属性,但还是有许多方法操作它。

方法之一是使用 __proto__

  1. let animal = {
  2. eats: true
  3. };
  4. let rabbit = {
  5. jumps: true
  6. };
  7. rabbit.__proto__ = animal;

💡 提示:** __proto__ 是历史遗留属性,实际上是基于 [[Prototype]] 的访问器属性(getter/setter)**

需要注意的是,__proto__ 不等于 [[Prototype]],它只是基于后者的 getter/setter。

它是因为历史原因而保留下来的。现在可以使用 Object.getPrototypeOf/Object.setPrototypeOf 方式替代直接操作 __proto__,用来获取和设置原型。使用这两个函数的原因会在之后介绍。

根据规范,__proto__ 属性浏览器环境提供,但实际上现在所有的宿主环境(包括服务器端)都支持这个接口。当前使用 __proto__ 属性来说明原型继承更加直观,因此本篇例子我们还是采用 __proto__

如果我们访问 rabbit 里的一个属性,但没有的话,JavaScript 会自动从 animal 中查找。

例如:

  1. let animal = {
  2. eats: true
  3. };
  4. let rabbit = {
  5. jumps: true
  6. };
  7. rabbit.__proto__ = animal; // (*)
  8. // eats 和 jumps 属性都能在 rabbit 上访问到
  9. alert( rabbit.eats ); // true (**)
  10. alert( rabbit.jumps ); // true

我们在 (*) 处将 rabbit 的原型设置为 animal

然后使用 rabbit.eats(**) 读取属性 eats,发现不在 rabbit 里,因此 JavaScript 会接着 查找 [[Prototype]] 引用,最终在 animal 中发现了这个属性(从下往上看图)。

原型继承 - 图2

我们可以说“animalrabbit 的原型”或“rabbit 的原型继承自 animal”。

所以,如果 animal 中很多有用的属性和方法的话,在 rabbit 中也能得到。这些属性是“继承的”。

如果 animal 中有个方法,则在 rabbit 中也能调用:

  1. let animal = {
  2. eats: true,
  3. walk() {
  4. alert("Animal walk");
  5. }
  6. };
  7. let rabbit = {
  8. jumps: true,
  9. __proto__: animal
  10. };
  11. // walk 方法来自原型
  12. rabbit.walk(); // Animal Walk

walk 方法是在原型里定义的,我们能通过继承关系来调用。

原型继承 - 图3

当然,原型链还可以更长:

  1. let animal = {
  2. eats: true,
  3. walk() {
  4. alert("Animal walk");
  5. }
  6. };
  7. let rabbit = {
  8. jumps: true,
  9. __proto__: animal
  10. };
  11. let longEar = {
  12. earLength: 10,
  13. __proto__: rabbit
  14. }
  15. // walk 方法来自原型链
  16. longEar.walk(); // Animal walk
  17. alert(longEar.jumps); // true(来自 rabbit)

原型继承 - 图4

这种链式的关系存在两点限制:

  1. 不能循环引用。如果在 __proto__ 上循环引用的话,会报错。

  2. __proto__ 的值可以是一个对象,也可以为 null。使用其他类性值设置原型会被忽略。

还有一个很明显的限制,就是一个对象只可能有唯一一个 [[Prototype]]。也就是说,一个对象不可能同时有两个原型对象。

不能写入原型属性

我们只能读取原型属性,不能写入原型属性。写入/删除属性的操作是直接作用在对象上的。

下例中,rabbit 有自己的 walk 方法:

  1. let animal = {
  2. eats: true,
  3. walk() {
  4. /* 这个方法不会被 rabbit 调用 */
  5. }
  6. };
  7. let rabbit = {
  8. __proto__: animal
  9. };
  10. rabbit.walk = function() {
  11. alert("Rabbit! Bounce-bounce!");
  12. };
  13. rabbit.walk(); // Rabbit! Bounce-bounce!

现在,rabbit.walk() 调用直接在对象上就能找到 walk 并执行,原型上的同名方法因此不会被调用:

原型继承 - 图5

访问器属性是个例外。对访问器属性赋值,实际上是在调用它的 setter 函数。因此,对访问器属性赋值等于在调用函数。

下面代码中,admin.fullName 属性就可以被成功赋值。

  1. let user = {
  2. name: "John",
  3. surname: "Smith",
  4. set fullName(value) {
  5. [this.name, this.surname] = value.split(" ");
  6. },
  7. get fullName() {
  8. return `${this.name} ${this.surname}`;
  9. }
  10. };
  11. let admin = {
  12. __proto__: user,
  13. isAdmin: true
  14. };
  15. alert(admin.fullName); // John Smith (*)
  16. // 触发了 setter
  17. admin.fullName = "Alice Cooper"; // (**)

() 处访问 admin.fullName 会调用原型对象 user 里的 getter。(*) 处给 admin.fullName 赋值,会调用原型对象 user 里 setter。

this

看完上面的代码,你可能会存在一个疑问:set fullName(value) 里的 this 指向的是谁呢?user 还是 admin

答案很简单:this 一点也不受原型影响。

不管方法是在哪里找到:对象或原型里。方法中的 this 总是指向点(.)前面的那个对象。

因此,setter 调用 admin.fullName= 中的 this 指向的是 admin,而非 user

这实际上是一件非常重要的事情,因为我们可能有一个包含许多方法的大对象,有其他对象继承自这个大对象。当我们在继承对象上运行继承方法的时候,最总修改的是继承对象自身的状态,而不是大对象的。
**
下例中,animal 表示“存储方法的地方”,rabbit 使用了它的方法。

调用 rabbit.sleep(),函数体里的 this.isSleeping 含义是指设置 rabbit 对象上的 isSleeping 属性。

  1. let animal = {
  2. walk() {
  3. if (!this.isSleeping) {
  4. alert('I Walk、')
  5. }
  6. },
  7. sleep() {
  8. this.isSlleeping = true;
  9. }
  10. };
  11. let rabbit = {
  12. name: "White Rabbit",
  13. __proto__: animal
  14. };
  15. // 修改 rabbit.isSleeping
  16. rabbit.sleep();
  17. alert(rabbit.isSleeping); // true
  18. alert(animal.isSleeping); // undefined (在原型对象里没有这个属性)

上述对象的继承关系如下:

原型继承 - 图6

如果我们还有像 birdsnake 等其他的继承自 animal 的对象。它们也能访问 animal 中的方法。但在每个方法调用里的 this,都是指向各自调用对象的(即 . 运算符之前的对象),而非 animal。因此,当向 this 写入数据时,实际上是在向这些对象写入。

结果,方法共享了,对象状态也存在于各自的对象之中。

for…in 循环

for...in 循环也会遍历出继承属性。

例如:

  1. let animal = {
  2. eats: true
  3. };
  4. let rabbit = {
  5. jumps: true,
  6. __proto__: animal
  7. };
  8. // Object.keys 方法仅返回自身属性
  9. alert(Object.keys(rabbit)); // jumps
  10. // for..in 循环除了能返回自身属性,还会返回继承属性
  11. for(let prop in rabbit) alert(prop); // jumps, 然后是 eats

如果我们想要排除继承属性,可以使用内置方法 obj.hasOwnProperty(key) 实现:如果 key 是对象 obj 的自身属性就返回 true,否则 false

据此,我们就能过滤掉继承属性了(或用继承属性做其它事情)。

  1. let animal = {
  2. eats: true
  3. };
  4. let rabbit = {
  5. jumps: true,
  6. __proto__: animal
  7. };
  8. for(let prop in rabbit) {
  9. let isOwn = rabbit.hasOwnProperty(prop);
  10. if (isOwn) {
  11. alert(`Our: ${prop}`); // Our: jumps
  12. } else {
  13. alert(`Inherited: ${prop}`); // Inherited: eats
  14. }
  15. }

上述代码的原型链情况,如下图所示:rabbit 继承自 animalanimal 继承自 Object.prototype(因为这里的 animal 是用字面量形式 {...} 创建的),Object.prototype 的原型则为 null 了:

image.png

注意,这里有件有趣的地方。rabbit.hasOwnProperty 方法是来自哪里的呢?我们没有定义它。查看原型链,我们看见这个方法来自 Object.prototype.hasOwnProperty。换句话说,是继承过来的。

但为何 hasOwnProperty 没有像 eatsjumps 那样出现在 for...in 循环中呢?不是说 for...in 也会遍历继承属性吗?

答案也比较简单:因为 hasOwnProperty 是不可枚举的。Object.prototype 对象上的所有属性都标记了 enumerable: false,而 for...in 之会遍历出可枚举属性。这就是为何 Object.prototype 上的属性都没有遍历出来的原因。

💡 提示:**几乎所有键/值获取方法(key/value-getting methods**)都会忽略继承属性

几乎所有键/值获取方法,比如 Object.keysObject.values 这些都会忽略继承属性。

这些方法只操作对象本身,来自原型的属性不会考虑在内。

**

总结

  • JavaScript 中,所有的对象都包含一个隐藏的 [[Prototype]] 属性,它的值可能是一个对象或者 null

  • 我们可以使用 obj.__proto__ 属性访问它(这个属性其实是个历史遗留属性,实际上是基于 [[Prototype]] 的访问器属性,之后会介绍)。

  • [[Prototype]] 引用的对象称为“原型”。

  • 如果我们想访问 obj 的一个属性或调用它的一个方法,如果 obj 没有的话,JavaScript 就会去原型里查找。

  • 写入/删除操作是直接作用在对象上的,并不会涉及原型(假设是数据属性,而不是访问器属性的 setter)。
  • 如果我们调用 obj.method,并且 method 来自原型的话,方法内的 this 仍是指向 obj 的。所以说,即便调用的方法是继承的,可操作的还是对象自身。
  • for...in 循环既会遍历自身属性,也会遍历继承属性。几乎所有键/值获取方法都是操作的对象自身属性的。

(完)


📄 文档信息

🕘 更新时间:2020/01/14
🔗 原文链接:http://javascript.info/prototype-inheritance