JavaScript 中的原型

实例对象的原型继承

如果要实现某个实例对象扩展另一个实例对象的属性和方法,大部分语言会使用到继承,比如 Java 的 class 继承,这些方式会从被继承的 class 处复制它的方法。

但是 JavaScript 的继承并不是这样的。

JavaScript 采用的是原型继承的方式,并且实例对象也能够继承。

什么是原型继承?

在 JavaScript 中,对象有一个特殊的隐藏属性 [[Prototype]](如规范中所命名的),它要么为 null,要么就是对另一个对象的引用。该对象被称为“原型”:

object.[[Prototype]] ===> prototype object

如果我们想要访问这个对象,除了在浏览器的控制台输入console.dir(object)外,还可以通过__proto__来设置和访问它。

js
1let dog = {
2  eats: true,
3}
4let animal = {
5  jumps: true,
6}
7
8dog.__proto__ = animal // 设置 dog.[[Prototype]] = animal

如果我们从 dog 中读取一个它没有的属性,JavaScript 会自动从 animal 中获取。

js
1let dog = {}
2let animal = {
3  jumps: true,
4  eats: true,
5}
6
7dog.__proto__ = animal // (*)
8
9// 现在这两个属性我们都能在 rabbit 中找到:
10alert(dog.eats) // true (**)
11alert(dog.jumps) // true

第*行,我们将 dog 的原型对象设置为 animal,虽然 dog 是个空对象,但是却能访问到 eats 和 jumps 属性。

如果对象身上没有某个属性,JavaScript 会顺着[[prototype]]往上查找,直到原型的终点——null 为止。

dog.eats ===> [[prototype]] ===> rabbit.eats

在上面的例子中,dog的原型是 animal。dog的jumps和eats方法继承自animal。

如果 animal 有其他属性和方法,那么它们将自动地变为在 dog 中可用。这种属性被称为“继承”。

一个对象可以继承多个其他对象的属性和方法,但是不可以有两个[[Prototype]]

如果要继承多个其他对象的属性和方法,则可以创建一组原型链表:

js
1let animal = {
2  eats: true,
3  walk() {
4    alert('Animal walk')
5  },
6}
7
8let rabbit = {
9  jumps: true,
10  __proto__: animal,
11}
12
13let longEar = {
14  earLength: 10,
15  __proto__: rabbit,
16}

现在的原型链表是这样的:

longEar ===> rabbit ===> animal

longEar可以调用 animal的 walk方法

js
1longEar.walk() // Animal walk

整体的流程是:访问 longEar 的 walk属性时,如果longEar本身没有,就会顺着[[Prototype]]往上查找,最终查找到animal的walk方法。

这里有两个限制:

  1. 原型链表不能有环形引用,否则 JavaScript 会报错
  2. 原型链表只能赋值给 null 或者对象类型,其他类型的数据都会被忽略

原型中的 this

有这么一个例子:

js
1let user = {
2  name: 'John',
3  surname: 'Smith',
4  get fullName() {
5    return `${this.name} ${this.surname}`
6  },
7}
8
9let admin = {
10  __proto__: user,
11  isAdmin: true,
12}

admin.fullName 此时返回什么?

答案很显而易见,由于调用的对象是 admin,所以 this指向admin。

又因为admin并没有 name 和 surname,所以就顺着admin的原型链,找到user对象,最终返回John Smith。

这说明this 在 JavaScript 的设计中不受原型的影响。

无论在哪里找到方法:在一个对象还是在原型中。在一个方法调用中,this 始终是点符号 . 前面的对象。

这个很重要,因为很有可能在继承的方法中会修改某一个属性,当继承的对象运行继承的方法时,它们将仅修改自己的状态,而不会修改大对象的状态。

例如,这里的 animal 代表“方法存储”,rabbit 在使用其中的方法。

调用 rabbit.sleep() 会在 rabbit 对象上设置 this.isSleeping:

js
1// animal 有一些方法
2let animal = {
3  walk() {
4    if (!this.isSleeping) {
5      alert(`I walk`)
6    }
7  },
8  sleep() {
9    this.isSleeping = true
10  },
11}
12
13let rabbit = {
14  name: 'White Rabbit',
15  __proto__: animal,
16}
17
18// 修改 rabbit.isSleeping
19rabbit.sleep()
20
21alert(rabbit.isSleeping) // true
22alert(animal.isSleeping) // undefined(原型中没有此属性)

如果我们还有从 animal 继承的其他对象,像 bird 和 snake 等,它们也将可以访问 animal 的方法。但是,每个方法调用中的 this 都是在调用时(点符号前)评估的对应的对象,而不是 animal。因此,当我们将数据写入 this 时,会将其存储到这些对象中。

所以,方法是共享的,但对象状态不是。

