原型链和继承

JavaScript 中没有类的概念的,主要通过原型链来实现继承。通常情况下,继承意味着复制操作,然而 JavaScript 默认并不会复制对象的属性,相反,JavaScript 只是在两个对象之间创建一个关联(原型对象指针),这样,一个对象就可以通过委托访问另一个对象的属性和函数,所以与其叫继承,委托的说法反而更准确些。

在 JavaScript 中对象拥有一个特殊的属性 [[Prototype]],这个特殊的属性是对其他对象的引用,为了更好的接受这个概念,我将 [[Prototype]] 所引用的对象理解为本对象的原型,对象总能从原型上”继承”属性,举个例子:

  1. var anotherObject = {
  2. name: 'O_c',
  3. }
  4. var myObject = Object.create(anotherObject)
  5. myObject.name // O_c

上述例子中 myObject 对象的 [[Prototype]] 属性引用了 anotherObject 对象,我们就能理解为 myObject 对象的”原型”指向 anotherObject对象,并从 anotherObject 对象上”继承”了 name 属性。

几乎所有的对象 在创建时 [[Prototype]] 属性都会被赋予非空的值,由 [[Prototype]] 串联起来的对象的关联链条我们就称为 [[Prototype]]链(原型链),一般情况下 [[Prototype]] 链的最顶层是 Object.prototype 对象。

prototype 实际上是所有 函数 都拥有的属性(enumerable为false) **prototype** 是与函数相关的概念。为了更透彻的了解 prototype 我们先来看一段经常见到的代码片段:

  1. function Person {
  2. ...
  3. }
  4. Person.prototype.name = 'O_c'
  5. var person = new Person();
  6. person.name // O_c

这段代码中我们通过 Person 构造函数创建出 person 对象,但是并没有为 person 对象创建名为 name 的属性,那为什么在访问 person.name 仍然能获得结果呢?

理由很简单,当我们用 new 关键字创建 person 对象时,person 对象的 [[Prototype]] 属性会引用 Person.prototype 指向的对象。

Person.prototype 事实上就是所有通过 Person 构造函数创建的实例的原型。普通的对象的原型即是 Object.prototype

proto

__proto__ 是一种非标准的方法,对象可以通过 __proto__ 访问内部的 [[Prototype]] 属性,既我们可以通过 __proto__ 访问对象的原型,它是一个与 对象 相关的概念。

如果用 __proto__ 描述 personPerson.prototype 的关系便是 person.__proto__ === Person.prototype

但是 __proto__ 只是某些浏览器自身的实现并非是标准的方法,在实际的开发中我们应该使用 Object.getPrototypeOf() 方法来获取对象的原型。

原型链

即: constructor1.prototype = instance2

鉴于上述游戏规则生效,如果试图引用constructor1构造的实例instance1的某个属性p1:
1).首先会在instance1内部属性中找一遍;
2).接着会在instance1.proto(constructor1.prototype)中找一遍,而constructor1.prototype 实际上是instance2, 也就是说在instance2中寻找该属性p1;
3).如果instance2中还是没有,此时程序不会灰心,它会继续在instance2.proto(constructor2.prototype)中寻找…直至Object的原型对象

搜索轨迹: instance1—> instance2 —> constructor2.prototype…—>Object.prototype

这种搜索的轨迹,形似一条长链, 又因prototype在这个游戏规则中充当链接的作用,于是我们把这种实例与原型的链条称作 原型链 .

ES5 继承

在说es5继承之前可以了解一下关于继承的历史

原型链继承:将子类的原型对象改写为父类的实例对象,再通过父类实例对象上的 [[Prototype]] 属性与父类的原型对象产生关联,从而达到让子类的实例对象能够使用父类原型上的属性和方法的目的。(不能)

原型链继承

  1. function Parent() {
  2. this.type = 'Parent'
  3. }
  4. Parent.prototype.getType = function() {
  5. return this.type
  6. }
  7. function Child() {
  8. this.subType = 'Child'
  9. }
  10. Child.prototype = new Parent()
  11. var instance = new Child()
  12. console.log(instance.getType()) // Parent

