JavaScript继承机制学习笔记
一、继承的基本概念与原理
在面向对象编程中,继承是类之间共享属性和方法的核心机制,它允许我们基于已有对象创建新对象,从而提高代码复用性 。JavaScript作为基于原型的语言,其继承机制与传统类继承有所不同,但通过巧妙的设计,同样实现了面向对象的继承特性。
JavaScript的继承本质是原型链继承,即通过将子类的原型对象指向父类的实例,从而形成一条链式结构。当访问对象的属性时,JavaScript引擎会先在对象自身查找,若未找到则沿着原型链向上查找,直到找到匹配属性或到达链的末端(即原型为null的对象) 。这种机制使得子类实例可以共享父类原型上的方法,同时拥有自己的实例属性。
理解原型链继承的关键在于掌握两个核心概念:[[Prototype]]和prototype。[[Prototype]]是对象的内部属性,指向其原型对象;而只有函数才有prototype属性,当函数作为构造函数使用时,新创建的对象会将该函数的prototype作为自己的[[Prototype]] 。通过Object.getPrototypeOf()和Object.setPrototypeOf()可以安全地操作原型链,避免使用非标准的__proto__属性。
二、原型链继承的实现方式
原型链继承是最基本的JavaScript继承方式,实现起来相对简单。其核心思想是将子类的原型对象设置为父类的实例,从而形成原型链。
1. 基础实现
// 父类
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype物种 = '动物';
Animal.prototype进食 = function() {
console.log('正在进食');
};
// 子类
function Cat(name, age, color) {
this.name = name;
this.age = age;
this.color = color;
}
Cat.prototype = new Animal(); // 原型链继承
// 修正构造函数指向
Cat.prototype.constructor = Cat;
const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物
cat1.进食(); // 输出:正在进食
这种方式实现了子类Cat对父类Animal的继承,Cat的实例可以访问Animal原型上的物种属性和进食方法。但原型链继承存在两个主要问题:
- 无法向父类构造函数传参:父类Animal的构造函数在创建Cat的原型时被调用,但无法传递特定参数,所有Cat实例共享同一条原型链。
- 引用类型属性共享问题:如果父类原型上有引用类型属性(如数组),所有子类实例会共享该属性,修改其中一个实例会影响其他实例。
2. 原型链继承的优化
为解决引用类型属性共享的问题,可以使用**Object.create()**方法创建子类的原型对象:
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype物种 = '动物';
function Cat(name, age, color) {
this.name = name;
this.age = age;
this.color = color;
}
// 使用Object.create()优化原型链
Cat.prototype = Object.create(Animal.prototype);
// 修正构造函数指向
Cat.prototype.constructor = Cat;
const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物
**Object.create()**的第二个参数可用于定义新对象的属性,实现更灵活的继承控制。例如:
// 创建一个继承自Animal.prototype的新对象,并添加额外属性
Cat.prototype = Object.create(Animal.prototype, {
constructor: {
value: Cat,
enumeration: false,
writable: true,
configurable: true
}
});
这种方式避免了父类构造函数的重复调用,同时保留了原型链继承的优点。
三、构造函数继承的实现
构造函数继承通过**call()或apply()**方法在子类构造函数中调用父类构造函数,从而复制父类的实例属性 。这种方式解决了原型链继承无法向父类传参的问题。
1. 基础实现
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype物种 = '动物';
function Cat(name, age, color) {
// 在子类构造函数中调用父类构造函数
Animal.call(this, name, age);
this.color = color;
}
Cat.prototype = new Animal(); // 原型链继承
Cat.prototype constructor = Cat; // 修正构造函数指向
const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物
2. 使用apply()传递参数
function Cat(name, age, color) {
// 使用apply()传递参数数组
Animal.apply(this, [name, age]);
this.color = color;
}
构造函数继承的优势在于可以向父类构造函数传递参数,使父类属性在子类实例中独立存在,避免了引用类型属性共享的问题。但其主要缺点是:
- 无法共享父类方法:所有方法都需要在子类构造函数中重新定义,导致代码重复 。
- 构造函数模式问题:必须在构造函数中定义方法,无法实现方法的重用。
四、组合继承:构造函数继承与原型链继承的结合
组合继承结合了构造函数继承和原型链继承的优点,是JavaScript中最常用的继承方式 。它通过在子类构造函数中调用父类构造函数(复制实例属性),并通过原型链继承父类方法(实现方法共享)。
1. 基础实现
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype物种 = '动物';
function Cat(name, age, color) {
// 复制实例属性
Animal.call(this, name, age);
this.color = color;
}
// 继承原型方法
Cat.prototype = new Animal();
// 修正构造函数指向
Cat.prototype constructor = Cat;
const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物
2. 组合继承的缺点
组合继承虽然解决了参数传递和方法共享的问题,但仍存在两个主要缺点:
- 父类构造函数被调用两次:一次在子类构造函数中(通过call/apply),一次在设置子类原型时(通过new Animal()) 。
- 属性遮蔽问题:父类原型上的属性和子类实例上的同名属性可能产生冲突,删除实例属性后会暴露原型属性 。
例如:
function Father(name) {
this.name = name;
this sayName = function() {
alert(this.name);
};
}
function Son(name, age) {
Father.call(this, name);
this.age = age;
}
Son.prototype = new Father();
var son1 = new Son("Patrick", 20);
son1 sayName(); // 输出Patrick
delete son1.name;
son1 sayName(); // 输出Adam(原型上的name属性)
五、寄生组合继承:优化的组合继承方式
为解决组合继承的父类构造函数被调用两次的问题,可以使用寄生组合继承,它通过Object.create()直接继承父类的原型对象,避免了父类构造函数的重复调用。
1. 基础实现
function inheritPrototype(Child, Parent) {
// 创建父类原型的副本
var F = function() {};
F.prototype = Parent.prototype;
// 继承父类原型
Child.prototype = new F();
// 修正构造函数指向
Child.prototype constructor = Child;
}
// 使用
inheritPrototype(Cat, Animal);
const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物
2. 优化实现
function inheritPrototype(Child, Parent) {
// 直接继承父类原型
Child.prototype = Object.create(Parent.prototype);
// 修正构造函数指向
Child.prototype constructor = Child;
}
// 使用
inheritPrototype(Cat, Animal);
const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物
寄生组合继承的优势在于:
- 避免父类构造函数的重复调用:仅在子类实例化时调用一次父类构造函数。
- 保持原型链结构:子类原型正确指向父类原型,确保方法共享。
- 属性隔离:父类实例属性在子类中独立存在,不会共享引用值。
六、ES6 class语法糖与super关键字
ES6引入了class语法糖和super关键字,使得JavaScript的面向对象编程更加直观和易于理解 。这些特性本质上是基于JavaScript原型链的语法封装。
1. class语法基础
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
console.log(`你好,我叫${this.name},今年${this.age}岁。`);
}
}
// 实例化
const person1 = new Person('张三', 30);
person1.greet(); // 输出:你好,我叫张三,今年30岁。
2. 继承实现
class Student extends Person {
constructor(name, age, major) {
// 必须先调用super()才能使用this
super(name, age);
this.major = major;
}
study() {
console.log(`正在学习${this.major}。`);
}
// 重写父类方法
greet() {
console.log(`你好,我是学生${this.name},今年${this.age}岁,主修${this重大}。`);
// 调用父类方法
super.greet();
}
}
// 实例化
const student1 = new Student('李四', 20, '计算机');
student1.greet(); // 输出:自定义的问候
student1 study(); // 输出:正在学习计算机
3. super关键字详解
**super()**用于在子类构造函数中调用父类构造函数,必须作为构造函数的第一条语句执行,否则会抛出错误 :
class Child extends Parent {
constructor() {
// 必须首先调用super()
super();
// 然后才能使用this
this property = value;
}
}
**super.method()**用于在子类方法中调用父类原型上的方法:
class Child extends Parent {
method() {
// 调用父类方法
super.method();
// 添加子类自己的逻辑
}
}
4. Babel编译后的底层实现
Babel将ES6的class语法编译为基于原型的ES5代码:
// Babel编译后的代码
var Parent = function Parent() {
_CallCheck(this, Parent);
};
var Child = function() {
_CallCheck(this, Child);
// 调用父类构造函数
_CallCheck(Parent, this);
// 继承父类实例属性
Parent.apply(this, arguments);
// 子类自己的初始化
this property = value;
};
// 设置原型链
Inherit(Child, Parent);
function _CallCheck(target, thisArg, argumentsList) {
if (thisArg === void 0) {
thisArg = this;
}
if (target === null) {
throw new TypeError("调用目标不能为null");
}
if (typeof target !== "function") {
throw new TypeError("预期为函数");
}
return target.apply(thisArg, argumentsList);
}
function _Inherit(subClass, superClass) {
if (typeof superClass !== "function" &&中学 !== null) {
throw new TypeError("Super expression must either be null or a function");
}
subClass.prototype = Object.create超级Class &&中学.prototype, {
constructor: {
value: subClass,
enumeration: false,
writable: true,
configurable: true
}
});
// 设置子类构造函数的原型指向父类
if (superClass) {
Object.setPrototypeOf ? Object.setPrototypeOf(subClass,中学) : subClass.__proto__ =中学;
}
}
ES6类继承的底层原理:
- 子类的[[Prototype]]指向父类:通过Object.setPrototypeOf(Child, Parent)实现,确保子类可以访问父类的静态方法和属性 。
- 子类原型的[[Prototype]]指向父类原型:通过Object.create(Parent.prototype)实现,确保子类实例可以访问父类原型的方法 。
七、继承方式的对比与选择
以下是JavaScript中几种主要继承方式的对比:
| 继承方式 | 参数传递 | 方法共享 | 引用属性共享 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 原型链继承 | 不支持 | 支持 | 存在 | 低 | 简单继承,无需参数传递 |
| 构造函数继承 | 支持 | 不支持 | 不存在 | 中 | 需要参数传递,不共享方法 |
| 组合继承 | 支持 | 支持 | 不存在 | 高 | 最常用,平衡参数传递和方法共享 |
| 寄生组合继承 | 支持 | 支持 | 不存在 | 中高 | 避免父构造函数重复调用 |
| ES6类继承 | 支持 | 支持 | 不存在 | 低 | 现代JavaScript开发,推荐使用 |
在实际开发中,建议优先使用ES6的class语法糖,因为它提供了更直观的继承语法,同时避免了组合继承的父构造函数重复调用问题。在需要兼容旧环境的情况下,可以使用寄生组合继承作为替代方案。
八、继承的常见问题与解决方案
1. 构造函数指向问题
当修改子类原型时,子类的prototype constructor属性会指向父类,导致实例的constructor属性不正确。解决方案是手动修正:
Cat.prototype = new Animal();
Cat.prototype constructor = Cat; // 修正构造函数指向
2. 引用类型属性共享问题
如果父类原型上有引用类型属性(如数组),所有子类实例会共享该属性。解决方案是将引用类型属性放在构造函数中:
function Animal(name, age) {
this.name = name;
this.age = age;
// 将引用类型属性放在构造函数中
this arr = [];
}
Animal.prototype物种 = '动物';
3. 静态方法继承
ES6类的静态方法不会自动继承,需要手动复制:
class Parent {
static staticMethod() {
console.log('静态方法');
}
}
class Child extends Parent {
// 手动继承静态方法
static staticMethod = Parent staticMethod;
}
4. 构造函数继承的参数传递
使用call/apply方法时,需要确保参数正确传递:
function Child(...args) {
// 使用剩余参数语法传递所有参数
Parent.apply(this, args);
// 子类自己的初始化
this property = value;
}
九、总结与实践建议
JavaScript的继承机制虽然复杂,但通过理解原型链、构造函数继承和组合继承等概念,我们可以灵活运用各种继承方式。ES6的class语法糖提供了最简洁和推荐的继承方式 ,但在需要兼容旧环境的情况下,寄生组合继承是一个很好的替代方案。
在实际开发中,建议遵循以下实践:
- 优先使用ES6的class语法:它提供了更直观的继承语法,同时避免了组合继承的父构造函数重复调用问题。
- 理解super关键字的作用:它不仅用于调用父类构造函数,还可以用于访问父类原型上的方法。
- 避免修改内置原型:直接修改Object.prototype会影响所有对象,破坏封装性。
- 合理使用继承:继承应该用于"is-a"关系,而不是简单的代码复用。
通过深入理解JavaScript的继承机制,我们可以更好地利用面向对象编程的特性,构建更模块化、可维护的JavaScript应用。