for..in 循环

for..in循环会遍历到继承的属性

js
1let animal = {
2  eats: true,
3}
4
5let rabbit = {
6  jumps: true,
7  __proto__: animal,
8}
9
10// Object.keys 只返回自己的 key
11alert(Object.keys(rabbit)) // jumps
12
13// for..in 会遍历自己以及继承的键
14for (let prop in rabbit) alert(prop) // jumps,然后是 eats

如果我们不需要遍历到继承的属性,一个方法是改用Object.keys替代for..in遍历,还有一个方法是使用hasOwnProperty来判断该属性是继承的还是自身上的,如果是自身的属性,则返回true。

使用hasOwnProperty配合 for..in迭代的示例如下:

js
1let animal = {
2  eats: true,
3}
4
5let rabbit = {
6  jumps: true,
7  __proto__: animal,
8}
9
10for (let prop in rabbit) {
11  let isOwn = rabbit.hasOwnProperty(prop)
12
13  if (isOwn) {
14    alert(`Our: ${prop}`) // Our: jumps
15  } else {
16    alert(`Inherited: ${prop}`) // Inherited: eats
17  }
18}

虽然for..in能遍历到继承的属性,但是几乎所有内置的继承到的方法却不会被遍历出来。

这是因为 JavaScript 内置的继承方法都设置了一个属性描述符——enumerable:false,

for..in只能列出可枚举的属性,这就是为什么hasOwnProperty等继承过来的方法不会被遍历出来。

构造函数 F.prototype

实例出来的对象.__prototype__ === 它的构造函数.prototype

当new操作符将一个对象创建出来后,它的内部会将构造函数F.prototype设置成新对象的[[Prototype]]。

下面是一个例子:

js
1let animal = {
2  eats: true,
3}
4
5function Rabbit(name) {
6  this.name = name
7}
8
9Rabbit.prototype = animal
10
11let rabbit = new Rabbit('White Rabbit') //  rabbit.__proto__ == animal
12
13alert(rabbit.eats) // true

当使用 new操作符创建由 Rabbit 构造函数构建的对象 rabbit时,rabbit.__proto__就被new操作符指向了Rabbit.prototype。

设置 Rabbit.prototype = animal 的字面意思是:“当创建了一个 new Rabbit 时,把它的 [[Prototype]] 赋值为 animal”。

image-20230213215024686

默认的 F.prototype

每个函数都有一个默认的prototype,里面仅有一个constructor属性,指向函数自身。

js
1function Rabbit() {}
2
3/* 默认的 prototype
4Rabbit.prototype = { constructor: Rabbit };
5*/
6Rabbit ===> { constructor: Rabbit }

image-20230213215417025

上面的 Rabbit 函数与默认的prototype的关系是这样的

image-20230213215535797

如果 Rabbit 的默认prototype没有被改变,那么由 Rabbit 构造的 rabbit 对象也可以通过[[prototype]]访问到 Rabbit.

js
1let rabbit = new Rabbit()
2rabbit.__proto__.constructor === Rabbit // true
3let rabbit2 = new rabbit.constructor('Black Rabbit')

这种方式的好处是,当我有一个对象,但是不知道它使用了哪个构造器时,就可以通过该对象的constructor属性来获取到这个构造器。

不过有一个注意点:

……JavaScript 自身并不能确保正确的 "constructor" 函数值。

也就是说,JavaScript 没有严格规定constructor的值,它仅存在于默认的prototype中。

特别是,如果我们将整个默认 prototype 替换掉,那么其中就不会有 "constructor" 了。

js
1function Rabbit() {}
2Rabbit.prototype = {
3  jumps: true,
4}
5
6let rabbit = new Rabbit()
7alert(rabbit.constructor === Rabbit) // false

因此,为了确保正确的constructor,我们有必要在写程序时加上它:

js
1function Rabbit() {}
2
3// 不要将 Rabbit.prototype 整个覆盖
4// 可以向其中添加内容
5Rabbit.prototype.jumps = true
6// 默认的 Rabbit.prototype.constructor 被保留了下来

或者就是手动创建constructor属性

js
1Rabbit.prototype = {
2  jumps: true,
3  constructor: Rabbit,
4}
5
6// 这样的 constructor 也是正确的,因为我们手动添加了它

Object.prototype

下面的例子中我们将rabbit的原型设置为adnimal

js
1let animal = {
2  eats: true,
3}
4
5let rabbit = {
6  jumps: true,
7  __proto__: animal,
8}

然后,我们在 for..in调用rabbit.hasOwnProperty方法。

js
1for(let prop in rabbit) {
2  let isOwn = rabbit.hasOwnProperty(prop);
3  ...
4}

