昨天我们聊了原型链,知道了JS对象之间是怎么"攀亲戚"的。今天咱们来聊聊继承------也就是怎么让一个对象"认祖归宗",继承另一个对象的属性和方法。从最原始的手动操作,到ES6优雅的class语法,这中间有好几种姿势,每种都有自己的脾气。今天一次性给你盘清楚。
前言
继承在JS里就像"房产继承"------你想把老爹的房子传给孩子,但又不想直接把房子拆了重新盖。不同的继承方式,就像是不同的"过户"手段,有的简单粗暴,有的精细巧妙,有的会留下后遗症。
今天我们就来盘点JS里实现继承的七种方式,从最基础的到最完善的,让你在面试官问"JS继承有哪些方式"时,能从容应对,还能说出各自的优缺点。
一、原型链继承:最原始的"血脉相连"
这是JS里最基础的继承方式,核心就是让子类的原型指向父类的实例。
js
function Animal() {
this.name = '动物';
this.colors = ['黑色', '白色'];
}
Animal.prototype.eat = function() {
console.log('吃东西');
};
function Dog() {}
// 关键一步:让Dog的原型指向Animal的实例
Dog.prototype = new Animal();
const dog1 = new Dog();
const dog2 = new Dog();
dog1.colors.push('金色');
console.log(dog2.colors); // ['黑色', '白色', '金色'] ------ 哎呀,被改了!
优点:实现了方法的继承,写起来简单。
缺点:
- 引用类型的属性会被所有实例共享,一个改了大家都改。
- 无法向父类构造函数传参。
- 子类实例的构造函数被"篡改"成了Animal。
这种继承就像家族企业,祖宗留下的财产(比如房产证)大家共用,一个孙子把房子卖了,其他孙子都没了。
二、构造函数继承:借鸡生蛋
为了解决引用共享和传参问题,诞生了"借用构造函数"的方式,在子类构造函数里调用父类构造函数。
js
function Animal(name) {
this.name = name;
this.colors = ['黑色', '白色'];
}
Animal.prototype.eat = function() {
console.log('吃东西');
};
function Dog(name) {
Animal.call(this, name); // 借调父类构造函数
}
const dog1 = new Dog('旺财');
const dog2 = new Dog('来福');
dog1.colors.push('金色');
console.log(dog1.colors); // ['黑色', '白色', '金色']
console.log(dog2.colors); // ['黑色', '白色'] ------ 没被影响!
console.log(dog1.eat); // undefined ------ 父类原型上的方法没继承到
优点:
- 解决了引用共享问题,每个实例有自己的属性副本。
- 可以向父类传递参数。
缺点:
- 只能继承父类实例属性,继承不到父类原型上的方法。
- 方法都在构造函数里定义,每次创建实例都会创建新方法,浪费内存。
这就像"借钱不借地",你把老爹的现金拿来了,但老爹的祖宅(原型上的方法)没拿到。
三、组合继承:取长补短
把原型链继承和构造函数继承结合起来,各取所长。
js
function Animal(name) {
this.name = name;
this.colors = ['黑色', '白色'];
}
Animal.prototype.eat = function() {
console.log('吃东西');
};
function Dog(name) {
Animal.call(this, name); // 第二次调用父类
}
Dog.prototype = new Animal(); // 第一次调用父类
Dog.prototype.constructor = Dog; // 修正构造函数指向
const dog1 = new Dog('旺财');
const dog2 = new Dog('来福');
dog1.colors.push('金色');
console.log(dog1.colors); // ['黑色', '白色', '金色']
console.log(dog2.colors); // ['黑色', '白色']
dog1.eat(); // 吃东西 ------ 方法也继承到了
优点:
- 解决了引用共享问题。
- 可以向父类传参。
- 继承了父类原型上的方法。
缺点:
- 父类构造函数被调用了两次,造成了一定的性能浪费和属性冗余(实例上有,原型上也有)。
组合继承是JS里最常用的继承方式,虽然有小瑕疵,但足够好用。ES6的class本质上就是它的语法糖。
四、原型式继承:Object.create的雏形
这种方式不涉及构造函数,直接通过一个对象创建另一个对象。
js
const animal = {
name: '动物',
colors: ['黑色', '白色'],
eat() {
console.log('吃东西');
}
};
function createObject(obj) {
function F() {}
F.prototype = obj;
return new F();
}
const dog1 = createObject(animal);
const dog2 = createObject(animal);
dog1.colors.push('金色');
console.log(dog2.colors); // ['黑色', '白色', '金色'] ------ 又被共享了
ES5提供了Object.create()方法,就是干这个的。
js
const dog1 = Object.create(animal);
const dog2 = Object.create(animal);
优点:不需要构造函数,直接基于已有对象创建新对象。
缺点:引用类型属性还是会被共享。
这种继承就像"克隆人",克隆体共享同一个原型,一个改了大家都改。
五、寄生式继承:给继承加个包装
在原型式继承的基础上,给新对象添加方法。
js
function createDog(original) {
const clone = Object.create(original);
clone.bark = function() {
console.log('汪汪汪');
};
return clone;
}
const animal = { name: '动物', eat() { console.log('吃东西'); } };
const dog = createDog(animal);
dog.bark(); // 汪汪汪
优点:可以在不修改原始对象的情况下添加新功能。
缺点:和原型式继承一样,引用共享问题依然存在;而且方法每次创建都会重新生成,没法复用。
六、寄生组合继承:最完美的姿势
组合继承的缺点是调用了两次父类构造函数。寄生组合继承解决了这个问题,它被认为是JS继承的"最佳实践"。
js
function Animal(name) {
this.name = name;
this.colors = ['黑色', '白色'];
}
Animal.prototype.eat = function() {
console.log('吃东西');
};
function Dog(name) {
Animal.call(this, name); // 只调用一次父类
}
// 核心:用Object.create代替new Animal()
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function() {
console.log('汪汪汪');
};
const dog1 = new Dog('旺财');
const dog2 = new Dog('来福');
dog1.colors.push('金色');
console.log(dog2.colors); // ['黑色', '白色'] ------ 不受影响
dog1.eat(); // 吃东西
dog1.bark(); // 汪汪汪
优点:
- 父类构造函数只调用一次。
- 原型链干干净净,没有冗余属性。
- 既有自己的属性副本,又继承了原型方法。
寄生组合继承是目前最理想的继承实现方式,也是ES6 class 背后做的事情。
七、ES6 Class:语法糖的终极形态
ES6引入了class关键字,让继承写起来像其他语言一样优雅。
js
class Animal {
constructor(name) {
this.name = name;
this.colors = ['黑色', '白色'];
}
eat() {
console.log('吃东西');
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // 调用父类构造函数
this.breed = breed;
}
bark() {
console.log('汪汪汪');
}
}
const dog1 = new Dog('旺财', '土狗');
const dog2 = new Dog('来福', '金毛');
dog1.colors.push('金色');
console.log(dog2.colors); // ['黑色', '白色'] ------ 完美
dog1.eat(); // 吃东西
dog1.bark(); // 汪汪汪
优点:
- 语法清晰,易读易写。
- 底层就是寄生组合继承,性能好。
- 支持
super关键字方便调用父类方法。
缺点:
- 本质还是原型那一套,但语法糖已经够甜了。
八、各种继承方式对比总结
| 继承方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 原型链继承 | 简单 | 引用共享、不能传参 | 基本不用 |
| 构造函数继承 | 解决引用共享、能传参 | 不能继承原型方法 | 基本不用 |
| 组合继承 | 两者优点都有 | 调用两次父类 | 以前常用 |
| 原型式继承 | 基于已有对象创建 | 引用共享 | 简单对象复用 |
| 寄生式继承 | 可添加新功能 | 引用共享 | 临时增强对象 |
| 寄生组合继承 | 完美 | 写法稍复杂 | ES6之前的首选 |
| ES6 class | 语法优雅、标准 | 需要转译(老环境) | 现代开发首选 |
九、实际开发中怎么选?
无脑选ES6 class 。除非你还要兼容IE这种古董,否则直接用class和extends就完事了。不仅代码量少,而且不容易踩坑。
如果你好奇class底层干了啥,或者要写一些高阶的继承场景(比如混入multiple inheritance),那寄生组合继承的手写实现还是值得掌握的。
js
function inherit(child, parent) {
child.prototype = Object.create(parent.prototype);
child.prototype.constructor = child;
}
这个工具函数,就是寄生组合继承的核心。
十、总结:从"手动挡"到"自动挡"
JS的继承演进史,其实就是一部从"手动挡"到"自动挡"的发展史:
- 原型链继承是手动挡,操作复杂容易出事。
- 组合继承是自动挡,但油耗(性能)稍高。
- 寄生组合继承是CVT,平顺又高效。
- ES6 class是智能驾驶,你只管踩油门,剩下的交给它。
无论哪种方式,底层都是原型链那一套。掌握了原型,继承的七种姿势不过是排列组合。以后面试官再问"JS继承有哪些方式",你可以从容地把这七种娓娓道来,顺便告诉他:"但实际开发,我选ES6 class。"
明天我们将进入JavaScript的另一个核心领域------异步编程,从回调地狱到Promise,再到async/await,带你彻底理清JS的异步世界。
如果你觉得今天的文章对你有帮助,点个赞让更多人看到。有疑问评论区见,我们明天见!