从开源项目聊JavaScript中的继承(上篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门
前言
Modeler => BaseModeler => BaseViewer => Diagram
而在实现继承的方式上,它选择了比较完善的原型链继承(即寄生组合继承),并且在后续的版本迭代中,又将自己的写法替换为了inherits-browser
这个库所提供的 inherits
方法。
bpmn-js 是一个有着8.5k ⭐的开源项目,至今已经完成了800次的PR合并,目前版本的代码是比较成熟的,其中的一些写法是值得我们去借鉴和学习的。
而且很碰巧的是,JavaScript
的原型和原型链、继承等都是面试中比较热门的考题。如果我们在回答相关问题的时候,能将热门的开源项目作为相关知识点所应用的业务场景,我想一定能够给面试官留下较深刻的印象。
在本篇文章中,我们将探讨 JavaScript
中出现的几种实现继承的方式,并结合开源项目,聊一聊为什么它们这里的代码要这么写。
(比如:inherits-browser
到底好在哪里?为什么 bpmn-js
最终选择了它?)
最后,我们会讨论ES6+使用 class
关键字实现的继承。
观前提醒
💉:本文的重心在于探讨几种继承方式的写法,因此适用于对原型和原型链已经具备一定基础的同学。
原型与原型链
在我之前写的这两篇文章中:
我们系统性地学习了 JavaScript
中的变量提升,知道了变量的声明是在编译阶段完成的,而编译阶段又在执行阶段之前,因此会出现提升的现象。
在搞清楚变量提升之后,我们自然而然地又对 JavaScript
是如何管理变量的产生了好奇,于是又顺便学习了一下作用域相关的知识点。
JavaScript
中有这样一套“规章制度”,用来存储这些变量,并且之后可以方便地找到这些变量,这套“规章制度”被称为作用域。
如果引擎在当前作用域找不到目标变量,则会顺着作用域链往上找。
var a = 1;
function logA() {
console.log(a);
}
logA();
// Output: 1
上面这段代码就展示了这一过程,在调用 logA()
之后,引擎发现在 logA
的函数作用域中找不到变量 a
,于是便会顺着作用域链往上找,即 全局作用域。
最终,引擎在全局作用域中找到了变量 a
,并将其传给 console.log
,成功打印出 1
。
原型和原型链,其实与作用域和作用域链大差不差。
- 作用域和作用域链:引擎在当前作用域找不到目标变量,于是会顺着作用域链层层向上查找。
- 原型和原型链:引擎在当前对象中找不到目标属性,于是会顺着原型链层层向上查找。
继承
这里我们不从面向对象编程思想之类的较为宏大的叙事角度去聊继承,我们从业务开发的角度去谈,什么时候我们要用到继承?
针对这个问题我个人的回答是:为了复用。
-
高情商:基于现有功能,进行创新和增强;明确对象层级关系,增强代码的可读性;实现接口统一,提高代码的可维护性和扩展性;优化代码结构,提高开发效率。😎
-
低情商:想少写点代码。🥺
🤣🤣🤣
插科打诨就到这,让我们来看看在 JavaScript
中实现继承都有哪些方式。
和我之前写的源码分析的文章思路相同,在本篇文章中,我们照样是会先靠自己从0到1实现一个继承的写法,然后在去和成熟的开源项目做对比,看看我们的实现方式欠缺在什么地方,然后优化这些地方。
ES6之前的继承
在本章节,我们先暂时忘记那令人愉悦的ES6,给 class
关键字放个假。探寻如何使用原型和原型链去模拟继承的行为。
(ECMAScript 5.1并没有正式支持面向对象的结构,比如类或继承。)
创建对象
假设我们现在需要创建一个对象 Car
,它需要包含 brand
、engine
、wheels
这3个属性。我们会怎么做?
我想最简单、最直接的实现方式是这样:
var car = { brand: "Benz", engine: "V8", wheels: 4 };
这种实现方式虽然简单,但是当我们需要创建多个对象时,我们就需要写很多重复的代码了。
举个例子,假如要以这种实现方式去创建10个对象:
var car = { brand: "Benz", engine: "V8", wheels: 4 };
var car2 = { brand: "Toyota", engine: "V4", wheels: 4 };
var car3 = { brand: "Ford", engine: "V6", wheels: 4 };
var car4 = { brand: "Chevrolet", engine: "V4", wheels: 4 };
var car5 = { brand: "Honda", engine: "V6", wheels: 4 };
var car6 = { brand: "BMW", engine: "V8", wheels: 4 };
var car7 = { brand: "Audi", engine: "V8", wheels: 4 };
var car8 = { brand: "Volkswagen", engine: "V8", wheels: 4 };
var car9 = { brand: "Nissan", engine: "V8", wheels: 4 };
var car10 = { brand: "Hyundai", engine: "V8", wheels: 4 };
我们需要频繁地去写brand
、engine
、wheels
,以及大括号,逗号,冒号。
这很累人!!
此时,我们就可以通过使用设计模式中的工厂模式,来帮助我们解决创建多个对象时,要写很多重复代码的问题。
工厂模式:用于抽象创建特定对象的过程。
function createCar(brand, engine, wheels) {
let car = new Object();
car.brand = brand;
car.engine = engine;
car.wheels = wheels;
return car;
}
通过createCar
来帮助我们创建多个对象时,则代码如下:
var car = createCar("Benz", "V8", 4);
var car2 = createCar("Toyota", "V4", 4);
var car3 = createCar("Ford", "V8", 4);
var car4 = createCar("Chevrolet", "V6", 4);
var car5 = createCar("Honda", "V4", 4);
var car6 = createCar("BMW", "V6", 4);
var car7 = createCar("Audi", "V8", 4);
var car8 = createCar("Volkswagen", "V8", 4);
var car9 = createCar("Nissan", "V8", 4);
var car10 = createCar("Hyundai", "V8", 4);
我们可以直观地感受到,我们少写了非常多的重复代码,省力了不少。
字符数:576 => 416
但是这种比较简单的工厂模式存在一个问题,就是通过 createCar
返回的对象并没有一个明确的类型。
什么叫做没有一个明确的类型呢?
我们通过构造函数的形式去创建对象,然后直观地对比下,大家就清楚了。
function Car(brand, engine, wheels) {
this.brand = brand;
this.engine = engine;
this.wheels = wheels;
}
写好构造函数后,我们分别通过 createCar()
和 new Car()
的方式创建两个对象 car
和 car_alter
,然后打印一下结果:
var car = createCar("Benz", "V8", 4);
var car_alter = new Car("Benz", "V8", 4);
可以看到通过构造函数的方式创建的对象会有明确的对象类型的标识。
此时我们对 car_alter
使用 instanceof
的话,也可以明确地判断是否是 Car
的实例
继承之【盗用构造函数】
现在,我们有了一个构造函数 Car
,帮助我们快速地创建携带汽车基本信息的对象。
那么在之后的业务需求里,我们发现,我们又需要处理更具体的对象,比如汽车中的 SUV
车型。这种车型在 Car
的基础信息上,还多出了 type
、size
的属性。
我们可以很快地写一个构造函数,并通过它创建实例:
function SportsUtilityVehicle(brand, engine, wheels, type, size) {
this.brand = brand;
this.engine = engine;
this.wheels = wheels;
this.type = type;
this.size = size;
}
var suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);
这时候我们发现,我们的代码里有冗余的地方了,这三行:
this.brand = brand;
this.engine = engine;
this.wheels = wheels;
上面这三行在Car
这个构造函数中写过了,我现在想偷懒,不想重复去写这三行代码了,那么该怎么办?
那我们就可以使用 “盗用构造函数”的方式(《JavaScript 高级程序设计》这本书是这么描述这个方式的),在子类的构造函数中调用父类的构造函数:
function SportsUtilityVehicle(brand, engine, wheels, type, size) {
Car.call(this, brand, engine, wheels);
this.type = type;
this.size = size;
}
“盗用构造函数”的这种方式也是 bpmn-js
源码中频繁使用的方式:
通过盗用构造函数的继承方式,我们成功让子类继承了实例属性。但是假如 Car
上还具备一些函数方法呢?比如 Car
的原型上具备一个函数方法叫做 getBriefIntro()
,能够返回一串有关基本信息的简介文案。
Car.prototype.getBriefIntro = function () {
return "This is a " + this.brand + " car with " + this.engine + " engine.";
};
我们期望 SUV
也能够复用这个 getBriefIntro()
,而不是自己重新再写一遍。
这时候,仅仅使用盗用构造函数继承就不够了。我们无法让 SUV
的实例能够获取到 getBriefIntro()
方法。
这时候我们就要使用另一种继承方法了,原型链继承。
继承之【原型链】、【原型式】
在动手写原型链继承的代码前,我们先来回顾一下构造函数、原型、实例这三者之间的关系。
从上图可以看到,每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。
这样就在实例和原型之间构造了一条原型链。这也就是原型链的基本构想。
现在我们期望 SUV
的实例对象 suv
也能够访问 Car.prototype
上的 getBriefIntro
方法。
那我们要做的事情就是将 SUV
的原型改写,比如想想办法让 SUV
的原型也能参与到 Car
的原型链中,这样通过 SUV
创建的 suv
实例就能顺着原型链找到 getBriefIntro
方法了。
原型链继承
既然要使得 SUV
的原型也能参与到 Car
的原型链中,那这就意味着我们需要对SportsUtilityVehicle.prototype
动手。
我们可以将 Car
创建的实例赋值给 SportsUtilityVehicle.prototype
。
SportsUtilityVehicle.prototype = new Car();
这样,当我们通过 SUV
构造函数创建实例 suv
后,试图调用 suv.getBriefIntro()
时,引擎就会顺着原型链向上查找,最终找到 Car.prototype
上的 getBriefIntro
方法。
由上图所示,suv
实例能够成功地调用 getBriefIntro
方法了。
对于这行代码:
SportsUtilityVehicle.prototype = new Car();
我们可以通过画图来帮助理解~
原型式继承
2006 年,Douglas Crockford 写了一篇文章:《JavaScript中的原型式继承》(“Prototypal Inheritance in JavaScript”)。这篇文章介绍了一种不涉及严格意义上构造函数的继承方法。他的出发点是即使不自定义 类型也可以通过原型实现对象之间的信息共享。
——《JavaScript高级程序设计》
文章最终给出了一个函数:
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
这个 object()
函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返 回这个临时类型的一个实例。本质上,object()
是对传入的对象执行了一次浅复制。
我们可以采用这样的设计思路,去修改一下代码:
var TempCtor = function () {};
TempCtor.prototype = Car.prototype;
SportsUtilityVehicle.prototype = new TempCtor();
var suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);
suv.getBriefIntro();
然后我们再看一下运行后的结果:
由上图所示,suv
实例也能够成功地调用 getBriefIntro
方法了。
我明白,这一小节的内容不能就这样结束了,对于这段代码:
var TempCtor = function () {};
TempCtor.prototype = Car.prototype;
SportsUtilityVehicle.prototype = new TempCtor();
我们照样画个图来帮助理解~
从上图可以看到,当我们调用suv.getBriefIntro()
时,引擎会沿着原型链一层一层地往上查找,
suv => TempCtor的实例 => TempCtor.prototype => Car.prototype
最终在 Car.prototype
里找到了 getBriefIntro()
。
继承之【组合继承】
其实我们在聊【原型链】继承的时候,以当时的代码状态,其实就已经实现了【组合继承】了。没错,就是将【原型链】继承和【盗用构造函数】继承组合在一起。
不过不知道你是否发现了一个问题?
我们仔细地对比一下使用【组合继承】后的 SUV
的构造函数、实例、原型的关系:
和
Car
的构造函数、实例、原型的关系,
我们发现,当我们重写 SUV
的原型后,默认 constructor
丢失了!
suv
实例的 constructor
变成了 Car
?!😮
这显然是个需要修复的问题。
因此,当我们使用【组合继承】去重写原型时,一定要注意手动修复一下默认 constructor
丢失的问题。
多加一行代码即可:
SportsUtilityVehicle.prototype.constructor = SportsUtilityVehicle;
源码对比
我们这时候再看一下 bpmn-js
中的写法:
export default function Modeler(options) {
BaseModeler.call(this, options);
}
inherits(Modeler, BaseModeler);
看起来和【组合继承】的写法真的很像呢。
BaseModeler.call(this, options);
对应【盗用构造函数】inherits(Modeler, BaseModeler);
对应【原型链】/【原型式】继承
然后我们再去找 inherits
这个函数的源码,它来自 inherits-browser
这个第三方库,对比一下:
} else {
// old school shim for old browsers
module.exports = function inherits(ctor, superCtor) {
if (superCtor) {
ctor.super_ = superCtor
var TempCtor = function () {}
TempCtor.prototype = superCtor.prototype
ctor.prototype = new TempCtor()
ctor.prototype.constructor = ctor
}
}
}
可以看到,它使用的是【原型式】继承,并且注意到了重写原型后,默认 constructor
丢失的问题,并对此问题完成了解决。
结语
在本篇文章中,我们通过不断优化自己所写代码的方式学习了 JavaScript
中常见的几种继承写法。
而文中介绍的继承方式中,多多少少都还存在一些问题,似乎并不是最优雅的解决方案。
比如,我们一定要使用
var TempCtor = function () {}
来做为中转站嘛?这样要写好多行代码,有没有更方便的写法?答:有的,可以使用
Object.create()
更何况,我们还剩下了一些继承方式没有提及:
-
继承之【寄生继承】
-
继承之【寄生组合继承】
-
ES6之后的继承
为什么寄生组合继承会被认为是实现基于类型继承的最有效方式?
ES6+提供给我们的 class
关键字到底有多方便?
在下一篇文章中,我们会继续介绍剩余的内容,最终彻底掌握 JavaScript
的继承!
期待与你在下一篇文章相遇~
转载自:https://juejin.cn/post/7416902812250357812