我们已经知道hasOwnProperty这种内置的方法是继承过来的,它的enumerable是false,所以不能被for..in遍历到。

那么这个方法存在哪里?JavaScript 是不是为所有对象都设计了一个最终的prototype?

答案是——的确如此,所有 JavaScript 对象都指向最终的原型:Object.prototype

rabbit对象的原型链是这样的:

js
1rabbit ===> animal ===> Object.prototype ===> null

我们可以通过浏览器的控制台来得知这一点

js
1rabbit.__proto__.__proto__ === Object.prototype // true
2Object.prototype.__proto__ === null // true

在不给object设置[[Prototype]]的情况下,每个object的__proto__属性都指向默认的Object.prototype,其中 Object 是所有对象的构造函数,其自身的 prototype 指向一个带有 toString 和其他方法的一个巨大的对象。

当 new Object()被调用或者obj={}时,这个对象的[[Prototype]]就会设置成Object.prototype.

image-20230214204738557

当我们调用obj.toString时,这个方法是从Object.prototype中获得的。

js
1let obj = {}
2
3alert(obj.__proto__ === Object.prototype) // true
4
5alert(obj.toString === obj.__proto__.toString) //true
6alert(obj.toString === Object.prototype.toString) //true
7alert(Object.prototype.__proto__) // null

Object.prototype的__proto__指向null,这是原型的终点了。

其他内建原型

很多内建对象,例如Array、Date、Function都有其自身的Prototype。

例如,当我们创建一个数组 [1, 2, 3],在内部会默认使用 new Array() 构造器。因此 Array.prototype 变成了这个数组的 prototype,并为这个数组提供数组的操作方法。这样内存的存储效率是很高的。

按照规范,所有的内建原型顶端都是 Object.prototype。这就是为什么有人说“一切都从对象继承而来”。

下面是比较完整的示意图

image-20230214205913431

有一些方法在原型上可能会重叠,例如Array.prototype.toString方法是为了给数组使用而设置的内置方法,由于原型链比Object.prototype.toString更近一些,所以我们调用[].toString()时调用的是Array.prototype.toString。

image-20230214210214256

使用console.dir可以看到这一点

image-20230214210403417

其他内建对象也以同样的方式运行。即使是函数 —— 它们是内建构造器 Function 的对象,并且它们的方法(call/apply 及其他)都取自 Function.prototype。函数也有自己的 toString 方法。

js
1function f() {}
2
3alert(f.__proto__ == Function.prototype) // true
4alert(f.__proto__.__proto__ == Object.prototype) // true,继承自 Object

对象包装器

基本数据类型中,字符串、数字和布尔值,它们并不是对象,但是如果我们试图访问它们的属性,那么临时包装器对象将会通过内建的构造器 String、Number 和 Boolean 被创建。它们提供给我们操作字符串、数字和布尔值的方法然后消失。

这些对象对我们来说是无形地创建出来的。大多数引擎都会对其进行优化,但是规范中描述的就是通过这种方式。这些对象的方法也驻留在它们的 prototype 中,可以通过 String.prototype、Number.prototype 和 Boolean.prototype 进行获取。

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

原型借用

有一些原生的原型方法通常允许被借用,例如,如果我们要创建类数组对象,则可能需要向其中复制一些 Array 方法。

js
1let obj = {
2  0: 'Hello',
3  1: 'world!',
4  length: 2,
5}
6
7obj.join = Array.prototype.join
8
9alert(obj.join(',')) // Hello,world!

上面这段代码有效,是因为内建的方法 join 的内部算法只关心正确的索引和 length 属性。它不会检查这个对象是否是真正的数组。

另一种方式是通过将 obj.__proto__ 设置为 Array.prototype,这样 Array 中的所有方法都自动地可以在 obj 中使用了。

但是如果 obj 已经从另一个对象进行了继承,那么这种方法就不可行了。因为这样会覆盖掉已有的继承。

请记住,我们一次只能继承一个对象。

现代方法操作原型

我们可以通过__proto__来获取和设置[[Prototype]]。但根据规范,这种方式已经过时(但仍然有效)了。

我们需要用现代 JavaScript 提供的 API 来获取和设置[[Prototype]]

例如

js
1let animal = {
2  eats: true,
3}
4
5// 创建一个以 animal 为原型的新对象
6let rabbit = Object.create(animal) // 与 {__proto__: animal} 相同
7
8alert(rabbit.eats) // true
9
10alert(Object.getPrototypeOf(rabbit) === animal) // true
11
12Object.setPrototypeOf(rabbit, {}) // 将 rabbit 的原型修改为 {}

Object.create 方法更强大,因为它有一个可选的第二参数:属性描述器。

我们可以在此处为新对象提供额外的属性,就像这样:

