likes
comments
collection
share

JS 常见的 6 种继承方式

作者站长头像
站长
· 阅读数 14

继承概念的探究

说到继承的概念,首先要说一个经典的例子。

先定义一个类(Class)叫汽车,汽车的属性包括颜色、轮胎、品牌、速度、排气量等,由汽车这个类可以派生出“轿车”和“货车”两个类,那么可以在汽车的基础属性上,为轿车添加一个后备厢、给货车添加一个大货箱。这样轿车和货车就是不一样的,但是二者都属于汽车这个类,这样从这个例子中就能详细说明汽车、轿车以及卡车之间的继承关系。

继承可以使得子类别具有父类的各种方法和属性,比如上面的例子中“轿车” 和 “货车” 分别继承了汽车的属性,而不需要再次在“轿车”中定义汽车已经有的属性。在“轿车”继承“汽车”的同时,也可以重新定义汽车的某些属性,并重写或覆盖某些属性和方法,使其获得与“汽车”这个父类不同的属性和方法。

JS 实现继承的几种方式

1、原生链继承

原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针。

  function Parent1() {

    this.name = 'parent1';

    this.play = [1, 2, 3]

  }

  function Child1() {

    this.type = 'child2';

  }

  Child1.prototype = new Parent1();

  console.log(new Child1());

原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针。虽然父类的方法和属性都能够访问,但其实有一个潜在的问题,明明我只改变了 s1 的 play 属性,为什么 s2 也跟着变了呢?原因很简单,因为两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化,这就是使用原型链继承方式的一个缺点。

  var s1 = new Child1();

  var s2 = new Child2();

  s1.play.push(4);

  console.log(s1.play, s2.play);

2、构造函数继承

  function Parent1(){

    this.name = 'parent1';

  }



  Parent1.prototype.getName = function () {

    return this.name;

  }



  function Child1(){

    Parent1.call(this);

    this.type = 'child1'

  }



  let child = new Child1();

  console.log(child);  // 没问题

  console.log(child.getName());  // 会报错

可以看到最后打印的 child 在控制台显示,除了 Child1 的属性 type 之外,也继承了 Parent1 的属性 name。这样写的时候子类虽然能够拿到父类的属性值,解决了第一种继承方式的弊端。

问题:父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法。

3、组合继承

这种方式结合了前两种继承方式的优缺点,结合起来的继承。

  function Parent3 () {

    this.name = 'parent3';

    this.play = [1, 2, 3];

  }



  Parent3.prototype.getName = function () {

    return this.name;

  }

  function Child3() {

    // 第二次调用 Parent3()

    Parent3.call(this);

    this.type = 'child3';

  }



  // 第一次调用 Parent3()

  Child3.prototype = new Parent3();

  // 手动挂上构造器,指向自己的构造函数

  Child3.prototype.constructor = Child3;

  var s3 = new Child3();

  var s4 = new Child3();

  s3.play.push(4);

  console.log(s3.play, s4.play);  // 不互相影响

  console.log(s3.getName()); // 正常输出'parent3'

  console.log(s4.getName()); // 正常输出'parent3'

问题:通过注释我们可以看到 Parent3 执行了两次,第一次是改变Child3 的 prototype 的时候,第二次是通过 call 方法调用 Parent3 的时候,那么 Parent3 多构造一次就多进行了一次性能开销,这是我们不愿看到的。

4、原型式继承

ES5 里面的 Object.create 方法,这个方法接收两个参数:一是用作新对象原型的对象、二是为新对象定义额外属性的对象(可选参数)。

  let parent4 = {

    name: "parent4",

    friends: ["p1", "p2", "p3"],

    getName: function() {

      return this.name;

    }

  };



  let person4 = Object.create(parent4);

  person4.name = "tom";

  person4.friends.push("jerry");



  let person5 = Object.create(parent4);

  person5.friends.push("lucy");



  console.log(person4.name);//tom

  console.log(person4.name === person4.getName());//true

  console.log(person5.name);//parent4

  console.log(person4.friends);//["p1","p2","p3","jerry","lucy"]

  console.log(person5.friends);//["p1","p2","p3","jerry","lucy"]

