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

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

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

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

Java关于继承中的内存分配

后话

总结一下:

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

分享一下我学习的路径:

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

然后:

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

参考链接

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

面向对象编程基本概念

原型模式

数据结构:链表

Javascript继承机制的设计思想

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

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

Java关于继承中的内存分配

相关推荐
Interview Aid1121 小时前
Snowflake OA 面试真题:前缀和、BFS 与动态规划
面试·动态规划·宽度优先
时间的情敌2 小时前
《回顾 2018 前端:Vue2 成熟期,Webpack4 发布,PWA 大火》
前端
学顶教育2 小时前
学顶教育:公务员面试常见问题!
面试·职场和发展
Java后端的Ai之路2 小时前
前端技术Vue-09-webpack-vuejs-实战
前端·vue.js·webpack
LMT流氓兔2 小时前
第五单元——第4课:给工具参数加校验
java·前端·算法·ai·langchain
涛涛ing2 小时前
React Compiler正式“Rust化”:你项目里的Babel插件,正在被编译进打包器
前端
迷迭绿2 小时前
Cesium 轨迹回放播放器
前端·javascript·cesium
HolySaga3 小时前
我用 UTS 写了一个三端同构的蓝牙插件(Android / iOS / 鸿蒙 NEXT),把踩过的坑都讲给你听
前端
程序员老赵3 小时前
Docker 部署 XWiki:轻松搭建企业知识库与协作 Wiki
前端·docker·开源
console.log('npc')3 小时前
第 10 章 更多业务模块扩展
前端·人工智能·大模型