原型

JavaScript中的对象有一个特殊的 [[prototype]] 内置属性,其实就是对于其他对象的引用。几乎所有的对象在创建时 [[prototype]] 属性都会被赋予一个非空的值。

[[prototype]] 引用有什么用呢?

当你试图引用对象的属性时会触发 [[Get]] 操作。对于默认的 [[Get]] 操作来说,第一步是检查对象本身是由有这个属性,如果有的话就使用它,如果没有的话,就会继续访问对象的 [[prototype]] 链。

Object.prototype

所有普通的 [[prototype]] 链最终都会指向内置的 Object.prototype

由于所有的 “普通”(内置,不是特定主机的扩展)对象都 “源于”(或者说把 [[prototype]] 链的顶端设置为)这个 Object.prototype 对象,所以它包含 JavaScript 中许多通用的功能。

属性设置和屏蔽

给一个对象设置属性并不仅仅是添加一个属性或者修改已有的属性值

1myObject.foo = "bar";

如果 myObject 对象中包含名为 foo 的普通数据访问属性,这条赋值语句只会修改已有的属性值。

如果属性名即出现在 myObject中也出现在myObject的[[prototype]]链上层,那么就会发生屏蔽。myObject中包含的foo属性会屏蔽原型链上层的所有foo属性,因为 myObject.foo总会选择原型链最底层的foo属性。

如果 foo 不是直接存在于 myObject 中,[[prototype]] 链就会被遍历,类似 [[Get]] 操作。如果原型链上找不到foo,foo就会被直接添加到 myObject上。

然而,如果foo存在于原型链上层,会出现三种情况:

  1. 如果在 [[prototype]]链上层存在名为foo的普通数据访问属性并且没有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo的新属性,它是屏蔽属性。

  2. 如果[[prototype]]链上存在foo,但是它被标记为只读(writable:false),那么无法修改已有属性或者在 myObject上创建屏蔽属性。如果运行在严格模式下,代码会抛出错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。

    • 只读属性会阻止[[prototype]]链下层隐式创建(屏蔽)同名属性。
  3. 如果在 [[prototype]] 链上层存在foo并且它是一个setter,那就一定会调用这个 setter。foo不会被添加到(或者说屏蔽于)myObject,也不会重新定义foo这个setter。

大多数开发者都认为如果向[[prototype]]链上层已经存在属性([[put]])赋值,就一定会触发屏蔽,但是三种情况中只有第一种是这样的,如果你希望第二种和第三种情况下也屏蔽foo,那就不能使用 = 操作符来赋值,而是使用 Object.defineProperty() 向 myObject 添加 foo。

为什一个对象需要关联到另一个对象?这样做有什么好处?

**JavaScript中只有对象。**实际上 JavaScript才是真正应该被称为「面向对象」的语言,因为它是少有的可以不通过类,直接创建对象的语言。

在 JavaScript中,类无法描述对象的行为(因为根本不存在类),对象直接定义自己的行为。

类函数

函数的特殊特性:所有的函数默认都会拥有一个名为 prototype 的公共并且不可枚举的属性,它会指向另一个对象,这个对象通常被称为该函数的原型。

通过 new Foo() 创建的每个对象将最终被 [[prototype]] 连接到这个 Foo.prototype 对象。

在面向对象的语言,类可以被复制多次,但是在 JavaScript中,并没有类似的复制机制,你不能创建一个类的多个实例,只能创建多个对象,它们 [[prototype]] 关联的是同一个对象。但是在默认情况下并不会进行复制,因此这些对象之间并不会完全失去联系,它们是互相关联的。

const a = new Foo();

new Foo() 会生成一个新对象(我们称之为a),这个新对象的内部链接 [[prototype]] 关联的是 Foo.prototype 对象。

最后我们得到了两个对象,它们之间互相关联。

