在学习JS的继承前,需要将一些基本的概念搞清楚,比如:构造函数、原型对象、实例等。 有一篇文章原博主写的很好如何理解构造函数,原型对象和实例的关系建议大家去看一下,我这里就提取重点简单总结一下!
一、构造函数,原型对象和实例的关系
1.构造函数、实例
构造函数,是用来创建对象的函数,本质上也是函数。
与其他函数的区别在于调用方式不同:
- 如果通过new操作符来调用的,就是构造函数
- 如果没有通过new操作符来调用的,就是普通函数
function Person(name, age) {
this.name = name;
this.age = age;
}
//当做构造函数调用
var person1 = new Person('Mike',10);
//当做普通函数调用,这里相当于给window对象添加了name和age属性
Person('Bob',12);
console.log(person1)//Person {name: "Mike", age: 10}
console.log(name)//Bob
console.log(age)//12
在var person1 = new Person('Mike',10)中,通过new操作符调用了函数Person,并且生成了person1。 这里的Person就称为构造函数,person1称为Person函数对象的一个实例(复制品)。
2.原型对象
当我们每次创建一个函数的时候,函数对象都会有一个prototype属性,这个属性是一个指针,指向它的原型对象。原型对象的本质也是一个对象。
- 代码理解:
function Person(name, age) {
this.name = name;
this.age = age;
}
console.log(Person.prototype)//object{constructor:Person}
可以看到Person.prototype指向了一个对象,即Person的原型对象(Person prototype)
并且这个对象(Person prototype)有一个constructor属性,又指向了Person函数对象(又指了回去)。
3.构造函数,原型对象和实例的关系
- 画图理解
- 函数对象的prototype指向原型对象,原型对象的constructor指向函数对象
- 实例对象的[Protoptype]属性指向原型对象,这里的[Protoptype]是内部属性,可以先理解为它是存在的,但是不允许我们访问。这个属性的作用是:允许实例通过该属性访问原型对象中的属性和方法。
function Person(name, age) {
this.name = name;
this.age = age;
}
//在原型对象中添加属性或者方法
Person.prototype.sex = '男';
var person1 = new Person('Mike',10);
var person2 = new Person('Alice',20);
//只给person2设置性别
person2.sex = '女';
console.log(person1.sex)//'男'
console.log(person2.sex)//'女'
这里我们没有给person1实例设置sex属性,但是因为[Protoptype]的存在,会访问原型对象中对应的属性;同时我们给person2设置sex属性后输出的是'女'。
说明只有当实例本身不存在对应的属性或方法时,才会去找原型对象上的对应属性或方法
总结: 构造函数、原型和实例之间的关系:
每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个原型对象的指针。
继承的本质就是复制,即重写原型对象,代之以一个新类型的实例。
二、继承
在理解了构造函数、原型和实例之间的关系后,我们正式进入到继承的学习中:
1. 原型链继承(基础版)
function Parent() {
this.name = 'Parent';
}
Parent.prototype.say = function() {
return this.name;
};
function Child() {}
Child.prototype = new Parent(); // 核心
const child = new Child();
console.log(child.say()); // Parent
- 重点:将子类的原型对象替换为父类的实例。(将子类的原型(Child.prototype)指向父类的一个实例。)
- 特点:通过原型链实现继承。 实例可继承的属性有:实例的构造函数的属性,父类构造函数属性,父类原型的属性。
- 缺点: 1. 新实例无法向父类构造函数传参。 2. 所有新实例都会共享父类实例的属性。(原型上的属性是共享的,一个实例修改了原型属性,另一个实例的原型属性也会被修改!)
function Parent() {
this.colors = ['red', 'blue'];
}
const child1 = new Child();
child1.colors.push('green');
const child2 = new Child();
console.log(child2.colors); // ['red', 'blue', 'green'] (共享问题)
2. 构造函数继承(解决属性独立)
function Child() {
Parent.call(this); // 核心
}
const child1 = new Child();
child1.colors.push('green');
const child2 = new Child();
console.log(child2.colors); // ['red', 'blue'] (独立实例)
-
重点:用.call()或.apply()将父类构造函数引入子类函数(在子类函数中做了父类函数的自执行(复制))
-
特点: 1. 可以继承多个构造函数属性(call多个)。 2. 在子实例中可向父实例传参。 3. 每个实例都有独立的父类属性副本。
-
缺点: 1. 无法继承父类原型方法。 2. 无法实现构造函数的复用。(每次用每次都要重新调用) 3. 每个新实例都有父类构造函数的副本,臃肿。
-
说明:
call 和 apply 功能相同,区别在于传参方式
Parent.call(this, name, age); // 参数逐个传递
Parent.apply(this, [name, age]); // 参数通过数组传递
为什么要用 call 而不是直接 new Parent()
直接 new Parent() 会创建一个新的父类实例,而不是在子类实例的上下文中初始化属性。使用 call 确保属性直接添加到子类实例。
Parent.call(this)的作用 或者 有传参时 Parent.call(this,name)的作用
-
调用父类构造函数 在子类构造函数中显式调用父类构造函数。通过这种方式,父类构造函数中定义的实例属性(如 this.name)会被添加到当前正在创建的子类实例上。
-
继承实例属性 通过这种方式,子类实例能够 独立继承父类的实例属性 ,确保每个子类实例都有自己独立的属性副本,避免了原型链继承中引用类型属性共享的问题。
-
传递参数初始化 name 参数被传递给父类构造函数,使得父类可以根据传入的值初始化实例属性。例如,若父类构造函数定义为:
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
子类通过 Parent.call(this, name); 后,子类实例将拥有 name 和 colors 属性。
Parent.call(this, name);
// 等效于在子类实例上下文中执行:
// this.name = name; (父类构造函数中的代码)
// this.colors = ['red', 'blue'];
3. 组合继承(经典方案)
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.age = 10;
Parent.prototype.home = '地址'
function Child(name, age) {
Parent.call(this, name); // 第二次调用父构造函数(实例属性)
this.age = age;
}
Child.prototype = new Parent(); // 第一次调用父构造函数(原型链)
const c1=new Child('jack',18);
console.log(c1.name)//jack 继承了构造函数的属性
console.log(c1.home)//地址 继承了父类原型的属性
console.log(c1.age)//18 自己实例有的属性不会再通过原型链去查找
- 优点:
- 实例属性独立
- 可继承原型方法
- 缺点:
- 父构造函数被调用两次
- 子类原型包含多余父类实例属性
- 父类原型上的引用类型属性会被所有子类实例共享。
- 说明:
两次调用父构造函数的问题
function Child() {
Parent.call(this); // 第二次调用(为实例添加属性)
}
Child.prototype = new Parent(); // 第一次调用(为原型添加属性)
第一次调用:创建父类实例作为子类原型,导致子类原型上存在冗余的父类实例属性。
第二次调用:在子类构造函数中初始化实例属性,覆盖原型上的同名属性。
实例属性上不存在引用数据共享,实例属性独立
-
属性查找优先级规则: JavaScript 在访问属性时,会优先查找实例自身的属性,若找不到才沿原型链向上查找。因此,即使原型链上有同名属性,实例自身的属性会覆盖原型属性。
-
构造函数继承的隔离作用: Parent.call(this) 会在每个子类实例上创建独立的实例属性,这些属性直接存储在实例自身,与原型链无关。
-
冗余原型属性的无害性: 虽然 Child.prototype 上存在 colors 属性,但它永远不会被访问到(因为实例自身已有该属性),因此不会导致共享问题。
原型链上的引用类型共享 若属性定义在父类原型上,所有子类实例必然共享该属性,与继承方式无关。
4.原型式继承(对象浅拷贝)
直接基于现有对象创建新对象,通过原型链共享属性和方法。核心是使用 Object.create() 或类似方法,将新对象的原型指向现有对象。
const parent = { name: 'Parent' };
const child = Object.create(parent); // 核心
console.log(child.name); // Parent
等同于
const parent = { name: 'Parent' };
function create(obj) {
function F() {}
F.prototype = obj;//继承了传入的参数
return new F();//返回函数对象
}
onst child = create(parent)
console.log(child.name); // Parent
-
重点:用一个函数包装一个对象,然后返回这个函数的调用,这个函数就变成了个可以随意增添属性的实例或对象。
-
特点:类似于复制一个对象,用函数来包装。
-
缺点:
1.引用类型共享:所有实例共享原型对象的引用属性(如数组、对象),导致意外修改。 (浅拷贝:复制的是引用地址)
const parent = { data: [1, 2] };
const child1 = Object.create(parent);
const child2 = Object.create(parent);
child1.data.push(3);
console.log(child2.data); // [1, 2, 3]
- 无法实现复用。(新实例属性都是后面添加的)
- 无法传参:无法在创建对象时初始化属性。
浅拷贝会创建一个新对象,但仅复制原对象的第一层内容。如果原对象的元素是引用类型(如列表、字典、对象等),则拷贝的是这些元素的引用地址,而非创建新的副本。
5.寄生式继承(工厂增强)
在原型式继承的基础上,通过工厂函数增强对象,添加私有方法或属性。
function createEnhancedObject(parent) {
const clone = Object.create(parent); // 基于原型创建对象
clone.sayHi = function() { // 增强对象
console.log('Hi!');
};
return clone;
}
const parent = { name: 'Parent' };
const child = createEnhancedObject(parent);
child.sayHi(); // 'Hi!'
- 重点:就是给原型式继承外面套了个壳子。
- 优点:
- 功能增强:可为对象添加实例特有的方法或属性。
- 灵活性高:适合需要定制化扩展的场景。
- 缺点:
- 方法无法复用:每次创建实例都会生成新方法,内存浪费。
const child1 = createEnhancedObject(parent);
const child2 = createEnhancedObject(parent);
console.log(child1.sayHi === child2.sayHi); // false(不同方法)
- 引用类型共享:原型链上的引用属性仍被共享。
6.寄生组合式继承(完美方案)
结合构造函数继承(属性独立)和寄生式继承(工厂增强),避免组合继承中父类构造函数被调用两次的问题。通过 Object.create() 继承父类原型,修正子类构造函数指向。
function inheritPrototype(Child, Parent) {
const prototype = Object.create(Parent.prototype); // 创建父类原型副本
prototype.constructor = Child; // 修正构造函数指向
Child.prototype = prototype; // 设置子类原型(继承父类原型属性)
}
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.sayName = function() {
console.log(this.name);
};
function Child(name, age) {
Parent.call(this, name); // 继承父类实例属性(第一次调用父构造函数)
this.age = age;
}
inheritPrototype(Child, Parent); // 继承原型方法(无第二次调用)
Child.prototype.sayAge = function() {
console.log(this.age);
};
// 测试
const child1 = new Child('Alice', 25);
child1.colors.push('green');
console.log(child1.colors); // ['red', 'blue', 'green']
const child2 = new Child('Bob', 30);
console.log(child2.colors); // ['red', 'blue'](属性独立)
- 优点:
- 高效性:父类构造函数仅调用一次,避免冗余属性。
- 原型链纯净:子类原型不包含父类实例属性。
- 完全继承:实例属性独立,原型方法复用,支持 instanceof 检查。
- 缺点:
- 实现稍复杂:需手动处理原型链和构造函数指向。
- 扩展
原型链上的引用类型共享问题解决方案
关键:colors 定义在父类原型上,在继承父类原型时,浅拷贝也只复制引用地址,所有子类实例通过原型链访问 Parent.prototype.colors,因此共享同一个数组。
要彻底解决引用类型共享问题,需根据场景选择以下方案:
方案 1:将引用类型定义为实例属性 修改父类构造函数,让引用类型属性属于实例而非原型:
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue']; // 实例属性
}
Parent.prototype.sayName = function() { /* 方法 */ };
// 寄生组合式继承
function Child(name, age) {
Parent.call(this, name); // 复制实例属性(包括 colors)
this.age = age;
}
inheritPrototype(Child, Parent);
// 测试
const child1 = new Child('A', 10);
child1.colors.push('green');
console.log(child1.colors); // ['red', 'blue', 'green']
const child2 = new Child('B', 20);
console.log(child2.colors); // ['red', 'blue'](独立)
方案 2:在子类中覆盖引用属性 修改子类构造函数,在子类构造函数中初始化成自己的引用属性:
function Parent(name) {
this.name = name;
}
Parent.prototype.colors = ['red', 'blue']; // 父类原型属性
function Child(name, age) {
Parent.call(this, name);
this.age = age;
this.colors = [...Parent.prototype.colors]; // 覆盖为独立数组
}
inheritPrototype(Child, Parent);
// 测试
const child1 = new Child('A', 10);
child1.colors.push('green');
console.log(child1.colors); // ['red', 'blue', 'green']
const child2 = new Child('B', 20);
console.log(child2.colors); // ['red', 'blue'](独立)
总结
- 寄生组合式继承的作用: 解决组合继承中父类构造函数被调用两次的问题,保持原型链纯净,但 无法自动处理父类原型上的引用类型共享。
-
引用类型共享的本质: 若属性定义在父类原型上,所有子类实例必然共享该属性,与继承方式无关。
-
最终建议:
- 若需要独立引用属性,应将其定义为 实例属性。
- 若必须使用原型属性,则需要在子类中 显式覆盖。
三、ES6 Class 继承(语法糖)
class Person {
constructor(name) {
this.name = name
}
// 原型方法
// 即 Person.prototype.getName = function() { }
// 下面可以简写为 getName() {...}
getName = function () {
console.log('Person:', this.name)
}
}
class Gamer extends Person {
constructor(name, age) {
// 子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
super(name)
this.age = age
}
}
const asuna = new Gamer('Asuna', 20)
asuna.getName() // 成功访问到父类的方法
1. constructor():类的构造函数
作用
- 初始化实例属性:在创建类的实例时自动调用,用于初始化对象的属性。
- 必须存在性:即使不显式定义,JavaScript 引擎也会默认生成一个空的 constructor()。
- 唯一性:一个类只能有一个 constructor()。
class Person {
constructor(name, age) {
this.name = name; // 实例属性
this.age = age;
}
}
const alice = new Person("Alice", 25);
console.log(alice.name); // "Alice"
2. super():调用父类构造函数或方法
作用
- 在子类中调用父类构造函数:必须在子类的 constructor() 中 优先调用 super(),才能使用 this。
- 调用父类方法:在子类方法中,可以通过 super.parentMethod() 调用父类的方法。
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a noise.`);
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // 调用父类构造函数( 必须优先调用!)
this.breed = breed;
}
speak() {
super.speak(); // 调用父类方法
console.log(`${this.name} barks!`);
}
}
const dog = new Dog("Buddy", "Golden Retriever");
dog.speak();
// 输出:
// "Buddy makes a noise."
// "Buddy barks!"
3. 为什么必须调用 super()?
- 本质原因:ES6 的类继承要求子类必须先构建父类的实例环境,才能初始化自身的属性。
- 底层机制:super() 等同于在 ES5 中调用 Parent.call(this),但 ES6 强制要求语法显式化。