js
1let animal = {
2  eats: true,
3}
4
5let rabbit = Object.create(animal, {
6  jumps: {
7    value: true,
8  },
9})
10
11alert(rabbit.jumps) // true

我们可以使用 Object.create 来实现比复制 for..in 循环中的属性更强大的对象克隆方式:

js
1let clone = Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj))

此调用可以对 obj 进行真正准确地拷贝,包括所有的属性:可枚举和不可枚举的,数据属性和 setters/getters —— 包括所有内容,并带有正确的 [[Prototype]]。

没有原型的对象

我们经常使用 object 来生成某些属性的映射,往往这些情况下会有一些小 bug:

js
1let object = {}
2object.__proto__ = 'proto' // 无效,因为字符串不能赋值给 __proto__

根据 JavaScript 的规范,__proto__只能赋值给 null、object。

所以设置__proto__就会因为这个规范变成一个 Bug。

除此之外可能还会有其他由于原型的规范而产生的问题。

那么我们该如何避免这种问题呢?有两种方法:

  1. 使用 Map 对象来做映射

    js
    1let map = new Map()
    2let key = prompt("What's the key?", '__proto__')
    3map.set(key, 'some value')
    4alert(map.get(key)) // "some value"(符合预期)
  2. 创建一个没有原型的对象(“very plain” 对象)

    js
    1let obj = Object.create(null)
    2// 或者:obj = { __proto__: null }
    3
    4let key = prompt("What's the key?", '__proto__')
    5obj[key] = 'some value'
    6
    7alert(obj[key]) // "some value"

    Object.create(null) 创建了一个空对象,这个对象没有原型([[Prototype]] 是 null)

    现在我们尝试一下设置它的__proto__

    js
    1obj.__proto__ = '123'
    2obj.__proto__ // '123' 成功了

    能成功的原因是:

    __proto__ 不是对象的属性,而是 Object.prototype 的访问器属性:

image-20230214214156035

如果 obj.__proto__ 被读取或者赋值,那么对应的 getter/setter 会被从它的原型中调用,它会 set/get [[Prototype]]。

当我们一开始就把__proto__设置为null并创建出该对象时,该对象就与[[prototype]]断开了唯一的通信通道。

因此,它没有继承 __proto__ 的 getter/setter 方法。现在,它被作为正常的数据属性进行处理,因此上面的这个示例能够正常工作。

缺点是这样的对象没有任何内建的对象的方法,例如 toString:

js
1let obj = Object.create(null)
2
3alert(obj) // Error (no toString)

在 alert 一个对象时,JavaScript 会默认调用obj.toString()来获取字符串,由于上面的对象没有原型,所以也不存在toString方法,于是就报错了。

大多数与对象相关的方法都是 Object.something(...),例如 Object.keys(obj) —— 它们不在 prototype 中,因此在 “very plain” 对象中它们还是可以继续使用:

js
1let chineseDictionary = Object.create(null)
2chineseDictionary.hello = '你好'
3chineseDictionary.bye = '再见'
4
5alert(Object.keys(chineseDictionary)) // hello,bye

总结

  • 每个对象都有一个特殊的隐藏属性[[Prototype]],它要么是另一个对象,要么就是 null。

  • 通过 [[Prototype]] 引用的对象被称为“原型”。

  • 原型可以链式引用,每个对象都会链接着一条原型链,终点是 null。

  • 很多种方式可以访问到它,比如通过访问器属性__proto__、Object.getPrototypeOf(obj)等

  • 很多种方式可以设置它,比如Object.setPrototypeOf(obj)、__proto__、Object.create等

  • 如果对象身上不存在某个属性,那么 JavaScript 会在原型上寻找该属性,这就是原型继承。

  • 原型方法上的 this是对象自身,因为对象.method()相当于显式声明了this。

  • 虽然 array、function、object 等都是不同的对象,它们也有不同的原型(Array.prototype、Object.prototype、Date.prototype 等),但是它们都会有共同的终极原型Object.prototype。

  • prototype存储着大量的内置方法,原始数据类型也将方法存储在包装器对象的prototype中,但undefined和null没有包装器。

  • for..in会经过原型对象,但由于属性描述符中的enumerable设置成了false,所以不会被遍历出来。

  • 默认情况下,每个对象的[[Prototype]]都指向它的构造函数的prototype。

    对象.__proto__ === 其构造函数.prototype。

  • 当使用 new 时,构造函数的prototype会自动加到创建出来的对象的__proto__上。

  • 原型方法可以被call、apply方法借用

  • 通常,对象会从 Object.prototype 继承内建的方法和 __proto__ getter/setter,会占用相应的键,且可能会导致副作用。原型为 null 时,对象才真正是空的。我们可以通过__proto__或者Object.create设置没有原型的对象。