JavaScript 中的原型
实例对象的原型继承
如果要实现某个实例对象扩展另一个实例对象的属性和方法,大部分语言会使用到继承,比如 Java 的 class 继承,这些方式会从被继承的 class 处复制它的方法。
但是 JavaScript 的继承并不是这样的。
JavaScript 采用的是原型继承的方式,并且实例对象也能够继承。
什么是原型继承?
在 JavaScript 中,对象有一个特殊的隐藏属性 [[Prototype]](如规范中所命名的),它要么为 null,要么就是对另一个对象的引用。该对象被称为“原型”:
object.[[Prototype]] ===> prototype object
如果我们想要访问这个对象,除了在浏览器的控制台输入console.dir(object)外,还可以通过__proto__来设置和访问它。
js1let dog = { 2 eats: true, 3} 4let animal = { 5 jumps: true, 6} 7 8dog.__proto__ = animal // 设置 dog.[[Prototype]] = animal
如果我们从 dog 中读取一个它没有的属性,JavaScript 会自动从 animal 中获取。
js1let 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]]
如果要继承多个其他对象的属性和方法,则可以创建一组原型链表:
js1let 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方法
js1longEar.walk() // Animal walk
整体的流程是:访问 longEar 的 walk属性时,如果longEar本身没有,就会顺着[[Prototype]]往上查找,最终查找到animal的walk方法。
这里有两个限制:
- 原型链表不能有环形引用,否则 JavaScript 会报错
- 原型链表只能赋值给 null 或者对象类型,其他类型的数据都会被忽略
原型中的 this
有这么一个例子:
js1let 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:
js1// 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循环会遍历到继承的属性
js1let 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迭代的示例如下:
js1let 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]]。
下面是一个例子:
js1let 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”。

默认的 F.prototype
每个函数都有一个默认的prototype,里面仅有一个constructor属性,指向函数自身。
js1function Rabbit() {} 2 3/* 默认的 prototype 4Rabbit.prototype = { constructor: Rabbit }; 5*/ 6Rabbit ===> { constructor: Rabbit }

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

如果 Rabbit 的默认prototype没有被改变,那么由 Rabbit 构造的 rabbit 对象也可以通过[[prototype]]访问到 Rabbit.
js1let rabbit = new Rabbit() 2rabbit.__proto__.constructor === Rabbit // true 3let rabbit2 = new rabbit.constructor('Black Rabbit')
这种方式的好处是,当我有一个对象,但是不知道它使用了哪个构造器时,就可以通过该对象的constructor属性来获取到这个构造器。
不过有一个注意点:
……JavaScript 自身并不能确保正确的 "constructor" 函数值。
也就是说,JavaScript 没有严格规定constructor的值,它仅存在于默认的prototype中。
特别是,如果我们将整个默认 prototype 替换掉,那么其中就不会有 "constructor" 了。
js1function Rabbit() {} 2Rabbit.prototype = { 3 jumps: true, 4} 5 6let rabbit = new Rabbit() 7alert(rabbit.constructor === Rabbit) // false
因此,为了确保正确的constructor,我们有必要在写程序时加上它:
js1function Rabbit() {} 2 3// 不要将 Rabbit.prototype 整个覆盖 4// 可以向其中添加内容 5Rabbit.prototype.jumps = true 6// 默认的 Rabbit.prototype.constructor 被保留了下来
或者就是手动创建constructor属性
js1Rabbit.prototype = { 2 jumps: true, 3 constructor: Rabbit, 4} 5 6// 这样的 constructor 也是正确的,因为我们手动添加了它
Object.prototype
下面的例子中我们将rabbit的原型设置为adnimal
js1let animal = { 2 eats: true, 3} 4 5let rabbit = { 6 jumps: true, 7 __proto__: animal, 8}
然后,我们在 for..in调用rabbit.hasOwnProperty方法。
js1for(let prop in rabbit) { 2 let isOwn = rabbit.hasOwnProperty(prop); 3 ... 4}
我们已经知道hasOwnProperty这种内置的方法是继承过来的,它的enumerable是false,所以不能被for..in遍历到。
那么这个方法存在哪里?JavaScript 是不是为所有对象都设计了一个最终的prototype?
答案是——的确如此,所有 JavaScript 对象都指向最终的原型:Object.prototype
rabbit对象的原型链是这样的:
js1rabbit ===> animal ===> Object.prototype ===> null
我们可以通过浏览器的控制台来得知这一点
js1rabbit.__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.

当我们调用obj.toString时,这个方法是从Object.prototype中获得的。
js1let 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。这就是为什么有人说“一切都从对象继承而来”。
下面是比较完整的示意图

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

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

其他内建对象也以同样的方式运行。即使是函数 —— 它们是内建构造器 Function 的对象,并且它们的方法(call/apply 及其他)都取自 Function.prototype。函数也有自己的 toString 方法。
js1function 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 方法。
js1let 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]]
- Object.getPrototypeOf(obj) —— 返回对象
obj的[[Prototype]]。 - Object.setPrototypeOf(obj, proto) —— 将对象
obj的[[Prototype]]设置为proto。 - Object.create(proto, [descriptors]) —— 利用给定的
proto作为[[Prototype]]和可选的属性描述来创建一个空对象。
例如
js1let 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 方法更强大,因为它有一个可选的第二参数:属性描述器。
我们可以在此处为新对象提供额外的属性,就像这样:
js1let 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 循环中的属性更强大的对象克隆方式:
js1let clone = Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj))
此调用可以对 obj 进行真正准确地拷贝,包括所有的属性:可枚举和不可枚举的,数据属性和 setters/getters —— 包括所有内容,并带有正确的 [[Prototype]]。
没有原型的对象
我们经常使用 object 来生成某些属性的映射,往往这些情况下会有一些小 bug:
js1let object = {} 2object.__proto__ = 'proto' // 无效,因为字符串不能赋值给 __proto__
根据 JavaScript 的规范,__proto__只能赋值给 null、object。
所以设置__proto__就会因为这个规范变成一个 Bug。
除此之外可能还会有其他由于原型的规范而产生的问题。
那么我们该如何避免这种问题呢?有两种方法:
-
使用 Map 对象来做映射
js1let map = new Map() 2let key = prompt("What's the key?", '__proto__') 3map.set(key, 'some value') 4alert(map.get(key)) // "some value"(符合预期) -
创建一个没有原型的对象(“very plain” 对象)
js1let 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__js1obj.__proto__ = '123' 2obj.__proto__ // '123' 成功了能成功的原因是:
__proto__不是对象的属性,而是Object.prototype的访问器属性:

如果 obj.__proto__ 被读取或者赋值,那么对应的 getter/setter 会被从它的原型中调用,它会 set/get [[Prototype]]。
当我们一开始就把__proto__设置为null并创建出该对象时,该对象就与[[prototype]]断开了唯一的通信通道。
因此,它没有继承 __proto__ 的 getter/setter 方法。现在,它被作为正常的数据属性进行处理,因此上面的这个示例能够正常工作。
缺点是这样的对象没有任何内建的对象的方法,例如 toString:
js1let obj = Object.create(null) 2 3alert(obj) // Error (no toString)
在 alert 一个对象时,JavaScript 会默认调用obj.toString()来获取字符串,由于上面的对象没有原型,所以也不存在toString方法,于是就报错了。
大多数与对象相关的方法都是 Object.something(...),例如 Object.keys(obj) —— 它们不在 prototype 中,因此在 “very plain” 对象中它们还是可以继续使用:
js1let 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设置没有原型的对象。