实际上,new Foo() 这个函数调用实际上并没有直接创建关联,这个关联只是一个意外的副作用。new Foo() 只是间接完成了我们的目标:一个关联到其他对象的新对象。

那么有没有更直接的方法来做到这一点呢?当前,就是 Object.create()

关于名称

在 JavaScript 中,我们并不会将一个对象(类)复制到另一个对象(实例),只是将它们关联起来,这个机制通常被称为「原型继承」。

构造函数

函数本省并不是构造函数,然后,当你在普通函数的调用前加上new关键字之后,就会把这个函数调用变成一个「构造函数调用」。实际上,new 会劫持所有普通对象并用构造对象的形式来调用它。

1function Person(){
2  console.log("Person");
3}
4
5const person = new Person();

Person() 只是一个普通函数,但是使用 new 调用,它就会构造一个对象并赋值给 person,这看起来像是 new 的一个副作用(无论如何都会构造一个对象)。每个调用时一个构造函数调用,但是 Person 本身并不是一个构造函数。

换句话说,在 JavaScript 中对于「构造函数」最准确的解释是,所有带new的函数调用。

函数不是构造函数,但是当前仅当使用 new 时,函数调用会变成 「构造函数调用」。

技术

1function Foo(name) {
2  this.name = name;
3}
4Foo.prototype.myName = function () {
5  return this.name;
6};
7let a = new Foo("a");
8console.log(a.myName()); // a
9console.log(a.constructor === Foo); // true
10
11let b = new Foo("b");
12console.log(b.myName()); // b
13console.log(b.constructor === Foo); // true

「面向类」的两种技巧:

  1. this.name = name 给每个对象都添加了 .name 属性,有点像类实例封装的原始值
  2. Foo.prototype.myName = 它会给 Foo.prototype 对象添加一个属性(函数)。
    • a.myName() 可以正常工作的原理:实例a内部的 [[prototype]] 会关联到 Foo.prototype 上。在 a 中无法找到 myName 时,它会通过 委托 在 Foo.prototype 上找到。

a.constructor === Foo

a.constructor 引用同样被委托给了 Foo.prototype,而 Foo.prototype.constructor 默认指向Foo。

Foo.prototype 的 **.constructor 属性只是 Foo 函数在声明时的默认属性。**如果你创建了一个新对象并替换了函数默认的 .prototype 对象引用,那么新对象并不会自动获得 .constructor 属性。

1function Foo(name) {
2  this.name = name;
3}
4// 创建一个新原型对象
5Foo.prototype = {};
6
7let a = new Foo("a");
8console.log(a.constructor === Foo); // false
9console.log(a.constructor === Object); // true

a并没有 .constructor属性,所以它会委托 [[prototype]] 链上的 Foo.prototype 。但是这个对象也没有 .constructor 属性(不过默认的 Foo.prototype 对象有这个属性!),所以它会继续委托,这次会委托链顶端的 Object.prototype。这个对象有 .constructor 属性,指向内置的 Object() 函数。

手动给 Foo.prototype 添加一个 .constructor 属性,不过这需要手动添加一个复合正常行为的不可枚举属性

1// constructor 并不是一个不可变的属性,它是不可枚举的,所以它是不可靠的
2Object.defineProperty(Foo.prototype, "constructor", {
3  enumerable: false,
4  writable: true,
5  configurable: true,
6  value: Foo, // 让 .constructor 指向 Foo
7});

实际上,对象的 .constructor 属性默认指向一个函数,而这个函数也有一个叫做 .prototype 的引用指向这个对象。

原型继承

如果能有一个标准且可靠的方法来修改对象的 [[prototype]] 关联就好了。

在 ES6 之前,我们只能通过设置 .__proto__ 属性来实现,但是这个方法并不是标准并且无法兼容所有浏览器。

ES6 添加了辅助函数 Object.setPrototypeOf(),可以用标准且可靠的方法来修改关联。

