继承
/ 10 min read
核心概念:原型链 (Prototype Chain)
在理解继承之前,必须先理解原型链。
- 每个对象都有一个原型:每个 JavaScript 对象(除了少数例外)都有一个内部私有属性
[[Prototype]],你可以通过__proto__(非标准但常用) 或Object.getPrototypeOf()来访问它。这个属性指向另一个对象,我们称之为该对象的“原型”。 - 原型也是对象:这个原型对象也有自己的原型,依此类推,直到一个对象的原型为
null。 - 形成链条:由
[[Prototype]]串联起来的这一系列对象,就构成了“原型链”。 - 属性查找机制:当你试图访问一个对象的属性时,JavaScript 引擎会:
- 首先在该对象自身上查找。
- 如果找不到,就沿着原型链向上,到它的原型对象上查找。
- 如果还找不到,就继续向上,直到原型链的顶端 (
null)。如果最终还是找不到,则返回undefined。
继承的本质就是利用原型链,让一个对象可以访问到另一个对象的属性和方法。
1. 原型链继承 (最基本的继承方式)
这是最直接、最核心的继承方式。
思路:让子构造函数的原型对象 (Child.prototype) 等于父构造函数的一个实例。
// 父构造函数function Animal() { this.species = '动物';}
Animal.prototype.eat = function() { console.log('正在吃东西');};
// 子构造函数function Cat() { this.name = '猫';}
// 核心:实现继承// 创建一个 Animal 的实例,并将其赋值给 Cat 的原型Cat.prototype = new Animal();
// 实例化子对象const myCat = new Cat();
console.log(myCat.species); // 输出: '动物' (从原型链上继承的)myCat.eat(); // 输出: '正在吃东西' (从原型链上继承的)console.log(myCat.name); // 输出: '猫' (自身属性)优点:
- 简单,易于理解,完美诠释了原型链的工作原理。
缺点:
-
引用类型属性共享问题:如果父构造函数的属性包含引用类型(如数组或对象),那么所有子实例都会共享这个引用。一个子实例修改了这个属性,会影响到所有其他实例。
function Animal() {this.features = ['毛', '四条腿'];}Cat.prototype = new Animal();const cat1 = new Cat();const cat2 = new Cat();cat1.features.push('会叫');console.log(cat2.features); // 输出: ['毛', '四条腿', '会叫'] -> cat2 被污染了! -
无法向父构造函数传递参数:在创建子实例时,无法向父构造函数传递参数,因为父构造函数是在
Cat.prototype = new Animal()时就被调用了。
2. 构造函数继承 (或称“借用构造函数”)
为了解决原型链继承的缺点,这种方式应运而生。
思路:在子构造函数内部,使用 .call() 或 .apply() 方法调用父构造函数,并将 this 指向子实例。
function Animal(name) { this.name = name; this.features = ['毛', '四条腿'];}
function Cat(name) { // 核心:借用父构造函数 Animal.call(this, name); // `this` 指向 new Cat() 创建的实例}
const cat1 = new Cat('咪咪');const cat2 = new Cat('小白');
cat1.features.push('会卖萌');
console.log(cat1.name); // '咪咪'console.log(cat1.features); // ['毛', '四条腿', '会卖萌']
console.log(cat2.name); // '小白'console.log(cat2.features); // ['毛', '四条腿'] (未被污染)优点:
- 完美解决了引用类型属性共享的问题。
- 可以在创建子实例时向父构造函数传递参数。
缺点:
-
无法继承父原型上的方法:父构造函数原型(
Animal.prototype)上定义的方法对子实例是不可见的。Animal.prototype.eat = function() { console.log('正在吃'); };const myCat = new Cat('Tom');// myCat.eat(); // TypeError: myCat.eat is not a function
3. 组合继承 (最经典的继承方式)
这是将前面两种方法组合起来的产物,集两者之长,也是 JavaScript 中最常用的继承模式之一。
思路:
- 使用构造函数继承来继承父类的实例属性(并解决引用共享问题)。
- 使用原型链继承来继承父类的原型方法。
function Animal(name) { this.name = name; this.features = ['毛', '四条腿'];}
Animal.prototype.eat = function() { console.log(this.name + ' 正在吃东西');};
function Cat(name, color) { // 1. 借用构造函数继承属性 Animal.call(this, name); this.color = color;}
// 2. 原型链继承方法Cat.prototype = new Animal();// 修正 constructor 指向(可选但推荐)Cat.prototype.constructor = Cat;
// 在子原型上添加自己的方法Cat.prototype.meow = function() { console.log('喵喵~');};
const myCat = new Cat('Tom', '灰色');myCat.eat(); // 'Tom 正在吃东西'myCat.meow(); // '喵喵~'console.log(myCat.features); // ['毛', '四条腿']
const anotherCat = new Cat('Jerry', '白色');myCat.features.push('抓老鼠');console.log(anotherCat.features); // ['毛', '四条腿'] (互不影响)优点:
- 结合了前两种方法的优点,既能继承方法,又能传递参数,还能解决引用共享问题。
缺点:
- 调用了两次父构造函数:
- 一次是在
Cat.prototype = new Animal()时。 - 另一次是在
Animal.call(this, name)时。 这导致子类的原型上有一份多余的、无用的父类实例属性。
- 一次是在
4. 寄生组合式继承 (更优化的组合继承)
这是对组合继承的优化,解决了调用两次父构造函数的问题。被认为是 ES6 class 出现之前最理想的继承方案。
思路:不再通过调用父构造函数来设置子原型,而是创建一个干净的、只链接到父原型的对象。
function inheritPrototype(subType, superType) { // 1. 创建一个继承自父原型的新对象 const prototype = Object.create(superType.prototype); // 2. 修正新对象的 constructor 指向 prototype.constructor = subType; // 3. 将这个新对象赋值给子原型 subType.prototype = prototype;}
// ------ 父子构造函数定义同上 ------function Animal(name) { this.name = name;}Animal.prototype.eat = function() { console.log(this.name + ' 正在吃东西');};function Cat(name, color) { Animal.call(this, name); this.color = color;}
// 核心:用寄生组合式继承来连接原型inheritPrototype(Cat, Animal);
Cat.prototype.meow = function() { console.log('喵喵~');};
const myCat = new Cat('Tom', '灰色');myCat.eat(); // 'Tom 正在吃东西'myCat.meow(); // '喵喵~'优点:
- 完美!只调用一次父构造函数,避免了在子原型上创建不必要的属性,同时保持了原型链的完整。
5. ES6 Class 继承 (extends)
ES6 引入了 class 关键字,它本质上是上面寄生组合式继承的语法糖。它让继承的写法更像传统的面向对象语言,更清晰、更简洁。
// 父类class Animal { constructor(name) { this.name = name; this.features = ['毛', '四条腿']; }
eat() { console.log(`${this.name} 正在吃东西`); }}
// 子类使用 `extends` 关键字实现继承class Cat extends Animal { constructor(name, color) { // 核心:调用 super() 来执行父类的构造函数 // 必须在 `this` 之前调用 super(name);
this.color = color; }
meow() { console.log('喵喵~'); }}
const myCat = new Cat('Tom', '灰色');myCat.eat(); // 'Tom 正在吃东西'myCat.meow(); // '喵喵~'
const anotherCat = new Cat('Jerry', '白色');myCat.features.push('抓老鼠');console.log(anotherCat.features); // ['毛', '四条腿'] (互不影响)super 关键字:
- 在
constructor中,super()作为函数调用,代表父类的构造函数。 - 在普通方法中,
super作为对象使用,指向父类的原型(super.eat()就是调用Animal.prototype.eat())。
优点:
- 语法清晰、简洁,符合传统面向对象开发者的习惯。
- 内置实现了最理想的寄生组合式继承。
总结
| 继承方式 | 解决了什么问题 | 引入了什么新问题 |
|---|---|---|
| 原型链继承 | 实现了基本的继承 | 引用类型共享、无法传参 |
| 构造函数继承 | 解决了引用共享和传参问题 | 无法继承原型方法 |
| 组合继承 | 解决了以上所有问题 | 调用了两次父构造函数,有性能浪费 |
| 寄生组合式继承 | 解决了调用两次的问题 | 写法复杂,需要封装辅助函数 |
ES6 class 继承 | 完美方案 | 无明显缺点,是现代 JS 的标准实践 |
对于现代 JavaScript 开发,直接使用 ES6 的 class 和 extends 是最推荐、最简单、最可靠的继承方式。但理解前面几种经典的原型继承模式,对于深入掌握 JavaScript 的底层原理至关重要。