JS 实现继承的 5 种方式

本文详细介绍了JavaScript中的五种继承方式:原型链继承、构造继承、复制继承、组合继承及寄生组合继承。每种方式均从实现原理、优缺点等方面进行了深入浅出的解析。

继承

继承作为面向对象语言的三大特性之一(继承、封装和多态),可以在不影响父类对象实现的情况下,使得子类对象具有父类对象的特性,同时还能在不影响父类对象行为的情况下扩展子类对象独有的特性,为编码带来了极大的便利。

定义一个父类 Animal 并增加属性,实例方法和原型上的属性和方法:

/**
  父类 Animal
*/
function Animal(name = "动物") {
  // 属性
  this.name = name;
  this.type = "animal";
  this.love = ["唱", "跳", "rapper"];
  
  // 方法
  this.sleep = function () {
    console.log(this.name + "正在睡觉");
  };
}

// 原型上的属性和方法
Animal.prototype.age = 100;
Animal.prototype.eat = function (food = "苹果") {
  console.log(this.name + "正在吃" + food);
};

原型链继承

原型链继承的主要思想是:重写子类的prototype属性,将其指向父类的实例。

定义一个子类 Cat,用于继承父类 Animal,子类 Cat 的实现代码如下:

/**
  子类
*/
function Cat(name = "卡菲猫") {
  this.name = name;
}

/**
  原型继承
*/
Cat.prototype = new Animal();
// 很关键的一句,将 Cat 的原型对象指向自身
// 如果不将 Cat 原型对象的 constructor 属性指向自身的话,那将会指向父类 Animal 的构造函数。
Cat.prototype.constructor = Cat;

const cat = new Cat("大肥猫");

console.log(cat.name); // '大肥猫'
console.log(cat.type); // 'animal'
console.log(cat.age);  // 100
cat.eat();             // '大肥猫正在吃苹果'

在子类 Cat 中,存在name属性,所以在创建一个 Cat 实例的时候,name属性值会覆盖父类 Animal 的name属性值,因此输出“大肥猫”。

在子类 Cat 中,不存在type属性,因此会继承父类 Animal 的type属性,输出“animal”age属性和eat()方法同理。

继承示意图:

在这里插入图片描述


原型链继承的优缺点

优点

1️⃣ 简单,容易实现

只需要设置子类的prototype属性为父类的实例即可,实现起来简单。

2️⃣ 继承关系纯粹

生成的实例即是子类的实例,也是父类的实例

console.log(cat instanceof Cat);    // true
console.log(cat instanceof Animal); // true

3️⃣ 可以通过子类直接访问父类原型链上属性和方法

通过原型链继承的子类,可以直接访问到父类原型链上新增的函数和属性。

// 新增属性和方法
Animal.prototype.color = "pink"
Animal.prototype.getColor = function () {
	return this.color;
}

// 访问新增的属性和方法
console.log(cat.color);    // 'pink'
console.log(cat.getColor); // 'pink'

缺点

1️⃣ 子类的所有实例将共享父类的属性

Cat.prototype = new Animal();

在使用原型链继承时,直接改写了子类 Cat 的prototype属性,将其指向一个 Animal 的实例,那么所有生成的 Cat 实例都将会共享 Animal 实例的属性和方法。

以上的描述可以理解为以下的代码:

// 生成一个 Animal 的实例
let animal = new Animal();

// 通过改写 Cat 的 prototype 属性,所有 Cat 实例将共享 animal 中的属性和方法
Cat.prototype = animal;

这会带来一个很严重的问题,如果父类 Animal 中有个值为引用数据类型,那么改变 Cat 某个实例的属性值将会影响其他实例的属性值。

// 创建两个实例
const cat1 = new Cat()
const cat2 = new Cat();

console.log("cat1", cat1.love);  // "cat1" ["唱", "跳", "rapper"];
console.log("cat2", cat2.love);  // "cat2" ["唱", "跳", "rapper"];

// 修改 cat1.love 属性值
cat1.love.push("打篮球");

console.log("cat1", cat1.love);  // "cat1" ["唱", "跳", "rapper", "打篮球"];
console.log("cat2", cat2.love);  // "cat2" ["唱", "跳", "rapper", "打篮球"];

