2022 年 5 月 6 日

原型继承

在编程中,我们经常希望获取某些内容并对其进行扩展。

例如,我们有一个具有其属性和方法的 user 对象,并希望将 adminguest 作为其经过轻微修改的变体。我们希望重复使用我们在 user 中的内容,而不是复制/重新实现其方法,只需在其之上构建一个新对象。

原型继承 是一个有助于实现此目的的语言特性。

[[Prototype]]

在 JavaScript 中,对象有一个特殊的隐藏属性 [[Prototype]](如规范中所述),该属性要么为 null,要么引用另一个对象。该对象称为“原型”

当我们从 object 读取一个属性,并且该属性不存在时,JavaScript 会自动从原型中获取它。在编程中,这称为“原型继承”。我们很快将学习此类继承的许多示例,以及建立在其之上的更酷的语言特性。

属性 [[Prototype]] 是内部的并且是隐藏的,但是有很多方法可以设置它。

其中一种方法是使用特殊名称 __proto__,如下所示

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // sets rabbit.[[Prototype]] = animal

现在,如果我们从 rabbit 中读取一个属性,并且它不存在,JavaScript 将自动从 animal 中获取它。

例如

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // (*)

// we can find both properties in rabbit now:
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true

这里的行 (*)animal 设置为 rabbit 的原型。

然后,当 alert 尝试读取属性 rabbit.eats (**) 时,它不在 rabbit 中,因此 JavaScript 遵循 [[Prototype]] 引用并在 animal 中找到它(从下往上看)

在这里我们可以说“animalrabbit 的原型”或“rabbitanimal 中继承原型”。

因此,如果 animal 具有很多有用的属性和方法,那么它们将自动在 rabbit 中可用。此类属性称为“继承的”。

如果我们在 animal 中有一个方法,则可以在 rabbit 上调用它

