手把手教会你 javascript 的继承原理

66 阅读13分钟

JavaScript继承:从原型到Class的转变

引言

作为前端开发者,面向对象编程(OOP)是我们必须掌握的核心概念之一,而继承则是OOP中实现代码复用和扩展的重要机制。与传统的基于类(Class-based)的编程语言(如Java、C++)不同,JavaScript采用了一种独特的基于原型(Prototype-based)的继承方式。

这种差异导致很多开发者在学习JavaScript继承时感到困惑:为什么JavaScript没有class关键字(ES6之前)?prototype__proto__有什么区别?如何实现真正的继承?

接下来,还是面试官开始提问:请说一下javascrpt 如何实现继承?有哪些方式?都有哪些优缺点?

一、继承的基本概念

1. 什么是继承?

继承是面向对象编程中的一种机制,它允许一个对象(子类)继承另一个对象(父类)的属性(数据)方法(函数),从而实现代码复用扩展

继承的主要优势:

  • 代码复用:避免重复编写相同的代码
  • 扩展性:可以在不修改原有代码的基础上添加新功能
  • 维护性:集中管理共享的属性和方法,便于维护

虽然大多数业务场景不需要你专门去实现一个继承,常见封装通用功能,可还得用(写一个父类),绕不过去;

2. JavaScript继承的特殊性

JavaScript是一种基于原型的编程语言,而不是传统的基于类的编程语言。这意味着:

  • 在JavaScript中,对象直接从其他对象继承,而不是通过类定义
  • 每个对象都有一个原型对象,可以从中继承属性和方法
  • 原型对象也可以有自己的原型,形成原型链

3. 核心概念回顾

在深入学习继承之前,我们需要先回顾几个与JavaScript继承密切相关的核心概念:

可以参考我的上篇文章 juejin.cn/post/757883…

3.1 原型(Prototype)

每个JavaScript对象都有一个原型对象,对象可以从原型中继承属性和方法。

3.2 __proto__(隐式原型)

每个对象都有一个__proto__属性,指向它的原型对象。这是一个非标准属性,推荐使用Object.getPrototypeOf()Object.setPrototypeOf()代替。

3.3 prototype(显式原型)

只有函数才有prototype属性,当函数作为构造函数使用时,新创建的对象会将这个prototype作为自己的__proto__

3.4 原型链(Prototype Chain)

对象通过__proto__形成的链式结构,用于属性和方法的查找。

二、JavaScript中的继承方式

JavaScript中有多种实现继承的方式,每种方式都有其优缺点:

1. 原型链继承

原型链继承是JavaScript中最基本的继承方式,它通过将子类的原型设置为父类的实例来实现继承。

实现原理
  1. 创建一个父类构造函数
  2. 创建一个子类构造函数
  3. 将子类的原型设置为父类的实例
  4. 修复子类原型的constructor指向
代码示例
// 父类:Animal
function Animal(type) {
  this.type = type;
  this.eating = true;
}

// 在父类原型上添加方法
Animal.prototype.eat = function() {
  console.log('进食中...');
};

// 子类:Dog
function Dog(name, breed) {
  this.name = name;
  this.breed = breed;
}

// 实现原型链继承:将Dog的原型设置为Animal的实例
Dog.prototype = new Animal('dog');

// 修复constructor指向
Dog.prototype.constructor = Dog;

// 在子类原型上添加方法
Dog.prototype.bark = function() {
  console.log('汪汪汪!');
};

// 创建实例
const myDog = new Dog('Buddy', 'Golden Retriever');

// 访问自身属性
console.log(myDog.name); // 输出:Buddy
console.log(myDog.breed); // 输出:Golden Retriever

// 访问继承的属性
console.log(myDog.type); // 输出:dog
console.log(myDog.eating); // 输出:true

// 调用继承的方法
myDog.eat(); // 输出:进食中...

// 调用子类方法
myDog.bark(); // 输出:汪汪汪!
原型链可视化
myDog (Dog实例)
  └── __proto__ → Dog.prototype (Animal实例)
                    ├── type: "dog"
                    ├── eating: true
                    ├── __proto__ → Animal.prototype
                                    ├── eat()方法
                                    └── __proto__ → Object.prototype
                                                      └── __proto__ → null
优缺点

优点

  • 实现简单,易于理解
  • 可以继承父类的属性和方法

