【译】继承与原型链(Inheritance and the prototype chain)
继承与原型链(Inheritance and the prototype chain)。
原文来自 MDN JavaScript 主题的高阶教程部分,一共 5 篇,分别涉及继承和原型链、严格模式、类型数组、内存管理、并发模型和事件循环。本篇是第 1 篇,关于继承与原型链。
2023-06-23 更新: 今天发现,MDN 原文结构进行了调整,原来高级教程的 5 篇文章只剩下了 3 篇,分别是继承和原型链、内存管理、并发模型和事件循环,其中,严格模式被转移到了 References/Misc 下,类型数组被转移到了 JavaScript Guide 下。
对于熟悉基于类的编程语言(例如 Java 和 C++)的开发者来说,JavaScript 会让他们感到困惑,因为 JS 的动态性以及其本身并不提供class
的实现(ES2015 中提出的class
关键字仅仅是语法糖,JS 仍然是基于原型的)
提到继承,JavaScript 只有一个结构:对象(objects)。每个对象都有一个私有属性,该属性链接到另一个对象(称为该对象的原型(prototype))。这个原型对象自身也有一个原型,直到一个对象的原型为null
。根据定义,null
不存在原型,它代表这条原型链的终点。
在 JavaScript 中,几乎所有对象都是Object
的实例,Object
在原型链顶端。
尽管这种困惑经常被认为是 JavaScript 的缺点,但是这种原型式的继承模型实际上比一些经典的模型更为强大。例如,在一个原型式模型的基础上再构造一个经典模型是非常简单的。
1 通过原型链继承
1.1 继承属性
JavaScript 对象就像一堆属性的动态“包裹”(这堆属性称为对象自身属性)(译者注:原文为 JavaScript objects are dynamic “bags” of properties (referred to as own properties).)。 JavaScript 对象有一个指向原型对象的链接。当访问一个对象的属性时,不仅会在该对象上查找,还会在该对象的原型,以及这个原型的原型上查找,直到匹配上这个属性名或者遍历完该原型链。
根据 ECMAScript 标准,
someObject.[[Prototype]]
用于指定someObject
的原型。从 ECMAScript 2015 开始,[[Prototype]]
可以通过Object.getPrototypeOf()
和Object.setPrototypeOf()
访问。这和通过 JavaScript 中的__proto__
访问是一样的,尽管这不标准,但是已经被很多浏览器所实现。 最好不要和函数的func.prototype
属性混淆。当一个函数被当做构造器(constructor)调用时,会生成一个对象,而函数上的func.prototype
属性引用的对象会作为生成对象的[[Prototype]]
存在。**Object.prototype
**就表示了Object
这一函数的 prototype。
下面例子展示了访问对象属性的过程:
|
|
在一个对象上设置属性称为创建了一个”自身属性“(译者注:原文为Setting a property to an object creates an own property.)。唯一会影响属性 set 和 get 行为的是当该属性使用getter 或者 setter定义。
1.2 继承“方法”
JavaScript 中并没有像在基于类语言中定义的”方法“。在 JavaScript 中,任何函数也是以属性的形式被添加到对象中,继承的函数和其他继承的属性一样,也存在上面提到的”属性覆盖”(这里叫做方法覆盖(method overriding))。
当一个继承的函数被执行时,函数内的this
指向当前继承的对象,而不一定是将该函数作为“自身属性“的对象本身。
|
|
2 在 JavaScript 中使用原型
让我们更详细地来看看背后的原理。
在 JavaScript 中,正如上面提到,函数也可以拥有属性。所有函数都有一个特殊的属性prototype
。请注意下面的代码是独立的(可以安全地假设网页中除了下面的代码就没有其他代码了)。为了更好的学习体验,非常推荐你打开浏览器的控制台,点击’console’标签,复制粘贴以下代码,点击 Enter/Return 键来执行它。(大多数浏览器的开发者工具(Developer Tools)中都包含控制台。详情请查看Firefox 开发者工具、Chrome 开发者工具,以及[Edge 开发者工具](Edge DevTools))
|
|
可以在 console 中看到,doSomething()
有一个默认的prototype
属性,打印的内容和下面类似:
|
|
如果我们在doSomething()
的prototype
上添加属性,如下:
|
|
结果为:
|
|
现在我们可以通过new
操作符来基于这个 prototype 对象创建doSomething()
的实例。使用new
操作符调用函数只需要在调用前加上new
前缀。这样该函数会返回其自身的一个实例对象。接着我们便可以往该实例对象上添加属性:
|
|
打印结果和如下类似:
|
|
可以得知,doSomeInstancing
的__proto__
就是doSomething.prototype
。但是,这代表什么呢?放你访问doSomeInstancing
的一个属性时,浏览器会首先查看doSomeInstancing
自身是否存在该属性。
如果不存在,浏览器会继续查找doSomeInstancing
的__proto__
(或者说是 doSomething.prototype
)。如果存在,则doSomeInstancing
的__proto__
的这个属性会被使用。
否则,会继续查找doSomeInstancing
的__proto__
的__proto__
。默认情况下,任何函数 prototype 属性的__proto__
属性就是window.Object.prototype
。因此,会在doSomeInstancing
的__proto__
的__proto__
(或者说是doSomething.prototype.__proto__
,或者说是Object.prototype
)继续查找对应属性。
最终,直到所有的__proto__
被查找完毕,浏览器会断言该属性不存在,因此得出结论:该属性的值为 undefined。
然我们在 console 上再添加一些代码:
|
|
结果如下:
|
|
3 使用不同的方法创建对象和原型链
3.1 使用语法结构(字面量)创建对象
|
|
3.2 使用构造器函数
构造器函数和普通函数的差别就在于其恰好使用new
操作符调用
|
|
3.3 使用 Object.create
ECMAScript 提出了一个新方法:Object.create()
。调用该方法时会创建一个新对象。这个对象的原型为传入该函数的第一个参数:
|
|
3.4 与Object.create
和new
操作符一起,使用delete
操作符
下面的示例使用Object.create
创建一个对象,并使用delete
操作符来展示原型链的变化
|
|
如果换成new
操作符创建对象,原型链更短:
|
|
3.5 使用 class 关键字
ECMAScript 2015 提出了一系列新的关键字用于实现类。包括class
、constructor
、static
、extends
以及super
。
|
|
3.6 关于性能
如果需要查找的对象属性位于原型链的顶端,查找时间会对性能有影响,尤其对于对性能要求很高的应用来说,影响会进一步放大。另外,如果是访问一个不存在的属性,总是会遍历整条原型链。
此外,当对对象的属性进行迭代查找时,原型链上所有可枚举的属性都会被遍历。为了检查哪些属性是对象的自身属性而不是来自其原型链,很有必要使用继承自Object.prototype
的hasOwnProperty
方法。下面来看一个具体的例子,该例子继续使用上一个图形的例子:
|
|
hasOwnProperty
是 JavaScript 中查找对象属性时唯一不遍历原型链的方法。
注意:仅仅检查属性是undefined
并不能代表该属性不存在,也许是因为它的值恰好被设置为了undefined
。
3.7 不好的实践:对原生的 prototypes 进行扩展
经常容易犯的一个错误是扩展Object.prototype
或者是一些其他内置的 prototype。
这被称为是”猴子补丁“,会打破程序的封装性。尽管在一些出名的框架中也这样做,例如 Prototype.js,但是仍然没有理由在内置类型上添加非标准的功能。
扩展内置类型的唯一理由是保证一些早期 JavaScript 引擎的兼容性,例如Array.forEach
(译者注:Array.forEach
是在 ECMA-262-5 中提出,部分早期浏览器引擎没有实现该标准,因此需要 polyfill)
3.8 继承原型链的方法总结
下面表格展示了四种方法以及它们各自的优缺点。以下例子创建的inst
对象完全一致(因此控制台打印的结果也一样),除了它们之间有不同的优缺点。
名称 | 举例 | 优点 | 缺点 |
---|---|---|---|
使用new 初始化 |
function foo(){}
foo.prototype = {
foo_prop: "foo val"
};
function bar(){}
var proto = new foo;
proto.bar_prop = "bar val";
bar.prototype = proto;
var inst = new bar;
console.log(inst.foo_prop);
console.log(inst.bar_prop);
| 支持所有浏览器(甚至到IE 5.5),同时,运行速度、标准化以及JIT优化性都非常好 | 问题是,为了使用该方法函数必须被初始化。在初始化过程中,构造函数可能会为每个创建对象创建一些特有属性,然而例子中只会构造一次,因此这些特有信息只会生成一次,可能存导致潜在问题。 之外,构造函数初始化时可能会添加冗余的方法到实例对象上。不过,只要这是你自己的代码且你明确这是干什么的,这些通常来说也不是问题(实际上是利大于弊)。 |
使用Object.create |
function foo(){}
foo.prototype = {
foo_prop: "foo val"
};
function bar(){}
var proto = Object.create(
foo.prototype
);
proto.bar_prop = "bar val";
bar.prototype = proto;
var inst = new bar;
console.log(inst.foo_prop);
console.log(inst.bar_prop);
function foo(){}
foo.prototype = {
foo_prop: "foo val"
};
function bar(){}
var proto = Object.create(
foo.prototype,
{
bar_prop: {
value: "bar val"
}
}
);
bar.prototype = proto;
var inst = new bar;
console.log(inst.foo_prop);
console.log(inst.bar_prop)
| 支持目前所有的现代浏览器,包括非IE浏览器以及IE9及以上版本浏览器。相当于允许一次性设置__proto__ ,这样有利于浏览器优化该对象。同时也允许创建没有原型的对象例如:Object.create(null) | 不支持IE8以及以下版本浏览器,不过,微软目前已不再支持运行这些浏览器的操作系统,对大多数应用来说这也不是一个问题。 之外,如果使用第二个参数,则对象的初始化会变慢,这也许会成为性能瓶颈,因为第二个参数作为对象描述符属性,每个对象的描述符属性是另一个对象。当以对象形式处理成千上万的对象描述符时,可能会严重影响运行速度。 |
使用Object.setPrototypeOf |
function foo(){}
foo.prototype = {
foo_prop: "foo val"
};
function bar(){}
var proto = {
bar_prop: "bar val"
};
Object.setPrototypeOf(
proto, foo.prototype
);
bar.prototype = proto;
var inst = new bar;
console.log(inst.foo_prop);
console.log(inst.bar_prop);
function foo(){}
foo.prototype = {
foo_prop: "foo val"
};
function bar(){}
var proto;
proto = Object.setPrototypeOf(
{ bar_prop: "bar val" },
foo.prototype
);
bar.prototype = proto;
var inst = new bar;
console.log(inst.foo_prop);
console.log(inst.bar_prop)
| 支持目前所有的现代浏览器,包括非IE浏览器以及IE9及以上版本浏览器。支持动态的操作对象的原型,甚至可以为Object.create(null) 创建的对象强制添加一个原型 | 由于性能不佳,应该会被弃用。如果你敢在生产环境中使用这样的语法,JavaScript代码快速运行几乎不可能。因为许多浏览器优化了原型,举个例子,在访问一个对象上的属性之前,编译器会提前确定原型上的属性在内存中的位置,但是如果使用了Object.setPrototypeOf 对原型进行动态更改,这相当于扰乱了优化,甚至会让编译器重新编译并放弃对这部分的优化,仅仅是为了能让你这段代码跑起来。
同时,不支持IE8以及以下版本浏览器 |
使用__proto__ |
function foo(){}
foo.prototype = {
foo_prop: "foo val"
};
function bar(){}
var proto = {
bar_prop: "bar val",
__proto__: foo.prototype
};
bar.prototype = proto;
var inst = new bar;
console.log(inst.foo_prop);
console.log(inst.bar_prop);
var inst = {
__proto__: {
bar_prop: "bar val",
__proto__: {
foo_prop: "foo val",
__proto__: Object.prototype
}
}
};
console.log(inst.foo_prop);
console.log(inst.bar_prop)
| 支持目前几乎所有的现代浏览器,包括非IE浏览器以及IE11及以上版本浏览器。将__proto__ 设置为非对象的类型不会抛出异常,但是会导致程序运行失败 | 严重过时而且性能不佳。如果你敢在生产环境中使用这样的语法,JavaScript代码快速运行几乎不可能。因为许多浏览器优化了原型,举个例子,在访问一个对象上的属性之前,编译器会提前确定原型上的属性在内存中的位置,但是如果使用了__proto__ 对原型进行动态更改,这相当于扰乱了优化,甚至会让编译器重新编译并放弃对这部分的优化,仅仅是为了能让你这段代码跑起来。
同时,不支持IE10及以下版本浏览器。 |
4 prototype
和Object.getPrototypeOf
对于从 Java 和 C++过来的开发者来说,JavaScript 会让他们感到有些困惑,因为 JavaScript 是动态类型、代码无需编译可以在 JS Engine 直接运行(译者注:Java 代码需要编译成机器码后在 JVM 执行),同时它还没有类。所有的几乎都是实例(objects)。尽管模拟了class
,但其本质还是函数对象。
你也许注意到了function A
上有一个特殊的属性prototype
。这个特殊属性与 JavaScriptnew
操作符一起使用。当使用new
操作符创建出来一个实例对象,这个特殊属性prototype
会被复制给该对象的内部[[Prototype]]
属性。举个例子,当运行var a1 = new A()
代码时,JavaScript(在内存中创建完新实例对象之后且准备运行函数A()
之前,运行函数时函数内部的this
会指向该对象)会设置:a1.[[Prototype]] = A.prototype
。
当你之后访问创建的对象属性时,JavaScript 首先会检查属性是否存在于对象本身,如果不存在,则继续查找其[[Prototype]]
。这意味着你在prototype
上定义的属性实际上被所有实例对象共享,如果你愿意,甚至可以修改prototype
,这些改动会同步到所有存在的实例对象中。
如果在上面的例子中,你执行:var a1 = new A(); var a2 = new A();
,那么a1.doSomething
就是Object.getPrototypeOf(a1).doSomething
,这和你定义的A.prototype.doSomething
是同一个对象,所以:Object.getPrototypeOf(a1).doSomething === Object.getPrototypeOf(a2).doSomething === A.prototype.doSomething
。
简而言之,prototype
是针对类型的,而Object.getPrototypeOf()
对于实例对象是一致的。(译者注:原文为In short, prototype
is for types, while Object.getPrototypeOf()
is the same for instances.)。
[[Prototype]]
会被递归地查找,例如:a1.doSomething
, Object.getPrototypeOf(a1).doSomething
, Object.getPrototypeOf(Object.getPrototypeOf(a1)).doSomething
等等,直到Object.getPrototypeOf
返回null
。
因此,当你执行:
|
|
实际上是执行:
|
|
接着如果你访问:
|
|
JavaScript 会检查是否 o 上存在自身属性someProp
。如果不存在,继续检查Object.getPrototypeOf(o).someProp
是否存在,如果还不存在继续检查Object.getPrototypeOf(Object.getPrototypeOf(o)).someProp
,依次类推。
5 总结
在编写基于原型的复杂代码之前,很有必要先理解原型式的继承模型。同时,请注意代码中原型链的长度,并且在必要时将其分解以避免可能存在的性能问题。此外,应该杜绝在原生的原型对象上进行扩展,除非是为了考虑兼容性,例如在老的 JavaScript 引擎上适配新的语言特性。
Tags: Advanced Guide Inheritance JavaScript OOP