原型继承
概述
- 例如,我们有一个
user
对象及其属性和方法,并希望将admin
和guest
作为基于user
稍加修改的变体- 想重用
user
中的内容,而不是复制/重新实现它的方法,而只是在其之上构建一个新的对象
- 想重用
- 原型继承(
Prototypal inheritance
) 这个语言特性能够帮助我们实现这一需求
[[Prototype]]
- 在
JavaScript
中,对象有一个特殊的隐藏属性[[Prototype]]
(如规范中所命名的)- 它要么为
null
- 要么就是对另一个对象的引用
- 该对象被称为“原型”
- 它要么为
- 特殊的名字
__proto__
1 2 3 4 5 6 7 8 |
let animal = { eats: true }; let rabbit = { jumps: true }; rabbit.__proto__ = animal; // 设置 rabbit.[[Prototype]] = animal |
- 现在,如果我们从
rabbit
中读取一个它没有的属性,JavaScript
会自动从animal
中获取
1 2 3 4 5 6 7 8 9 10 11 12 |
let animal = { eats: true }; let rabbit = { jumps: true }; rabbit.__proto__ = animal; // (*) // 现在这两个属性我们都能在 rabbit 中找到: alert( rabbit.eats ); // true (**) alert( rabbit.jumps ); // true |
- 在这儿我们可以说 “
animal
是rabbit
的原型”,或者说 “rabbit
的原型是从animal
继承而来的”- 因此,如果
animal
有许多有用的属性和方法,那么它们将自动地变为在rabbit
中可用 - 这种属性被称为“继承”
- 因此,如果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
let animal = { eats: true, walk() { alert("Animal walk"); } }; let rabbit = { jumps: true, __proto__: animal }; // walk 方法是从原型中获得的 rabbit.walk(); // Animal walk |
- 两个限制:
- 引用不能形成闭环。如果我们试图给
__proto__
赋值但会导致引用形成闭环时,JavaScript
会抛出错误 __proto__
的值可以是对象,也可以是null
。而其他的类型都会被忽略
- 引用不能形成闭环。如果我们试图给
__proto__
是 [[Prototype]]
的 getter/setter
- 注意,
__proto__
与内部的[[Prototype]]
不一样 __proto__
是[[Prototype]]
的getter/setter
__proto__
属性有点过时了。它的存在是出于历史的原因,现代编程语言建议我们应该使用函数Object.getPrototypeOf/Object.setPrototypeOf
来取代__proto__
去 get/set 原型
写入不使用原型
- 原型仅用于读取属性
- 对于写入/删除操作可以直接在对象上进行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
let animal = { eats: true, walk() { /* rabbit 不会使用此方法 */ } }; let rabbit = { __proto__: animal }; rabbit.walk = function() { alert("Rabbit! Bounce-bounce!"); }; rabbit.walk(); // Rabbit! Bounce-bounce! |
- 访问器(
accessor
)属性是一个例外,因为赋值(assignment
)操作是由setter
函数处理的。因此,写入此类属性实际上与调用函数相同- 也就是这个原因,所以下面这段代码中的
admin.fullName
能够正常运行: - 在
(*)
行中,属性admin.fullName
在原型user
中有一个getter
,因此它会被调用。在(**)
行中,属性在原型中有一个setter
,因此它会被调用
- 也就是这个原因,所以下面这段代码中的
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 user = { name: "John", surname: "Smith", set fullName(value) { [this.name, this.surname] = value.split(" "); }, get fullName() { return `${this.name} ${this.surname}`; } }; let admin = { __proto__: user, isAdmin: true }; alert(admin.fullName); // John Smith (*) // setter triggers! admin.fullName = "Alice Cooper"; // (**) alert(admin.fullName); // Alice Cooper,admin 的内容被修改了 alert(user.fullName); // John Smith,user 的内容被保护了 |
this
的值
- 在
set fullName(value)
中this
的值是什么?属性this.name
和this.surname
被写在哪里:在user
还是admin
?this
根本不受原型的影响- 无论在哪里找到方法:在一个对象还是在原型中。在一个方法调用中,
this
始终是点符号.
前面的对象 - 因此,
setter
调用admin.fullName=
使用admin
作为this
,而不是user
- 这是一件非常重要的事儿,因为我们可能有一个带有很多方法的大对象,并且还有从其继承的对象
- 当继承的对象运行继承的方法时,它们将仅修改自己的状态,而不会修改大对象的状态
- 例如,这里的
animal
代表“方法存储”,rabbit
在使用其中的方法 - 调用
rabbit.sleep()
会在rabbit
对象上设置this.isSleeping
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// animal 有一些方法 let animal = { walk() { if (!this.isSleeping) { alert(`I walk`); } }, sleep() { this.isSleeping = true; } }; let rabbit = { name: "White Rabbit", __proto__: animal }; // 修改 rabbit.isSleeping rabbit.sleep(); alert(rabbit.isSleeping); // true alert(animal.isSleeping); // undefined(原型中没有此属性) |
- 如果我们还有从
animal
继承的其他对象,像bird
和snake
等,它们也将可以访问animal
的方法- 但是,每个方法调用中的
this
都是在调用时(点符号前)评估的对应的对象,而不是animal
- 因此,当我们将数据写入
this
时,会将其存储到这些对象中 - 所以,方法是共享的,但对象状态不是
- 但是,每个方法调用中的
for..in
for..in
循环也会迭代继承的属性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
let animal = { eats: true }; let rabbit = { jumps: true, __proto__: animal }; // Object.keys 只返回自己的 key alert(Object.keys(rabbit)); // jumps // for..in 会遍历自己以及继承的键 for(let prop in rabbit) alert(prop); // jumps,然后是 eats |
- 如果这不是我们想要的,并且我们想排除继承的属性,那么这儿有一个内建方法
obj.hasOwnProperty(key)
:如果obj
具有自己的(非继承的)名为key
的属性,则返回true
- 因此,我们可以过滤掉继承的属性(或对它们进行其他操作):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
let animal = { eats: true }; let rabbit = { jumps: true, __proto__: animal }; for(let prop in rabbit) { let isOwn = rabbit.hasOwnProperty(prop); if (isOwn) { alert(`Our: ${prop}`); // Our: jumps } else { alert(`Inherited: ${prop}`); // Inherited: eats } } |
注意
- 方法
rabbit.hasOwnProperty
来自哪儿?- 我们并没有定义它
- 该方法是
Object.prototype.hasOwnProperty
提供的。换句话说,它是继承的
- 如果
for..in
循环会列出继承的属性,那为什么hasOwnProperty
没有像eats
和jumps
那样出现在for..in
循环中?- 答案很简单:它是不可枚举的
- 就像
Object.prototype
的其他属性,hasOwnProperty
有enumerable:false
标志 - 并且
for..in
只会列出可枚举的属性。这就是为什么它和其余的Object.prototype
属性都未被列出
- 几乎所有其他键/值获取方法都忽略继承的属性
- 几乎所有其他键/值获取方法,例如
Object.keys
和Object.values
等,都会忽略继承的属性 - 它们只会对对象自身进行操作。不考虑 继承自原型的属性
- 几乎所有其他键/值获取方法,例如
F.prototype
概述
- 还记得,可以使用诸如
new F()
这样的构造函数来创建一个新对象 - 如果
F.prototype
是一个对象,那么new
操作符会使用它为新对象设置[[Prototype]]
注意1
JavaScript
从一开始就有了原型继承。这是JavaScript
编程语言的核心特性之一- 但是在过去,没有直接对其进行访问的方式
- 唯一可靠的方法是构造函数的
"prototype"
属性 - 目前仍有许多脚本仍在使用它
- 唯一可靠的方法是构造函数的
- 请注意,这里的
F.prototype
指的是F
的一个名为"prototype"
的常规属性- 这听起来与“原型”这个术语很类似,但这里我们实际上指的是具有该名字的常规属性
示例
- 设置
Rabbit.prototype = animal
的字面意思是:“当创建了一个new Rabbit
时,把它的[[Prototype]]
赋值为animal
” F.prototype
属性仅在new F
被调用时使用,它为新对象的[[Prototype]]
赋值- 如果在创建之后,
F.prototype
属性有了变化(F.prototype = <another object>
),那么通过new F
创建的新对象也将随之拥有新的对象作为[[Prototype]]
,但已经存在的对象将保持旧有的值 - 就是说,当
Rabbit.prototype
后续更新为其他对象后,不会改变以前创建的对象的Rabbit.prototype
的内容,但是后续新创建的对象的Rabbit.prototype
是更新后的新值
- 如果在创建之后,
1 2 3 4 5 6 7 8 9 10 11 12 13 |
let animal = { eats: true }; function Rabbit(name) { this.name = name; } Rabbit.prototype = animal; let rabbit = new Rabbit("White Rabbit"); // rabbit.__proto__ == animal alert( rabbit.eats ); // true |
默认的 F.prototype
,构造器属性
- 每个函数都有
"prototype"
属性,即使我们没有提供它 - 默认的
"prototype"
是一个只有属性constructor
的对象,属性constructor
指向函数自身
1 2 3 4 5 |
function Rabbit() {} /* 默认的 prototype Rabbit.prototype = { constructor: Rabbit }; */ |
1 2 3 4 5 |
function Rabbit() {} // 默认: // Rabbit.prototype = { constructor: Rabbit } alert( Rabbit.prototype.constructor == Rabbit ); // true |
- 通常,如果我们什么都不做,
constructor
属性可以通过[[Prototype]]
给所有rabbits
使用:
1 2 3 4 5 6 7 |
function Rabbit() {} // 默认: // Rabbit.prototype = { constructor: Rabbit } let rabbit = new Rabbit(); // 继承自 {constructor: Rabbit} alert(rabbit.constructor == Rabbit); // true (from prototype) |
- 我们可以使用
constructor
属性来创建一个新对象,该对象使用与现有对象相同的构造器- 当我们有一个对象,但不知道它使用了哪个构造器(例如它来自第三方库),并且我们需要创建另一个类似的对象时,用这种方法就很方便
1 2 3 4 5 6 7 8 |
function Rabbit(name) { this.name = name; alert(name); } let rabbit = new Rabbit("White Rabbit"); let rabbit2 = new rabbit.constructor("Black Rabbit"); |
注意2
- 但是,关于
"constructor"
最重要的是JavaScript
自身并不能确保正确的"constructor"
函数值- 它存在于函数的默认
"prototype"
中,但仅此而已。之后会发生什么 —— 完全取决于我们 - 特别是,如果我们将整个默认
prototype
替换掉,那么其中就不会有"constructor"
了
1 2 3 4 5 6 7 |
function Rabbit() {} Rabbit.prototype = { jumps: true }; let rabbit = new Rabbit(); alert(rabbit.constructor === Rabbit); // false |
- 因此,为了确保正确的
"constructor"
,我们可以选择添加/删除属性到默认"prototype"
,而不是将其整个覆盖:- 或者,也可以手动重新创建
constructor
属性:
- 或者,也可以手动重新创建
1 2 3 4 5 6 |
function Rabbit() {} // 不要将 Rabbit.prototype 整个覆盖 // 可以向其中添加内容 Rabbit.prototype.jumps = true // 默认的 Rabbit.prototype.constructor 被保留了下来 |
1 2 3 4 5 6 |
Rabbit.prototype = { jumps: true, constructor: Rabbit }; // 这样的 constructor 也是正确的,因为我们手动添加了它 |
原生的原型
概述
"prototype"
属性在JavaScript
自身的核心部分中被广泛地应用- 所有的内建构造函数都用到了它
Object.prototype
- 假如我们输出一个空对象:
- 生成字符串
"[object Object]"
的代码在哪里? - 那就是一个内建的
toString
方法,但是它在哪里呢?obj
是空的! - 然而简短的表达式
obj = {}
和obj = new Object()
是一个意思,其中Object
就是一个内建的对象构造函数,其自身的prototype
指向一个带有toString
和其他方法的一个巨大的对象
- 生成字符串
1 2 |
let obj = {}; alert( obj ); // "[object Object]" ? |
- 当
new Object()
被调用(或一个字面量对象{...}
被创建),这个对象的[[Prototype]]
属性被设置为Object.prototype
:- 所以,之后当
obj.toString()
被调用时,这个方法是从Object.prototype
中获取的
- 所以,之后当
1 2 3 4 5 6 |
let obj = {}; alert(obj.__proto__ === Object.prototype); // true alert(obj.toString === obj.__proto__.toString); //true alert(obj.toString === Object.prototype.toString); //true |
- 请注意在
Object.prototype
上方的链中没有更多的[[Prototype]]
:
1 |
alert(Object.prototype.__proto__); // null |
其他内建原型
- 其他内建对象,像
Array
、Date
、Function
及其他,都在prototype
上挂载了方法 - 例如,当我们创建一个数组
[1, 2, 3]
,在内部会默认使用new Array()
构造器- 因此
Array.prototype
变成了这个数组的prototype
,并为这个数组提供数组的操作方法 - 这样内存的存储效率是很高的
- 因此
- 按照规范,所有的内建原型顶端都是
Object.prototype
。这就是为什么有人说“一切都从对象继承而来”
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 |
- 一些方法在原型上可能会发生重叠,例如,
Array.prototype
有自己的toString
方法来列举出来数组的所有元素并用逗号分隔每一个元素- 正如我们之前看到的那样,
Object.prototype
也有toString
方法,但是Array.prototype
在原型链上更近,所以数组对象原型上的方法会被使用
- 正如我们之前看到的那样,
1 2 |
let arr = [1, 2, 3] alert(arr); // 1,2,3 <-- Array.prototype.toString 的结果 |
- 浏览器内的工具,像
Chrome
开发者控制台也会显示继承性(可能需要对内建对象使用console.dir
):
1 2 3 4 |
function f() {} alert(f.__proto__ == Function.prototype); // true alert(f.__proto__.__proto__ == Object.prototype); // true,继承自 Object |
基本数据类型
- 最复杂的事情发生在字符串、数字和布尔值上
- 它们并不是对象
- 但是如果我们试图访问它们的属性,那么临时包装器对象将会通过内建的构造器
String
、Number
和Boolean
被创建 - 它们提供给我们操作字符串、数字和布尔值的方法然后消失
- 这些对象对我们来说是无形地创建出来的
- 大多数引擎都会对其进行优化,但是规范中描述的就是通过这种方式
- 这些对象的方法也驻留在它们的
prototype
中,可以通过String.prototype
、Number.prototype
和Boolean.prototype
进行获取
注意1
- 值
null
和undefined
没有对象包装器- 特殊值
null
和undefined
比较特殊 - 它们没有对象包装器,所以它们没有方法和属性。并且它们也没有相应的原型
- 特殊值
更改原生原型
- 原生的原型是可以被修改的
- 例如,我们向
String.prototype
中添加一个方法,这个方法将对所有的字符串都是可用的:- 在开发的过程中,我们可能会想要一些新的内建方法,并且想把它们添加到原生原型中
- 但这通常是一个很不好的想法
1 2 3 4 5 |
String.prototype.show = function() { alert(this); }; "BOOM!".show(); // BOOM! |
注意2
- 原型是全局的,所以很容易造成冲突
- 如果有两个库都添加了
String.prototype.show
方法,那么其中的一个方法将被另一个覆盖 - 所以,通常来说,修改原生原型被认为是一个很不好的想法
- 在现代编程中,只有一种情况下允许修改原生原型。那就是
polyfilling
Polyfilling
是一个术语,表示某个方法在JavaScript
规范中已存在,但是特定的JavaScript
引擎尚不支持该方法,那么我们可以通过手动实现它,并用以填充内建原型- 例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
if (!String.prototype.repeat) { // 如果这儿没有这个方法 // 那就在 prototype 中添加它 String.prototype.repeat = function(n) { // 重复传入的字符串 n 次 // 实际上,实现代码比这个要复杂一些(完整的方法可以在规范中找到) // 但即使是不够完美的 polyfill 也常常被认为是足够好的 return new Array(n + 1).join(this); }; } alert( "La".repeat(3) ); // LaLaLa |
从原型中借用
- 比如有一种简单的方法可以使用数组的
join
方法:- 这个技巧被称为 方法借用(
method borrowing
) - 从常规数组
[].join
中获取(借用)join
方法,并使用[].join.call
在arguments
的上下文中运行它
- 这个技巧被称为 方法借用(
1 2 3 4 5 |
function hash() { alert( [].join.call(arguments) ); // 1,2 } hash(1, 2); |
- 一些原生原型的方法通常会被借用
- 例如,如果我们要创建类数组对象,则可能需要向其中复制一些
Array
方法
- 例如,如果我们要创建类数组对象,则可能需要向其中复制一些
1 2 3 4 5 6 7 8 9 |
let obj = { 0: "Hello", 1: "world!", length: 2, }; obj.join = Array.prototype.join; alert( obj.join(',') ); // Hello,world! |
- 上面这段代码有效,是因为内建的方法
join
的内部算法只关心正确的索引和length
属性- 它不会检查这个对象是否是真正的数组。许多内建方法就是这样
- 另一种方式是通过将
obj.__proto__
设置为Array.prototype
,这样Array
中的所有方法都自动地可以在obj
中使用了- 但是如果
obj
已经从另一个对象进行了继承,那么这种方法就不可行了 - 因为这样会覆盖掉已有的继承
- 此处
obj
其实已经从Object
进行了继承,但是Array
也继承自Object
,所以此处的方法借用不会影响obj
对原有继承的继承,因为obj
通过原型链依旧继承了Object
- 请记住,我们一次只能继承一个对象
- 但是如果
原型方法,没有 proto
的对象
概述
- 使用
obj.__proto__
设置或读取原型被认为已经过时且不推荐使用(deprecated
)了 - 现代的获取/设置原型的方法有:
Object.getPrototypeOf(obj)
—— 返回对象obj
的[[Prototype]]
。Object.setPrototypeOf(obj, proto)
—— 将对象obj
的[[Prototype]]
设置为proto
__proto__
不被反对的唯一的用法是在创建新对象时,将其用作属性:{ __proto__: ... }
- 虽然,也有一种特殊的方法:
Object.create(proto, descriptors)
—— 利用给定的proto
作为[[Prototype]]
和可选的属性描述来创建一个空对象
1 2 3 4 5 6 7 8 9 10 11 12 |
let animal = { eats: true }; // 创建一个以 animal 为原型的新对象 let rabbit = Object.create(animal); // 与 {__proto__: animal} 相同 alert(rabbit.eats); // true alert(Object.getPrototypeOf(rabbit) === animal); // true Object.setPrototypeOf(rabbit, {}); // 将 rabbit 的原型修改为 {} |
Object.create
方法更强大,因为它有一个可选的第二参数:属性描述器- 我们可以在此处为新对象提供额外的属性,就像这样:
1 2 3 4 5 6 7 8 9 10 11 |
let animal = { eats: true }; let rabbit = Object.create(animal, { jumps: { value: true } }); alert(rabbit.jumps); // true |
- 可以使用
Object.create
来实现比复制for..in
循环中的属性更强大的对象克隆方式:- 此调用可以对
obj
进行真正准确地拷贝,包括所有的属性:可枚举和不可枚举的,数据属性和setters/getters
—— 包括所有内容,并带有正确的[[Prototype]]
- 此调用可以对
1 2 3 4 |
let clone = Object.create( Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj) ); |
注意
- 如果速度很重要,就请不要修改已存在的对象的
[[Prototype]]
- 从技术上来讲,我们可以在任何时候 get/set
[[Prototype]]
- 但是通常我们只在创建对象的时候设置它一次,自那之后不再修改:
rabbit
继承自animal
,之后不再更改 - 并且,
JavaScript
引擎对此进行了高度优化。用Object.setPrototypeOf
或obj.__proto__=
“即时”更改原型是一个非常缓慢的操作,因为它破坏了对象属性访问操作的内部优化 - 因此,除非你知道自己在做什么,或者
JavaScript
的执行速度对你来说完全不重要,否则请避免使用它
- 从技术上来讲,我们可以在任何时候 get/set
"Very plain" objects
- 对象可以用作关联数组(
associative arrays
)来存储键/值对- 但是如果我们尝试在其中存储 用户提供的 键(例如:一个用户输入的字典),我们可以发现一个有趣的小故障:所有的键都正常工作,除了
"__proto__"
- 如果用户输入
__proto__
,那么在第四行的赋值会被忽略 __proto__
属性很特殊:它必须是一个对象或者null
。字符串不能成为原型。这就是为什么将字符串赋值给__proto__
会被忽略
- 但是如果我们尝试在其中存储 用户提供的 键(例如:一个用户输入的字典),我们可以发现一个有趣的小故障:所有的键都正常工作,除了
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"! |
- 想要存储键值对,然而键名为
"__proto__"
的键值对没有被正确存储。所以这是一个bug
- 怎么避免这样的问题呢?
- 首先,我们可以改用
Map
来代替普通对象进行存储,这样一切都迎刃而解:
1 2 3 4 5 6 |
let map = new Map(); let key = prompt("What's the key?", "__proto__"); map.set(key, "some value"); alert(map.get(key)); // "some value"(符合预期) |
- 现在,我们想要将一个对象用作关联数组,并且摆脱此类问题,我们可以使用一些小技巧:
Object.create(null)
创建了一个空对象,这个对象没有原型([[Prototype]]
是null
):- 因此,它没有继承
__proto__
的getter/setter
方法。现在,它被作为正常的数据属性进行处理,因此上面的这个示例能够正常工作
1 2 3 4 5 6 7 |
let obj = Object.create(null); // 或者:obj = { __proto__: null } let key = prompt("What's the key?", "__proto__"); obj[key] = "some value"; alert(obj[key]); // "some value" |
- 我们可以把这样的对象称为 “
very plain
” 或 “pure dictionary
” 对象,因为它们甚至比通常的普通对象(plain object
){...}
还要简单- 缺点是这样的对象没有任何内建的对象的方法,例如
toString
: - 但是它们通常对关联数组而言还是很友好
- 缺点是这样的对象没有任何内建的对象的方法,例如
1 2 3 |
let obj = Object.create(null); alert(obj); // Error (no toString) |
- 请注意,大多数与对象相关的方法都是
Object.something(...)
- 例如
Object.keys(obj)
—— 它们不在prototype
中,因此在 “very plain
” 对象中它们还是可以继续使用:
- 例如
1 2 3 4 5 |
let chineseDictionary = Object.create(null); chineseDictionary.hello = "你好"; chineseDictionary.bye = "再见"; alert(Object.keys(chineseDictionary)); // hello,bye |
本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ WebSocket协议相关学习一03/24
- ♥ HTTP协议相关学习一03/22
- ♥ 【Javascript】赋值解构,日期时间,JSON04/05
- ♥ 【Javascript】属性标志,属性描述符,getter,setter04/12
- ♥ 【Javascript】数组,可迭代对象04/04
- ♥ 【Javascript】第一部分05/10