基础概念
要了解 JavaScript 继承,需要先弄明白相关的4 个概念,3 个关系,3 个属性以及 1 个操作符。
4 个概念
构造函数
:任何函数,只要能通过 new
操作符来调用,那它就可以作为构造函数;构造函数本质就是一个函数;原型对象
:通过调用构造函数而创建的那个对象实例的原型对象;该对象包含由特定类型的所有实例共享的属性和方法;实例
:通过 new
操作符调用构造函数得到的对象;原型链
:每个构造函数都有一个原型对象,我们如果将这个原型对象改为另一个对象的实例,就会形成原型链。
P.S. 原型链后面会详细讲解,所以没有理解没关系。
代码示例 1:
3 个关系
3 个关系是构造函数,原型对象与实例之间的关系:
- 每个构造函数都有一个原型对象
- 原型对象都包含一个指向构造函数的指针
- 每个实例都包含一个指向原型对象的内部指针
根据代码示例 1 中 Point 类的定义,我们可以得到如下关系图:
图 1:Point 类构造函数,原型对象与实例之间的关系图
原型链
:构造函数 Point 和 ColorPoint,我们知道这两个构造函数都会有默认的原型对象。上面我们说到如果我们将 ColorPoint 的原型对象改为 Point 的实例,我们就会得到原型链。那原型链是如何形成的呢?就是通过原型对象与实例之间的这个关系形成的。具体形成过程我们需要再了解一下关系中涉及到的 3 个属性。
3 个属性
3 个属性与 3 个关系相关联:prototype
,constructor
,__proto__
:
- prototype:构造函数中指向原型对象的指针
- constructor:原型对象中指向构造函数的指针
- __proto__:实例中指向原型对象的内部指针
根据代码示例 1 中 Point 与 ColorPoint 的定义,我们可以得到如下关系图:
图 2:ColorPoint、Point、Object 之间的关系图
原型链
:本来构造函数 Point 和 ColorPoint 是两个独立的函数,之间没有关系。如果我们将 ColorPoint 的原型对象重写,即 ColorPoint.prototype = new Point()。那 prototype 属性就指向了 Point 的实例,而从上面我们已经知道了每个实例都有指向原型对象的内部指针 __proto__,到这里,我们可以得到一个原型链:
- ColorPoint 的实例中 __proto__ 指向 ColorPoint 的原型对象,即 Point 的实例;
- 而 Point 的实例也有一个 __proto__ 指向 Point 的原型对象;
- 而 JavaScript 中任何对象都是继承自 Object,所以 Point 的原型对象也有一个 __proto__ 指针指向 Object 的原型对象;
- 直到 Object 的原型对象的 __proto__ 被指向 null;
- 根据定义,null没有原型,并且作为这个原型链 prototype chain 中的最终链接。
通过 __proto__ 指针所形成这个实例与原型的链条就是原型链,即图 2 中蓝色链条,基于原型链的属性和方法查找就是按照这个蓝色链条层层往上的。
1 个操作符
前面说过,new
操作符是区别构造函数与普通函数的关键,我们看一下new
操作符执行的步骤:
- 创建一个新对象;
- 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象);
- 执行构造函数中的代码(为这个新对象添加属性);
- 返回新对象。
继承的方法
继承的本质就是通过重写原型对象实现原型链,使得子类型可以通过原型链找到父类型中的属性和方法。有了上面的基础,我们看一下 JavaScript 中如何实现继承,讨论每种方法的优缺点。
基于原型链
代码实例 1 中通过重写 ColorPoint 的原型对象,形成一条原型链实现了继承。原来存在于 Point 的实例中的属性 x
、y
和方法 show
现在也存在于 ColorPoint.prototype 中。但这种实现存在两个问题:
- 父类中如果存在引用类型的属性(例如数组),那么子类的所有实例都会共享这个属性;
比如,如果 Point 中有一个数组属性 shapes,那在所有的 ColorPoint 的实例都会共享这个数组 shapes,任意一个实例修改 shapes,都会影响其他实例。
代码示例 2:
- 在创建子类型的实例时,不能向父类型的构造函数传递参数。
我们还看到,在实现继承的过程中,我们直接将 new Point() 替换了 ColorPoint 的原型。在 new 的过程中没有传入任何参数,即使我们传入参数,也把子类 ColorPoint 的 x 和 y 属性固定死了。这会导致 ColorPoint 在创建实例时,无法传递 x 和 y 属性。
所以原型链这种方式只是实现了继承而已,并不能满足实际工作的需求。
使用 call 和 apply —— 构造函数绑定
为了解决上面说的两个问题,我们可以通过 call() 和 apply() 在子类的构造函数中将 this
和参数
传递给父类构造函数。
代码示例 3:
虽然解决了原型链中的两个问题,但这种借用构造函数的方法还是存在其他问题,就是函数复用的问题。父类 Point 中的 show 方法无法复用,子类 ColorPoint 的所有实例都会重新创建 show 方法。所以这种继承方式在实际工作中也是很少用的。
组合继承 —— 最常用
为了解决上一小节中函数复用的问题,我们可以借用原型对象中的属性是共享这一特性。思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。
代码示例 4:
根据代码示例 4 ,我们可以得到如下关系图:
图 3:ColorPoint、Point、Object 之间新的关系图
这种继承的实现方式,解决了前两个小节中出现的问题,但也有一些小瑕疵,就是父类的构造函数会被调用两次:一次是创建子类型的原型对象, 还有一次是子类型的构造函数中的 call。虽然存在这个问题,但影响不是特别大,所以这种方式还是 JavaScript 最常用的继承模式。
Class extends
ES6 提供了更接近传统面向对象语言的继承写法,引入了 Class 的概念, Class 之间可以通过 extends 继承。
代码示例 5:
这段代码中各个类之间的原型链图可以参考另一篇博客《图解 JavaScript 中的 __proto__ 与 prototype》。
ES6 虽然有 Class 的概念,但是其本质还是基于原型链的,我们可以把 Class 看作一个语法糖。ES6 中关于 Class 的关键字除了 class 和 extends,还新增了 constructor, static 和 super。关于 Class 的详细知识可以参考阮一峰老师的《ES6 标准入门》中的 Class 章节。
总结
除了上面提到的继承方法,大神 Douglas Crockford 还推荐了两种实现继承的方法: 原型式继承 和 寄生式继承。还有 YUI 的 YAHOO.lang.extend() 采用的基于寄生式继承和组合继承的 寄生组合式继承,该继承方法可以解决组合继承中父类构造函数被调用两次的问题。这三种继承方法在红宝书《JavaScript 高级程序设计》第 6 章中都有详细介绍。
书该读还是得读,常读常新,需要了解的基础都在里面。