上面的例子就是原型链继承的实现方法,子类实例可以访问父类的属性和方法。但是这种继承方法有一个非常大的问题,子类的所有实例的 [[Prototype]] 都会与这个父类的实例对象产生关联。当这个父类的实例对象上存在引用类型的属性时,又刚好某个子类实例通过方法修改了这个引用属性,那这种修改会影响到所有的实例。请看示例:

  1. function Parent() {
  2. this.colors = ['red', 'blue', 'green']
  3. }
  4. function Child() { }
  5. Child.prototype = new Parent()
  6. var instance1 = new Child()
  7. instance1.colors.push('black')
  8. var instance2 = new Child()
  9. console.log(instance2.colors) // ["red", "blue", "green", "black"]

instance1instance2 共享原型对象的 colors 属性,这种互相影响的情况是程序设计当中应该尽量避免的。原型链继承的另一个问题是,子类在实例化时无法给父类的构造函数传递参数。

所以就有下面的构造函数继承方式:

盗用构造函数(经典继承)

为了解决原型链继承中引用类型相互影响和无法向父类构造函数传递参数的问题,衍生出了盗用构造函数技巧,许多时候这种技巧也被称为经典继承。它会在子类当中调用父类的构造函数并使用 call 或者 apply 方法指定 this ,让子类实例化时先执行父类构造函数中初始化的逻辑。

  1. function Parent(name) {
  2. this.name = name
  3. this.colors = ['red', 'blue', 'green']
  4. }
  5. function Child(name, age) {
  6. Parent.call(this, name)
  7. this.age = age
  8. }
  9. var instance1 = new Child('O_c', 24)
  10. instance1.colors.push('black')
  11. var instance2 = new Child('馒头君', 24)
  12. console.log(instance1.name, instance2.colors) // O_c ["red", "blue", "green"]

上面的例子为我们展现了如何盗用构造函数,这种技术其实相当于借用了父类构造函数的初始化逻辑。当我们在执行子类实例化时,用 call 方法改变 this 的指向,当父类构造函数执行时相当于是在子类生成的实例对象上面添加属性。这样操作后引用类型的属性是独立存在于各个实例对象当中的,与原型对象无关自然没有互相影响的问题了。
盗用构造函数这种继承方式也有缺点,当我们使用这种方式实现继承时,子类就无法访问父类原型上定义的方法

于是乎就有一下这种继承方式

组合继承

  1. function A() {
  2. this.a = 'hello';
  3. }
  4. function B() {
  5. // 构造函数 B 的实例继承构造函数 A 的实例属性
  6. A.call(this);
  7. this.b = 'world';
  8. }
  9. B.prototype = Object.create(A.prototype, {
  10. // 构造函数 B 的 prototype 对象中的 __proto__ 属性指向构造函数 A 的 prototype 对象
  11. // 构造函数 B 的 prototype 对象的 constructor 属性赋值为构造函数 B
  12. constructor: { value: B, writable: true, configurable: true }
  13. });
  14. let b = new B();

代码中,构造函数 B 继承构造函数 A,首先让构造函数 B 的 prototype 对象中的 proto 属性指向构造函数 A 的 prototype 对象,并且将构造函数 B 的 prototype 对象的 constructor 属性赋值为构造函数 B,让构造函数 B 的实例继承构造函数 A 的原型对象上的属性,然后在构造函数 B 内部的首行写上 A.call(this),让构造函数 B 的实例继承构造函数 A 的实例属性。在 ES5 中实现两个构造函数之间的继承,只需要做这两步即可。下面六幅图分别是,实例 b 的原型链及验证图,构造函数 B 的原型链及验证图,构造函数 A 的原型链及验证图。

image.png
image.png

image.png
image.png

从上面 6 幅图可知,构造函数 B 的实例 b 继承了构造函数 A 的实例属性,继承了构造函数 A 的原型对象上的属性,继承了构造函数 Object 的原型对象上的属性。构造函数 B 是构造函数 Function 的实例,继承了构造函数 Function 的原型对象上的属性,继承了构造函数 Object 的原型对象上的属性。 构造函数 A 是构造函数 Function 的实例,继承了构造函数 Function 的原型对象上的属性,继承了构造函数 Object 的原型对象上的属性。可看出,构造函数 A 与 构造函数 B 并没有继承关系,即构造函数 B 没有继承构造函数 A 上面的属性,在 ES6 中,用 extends 实现两个类的继承,两个类之间是有继承关系的,即子类继承了父类的方法,这是 ES6 与 ES5 继承的第一点区别,下面通过 ES6 的继承来说明这一点。

ES6 继承

  1. class A {
  2. constructor() {
  3. this.a = 'hello';
  4. }
  5. }
  6. class B extends A {
  7. constructor() {
  8. super();
  9. this.b = 'world';
  10. }
  11. }
  12. let b = new B();