通过 Object.create 这个方法可以实现普通对象的继承,不仅仅能继承属性,同样也可以继承 getName 的方法。

第一个结果“tom”,比较容易理解,person4 继承了 parent4 的 name 属性,但是在这个基础上又进行了自定义。

第二个是继承过来的 getName 方法检查自己的 name 是否和属性里面的值一样,答案是 true。

第三个结果“parent4”也比较容易理解,person5 继承了 parent4 的 name 属性,没有进行覆盖,因此输出父对象的属性。

最后两个输出结果是一样的,其实 Object.create 方法是可以为一些对象实现浅拷贝的。

问题:多个实例的引用类型属性指向相同的内存,存在篡改的可能。

5、寄生继承

使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法,这样的继承方式就叫作寄生式继承。

虽然其优缺点和原型式继承一样,但是对于普通对象的继承方式来说,寄生式继承相比于原型式继承,还是在父类基础上添加了更多的方法。

   let parent5 = {

    name: "parent5",

    friends: ["p1", "p2", "p3"],

    getName: function() {

      return this.name;

    }

  };



  function clone(original) {

    let clone = Object.create(original);

    clone.getFriends = function() {

      return this.friends;

    };

    return clone;

  }



  let person5 = clone(parent5);



  console.log(person5.getName());

  console.log(person5.getFriends());

 person5 是通过寄生式继承生成的实例,它不仅仅有 getName 的方法,而且可以看到它最后也拥有了 getFriends 的方法。person5 通过 clone 的方法,增加了 getFriends 的方法,从而使 person5 这个普通对象在继承过程中又增加了一个方法,这样的继承方式就是寄生式继承。

问题:优缺点和原型式继承一样,但是对于普通对象的继承方式来说,寄生式继承相比于原型式继承,还是在父类基础上添加了更多的方法。

6、寄生组合式继承

结合第四种中提及的继承方式,解决普通对象的继承问题的 Object.create 方法,我们在前面这几种继承方式的优缺点基础上进行改造,得出了寄生组合式的继承方式,这也是所有继承方式里面相对最优的继承方式

  function clone (parent, child) {

    // 这里改用 Object.create 就可以减少组合继承中多进行一次构造的过程

    child.prototype = Object.create(parent.prototype);

    child.prototype.constructor = child;

  }



  function Parent6() {

    this.name = 'parent6';

    this.play = [1, 2, 3];

  }

   Parent6.prototype.getName = function () {

    return this.name;

  }

  function Child6() {

    Parent6.call(this);

    this.friends = 'child5';

  }



  clone(Parent6, Child6);



  Child6.prototype.getFriends = function () {

    return this.friends;

  }



  let person6 = new Child6();

  console.log(person6);

  console.log(person6.getName());

  console.log(person6.getFriends());

寄生组合式继承方式,基本可以解决前几种继承方式的缺点,较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销。

总结:

(1)第一种是以原型链的方式来实现继承,但是这种实现方式存在 的缺点是,在包含有引用类型的数据时,会被所有的实例对象所共享, 容易造成修改的混乱。还有就是在创建子类型的时候不能向超类型传 递参数。

(2)第二种方式是使用借用构造函数的方式,这种方式是通过在子 类型的函数中调用超类型的构造函数来实现的,这一种方法解决了不 能向超类型传递参数的缺点,但是它存在的一个问题就是无法实现函 数方法的复用,并且超类型原型定义的方法子类型也没有办法访问到。

(3)第三种方式是组合继承,组合继承是将原型链和借用构造函数 组合起来使用的一种方式。通过借用构造函数的方式来实现类型的属 性的继承,通过将子类型的原型设置为超类型的实例来实现方法的继 承。这种方式解决了上面的两种模式单独使用时的问题,但是由于我 们是以超类型的实例来作为子类型的原型,所以调用了两次超类的构 造函数,造成了子类型的原型中多了很多不必要的属性

