创见博客
JavaScript中的继承是如何实现的
七崽爱吃小饼干2026/01/28阅读 0专栏 JavaScript

一、核心基础:原型链继承(最底层)

原理

JS 中每个对象都有一个 __proto__ 属性(隐式原型),每个函数(构造函数)都有一个 prototype 属性(显式原型)。

  • 当访问一个对象的属性/方法时,会先在自身查找,找不到就通过 __proto__ 向上查找父对象的 prototype,直到 null,这条查找链就是「原型链」。
  • 原型链继承的核心:让子类构造函数的原型,指向父类的实例,这样子类实例就能通过原型链访问父类的属性和方法。

代码实现

javascript
// 1. 定义父类(构造函数)
function Parent(name) {
  // 父类实例属性
  this.name = name || "默认父类名称";
  // 父类实例方法(注意:这里定义的方法,每个子类实例都会复制一份,不推荐)
  this.sayHi = function() {
    console.log(`Hi, 我是${this.name}`);
  };
}

// 2. 父类原型上的方法(所有实例共享,推荐定义在这里)
Parent.prototype.eat = function() {
  console.log(`${this.name} 在吃饭`);
};

// 3. 定义子类(构造函数)
function Child(age) {
  this.age = age;
}

// 4. 核心:让子类原型指向父类实例,建立原型链
Child.prototype = new Parent();
// 修复子类构造函数指向(否则 Child.prototype.constructor 会指向 Parent)
Child.prototype.constructor = Child;

// 5. 子类原型上扩展自己的方法
Child.prototype.run = function() {
  console.log(`${this.name} 今年${this.age}岁,在跑步`);
};

// 测试使用
const child1 = new Child(10);
child1.name = "小明"; // 给子类实例赋值父类属性
child1.sayHi(); // 输出:Hi, 我是小明(访问父类实例方法)
child1.eat(); // 输出:小明 在吃饭(访问父类原型方法)
child1.run(); // 输出:小明 今年10岁,在跑步(访问子类原型方法)

缺点

  1. 父类的实例属性会变成子类的「原型属性」,被所有子类实例共享,修改一个实例的该属性会影响其他实例。
  2. 无法在子类构造函数中向父类构造函数传参(只能在赋值原型时统一传参,灵活性差)。

二、改进:借用构造函数继承(解决实例属性共享问题)

原理

在子类构造函数中,通过 call() 或 apply() 调用父类构造函数,强制将父类的实例属性绑定到子类实例上,避免共享。

代码实现

javascript
// 1. 定义父类
function Parent(name) {
  this.name = name; // 实例属性,不共享
  this.hobbies = ["看书", "听歌"]; // 引用类型属性,避免共享
}

Parent.prototype.eat = function() {
  console.log(`${this.name} 在吃饭`);
};

// 2. 定义子类,借用父类构造函数
function Child(name, age) {
  // 核心:调用父类构造函数,绑定子类实例的 this,还能传参
  Parent.call(this, name); 
  this.age = age; // 子类自己的实例属性
}

// 测试使用
const child1 = new Child("小明", 10);
const child2 = new Child("小红", 12);

child1.hobbies.push("打球"); // 修改 child1 的引用类型属性
console.log(child1.hobbies); // 输出:["看书", "听歌", "打球"]
console.log(child2.hobbies); // 输出:["看书", "听歌"](互不影响)
child1.sayHi(); // 正常输出:Hi, 我是小明

// 注意:无法访问父类原型上的方法(原型链未建立)
child1.eat(); // 报错:child1.eat is not a function

优点

  1. 父类实例属性不共享,每个子类实例都有独立副本。
  2. 支持向父类构造函数传参,灵活性高。

缺点

  1. 无法访问父类原型上的方法(只能继承父类构造函数内的属性/方法)。
  2. 父类方法如果定义在构造函数内,每个子类实例都会复制一份,造成内存浪费(无法共享方法)。

三、最优解:组合继承(原型链 + 借用构造函数,最常用)

原理

结合「原型链继承」和「借用构造函数继承」的优点:

  1. 用原型链继承实现父类「原型方法」的共享。
  2. 用借用构造函数继承实现父类「实例属性」的独立和传参。

代码实现

javascript
// 1. 定义父类
function Parent(name) {
  this.name = name;
  this.hobbies = ["看书", "听歌"];
}

Parent.prototype.eat = function() {
  console.log(`${this.name} 在吃饭`);
};

// 2. 定义子类
function Child(name, age) {
  // 借用构造函数:继承实例属性,支持传参
  Parent.call(this, name); 
  this.age = age;
}

// 原型链:继承原型方法,实现方法共享
Child.prototype = new Parent();
Child.prototype.constructor = Child;

// 子类扩展自己的原型方法
Child.prototype.run = function() {
  console.log(`${this.name} 今年${this.age}岁,在跑步`);
};

// 测试使用
const child1 = new Child("小明", 10);
const child2 = new Child("小红", 12);

// 验证实例属性不共享
child1.hobbies.push("打球");
console.log(child1.hobbies); // ["看书", "听歌", "打球"]
console.log(child2.hobbies); // ["看书", "听歌"]

// 验证原型方法可访问且共享
child1.eat(); // 小明 在吃饭
child2.eat(); // 小红 在吃饭
child1.run(); // 小明 今年10岁,在跑步

优点

  1. 实例属性独立,原型方法共享,兼顾性能和灵活性。
  2. 支持向父类传参,能访问父类所有属性和方法。
  3. 是 ES6 之前最常用、最成熟的继承方案。

