JavaScript原型链继承与类式继承的内存结构对比
JavaScript 中实现继承的核心在于如何利用内存中的原型链与构造函数。两种最基础的模式分别是“原型链继承”和“类式继承(借用构造函数)”。它们在内存中的存储方式、属性查找机制以及数据共享策略上存在本质区别。
一、 原型链继承
原型链继承的核心思路是利用原型让一个引用类型继承另一个引用类型的属性和方法。
1. 实现步骤
- 定义父类构造函数
Parent。 - 定义子类构造函数
Child。 - 将父类的一个实例赋值给子类的原型属性,即
Child.prototype = new Parent()。
// 1. 定义父类
function Parent() {
this.name = 'Parent Name';
this.hobbies = ['reading', 'coding']; // 引用类型属性
}
// 2. 定义子类
function Child() {
this.age = 18;
}
// 3. 实现继承:关键在于将父类实例挂载到子类原型上
Child.prototype = new Parent();
// 创建实例
const child1 = new Child();
const child2 = new Child();
2. 内存结构分析
在这种模式下,子类的实例 child1 和 child2 本身并不包含父类定义的属性(如 hobbies)。它们的内部指针 __proto__ 指向了同一个对象——即 Child.prototype(也就是 Parent 的实例)。
这意味着 hobbies 数组只存在于内存中的一个位置,所有子类实例共享这份内存。
age: 18"] Child2["Child Instance 2
age: 18"] Proto["Shared Prototype Object
(Parent Instance)
hobbies: ['reading', 'coding']
name: 'Parent Name'"] Child1 -- "__proto__" --> Proto Child2 -- "__proto__" --> Proto end
3. 内存缺陷演示
由于属性共享,任何一个实例修改了原型上的引用类型数据,都会直接反映在其他实例上。
- 访问
child1.hobbies。 - 执行
child1.hobbies.push('swimming')。 - 打印
child2.hobbies。
此时你会发现 child2.hobbies 也变成了 ['reading', 'coding', 'swimming']。这就是原型链继承在内存处理上的最大风险:引用类型数据的污染。
二、 类式继承(借用构造函数)
为了解决原型链继承中引用类型被共享的问题,类式继承(又称借用构造函数继承)采取了完全不同的内存策略。
1. 实现步骤
- 定义父类构造函数
Parent。 - 定义子类构造函数
Child。 - 在子类构造函数内部,使用
Parent.call(this)或Parent.apply(this)调用父类构造函数。
// 1. 定义父类
function Parent() {
this.name = 'Parent Name';
this.hobbies = ['reading', 'coding'];
}
// 2. 定义子类
function Child() {
// 3. 核心代码:调用父类构造函数,修正 this 指向
Parent.call(this);
this.age = 18;
}
// 创建实例
const child1 = new Child();
const child2 = new Child();
2. 内存结构分析
当使用 Parent.call(this) 时,JavaScript 引擎会在 new Child() 的过程中,将父类构造函数中定义的所有属性(包括 hobbies 数组)直接复制一份,挂载到当前的 this 对象(即子类实例)上。
每个子类实例在内存中都拥有自己独立的一套父类属性副本,互不干扰。
age: 18
name: 'Parent Name'
hobbies: ['reading', 'coding']"] Child2["Child Instance 2
age: 18
name: 'Parent Name'
hobbies: ['reading', 'coding']"] ParentFunc -- "call() copies props" --> Child1 ParentFunc -- "call() copies props" --> Child2 end
3. 内存隔离演示
因为每个实例都有独立的内存空间,修改其中一个不会影响另一个。
- 执行
child1.hobbies.push('swimming')。 - 打印
child2.hobbies。
你会发现 child2.hobbies 依然保持原样 ['reading', 'coding'],因为它使用的是另一块内存地址。
三、 两种模式的核心差异对比
为了更直观地理解两者的区别,我们可以从内存分配、属性查找和适用场景三个维度进行对比。
| 特性 | 原型链继承 | 类式继承 |
|---|---|---|
| 内存分配 | 共享。父类属性(包括引用类型)存储在原型对象上,所有子类实例指向同一块内存。 | 独立。父类属性在每个子类实例中都复制一份,占用多份内存。 |
| 引用类型安全 | 不安全。一个实例修改引用类型(如数组、对象),会影响所有实例。 | 安全。每个实例的引用类型都是独立的副本,互不影响。 |
| 参数传递 | 无法传递。创建子类实例时无法向父类构造函数传参(因为 new Parent() 在定义原型时就执行了)。 |
支持传递。可以在子类构造函数中向父类传递参数(如 Parent.call(this, name))。 |
| 函数复用 | 复用。父类的方法定义在原型上,所有实例共享同一个方法函数,节省内存。 | 不复用。如果方法定义在构造函数中,每个实例都会创建一个新的函数副本,浪费内存。 |
| 实现本质 | 重写原型对象。 | 盗用构造函数。 |
四、 如何选择与优化
在实际开发中,单纯使用某一种继承方式往往无法满足需求。通常需要根据场景进行权衡或组合使用。
1. 选择原型链继承的场景
当你需要共享方法且父类属性不包含引用类型(只有基本数据类型,如 string, number)时,原型链继承是最高效的,因为它节省了大量内存。
2. 选择类式继承的场景
当你需要隔离数据,避免实例间相互干扰,或者必须向父类构造函数传递初始化参数时,类式继承是必要的。
3. 组合继承(推荐方案)
为了取长补短,通常采用组合继承:使用类式继承来继承实例属性(保证数据隔离和传参),使用原型链继承来继承原型方法(保证函数复用)。
function Parent(name) {
this.name = name;
this.hobbies = ['reading'];
}
Parent.prototype.sayHello = function() {
console.log('Hello');
};
function Child(name, age) {
// 1. 类式继承:继承属性,隔离引用类型,支持传参
Parent.call(this, name);
this.age = age;
}
// 2. 原型链继承:继承方法
Child.prototype = new Parent();
Child.prototype.constructor = Child;
const c1 = new Child('Mike', 18);
const c2 = new Child('John', 20);
在这种模式下,内存中存在两份父类属性:一份在子类实例上(通过 call 复制),一份在子类原型上(通过 new Parent 创建)。虽然会轻微浪费内存(属性重复),但完美解决了共享与复用的矛盾。
4. 寄生组合继承(终极优化)
如果你追求极致的内存性能,可以使用 Object.create() 来避免父类构造函数的重复调用,实现寄生组合继承。
- 创建一个超类型的原型副本。
- 将副本赋值给子类型的原型。
- 修正
constructor指针。
function inheritPrototype(subType, superType) {
const prototype = Object.create(superType.prototype); // 创建对象
prototype.constructor = subType; // 增强对象
subType.prototype = prototype; // 指定对象
}
通过这种方式,子类原型继承了父类原型的方法,子类实例通过 call 获得了父类的独立属性,且父类构造函数只被调用了一次,这是目前内存利用率最高的继承模式。

暂无评论,快来抢沙发吧!