写代码时,我们通常有扩展某些对象的需求。
例如,我们有一个对象变量 user
,它有一些属性和方法。对象 admin
和 guest
与它基本相同,可以看成是 user
的轻度变体。我们想要重用 user
里的属性和方法,不想通过复制已有代码的方式去实现,只是想在 user
的基础上构建出这两个对象。
原型继承就是用来解决这个问题的语言特性。
[[Prototype]]
JavaScript 中,每次对象都有一个隐藏的属性 [[Prototype]]
(规范中的名称),它的值为 null
或是另一个对象的引用。这个对象称为“原型(prototype)”。
[[Prototype]]
有个“神奇”的地方。当我们从一个 object
里读属性的时候,如果这个属性在不存在,JavaScript 就会自动从原型里查找。在编程世界里,这叫“原型继承(prototypal inheritance)”。许多酷酷的语言是特性和编程技巧都是基于此的。
[[Prototype]]
虽是一个内部属性,但还是有许多方法操作它。
方法之一是使用 __proto__
:
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal;
💡 提示:**
__proto__
是历史遗留属性,实际上是基于[[Prototype]]
的访问器属性(getter/setter)**需要注意的是,
__proto__
不等于[[Prototype]]
,它只是基于后者的 getter/setter。它是因为历史原因而保留下来的。现在可以使用
Object.getPrototypeOf
/Object.setPrototypeOf
方式替代直接操作__proto__
,用来获取和设置原型。使用这两个函数的原因会在之后介绍。根据规范,
__proto__
属性浏览器环境提供,但实际上现在所有的宿主环境(包括服务器端)都支持这个接口。当前使用__proto__
属性来说明原型继承更加直观,因此本篇例子我们还是采用__proto__
。
如果我们访问 rabbit
里的一个属性,但没有的话,JavaScript 会自动从 animal
中查找。
例如:
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal; // (*)
// eats 和 jumps 属性都能在 rabbit 上访问到
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true
我们在 (*) 处将 rabbit
的原型设置为 animal
。
然后使用 rabbit.eats(**)
读取属性 eats
,发现不在 rabbit
里,因此 JavaScript 会接着 查找 [[Prototype]]
引用,最终在 animal
中发现了这个属性(从下往上看图)。
我们可以说“animal
是 rabbit
的原型”或“rabbit
的原型继承自 animal
”。
所以,如果 animal
中很多有用的属性和方法的话,在 rabbit
中也能得到。这些属性是“继承的”。
如果 animal
中有个方法,则在 rabbit
中也能调用:
let animal = {
eats: true,
walk() {
alert("Animal walk");
}
};
let rabbit = {
jumps: true,
__proto__: animal
};
// walk 方法来自原型
rabbit.walk(); // Animal Walk
walk
方法是在原型里定义的,我们能通过继承关系来调用。
当然,原型链还可以更长:
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)
这种链式的关系存在两点限制:
不能循环引用。如果在
__proto__
上循环引用的话,会报错。__proto__
的值可以是一个对象,也可以为null
。使用其他类性值设置原型会被忽略。
还有一个很明显的限制,就是一个对象只可能有唯一一个 [[Prototype]]
。也就是说,一个对象不可能同时有两个原型对象。
不能写入原型属性
我们只能读取原型属性,不能写入原型属性。写入/删除属性的操作是直接作用在对象上的。
下例中,rabbit
有自己的 walk
方法:
let animal = {
eats: true,
walk() {
/* 这个方法不会被 rabbit 调用 */
}
};
let rabbit = {
__proto__: animal
};
rabbit.walk = function() {
alert("Rabbit! Bounce-bounce!");
};
rabbit.walk(); // Rabbit! Bounce-bounce!
现在,rabbit.walk()
调用直接在对象上就能找到 walk
并执行,原型上的同名方法因此不会被调用:
但访问器属性是个例外。对访问器属性赋值,实际上是在调用它的 setter 函数。因此,对访问器属性赋值等于在调用函数。
下面代码中,admin.fullName
属性就可以被成功赋值。
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
admin.fullName = "Alice Cooper"; // (**)
() 处访问 admin.fullName
会调用原型对象 user
里的 getter
。(*) 处给 admin.fullName
赋值,会调用原型对象 user
里 setter。
this
值
看完上面的代码,你可能会存在一个疑问:set fullName(value)
里的 this
指向的是谁呢?user
还是 admin
?
答案很简单:this
一点也不受原型影响。
不管方法是在哪里找到:对象或原型里。方法中的 this
总是指向点(.
)前面的那个对象。
因此,setter 调用 admin.fullName=
中的 this
指向的是 admin
,而非 user
。
这实际上是一件非常重要的事情,因为我们可能有一个包含许多方法的大对象,有其他对象继承自这个大对象。当我们在继承对象上运行继承方法的时候,最总修改的是继承对象自身的状态,而不是大对象的。
**
下例中,animal
表示“存储方法的地方”,rabbit
使用了它的方法。
调用 rabbit.sleep()
,函数体里的 this.isSleeping
含义是指设置 rabbit
对象上的 isSleeping
属性。
let animal = {
walk() {
if (!this.isSleeping) {
alert('I Walk、')
}
},
sleep() {
this.isSlleeping = true;
}
};
let rabbit = {
name: "White Rabbit",
__proto__: animal
};
// 修改 rabbit.isSleeping
rabbit.sleep();
alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined (在原型对象里没有这个属性)
上述对象的继承关系如下:
如果我们还有像 bird
、snake
等其他的继承自 animal
的对象。它们也能访问 animal
中的方法。但在每个方法调用里的 this
,都是指向各自调用对象的(即 .
运算符之前的对象),而非 animal
。因此,当向 this
写入数据时,实际上是在向这些对象写入。
结果,方法共享了,对象状态也存在于各自的对象之中。
for…in 循环
for...in
循环也会遍历出继承属性。
例如:
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
// Object.keys 方法仅返回自身属性
alert(Object.keys(rabbit)); // jumps
// for..in 循环除了能返回自身属性,还会返回继承属性
for(let prop in rabbit) alert(prop); // jumps, 然后是 eats
如果我们想要排除继承属性,可以使用内置方法 obj.hasOwnProperty(key)
实现:如果 key
是对象 obj
的自身属性就返回 true
,否则 false
。
据此,我们就能过滤掉继承属性了(或用继承属性做其它事情)。
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
继承自 animal
,animal
继承自 Object.prototype
(因为这里的 animal
是用字面量形式 {...}
创建的),Object.prototype
的原型则为 null
了:
注意,这里有件有趣的地方。rabbit.hasOwnProperty
方法是来自哪里的呢?我们没有定义它。查看原型链,我们看见这个方法来自 Object.prototype.hasOwnProperty
。换句话说,是继承过来的。
但为何 hasOwnProperty
没有像 eats
、jumps
那样出现在 for...in
循环中呢?不是说 for...in
也会遍历继承属性吗?
答案也比较简单:因为 hasOwnProperty
是不可枚举的。Object.prototype
对象上的所有属性都标记了 enumerable: false
,而 for...in
之会遍历出可枚举属性。这就是为何 Object.prototype
上的属性都没有遍历出来的原因。
💡 提示:**几乎所有键/值获取方法(key/value-getting methods**)都会忽略继承属性
几乎所有键/值获取方法,比如
Object.keys
、Object.values
这些都会忽略继承属性。这些方法只操作对象本身,来自原型的属性不会考虑在内。
总结
JavaScript 中,所有的对象都包含一个隐藏的
[[Prototype]]
属性,它的值可能是一个对象或者null
。我们可以使用
obj.__proto__
属性访问它(这个属性其实是个历史遗留属性,实际上是基于[[Prototype]]
的访问器属性,之后会介绍)。被
[[Prototype]]
引用的对象称为“原型”。如果我们想访问
obj
的一个属性或调用它的一个方法,如果obj
没有的话,JavaScript 就会去原型里查找。- 写入/删除操作是直接作用在对象上的,并不会涉及原型(假设是数据属性,而不是访问器属性的 setter)。
- 如果我们调用
obj.method
,并且method
来自原型的话,方法内的this
仍是指向obj
的。所以说,即便调用的方法是继承的,可操作的还是对象自身。 for...in
循环既会遍历自身属性,也会遍历继承属性。几乎所有键/值获取方法都是操作的对象自身属性的。
(完)
📄 文档信息
🕘 更新时间:2020/01/14
🔗 原文链接:http://javascript.info/prototype-inheritance