原型链

__proto__

__proto__[[Prototype]] 的因历史原因而留下来的 getter/setter。
请注意,__proto__ 与内部的 [[Prototype]] 不一样__proto__[[Prototype]] 的 getter/setter。稍后,我们将看到在什么情况下理解它们很重要,在建立对 JavaScript 语言的理解时,让我们牢记这一点。
__proto__ 属性有点过时了。它的存在是出于历史的原因,现代编程语言建议我们应该使用函数 Object.getPrototypeOf/Object.setPrototypeOf 来取代 __proto__ 去 get/set 原型。 根据规范,__proto__ 必须仅受浏览器环境的支持。但实际上,包括服务端在内的所有环境都支持它,因此我们使用它是非常安全的。

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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)

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

这里只有两个限制:

  1. 引用不能形成闭环。如果我们试图在一个闭环中分配 __proto__,JavaScript 会抛出错误。
  2. __proto__ 的值可以是对象,也可以是 null。而其他的类型都会被忽略。

几乎所有其他键/值获取方法都忽略继承的属性.
几乎所有其他键/值获取方法,例如 Object.keysObject.values 等,都会忽略继承的属性。
它们只会对对象自身进行操作。不考虑 继承自原型的属性。

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

prototype

js
1
2
3
let obj = {};

alert(obj.__proto__ === Object.prototype); // true
js
1
2
3
4
5
6
7
8
9
10
let arr = [1, 2, 3];

// 它继承自 Array.prototype?
alert( arr.__proto__ === Array.prototype ); // true

// 接下来继承自 Object.prototype?
alert( arr.__proto__.__proto__ === Object.prototype ); // true

// 原型链的顶端为 null。
alert( arr.__proto__.__proto__.__proto__ ); // null
js
1
2
3
4
function f() {}

alert(f.__proto__ == Function.prototype); // true
alert(f.__proto__.__proto__ == Object.prototype); // true,继承自 Object

基本数据类型

最复杂的事情发生在字符串、数字和布尔值上。
正如我们记忆中的那样,它们并不是对象。但是如果我们试图访问它们的属性,那么临时包装器对象将会通过内建的构造器 StringNumberBoolean 被创建。它们提供给我们操作字符串、数字和布尔值的方法然后消失。
这些对象对我们来说是无形地创建出来的。大多数引擎都会对其进行优化,但是规范中描述的就是通过这种方式。这些对象的方法也驻留在它们的 prototype 中,可以通过 String.prototypeNumber.prototypeBoolean.prototype 进行获取。

特殊值 nullundefined 比较特殊。它们没有对象包装器,所以它们没有方法和属性。并且它们也没有相应的原型。

Object.setPrototypeOf and Object.getPrototypeOf

使用 obj.__proto__ 设置或读取原型被认为已经过时且不推荐使用(deprecated)了(已经被移至 JavaScript 规范的附录 B,意味着仅适用于浏览器)。

现代的获取/设置原型的方法有:

原型历史

  • 构造函数的 "prototype" 属性自古以来就起作用。这是使用给定原型创建对象的最古老的方式。
  • 之后,在 2012 年,Object.create 出现在标准中。它提供了使用给定原型创建对象的能力。但没有提供 get/set 它的能力。一些浏览器实现了非标准的 __proto__ 访问器,以为开发者提供更多的灵活性。
  • 之后,在 2015 年,Object.setPrototypeOfObject.getPrototypeOf 被加入到标准中,执行与 __proto__ 相同的功能。由于 __proto__ 实际上已经在所有地方都得到了实现,但它已过时,所以被加入到该标准的附件 B 中,即:在非浏览器环境下,它的支持是可选的。
  • 之后,在 2022 年,官方允许在对象字面量 {...} 中使用 __proto__(从附录 B 中移出来了),但不能用作 getter/setter obj.__proto__(仍在附录 B 中)。

如果速度很重要,就请不要修改已存在的对象的 [[Prototype]]
从技术上来讲,我们可以在任何时候 get/set [[Prototype]]。但是通常我们只在创建对象的时候设置它一次,自那之后不再修改:rabbit 继承自 animal,之后不再更改。
并且,JavaScript 引擎对此进行了高度优化。用 Object.setPrototypeOf 或 obj.__proto__= “即时”更改原型是一个非常缓慢的操作,因为它破坏了对象属性访问操作的内部优化。因此,除非你知道自己在做什么,或者 JavaScript 的执行速度对你来说完全不重要,否则请避免使用它。

原型只能是对象或者 null

js
1
2
3
4
5
6
let obj = {};

let key = prompt("What's the key?", "__proto__");
obj[key] = "some value";

alert(obj[key]); // [object Object],并不是 "some value"!
js
1
2
3
4
5
6
let obj = {};

let key = prompt("What's the key?", "__proto__");
obj[key] = new String("some value");

alert(obj[key]); // 这里却会是 "some value"!

__proto__ 属性很特殊:它必须是一个对象或者 null。字符串不能成为原型。这就是为什么将字符串赋值给 __proto__ 会被忽略。

创建无原型对象

Object.create(null){__proto__: null} 创建的无原型的对象。
对象会从 Object.prototype 继承内建的方法和 __proto__ getter/setter,会占用相应的键,且可能会导致副作用。原型为 null 时,对象才真正是空的。

[[HomeObject]]

当一个函数被定义为类或者对象方法时,它的 [[HomeObject]] 属性就成为了该对象。
然后 super 使用它来解析(resolve)父原型及其方法。

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
let animal = {
  name: "Animal",
  eat() {         // animal.eat.[[HomeObject]] == animal
    alert(`${this.name} eats.`);
  }
};

