【深入浅出JS六种继承】

57 阅读12分钟

在学习JS的继承前,需要将一些基本的概念搞清楚,比如:构造函数、原型对象、实例等。 有一篇文章原博主写的很好如何理解构造函数,原型对象和实例的关系建议大家去看一下,我这里就提取重点简单总结一下!

一、构造函数,原型对象和实例的关系

1.构造函数、实例

构造函数,是用来创建对象的函数,本质上也是函数。

与其他函数的区别在于调用方式不同:

  • 如果通过new操作符来调用的,就是构造函数
  • 如果没有通过new操作符来调用的,就是普通函数
function Person(name, age) {
   this.name = name;
   this.age = age;
 }
 //当做构造函数调用
 var person1 = new Person('Mike',10);
 
 //当做普通函数调用,这里相当于给window对象添加了name和age属性
 Person('Bob',12);
 
 console.log(person1)//Person {name: "Mike", age: 10}
 console.log(name)//Bob
 console.log(age)//12

在var person1 = new Person('Mike',10)中,通过new操作符调用了函数Person,并且生成了person1。 这里的Person就称为构造函数person1称为Person函数对象的一个实例(复制品)。

2.原型对象

当我们每次创建一个函数的时候,函数对象都会有一个prototype属性,这个属性是一个指针,指向它的原型对象原型对象的本质也是一个对象

  • 代码理解:
function Person(name, age) {
   this.name = name;
   this.age = age;
}
console.log(Person.prototype)//object{constructor:Person}

在这里插入图片描述 可以看到Person.prototype指向了一个对象,即Person的原型对象(Person prototype)

并且这个对象(Person prototype)有一个constructor属性,又指向了Person函数对象(又指了回去)。

3.构造函数,原型对象和实例的关系

  • 画图理解

在这里插入图片描述

  • 函数对象的prototype指向原型对象,原型对象的constructor指向函数对象
  • 实例对象的[Protoptype]属性指向原型对象,这里的[Protoptype]是内部属性,可以先理解为它是存在的,但是不允许我们访问。这个属性的作用是:允许实例通过该属性访问原型对象中的属性和方法。
function Person(name, age) {
        this.name = name;
        this.age = age;
      }
      //在原型对象中添加属性或者方法
     Person.prototype.sex = '男'; 
     var person1 = new Person('Mike',10);
     var person2 = new Person('Alice',20);
     //只给person2设置性别
     person2.sex = '女';
     console.log(person1.sex)//'男'
     console.log(person2.sex)//'女'

在这里插入图片描述 这里我们没有给person1实例设置sex属性,但是因为[Protoptype]的存在,会访问原型对象中对应的属性;同时我们给person2设置sex属性后输出的是'女'。

说明只有当实例本身不存在对应的属性或方法时,才会去找原型对象上的对应属性或方法

总结: 构造函数、原型和实例之间的关系:

每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个原型对象的指针。

继承的本质就是复制,即重写原型对象,代之以一个新类型的实例。

二、继承

在理解了构造函数、原型和实例之间的关系后,我们正式进入到继承的学习中:

1. 原型链继承(基础版)

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

function Child() {}
Child.prototype = new Parent(); // 核心

const child = new Child();
console.log(child.say()); // Parent
  • 重点:将子类的原型对象替换为父类的实例。将子类的原型(Child.prototype)指向父类的一个实例。
  • 特点:通过原型链实现继承。 实例可继承的属性有:实例的构造函数的属性,父类构造函数属性,父类原型的属性。
  • 缺点: 1. 新实例无法向父类构造函数传参。 2. 所有新实例都会共享父类实例的属性。(原型上的属性是共享的,一个实例修改了原型属性,另一个实例的原型属性也会被修改!)
function Parent() {
  this.colors = ['red', 'blue'];
}
const child1 = new Child();
child1.colors.push('green');

const child2 = new Child();
console.log(child2.colors); // ['red', 'blue', 'green'] (共享问题)

2. 构造函数继承(解决属性独立)

function Child() {
  Parent.call(this); // 核心
}

const child1 = new Child();
child1.colors.push('green');

const child2 = new Child();
console.log(child2.colors); // ['red', 'blue'] (独立实例)
  • 重点:用.call()或.apply()将父类构造函数引入子类函数(在子类函数中做了父类函数的自执行(复制))

  • 特点: 1. 可以继承多个构造函数属性(call多个)。 2. 在子实例中可向父实例传参。 3. 每个实例都有独立的父类属性副本

  • 缺点: 1. 无法继承父类原型方法。 2. 无法实现构造函数的复用。(每次用每次都要重新调用) 3. 每个新实例都有父类构造函数的副本,臃肿。

  • 说明:

call 和 apply 功能相同,区别在于传参方式

Parent.call(this, name, age);     // 参数逐个传递
Parent.apply(this, [name, age]);  // 参数通过数组传递

为什么要用 call 而不是直接 new Parent()

直接 new Parent() 会创建一个新的父类实例,而不是在子类实例的上下文中初始化属性。使用 call 确保属性直接添加到子类实例。