1function Foo(name) {
2  this.name = name;
3}
4Foo.prototype.myName = function () {
5  return this.name;
6};
7
8function Bar(name, label) {
9  Foo.call(this, name);
10  this.label = label;
11}
12
13// 核心代码开始: 创建一个新的 Bar.prototype 对象并关联到 Foo.prototype
14
15// 【不推荐】
16// 并不会创建一个关联到 Bar.prototype 的新对象,它只是让 Bar.prototype 直接引用 Foo.prototype 对象。因此当你执行类似 Bar.prototype.myLabel = ... 的赋值语句时会直接修改 Foo.prototype 对象本身。
17Bar.prototype = Foo.prototype;
18
19// 【不推荐】
20// 的确会创建一个关联到 Foo.prototype 的新对象。但是它使用了 Foo() 的构造函数调用,如果函数Foo有一些副作用的话,就会影响到 Bar() 的后代,后果不堪设想。
21Bar.prototype = new Foo()
22
23// 【如果忽略掉Object.create()方法带来的轻微性能损失(抛弃的对象需要进行垃圾回收),它实际上比ES6及其之后的方法更短而且可读性更高】
24// ES6之前需要抛弃默认的 Bar.prototype
25// 要创建一个合适的关联对象,可以使用Object.create(),这样做唯一的缺点就是需要创建一个新对象然后把旧对象抛弃掉,不能直接修改已有的默认对象。
26// 注意:现在没有 Bar.prototype.constructor 了
27Bar.prototype = Object.create(Foo.prototype);
28
29// 【推荐,标准且可靠】
30// ES6开始可以直接修改现有的 Bar.prototype
31Object.setPrototypeOf(Bar.prototype, Foo.prototype);
32
33// 核心代码结束
34
35Bar.prototype.myLabel = function () {
36  return this.label;
37};
38
39let a = new Bar("a", "obj a");
40console.log(a.myName()); // a
41console.log(a.myLabel()); // obj a

检查类关系

假设有对象a,如何寻找对象a委托的对象(如果存在的话)呢?

在传统的面向类环境中,检查一个实例(JavaScript中的对象)的继承祖先(JavaScript中的委托关联)通常被称为「内省(或反射)」。

1function Foo() {}
2const a = new Foo();
3
4// 方法1
5// 这种方式只能处理对象和函数(带.prototype)之间的关系。
6// 如果想判断两个对象之间是否通过 [[prototype]] 链关联,只用 instanceof 无法实现
7console.log(a instanceof Foo); // true
8
9
10// 方法2
11// 在 a 的整条[[prototype]]链中是否出现过Foo.prototype
12Foo.prototype.isPrototypeOf(a); // true
13
14// 方法3
15Object.getPrototypeOf(a) === Foo.prototype; // true
16
17// 方法4
18// 浏览器也支持一种非标准的方法来访问内部的[[prototype]]属性
19// 这个神奇的 .__proto__ 引用了内部的 [[prototype]] 对象
20// 和.constructor一样,.__proto__实际上并不存在于你正在使用的对象a上。实际上,它和其他的常用函数(.toString()、.isPrototypeOf()等等)一样,存在于内置的 Object.prototype 中。
21// 此外,.__proto__看起来很像个属性,但是实际上它更像一个 getter/setter
22a.__proto__ === Foo.prototype; // true

_proto_

1Object.defineProperty(Object.property, "__proto__", {
2  get: function () {
3    return Object.getPrototypeOf(this);
4  },
5  set: function (o) {
6    // ES6中的 setPrototypeOf()
7    Object.setPrototypeOf(this, o);
8    return o;
9  },
10});

访问(获取值)a.__proto__ 时,实际上是调用了 a.__proto__()(调用getter函数)。虽然getter函数存在于 Object.prototype 对象中,但是它的this指向对象a,所以和 Object.getPrototypeOf(a) 结果相同。

.__proto__ 是可设置属性的,之前代码中使用ES6的 Object.setPrototypeOf() 进行设置。

