一篇文章看懂 JavaScript 如何实现继承
一、背景简介
JavaScript 在编程语言界是个特殊种类,它和其他编程语言很不一样,JavaScript 可以在运行的时候动态地改变某个变量的类型。
比如你永远也没法想到像 isTimeout 这样一个变量可以存在多少种类型,除了布尔值 true 和 false,它还可能是 undefined、1 和 0、一个时间戳,甚至一个对象。
如果代码跑异常,打开浏览器,开始断点调试,发现 InfoList 这个变量第一次被赋值的时候是个数组:
过了一会竟然变成了一个对象:
除了变量可以在运行时被赋值为任何类型以外,JavaScript 中也能实现继承,但它不像 Java、C++、C# 这些编程语言一样基于类来实现继承,而是基于原型进行继承。
这是因为 JavaScript 中有个特殊的存在:对象。每个对象还都拥有一个原型对象,并可以从中继承方法和属性。
提到对象和原型,有如下问题:
JavaScript 的函数怎么也是个对象?
proto 和 prototype 到底是啥关系?
JavaScript 中对象是怎么实现继承的?
JavaScript 是怎么访问对象的方法和属性的?
文章开始之前,推荐一款强大的软件开发工具:
JNPF 开发平台,很多人都用过它,它是功能的集大成者,任何信息化系统都可以基于它开发出来。原理是将开发过程中某些重复出现的场景、流程,具象化成一个个组件、api、数据库接口,避免了重复造轮子。因而极大的提高了程序员的生产效率。
官网:www.jnpfsoft.com/?infoq,如果你有闲暇时间,可以做个知识拓展。
这是一个基于 Java Boot/.Net Core 构建的简单、跨平台快速开发框架。前后端封装了上千个常用类,方便扩展;集成了代码生成器,支持前后端业务代码生成,满足快速开发,提升工作效率;框架集成了表单、报表、图表、大屏等各种常用的 Demo 方便直接使用;后端框架支持 Vue2、Vue3。
为了支撑更高技术要求的应用开发,从数据库建模、Web API 构建到页面设计,与传统软件开发几乎没有差异,只是通过低代码可视化模式,减少了构建“增删改查”功能的重复劳动。
二、原型对象和对象的关系
在 JavaScript 中,对象由一组或多组的属性和值组成:
在 JavaScript 中,对象的用途很是广泛,因为它的值既可以是原始类型(number、string、boolean、null、undefined、bigint 和 symbol),还可以是对象和函数。
不管是对象,还是函数和数组,它们都是 Object 的实例,也就是说在 JavaScript 中,除了原始类型以外,其余都是对象。
这也就解答了问题 1:JavaScript 的函数怎么也是个对象?
在 JavaScript 中,函数也是一种特殊的对象,它同样拥有属性和值。所有的函数会有一个特别的属性 prototype,该属性的值是一个对象,这个对象便是我们常说的“原型对象”。
我们可以在控制台打印一下这个属性:
打印结果显示为:
可以看到,该原型对象有两个属性:constructor 和 proto。
到这里,我们仿佛看到疑惑 “2:proto 和 prototype 到底是啥关系?”的答案要出现了。在 JavaScript 中,proto 属性指向对象的原型对象,对于函数来说,它的原型对象便是 prototype。函数的原型对象 prototype 有以下特点:
默认情况下,所有函数的原型对象(prototype)都拥有 constructor 属性,该属性指向与之关联的构造函数,在这里构造函数便是 Person 函数;
Person 函数的原型对象(prototype)同样拥有自己的原型对象,用 proto 属性表示。前面说过,函数是 Object 的实例,因此 Person.prototype 的原型对象为 Object.prototype。
我们可以用这样一张图来描述 prototype、proto 和 constructor 三个属性的关系:
从这个图中,我们可以找到这样的关系:
在 JavaScript 中,proto 属性指向对象的原型对象;
对于函数来说,每个函数都有一个 prototype 属性,该属性为该函数的原型对象;
三、使用 prototype 和 proto 实现继承
对象之所以使用广泛,是因为对象的属性值可以为任意类型。因此,属性的值同样可以为另外一个对象,这意味着 JavaScript 可以这么做:通过将对象 A 的 proto 属性赋值为对象 B,即:
此时使用 A.proto 便可以访问 B 的属性和方法。
这样,JavaScript 可以在两个对象之间创建一个关联,使得一个对象可以访问另一个对象的属性和方法,从而实现了继承;
四、使用 prototype 和 proto 实现继承
以 Person 为例,当我们使用 new Person()创建对象时,JavaScript 就会创建构造函数 Person 的实例,比如这里我们创建了一个叫“zhangsan”的 Person:
上述这段代码在运行时,JavaScript 引擎通过将 Person 的原型对象 prototype 赋值给实例对象 zhangsan 的 proto 属性,实现了 zhangsan 对 Person 的继承,即执行了以下代码:
我们来打印一下 zhangsan 实例:
结果如下图所示:
可以看到,zhangsan 作为 Person 的实例对象,它的 proto 指向了 Person 的原型对象,即 Person.prototype。
这时,我们再补充下上图中的关系:
从这幅图中,我们可以清晰地看到构造函数和 constructor 属性、原型对象(prototype)和 proto、实例对象之间的关系,这是很多容易混淆。根据这张图,我们可以得到以下的关系:
每个函数的原型对象(Person.prototype)都拥有 constructor 属性,指向该原型对象的构造函数(Person);
使用构造函数(new Person())可以创建对象,创建的对象称为实例对象(lily);
实例对象通过将 proto 属性指向构造函数的原型对象(Person.prototype),实现了该原型对象的继承。
那么现在,关于 proto 和 prototype 的关系,我们可以得到这样的答案:
每个对象都有 proto 属性来标识自己所继承的原型对象,但只有函数才有 prototype 属性;
对于函数来说,每个函数都有一个 prototype 属性,该属性为该函数的原型对象;
通过将实例对象的 proto 属性赋值为其构造函数的原型对象 prototype,JavaScript 可以使用构造函数创建对象的方式,来实现继承。
所以一个对象可通过 proto 访问原型对象上的属性和方法,而该原型同样也可通过 proto 访问它的原型对象,这样我们就在实例和原型之间构造了一条原型链。红色线条所示:
五、通过原型链访问对象的方法和属性
当 JavaScript 试图访问一个对象的属性时,会基于原型链进行查找。查找的过程是这样的:
首先会优先在该对象上搜寻。如果找不到,还会依次层层向上搜索该对象的原型对象、该对象的原型对象的原型对象等(套娃告警);
JavaScript 中的所有对象都来自 Object,Object.prototype.proto === null。null 没有原型,并作为这个原型链中的最后一个环节;
JavaScript 会遍历访问对象的整个原型链,如果最终依然找不到,此时会认为该对象的属性值为 undefined。
我们可以通过一个具体的例子,来表示基于原型链的对象属性的访问过程,在该例子中我们构建了一条对象的原型链,并进行属性值的访问:
当我们在获取属性值的时候,就会触发原型链的查找:
综上,整个原型链如下:
可以看到,当我们对对象进行属性值的获取时,会触发该对象的原型链查找过程。
既然 JavaScript 中会通过遍历原型链来访问对象的属性,那么我们可以通过原型链的方式进行继承。
也就是说,可以通过原型链去访问原型对象上的属性和方法,我们不需要在创建对象的时候给该对象重新赋值/添加方法。比如,我们调用 lily.toString()时,JavaScript 引擎会进行以下操作:
先检查 lily 对象是否具有可用的 toString()方法;
如果没有,则``检查 lily 的原型对象(Person.prototype)是否具有可用的 toString()方法;
如果也没有,则检查 Person()构造函数的 prototype 属性所指向的对象的原型对象(即 Object.prototype)是否具有可用的 toString()方法,于是该方法被调用。
由于通过原型链进行属性的查找,需要层层遍历各个原型对象,此时可能会带来性能问题:
当试图访问不存在的属性时,会遍历整个原型链;
在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。
因此,我们在设计对象的时候,需要注意代码中原型链的长度。当原型链过长时,可以选择进行分解,来避免可能带来的性能问题。
六、其他方式实现继承
除了通过原型链的方式实现 JavaScript 继承,JavaScript 中实现继承的方式还包括经典继承(盗用构造函数)、组合继承、原型式继承、寄生式继承,等等。
原型链继承方式中引用类型的属性被所有实例共享,无法做到实例私有;
经典继承方式可以实现实例属性私有,但要求类型只能通过构造函数来定义;
组合继承融合原型链继承和构造函数的优点,它的实现如下:
组合继承模式通过将共享属性定义在父类原型上、将私有属性通过构造函数赋值的方式,实现了按需共享对象和方法,是 JavaScript 中最常用的继承模式。
虽然在继承的实现方式上有很多种,但实际上都离不开原型对象和原型链的内容,因此掌握 proto 和 prototype、对象的继承等这些知识,是我们实现各种继承方式的前提条件。
七、总结
关于 JavaScript 的原型和继承,常常会在我们面试题中出现。随着 ES6/ES7 等新语法糖的出现,可能更倾向于使用 class/extends 等语法来编写代码,原型继承等概念逐渐变淡。
其次 JavaScript 的设计在本质上依然没有变化,依然是基于原型来实现继承的。如果不了解这些内容,可能在我们遇到一些超出自己认知范围的内容时,很容易束手无策。
版权声明: 本文为 InfoQ 作者【树上有只程序猿】的原创文章。
原文链接:【http://xie.infoq.cn/article/b284f82b0dd1491aba7d45407】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论