缺点

  • 父类的引用类型属性会被所有子类实例共享
  • 创建子类实例时,无法向父类构造函数传递参数

2. 构造函数继承

构造函数继承通过在子类构造函数中调用父类构造函数来实现继承,主要解决了原型链继承中引用类型属性共享的问题。

实现原理
  1. 创建一个父类构造函数
  2. 创建一个子类构造函数
  3. 在子类构造函数中使用call()apply()方法调用父类构造函数
代码示例
// 父类:Animal
function Animal(type) {
  this.type = type;
  this.skills = ['run', 'jump'];
}

// 子类:Dog
function Dog(name, breed, type) {
  // 使用call()调用父类构造函数,实现属性继承
  Animal.call(this, type);
  this.name = name;
  this.breed = breed;
}

// 创建实例
const dog1 = new Dog('Buddy', 'Golden Retriever', 'dog');
const dog2 = new Dog('Max', 'German Shepherd', 'dog');

// 修改dog1的skills数组
dog1.skills.push('swim');

console.log(dog1.skills); // 输出:["run", "jump", "swim"]
console.log(dog2.skills); // 输出:["run", "jump"](未受影响)
优缺点

优点

  • 解决了原型链继承中引用类型属性共享的问题
  • 创建子类实例时,可以向父类构造函数传递参数

缺点

  • 无法继承父类原型上的方法
  • 每个子类实例都会创建父类方法的副本,造成内存浪费

3. 组合继承

组合继承(也称为伪经典继承)结合了原型链继承和构造函数继承的优点,是JavaScript中最常用的继承模式。

实现原理
  1. 使用构造函数继承继承父类的属性
  2. 使用原型链继承继承父类的方法
代码示例
// 父类:Animal
function Animal(type) {
  this.type = type;
  this.skills = ['run', 'jump'];
}

// 在父类原型上添加方法
Animal.prototype.eat = function() {
  console.log('进食中...');
};

// 子类:Dog
function Dog(name, breed) {
  // 使用构造函数继承继承属性
  Animal.call(this, 'dog');
  this.name = name;
  this.breed = breed;
}

// 使用原型链继承继承方法
Dog.prototype = new Animal();

// 修复constructor指向
Dog.prototype.constructor = Dog;

// 在子类原型上添加方法
Dog.prototype.bark = function() {
  console.log('汪汪汪!');
};

// 创建实例
const myDog = new Dog('Buddy', 'Golden Retriever');

// 访问属性
console.log(myDog.name); // 输出:Buddy
console.log(myDog.type); // 输出:dog

// 调用方法
myDog.eat(); // 输出:进食中...
myDog.bark(); // 输出:汪汪汪!

// 验证引用类型属性不共享
const dog2 = new Dog('Max', 'German Shepherd');
myDog.skills.push('swim');
console.log(myDog.skills); // 输出:["run", "jump", "swim"]
console.log(dog2.skills); // 输出:["run", "jump"]
优缺点

优点

  • 既可以继承父类的属性,又可以继承父类原型上的方法
  • 解决了引用类型属性共享的问题
  • 可以向父类构造函数传递参数

缺点

  • 父类构造函数会被调用两次(一次在创建子类原型时,一次在子类构造函数中)
  • 子类原型上会存在父类构造函数创建的不必要的属性

4. 原型式继承

原型式继承是由道格拉斯·克罗克福德(Douglas Crockford)提出的一种继承方式,它基于Object.create()方法实现,主要用于创建一个对象的浅拷贝。

实现原理
  1. 创建一个临时构造函数
  2. 将该构造函数的原型设置为要继承的对象
  3. 返回临时构造函数的实例
代码示例
// 要继承的对象
const animal = {
  type: 'animal',
  skills: ['run', 'jump'],
  eat: function() {
    console.log('进食中...');
  }
};

// 原型式继承函数
function createObject(proto) {
  function F() {} // 临时构造函数
  F.prototype = proto; // 设置原型
  return new F(); // 返回实例
}

// 使用原型式继承创建新对象
const dog = createObject(animal);
dog.name = 'Buddy';
dog.breed = 'Golden Retriever';

// 使用ES6的Object.create()方法(更简洁)
const cat = Object.create(animal);
cat.name = 'Kitty';
cat.breed = 'Persian';

console.log(dog.name); // 输出:Buddy
console.log(dog.type); // 输出:animal(继承自animal)
dog.eat(); // 输出:进食中...(继承自animal)