我们只有在一些特殊情况下需要设置函数的默认 .prototype 对象的 [[prototype]],让它引用其他对象(除了Object.prototype)。这样可以避免使用全新的对象替换默认对象。此外,最好把 [[prototype]] 对象关联看作是只读属性,从而增加代码的可读性。

JavaScript社区中对于双下划线有一个非官方的称呼,他们会把类似 __proto__ 的属性称为「笨蛋(dunder)」。所以,JavaScript潮人们会把 __proto__ 叫做「笨蛋proto」

对象关联

[[prototype]] 机制就是存在于对象中的一个内部链接,它会指向其他对象。

这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在 [[prototype]] 关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的 [[prototype]],以此类推。这一系列的链接被称为「原型链」。

创建关联

[[prototype]] 机制的意义是什么?

1let foo = {}
2let bar = Object.create( foo );

Object.create() 会创建一个新对象bar并把它关联到我们指定的对象(foo),这样我们就可以充分发挥 [[prototype]] 机制的委托并且避免不必要的麻烦(比如使用new的构造函数调用会生成 .prototype.constructor 引用)。

Object.create(null) 会创建一个拥有空(或者说null)[[prototype]] 链接的对象,这个对象无法进行委托。由于这个对象没有原型链,所以 instanceof 操作符无法进行判断,因此总是返回false。这些特殊的空[[prototype]] 对象通常被称作「字典」,它们完全不会受到原型链的干扰,因此非常适合用来存储数据。

我们并不需要类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了。而且 Object.create() 不包含任何 “类的诡计”,所以它可以完美地创建我们想要的关联关系。

关联关系是备用的

看起来对象之间的关联关系是处理「缺失」属性或者方法时的一种备用选项。

这个说法有点道理,但并不是 [[prototype]] 的本质。

假设要调用 myObject.foo(),如果 myObject上不存在 foo() 时这条语句也可以正常工作的话,那你的API设计就会变得很神奇,对于未来维护者来说可能不太好理解。

内部委托

内部委托可以让你的API设计不那么神奇,同时仍然能发挥 [[prototype]] 关联的威力:

1let anotherObject = {
2  cool: function () {
3    console.log('cool')
4  }
5}
6
7let myObject = Object.create( anotherObject );
8myObject.doCool = function () {
9  this.cool(); // 内部委托
10}
11myObject.doCool(); // cool

内部委托比起直接委托可以让API接口设计更加清晰。

小结

  • 如果要访问对象中并不存在的一个属性,[[Get]] 操作就会查找对象内部 [[Prototype]] 关联的对象。这个关联关系实际上定义了一条“原型链” (有点像嵌套的作用域链) ,在查找属性时会对它进行遍历。

  • 所有普通对象都有内置的 Object.prototype,指向原型链的顶端(比如说全局作用域) ,如果在原型链中找不到指定的属性就会停止。toString()valueOf() 和其他一些通用的功能都存在于Object.prototype 对象上,因此语言中所有的对象都可以使用它们。

  • 关联两个对象最常用的方法是使用 new 关键词进行函数调用,在调用的 4 个步骤中会创建一个关联其他对象的新对象。

  • 使用new 调用函数时会把新对象的.prototype 属性关联到“其他对象”。带new 的函数调用通常被称为“构造函数调用”,尽管它们实际上和传统面向类语言中的类构造函数不一样。

  • 虽然这些 JavaScript 机制和传统面向类语言中的“类初始化”和“类继承”很相似,但是 JavaScript 中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的 [[Prototype]] 链关联的。

  • 出于各种原因,以“继承”结尾的术语(包括“原型继承” )和其他面向对象的术语都无法帮助你理解JavaScript 的真实机制(不仅仅是限制我们的思维模式) 。相比之下,“委托”是一个更合适的术语,因为对象之间的关系不是复制而是委托。