【前端必会知识】Js 如何实现继承?

1,865 阅读14分钟

继承是什么?

继承是面向对象编程(OOP)中的一个重要概念,它允许一个对象或类别(称为子类或派生类)获取另一个对象或类别(称为父类或基类)的属性和方法。这意味着子类可以重用父类的代码,同时还可以添加自己的特定功能或修改继承的功能。

继承的核心思想是建立一种层次结构,其中父类包含通用的特征和行为,而子类可以在这个基础上进行扩展或修改,以满足特定需求。这有助于代码的重用、可维护性和扩展性。

在继承中,子类通常会继承以下内容:

  1. 属性(字段) :子类可以继承父类的属性,这些属性包括变量或数据。
  2. 方法:子类可以继承父类的方法,这些方法包括函数或行为。
  3. 构造函数:子类可以继承父类的构造函数,这是在创建子类实例时初始化对象的方法。

通过继承,可以建立对象之间的关系,实现代码的模块化和重用,同时允许在需要时进行自定义扩展。

谈论继承时,我觉得也可以将其比喻为一种关系,类似于家庭关系(不知道这么说好不好哈哈哈哈),就可以想象一下有一个大家庭,有祖父母、父母、孩子等不同的角色。

  • 父类就像家庭中的祖父母或父母,它是一个通用的类,具有一些共同的特征和行为。

  • 子类就像家庭中的孩子,他们继承了一些特征和行为,但也可以有自己独特的特征。

继承的好处就像是家庭中的传承和共享。孩子继承了父母的姓氏和一些遗传特征,但他们也可以在成长过程中发展出自己独特的特点。

此外,子类还可以重写或覆盖父类的一些特征。就像孩子可以在成年后选择自己的职业,有时甚至超越父母的成就,子类可以在继承父类的基础上添加新的功能或修改旧的功能,以满足特定需求。

继承的实现方式

JavaScripy常见的继承方式

  • 原型链继承
  • 构造函数继承(借助 call)
  • 组合继承
  • 原型式继承
  • 寄生式继承
  • 寄生组合式继承
  • ES6类继承:

1. 原型链继承

当涉及原型链继承时,理解构造函数、原型和实例之间的关系是至关重要的。

  1. 构造函数(Constructor): 构造函数是用来创建对象实例的函数。它定义了对象的初始状态和行为。构造函数的主要责任是初始化实例属性,通常通过使用 this 关键字来完成。

  2. 原型对象(Prototype Object): 每个构造函数都有一个关联的原型对象(prototype)。原型对象包含了共享给该构造函数创建的所有实例的属性和方法。这些属性和方法可以被实例访问,因为实例包含一个指向构造函数的原型对象的指针。

  3. 实例(Instance): 实例是通过构造函数创建的对象。实例包含了自己的属性,这些属性可以在构造函数内部使用 this 关键字来定义,并且实例还包含一个指向构造函数的原型对象的指针。这个指针使得实例可以访问原型对象上定义的属性和方法,如果在实例上找不到属性或方法,JavaScript 引擎会沿着原型链向上查找直到找到或达到原型链的顶端。

  4. 原型链(Prototype Chain): 原型链是一种由原型对象链接而成的链式结构,它连接了一个构造函数的原型对象与 Object.prototype,通常还包括其他中间原型对象。这意味着,如果在实例上找不到属性或方法,JavaScript 引擎将依次查找原型链,直到找到或达到原型链的顶端。这使得属性和方法可以在整个继承链上传播,从而实现继承和代码重用。

总之,原型链继承通过构造函数、原型对象和实例之间的关系(即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针),实现了对象之间的属性和方法共享。

function Fruit() {
  this.colors = ["red", "yellow"];
}

function Apple() {
  this.type = "apple";
}

Apple.prototype = new Fruit();
let apple = new Apple();
console.log(apple.colors); // [ 'red', 'yellow' ]

上述例子中Apple 构造函数继承了 Fruit 构造函数的属性。

但是原型链继承中存在引用类型属性的共享问题,这可能导致在一个实例上的操作影响到其他实例。比如以下例子:

var apple1 = new Apple();
var apple2 = new Apple();

apple1.colors.push("green");

console.log(apple1.colors); // 输出:["red", "yellow", "green"]
console.log(apple2.colors); // 输出:["red", "yellow", "green"]

colors 是一个数组,它在 Fruit 构造函数中被初始化为 ["red", "yellow"]。然后,我们创建两个 Apple 的实例:apple1apple2

当我们将 "green" 添加到 apple1.colors 中时,由于 apple1apple2 共享同一个原型对象,它们的 colors 属性都会受到影响,因此输出结果都是 ["red", "yellow", "green"]

优点:

  1. 简单易懂: 原型链继承是一种非常直观的继承方式,它利用了 JavaScript 中原型和原型链的基本概念,因此易于理解和实现。
  2. 代码重用: 可以在原型对象上定义方法和属性,这些方法和属性将被所有继承该原型的子对象共享,从而实现了代码的重用。
  3. 运行时扩展: 可以随时向原型对象添加新的方法和属性,这将自动地被继承自该原型的所有对象所拥有。

缺点:

  1. 共享属性问题: 原型链继承最大的问题是所有子对象共享同一个原型对象,因此,如果一个子对象修改了继承的引用类型属性(如数组或对象),则会影响到其他子对象,这可能会导致意外的行为。
  2. 不能传递参数: 在使用原型链继承时,不能向父类构造函数传递参数,因为实例化子类时实际上是调用了父类构造函数,而不是传递参数的方式。
  3. 无法实现多继承: JavaScript 的原型链继承只支持单继承,一个子类只能继承一个父类的属性和方法。

2. 构造函数继承

构造函数继承是一种 JavaScript 中的继承方式,它通过在子类构造函数内部调用父类构造函数来实现继承。(一般借助 call调用Parent函数)

function Parent(name) {
  this.name = name;
  this.tools = ["锤子", "铁锹"];
}

Parent.prototype.getName = function () {
    return this.name;
}

function Child(name, age) {
  // 使用 call 调用父类构造函数,并传递当前子类实例(this)和参数 name
  Parent.call(this, name);

  // 子类独有的属性
  this.age = age;
}

// 创建一个 Parent 实例
let parent = new Parent("John");
console.log(parent.name); //  John

let child1 = new Child("Alice", 5);
let child2 = new Child("Lucy", 6);
child1.tools.push("螺丝刀");

console.log(child1.name); //  Alice
console.log(child2.name); // Lucy
console.log(child1.tools); // [ '锤子', '铁锹', '螺丝刀' ]
console.log(child2.tools); // [ '锤子', '铁锹' ]
console.log(child1.getName());  // 会报错

上述例子创建了一个 Parent 实例,即 parent,并传递了名字 "John" 作为参数。这个实例拥有一个属性 name,其值为 "John"。

又创建了两个 Child 的实例:child1child2,并分别传递了名字 "Alice" 和 "Lucy" 以及年龄参数。这两个子类实例继承了父类 Parent 的属性和方法,包括 name 属性。

然后,我们在 child1tools 数组中添加了 "螺丝刀"。由于在 Child 构造函数内部通过 Parent.call(this, name); 调用了父类构造函数,child1child2 都有一个属性 tools,该属性是一个数组,初始值为 ["锤子", "铁锹"]。但是尽管 child1child2 共享相同的原型对象,但它们的实例属性(如 nametools)是独立的,因此一个实例的属性修改不会影响另一个实例。因此child1的tools输出结果是 [ '锤子', '铁锹', '螺丝刀' ],但是child2的tools输出结果是 [ '锤子', '铁锹' ]。

优点:

  1. 属性不会被共享: 与原型链继承不同,构造函数继承不会共享父类的引用属性。这意味着每个子类实例都有自己的属性副本,不会相互影响。

缺点:

  1. 无法继承原型链上的方法: 一个主要的缺点是,构造函数继承无法继承父类原型上的方法,因此子类无法访问 Parent.prototype 上的方法。这就是为什么 child.getName() 报错的原因。

  2. 不利于方法共享: 因为每个子类实例都会拥有父类构造函数内的属性和方法的副本,这可能会导致内存浪费,尤其在创建多个子类实例时。

  3. 不符合原型链继承的特点: 构造函数继承的方式并没有真正地继承父类的原型链,因此它不符合 JavaScript 原型链继承的思想。

