JavaScript "原型链与继承" 到底指的是什么?
前言
原型链与继承
是一道面试题,用来考你对 JavaScript 有关对象的基本理解。
我看了很多文章,尝试想得到理解,但是我的得出的结论:
正确的废话千篇一律,有趣的解读却万里挑一
这些千篇一律的文章,并没有帮我理解,反而加深了我的疑惑。 最后都会变成问题是,JavaScript 实现继承的方式有多少种?实际上死记硬背这些方法根本毫无意义。
如果我背下了下面这段原型链的百科解释,根本没有意义,因为我根本看不懂。
JavaScript 对象是动态的属性(指其自有属性)"包"。JavaScript 对象有一个指向一个原型对象的链。当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。
还有很多用下面这张图片用来解释
当你认真去看这张图片的时候,你是不是更迷惑了?你会不会开始怀疑自己的理解能力有问题,看完之后,好像更懵了,没错,我也是。在我的日常工作中,很多的前端同事对原型链和继承
这两个东西认知很模糊,甚至会认为 原型链 = 继承
。
我充满了疑惑:
原型链
到底是什么东西?继承
到底是什么东西?- 为什么 JavaScript 需要这么多种继承方式?他们之间有什么关系?
- 我能不能不记住这么多种方式?我能不能无脑
class extend
? - 继承和原型链到底是什么关系?
- 为什么 Javascript 要使用
原型链
? - 像 Java 的继承和 JavaScript 的继承有什么区别?
我这个人讨厌一知半解,我想要知道为什么,所以我查阅了大量的资料,我要尝试从根本上去理解 原型链与继承
,我也要解答我心中的这些疑惑。
定义
我如果遇到不懂的东西,我就喜欢抠字眼,原型链与继承
,把它拆解一下就是:
原型链
继承
原型链
先来看看标准的原型链定义:
4.2.1 Objects
Every object created by a constructor has an implicit reference (called the object's prototype ) to the value of its constructor's
"prototype"
property. Furthermore, a prototype may have a non-null implicit reference to its prototype, and so on; this is called the prototype chain.
来看翻译:
构造函数创建的每个对象都有对其构造函数的"prototype"属性值的隐式引用(称为对象的原型)。此外,原型可能具有对其原型的非空隐式引用,等等;这称为原型链。
似懂非懂,把原型链进一步拆解吧:
- Prototype 原型
- Chain 链
Prototype 原型
先跳出 JavaScript 的范畴,这里的Prototype
其实指的是一种常见的开发模式,原型模式 Prototype Pattern
,就是类似工厂模式
,策略模式
等,并非是 JavaScript 独有的。
原型模式
原型模式(Prototype Pattern)是用于创建重复的对象,同时又能保证性能。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式之一。 这种模式是实现了一个原型接口,该接口用于创建当前对象的克隆。当直接创建对象的代价比较大时,则采用这种模式。
这个模式的关键:
- 创建对象
- 克隆对象
- 性能
进一步可以参考: 原型模式
我们再来看看 JavaScript 中,原型的定义:
4.3.5 prototype
object that provides shared properties for other objects
原型的定义就是:对象提供共享的属性给其他对象。
还有上面原型链的定义:
Every object created by a constructor has an implicit reference (called the object's prototype ) to the value of its constructor's
"prototype"
property.
构造函数创建的每个对象都有对其构造函数的"prototype"属性值的隐式引用(称为对象的原型)。
提取这两段文字里面的关键词是
- 创建对象
- 属性共享
- 隐式引用
所以,原型,其实就是一种创建对象,共享属性的一种开发模式,衍生出来的各种__proto__
,hasOwnProperty
其实用来管理原型对象使用的。
链 Chain
好了讲完了原型了,但是你有没有发现,原型链,它的全称应该是:Prototype Chain
,但是大家都是称呼为 Prototype 原型
,却缩略了链 Chain
,这个关键信息。
这里的 链 Chain
实际上是一种常见的数据结构:链表 LinkedList
。
链表是一种用于存储数据的数据结构,通过如链条一般的指针来连接元素。它的特点是插入与删除数据十分方便,但寻找与读取数据的表现欠佳。
这是一段链表的解释,我没有打算在这里详细解释链表,如果有兴趣的话可以参考:
但你把原型链
套进去这个链表,其实每个对象就是一个节点,里面的 __proto__
属性,它就是一个链接到上一个的原型对象。
你看上面的链表
结构,实际上,对象的 __proto__
就是 prev
,它指向的上一个原型对象就是 next
,只是它是一种单向链表
,它只能从后往前查,不能从前往后查,就是你能从 __proto__
一直溯源查到最上层的 prototype
,就是这个链表的头 null
。
如果用一个结构图显示,就是如下(这里面找不到图片,只能自己画,将就着看):
然后关键的一点,当每个子类访问某个方法,或者属性的时候,如果它本身没有,它就会像上面这个链表一样,一层一层的往前去查找,直到找到为止。
这样看,其实下面的这些方法就很好理解了,哪些是在自己的节点里面查询的,哪些是要向上节点查询的。
hasOwnProperty
instanceOf
isPrototypeOf
in
链表 延伸
既然都讲到了链表,再继续深入一下
它的特点是插入与删除数据十分方便,但寻找与读取数据的表现欠佳。
链表的介绍说了,它的特点是插入和删除方便,但读取数据的表现欠佳,那是它用数组 Array
作为比较,数组在寻找和访问有显著的优势,但是链表插入和删除有显著优势。
那你知道这些有什么用?既然你知道了原型链
是链表
结构,那它的特性,你也应该了解它的缺点。
- 如果你创建了一个过长的原型链列表,就是无限套娃,当你的对象要访问的某些方法属性的时候,它的负担会非常重,因为它每次都往前会去查找一遍,会有潜在性能问题。
什么是原型链
你现在回来看开头的那张图,是不是感觉好多了?你只要把原型链
当作一个正常的链表对待,其实它并没这么复杂。
现在我可以来回答第1个问题了,什么是原型链?
结合上面原型,和链的理解,下面是我自己总结的个人理解。
原型链,是 JavaScript 选取的用来创建和管理对象的一种模式,因为它本身是一个单向链表结构,对象的一个隐式对象
__proto__
指向着上一个链表的节点,而链表的头是所有对象的源,也就是null
。原型链中的对象可以共享和使用前面节点的所有属性和方法,而且它拥有链表的特点,可以自由的在整个链中插入或者删除一个原型引用,也不会带来很大的开销,但是原型链的链条太长,每当访问方法和属性时会有很大开销。
继承
这里说的继承 Inheritance
大部分原意,是来源于 OOP (Object-oriented programming 面向对象编程)
的里面的一个特性。
但作为一个正统的 OOP 语言的过来人,OOP 的三大特性我是滚瓜烂熟的:
- 封装 Encapsulation
- 继承 Inheritance
- 多态 Polymorphism
但如果你是一个前端开发者,没有正儿八经的学过 OOP,你看到这些真的会一头雾水,因为要认真讲完 OOP,它可以整整讲一大门学科,我大学里面就有专门的学科教这个OOP -《软件工程》。
但是不是不整套学完这些就无法无法使用继承呢?也不是,因为 JavaScript 并不是一门严谨的 OOP 传统语言,虽然它万物皆对象,但是它的设计哲学是简化和简单易用,没有 Java 那么教条主义。当然这是它的优点也是它的缺点。
简单的说:
继承的作用其实就是为了
代码复用
,子类对象拥有父类对象的属性和方法。
但格局再提升一点,不局限于继承,还有很多种代码复用的方式,严格意义按照 UML 描述来说,有六大类:
- (关联)Association:A类有B类有逻辑上的连接
- (聚合)Aggregation : A类有一个B类
- (组合)Composition : A类拥有一个B类
- (依赖)Dependency : A类使用了B类
- (继承)Inheritance : B类是一个A类 (或者B类扩展A类)
- (实现)Realization : B类实现了接口A
不要局限于 (继承)Inheritance
单个概念之后,这个问题会简单了很多。
延伸扩展 - 系统学习 OOP 有什么好处?
虽说 JavaScript 不是严谨的 OOP 语言,但从OOP中理解继承并非没有好处,可以参考以下链接,作进一步阅读:
理解 JavaScript 面向对象的 封装、继承、多态 三大特性
我摘取里面继承的描述:
继承是面向对象语言中最有意思的概念。
许多面向对象语言都支持两种继承方式,继承通常包括"实现继承"和"接口继承"。
- 接口继承:继承方法签名
- 实现继承:继承实际方法
由于 JS 中没有签名,所以无法实现接口继承,只支持实现继承,依赖原型链实现。
TypeScript 是一门正统的 OOP 语言
JavaScript 的确没有接口继承Interface
这个概念,但是 TypeScript
有🐶,在我工作中,设计了一些Interface
的时候,我发现很多前端同事是无法理解这个东西,他们也分不出来这和 type
有什么区别,类似的东西还有很多,例如范型。
所以我单独拎这个出来是想说,如果你想在前端的路上走的更远,TypeScript
是一个必须要学习的东西,而要学习 TypeScript
,里面有很多特性是静态语言,是 OOP 传统语言过来的特性(毕竟是 C# 的创始人亲自操刀的语言),系统的学习 OOP 其实很有必要。
格局再放大一点,如果你想把程序设计再上一个层次,各类设计模式,OOP 是你无法越过的一个鸿沟,如果是一个架构师(虽然前端目前不太需要架构师),哪怕你只是你想设计一个好维护,易扩展的 UI 组件库, 如果你熟练掌握 OOP 会对你的程序设计能力上一个台阶。
那为什么 JavaScript 需要这么多种实现继承的方法?
那回来 JavaScript 这里,为了解答这个疑惑,但是我查阅了很多资料之后,得出了一个答案:
因为历史问题
JavaScript 的前世今生
JavaScript 的继承机制让人疑惑其实是 JavaScript 的设计和历史原因带来的。
JavaScript 这个名字,在今天看来我们早就见怪不怪,但是在刚出现的时候,用现在的话就是蹭热度,看起来和 Java 有点关系,但是他们就像雷锋和雷峰塔的区别一样。
当年最流行的语言是:C/C++,但 Java 异军突起,OOP 是当时最时髦的元素。
JavaScript 自然不会错过这个热点,但是因为它的出发点只是作为一个脚本语言,并不想设计得太复杂,所以它最开始没有 类Class
,但是没有类,要怎么实现继承?
1. 原型对象继承
JavaScript 为了简洁,在设计之初就使用了原型链
,你只要把子类对象的 prototype
原型对象指向父类对象,你就可以获得父类对象的所有方法和属性。
这就是继承的最基础的方法之一,我引用了这篇文章的代码作为案例:
为了减少文章篇幅,我就不仔细展开每种继承的优缺点去分析,你们可以直接看上面文章
javascript
function Parent () {
this.name = 'kevin';
}
Parent.prototype.getName = function () {
console.log(this.name);
}
function Child () {
}
Child.prototype = new Parent();
var child1 = new Child();
console.log(child1.getName()) // kevin
虽然这个方法的确很简单,但是会带来一个大麻烦,首先它的父类其实是一个实例化的对象,所以当父类的值改变之后,所有的子类对象都会改变。
作为一个传统的 OOP 开发者,一看到这个,其实它根本就不是继承,因为父类的属性更像是传统 OOP 的 static
属性。
然后他也无法给传值给父类。
还有一种类似的继承:
javascript
function createObj(o) {
function F(){}
F.prototype = o;
return new F();
}
问题和上面是一模一样的。
2. 构造函数继承
为了解决污染问题,又冒出了一个新的继承方式,类似构造函数一样的继承方式。
javascript
function Parent () {
this.names = ['kevin', 'daisy'];
}
function Child () {
Parent.call(this);
}
var child1 = new Child();
child1.names.push('yayu');
console.log(child1.names); // ["kevin", "daisy", "yayu"]
var child2 = new Child();
console.log(child2.names); // ["kevin", "daisy"]
由于它的原型对象,是每次都初始化的,所以,它就解决了属性被污染的问题了。
但是问题又来了,因为它在初始化的时候创建方法,所以它会每次都会初始化一次 Parent
3. 组合继承
然后就搞出了一个组合继承,就是把前面两种继承融合一下。
javascript
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.getName = function () {
console.log(this.name)
}
function Child (name, age) {
Parent.call(this, name);
this.age = age;
}
Child.prototype = new Parent();
var child1 = new Child('kevin', '18');
console.log(child1)
4. 寄生继承
接下来就开始有点扯淡,搞了一个很高大上的名字寄生,
javascript
function createObj (o) {
var clone = Object.create(o);
clone.sayName = function () {
console.log('hi');
}
return clone;
}
其实就是 ES5 增加了一个新的方法 Object.create
,使用它可以简化 构造函数继承 ,但是它的问题和构造函数继承 是一样的。
5. 组合寄生继承
所以最后又发明了一种终极的解决方法:组合寄生继承
javascript
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
function prototype(child, parent) {
var prototype = object(parent.prototype);
prototype.constructor = child;
child.prototype = prototype;
}
// 当我们使用的时候:
prototype(Child, Parent);
引用《JavaScript高级程序设计》中对寄生组合式继承的夸赞就是:
这种方式的高效率体现它只调用了一次 Parent 构造函数,并且因此避免了在 Parent.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。
但是我不知道为什么,只感觉有点搞笑。
6. Class Extend
看到一个终于像样的了,来自于 ES2015 的标准,但实际上它的本质就是对 组合寄生继承 的一种语法糖,底层实现实际上就是组合寄生继承。
javascript
class Cake {
constructor(size) {
this.size = size;
}
cook() {
console.log('cook', this.size);
}
}
class CupCake extends Cake {
constructor(size) {
super(size);
}
cup() {
console.log('I am a cup cake');
}
}
let cupcake1 = new CupCake('100');
let cupcake2 = new CupCake('200');
有关 JavaScript 继承的吐槽
作为一个传统 OOP 开发者看到这些乱七八糟的继承的时候,我真的有一种想骂人的冲动,不过我仔细想了想之后,JavaScript 其实一开始并没有想成为传统的 OOP 语言,所以无法实现传统的继承,但是很多开发者有这样的需要,就缝缝补补,也发明了很多野生的组合方法来达到这个目的,结果到最后终于还是使用了最传统的 class extend
的方式。
所以其实你根本没必要记住这么多的继承方式,你只要无脑使用 class extend
即可,但是作为一个合格的前端开发,学会组合寄生继承也未尝不可,总得有时候要看一些 ES5 的老代码的时候,另外,理解它的底层实现也是一件好事。
所以你现在看这题:JavaScript实现继承的方法有多少种? 你去背这种东西根本没有意义,面试问这种问题,也真的没什么意义。
为什么 JavaScript 要用原型链?
基于我上面的理解,我自己的总结:
JavaScript 使用了原型链的方式来创建对象和共享对象的属性、方法,这是 JavaScript 的选择的一种策略,因为 JavaScript 本身是一种脚本语言,简洁易用是它的目的,原型链拥有很不错的创建对象的性能,而且很容易的共享里面的属性。
原型链和继承到底是什么关系?
你有没有发现,其实继承
压根和原型链
没有什么直接的关系。
所以不要再认为 原型链 = 继承
,他们两个是完全不同的东西。
JavaScript 使用原型链用来创建对象和共享对象的属性,而继承是 OOP 中的一个特性,他们之间的关系就是,想要在 JavaScript 中实现继承,就需要用到 PrototypeChain 原型链
的特性,仅此而已。
Java 是怎么实现继承的?
我一直说了 JavaScript 使用原型链来实现继承,但是你有没有想过其他语言,特别是 Java 这种语言是怎么实现的?
JavaScript 的继承
先来看 JavaScript 现在我们直接看一个例子:
假设现在有 CupCake,继承了父类 Cake
以下是 ES6 的代码
javascript
class Cake {
constructor(size) {
this.size = size;
}
cook() {
console.log('cook', this.size);
}
}
class CupCake extends Cake {
constructor(size) {
super(size);
}
cup() {
console.log('I am a cup cake');
}
}
let cupcake1 = new CupCake('100');
let cupcake2 = new CupCake('200');
在 Chrome 里面运行的话,内存结构是这样的,可以看到里面有3个对象,两个子类 CupCake 对象和一个父类 Cake 对象。
展开之后可以看到,每个子类 CupCake 有自己的成员属性(size)
然后可以看到,两个 CupCake 对象的 __proto__
指向的是都是 Cake @910135
这个对象
有没有发现,JavaScript 的的继承其实,它是同时初始化,父类对象,和子类对象的,因为本质你写的 Class Extend
的语法,它本来就是组合寄生继承
的语法糖,它本质还是两个对象,他们通过 prototype
的链条链接在一起,因此子类可以共享父类的属性。
Java 的继承
但实际上,在 Java 的语言实现里面,Java 的继承完全不是这么一回事。
Java 的继承,首先要解释 Class 类
和 Object 对象
的概念,你可以理解 Class 是一张设计图,它并不是实物,当你 new 一个 Object 的时候,其实用这个设计图生成了一个对象,在内存里面,只有对象,并没有类这个东西,因为 Java 是静态语言,它也要经过编译把语言转义,所以类和对象的概念,只存在设计阶段,在运行阶段并没有。
严格意义来说 Java 其实也有 Class 这个对象,但是它更多是用来做反射等,实现一些高级特性,它更像 JavaScript 中 Module
这个概念,但这不妨碍我的这个结论
自然的,上面提到的父类,子类的对象,在 Java 的世界,当你实例化一个子类的时候,只有 CupCake1
和 CupCake2
子类的对象,父类的方法和属性,其实都存在于每个子类中,但是内存里面并没有一个父类的对象出现。
继承的概念,只是说,Java 里面有两张设计图,一张是 Cake
,一张是 CupCake
,他们是继承关系。
这就是 Java 和 JavaScript 实现继承最大的区别,它们是本质上的区别。
更多有关 Java 继承相关的可以查阅:
后话
总结一下:
- 继承是 OOP 的其中一种特性
- 因为 JavaScript 的历史问题导致,要实现继承走了很多弯路,才有现在这么多种实现方式
- 无脑使用
class extend
即可,但了解 组合寄生继承 的原理有助你理解 JavaScript - 原型链是 JavaScript 选择的一种用来创建对象和共享属性的策略
- 原型本身就是一种开发模式,而原型链本身又是一种链表结构,把它当作普通的链表对待,很多东西会容易理解很多
- 继承只是代码复用的其中一种模式
- 并不是只有原型链可以实现继承,只是 JavaScript 选择了这种策略而已,他们两个不是对等关系
分享一下我学习的路径:
- 每个问题都是可以分解的,例如,
原型链与继承
它其实就是原型链
和继承
,原型链还可以进一步分解原型
和链
- 然后对每个分解下来的点逐个去挖掘并理解,
原型
是什么,链
是什么,继承
又是什么? - 如果有些还是不明白,就再放大一点格局,例如,原型模式开发,其他语言又是怎么实现继承的?大胆假设,小心求证
- 最后最重要的一点,把这些知识点全部串起来,当你串起来的那一刻,你会对这些知识有了新的理解
然后:
- 不要背题,不要流于表面的理解,除了让你应付面试题,它并没有帮助到你,相反可能让你误解为你掌握了,而且真正的技术面试,会进一步考你对其中的理解,你理解不理解很容易知道
- 自己不懂的东西,大胆的提出疑问,并且尝试一一去解答
- 没有答案就自己找,世界上不缺千篇一律的正确废话,但缺少有独特的见解,哪怕它不一定对的