本资料仅提供以下语言版本:English, 日本語, Русский。请 帮助我们 将其翻译为 简体中文 版本。

原型继承

在编程中,我们经常想要获取并扩展一些事情。

例如,我们有一个 user 对象及其属性和方法。而且希望将 adminguest 作为它稍加修改的变体。我们希望重用 user 所拥有的内容,而不是复制/实现它的方法,只需要在其上创建一个新的对象。

原型继承的语言特性可以帮助我们实现这一需求。

[[Prototype]]

在 JavaScript 中, 对象有一个特殊的隐藏属性 [[Prototype]](如规范中说描述的),即 null 或者是另一个引用对象。该对象叫作 “a prototype”:

[[Prototype]] 有一个“神奇”的意义。当我们想要从 object 中读取一个属性时,它就丢失了。JavaScript 会自动从原型中获取它。在编程中,这样的事情称为“原型继承”。许多很酷的语言特性和编程技巧都是基于它的。

属性 [[Prototype]] 是内部的而且隐藏的,但是设置它的方法却有很多种。

其中之一是使用 __proto__,就像这样:

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

rabbit.__proto__ = animal;

请注意 __proto__[[Prototype]] 不一样。这是一个 getter/setter。我们之后会讨论如何设置它,但是现在 __proto__ 工作的很好。

如果我们在 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" 的原型或者说 "rabbit 的原型继承自 animal"。

因此如果 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)

实际上只有两个限制:

  1. 引用不能形成闭环。如果我们试图在一个闭环中分配 __proto__,JavaScript 会抛出异常。
  2. __proto__ 的值可以是对象,也可以是 null。所有其他的值(例如原语)都会被忽略。

这也可能是显而易见的,即:只能有一个 [[Prototype]]。对象不能继承自其他两个对象。

读写规则

原型仅用于读取属性。

对于数据属性(不是 getters/setters)写/删除操作直接在对象上进行操作。

在以下示例中,我们将 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() 调用将立即在对象中找到方法并执行它,而不是使用原型:

对于 getters/setters —— 如果我们读写一个属性,就会在原型中查找并调用它们。

例如,在以下代码中检查出 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"; // (**)

这里的 (*) 属性 admin.fullName 在原型 user 中有一个 getter,因此它会被调用。在 (**) 中,属性在原型中有一个 setter,因此它会被调用。

“this” 的值

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

答案很简单:this 根本不受原型的影响。

无论在哪里找到方法:在对象或者原型中。调用方法时,this 始终是点之前的对象。

因此,实际上 setter 使用 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)

结果:

如果我们从 animal 继承像 birdsnake 等其他对象,他们也将获取 animal 的方法。但是每个方法 this 都是相应的对象,而不是 animal,在调用时(在点之前)确定。因此当我们将数据写入 this 时,它会被存储进这些对象中。

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

总结

  • JavaScript 中,所有的对象都有一个隐藏的 [[Prototype]] 属性,它可以是另一个对象或者 null
  • 我们可以使用 obj.__proto__ 进行访问(还有其他方法,但很快就会被覆盖)。
  • [[Prototype]] 引用的对象称为“原型”。
  • 如果我们想要读取 obj 属性或者调用一个方法,而且它不存在,那么 JavaScript 就会尝试在原型中查找它。写/删除直接在对象上进行操作,它们不使用原型(除非属性实际上是一个 setter)。
  • 如果我们调用 obj.method(),而且 method 是从原型中获取的,this 仍然会引用 obj。因此方法重视与当前对象一起工作,即使它们是继承的。

任务

重要程度: 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. 回答问题:如果需要检测的话,将 glasses 作为 pockets.glasses 更快还是作为 head.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

rabbit 继承自 animal

如果我们调用 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 ,将食物添加到胃的原型链中。

因此所有的仓鼠都有共享一个胃!

每次 stomach 从原型中获取,然后 stomach.push 修改它的“位置”。

请注意,这种情况在 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,通常都被写入到该对象中。这防止了类似问题的出现。

教程路线图

评论

在评论之前先阅读本内容…
  • 欢迎你在文章下添加补充内容、提出你的问题或回答提出的问题。
  • 使用 <code> 标签插入几行代码,对于多行代码 — 可以使用 <pre>,对于超过十行的代码 — 建议使用沙箱(plnkrJSBincodepen 等)。
  • 如果你无法理解文章中的内容 — 请详细说明。