// 注意:引用类型属性仍然共享
dog.skills.push('swim');
console.log(dog.skills); // 输出:["run", "jump", "swim"]
console.log(cat.skills); // 输出:["run", "jump", "swim"](也被修改了)
优缺点

优点

  • 实现简单,适合创建对象的浅拷贝
  • 不需要创建构造函数

缺点

  • 引用类型属性会被所有实例共享
  • 无法传递参数

5. 寄生式继承

寄生式继承是在原型式继承的基础上,增强对象的一种继承方式。它通过创建一个仅用于封装继承过程的函数,在内部增强对象,然后返回该对象。

实现原理
  1. 使用原型式继承创建一个新对象
  2. 增强新对象(添加属性和方法)
  3. 返回增强后的对象
代码示例
// 原型式继承函数
function createObject(proto) {
  function F() {};
  F.prototype = proto;
  return new F();
}

// 寄生式继承函数
function createDog(proto, name, breed) {
  // 使用原型式继承创建对象
  const dog = createObject(proto);
  
  // 增强对象
  dog.name = name;
  dog.breed = breed;
  dog.bark = function() {
    console.log('汪汪汪!');
  };
  
  return dog;
}

// 要继承的对象
const animal = {
  type: 'animal',
  eat: function() {
    console.log('进食中...');
  }
};

// 使用寄生式继承创建dog对象
const myDog = createDog(animal, 'Buddy', 'Golden Retriever');

console.log(myDog.name); // 输出:Buddy
console.log(myDog.type); // 输出:animal(继承自animal)
myDog.eat(); // 输出:进食中...(继承自animal)
myDog.bark(); // 输出:汪汪汪!(增强的方法)
优缺点

优点

  • 可以增强对象,添加新的属性和方法
  • 实现简单,不需要创建构造函数

缺点

  • 引用类型属性会被所有实例共享
  • 无法传递参数给父类构造函数
  • 增强的方法会在每个实例上创建副本,造成内存浪费

6. 寄生组合式继承

寄生组合式继承是组合继承的优化版本,它通过寄生式继承来继承父类的原型,解决了组合继承中父类构造函数被调用两次的问题,是目前最理想的继承方式之一。

实现原理
  1. 使用构造函数继承继承父类的属性
  2. 使用寄生式继承继承父类的原型(而不是创建父类实例)
  3. 修复子类原型的constructor指向
代码示例
// 父类:Animal
function Animal(type) {
  this.type = type;
  this.skills = ['run', 'jump'];
}

// 在父类原型上添加方法
Animal.prototype.eat = function() {
  console.log('进食中...');
};

// 子类:Dog
function Dog(name, breed) {
  // 使用构造函数继承继承属性
  Animal.call(this, 'dog');
  this.name = name;
  this.breed = breed;
}

// 寄生组合式继承函数
function inheritPrototype(subType, superType) {
  // 创建父类原型的副本
  const prototype = Object.create(superType.prototype);
  // 修复constructor指向
  prototype.constructor = subType;
  // 设置子类原型
  subType.prototype = prototype;
}

// 实现继承
inheritPrototype(Dog, Animal);

// 在子类原型上添加方法
Dog.prototype.bark = function() {
  console.log('汪汪汪!');
};

// 创建实例
const myDog = new Dog('Buddy', 'Golden Retriever');

console.log(myDog.name); // 输出:Buddy
console.log(myDog.type); // 输出:dog
myDog.eat(); // 输出:进食中...
myDog.bark(); // 输出:汪汪汪!
优缺点

优点

  • 只调用一次父类构造函数
  • 避免了在子类原型上创建不必要的属性
  • 保持了原型链的完整性
  • 是最理想的继承方式之一

缺点

  • 实现相对复杂

7. ES6 Class继承

ES6引入了class关键字,提供了更接近传统类语言的语法糖,但底层仍然基于原型链实现。

实现原理
  1. 使用class关键字定义父类
  2. 使用class关键字定义子类,并使用extends关键字继承父类
  3. 在子类构造函数中使用super()调用父类构造函数
代码示例
// 父类:Animal
class Animal {
  constructor(type) {
    this.type = type;
    this.skills = ['run', 'jump'];
  }
  
  // 实例方法
  eat() {
    console.log('进食中...');
  }
  
  // 静态方法
  static create(type) {
    return new Animal(type);
  }
}