let animal = {
  eats: true,
  walk() {
    alert("Animal walk");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// walk is taken from the prototype
rabbit.walk(); // Animal walk

该方法将自动从原型中获取,如下所示

原型链可以更长

let animal = {
  eats: true,
  walk() {
    alert("Animal walk");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

let longEar = {
  earLength: 10,
  __proto__: rabbit
};

// walk is taken from the prototype chain
longEar.walk(); // Animal walk
alert(longEar.jumps); // true (from rabbit)

现在,如果我们从 longEar 中读取一些内容,并且它不存在,JavaScript 将在 rabbit 中查找它,然后在 animal 中查找它。

只有两个限制

  1. 引用不能循环。如果我们尝试以循环方式分配 __proto__,JavaScript 将抛出错误。
  2. __proto__ 的值可以是对象或 null。其他类型将被忽略。

它可能很明显,但仍然:只能有一个 [[Prototype]]。一个对象不能从两个其他对象中继承。

__proto__[[Prototype]] 的历史 getter/setter

初学者开发者不知道这两个之间的区别是一个常见的错误。

请注意,__proto__ 内部 [[Prototype]] 属性不同。它是 [[Prototype]] 的 getter/setter。稍后我们将看到它重要的场景,现在让我们记住它,因为我们在构建对 JavaScript 语言的理解。

__proto__ 属性有点过时。它存在于历史原因,现代 JavaScript 建议我们使用 Object.getPrototypeOf/Object.setPrototypeOf 函数来获取/设置原型。我们稍后也会介绍这些函数。

根据规范,__proto__ 只能由浏览器支持。但事实上,包括服务器端在内的所有环境都支持 __proto__,因此我们使用它非常安全。

由于 __proto__ 表示法更直观,我们在示例中使用它。

写入不使用原型

原型仅用于读取属性。

写入/删除操作直接作用于对象。

在下面的示例中,我们将自己的 walk 方法分配给 rabbit

let animal = {
  eats: true,
  walk() {
    /* this method won't be used by rabbit */
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.walk = function() {
  alert("Rabbit! Bounce-bounce!");
};

rabbit.walk(); // Rabbit! Bounce-bounce!

从现在开始,rabbit.walk() 调用会在对象中立即找到该方法并执行它,而无需使用原型

访问器属性是一个例外,因为赋值由 setter 函数处理。因此,写入此类属性实际上与调用函数相同。

因此,admin.fullName 在下面的代码中可以正常工作

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
};

alert(admin.fullName); // John Smith (*)

// setter triggers!
admin.fullName = "Alice Cooper"; // (**)

alert(admin.fullName); // Alice Cooper, state of admin modified
alert(user.fullName); // John Smith, state of user protected

在行 (*) 中,属性 admin.fullName 在原型 user 中有一个 getter,因此会调用它。而在行 (**) 中,该属性在原型中有一个 setter,因此会调用它。

“this” 的值

在上面的示例中可能会出现一个有趣的问题:set fullName(value)this 的值是什么?属性 this.namethis.surname 写入到哪里:user 还是 admin

答案很简单:this 完全不受原型影响。

无论在何处找到该方法:在对象还是其原型中。在方法调用中,this 始终是点之前的对象。

因此,setter 调用 admin.fullName= 使用 admin 作为 this,而不是 user

这实际上是一件非常重要的事情,因为我们可能有一个包含许多方法的大对象,并拥有从它继承的对象。当继承对象运行继承方法时,它们将只修改它们自己的状态,而不是大对象的状态。

例如,这里 animal 表示一个“方法存储”,而 rabbit 使用它。

调用 rabbit.sleep()rabbit 对象上设置 this.isSleeping

// animal has methods
let animal = {
  walk() {
    if (!this.isSleeping) {
      alert(`I walk`);
    }
  },
  sleep() {
    this.isSleeping = true;
  }
};

let rabbit = {
  name: "White Rabbit",
  __proto__: animal
};

// modifies rabbit.isSleeping
rabbit.sleep();

alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined (no such property in the prototype)

结果图

如果我们有其他对象,如 birdsnake 等从 animal 继承,它们也将获得对 animal 方法的访问权限。但在每个方法调用中,this 将是相应对象,在调用时(点之前)进行评估,而不是 animal。因此,当我们将数据写入 this 时,它将存储到这些对象中。

因此,方法是共享的,但对象状态不是。

for…in 循环

for..in 循环也会迭代继承的属性。

例如

let animal = {
  eats: true
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// Object.keys only returns own keys
alert(Object.keys(rabbit)); // jumps

// for..in loops over both own and inherited keys
for(let prop in rabbit) alert(prop); // jumps, then eats

如果这不是我们想要的,并且我们希望排除继承的属性,那么有一个内置方法 obj.hasOwnProperty(key):如果 obj 有自己的(未继承的)名为 key 的属性,则返回 true

因此,我们可以过滤掉继承的属性(或对它们执行其他操作)

let animal = {
  eats: true
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

for(let prop in rabbit) {
  let isOwn = rabbit.hasOwnProperty(prop);

  if (isOwn) {
    alert(`Our: ${prop}`); // Our: jumps
  } else {
    alert(`Inherited: ${prop}`); // Inherited: eats
  }
}

这里我们有以下继承链:rabbit 继承自 animal,后者继承自 Object.prototype(因为 animal 是一个字面量对象 {...},所以它默认如此),然后是它上面的 null

请注意,有一件有趣的事情。rabbit.hasOwnProperty 方法来自哪里?我们没有定义它。查看链,我们可以看到该方法由 Object.prototype.hasOwnProperty 提供。换句话说,它是继承的。

…但是,如果 for..in 列出继承的属性,为什么 hasOwnProperty 不像 eatsjumps 那样出现在 for..in 循环中?

答案很简单:它不可枚举。就像 Object.prototype 的所有其他属性一样,它有 enumerable:false 标志。而 for..in 只列出可枚举的属性。这就是为什么它和 Object.prototype 的其他属性没有列出的原因。

几乎所有其他键/值获取方法都忽略继承的属性

几乎所有其他键/值获取方法,例如 Object.keysObject.values 等都忽略继承的属性。

它们只对对象本身进行操作。不考虑原型中的属性。

总结

  • 在 JavaScript 中,所有对象都有一个隐藏的 [[Prototype]] 属性,它可能是另一个对象或 null
  • 我们可以使用 obj.__proto__ 访问它(一个历史悠久的 getter/setter,还有其他方法,很快就会介绍)。
  • [[Prototype]] 引用的对象称为“原型”。
  • 如果我们想读取 obj 的属性或调用方法,但它不存在,那么 JavaScript 会尝试在原型中找到它。
  • 写/删操作直接作用于对象,它们不使用原型(假设它是一个数据属性,而不是一个 setter)。
  • 如果我们调用 obj.method(),并且 method 是从原型中获取的,则 this 仍然引用 obj。因此,即使方法是继承的,它们也始终与当前对象一起工作。
  • for..in 循环会遍历它自己的和它继承的属性。所有其他键/值获取方法仅对对象本身进行操作。

任务

重要性:5

以下是创建一对对象并对其进行修改的代码。

在此过程中显示哪些值?

let animal = {
  jumps: null
};
let rabbit = {
  __proto__: animal,
  jumps: true
};

alert( rabbit.jumps ); // ? (1)

delete rabbit.jumps;

alert( rabbit.jumps ); // ? (2)

delete animal.jumps;

alert( rabbit.jumps ); // ? (3)

应该有 3 个答案。

  1. true,取自 rabbit
  2. null,取自 animal
  3. undefined,不再有这样的属性。
重要性:5

任务分为两部分。

给定以下对象

let head = {
  glasses: 1
};

let table = {
  pen: 3
};

let bed = {
  sheet: 1,
  pillow: 2
};

let pockets = {
  money: 2000
};
  1. 使用 __proto__ 以一种方式分配原型,即任何属性查找都将遵循以下路径:pocketsbedtablehead。例如,pockets.pen 应为 3(在 table 中找到),而 bed.glasses 应为 1(在 head 中找到)。
  2. 回答问题:作为 pockets.glasses 还是 head.glasses 获取 glasses 更快?如有需要,请进行基准测试。
  1. 让我们添加 __proto__

    let head = {
      glasses: 1
    };
    
    let table = {
      pen: 3,
      __proto__: head
    };
    
    let bed = {
      sheet: 1,
      pillow: 2,
      __proto__: table
    };
    
    let pockets = {
      money: 2000,
      __proto__: bed
    };
    
    alert( pockets.pen ); // 3
    alert( bed.glasses ); // 1
    alert( table.money ); // undefined
  2. 在现代引擎中,从对象或其原型获取属性在性能方面没有区别。它们会记住属性在哪里找到,并在下一次请求中重复使用它。

    例如,对于 pockets.glasses,它们会记住在何处找到 glasses(在 head 中),下次将直接在那里搜索。它们还足够智能,可以在发生更改时更新内部缓存,以便优化是安全的。

重要性:5

我们有从 animal 继承的 rabbit

如果我们调用 rabbit.eat(),哪个对象将接收 full 属性:animal 还是 rabbit

let animal = {
  eat() {
    this.full = true;
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.eat();

答案:rabbit

这是因为 this 是点之前的对象,所以 rabbit.eat() 会修改 rabbit

属性查找和执行是两件不同的事情。

方法 rabbit.eat 首先在原型中找到,然后使用 this=rabbit 执行。

重要性:5

我们有两隻仓鼠:speedylazy,它们继承自通用的 hamster 对象。

当我们喂其中一只时,另一只也吃饱了。为什么?我们如何解决这个问题?

let hamster = {
  stomach: [],

  eat(food) {
    this.stomach.push(food);
  }
};

let speedy = {
  __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

// This one found the food
speedy.eat("apple");
alert( speedy.stomach ); // apple

// This one also has it, why? fix please.
alert( lazy.stomach ); // apple

让我们仔细看看调用 speedy.eat("apple") 时发生了什么。

  1. 方法 speedy.eat 在原型(=hamster)中找到,然后使用 this=speedy(点之前的对象)执行。

  2. 然后 this.stomach.push() 需要找到 stomach 属性并在其上调用 push。它在 this=speedy)中查找 stomach,但什么也没找到。

  3. 然后它沿着原型链查找并在 hamster 中找到 stomach

  4. 然后它在其上调用 push,将食物添加到原型的胃中。

因此,所有仓鼠共用一个胃!

对于 lazy.stomach.push(...)speedy.stomach.push(),属性 stomach 都在原型中找到(因为它不在对象本身中),然后将新数据推入其中。

请注意,在简单赋值 this.stomach= 的情况下不会发生这种情况。

let hamster = {
  stomach: [],

  eat(food) {
    // assign to this.stomach instead of this.stomach.push
    this.stomach = [food];
  }
};

let speedy = {
   __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

// Speedy one found the food
speedy.eat("apple");
alert( speedy.stomach ); // apple

// Lazy one's stomach is empty
alert( lazy.stomach ); // <nothing>

现在一切都很好,因为 this.stomach= 不会执行 stomach 的查找。该值直接写入 this 对象。

我们还可以通过确保每只仓鼠都有自己的胃来完全避免这个问题。

let hamster = {
  stomach: [],

  eat(food) {
    this.stomach.push(food);
  }
};

let speedy = {
  __proto__: hamster,
  stomach: []
};

let lazy = {
  __proto__: hamster,
  stomach: []
};

// Speedy one found the food
speedy.eat("apple");
alert( speedy.stomach ); // apple

// Lazy one's stomach is empty
alert( lazy.stomach ); // <nothing>

作为一种通用的解决方案,所有描述特定对象状态的属性(如上面的 stomach)都应写入该对象。这可以防止此类问题。

教程地图

评论

在评论之前阅读此内容…
  • 如果您有改进建议 - 请 提交 GitHub 问题 或提交拉取请求,而不是发表评论。
  • 如果您无法理解文章中的某些内容 - 请详细说明。
  • 要插入几行代码,请使用 <code> 标签,对于多行 - 将其包装在 <pre> 标签中,对于超过 10 行 - 使用沙盒(plnkrjsbincodepen…)