3. 组合继承(结合原型链继承和构造函数继承)

组合继承是一种继承方式,它结合了构造函数继承和原型链继承,以克服它们各自的缺点。

function Parent () {
    this.name = 'parent';
    this.play = [1, 2, 3];
}

Parent.prototype.getName = function () {
    return this.name;
}

function Child() {
    Parent.call(this);
    this.type = 'child';
}

Child.prototype = new Parent();
// 手动挂上构造器,指向自己的构造函数
Child.prototype.constructor = Child;
var s1 = new Child();
var s2 = new Child();
s1.play.push(4);
console.log(s1.play, s2.play);  // 不互相影响 [1,2,3,4] [1,2,3]
console.log(s1.getName()); // 正常输出'parent3'
console.log(s2.getName()); // 正常输出'parent3'

Parent 构造函数内部,定义了一个属性 name 和数组 play,这些属性会被设置为每个 Parent 实例的属性。

通过 Parent.prototype 添加了一个方法 getName,这个方法被所有 Parent 的实例共享。

Child 构造函数内部,首先使用 Parent.call(this); 调用父类构造函数,以继承父类的属性。

然后,将 Child.prototype 设置为一个新的 Parent 实例,这样子类的原型链就包括了父类的原型方法和属性。

最后,修复 Child.prototype.constructor,以确保它指向 Child 构造函数,解决共享引用数据的问题。

优点:

  1. 继承父类属性和方法: 组合继承能够同时继承父类构造函数内的属性和方法,以及父类原型链上的方法。这使得子类非常灵活,能够使用父类的属性和方法,同时也能继承共享的方法。

  2. 不共享引用属性: 与构造函数继承不同,组合继承不会共享父类构造函数内的引用属性,因此每个子类实例都有自己的属性副本。

缺点:

  1. 多次调用父类构造函数: 一个主要的缺点是,组合继承在创建子类实例时会调用两次父类构造函数。首先是通过 Parent.call(this); 在子类构造函数内部调用,然后是通过 Child.prototype = new Parent(); 在原型链上创建实例。这可能会导致性能开销,尤其是如果父类构造函数执行复杂操作或依赖外部资源。

  2. 原型链上的冗余数据: 由于 Child.prototype 包含了一个父类的实例,因此原型链上会存在一些冗余的数据,尽管这不会对子类的功能造成问题,但会占用额外的内存空间。

4. 原型式继承

原型式继承借助 Object.create() 方法创建一个新对象,这个新对象的原型(prototype)是另一个已存在的对象,从而实现继承。

let parent = {
  name: "parent",
  friends: ["p1", "p2", "p3"],
  getName: function() {
    return this.name;
  }
};

let person1 = Object.create(parent);
person1.name = "tom";
person1.friends.push("jerry");

let person2 = Object.create(parent);
person2.friends.push("lucy");

console.log(person1.name); // tom
console.log(person1.name === person1.getName()); // true
console.log(person2.name); // parent1
console.log(person1.friends); // ["p1", "p2", "p3","jerry","lucy"]
console.log(person2.friends); // ["p1", "p2", "p3","jerry","lucy"]

上述例子,person1person2 都通过 Object.create(parent) 继承了 parent 对象。

优点:

  1. 简单易用: 原型式继承是一种简单的方式来创建对象之间的继承关系,无需定义构造函数或类。

  2. 共享属性和方法: 由于多个实例共享原型对象上的属性和方法,这可以节省内存空间。

缺点:

  1. 共享引用类型属性: 原型式继承会导致多个实例共享引用类型属性,如果一个实例修改了这个属性,其他实例也会受到影响,可能引发意外的副作用。

5. 寄生式继承

寄生式继承是一种在 JavaScript 中实现对象继承的变种方法,它在原型式继承的基础上进行了增强,通常用于向对象添加额外的方法或属性。