缺点(轻微)

父类构造函数会被调用两次:一次是赋值子类原型时,一次是子类构造函数内 call 时(造成少量冗余属性,不影响使用)。


四、ES6 语法糖:class + extends(推荐)

原理

ES6 引入了 class、extends、super 关键字,本质是对「组合继承」的封装(底层还是原型链),语法更简洁,更接近传统面向对象的类继承。

代码实现

javascript
// 1. 定义父类(class 本质是构造函数的语法糖)
class Parent {
  // 构造函数:对应 ES5 的父类构造函数
  constructor(name) {
    this.name = name;
    this.hobbies = ["看书", "听歌"];
  }

  // 原型方法:对应 ES5 的 Parent.prototype.eat
  eat() {
    console.log(`${this.name} 在吃饭`);
  }
}

// 2. 定义子类,使用 extends 继承父类
class Child extends Parent {
  // 子类构造函数
  constructor(name, age) {
    // 核心:super 必须在子类 this 之前调用,对应 ES5 的 Parent.call(this, name)
    super(name); // 调用父类构造函数,传参
    this.age = age; // 子类自己的实例属性
  }

  // 子类原型方法
  run() {
    console.log(`${this.name} 今年${this.age}岁,在跑步`);
  }
}

// 测试使用(和 ES5 组合继承效果一致)
const child1 = new Child("小明", 10);
const child2 = new Child("小红", 12);

child1.hobbies.push("打球");
console.log(child1.hobbies); // ["看书", "听歌", "打球"]
console.log(child2.hobbies); // ["看书", "听歌"]
child1.eat(); // 小明 在吃饭
child1.run(); // 小明 今年10岁,在跑步

优点

  1. 语法简洁清晰,可读性高,符合主流面向对象编程习惯。
  2. 自动处理原型链和构造函数调用,无需手动修复 constructor 指向。
  3. 支持 super 关键字,既能调用父类构造函数,也能调用父类原型方法(如 super.eat())。

注意点

  1. 子类构造函数中,如果要使用 this,必须先调用 super(),否则报错。
  2. class 定义的方法都是不可枚举的(和 ES5 手动添加到 prototype 上的方法不同)。
  3. class 不能像普通函数那样直接调用,必须使用 new 关键字实例化。

五、其他补充:寄生组合继承(解决组合继承的冗余问题)

如果追求极致优化,可使用「寄生组合继承」,它通过「空构造函数」中转,避免父类构造函数被调用两次,消除冗余属性。

核心代码(简化版)

javascript
function Parent(name) {
  this.name = name;
  this.hobbies = ["看书", "听歌"];
}

Parent.prototype.eat = function() {
  console.log(`${this.name} 在吃饭`);
};

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}

// 核心:用空函数 F 中转,避免调用 Parent 构造函数
const F = function() {};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;

Child.prototype.run = function() {
  console.log(`${this.name} 今年${this.age}岁,在跑步`);
};

这种方式是最完美的 ES5 继承方案,ES6 的 extends 底层其实就是类似寄生组合继承的实现。


五、对比

继承方式核心原理优点缺点适用场景
原型链继承子类原型指向父类实例,建立原型链1. 实现原型方法共享;2. 语法简单1. 父类实例属性变成子类原型属性,被所有子类实例共享;2. 无法向父类构造函数传参;3. 引用类型属性修改会相互影响仅了解底层原理,实际开发几乎不使用
借用构造函数继承子类构造函数中用 call/apply 调用父类构造函数1. 父类实例属性独立,不共享;2. 支持向父类传参;3. 引用类型属性互不影响1. 无法继承父类原型上的方法;2. 父类方法若定义在构造函数内,会被每个子类实例复制,浪费内存仅需要继承父类实例属性,不需要继承原型方法的场景(极少)
组合继承(原型链+借用构造函数)结合前两种方式:原型链继承方法,借用构造函数继承属性1. 兼顾实例属性独立和原型方法共享;2. 支持向父类传参;3. 功能完善,兼顾性能和灵活性1. 父类构造函数被调用两次(赋值子类原型+子类构造函数内call);2. 子类原型上存在冗余的父类实例属性(不影响使用)ES6 之前的主流实用方案,兼容低版本环境
寄生组合继承用空构造函数中转,优化组合继承的冗余问题1. 完美解决组合继承的构造函数调用两次问题;2. 无冗余属性;3. 兼顾所有优点,是 ES5 最完美继承方案1. 语法相对复杂,手动实现步骤多追求极致性能的 ES5 项目,ES6 extends 底层实现原理
ES6 class + extends语法糖,底层基于寄生组合继承实现1. 语法简洁清晰,符合传统面向对象编程习惯;2. 自动处理原型链和构造函数,无需手动修复 constructor;3. 支持 super 关键字,方便调用父类构造函数和方法;4. 无冗余问题1. 不支持低版本浏览器(需 Babel 转译);2. 本质是语法糖,底层原型链逻辑仍需理解现代 JS 开发的首选方案,绝大多数项目场景

总结

  1. JS 继承的底层核心是原型链,所有继承方式都围绕原型链展开,ES6 class 只是语法糖。
  2. 实际开发中,优先使用 ES6 的 class + extends,简洁高效;如需兼容低版本环境,使用「组合继承」。
  3. 关键要点:原型链负责共享方法,借用构造函数(call/apply/super)负责独立实例属性和传参。
评论
0/100