2️⃣ 在创建子类实例时,无法向父类构造函数传递参数。

在通过new操作符创建子类的实例时,会调用子类的构造函数,而在子类的构造函数中并没有设置与父类的关联,从而导致无法向父类的构造函数传递参数。

3️⃣ 无法实现多继承

由于子类 Cat 的prototype属性只能设置为一个值,如果同时设置为多个值的话,后面的值会覆盖前面的值,导致 Cat 只能继承一个父类,而无法实现多继承。

4️⃣ 为子类的原型对象添加属性和方法时,必须放在new Animal语句之后。

实现继承的关键语句是下面这句代码,它实现了子类的prototype属性的改写

Cat.prototpe = new Animal();

如果想要为子类的原型对象添加的属性和方法,那么需要在这个语句之后再进行添加,因为如果在这个语句之前设置了prototype属性,后面执行的语句会直接重写prototype属性,导致之前的设置全部失效。


构造继承

构造继承的主要思想是在子类的构造函数中通过call()函数改变this的指向,调用父类的构造函数,从而能将父类的实例的属性和方法绑定到子类的this上。

// 子类
function Cat() {
  // 通过 call() 改变 this 的指向实现继承
  Animal.call(this);
}

const cat = new Cat();
console.log(cat.name);  // "Tom"
console.log(cat.age);   // undefined
cat.sleep();            // "Tom正在睡觉"
cat.eat();              // cat.eat() is not a function

通过代码可以发现,子类可以正常调用父类的属性和方法,而无法调用父类原型对象上的属性和方法,这是因为子类并没有和父类的原型对象建立关联。


构造继承的优缺点

优点

1️⃣ 可以解决子类实例共享父类属性的问题。

call()方法实际是改变了父类 Anima l构造函数中this的指向,调用后this指向了子类 Cat,相当于父类的nametypelovesleep等直接绑定到了子类的this中,成了子类实例的属性和函数,因此生成的子类实例中各自拥有自己的nametypelovesleep,不会互相影响。

2️⃣ 创建子类的实例时,可以向父类传递参数

call()方法中,可以传递参数,这时候参数是传递给父类的,这样就可以对父类的属性进行设置,同时由子类继承下来。

// 定义父类
function Animal(age) {
  this.age = age;
}

// 定义子类
function Cat(age) {
  // 在子类生成实例时,传递参数给 call(),间接地传递给父类,然后被子类继承
  Animal.call(this, age);
}

const cat = new Cat(100);
console.log(cat.age);  // 100

3️⃣ 可以实现多继承

在子类的构造函数中,可以通过多次调用call()方法来继承多个父对象,每调用一次call()方法就会将父类的实例的属性和方法绑定到子类的this中。

// 定义第一个父类
function Animal(name) {
  this.name1 = name;
}

// 定义第二个父类
function Car(name) {
  this.name2 = name;
}

// 定义第三个父类
function Person(name) {
  this.name3 = name;
}

// 定义子类
function Cat(name1, name2, name3) {
  Animal.call(this, name1);
  Car.call(this, name2);
  Person.call(this, name3);
}

const cat = new Cat("卡菲猫", "兰博基尼", "钢铁侠");
console.log(cat.name1); // "卡菲猫"
console.log(cat.name2); // "兰博基尼"
console.log(cat.name3); // "钢铁侠"

缺点

1️⃣ 实例只是子类的实例,并不是父类的实例
因为没有将子类与父类进行关联起来,所以生成的实例与父类并没有关系,这样就失去了继承的意义。

const cat = new Cat();
console.log(cat instanceof Cat);    // true
console.log(cat instanceof Animal); // false

2️⃣ 只能继承父类的属性和方法,并不能继承原型对象上的属性和方法。

3️⃣ 无法复用父类的方法

由于父类的方法将通过call()绑定到子类的this中,因此子类生成的每一个实例都会拥有自己的方法,这会造成不必要的内存消耗,影响性能。

const cat1 = new Cat();
const cat2 = new Cat();