// 子类:Dog
class Dog extends Animal {
  constructor(name, breed) {
    // 必须先调用super()
    super('dog');
    this.name = name;
    this.breed = breed;
  }
  
  // 子类方法
  bark() {
    console.log('汪汪汪!');
  }
  
  // 重写父类方法
  eat() {
    console.log('狗进食中...');
  }
}

// 创建实例
const myDog = new Dog('Buddy', 'Golden Retriever');

console.log(myDog.name); // 输出:Buddy
console.log(myDog.type); // 输出:dog
myDog.eat(); // 输出:狗进食中...(重写后的方法)
myDog.bark(); // 输出:汪汪汪!

// 访问静态方法
const cat = Animal.create('cat');
console.log(cat.type); // 输出:cat
优缺点

优点

  • 语法简洁清晰,更接近传统类语言
  • 自动处理原型链和constructor指向
  • 支持super关键字调用父类方法
  • 支持静态方法继承

缺点

  • 底层仍然基于原型链,理解原型仍然很重要
  • ES6之前的环境需要转译

三、各种继承方式的对比

继承方式优点缺点
原型链继承实现简单引用类型属性共享,无法传递参数
构造函数继承避免引用类型共享,可传递参数无法继承原型方法,方法重复创建
组合继承继承属性和方法,避免引用类型共享父类构造函数调用两次
原型式继承实现简单,适合浅拷贝引用类型属性共享
寄生式继承增强对象,实现简单引用类型共享,方法重复创建
寄生组合式继承最优继承方式,只调用一次父类构造函数实现复杂
ES6 Class继承语法简洁,支持super关键字底层仍是原型链

四、继承的最佳实践

1. 优先使用ES6 Class继承

ES6的class语法提供了更清晰、更易读的继承实现方式,建议优先使用。

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

class Child extends Parent {
  constructor(name, age) {
    super(name);
    this.age = age;
  }
  
  sayAge() {
    console.log(this.age);
  }
}

2. 避免使用原型链继承和构造函数继承

这两种继承方式都有明显的缺点,建议使用组合继承或寄生组合式继承(ES5环境下)。

3. 理解原型链的工作原理

即使使用ES6的class语法,理解原型链的工作原理仍然很重要,这有助于你调试和优化代码。

4. 避免修改内置对象的原型

修改内置对象(如ArrayObject)的原型可能会导致命名冲突和意外行为。

// 不推荐:修改内置对象原型
Array.prototype.sum = function() {
  return this.reduce((a, b) => a + b, 0);
};

// 推荐:创建工具函数
function sumArray(arr) {
  return arr.reduce((a, b) => a + b, 0);
}

5. 使用组合而非继承

在某些情况下,使用组合(对象组合)可能比继承更灵活。组合是指一个对象包含另一个对象,而不是继承它。

// 组合方式
const animal = {
  eat: function() {
    console.log('进食中...');
  }
};

const dog = {
  ...animal, // 组合animal的功能
  name: 'Buddy',
  bark: function() {
    console.log('汪汪汪!');
  }
};

五、总结

通过本文的学习,我们已经全面了解了JavaScript中各种继承方式的原理和实现:

  1. 原型链继承:通过设置子类原型为父类实例实现
  2. 构造函数继承:通过在子类构造函数中调用父类构造函数实现
  3. 组合继承:结合原型链继承和构造函数继承
  4. 原型式继承:基于Object.create()创建对象的浅拷贝
  5. 寄生式继承:在原型式继承基础上增强对象
  6. 寄生组合式继承:组合继承的优化版本,只调用一次父类构造函数
  7. ES6 Class继承:提供更简洁的语法糖,底层仍基于原型链

每种继承方式都有其优缺点,在实际开发中,我们应根据具体需求选择合适的继承方式。ES6的class语法是目前推荐的方式,它提供了更清晰、更易读的代码结构。

记住,理解JavaScript的原型机制是掌握继承的关键,即使使用ES6的class语法,原型链仍然在底层发挥着重要作用。

思考与练习

  1. 为什么JavaScript采用基于原型的继承而不是基于类的继承?
  2. 组合继承和寄生组合式继承的主要区别是什么?
  3. ES6的class继承和ES5的继承有什么不同?
  4. 尝试实现一个完整的寄生组合式继承案例
  5. 什么时候应该使用组合而不是继承?

参考资料


如果你觉得本文对你有帮助,欢迎点赞、收藏、分享,也欢迎关注我,获取更多前端技术干货!