let rabbit = {
  __proto__: animal,
  name: "Rabbit",
  eat() {         // rabbit.eat.[[HomeObject]] == rabbit
    super.eat();
  }
};

let longEar = {
  __proto__: rabbit,
  name: "Long Ear",
  eat() {         // longEar.eat.[[HomeObject]] == longEar
    super.eat();
  }
};

// 正确执行
longEar.eat();  // Long Ear eats.

应该是方法而不是函数属性

[[HomeObject]] 是为类和普通对象中的方法定义的。但是对于对象而言,方法必须确切指定为 method(),而不是 "method: function()"
这个差别对我们来说可能不重要,但是对 JavaScript 来说却非常重要。
在下面的例子中,使用非方法(non-method)语法进行了比较。未设置 [[HomeObject]] 属性,并且继承无效:

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let animal = {
  eat: function() { // 这里是故意这样写的,而不是 eat() {...
    // ...
  }
};

let rabbit = {
  __proto__: animal,
  eat: function() {
    super.eat();
  }
};

rabbit.eat();  // 错误调用 super(因为这里没有 [[HomeObject]])

Symbol.species

以给类添加一个特殊的静态 getter Symbol.species。如果存在,则应返回 JavaScript 在内部用来在 mapfilter 等方法中创建新实体的 constructor
如果我们希望像 mapfilter 这样的内建方法返回常规数组,我们可以在 Symbol.species 中返回 Array,就像这样:

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class PowerArray extends Array {
  isEmpty() {
    return this.length === 0;
  }

  // 内建方法将使用这个作为 constructor
  static get [Symbol.species]() {
    return Array;
  }
}

let arr = new PowerArray(1, 2, 5, 10, 50);
alert(arr.isEmpty()); // false

// filter 使用 arr.constructor[Symbol.species] 作为 constructor 创建新数组
let filteredArr = arr.filter(item => item >= 10);

// filteredArr 不是 PowerArray,而是 Array
alert(filteredArr.isEmpty()); // Error: filteredArr.isEmpty is not a function

内建类没有静态方法继承
内建对象有它们自己的静态方法,例如 Object.keysArray.isArray 等。
如我们所知道的,原生的类互相扩展。例如,Array 扩展自 Object

Symbol.hasInstance

obj instanceof Class 算法的执行过程大致如下:

  1. 如果这儿有静态方法 Symbol.hasInstance,那就直接调用这个方法:
    例如:
    js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 设置 instanceOf 检查
    // 并假设具有 canEat 属性的都是 animal
    class Animal {
      static [Symbol.hasInstance](obj) {
        if (obj.canEat) return true;
      }
    }
    let obj = { canEat: true };
    alert(obj instanceof Animal); // true:Animal[Symbol.hasInstance](obj) 被调用
  2. 大多数 class 没有 Symbol.hasInstance。在这种情况下,标准的逻辑是:使用 obj instanceOf Class 检查 Class.prototype 是否等于 obj 的原型链中的原型之一。
    换句话说就是,一个接一个地比较:
    js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    obj.__proto__ === Class.prototype?
    obj.__proto__.__proto__ === Class.prototype?
    obj.__proto__.__proto__.__proto__ === Class.prototype?
    ...
    // 如果任意一个的答案为 true,则返回 true
    // 否则,如果我们已经检查到了原型链的尾端,则返回 false
    
    在上面那个例子中,`rabbit.__proto__ === Rabbit.prototype`,所以立即就给出了结果。
    
    而在继承的例子中,匹配将在第二步进行:
    
    class Animal {}
    class Rabbit extends Animal {}
    
    let rabbit = new Rabbit();
    alert(rabbit instanceof Animal); // true
    
    // rabbit.__proto__ === Animal.prototype(无匹配)
    // rabbit.__proto__.__proto__ === Animal.prototype(匹配!)

这里还要提到一个方法 objA.isPrototypeOf(objB),如果 objA 处在 objB 的原型链中,则返回 true。所以,可以将 obj instanceof Class 检查改为 Class.prototype.isPrototypeOf(obj)
但是 Class 的 constructor 自身是不参与检查的!检查过程只和原型链以及 Class.prototype 有关。
创建对象后,如果更改 prototype 属性,可能会导致有趣的结果。
就像这样:

js
1
2
3
4
5
6
7
8
function Rabbit() {}
let rabbit = new Rabbit();

// 修改了 prototype
Rabbit.prototype = {};

// ...再也不是 rabbit 了!
alert( rabbit instanceof Rabbit ); // false

Object.prototype.toString.call

内建的 toString 方法可以被从对象中提取出来,并在任何其他值的上下文中执行。其结果取决于该值。

  • 对于 number 类型,结果是 [object Number]
  • 对于 boolean 类型,结果是 [object Boolean]
  • 对于 null[object Null]
  • 对于 undefined[object Undefined]
  • 对于数组:[object Array]
  • ……等(可自定义)
js
1
2
3
4
5
let s = Object.prototype.toString;

alert( s.call(123) ); // [object Number]
alert( s.call(null) ); // [object Null]
alert( s.call(alert) ); // [object Function]

Symbol.toStringTag

可以使用特殊的对象属性 Symbol.toStringTag 自定义对象的 toString 方法的行为。
例如:

js
1
2
3
4
5
let user = {
  [Symbol.toStringTag]: "User"
};

alert( {}.toString.call(user) ); // [object User]