第五章: 原型 - Prototype

优质
小牛编辑
129浏览
2023-12-01

[[Prototype]]

JavaScript 中的对象有一个内部属性,在语言规范中称为 [[Prototype]],它只是一个其他对象的引用。几乎所有的对象在被创建时,它的这个属性都被赋予了一个非 null 值。

注意: 我们马上就会看到,一个对象拥有一个空的 [[Prototype]] 链接是 可能 的,虽然这有些不寻常。

考虑下面的代码:

  1. var myObject = {
  2. a: 2
  3. };
  4. myObject.a; // 2

[[Prototype]] 引用有什么用?在第三章中,我们讲解了 [[Get]] 操作,它会在你引用一个对象上的属性时被调用,比如 myObject.a。对于默认的 [[Get]] 操作来说,第一步就是检查对象本身是否拥有一个 a 属性,如果有,就使用它。

注意: ES6 的代理(Proxy)超出了我们要在本书内讨论的范围(将会在本系列的后续书目中涵盖!),但是如果加入 Proxy,我们在这里讨论的关于普通 [[Get]][[Put]] 的行为都是不被采用的。

但是如果 myObject 存在 a 属性时,我们就将注意力转向对象的 [[Prototype]] 链。

如果默认的 [[Get]] 操作不能直接在对象上找到被请求的属性,那么它会沿着对象的 [[Prototype]] 继续处理。

  1. var anotherObject = {
  2. a: 2
  3. };
  4. // 创建一个链接到 `anotherObject` 的对象
  5. var myObject = Object.create( anotherObject );
  6. myObject.a; // 2

注意: 我们马上就会解释 Object.create(..) 是做什么,如何做的。眼下先假设,它创建了一个对象,这个对象带有一个链到指定对象的 [[Prototype]] 链接,这个链接就是我们要讲解的。

那么,我们现在让 myObject [[Prototype]] 链到了 anotherObject。虽然很明显 myObject.a 实际上不存在,但是无论如何属性访问成功了(在 anotherObject 中找到了),而且确实找到了值 2

但是,如果在 anotherObject 上也没有找到 a,而且如果它的 [[Prototype]] 链不为空,就沿着它继续查找。

这个处理持续进行,直到找到名称匹配的属性,或者 [[Prototype]] 链终结。如果在链条的末尾都没有找到匹配的属性,那么 [[Get]] 操作的返回结果为 undefined

和这种 [[Prototype]] 链查询处理相似,如果你使用 for..in 循环迭代一个对象,所有在它的链条上可以到达的(并且是 enumerable —— 见第三章)属性都会被枚举。如果你使用 in 操作符来测试一个属性在一个对象上的存在性,in 将会检查对象的整个链条(不管 可枚举性)。

  1. var anotherObject = {
  2. a: 2
  3. };
  4. // 创建一个链接到 `anotherObject` 的对象
  5. var myObject = Object.create( anotherObject );
  6. for (var k in myObject) {
  7. console.log("found: " + k);
  8. }
  9. // 找到: a
  10. ("a" in myObject); // true

所以,当你以各种方式进行属性查询时,[[Prototype]] 链就会一个链接一个链接地被查询。一旦找到属性或者链条终结,这种查询会就会停止。

Object.prototype

但是 [[Prototype]] 链到底在 哪里 “终结”?

每个 普通[[Prototype]] 链的最顶端,是内建的 Object.prototype。这个对象包含各种在整个 JS 中被使用的共通工具,因为 JavaScript 中所有普通(内建,而非被宿主环境扩展的)的对象都“衍生自”(也就是,使它们的 [[Prototype]] 顶端为)Object.prototype 对象。

你会在这里发现一些你可能很熟悉的工具,比如 .toString().valueOf()。在第三章中,我们介绍了另一个:.hasOwnProperty(..)。还有另外一个你可能不太熟悉,但我们将在这一章里讨论的 Object.prototype 上的函数是 .isPrototypeOf(..)

设置与遮蔽属性

回到第三章,我们提到过在对象上设置属性要比仅仅在对象上添加新属性或改变既存属性的值更加微妙。现在我们将更完整地重温这个话题。

  1. myObject.foo = "bar";

如果 myObject 对象已直接经拥有了普通的名为 foo 的数据访问器属性,那么这个赋值就和改变既存属性的值一样简单。

