从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门
往期回顾
前言
在本篇文章中,我们会继续探讨 JavaScript
中实现继承的方式。并聊一聊ES5、ES6所带来的新规范对于实现继承的影响。
阅读完上、下两篇文章后,我想你应该再也不会担心面试官让你现场手写一个 JavaScript
的继承了。如果可以的话,你还能当场给他画一个原型、构造函数、实例的关系图。
闲话就说到这,让我们开始今天的学习~
Object.create
在上一篇文章的末尾,聊【原型式】继承的时候,我们探讨了是否存在一种更便捷的写法,能够不需要写那么多行代码:
var TempCtor = function () {};
TempCtor.prototype = Car.prototype;
SportsUtilityVehicle.prototype = new TempCtor();
便捷的写法是存在的,那就是利用ES5推出的 Object.create()
。
Object.create()
静态方法以一个现有对象作为原型,创建一个新对象。
换句话说,ES5 通过新增 Object.create()
将【原型式】继承的概念规范化了。
入参
-
- 新创建对象的原型对象。
-
- 如果该参数被指定且不为
undefined
,则该传入对象可枚举的自有属性将为新创建的对象添加具有对应属性名称的属性描述符。这些属性对应于Object.defineProperties()
的第二个参数。
- 如果该参数被指定且不为
使用
接下来,我们就利用 Object.create()
来修改一下之前我们写的代码:
SportsUtilityVehicle.prototype = Object.create(Car.prototype);
然后我们看一下运行后的结果:
可以看到,没有任何问题,并且代码变得简洁了许多。
注意事项
在【入参】这一小节,我们介绍了 Object.create
所接收的2个参数。当我们在给第1个参数,也就是 proto
赋值时,要注意传值的类型,只能是 null
或者 对象类型,否则会抛出异常。
延伸面试题
如何创建一个真正空的空对象?
如果我们使用let a = {}
这样创建一个空对象,那么实际上它并不是真正的空,因为变量a
存在[[Prototype]]/__proto__
,指向它的原型。
此时,我们可以使用Object.create(null)
去创建一个真正空的空对象。
源码对比
在【注意事项】章节里,我特意提了一嘴关于第1个入参的使用限制。还没有聊第2个入参的使用建议。
这是因为我想把它留到这个章节里来说,因为 inherits-browser
的源码里也使用到了Object.create()
去实现继承,并且它还传入了一个对象作为第2个入参。
// implementation from standard node.js 'util' module
module.exports = function inherits(ctor, superCtor) {
if (superCtor) {
ctor.super_ = superCtor
ctor.prototype = Object.create(superCtor.prototype, {
constructor: {
value: ctor,
enumerable: false,
writable: true,
configurable: true
}
})
}
};
它的写法是不是很妙,或者说很优雅?
既然我们都使用了 Object.create
,为啥我们还要手动去多写一行:
SportsUtilityVehicle.prototype.constructor = SportsUtilityVehicle;
来解决默认 constructor
丢失的问题呢?
如果我们在面试的时候,面试官要求我们手写继承,我们还是用这种老套的写法,他肯定会追问我们
Object.create
的第二个参数有什么作用,为什么我们不用。
如果我们回答不出来,是不是就会被他怀疑我们是默写的继承代码,而没有去真的了解过代码的写法。
这显然不是我们所期望的,我们期望自己能够尽量做到无懈可击,不露出弱点。
所以一起来仔细地学一学 Object.create
的第2个入参吧~
我们回顾一下第2个参数的定义:
-
- 如果该参数被指定且不为
undefined
,则该传入对象可枚举的自有属性将为新创建的对象添加具有对应属性名称的属性描述符。这些属性对应于Object.defineProperties()
的第二个参数。
- 如果该参数被指定且不为
定义很长,我们提取一些关键词:
- 可枚举的自有属性
- 将为新创建的对象添加
Object.defineProperties()
不知道这些关键词大家熟悉多少?别担心,我们会一一介绍。
属性的类型
在聊什么是【可枚举的自有属性】之前,我们先来看看什么是属性的类型。
ECMA-262 使用一些内部特性来描述属性的特征。这些特性是由为
JavaScript
实现引擎的规范定义 的。因此,开发者不能在JavaScript
中直接访问这些特性。为了将某个特性标识为内部特性,规范会用 两个中括号把特性的名称括起来,比如[[Enumerable]]
。属性分两种:数据属性和访问器属性。
——《JavaScript高级程序设计》
【数据属性】顾名思义,就是保存数据值的属性。
具体来说,数据属性包含一个保存数据值的位置,这个位置既可以用于值的读取,也可用于值的写入。
目前存在4个特性,用于描述数据属性的行为:
-
[[Configurable]]
:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。它的取值是 true 或 false。
true
:可以通过 delete 删除并重新定义;可以修改它的特性;可以把它改为访问器属性。false
:不可以通过 delete 删除并重新定义;不可以修改它的特性;不可以把它改为访问器属性。
默认情况下,所有直接定义在对象上的属性的这个特性都是true,我们可以通过代码来看下实际情况:
由上图可以看到,直接定义在
a
上的name
属性可以被修改,被删除。 -
[[Enumerable]]
:表示属性是否可以通过for-in循环返回。它的取值是 true 或 false。
true
:可以通过for-in循环返回。false
:不可以通过for-in循环返回。
默认情况下,所有直接定义在对象上的属性的这个特性都是true,我们也可以通过代码来看下实际情况:
由上图可以看到,直接定义在
a
上的name
、age
、gender
属性均可以通过for-in循环返回。 -
[[Writable]]
:表示属性的值是否可以被修改。它的取值是 true 或 false。
true
:可以被修改。false
:不可以被修改。
默认情况下,所有直接定义在对象上的属性的这个特性都是true,我们当然可以通过代码来看下实际情况:
由上图可以看到,直接定义在
a
上的age
属性可以被修改。 -
[[Value]]
:包含属性实际的值。这就是我们先前聊到的那个读取和写入属性值的位置。这个特性的默认值为
undefined
。
终于,我们结合实际的代码例子以及特性的定义语言,看完了这四个特性,小憩一下,整理一下目前的思绪,我们再继续后续的扩展。
OK,休息结束,咱们继续聊。
在聊这4个特性的时候,我们一直在说默认情况是true、默认情况是true,那么有没有什么方式,能够让我们手动去修改这些特性的值呢?比如手动将 [[Writable]]
改为 false
。
有的,那就是 Object.defineProperties()
。
我们可以使用Object.defineProperties()
来修改属性的默认特性。
这个方法接收3个参数: 要给其添加属性的对象、属性的名称和一个描述符对象。
最后一个参数,即描述符对象上的属性可以包含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。
根据要修改的特性,可以设置其中一个或多个值。
我们通过一个代码例子直观地看下它的使用:
let a = {};
a.name = "海石";
Object.defineProperty(a, "age", {
value: 18,
writable: false,
configurable: false,
enumerable: false,
});
我们创建了一个对象 a
,并通过2种不同的方式给它设置了2个属性
- 默认方式设置
name
。 Object.defineProperty
设置age
。
👇然后我们来看看面对同样的操作行为,这2个属性的表现吧👇:
由上图可以看到,当我们手动将对象 a
的 age
属性的4个特性全部改为 false
后,我们对 age
属性做的删除、修改、遍历操作全部都失败了。
在了解完毕数据属性及其描述特性之后,我们再回过头来看看之前的源码:
ctor.prototype = Object.create(superCtor.prototype, {
constructor: {
value: ctor,
enumerable: false,
writable: true,
configurable: true,
},
});
现在我们是不是特别好理解 Object.create
函数所接收的第2个参数的作用了?
这个参数的作用就是给 Object.create
即将返回的新对象增加一个属性,并且能够显示地设置这个属性的4个特性。
通过SportsUtilityVehicle.prototype.constructor = SportsUtilityVehicle;
这样的方式设置的 constructor
是默认可枚举的。
而实际上的constructor
是不可枚举的属性。
(我们可以通过 Object.prototype.propertyIsEnumerable()
来判断一个属性是否可枚举)
请出我们的老朋友 Car
,来给我们做个演示。
因此我们也要保持constructor
是不可枚举的这一特点,所以我们应该再次修改一下之前写的【组合继承】的代码:
function Car(brand, engine, wheels) {
this.brand = brand;
this.engine = engine;
this.wheels = wheels;
}
Car.prototype.getBriefIntro = function () {
return "This is a " + this.brand + " car with " + this.engine + " engine.";
};
function SportsUtilityVehicle(brand, engine, wheels, type, size) {
Car.call(this, brand, engine, wheels);
this.type = type;
this.size = size;
}
SportsUtilityVehicle.prototype = Object.create(Car.prototype, {
constructor: {
value: SportsUtilityVehicle,
enumerable: false,
writable: true,
configurable: true,
},
});
var suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);
继承之【寄生】
还记得基于【原型链继承】实现的【组合继承】嘛?
其实在本文中,我们一直在说的【组合继承】,包括刚刚我们改版后的代码,实际上都是基于【原型式继承】实现的。
我知道,讲到这里大家可能会有点混淆了,其实我也觉得没必要这样死抠细节,只要给出最佳实践就可以了。
但是为了说明和解释清楚,所以在这里再唠叨一句:
【原型链继承】和【原型式继承】的区别就在于重写原型的方式。
- 原型链继承:
SportsUtilityVehicle.prototype = new Car();
- 原型式继承:
var TempCtor = function () {};
TempCtor.prototype = Car.prototype;
SportsUtilityVehicle.prototype = new TempCtor();
而如果我们使用基于【原型链继承】实现的【组合继承】,这会存在一个问题,那就是当我们创建一个子类的实例时,我们会调用两遍父类的构造函数。
因此当我们不期望调用多次父类的构造函数,并且还期望对继承后的对象做一些强化工作(比如创建一些子类原型上独有的函数方法),我们就可以使用【寄生继承】。
与原型式继承比较接近的一种继承方式是寄生式继承(parasitic inheritance),也是 Crockford首倡的 一种模式。寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种 方式增强对象,然后返回这个对象。
——《JavaScript高级程序设计》
它的写法和原型式继承非常的相似:
function createSUVProtptype() {
var TempCtor = function () {};
TempCtor.prototype = Car.prototype;
let clone = new TempCtor();
clone.getType = function () {
console.log("Hello, This is SUV.");
};
return clone;
}
我们可以看到,【寄生继承】本身实际上并不关注构造函数。
我们通过 createSUVProtptype()
方法返回一个被加强后的对象(加强是指这里给它新增了一个函数方法)作为 SUV
的原型。
上述演示的例子实际上并不纯粹是【寄生继承】,而是结合了【盗用构造函数】的【寄生组合继承】。
继承之【寄生组合】
不关注构造函数、类似工厂模式的【寄生继承】也缺失了类型标识(上一篇有提到有关类型标识的问题,可以回顾一下),这显然并不是一个完善的实现继承的方法。
于是就和【组合继承】一样,我们要使用【寄生组合继承】。
当我们阅读完这么多章节的内容后,不知道大家产生了多少想法?
一千位读者有一千位哈姆雷特。
就我个人而言,我觉得【寄生组合继承】就是 plus
版本的基于【原型式继承】实现的【组合继承】。
不知道各位又是什么见解呢?方便的话,欢迎在评论区交流~
因此如果面试官让我们写一个完善的【寄生组合继承】,我们用下方的最终版代码应该就可以了:
function Car(brand, engine, wheels) {
this.brand = brand;
this.engine = engine;
this.wheels = wheels;
}
Car.prototype.getBriefIntro = function () {
return "This is a " + this.brand + " car with " + this.engine + " engine.";
};
function SportsUtilityVehicle(brand, engine, wheels, type, size) {
Car.call(this, brand, engine, wheels);
this.type = type;
this.size = size;
}
SportsUtilityVehicle.prototype = Object.create(Car.prototype, {
constructor: {
value: SportsUtilityVehicle,
enumerable: false,
writable: true,
configurable: true,
},
});
var suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);
ES6之后的继承
经过上面内容的阅读,我们不难感受到,使用原型和原型链的方式去实现继承,哪怕是使用【寄生组合继承】,都要写非常长的一段代码,真的太累人了,🤦。
此时此刻,不禁在内心中呼唤,英雄在哪里,帮助我们脱离代码苦海的英雄何时现身?
回应我们的是ES6,它带着 class
关键字来拯救我们了!
如何用 class
关键字实现继承?
Talk is cheap, show me the code.
class Car {
constructor(brand, engine, wheels) {
this.brand = brand;
this.engine = engine;
this.wheels = wheels;
}
getBriefIntro() {
return `This is a ${this.brand} car with${this.engine} engine.`;
}
}
class SportsUtilityVehicle extends Car {
constructor(brand, engine, wheels, type, size) {
super(brand, engine, wheels);
this.type = type;
this.size = size;
}
}
const suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);
告诉我看完上面代码的第一感受是什么?
少,没错,就是少!
使用 class
关键字来实现继承,字符数出现了显著的下降:
641 => 469
整整少了 27%
的代码量,太爽了~
当然, class
关键字也并不是随心所欲地使用的,要通过它来实现继承,需要遵循以下使用规范。
使用规范
使用 extends
关键字实现继承
如果我们期望子类继承父类,则我们需要使用extends
关键字。
class SportsUtilityVehicle extends Car {}
在子类的构造函数中使用 super
关键字
使用extends
关键字只是第一步,我们还必须注意,要在子类的构造函数中使用 super
关键字。
class SportsUtilityVehicle extends Car {
constructor(brand, engine, wheels, type, size) {
super(brand, engine, wheels);
this.type = type;
this.size = size;
}
}
使用 super
时,还有一些注意事项:
-
super
只能在派生类构造函数和静态方法中使用。 -
不能单独引用
super
关键字,要么用它调用构造函数,要么用它引用静态方法。 -
调用
super()
会调用父类构造函数,并将返回的实例赋值给this
。- 因此我们可以看到,当我们在
SportsUtilityVehicle
的构造函数内部调用super()
时,我们需要传递参数:brand, engine, wheels
,因为父类Car
的构造函数中需要这些参数。
- 因此我们可以看到,当我们在
-
如果没有定义类构造函数,在实例化派生类时会调用
super()
,而且会传入所有传给派生类的 参数假如我们没有在
SportsUtilityVehicle
的构造函数内部调用super()
,则会默认在实例化的时候调用super()
,此时通过实例化时传入的参数,会被传给super()
,这一点我们可以通过代码例子来看下:class Car { constructor(brand, engine, wheels) { this.brand = brand; this.engine = engine; this.wheels = wheels; } getBriefIntro() { return `This is a ${this.brand} car with${this.engine} engine.`; } } class SportsUtilityVehicle extends Car { constructor(brand, engine, wheels, type, size) { this.type = type; this.size = size; } } const suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);
上面这样的写法会报错,因为这和下一条规则有关呢
-
在类构造函数中,不能在调用
super()
之前引用this
。为了遵循这条规则,我们再修改一下代码:
class Car { constructor(brand, engine, wheels) { this.brand = brand; this.engine = engine; this.wheels = wheels; } getBriefIntro() { return `This is a ${this.brand} car with${this.engine} engine.`; } } class SportsUtilityVehicle extends Car { constructor(brand, engine, wheels, type, size) {} } const suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);
这次我们删干净了
SportsUtilityVehicle
的构造函数内部中的this
,再试一次还是报错,为啥?唉,其实这又和下一条规则有关
-
如果在派生类中显式定义了构造函数,则要么必须在其中调用
super()
,要么必须在其中返回 一个对象。得得,这次我们再在
SportsUtilityVehicle
的构造函数内部返回一个空对象。class Car { constructor(brand, engine, wheels) { this.brand = brand; this.engine = engine; this.wheels = wheels; } getBriefIntro() { return `This is a ${this.brand} car with${this.engine} engine.`; } } class SportsUtilityVehicle extends Car { constructor(brand, engine, wheels, type, size) { return {}; } } const suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);
然后我们运行代码看看:
报错是没有了,但是代码也没用了.....
因此我们还是要记住,一定要在子类的构造函数中调用
super()
哦。
结语
经过上、下两篇文章的阅读,我想我们对于 JavaScript
中的继承应该具备了充足的理论基础。但是这就和我之前写变量提升相关的博客一样:
对于继承,我们也需要整一篇【面试强化版的文章】,通过具体问题的磨练,来加强我们对于理论知识的掌握和理解。
当我们能够消化问题背后的原理时,我们回答问题时也就能淡定自若,口若悬河。
讲故事 和 背故事,在境界上可谓是天差地别。
期待与你在之后的【面试强化版文章】相遇~
转载自:https://juejin.cn/post/7417290543557263394