let parent = {
  name: "parent",
  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 person = clone(parent);

console.log(person.getName()); // parent
console.log(person.getFriends()); // ["p1", "p2", "p3"]

上述例子中,clone 函数创建了一个继承自 parent5 对象的新对象,并添加了一个名为 getFriends 的方法。

优点:

  1. 简单易用: 与原型式继承一样,寄生式继承也是一种简单的继承方式,无需定义构造函数或类。

  2. 可以在不修改原对象的情况下添加方法或属性: 通过在新对象上添加额外的方法或属性,可以在不改变原对象的情况下扩展其功能,这有助于保持原对象的封装性。

缺点:

  1. 共享引用类型属性: 与原型式继承一样,寄生式继承也会导致多个实例共享引用类型属性,可能存在篡改的风险。

6. 寄生组合式继承

寄生组合式继承是一种继承模式,它结合了寄生继承和组合继承的优点,旨在解决传统组合继承的性能和构造函数调用两次的问题。

function clone (parent, child) {
  // 这里改用 Object.create 就可以减少组合继承中多进行一次构造的过程
  child.prototype = Object.create(parent.prototype);
  child.prototype.constructor = child;
}

function Parent() {
  this.name = 'parent';
  this.play = [1, 2, 3];
}
Parent.prototype.getName = function () {
  return this.name;
}
function Child() {
  Parent.call(this);
  this.friends = 'child5';
}

clone(Parent, Child);

Child.prototype.getFriends = function () {
  return this.friends;
}

let person = new Child();
console.log(person); // Child { name: 'parent', play: [ 1, 2, 3 ], friends: 'child5' }
console.log(person.getName()); // parent
console.log(person.getFriends()); // child5

优点:

  1. 继承和实例化都得到了优化: 与传统的组合继承相比,寄生组合式继承避免了在子类构造函数中调用父类构造函数两次的问题,因此提高了性能。

  2. 避免了共享引用类型属性: 与原型式继承和寄生式继承相比,寄生组合式继承不会共享引用类型属性,因此不存在共享属性的问题。

  3. 保持了原型链: 这种继承方式保持了原型链的完整性,使得 instanceof 和其他原型链相关的特性能够正常工作。

缺点:

  1. 相对复杂: 相对于原型式继承和寄生式继承,寄生组合式继承需要更多的代码,可能会稍微复杂一些。而且可能需要一个额外的函数(clone 函数)来设置原型链,这增加了代码的复杂性。

7. ES6类继承(extends)

ES6 类继承(extends)是 JavaScript 中一种现代的面向对象编程方式,它提供了一种更清晰、更易读的方法来实现继承。

class Parent {
  constructor(name) {
    this.name = name;
  }

  sayHello() {
    console.log("Hello from Parent");
  }
}

class Child extends Parent {
  constructor(name, childName) {
    super(name);
    this.childName = childName;
  }
}

const child = new Child("John", "Junior");

console.log(child.childName); // Junior
child.sayHello(); // Hello from Parent

优点:

  1. 清晰的语法:ES6 类继承使用 class 关键字,提供了一种更直观的方式来定义和扩展类,使代码更易读、易维护。

  2. 内置的 super 关键字super 关键字简化了调用父类构造函数和方法的过程,使得在子类中扩展父类更加容易和直观。

  3. 支持封装性:ES6 类继承提供了公共和私有成员变量的支持,通过 constructor 中定义的成员变量和方法,可以实现更好的封装性。

缺点:

  1. 不支持早期 JavaScript 版本:ES6 类继承是现代 JavaScript 的一部分,因此不适用于旧版 JavaScript 环境,需要在支持 ES6 的环境中使用。

  2. 可能引发继承链问题:虽然 ES6 类继承简化了继承的语法,但仍然需要小心处理继承链问题,避免深层次的继承关系导致不易维护的代码。

  3. 不支持多继承:ES6 类继承不支持多继承,即一个类不能同时继承多个父类,这可能限制了某些特定情况下的代码结构。

后记

希望本文能增加大家对 Js 继承的理解。本人水平有限,如果发现问题或者需要补充的点欢迎大家通过评论告诉我!!!