如果 foo 还没有直接存在于 myObject[[Prototype]] 就会被遍历,就像 [[Get]] 操作那样。如果在链条的任何地方都没有找到 foo,那么就会像我们期望的那样,属性 foo 就以指定的值被直接添加到 myObject 上。

然而,如果 foo 已经存在于链条更高层的某处,myObject.foo = "bar" 赋值就可能会发生微妙的(也许令人诧异的)行为。我们一会儿就详细讲解。

如果属性名 foo 同时存在于 myObject 本身和从 myObject 开始的 [[Prototype]] 链的更高层,这样的情况称为 遮蔽。直接存在于 myObject 上的 foo 属性会 遮蔽 任何出现在链条高层的 foo 属性,因为 myObject.foo 查询总是在寻找链条最底层的 foo 属性。

正如我们被暗示的那样,在 myObject 上的 foo 遮蔽没有看起来那么简单。我们现在来考察 myObject.foo = "bar" 赋值的三种场景,当 foo 不直接存在myObject,但 存在myObject[[Prototype]] 链的更高层时:

  1. 如果一个普通的名为 foo 的数据访问属性在 [[Prototype]] 链的高层某处被找到,而且没有被标记为只读(writable:false,那么一个名为 foo 的新属性就直接添加到 myObject 上,形成一个 遮蔽属性
  2. 如果一个 foo[[Prototype]] 链的高层某处被找到,但是它被标记为 只读(writable:false ,那么设置既存属性和在 myObject 上创建遮蔽属性都是 不允许 的。如果代码运行在 strict mode 下,一个错误会被抛出。否则,这个设置属性值的操作会被无声地忽略。不论怎样,没有发生遮蔽
  3. 如果一个 foo[[Prototype]] 链的高层某处被找到,而且它是一个 setter(见第三章),那么这个 setter 总是被调用。没有 foo 会被添加到(也就是遮蔽在)myObject 上,这个 foo setter 也不会被重定义。

大多数开发者认为,如果一个属性已经存在于 [[Prototype]] 链的高层,那么对它的赋值([[Put]])将总是造成遮蔽。但如你所见,这仅在刚才描述的三中场景中的一种(第一种)中是对的。

如果你想在第二和第三种情况中遮蔽 foo,那你就不能使用 = 赋值,而必须使用 Object.defineProperty(..)(见第三章)将 foo 添加到 myObject

注意: 第二种情况可能是三种情况中最让人诧异的了。只读 属性的存在会阻止同名属性在 [[Prototype]] 链的低层被创建(遮蔽)。这个限制的主要原因是为了增强类继承属性的幻觉。如果你想象位于链条高层的 foo 被继承(拷贝)至 myObject, 那么在 myObject 上强制 foo 属性不可写就有道理。但如果你将幻觉和现实分开,而且认识到 实际上 没有这样的继承拷贝发生(见第四,五章),那么仅因为某些其他的对象上拥有不可写的 foo,而导致 myObject 不能拥有 foo 属性就有些不自然。而且更奇怪的是,这个限制仅限于 = 赋值,当使用 Object.defineProperty(..) 时不被强制。

如果你需要在方法间进行委托,方法 的遮蔽会导致难看的 显式假想多态(见第四章)。一般来说,遮蔽与它带来的好处相比太过复杂和微妙了,所以你应当尽量避免它。第六章介绍另一种设计模式,它提倡干净而且不鼓励遮蔽。

遮蔽甚至会以微妙的方式隐含地发生,所以要想避免它必须小心。考虑这段代码:

  1. var anotherObject = {
  2. a: 2
  3. };
  4. var myObject = Object.create( anotherObject );
  5. anotherObject.a; // 2
  6. myObject.a; // 2
  7. anotherObject.hasOwnProperty( "a" ); // true
  8. myObject.hasOwnProperty( "a" ); // false
  9. myObject.a++; // 噢,隐式遮蔽!
  10. anotherObject.a; // 2
  11. myObject.a; // 3
  12. myObject.hasOwnProperty( "a" ); // true

虽然看起来 myObject.a++ 应当(通过委托)查询并 原地 递增 anotherObject.a 属性,但是 ++ 操作符相当于 myObject.a = myObject.a + 1。结果就是在 [[Prototype]] 上进行 a[[Get]] 查询,从 anotherObject.a 得到当前的值 2,将这个值递增1,然后将值 3[[Put]] 赋值到 myObject 上的新遮蔽属性 a 上。噢!

修改你的委托属性时要非常小心。如果你想递增 anotherObject.a, 那么唯一正确的方法是 anotherObject.a++