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岁,在跑步(访问子类原型方法)
缺点
- 父类的实例属性会变成子类的「原型属性」,被所有子类实例共享,修改一个实例的该属性会影响其他实例。
- 无法在子类构造函数中向父类构造函数传参(只能在赋值原型时统一传参,灵活性差)。
二、改进:借用构造函数继承(解决实例属性共享问题)
原理
在子类构造函数中,通过 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
优点
- 父类实例属性不共享,每个子类实例都有独立副本。
- 支持向父类构造函数传参,灵活性高。
缺点
- 无法访问父类原型上的方法(只能继承父类构造函数内的属性/方法)。
- 父类方法如果定义在构造函数内,每个子类实例都会复制一份,造成内存浪费(无法共享方法)。
三、最优解:组合继承(原型链 + 借用构造函数,最常用)
原理
结合「原型链继承」和「借用构造函数继承」的优点:
- 用原型链继承实现父类「原型方法」的共享。
- 用借用构造函数继承实现父类「实例属性」的独立和传参。
代码实现
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岁,在跑步
优点
- 实例属性独立,原型方法共享,兼顾性能和灵活性。
- 支持向父类传参,能访问父类所有属性和方法。
- 是 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岁,在跑步
优点
- 语法简洁清晰,可读性高,符合主流面向对象编程习惯。
- 自动处理原型链和构造函数调用,无需手动修复
constructor指向。 - 支持
super关键字,既能调用父类构造函数,也能调用父类原型方法(如super.eat())。
注意点
- 子类构造函数中,如果要使用
this,必须先调用super(),否则报错。 class定义的方法都是不可枚举的(和 ES5 手动添加到prototype上的方法不同)。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 开发的首选方案,绝大多数项目场景 |
总结
- JS 继承的底层核心是原型链,所有继承方式都围绕原型链展开,ES6
class只是语法糖。 - 实际开发中,优先使用 ES6 的
class + extends,简洁高效;如需兼容低版本环境,使用「组合继承」。 - 关键要点:原型链负责共享方法,借用构造函数(
call/apply/super)负责独立实例属性和传参。