目录
一、概述
遥想ES6刚刚问世时,不少曾经从事过后端开发的程序员都觉得眼前一亮,因为JavaScript这门“不成熟”的面向对象语言终于支持class(类)了,以为自己终于可以像写Java一样写前端代码了。结果仔细研究之后却大失所望,因为JavaScript的class,其实是基于原型链实现的,换句话说,它只是个语法糖。
但从另一个角度来说,原型链本就是JavaScript这门语言的实现基础,如果标准工作组真的舍弃原型链,采用传统面向对象语言的类机制,JavaScript是否还能有今日的影响力,反倒成了一个未知数。但有一点是肯定的,JavaScript将不会再像以前那样值得人们津津乐道了。
回过头来看class,尽管只算是一个语法糖,但它却是语言规范方面的一大成就,也对之前的继承进行了一定的增强。在过去的ES5版本中,我们共有六种实现继承的方式(感兴趣的请参考前文 js基础之六种继承方式),其中受到认可的两种继承方式为:组合继承和寄生组合式继承。而class比较接近寄生组合式继承的实现,并对其进行了一定的增强。
自此,JavaScript开发者终于不用再手动实现继承,并且这门语言也保留了它最重要的特色:原型链。
下面我们就一起来探究class的实现原理。
二、class的实现
1. 基本原理
下面是一个最简单的类的写法:
class Child{
}
这就是一个类,如果你继续写:
const c = new Child()
那么c
就是一个Child
的实例,只是它没有属于自己的属性和方法,只有继承自上层构造函数(如Object)的属性和方法。
如果你输出一下Child的类型,你可能会先大吃一惊,反思一下却觉得合情合理:
> typeof Child
< "function"
没错,我们所定义的“类”,其实就是个函数,准确地讲,是个构造函数(现在再看new Child()
语句,是不是豁然开朗?)。
当然了,class关键字定义的函数不是普通的构造函数。ES6标准规定,它只能被new关键字调用。也就是说,Child()
这样的语句会立即报错。
下面是一个拥有更多属性和方法的类(请注意,class中的各个方法之前既没有分号,也没有逗号,加了都会报错):
class Parent{
constructor(name, age){
this.name = name;
this.age = age;
}
getName(){
return this.name;
}
getAge(){
return this.age;
}
}
上面我们已经知道,class本质上是在定义构造函数。那么这里定义的三个方法与这个函数又是什么关系呢?下面的输出可以让你明白:
> Parent.prototype
< {constructor: ƒ, getName: ƒ, getAge: ƒ}
再一次豁然开朗!原来定义在class里的方法,全都被添加到了Parent的原型对象上了。也就是说,上面的Parent类似于以下的实现:
function Parent(name, age){
this.name = name;
this.age = age;
}
Parent.prototype = {
getName: function(){
return this.name;
},
getAge: function(){
return this.age;
}
}
在基于ES5的实现中,我们虽然没有在prototype上定义constructor方法,但是js引擎仍然会为我们生成默认的方法:Parent.prototype.constructor
,它的值正是构造函数本身(即Parent.prototype.constructor === Parent,这与ES6是一致的)。因此这两种写法几乎是完全等价的。
我们知道,在JavaScript中,函数本质上是个对象。因此我们可以像下面一样为函数直接添加属性和方法:
Parent.type = "people";
Parent.run = function(){...}
那么class是如何实现这类的属性和方法的呢?
这类属性和方法在ES6中被称为类的静态属性和静态方法(它只能通过类直接调用,无法通过实例访问,所以称为静态)。目前对于class来说,静态属性并未提供规范的写法,也就是说你仍然只能通过Parent.type = "people"
来为类定义静态属性。
对于静态方法,ES6给出了规范的写法,即在方法前加static关键字:
class Parent{
static run(){}
}
这就是在为Parent类定义静态方法run,这个方法只能通过Parent.run()
来调用,并且该方法内的this是Parent本身(请区别普通的原型方法,原型方法内的this指向的是构造出的实例对象)。
目前ES6不允许在class内定义静态属性(date:2020/1/12),不过有提案建议同样通过在属性前加static关键字来定义静态属性。
如果你对class的实现仍然不是很清楚,没关系,我们来看一张内存图:
我们可以把原型对象prototype理解为一个“共享池”。它容纳了所有类的实例所共享的属性和方法(在构造实例时,浏览器会默认为每个实例添加一个__proto__属性来指向这个“共享池”,这就是原型链)。而Parent类的静态属性和方法,则直接被添加到了Parent内部。
不知道你是否注意到,除却通过new Parent()
调用生成Parent实例,Parent本身和它所构造出的实例之间并没有直接联系。
有人可能会说,Parent.prototype是Parent的属性,而Parent实例的__proto__指向这个属性,那不就相当于指向Parent了吗?从概念上确实可以这么理解。但是别忘了,JavaScript中的变量保存的只是内存中的地址。
也就是说,Parent的prototype属性只是持有了内存中这个“共享池”的内存地址,同样的,Parent实例的__proto__属性也是持有该“共享池”的内存地址。我们总不能因为A和B都有一个属性指向同一块内存地址,就说A指向B吧(哪怕A的这个属性值是从B复制过来的)!
还有人会说,我们通过实例原型上的constructor就可以访问它的构造函数。这句话确实没错,但这是一种间接关系,我们说的是,两者没有直接关系。
为什么我们会谈到这一点呢?实际上我们想要阐述的是构造函数、实例和原型对象三者之间的关系。
当我们定义一个构造函数时(包括定义一个类),js引擎会在内存区中额外开辟一块内存,作为该构造函数所构造的所有实例的“共享池”使用,并将该“共享池”的地址保存在构造函数的prototype属性(实际上是构造函数的静态属性)上。随后,由该构造函数构造出的实例,都会默认得到一个属性__proto__(即使少数浏览器没有暴露出来,该属性也是存在的),它的值正是上述“共享池”的内存地址,是由构造函数的prototype属性赋值而来的。因此,所有的实例都可以访问该“共享池”。
理解上述关系对理解class的继承至关重要,如果你没有读懂,或者在之后阅读class的继承机制时遇到了困惑,请回头再来理解这段话。
讲到这里,我想class的实现原理已经很清楚了。以上述的Parent类为例,首先将class Parent{}
内声明的所有不带static的方法添加到Parent原型上,将带有static的方法添加为Parent的静态方法。在调用new Parent()
时,使用constructor方法来构造实例,构造出的实例默认拥有__proto__属性指向Parent的原型。
注意:通常来说,实例属性和方法应该在constructor内通过this来添加,不过ES6允许直接写在类的顶部:
class Parent{
name;
age = 24;
}
这种写法适合不需要初始化或者有默认初始值的实例属性,并且更加贴近于传统面向对象语言的类写法。另外,类可以不显式定义constructor方法,js引擎会默认为类添加一个空的constructor方法:constructor(){}
。此外比较特别的一点是,在class中定义的方法是不可枚举的,如使用Object.keys(Parent.prototype)不能输出Parent的原型方法,而使用ES5的写法则是可以的。
2. class语法规范
关于class的核心语法,我们上面已经简要介绍了,下面我们来进行一些补充。
(1). 取值函数(getter)和存值函数(setter)
class允许为实例属性定义取值和存值的拦截函数,当试图读取或修改属性值时,它们就会被调用:
class Parent{
constructor(name){
this.name = name;
}
get name(){
console.log("触发name属性的取值函数!");
return this.name;
}
set name(name){
console.log("触发name属性的存值函数!");
this.name = name;
}
}
const p = new Parent("Carter");
p.name; // => 触发name属性的取值函数!
// => Carter
p.name = "张三"; // => 触发name属性的存值函数!
这里的取值和存值函数其实是被定义到了name属性的Descriptor描述符对象上,也就是相当于:
Object.defineProperty(p, "name", {
get(){...},
set(name){...}
})
(2). 属性表达式
class的属性名允许使用变量,但需要使用中括号括起来,如:
let f = "getName";
class Parent{
[f](){...}
}
Parent.prototype.getName; // f
属性表达式的主要使用场景是使用Symbol类型作为属性名:
let symbol = new Symbol();
class Parent{
[symbol](){...}
}
// 或者
class Parent{
*[Symbol.iterator](){
... // 定义对象的遍历器
}
}
(3)class表达式
由于类本质上是一个构造函数,所以它也支持表达式,如:
const Par = class Parent{
static run(){console.log("run");}
do(){
return Parent.run(); // 只在class内部可以引用Parent
}
}
在使用表达式时需要特别注意的是,此时只有被赋值的变量是可以用new调用的,class后面的原始类名只能在类的内部使用,即:
const p = new Par(); // 正确
const p2 = new Parent(); // 报错,Parent is not a function
p.do(); // run// 因为do是class内部的方法,所以它可以访问Parent
如果类的内部没有引用Parent,那么它也可以省略:
const Par = class {
...
}
甚至可以写出立即执行的匿名类:
const p = class {
constructor(name){
this.name = name;
}
}("张三");
这个匿名类构造完变量p后将立即失效,因为它是匿名的,无法再次被访问。
(4). this的指向
又是JavaScript中一个老生常谈的问题。
理解了class的实现机制后,它的this指向也不再神秘,实际上只是与ES5遵循了相同的规则。
不带static的方法,是类的原型方法,内部的this指向实例对象。如果该方法没有被类的实例调用,那么this将指向调用者。需要声明的是,ES6规定,所有的模块环境和类的内部默认启用严格模式,而严格模式下是不存在全局对象的(即为undefined)。也就是说,类内部的方法不能像普通函数一样直接调用。如:
class Parent {
getName(){
return this.name;
}
}
const p = new Parent();
let getName = p.getName;
getName(); // 报错,Cannot read property 'name' of undefined
我们的变量getName指向Parent原型上的getName方法,但没有通过Parent实例调用,而是直接调用。由于在严格模式下,全局对象是undefined,所以this的值是undefined,自然也就无法访问this.name。
这也可以解释React中为什么需要在constructor中绑定this:
class MyComponent {
constructor(name){
this.name = name;
this.printName = this.printName.bind(this);
}
printName(){
console.log(this.name);
}
}
const btn = new MyComponent("button");
let print = btn.printName;
print(); // 正确输出“button”
在React中,经常需要将组件实例的方法传递到其他组件使用,为了保证这些方法总能正常访问当前组件实例,必须在constructor中将它们与当前实例绑定,即bind(this)。
关于class的私有属性和私有方法(只能在类的内部访问的属性和方法),由于目前暂无官方实现,所以这里不再详述。
三、class的继承
1. 继承的概念
在最初学习面向对象语言的时候,老师会跟我们说,类的实现有三大要点:封装、继承和多态。
由于在JavaScript中不存在函数签名,因此JavaScript无法实现传统意义上的多态(当然也没有必要,因为JavaScript的灵活性足以弥补这一点)。上一部分所讲的正是它的封装,而这一部分我们要讲的就是class的继承。
先简单介绍一下什么是继承吧。这是面向对象的语言将实体抽象为类的一个重要目的。
一个特定的类应该拥有某些固定的特征,比如 “车” 这个类有 “发送机”、“底盘”、“车身”这些属性,也有“启动”、“停止”、“加速”这些行为。前者称为 “车” 的属性,后者称为它的方法。
上述定义“车”这个类的过程就是在进行抽象,而抽象的目的是提取某类事物的公共特性。之所以要提取公共特性,是因为面向对象的语言所面临的业务场景通常较为复杂,难以厘清关系。将实体抽象为类,提取公共部分,并对相关的类建立联系,有助于开发者厘清业务逻辑,提升开发效率。
假如现在我们又定义了另一个类:“货车”。我们知道,“货车”是属于 “车” 的,因此它也应该具备“车”所拥有的那些属性和方法(如“发动机”、“底盘”等属性和“启动”等方法)。我们称“货车”是“车”的一个子类,用面向对象的说法就是,“货车”继承自“车”。
从语言实现来说,只要我们在定义“货车”时声明它继承自“车”,那么所有的“货车”实例都将自动获得我们为“车”抽象出来的属性和方法,而不用重复定义。如果子类某个属性或方法的值与父类不完全一样,则可以重新定义它,以覆盖父类的实现。这就是继承。比如:
class Vehicle { // “车”
engine;
start(){}
}
class Lorry extends Vehicle{ // “货车”,继承自“车”
}
const lorry = new Lorry(); // 生成一个“货车”实例
// 即使“货车”没有定义start方法,也可以调用继承自“车”的start方法
lorry.start();
2. 继承的基本原理
JavaScript中类的继承包含三个任务:
- 通过父类的构造函数构造子类
- 继承父类的原型属性和方法
- 继承父类的静态属性和方法。
任务一,通过父类的构造函数构造子类。这是为了让子类继承父类的实例属性和方法,主要是通过借用构造函数(关于js的继承方式中有介绍)实现的。它的基本原理是:
function Parent(name){
this.name = name;
}
function Child(name){
Parent.call(this, name); //借用构造函数
}
const child = new Child("张三");
child.name; // 张三
Child借用了Parent的构造函数,为自己构造出了name属性,这也是继承的一种体现。
任务二,继承父类的原型属性和方法。这可以借助原型链来实现,类似于原型式继承。不过在class中,并不是直接让子类的实例继承父类的原型,而是让子类的原型继承父类的原型。它的实现大致如下:
function Parent(){}
function Child(){}
Object.setPrototypeOf(Child.prototype, Parent.prototype);
setPrototypeOf的实现大致为:
Object.setPrototypeOf = function (obj, proto) {
obj.__proto__ = proto;
return obj;
}
所以继承父类原型属性和方法的原理可以浓缩成下面的一行代码:
Child.prototype.__proto__ = Parent.prototype;
为什么只要这一行代码就可以继承父类的属性和方法了呢?
让我们来回顾一下原型链查找规则。当我们通过类似child.cry()
的形式调用某个方法时,js引擎首先会查找实例中是否存在该方法。如果不存在,会去它的原型上查找,也就是查找child.__proto__.cry()
。如果仍然找不到,就会去child.__proto__.__proto__
上查找,以此类推。
我们知道,child.__proto__ === Child.prototype
,进行一次等价替换,我们就可以得到child.__proto__.__proto__ === Child.prototype.__proto__
。也就是说,第二次查找实际上是在Child.prototype的__proto__属性上进行的。那么如果我们把它赋值为Parent.prototype,js引擎不就会去父类的原型上去查找该方法了吗?这就实现了原型方法的继承。
如果你觉得上述理论较为抽象,可以参考下图:
从查找链路(child --> Child.prototype --> Parent.prototype)可以明显看到class是如何继承父类的原型属性和方法的。所以借助这个图,你应该可以明白为什么那一行代码拥有这么大的威力了吧。
任务三,继承父类的静态属性和方法。我们想要实现彻底的继承,就必须把父类的静态属性和方法也继承到子类上,当然了,继承过来之后仍然是静态的,所以仍然不能通过实例来访问。
举个例子:
class Parent{
static run(){}
}
class Child extends Parent{
}
Child.run(); // 子类同样应该具有run这个静态方法
那么这又是怎么实现的呢?其实原理跟任务二类似,它只需要下面的一行代码:
Child.__proto__ = Parent;
写成规范的格式是:
Object.setProrotypeOf(Child, Parent);
似乎有点不可思议!我们只见过实例具有__proto__属性,没想到构造函数也可以添加__proto__属性。没错,当我们在这样做时,我们是基于一个我已经强调了很多次的理论:JavaScript中的函数也是对象。
现在让我们暂时忘了Child和Parent是个类,也忘了它们是构造函数,我们只记得它们都是对象。所以下面的图就很好理解了:
Parent和Child不过是两个具有prototype属性,可以用new关键字构造实例的对象而已,那么我们为Child添加__proto__属性又有什么不可以呢?
注意:严格来说这里并不是添加,而是修改。因为Child本身就有__proto__属性,只是原来指向Function.prototype,毕竟作为函数,它们都是Function的实例。
有人可能会迷惑,它们既是Function的实例,又是对象(也就是Object的实例),那么Object和Function又是什么关系呢?两者的关系是,Function继承自Object。实际上,Object是JavaScript继承关系树的根节点。
回到上述原理图,我们所谓的构造函数的静态方法,以一个对象的角度来看,其实就是它的实例方法而已。那么查找规则也很明朗了,当调用Child.run()
时,首先从Child上查找run方法,如果查找失败,会去Child.__proto__
上去查找。由于我们设置了Child.__proto__ === Parent
,因此此时实际上是在Parent上查找该方法,也就是查找Parent的静态方法。至此,我们就实现了静态方法的继承。静态属性也是同样的道理。
至此,继承的三个任务全部完成,我们用一个整合后的原理图来归纳一下:
这样就形成了两条继承链,一条是原型对象的继承链,子类实例可以通过这条继承链访问父类原型上的属性和方法;另一条是构造函数的继承链,子类可以通过它访问父类的静态属性和方法。三个任务总结出来就是三行代码:
// 借用构造函数
Parent.prototype.constructor.call(this, ...args);
//构建原型的继承链
Object.setPrototypeOf(Child.prototype, Parent.prototype);
//构造静态属性和方法的继承链
Object.setPrototypeOf(Child, Parent);
3. 继承的相关语法
(1). super关键字
ES6规定,super关键字只能在class中出现,用于引用当前类的父类。
根据super关键字所处的位置不同,super所代表的含义也不同。这与class的继承机制有关,因为根据上述讲解我们知道,子类原型上的方法只能继承自父类的原型,而子类的静态方法只能继承自父类本身。所以,在子类的原型方法中让super指向父类意义不大,还要通过super.prototype找到父类的原型对象,属于多此一举,而在静态方法中也是同样的道理。
所以,在子类的原型方法(也就是没有添加static的方法)内,super代指父类的原型对象;而在子类的静态方法内,super代指父类。
特别的是,在调用父类的构造方法时,不需要写super.constructor()
,而是直接写super()
即可。此外,ES6规定,父类的构造方法只能在子类的构造方法中调用,即super()
只能出现在子类的constructor方法中。
关于super()
还有一点必须特别注意,那就是只要子类定义了constructor,都必须在内部调用一次super()
,并且在调用该语句之前,不允许使用this。比如下面的例子都会报错:
class Parent{
constructor(name){
this.name = name;
}
}
// 报错,没有用super()初始化父类
class Child{
constructor(name){
this.name = name;
}
}
// 报错,在调用super之前不允许使用this
class Child{
constructor(name){ // 报错,没有用super()初始化父类
this.name = name;
super(name)
}
}
为什么会出现这样的情况呢?
因为ES6的class实现继承的机制是,先通过父类的构造函数构造this,然后再为this添加属性和方法。这也就意味着,如果没有显式调用super()
,子类构造函数中根本不存在this,直接调用this当然会报错。如果整个构造函数中都没有调用super()
,那么构造函数根本没有构造出任何实例,引擎当然也会报错。
这与ES5的继承机制是不同的。ES5中的继承是先构造一个空的子类实例(即初始化this),再将父类的属性和方法添加到这个空的实例上,最后添加子类自身的属性和方法,所以没有上述要求。
ES6为class规定的这种特殊机制,使继承原生构造函数成为了可能,下面我们来看。
(2). 原生构造函数的继承
上面说到,ES5中的继承是先构造子类实例,再借由父类的构造函数来为这个实例添加属性和方法。但这个机制对原生的构造函数是行不通的,因为原生构造函数不允许绑定this,也就是说,ES5的借用构造函数机制对原生构造函数不生效。如
function MyArray() {
Array.apply(this, arguments);
}
MyArray.prototype = Object.create(Array.prototype, {
constructor: {
value: MyArray,
writable: true,
configurable: true,
enumerable: true
}
});
我们本来是希望通过继承Array,构造一个自己的MyArray类,但是它却与原生的Array行为大相径庭。比如:
let arr = new MyArray();
arr[0] = 1;
arr.length; // 0
如果是原生的Array,arr.length应该输出1,而这里却输出0。
这是因为我们根本无法直接获取原生构造函数的内部属性和方法(注意,length不是内部属性,内部属性指的是引擎没有暴露给我们的属性)。也就是说,如果你已经有了一个对象,想通过绑定this往这个对象上添加原生构造函数的内部属性和方法是行不通的。而arr[0] = 1
这样的语句想要触发length变化,必须要这些内部属性和方法的支持。所以你可以看到,即使为arr[0]赋值了,length属性也并没有变化。即使通过apply将this显式绑定到Array,也无法获取内部属性和方法。
而ES6的class的继承机制可以解决这个问题。在ES6中,我们并不视图获取原生构造函数的内部属性和方法,而是直接通过原生构造函数构造一个对象出来,再向这个对象添加我们自己的属性和方法。比如下面的例子:
class MyArray extends Array {
constructor(...args) {
super(...args);
}
}
var arr = new MyArray();
arr[0] = 12;
arr.length // 1
我们看到,这个MyArray实现了Array的基本能力。因为当我们在调用super(arguments)
时,引擎是直接调用原生构造函数Array来构造的this。你甚至可以认为,此时的this就是一个数组实例,那么它具有数组的能力是理所应当的。待this构造完毕后,我们才在这个实例上添加自己的实例方法以及原型方法。这就实现了对原生构造函数的继承。
总结
本文偏向于class原理的介绍,对class的一些语法并没有进行细致的探讨,如果感兴趣,请参考阮一峰的 Class 的基本语法 以及 Class 的继承 。