原文链接:javascript.info/prototype-i…

说明:本文摘自我正在翻译的《现代 JavaScript 教程》,翻译来源是 javascript.info。虽然已经有官方中文在推进了,但不够好看。我不是严格按照原文翻译的,有些地方做了少许修改,以求达到流畅的中文阅读体验。

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

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

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

[[Prototype]]

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

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

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

方法之一是使用 __proto__

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

rabbit.__proto__ = animal;
复制代码

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

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

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

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

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

例如:

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

rabbit.__proto__ = animal; // (*)

// eats 和 jumps 属性都能在 rabbit 上访问到 
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true
复制代码

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

然后使用 rabbit.eats(**) 读取属性 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 方法来自原型
rabbit.walk(); // Animal Walk
复制代码

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

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

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

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

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

// walk 方法来自原型链
longEar.walk(); // Animal walk
alert(longEar.jumps); // true(来自 rabbit)
复制代码

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

  1. 不能循环引用。如果在 __proto__ 上循环引用的话,会报错。
  2. __proto__ 的值可以是一个对象,也可以为 null。使用其他类性值设置原型会被忽略。

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

不能写入原型属性

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

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

let animal = {
  eats: true,
  walk() {
    /* 这个方法不会被 rabbit 调用 */
  }
};

let rabbit = {
  __proto__: animal
};

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

rabbit.walk(); // Rabbit! Bounce-bounce!
复制代码

现在,rabbit.walk() 调用直接在对象上就能找到 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
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  属性。

let animal = {
  walk() {
    if (!this.isSleeping) {
      alert('I Walk、')
    }
  },
  sleep() {
    this.isSlleeping = true;
  }
};

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

// 修改 rabbit.isSleeping
rabbit.sleep();
alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined (在原型对象里没有这个属性)
复制代码

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

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

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

for...in 循环

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

例如:

let animal = {
  eats: true
};

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

// Object.keys 方法仅返回自身属性
alert(Object.keys(rabbit)); // jumps

// for..in 循环除了能返回自身属性,还会返回继承属性
for(let prop in rabbit) alert(prop); // jumps, 然后是 eats
复制代码

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

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

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 继承自 animalanimal 继承自 Object.prototype(因为这里的 animal 是用字面量形式 {...} 创建的),Object.prototype 的原型则为 null 了:

注意,这里有件有趣的地方。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 循环既会遍历自身属性,也会遍历继承属性。几乎所有键/值获取方法都是操作的对象自身属性的。

(正文完)


广告时间(长期有效)

我有一位好朋友开了一间猫舍,在此帮她宣传一下。现在猫舍里养的都是布偶猫。如果你也是个猫奴并且有需要的话,不妨扫一扫她的【闲鱼】二维码。不买也不要紧,看看也行。

(完)