es6之class原理

一、概述

遥想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中类的继承包含三个任务:

  1. 通过父类的构造函数构造子类
  2. 继承父类的原型属性和方法
  3. 继承父类的静态属性和方法。

任务一,通过父类的构造函数构造子类。这是为了让子类继承父类的实例属性和方法,主要是通过借用构造函数(关于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 的继承

    • 2
      点赞
    • 6
      收藏
      觉得还不错? 一键收藏
    • 0
      评论

    “相关推荐”对你有帮助么?

    • 非常没帮助
    • 没帮助
    • 一般
    • 有帮助
    • 非常有帮助
    提交
    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值