console.log(cat1.sleep === cat2.sleep);  // false

复制继承

复制继承的主要思想是首先生成父类的实例,然后通过for...in遍历父类实例的属性和函数,并将其依次设置为子类实例的属性和函数或者原型对象上的属性和方法。

function Cat(name = "卡菲猫") {
  const animal = new Animal();

  for (key in animal) {
    if (animal.hasOwnProperty(key)) {
      this[key] = animal[key];
    } else {
      Cat.prototype[key] = animal[key];
    }
  }
}

const cat = new Cat();

console.log(cat.name); // "Tom"
console.log(cat.age);  // 100
cat.sleep();           // Tom正在睡觉
cat.eat();             // Tom正在吃苹果

复制继承的优缺点

优点

1️⃣ 支持多继承

只需要在子类的构造函数中生成多个父类的实例,然后通过相同的for...in处理即可。

2️⃣ 能同时继承实例的属性和方法与父类的原型对象上的属性和方法

因为对所有的属性进行遍历时,会通过hasOwnProperty()判断其是实例的属性和方法还是原型对象上的属性和方法,并根据结果进行不同的设置,从而既能继承实例的属性和方法又能继承原型对象上的属性和方法。

3️⃣ 可以向父类构造函数中传值

在生成子类的实例时,可以先把值传给子类的构造函数,然后在子类构造函数中,再将值传递给父类的构造函数。

function Cat(name) {
  const animal = new Animal(name);
}

const cat = new Cat("汤姆");

缺点

1️⃣ 父类的所有属性都需要复制,消耗内存。

对于父类的所有属性值都需要复制一遍,这会造成内存的重复利用,降低性能。

2️⃣ 实例只是子类的实例,并不是父类的实例。

实际上只是通过遍历父类的属性和方法并将其复制到子类上,并没有通过原型对象串联起父类和子类,因此子类的实例并不是父类的实例。

const cat = new Cat();

console.log(cat instanceof Cat);     // true
console.log(cat instanceof Animal);  // false

组合继承

组合继承的主要思想是结合了构造继承和原型继承两种方法,一方面在子类的构造函数中通过call()函数调用父类的构造函数,将父类的实例的属性和方法绑定到子类的this中,另一方面,通过改变子类的prototype属性,继承父类的原型对象上的属性和方法。

// 子类;
function Cat() {
  // 构造继承
  Animal.call(this);
}

// 原型链继承
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

const cat = new Cat();

console.log(cat.name); // "Tom"
console.log(cat.age);  // 100
cat.sleep();           // "Tom正在睡觉"
cat.eat();             // "Tom正在吃苹果"

组合继承的优缺点

优点

1️⃣ 既能继承父类的实例的属性和方法,又能继承父类原型对象的属性和方法。

2️⃣ 即是子类的实例,又是父类的实例。

console.log(cat instanceof Cat);     // true
console.log(cat instanceof Animal);  // true

3️⃣ 不存在引用属性共享的问题。

4️⃣ 可以向父类的构造函数中传递参数

通过call()可以向父类的构造函数中传递参数。


缺点

1️⃣ 组合继承的缺点为父类的实例的属性和方法会绑定 2 次

在子类的构造函数中,通过call()函数调用了一次父类的构造函数,在改写子类的prototype属性,生成父类的实例时调用了一次父类的构造函数。


寄生组合继承

此继承方法是针对组合继承的优化。组合继承在进行子类的prototype属性的设置时,可以去掉父类实例的属性和方法。

// 子类;
function Cat(name = "卡菲猫") {
  // 构造继承
  Animal.call(this);
}

(function () {
  // 声明任意函数 Super()
  var Super = function () {};
  // 关键语句:Super() 函数的原型指向父类 Animal 的原型,去掉父类的实例的属性和方法
  Super.prototype = Animal.prototype;
  Cat.prototype = new Super();
  Cat.prototype.constructor = Cat;
})();

const cat = new Cat();

console.log(cat.name); // "Tom"
console.log(cat.age);  // 100
cat.sleep();           // "Tom正在睡觉"
cat.eat();             // "Tom正在吃苹果"

参考

点击跳转

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值