文章目录
  1. 1. 原型对象
  2. 2. 原型链
  3. 3. 实现JavaScript继承
  4. 4. 总结

我们都知道,前端页面中HTML用于描述页面结构,CSS用于装饰页面样式,这两者结合得到一个暂时还没办法和用户交互的静态页面。为了使得页面能接收用户的输入,然后进行相应的反馈,我们需要用到JavaScript。

如今前端项目规模越来越大,架构也越来越复杂,甚至在一些项目中我们都接触不到页面布局、样式调整等工作。但不管如何,基本上都不会离开 JavaScript 开发,因此我们需要掌握 JavaScript 的一些特点,所谓知己知彼,百战不殆。

对于Java、C++、C#这些编程语言来说,它们都是基于类来实现继承的。JavaScript最初设计是为了让网页可交互,因此使用了更简单的继承方式:基于原型继承。在JavaScript中,每个对象拥有一个原型对象,并可以从中继承方法和属性。

因此,在JavaScript中并没有publicprivateprotect等关键字,我们如今经常使用的class也只是ES6的语法糖,JavaScript本质上依然是基于原型来实现继承的。

翻出来旧文新发

原型对象

当谈到继承时,JavaScript只有一种结构:对象。

几乎所有JavaScript中的对象都是Object的实例,包括函数、数组、对象等。在JavaScript中,对象由一组或多组的属性和值组成:

1
2
3
4
5
{
key1: value1,
key2: value2,
key3: value3,
}

JavaScript中的对象之所以用途广泛,是因为它的值既可以是原始类型(numverstringbooleannullundefinedbigintsymbol),还可以是对象和函数。其中,函数也是一种特殊的对象,它同样拥有属性和值,所有的函数会有一个特别的属性prototype。除此之外,在V8引擎中函数还有name(函数名)和code(函数代码)两个隐藏属性,因此可被调用。

在一个对象中,属性的值同样可以为另外一个对象,因此我们可以通过这样的方式来实现继承:使用一个代表原型的属性,属性的值为被继承的对象,此时可以通过层层查找来得到原型链上的对象和属性。在JavaScript中,该属性便是__proto__,被继承的对象即原型对象prototype。通过prototype属性,一个对象可以访问其他对象的属性和方法。

作为一种特殊的对象,我们来看一下函数的原型对象有什么:

1
2
3
4
function Person(name) {
this.name = name;
}
console.log(Person.prototype)

打印结果为:

图片

我们可以看到,Person函数的原型对象(prototype)有两个属性:constructor__proto__。我们已经知道,__proto__属性值指向原型对象,在这里是Objcet。此时,我们可以获得这样的关系:

图片

默认情况下,所有原型对象(prototype)自动获得一个constructor属性,指向与之关联的构造函数。我们也能看到,Person函数的原型对象的constructor属性值便是指向Person函数自身。当我们创建对象时,JavaScript就会创建该构造函数的实例。

我们可以使用工厂模式、构造函数模式、原型模式等各种模式创建一个对象,具体来说可以使用以下方法:

  • 使用语法结构创建对象:即定义一个数组、函数、对象等,如var o = {a: 1};function f(){}
  • 使用构造器new XXX()创建对象:构造器其实就是一个普通的函数,当我们使用new操作符的方式使用这个函数时,它就被我们称为构造函数
  • 使用Object.create()创建对象:使用Object.create(null)可以创建出来的没有原型的对象
  • 使用ES6class关键字创建对象:class是ES6语法糖,JavaScript依然是基于原型的

不管是哪种方式,都可以理解为通过将__proto__属性赋值为原型对象(prototype)来实现继承。其中,最常见的便是使用构造函数来创建对象,在这个过程中创建的实例通过将__proto__指向构造函数的原型对象(prototype),来继承该原型对象的所有属性和方法。

也就是说,当我们运行以下代码时:

1
var lily = new Person('Lily')

实际上JavaScript引擎执行了以下代码:

1
2
3
var lily = {}  
lily.__proto__ = Person.prototype
Person.call(lily, 'Lily')

我们来打印一下lily实例:

图片

可以看到,lily作为Person的实例对象,它的__proto__指向了Person的原型对象,即Person.prototype

图片

很多初学者容易搞混构造函数和constructor属性、原型对象(prototype)和__proto__、实例对象之间的关系,现在我们可以直观地看到:

  1. 每个原型对象(Person.prototype)都拥有constructor属性,指向该原型对象的构造函数(Person)。

  2. 使用构造函数(new Person())可以创建对象,创建的对象称为实例对象(lily)。

  3. 实例对象通过将__proto__属性指向原型对象(Person.prototype),实现了该原型对象的继承。

我们能看到,实例(lily)与构造函数原型(Person.prototype)之间有直接的关系,但与构造函数(Person)之间没有。

关于__proto__prototype,很多时候我们容易搞混:

  • 每个对象都有__proto__属性来标识自己所继承的原型对象,但只有函数才有prototype属性

  • 通过prototype__proto__,JavaScript可以在两个对象之间创建一个关联,使得一个对象可以访问另一个对象的属性和函数,从而实现了继承

原型链

原型链是JavaScript中主要的继承方式。我们已经知道,一个对象可通过__proto__访问原型对象上的属性和方法,而该原型同样也可通过__proto__访问它的原型对象,这样我们就在实例和原型之间构造了一条原型链。图中红色的线则为原型链:

图片

JavaScript中的所有对象都来自Object,因此默认情况下,任何函数的原型属性__proto__都是window.Object.prototypeprototype原型对象同样会具有一个自己的原型,层层向上直到一个对象的原型为null

关于原型链,我们需要知道:

  • 当试图访问一个对象的属性时,会优先在该对象上搜寻。如果找不到,还会依次层层向上搜索该对象的原型对象、该对象的原型对象的原型对象等(套娃告警)

  • 根据定义,null没有原型,并作为这个原型链中的最后一个环节

  • __proto__的整个原型链被查看之后,浏览器才会认为该属性不存在,并给出属性值为undefined的结论

1
2
3
4
// 任何函数的原型属性 __proto__ 都是 Object.prototype
// Object.getPrototypeOf() 方法返回指定对象的原型
// 我们能看到,null 作为原型链中最后一个环节
Object.getPrototypeOf(Object.prototype) === null; // true

我们来看个具体的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 让我们假设我们有一个对象 o, 其有自己的属性 a 和 b:
var o = {a: 1, b: 2};
// o 的原型 o.__proto__有属性 b 和 c:
o.__proto__ = {b: 3, c: 4};
// 最后, o.__proto__.__proto__ 是 null.
// 这就是原型链的末尾,即 null,
// 根据定义,null 没有__proto__.
// 综上,整个原型链如下:
{a:1, b:2} ---> {b:3, c:4} ---> null


// 当我们在获取属性值的时候,就会触发原型链的查找:
console.log(o.a); // o.a => 1
console.log(o.b); // o.b => 2
console.log(o.c); // o.c => o.__proto__.c => 4
console.log(o.d); // o.c => o.__proto__.d => o.__proto__.__proto__ == null => undefined

原型链带来了继承的遍历,我们不需要在创建对象的时候给该对象重新赋值/添加方法,便可以通过原型链去访问原型对象上的属性和方法。比如,我们调用lily.valueOf()时,JavaScript引擎会进行以下操作:

  1. 先检查lily对象是否具有可用的valueOf()方法。
  2. 如果没有,则检查lily的原型对象(Person.prototype)是否具有可用的valueof()方法。
  3. 如果也没有,则检查Person()构造函数的prototype属性所指向的对象的原型对象(即Object.prototype)是否具有可用的valueOf()方法,于是该方法被调用。

我们能看到,通过原型链进行属性的查找会层层遍历每个原型对象,因此也可能会带来性能问题:

  • 当试图访问不存在的属性时,会遍历整个原型链
  • 在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要

因此,我们在设计对象的时候,需要注意代码中原型链的长度。当原型链过长时,可以选择进行分解,来避免可能带来的性能问题。

说到属性查找,前面我们提到JavaScript中对象是由一组或多组的属性和值组成。要保存这些属性和值,一般我们都会想到使用字典,使用字符串作为键名,键值可以是任意对象,通过键名就可以读写键值。

但我们都知道字典是非线性结构的,会导致读取效率会大大降低。因此,V8引擎采用了一套更为复杂和高效的存储策略,其中便涉及我们常说的快属性和慢属性,大家有兴趣也可以再深入进行研究。

实现JavaScript继承

通过原型链可以实现JavaScript继承,实际上JavaScript中实现继承的方式还包括经典继承(盗用构造函数)、组合继承、原型式继承、寄生式继承等等。

其中,原型链继承方式中引用类型的属性被所有实例共享,无法做到实例私有;经典继承方式可以实现实例属性私有,但要求类型只能通过构造函数来定义;组合继承融合原型链继承和构造函数的优点,是JavaScript中最常用的继承模式,它长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Parent(name) {
// 私有属性,不共享
this.name = name;
}
// 需要复用、共享的方法定义在父类原型上
Parent.prototype.speak = function() {
console.log('hello');
}
function Child(name) {
Parent.call(this, name);
}
// 将子类的 __proto__ 指向父类原型
Child.__proto__ = Parent.prototype

虽然如今我们更倾向于使用ES6中的class,但实际上ES6/ES7中的新特性本质上主要是用来提升开发效率的语法糖。我们可以使用@babel/plugin-transform-classesclass相关代码进行编译,便可以看到编译后的代码本质上也是通过组合继承类似的方式实现继承。

总结

关于JavaScript的原型和继承,常常会在我们面试题中出现。随着ES6/ES7等新语法糖的出现,我们在日常开发中可能更倾向于使用class等语法来编写代码,原型继承等概念逐渐变淡。

但不管语法糖怎么先进,JavaScript的设计在本质上依然没有变化。如果不了解这些内容,可能在我们遇到一些超出自己认知范围的内容时,很容易束手无策。

码生艰难,写文不易,给我家猪囤点猫粮了喵~

B站: 被删

查看Github有更多内容噢:https://github.com/godbasin
更欢迎来被删的前端游乐场边撸猫边学前端噢

如果你想要关注日常生活中的我,欢迎关注“牧羊的猪”公众号噢

作者:被删

出处:https://godbasin.github.io

本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

文章目录
  1. 1. 原型对象
  2. 2. 原型链
  3. 3. 实现JavaScript继承
  4. 4. 总结