代码中,类 B 通过 extends 关键字继承类 A 的属性及其原型对象上的属性,通过在类 B 的 constructor 函数中执行 super() 函数,让类 B 的实例继承类 A 的实例属性,super() 的作用类似构造函数 B 中的 A.call(this),但它们是有区别的,这是 ES6 与 ES5 继承的第二点区别,这个区别会在文章的最后说明。在 ES6 中,两个类之间的继承就是通过 extends 和 super 两个关键字实现的。下面四幅图分别是,实例 b 的原型链及验证图,类 B 的原型链及验证图。

image.png
image.png
image.png
image.png

通过上面 4 幅图可知,在 ES6 与 ES5 中,类 B 的实例 b 的原型链与构造函数 B 的实例 b 的原型链是相同的,但是在 ES6 中类 B 继承了类 A 的属性,在 ES5 中,构造函数 B 没有继承构造函数 A 的属性,这是 ES6 与 ES5 继承的第一个区别。

super() 与 A.call(this) 的区别

在 ES5 中,构造函数 B 的实例继承构造函数 A 的实例属性是通过 A.call(this) 来实现的,在 ES6 中,类 B 的实例继承类 A 的实例属性,是通过 super() 实现的。在不是继承原生构造函数的情况下,A.call(this) 与 super() 在功能上是没有区别的,用 babel 在线转换 将类的继承转换成 ES5 语法,babel 也是通过 A.call(this) 来模拟实现 super() 的。但是在继承原生构造函数的情况下,A.call(this) 与 super() 在功能上是有区别的,ES5 中 A.call(this) 中的 this 是构造函数 B 的实例,也就是在实现实例属性继承上,ES5 是先创造构造函数 B 的实例,然后在让这个实例通过 A.call(this) 实现实例属性继承,在 ES6 中,是先新建父类的实例对象this,然后再用子类的构造函数修饰 this,使得父类的所有行为都可以继承。下面通过 2 段代码说明这个问题。

  1. function MyArray() {
  2. Array.call(this);
  3. }
  4. MyArray.prototype = Object.create(Array.prototype, {
  5. constructor: {
  6. value: MyArray,
  7. writable: true,
  8. configurable: true
  9. }
  10. });
  11. var colors = new MyArray();
  12. colors[0] = "red";
  13. console.log('colors',colors)
  14. colors.length;

这段代码的思路就是,让构造函数 MyArray 继承原生构造函数 Array,然后验证 MyArray 的实例是否具有 Array 实例的特性

从结果可以看出,MyArray 的实例并不具有 Array 实例的特性,之所以会发生这种情况,是因为 MyArray 的实例无法获得原生构造函数 Array 实例的内部属性,通过 Array.call(this) 也不行

  1. class MyArray extends Array {
  2. constructor() {
  3. super();
  4. }
  5. }
  6. var arr = new MyArray();
  7. arr[0] = 12;
  8. arr.length;

从结果可以看出,通过 super(),MyArray 的实例具有 Array 实例的特性。

  1. class 声明会提升,但不会初始化赋值。Foo 进入暂时性死区,类似于 letconst 声明变量。
  2. class 声明内部会启用严格模式。
  3. class 的所有方法(包括静态方法和实例方法)都是不可枚举的。
  4. class 的所有方法(包括静态方法和实例方法)都没有原型对象 prototype,所以也没有[[construct]],不能使用 new 来调用。
  5. 必须使用 new 调用 class
  6. class 内部无法重写类名。

总结

  • [[Prototype]] 是对象的内置属性,它会指向其他的对象,我们将它所关联的对象称之为对象的原型,由 [[Prototype]] 串联起的链条称为原型链。
  • prototype 是函数拥有的属性,当我们将函数与 new 关键词搭配,采用构造函数创建对象时,被创建对象的 [[Prototype]] 就会与函数的 prototype 关联,成为实例对象的原型。
  • __proto__ 是对象拥有的属性,它是一个非标准的方法,但是通过它可以获得对象的原型。
  • ES6 与 ES5 中的继承有 2 个区别,第一个是,ES6 中子类会继承父类的属性,第二个区别是,super() 与 A.call(this) 是不同的,在继承原生构造函数的情况下,体现得很明显,ES6 中的子类实例可以继承原生构造函数实例的内部属性,而在 ES5 中做不到。