Parent.call(this)的作用 或者 有传参时 Parent.call(this,name)的作用

  1. 调用父类构造函数 在子类构造函数中显式调用父类构造函数。通过这种方式,父类构造函数中定义的实例属性(如 this.name)会被添加到当前正在创建的子类实例上

  2. 继承实例属性 通过这种方式,子类实例能够 独立继承父类的实例属性 ,确保每个子类实例都有自己独立的属性副本,避免了原型链继承中引用类型属性共享的问题。

  3. 传递参数初始化 name 参数被传递给父类构造函数,使得父类可以根据传入的值初始化实例属性。例如,若父类构造函数定义为:

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

子类通过 Parent.call(this, name); 后,子类实例将拥有 name 和 colors 属性。

Parent.call(this, name);
// 等效于在子类实例上下文中执行:
// this.name = name; (父类构造函数中的代码)
// this.colors = ['red', 'blue'];

3. 组合继承(经典方案)

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

Parent.prototype.age = 10;
Parent.prototype.home = '地址'

function Child(name, age) {
  Parent.call(this, name);  // 第二次调用父构造函数(实例属性)
  this.age = age;
}

Child.prototype = new Parent();  // 第一次调用父构造函数(原型链)

const c1=new Child('jack',18);
console.log(c1.name)//jack 继承了构造函数的属性
console.log(c1.home)//地址 继承了父类原型的属性
console.log(c1.age)//18 自己实例有的属性不会再通过原型链去查找

  • 优点:
    1. 实例属性独立
    2. 可继承原型方法
  • 缺点:
    1. 父构造函数被调用两次
    2. 子类原型包含多余父类实例属性
    3. 父类原型上的引用类型属性会被所有子类实例共享。
  • 说明:

两次调用父构造函数的问题

function Child() {
  Parent.call(this);       // 第二次调用(为实例添加属性)
}
Child.prototype = new Parent(); // 第一次调用(为原型添加属性)

第一次调用:创建父类实例作为子类原型,导致子类原型上存在冗余的父类实例属性。

第二次调用:在子类构造函数中初始化实例属性,覆盖原型上的同名属性。

实例属性上不存在引用数据共享,实例属性独立

在这里插入图片描述

  1. 属性查找优先级规则: JavaScript 在访问属性时,会优先查找实例自身的属性,若找不到才沿原型链向上查找。因此,即使原型链上有同名属性,实例自身的属性会覆盖原型属性。

  2. 构造函数继承的隔离作用: Parent.call(this) 会在每个子类实例上创建独立的实例属性,这些属性直接存储在实例自身,与原型链无关。

  3. 冗余原型属性的无害性: 虽然 Child.prototype 上存在 colors 属性,但它永远不会被访问到(因为实例自身已有该属性),因此不会导致共享问题。

原型链上的引用类型共享 若属性定义在父类原型上,所有子类实例必然共享该属性,与继承方式无关。

在这里插入图片描述

4.原型式继承(对象浅拷贝)

直接基于现有对象创建新对象,通过原型链共享属性和方法。核心是使用 Object.create() 或类似方法,将新对象的原型指向现有对象。

const parent = { name: 'Parent' };
const child = Object.create(parent); // 核心
console.log(child.name); // Parent

等同于

