继承做为基本功和面试必考点,必需要熟练掌握才行。小公司可能仅让你手写继承(通常写
寄生组合式继承
便可),大厂就得要求你全面分析各个继承的优缺点了。这篇文章深刻浅出,让你全面了解 JavaScript 继承及其优缺点,以在寒冬中立于不败之地。html
上一篇文章《从感性角度谈原型 / 原型链》介绍了什么是原型和原型链。咱们简单回忆一下构造函数、原型、原型链之间的关系:每一个构造函数有一个 prototype
属性,它指向原型对象,而原型对象都有一个指向构造函数的指针 constructor
,实例对象都包含指向原型对象的内部指针 [[prototype]]
。若是咱们让原型对象等于另外一个构造函数的实例,那么此原型对象就会包含一个指向另外一个原型的指针。这样一层一层,逐级向上,就造成了原型链。前端
根据上面的介绍,咱们能够写出 原型链继承
。git
function Vehicle(powerSource) {
this.powerSource = powerSource;
this.components = ['座椅', '轮子'];
}
Vehicle.prototype.run = function() {
console.log('running~');
};
function Car(wheelNumber) {
this.wheelNumber = wheelNumber;
}
Car.prototype.playMusic = function() {
console.log('sing~');
};
// 将父构造函数的实例赋值给子构造函数的原型
Car.prototype = new Vehicle();
const car1 = new Car(4);
复制代码
上面这个例子中,首先定义一个叫作 交通工具
的构造函数,它有两个属性分别是是 驱动方式
和 组成部分
,还有一个原型方法是 跑
;接下来定义叫作 汽车
的构造函数,它有 轮胎数量
属性和 播放音乐
方法。咱们将 Vehicle
的实例赋值给 Car
的原型,并建立一个名叫 car1
的实例。github
可是该方式有几个缺点:面试
多个实例对引用类型的操做会被篡改微信
子类型的原型上的 constructor 属性被重写了函数
给子类型原型添加属性和方法必须在替换原型以后工具
建立子类型实例时没法向父类型的构造函数传参post
从上图能够看出,父类的实例属性被添加到了实例的原型中,当原型的属性为引用类型时,就会形成数据篡改。ui
咱们新增一个实例叫作 car2
,并给 car2.components
追加一个新元素。打印 car1
,发现 car1.components
也发生了变化。这就是所谓多个实例对引用类型的操做会被篡改。
const car2 = new Car(8);
car2.components.push('灯具');
car2.components; // ['座椅', '轮子', '灯具']
car1.components; // ['座椅', '轮子', '灯具']
复制代码
该方式致使 Car.prototype.constructor
被重写,它指向的是 Vehicle
而非 Car
。所以你须要手动将 Car.prototype.constructor
指回 Car
。
Car.prototype = new Vehicle();
Car.prototype.constructor === Vehicle; // true
// 重写 Car.prototype 中的 constructor 属性,指向本身的构造函数 Car
Car.prototype.constructor = Car;
复制代码
由于 Car.prototype = new Vehicle();
重写了 Car 的原型对象,因此致使 playMusic
方法被覆盖掉了,所以给子类添加原型方法必须在替换原型以后。
function Car(wheelNumber) {
this.wheelNumber = wheelNumber;
}
Car.prototype = new Vehicle();
// 给子类添加原型方法必须在替换原型以后
Car.prototype.playMusic = function() {
console.log('sing~');
};
复制代码
显然,建立 car
实例时没法向父类的构造函数传参,也就是没法初始化 powerSource
属性。
const car = new Car(4);
// 只能建立实例以后再修改父类的属性
car.powerSource = '汽油';
复制代码
该方法又叫 伪造对象
或 经典继承
。它的实质是 在建立子类实例时调用父类的构造函数
。
function Vehicle(powerSource) {
this.powerSource = powerSource;
this.components = ['座椅', '轮子'];
}
Vehicle.prototype.run = function() {
console.log('running~');
};
function Car(wheelNumber) {
this.wheelNumber = wheelNumber;
// 继承父类属性而且能够传参
Vehicle.call(this, '汽油');
}
Car.prototype.playMusic = function() {
console.log('sing~');
};
const car = new Car(4);
复制代码
使用经典继承的好处是能够给父类传参,而且该方法不会重写子类的原型,故也不会损坏子类的原型方法。此外,因为每一个实例都会将父类中的属性复制一份,因此也不会发生多个实例篡改引用类型的问题(由于父类的实例属性不在原型中了)。
然而缺点也是显而易见的,咱们丝毫找不到 run
方法的影子,这是由于该方式只能继承父类的实例属性和方法,不能继承原型上的属性和方法。
回忆上一篇文章讲到的构造函数,为了将公有方法放到全部实例都能访问到的地方,咱们通常将它们放到构造函数的原型中。而若是让 借用构造函数继承
运做下去,显然须要将 公有方法
写在构造函数里而非其原型,这在建立多个实例时势必形成浪费。
组合继承吸取上面两种方式的优势,它使用原型链实现对原型方法的继承,并借用构造函数来实现对实例属性的继承。
function Vehicle(powerSource) {
this.powerSource = powerSource;
this.components = ['座椅', '轮子'];
}
Vehicle.prototype.run = function() {
console.log('running~');
};
function Car(wheelNumber) {
this.wheelNumber = wheelNumber;
Vehicle.call(this, '汽油'); // 第二次调用父类
}
Car.prototype = new Vehicle(); // 第一次调用父类
// 修正构造函数的指向
Car.prototype.constructor = Car;
Car.prototype.playMusic = function() {
console.log('sing~');
};
const car = new Car(4);
复制代码
虽然该方式可以成功继承到父类的属性和方法,但它却调用了两次父类。第一次调用父类的构造函数时,Car.prototype
会获得 powerSource
和 components
两个属性;当调用 Car
构造函数生成实例时,又会调用一次 Vehicle
构造函数,此时会在这个实例上建立 powerSource
和 components
。根据原型链的规则,实例上的这两个属性会屏蔽原型链上的两个同名属性。
该方式经过借助原型,基于已有对象建立新的对象。
首先建立一个名为 object
的函数,而后在里面中建立一个空的函数 F
,并将该函数的 prototype
指向传入的对象,最后返回该函数的实例。本质来说,object()
对传入的对象作了一次 浅拷贝
。
function object(proto) {
function F() {}
F.prototype = proto;
return new F();
}
const cat = {
name: 'Lolita',
friends: ['Yancey', 'Sayaka', 'Mitsuha'],
say() {
console.log(this.name);
},
};
const cat1 = object(cat);
复制代码
虽然这种方式很简洁,但仍然有一些问题。由于 原型式继承
至关于 浅拷贝
,因此会致使 引用类型
被多个实例篡改。下面这个例子中,咱们给 cat1.friends
追加一个元素,却致使 cat.friends
被篡改了。
cat1.friends.push('Hachi');
cat.friends; // ['Yancey', 'Sayaka', 'Mitsuha', 'Hachi']
复制代码
若是你读过 Object.create()
的 polyfill,应该不会对上面的代码感到陌生。该方法规范了原型式继承,它接收两个参数:第一个参数传入用做新对象原型的对象,第二个参数传入属性描述符对象或 null。关于此 API 的详细文档能够点击 Object.create() | JavaScript API 全解析
const cat = {
name: 'Lolita',
friends: ['Yancey', 'Sayaka', 'Mitsuha'],
say() {
console.log(this.name);
},
};
const cat1 = Object.create(cat, {
name: {
value: 'Kitty',
writable: false,
enumerable: true,
configurable: false,
},
friends: {
get() {
return ['alone'];
},
},
});
复制代码
该方式建立一个仅用于封装继承过程的函数,该函数在内部以某种方式来加强对象,最后再像真的是它作了全部工做同样返回对象。
const cat = {
name: 'Lolita',
friends: ['Yancey', 'Sayaka', 'Mitsuha'],
say() {
console.log(this.name);
},
};
function createAnother(original) {
const clone = Object.create(original); // 获取源对象的副本
clone.gender = 'female';
clone.fly = function() {
// 加强这个对象
console.log('I can fly.');
};
return clone; // 返回这个对象
}
const cat1 = createAnother(cat);
复制代码
和 原型式继承
同样,该方式会致使 引用类型
被多个实例篡改,此外,fly
方法存在于 实例
而非 原型
中,所以 函数复用
无从谈起。
上面咱们谈到了 组合继承
,它的缺点是会调用两次父类,所以父类的实例属性会在子类的实例和其原型上各自建立一份,这会致使实例属性屏蔽原型链上的同名属性。
好在咱们有 寄生组合式继承
,它本质上是经过 寄生式继承
来继承父类的原型,而后再将结果指定给子类的原型。这能够说是在 ES6 以前最好的继承方式了,面试写它没跑了。
function inheritPrototype(child, parent) {
const prototype = Object.create(parent.prototype); // 建立父类原型的副本
prototype.constructor = child; // 将副本的构造函数指向子类
child.prototype = prototype; // 将该副本赋值给子类的原型
}
复制代码
而后咱们尝试写一个例子。
function Vehicle(powerSource) {
this.powerSource = powerSource;
this.components = ['座椅', '轮子'];
}
Vehicle.prototype.run = function() {
console.log('running~');
};
function Car(wheelNumber) {
this.wheelNumber = wheelNumber;
Vehicle.call(this, '汽油');
}
inheritPrototype(Car, Vehicle);
Car.prototype.playMusic = function() {
console.log('sing~');
};
复制代码
看上面这张图就知道为何这是最好的方法了。它只调用了一次父类,所以避免了在子类的原型上建立多余的属性,而且原型链结构还能保持不变。
硬要说缺点的话,给子类型原型添加属性和方法仍要放在 inheritPrototype
函数以后。
功利主义来说,在 ES6 新增 class 语法以后,上述几种方法已沦为面试专用。固然 class 仅仅是一个语法糖,它的核心思想仍然是 寄生组合式继承
,下面咱们看一看怎样用 ES6 的语法实现一个继承。
class Vehicle {
constructor(powerSource) {
// 用 Object.assign() 会更加简洁
Object.assign(
this,
{ powerSource, components: ['座椅', '轮子'] },
// 固然你彻底能够用传统的方式
// this.powerSource = powerSource;
// this.components = ['座椅', '轮子'];
);
}
run() {
console.log('running~');
}
}
class Car extends Vehicle {
constructor(powerSource, wheelNumber) {
// 只有 super 方法才能调用父类实例
super(powerSource, wheelNumber);
this.wheelNumber = wheelNumber;
}
playMusic() {
console.log('sing~');
}
}
const car = new Car('核动力', 3);
复制代码
下面代码是继承的 polyfill,思路和 寄生组合式继承
一致。
function _inherits(subType, superType) {
// 建立对象,建立父类原型的一个副本
subType.prototype = Object.create(superType && superType.prototype, {
// 加强对象,弥补因重写原型而失去的默认的constructor 属性
constructor: {
value: subType,
enumerable: false,
writable: true,
configurable: true,
},
});
if (superType) {
// 指定对象,将新建立的对象赋值给子类的原型
Object.setPrototypeOf
? Object.setPrototypeOf(subType, superType)
: (subType.__proto__ = superType);
}
}
复制代码
ES5 是用构造函数建立类,所以会发生 函数提高
;而 class 相似于 let 和 const,所以不可以先建立实例,再声明类,不然直接报错。
// Uncaught ReferenceError: Rectangle is not defined
let p = new Rectangle();
class Rectangle {}
复制代码
ES5 的继承实质上是先建立子类的实例对象,而后再将父类的方法添加到 this 上,即 Parent.call(this)
.
ES6 的继承有所不一样,实质上是先建立父类的实例对象 this,而后再用子类的构造函数修改 this。由于子类没有本身的 this 对象,因此必须先调用父类的 super()方法,不然新建实例报错。
欢迎关注个人微信公众号:进击的前端
《JavaScript 高级程序设计 (第三版)》 —— Nicholas C. Zakas