(4)第四种方式是原型式继承,原型式继承的主要思路就是基于已 有的对象来创建新的对象,实现的原理是,向函数中传入一个对象, 然后返回一个以这个对象为原型的对象。这种继承的思路主要不是为 了实现创造一种新的类型,只是对某个对象实现一种简单继承,ES5 中定义的 Object.create() 方法就是原型式继承的实现。缺点与原 型链方式相同。

(5)第五种方式是寄生式继承,寄生式继承的思路是创建一个用于 封装继承过程的函数,通过传入一个对象,然后复制一个对象的副本, 然后对象进行扩展,最后返回这个对象。这个扩展的过程就可以理解 是一种继承。这种继承的优点就是对一个简单对象实现继承,如果这 个对象不是自定义类型时。缺点是没有办法实现函数的复用。

(6)第六种方式是寄生式组合继承,组合继承的缺点就是使用超类 型的实例做为子类型的原型,导致添加了不必要的原型属性。寄生式 组合继承的方式是使用超类型的原型的副本来作为子类型的原型,这 样就避免了创建不必要的属性。 

上面的继承看见就想吐,而且还有人拷问这些东西,很烦!还好ES6出了class和extends ,我们可以通过class创造类,使用extends实现类的继承。

class Person {

  constructor(name) {

    this.name = name

  }

  // 原型方法

  // 即 Person.prototype.getName = function() { }

  // 下面可以简写为 getName() {...}

  getName = function () {

    console.log('Person:', this.name)

  }

}

class Gamer extends Person {

  constructor(name, age) {

    // 子类中存在构造函数,则需要在使用“this”之前首先调用 super()。

    super(name)

    this.age = age

  }

}

const asuna = new Gamer('Asuna', 20)

asuna.getName() // 成功访问到父类的方法

因为浏览器的兼容性问题,如果遇到不支持 ES6 的浏览器,那么就得利用 babel 这个编译工具,将 ES6 的代码编译成 ES5,让一些不支持新语法的浏览器也能运行。

function _possibleConstructorReturn (self, call) { 

		// ...

		return call && (typeof call === 'object' || typeof call === 'function') ? call : self; 

}

function _inherits (subClass, superClass) { 

    // 这里可以看到

	subClass.prototype = Object.create(superClass && superClass.prototype, { 

		constructor: { 

			value: subClass, 

			enumerable: false, 

			writable: true, 

			configurable: true 

		} 

	}); 

	if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; 

}



var Parent = function Parent () {

	// 验证是否是 Parent 构造出来的 this

	_classCallCheck(this, Parent);

};

var Child = (function (_Parent) {

	_inherits(Child, _Parent);

	function Child () {

		_classCallCheck(this, Child);

		return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));

}

	return Child;

}(Parent));

ES5/ES6 的继承除了写法以外还有什么区别?

1. ES5 的继承实质上是先创建子类的实例对象,然后再将父类的方法添加 到 this 上(Parent.apply(this))

2. ES6 的继承机制完全不同,实质上是先创建父类的实例对象 this(所以必 须先调用父类的 super()法),然后再用子类的构造函数修改 this。

3. ES5 的继承时通过原型或构造函数机制来实现。

4. ES6 通过 class 关键字定义类,里面有构造方法,类之间通过 extends 关 键字实现继承。

5. 子类必须在 constructor 方法中调用 super 方法,否则新建实例报错。因为子类没有自己的 this 对象,而是继承了父类的 this 对象,然后对其进行加工。 如果不调用 super 方法,子类得不到 this 对象。

6. 注意 super 关键字指代父类的实例,即父类的 this 对象。

7. 注意:在子类构造函数中,调用 super 后,才可使用 this 关键字,否则 报错。