JavaScript “原型链与继承” 到底指的是什么?

JavaScript "原型链与继承" 到底指的是什么?

前言

原型链与继承是一道面试题,用来考你对 JavaScript 有关对象的基本理解。

我看了很多文章,尝试想得到理解,但是我的得出的结论:

正确的废话千篇一律,有趣的解读却万里挑一

这些千篇一律的文章,并没有帮我理解,反而加深了我的疑惑。 最后都会变成问题是,JavaScript 实现继承的方式有多少种?实际上死记硬背这些方法根本毫无意义。

如果我背下了下面这段原型链的百科解释,根本没有意义,因为我根本看不懂。

JavaScript 对象是动态的属性(指其自有属性)"包"。JavaScript 对象有一个指向一个原型对象的链。当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。

还有很多用下面这张图片用来解释

当你认真去看这张图片的时候,你是不是更迷惑了?你会不会开始怀疑自己的理解能力有问题,看完之后,好像更懵了,没错,我也是。在我的日常工作中,很多的前端同事对原型链和继承这两个东西认知很模糊,甚至会认为 原型链 = 继承

我充满了疑惑:

  1. 原型链到底是什么东西?
  2. 继承到底是什么东西?
  3. 为什么 JavaScript 需要这么多种继承方式?他们之间有什么关系?
  4. 我能不能不记住这么多种方式?我能不能无脑 class extend
  5. 继承和原型链到底是什么关系?
  6. 为什么 Javascript 要使用 原型链?
  7. 像 Java 的继承和 JavaScript 的继承有什么区别?

我这个人讨厌一知半解,我想要知道为什么,所以我查阅了大量的资料,我要尝试从根本上去理解 原型链与继承,我也要解答我心中的这些疑惑。

定义

我如果遇到不懂的东西,我就喜欢抠字眼,原型链与继承,把它拆解一下就是:

  • 原型链
  • 继承

原型链

先来看看标准的原型链定义:

来自 ECMAScript 2019 的摘选

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 中,原型的定义:

ECMAScript 2019

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作为比较,数组在寻找和访问有显著的优势,但是链表插入和删除有显著优势。

参考链接: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深入之继承的多种方式和优缺点

为了减少文章篇幅,我就不仔细展开每种继承的优缺点去分析,你们可以直接看上面文章

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 的世界,当你实例化一个子类的时候,只有 CupCake1CupCake2 子类的对象,父类的方法和属性,其实都存在于每个子类中,但是内存里面并没有一个父类的对象出现。

继承的概念,只是说,Java 里面有两张设计图,一张是 Cake,一张是 CupCake,他们是继承关系。

这就是 Java 和 JavaScript 实现继承最大的区别,它们是本质上的区别。

更多有关 Java 继承相关的可以查阅:

Java关于继承中的内存分配

后话

总结一下:

  • 继承是 OOP 的其中一种特性
  • 因为 JavaScript 的历史问题导致,要实现继承走了很多弯路,才有现在这么多种实现方式
  • 无脑使用 class extend 即可,但了解 组合寄生继承 的原理有助你理解 JavaScript
  • 原型链是 JavaScript 选择的一种用来创建对象和共享属性的策略
  • 原型本身就是一种开发模式,而原型链本身又是一种链表结构,把它当作普通的链表对待,很多东西会容易理解很多
  • 继承只是代码复用的其中一种模式
  • 并不是只有原型链可以实现继承,只是 JavaScript 选择了这种策略而已,他们两个不是对等关系

分享一下我学习的路径:

  • 每个问题都是可以分解的,例如,原型链与继承 它其实就是 原型链继承,原型链还可以进一步分解原型
  • 然后对每个分解下来的点逐个去挖掘并理解,原型是什么,是什么,继承又是什么?
  • 如果有些还是不明白,就再放大一点格局,例如,原型模式开发,其他语言又是怎么实现继承的?大胆假设,小心求证
  • 最后最重要的一点,把这些知识点全部串起来,当你串起来的那一刻,你会对这些知识有了新的理解

然后:

  • 不要背题,不要流于表面的理解,除了让你应付面试题,它并没有帮助到你,相反可能让你误解为你掌握了,而且真正的技术面试,会进一步考你对其中的理解,你理解不理解很容易知道
  • 自己不懂的东西,大胆的提出疑问,并且尝试一一去解答
  • 没有答案就自己找,世界上不缺千篇一律的正确废话,但缺少有独特的见解,哪怕它不一定对的

参考链接

面向对象程序设计-维基百科

面向对象编程基本概念

原型模式

数据结构:链表

Javascript继承机制的设计思想

进阶必读:深入理解 JavaScript 原型

理解 JavaScript 面向对象的 封装、继承、多态 三大特性

Java关于继承中的内存分配

相关推荐
网络研究院5 分钟前
新工具可绕过 Google Chrome 的新 Cookie 加密系统
前端·chrome·系统·漏洞·加密·绕过
理想不理想v1 小时前
【问答】浏览器如何编译前端代码?
前端·javascript·css·html
风清云淡_A1 小时前
react18中redux-saga实战系统登录功能及阻塞与非阻塞的性能优化
前端·react.js
偷光1 小时前
React 中使用 Echarts
前端·react.js·echarts
Luckyfif1 小时前
Webpack 是什么? 解决了什么问题? 核心流程是什么?
前端·webpack·node.js
王哲晓1 小时前
第十五章 Vue工程化开发及Vue CLI脚手架
前端·javascript·vue.js
放逐者-保持本心,方可放逐1 小时前
react 框架应用+总结+参考
前端·前端框架·react
练习两年半的工程师1 小时前
建立一个简单的todo应用程序(前端React;后端FastAPI;数据库MongoDB)
前端·数据库·react.js·fastapi
爱编程的小金1 小时前
React-query vs. 神秘新工具:前端开发的新较量
前端·javascript·react.js·http·前端javascript
qq_427506081 小时前
react轮播图示例
前端·javascript·react.js