原型链继承是 JavaScript 中最纯粹的继承方式,直接利用了原型链的查找机制:让子类的原型对象指向父类的实例,从而子类实例可以通过原型链访问父类的方法和属性。
function Parent() {
this.names = ['张三', '李四'];
}
Parent.prototype.sayHello = function() {
console.log('你好,我是父类');
};
function Child() {}
// 关键一步:将子类原型指向父类实例
Child.prototype = new Parent();
const child1 = new Child();
const child2 = new Child();
child1.names.push('王五');
console.log(child2.names); // ['张三', '李四', '王五'],共享了引用类型
child1.sayHello(); // '你好,我是父类'
原理分析
Child.prototype现在是Parent的一个实例,该实例的内部[[Prototype]](即proto)指向Parent.prototype。- 当访问
child1.sayHello时,先在自身属性中找,找不到就去Child.prototype(即父类实例)上找,还找不到就继续沿着原型链到Parent.prototype上找,最终找到方法。
核心缺点
- 引用类型属性被所有实例共享:如上面的
names数组,当原型对象上存在引用类型值时,所有子类实例会共享同一个引用,一处修改处处受影响。 - 无法向父类构造函数传参:在创建
Child.prototype = new Parent()这一步时,无法为不同子类实例传递不同的参数,所有子类实例共享同一个父类实例的属性初始值。 - 子类原型 constructor 指向错误:
Child.prototype.constructor会指向Parent,需要手动修复。
适用场景
几乎没有单独使用的价值,通常作为组合继承的一部分出现,或用于极简单的单例式继承。
4.5.2 构造函数继承
构造函数继承通过在子类构造函数中直接调用父类构造函数,并利用 call 或 apply 方法将父类构造函数内的属性绑定到子类实例上。这种方式本质上只是复制属性,并不涉及原型链。
function Parent(name) {
this.name = name;
this.colors = ['红', '蓝'];
}
Parent.prototype.sayHello = function() {
console.log('你好');
};
function Child(name, age) {
// 在子类构造函数中调用父类构造函数,并传入当前实例
Parent.call(this, name);
this.age = age;
}
const child1 = new Child('小明', 10);
const child2 = new Child('小红', 12);
child1.colors.push('绿');
console.log(child1.name); // '小明'
console.log(child1.colors); // ['红', '蓝', '绿']
console.log(child2.colors); // ['红', '蓝'],互不影响
// 但无法继承父类原型上的方法
child1.sayHello(); // TypeError: child1.sayHello is not a function
原理分析
Parent.call(this, name)将Parent内部的this强制绑定为当前的Child实例,所以Parent内部通过this.xxx定义的属性都会被直接挂载到子类实例上。- 每个子类实例都拥有父类属性的独立副本,解决了原型链继承中引用类型共享的痛点。
核心优点
- 可以向父类构造函数传递参数,每个实例可以有不同的初始值。
- 引用类型属性不再共享,安全可靠。
核心缺点
- 无法继承父类原型上的方法:因为构造函数的
call只复制实例属性,子类实例和父类原型之间没有任何联系。 - 方法必须定义在构造函数内部(每次实例化都会重新创建函数),无法实现函数复用,内存消耗大。
适用场景
单独使用的情况很少,更多的是与其他方式配合,解决引用类型共享和传参问题。
4.5.3 组合继承
组合继承(有时也称“伪经典继承”)将原型链继承和构造函数继承两者的优点结合起来:用原型链继承方法,用构造函数继承属性。它曾经是 JavaScript 中最常用的继承模式。
function Parent(name) {
this.name = name;
this.colors = ['红', '蓝'];
}
Parent.prototype.sayHello = function() {
console.log('我是' + this.name);
};
function Child(name, age) {
// 第二次调用 Parent:继承属性
Parent.call(this, name);
this.age = age;
}
// 第一次调用 Parent:继承原型方法
Child.prototype = new Parent();
// 修复 constructor 指向
Child.prototype.constructor = Child;
const child1 = new Child('小明', 10);
const child2 = new Child('小红', 12);
child1.colors.push('绿');
console.log(child1.colors); // ['红', '蓝', '绿']
console.log(child2.colors); // ['红', '蓝'],属性独立
child1.sayHello(); // '我是小明',可继承方法
原理分析
- 属性继承:通过
Parent.call(this, name),每个子类实例都拥有父类实例属性的独立副本,避免了引用类型共享。 - 方法继承:通过
Child.prototype = new Parent(),子类原型成为父类实例,从而能通过原型链访问Parent.prototype上定义的方法,实现函数复用。 - 修复
constructor指向,保证类型判断不出错。
核心优点
- 既能向父类传参,又能共享原型方法,解决了前两种继承模式各自的硬伤。
- 是 ES6 之前最成熟、最通用的继承实现方式。
核心缺点
- 父类构造函数被调用了两次:一次是设置原型
new Parent(),另一次是在子类构造函数内部Parent.call(this)。这导致子类原型上多了一套父类实例属性,虽然被实例属性屏蔽(因为实例属性同名时会覆盖原型属性),但造成了一定的内存浪费和逻辑冗余。 - 实现稍显繁琐,需要手动修复
constructor和维护原型链。
现实地位
在 ES6 引入 class 语法之前,组合继承几乎是所有严肃项目中处理继承问题的标配方案。理解它的运行机制,对于阅读和维护老代码、深入理解原型链仍然至关重要。下一节将介绍如何用寄生组合继承来修复“两次调用”的问题,以及 ES6 Class 如何用更优雅的方式完成同样的工作。