《大前端三问》 - JavaScript中的面向对象与原型链2

本章我们将探讨JavaScript中继承多态的设计及实现。

1 JavaScript的继承

首先,关于继承的概念我觉得无需多说了,这基本上属于程序员的入门课程了。

这里要多说的是,继承,其实也是一种代码复用的技术。我们上节说过,抽象与封装是一种代码复用技术,旨在将具有相同属性与行为的事物抽象出类型(在JavaScript中使用构造函数原型实现)。如果说抽象与封装复用的是一个类型模板的属性与行为,那继承则是复用一系列具有相近属性与行为的类型,这个结构有点像俄罗斯套娃,子类继承自父类,就像在父类这个娃娃上套个更大的子类大娃(在父类的基础上构建另外自己独特的部分)。而子类自己也可以有子类,在这之上所有祖先的代码都将复用。

1.1 继承的实现

因为有了第一章原型链的基础,本章我们将更好理解。就像是我很喜欢的一部电影《盗梦空间》空间一样,第一章中我们只是一层梦境,而这章将讲的是多层梦境而已。

从上一章我们知道,新建一个People构造函数function People,一个<<People.prototype>>原型就伴随着构造函数的定义而自动产生的。而当我们调用new People(),构造函数将返回一个已初始化完成的People对象,系统会自动将<<People.prototype>>原型对象的引用赋给此对象的_prop__成员。

而正因为__prop__的存在,产生了所谓的原型链。当我们访问对象的成员时,如果在本对象的散列表结构中没有找到相应的引用时,解释器会自动帮我们作向上查找,沿着其__proto__属性找到原型对象<<prototype>>。如果还是没有,会一直沿着原型的原型一路向上找,直到找到<<Object.prototype>>, 其__proto__为null。

那么我们现在就要定义我们的目标,要实现继承的复用原则,就是要让我们新定义的Student类型可以复用People类型的属性与方法。 – 一般地,我们使用prototype chan原型链来实现:

使用原型链来实现继承
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
       ┌───────────────────────┐
│ function People() │
│ │◀───────────────────────────────────────┐
├────────────┬──────────┤ ┌──────────────────────────┐ │
│ prototype │ │ │ People │ │
│ │ ─────┼─────────▶│ <<prototype>> │◀─┼───┐
└────────────┴──────────┘ ├────────────┬─────────────┤ │ │
△ │constructor │ ────────┼──┘ │
│ ├────────────┼─────────────┤ │
│ __proto__ │ Object │ │
│ (继承) │ │<<prototype>>│ │
├────────────┼─────────────┤ │
│ │ sayHi │ (function) │ │
└────────────┴─────────────┘ │
│ │
┌(增加的Student类型)─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┼ ┐
│ │
│ ┌───────────────────────┐◀───────────────────────────────────────┐ │ │
│ function Student() │ ┌──────────────────────────┐ │ │
│ ├────────────┬──────────┤ │ Student │ │ │ │
│ prototype │ ─────┼───────┬──▶│ <<prototype>> │ │ │
│ └────────────┴──────────┘ │ ├────────────┬─────────────┤ │ │ │
▲ │ │constructor │ ───────┼─┘ │
│ │ │ ├────────────┼─────────────┤ │ │
│ │ │ __proto__ │ ───────┼─────┘
│ │ │ └────────────┴─────────────┘ │
│ │
│ │ │ │
─ ─ ─ ─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│ │
┌───────────────────────┐ │
│ peter (new Student) │ │
├────────────┬──────────┤ │
│ __proto__ │ ─────┼───────┘
├────────────┼──────────┤
│ name │ 'peter' │
├────────────┼──────────┤
│ age │ 9 │
├────────────┼──────────┤
│ grade │ 2 │
└────────────┴──────────┘

上图中虚线框出的就是我们要增加的部分。现在我们增加了一个Student类型(使用Student函数),这在Java等高级面向对象语言中是那么容易,但是在JS中我们要做很多看起来繁杂的处理。

首先第一个要解决的问题是Student类型要复用在People构造函数中定义的成员。解决方案很简单,在Student的构造函数中再调用People构造函数就可以了,这个看起来有点像Java中的构造器重载:

复用父类中定义的成员示例
1
2
3
4
5
6
7
8
9
10
11
12
function People(name, age) {
this.name = name;
this.age = age;
}
People.prototype.sayHi = function() {
console.log('Hi~My name is ' + this.name);
}

function Student(name, age, grade) {
People.call(this, name, age);
this.grade = grade;
}

这样当我们let peter = new Student('Peter', 9, 2)时,实际发生了下面的事:

1
2
3
var peter = new Object();
Student.call(peter, 'Peter', 9, 2); --> People.call(this, 'Peter', 9);
peter.__proto__ = Student.prototype;

我们这里简介下function.call(this),这个方法将调用指定方法,同时指定所调用方法的this(在介绍this的一章中我们将详细讲解)。

所以我们来分析上面的代码:

  1. var peter = new Object(); - 相当于new Student('Peter', 9, 2)时,解释器先帮我们基于Object生成了一个普通对象。
  2. Student.call(...) - 然后调用Student构造器,并将新生成的对象设为该构造器的this。在Student构造器中,给对象添加了grade成员,然后又继续效用此法调用People构造器,为此对象添加了name、age两个成员。
  3. 最后在返回对象前将其__proto__原型链的引用指向<<Student.prototype>>,使其可以复用类型原型上的成员变量与方法。

此时,生成的peter对象如上图一样了:

1
2
3
4
5
6
7
8
9
10
11
┌────────────────────────────────────┐
│ peter (new Student) │
├────────────┬───────────────────────┤
│ __proto__ │ <<Student.prototype>> │
├────────────┼───────────────────────┤
│ name │ 'peter' │
├────────────┼───────────────────────┤
│ age │ 9 │
├────────────┼───────────────────────┤
│ grade │ 2 │
└────────────┴───────────────────────┘

1.2 复用父类原型

但是还没完,还有一个问题,就是Student

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Details_of_the_Object_Model
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/this
https://www.cnblogs.com/leijee/p/7490822.html
https://www.jianshu.com/p/5cb692658704
https://www.runoob.com/w3cnote/js-call-apply-bind.html

2 性能

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

在对效率要求高的场合,建议使用hasOwnProperty()Object.keys(),因为这两个方法不会遍历原型链。

比如想引用某个属性前,先使用hasOwnProperty()查看下,该对象的散列结构中是否存在此属性:

对象直接拥有此方法成员时才调用的示例
1
2
3
if (student.hasOwnProperty("sayHi")) {
student.sayHi();
}

我们说hasOwnProperty() 方法不会遍历原型链的意思就是,比如student对象的实际散列结构下如:

1

hasOwnProperty("sayHi")查找sayHi方法时,不会顺着其__proto__成员沿着原型链向上查找。

3 小结

4 引用

  1. 《重新介绍JavaScript》- 介绍JS很好的入门材料
  2. 《JavaScript高级程序设计》- (美)(Nicholas C.Zakas)扎卡斯 学习JS挺好的入门教材
  3. 《继承与原型链》- MDN web docs 继承与原型链, 很好,讲了性能,和原型实际
  4. [《Function函数与Object对象的关系》- 网文](https://www.cnblogs.com/nature-tao/p/9504712.html Function函数与Object对象的关系的一篇挺好的探索文章