const parent = { name: 'Parent' };
function create(obj) {
  function F() {}
  F.prototype = obj;//继承了传入的参数
  return new F();//返回函数对象
}
onst child = create(parent)
console.log(child.name); // Parent
  • 重点:用一个函数包装一个对象,然后返回这个函数的调用,这个函数就变成了个可以随意增添属性的实例或对象。

  • 特点:类似于复制一个对象,用函数来包装。

  • 缺点:

    1.引用类型共享:所有实例共享原型对象的引用属性(如数组、对象),导致意外修改。 (浅拷贝:复制的是引用地址

const parent = { data: [1, 2] };
const child1 = Object.create(parent);
const child2 = Object.create(parent);
child1.data.push(3);
console.log(child2.data); // [1, 2, 3]
  1. 无法实现复用。(新实例属性都是后面添加的)
  2. 无法传参:无法在创建对象时初始化属性。

浅拷贝会创建一个新对象,但仅复制原对象的第一层内容。如果原对象的元素是引用类型(如列表、字典、对象等),则拷贝的是这些元素的引用地址,而非创建新的副本。

5.寄生式继承(工厂增强)

在原型式继承的基础上,通过工厂函数增强对象,添加私有方法或属性。

function createEnhancedObject(parent) {
  const clone = Object.create(parent); // 基于原型创建对象
  clone.sayHi = function() {           // 增强对象
    console.log('Hi!');
  };
  return clone;
}

const parent = { name: 'Parent' };
const child = createEnhancedObject(parent);
child.sayHi(); // 'Hi!'
  • 重点:就是给原型式继承外面套了个壳子。
  • 优点:
  1. 功能增强:可为对象添加实例特有的方法或属性。
  2. 灵活性高:适合需要定制化扩展的场景。
  • 缺点:
  1. 方法无法复用:每次创建实例都会生成新方法,内存浪费。
const child1 = createEnhancedObject(parent);
const child2 = createEnhancedObject(parent);
console.log(child1.sayHi === child2.sayHi); // false(不同方法)
  1. 引用类型共享:原型链上的引用属性仍被共享。

6.寄生组合式继承(完美方案)

结合构造函数继承(属性独立)寄生式继承(工厂增强)避免组合继承中父类构造函数被调用两次的问题。通过 Object.create() 继承父类原型,修正子类构造函数指向。

function inheritPrototype(Child, Parent) {
  const prototype = Object.create(Parent.prototype); // 创建父类原型副本
  prototype.constructor = Child;                    // 修正构造函数指向
  Child.prototype = prototype;                     // 设置子类原型(继承父类原型属性)
}

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

Parent.prototype.sayName = function() {
  console.log(this.name);
};

function Child(name, age) {
  Parent.call(this, name); // 继承父类实例属性(第一次调用父构造函数)
  this.age = age;
}

inheritPrototype(Child, Parent); // 继承原型方法(无第二次调用)

Child.prototype.sayAge = function() {
  console.log(this.age);
};

// 测试
const child1 = new Child('Alice', 25);
child1.colors.push('green');
console.log(child1.colors); // ['red', 'blue', 'green']

const child2 = new Child('Bob', 30);
console.log(child2.colors); // ['red', 'blue'](属性独立)
  • 优点:
  1. 高效性:父类构造函数仅调用一次,避免冗余属性。
  2. 原型链纯净:子类原型不包含父类实例属性。
  3. 完全继承:实例属性独立,原型方法复用,支持 instanceof 检查。
  • 缺点:
  1. 实现稍复杂:需手动处理原型链和构造函数指向。
  • 扩展

原型链上的引用类型共享问题解决方案

在这里插入图片描述 关键:colors 定义在父类原型上,在继承父类原型时,浅拷贝也只复制引用地址,所有子类实例通过原型链访问 Parent.prototype.colors,因此共享同一个数组。

要彻底解决引用类型共享问题,需根据场景选择以下方案:

方案 1:将引用类型定义为实例属性 修改父类构造函数,让引用类型属性属于实例而非原型:

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue']; // 实例属性
}

Parent.prototype.sayName = function() { /* 方法 */ };

// 寄生组合式继承
function Child(name, age) {
  Parent.call(this, name); // 复制实例属性(包括 colors)
  this.age = age;
}
inheritPrototype(Child, Parent);

// 测试
const child1 = new Child('A', 10);
child1.colors.push('green');
console.log(child1.colors); // ['red', 'blue', 'green']

const child2 = new Child('B', 20);
console.log(child2.colors); // ['red', 'blue'](独立)

方案 2:在子类中覆盖引用属性 修改子类构造函数,在子类构造函数中初始化成自己的引用属性:

function Parent(name) {
  this.name = name;
}

Parent.prototype.colors = ['red', 'blue']; // 父类原型属性

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
  this.colors = [...Parent.prototype.colors]; // 覆盖为独立数组
}

inheritPrototype(Child, Parent);

// 测试
const child1 = new Child('A', 10);
child1.colors.push('green');
console.log(child1.colors); // ['red', 'blue', 'green']

const child2 = new Child('B', 20);
console.log(child2.colors); // ['red', 'blue'](独立)

在这里插入图片描述 总结

  • 寄生组合式继承的作用: 解决组合继承中父类构造函数被调用两次的问题,保持原型链纯净,但 无法自动处理父类原型上的引用类型共享。
  • 引用类型共享的本质: 若属性定义在父类原型上,所有子类实例必然共享该属性,与继承方式无关。

  • 最终建议:

    • 若需要独立引用属性,应将其定义为 实例属性。
    • 若必须使用原型属性,则需要在子类中 显式覆盖。

三、ES6 Class 继承(语法糖)

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() // 成功访问到父类的方法

1. constructor():类的构造函数

作用

  • 初始化实例属性:在创建类的实例时自动调用,用于初始化对象的属性。
  • 必须存在性:即使不显式定义,JavaScript 引擎也会默认生成一个空的 constructor()。
  • 唯一性:一个类只能有一个 constructor()。
class Person {
  constructor(name, age) {
    this.name = name; // 实例属性
    this.age = age;
  }
}

const alice = new Person("Alice", 25);
console.log(alice.name); // "Alice"

2. super():调用父类构造函数或方法

作用

  • 在子类中调用父类构造函数:必须在子类的 constructor() 中 优先调用 super(),才能使用 this。
  • 调用父类方法:在子类方法中,可以通过 super.parentMethod() 调用父类的方法。
class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // 调用父类构造函数( 必须优先调用!)
    this.breed = breed;
  }

  speak() {
    super.speak(); // 调用父类方法
    console.log(`${this.name} barks!`);
  }
}

const dog = new Dog("Buddy", "Golden Retriever");
dog.speak();
// 输出:
// "Buddy makes a noise."
// "Buddy barks!"

3. 为什么必须调用 super()?

  • 本质原因:ES6 的类继承要求子类必须先构建父类的实例环境,才能初始化自身的属性。
  • 底层机制:super() 等同于在 ES5 中调用 Parent.call(this),但 ES6 强制要求语法显式化。