【JavaScript】类class

学习链接

现代 JavaScript 教程:类

ES6 标准入门:Class 的基本语法

ES6 标准入门:Class 的继承


Class

在 JavaScript 中,类是一种函数。

class MyClass {
    prop = value; // 属性

    constructor(...) { // 构造器
        // ...
    }

    method(...) {} // method

    get something(...) {} // getter 方法
    set something(...) {} // setter 方法

    [Symbol.iterator]() {} // 有计算名称(computed name)的方法(此处为 symbol)
    // ...
}

// MyClass 是一个函数
alert(typeof MyClass); // function

// 更确切地说,是 constructor 方法
alert(MyClass === MyClass.prototype.constructor); // true
  • 技术上来说,MyClass 是一个函数(即 constructor 方法,若不编写则假定为空)

  • 而 methods、getters 和 settors 都被写入了 MyClass.prototype

  • 类字段(即属性)会在实例中被设定,而不非 MyClass.prototype


不仅仅是语法糖

class 通常被视为一种定义构造器及其原型方法的语法糖。

事实上,它们之间存在着重大差异:

  1. 首先,通过 class 创建的函数具有特殊的内部属性标记 [[IsClassConstructor]]: true。因此,它与手动创建并不完全相同。

    编程语言会在许多地方检查该属性。例如,与普通函数不同,必须使用 new 来调用它。

    Table 33: Internal Slots of ECMAScript Function Objects

    Internal SlotTypeDescription
    [[IsClassConstructor]]a BooleanIndicates whether the function is a class constructor. (If true, invoking the function’s [[Call]] will immediately throw a TypeError exception.)
  2. 类方法不可枚举。 类定义将 "prototype" 中的所有方法的 enumerable 标志设置为 false

    这很好,因为如果我们对一个对象调用 for..in 方法,我们通常不希望 class 方法出现。

  3. 类总是使用 use strict。 在类构造中的所有代码都将自动进入严格模式。

  4. 类不存在变量提升(hoist),这一点与 ES5 完全不同。

    new Foo(); // ReferenceError
    class Foo {}
    

    因为 ES6 不会把类的声明提升到代码头部。这种规定的原因与继承有关,必须保证子类在父类之后定义。

  5. 默认有两条继承链,即构造函数的继承和原型对象的继承。

  6. 派生类的 new 行为不同,即 [[ConstructorKind]]:"derived" 的构造函数

    1. ES5 中的继承,实例在前,继承在后

      先创建子类实例,后继承父类特性,添加自身特性

    2. ES6 中的继承,继承在前,实例在后

      先创建父类实例,再添加子类特性


this 的指向

优雅解法

类的方法内部如果含有this,它默认指向类的实例。但是,必须非常小心,一旦单独使用该方法,很可能报错。

class Button {
    constructor(value) {
        this.value = value;
    }

    click() {
        console.log(this); // Window
        console.log(this.value); // undefined
    }
}

const button = new Button("hello");

setTimeout(button.click, 0);
解决方案
  1. 传递一个包装函数,例如 setTimeout(() => button.click(), 1000)

  2. 将方法绑定到对象,例如在 constructorthis.click = this.click.bind(this)

  3. 使用类字段搭配**箭头函数**,此时的 this 指向实例对象。

    click = () => {
        console.log(this); // button
        console.log(this.value); // hello
    }
    

类继承 extends

image-20220726141315669

  • 子类的 [[Prototype]] 属性指向父类,表示构造函数的继承。

  • 子类 prototype 属性的 [[Prototype]] 属性指向父类 prototype 属性,表示原型对象的继承(方法的继承)。


super 关键字

必须显式指定作为函数还是对象使用。

作为函数使用

  • 代表父类构造函数,返回子类实例

  • super() 内部的 this 指向子类实例

  • super() 只能用在子类的构造函数中

let x = null;

class A {
    constructor() { x = this; }
}

class B extends A {
    constructor() { super(); }
}

const instance = new B();

instance === x // true

继承类的 constructor 必须调用 super(...),并且 (!) 一定要在使用 this 之前调用。

在 JavaScript 中,继承类(所谓的“派生构造器”,英文为 “derived constructor”)的构造函数与其他函数之间是有区别的。

派生构造器具有特殊的内部属性 [[ConstructorKind]]:"derived"。这是一个特殊的内部标签。

该标签会影响它的 new 行为:

  • 当通过 new 执行一个常规函数时,它将创建一个空对象,并将这个空对象赋值给 this
  • 但是当继承类的 constructor 执行时,它不会执行此操作。它期望父类的 constructor 来完成这项工作

因此,派生的 constructor 必须调用 super 才能执行其父类(base)的 constructor,否则 this 指向的那个对象将不会被创建。并且我们会收到一个报错。


类字段的初始化顺序:

  • 对于基类(还未继承任何东西的那种),在构造函数调用前初始化
  • 对于派生类,在 super() 后立刻初始化

也就是说,可以对派生类的 new 的行为步骤做如下的理解:

  • 先创建(new)一个父类实例,而不是空对象

  • 然后将这个实例[[Prototype]] 指向子类的 prototype 属性

  • 这个实例赋值给派生类构造函数的 this

  • 执行派生类构造函数的代码

  • 返回这个实例对象


这也就是下面的例子都输出 animal 的原因:

class Animal {
    name = 'animal';

    constructor() {
        alert(this.name); // (*)
    }
}

class Rabbit extends Animal {
    name = 'rabbit';
}

new Animal(); // animal
new Rabbit(); // animal, super() 会先 new Animal() (从表现出的行为来看)

作为对象使用

普通方法中
  • super 指向父类的 prototype父类的原型对象

  • 定义在父类实例上的方法或属性,无法通过 super 调用

  • super 调用父类方法时,方法内部的 this 指向当前的子类实例

    class A {
        constructor() { this.x = 1; }
        print() { console.log(this.x); }
    }
    
    class B extends A {
        constructor() { super(); this.x = 2; }
        m() { super.print(); }
    }
    
    let b = new B();
    b.m() // 2
    
  • 通过 super 对属性赋值,此时 super 就是 this,指向子类实例

    class A {
        constructor() { this.x = 1; }
    }
    
    class B extends A {
        constructor() {
            super();
            this.x = 2;
            super.x = 3; // 等价于 this.super = 3;
            console.log(super.x); // undefined (等价于获取 A.prototype.x)
            console.log(this.x); // 3
        }
    }
    
    let b = new B();
    

猜测

Object.prototype 上有一个最原始且特殊getter/setter,正常的对象定义属性的时候,都会沿着原型链去获取这个方法,然后设置和读取自身的属性,

这一操作可在定义时直接完成,例如 let obj = { a: 1}

也可在定义后额外添加属性,例如 obj.b = 2

但是,如果在自身或者原型链上的某个对象中,定义了对应名称的 getter/setter

则会拦截或者说劫持对象的设置和读取属性操作,

也就是说,阻止了对象去 Object.prototype 中获取对应的最原始且特殊getter/setter

仅作猜想,未经证实。


静态方法中

super 指向父类

super 调用父类方法时,方法内部的 this 指向当前子类

class A {
    constructor() { this.x = 1; }
    static print() { console.log(this.x); }
}

class B extends A {
    constructor() {
        super();
        this.x = 2;
    }
    static m() {
        super.print(); // this 指向 子类 B
    }
}

B.m() // 2

内建类没有静态方法继承

内建对象有它们自己的静态方法,例如 Object.keysArray.isArray 等。

如我们所知道的,原生的类互相扩展。例如,Array 扩展自 Object

通常,当一个类扩展另一个类时,静态方法和非静态方法都会被继承。

但内建类却是一个例外。它们相互间不继承静态方法。

例如,ArrayDate 都继承自 Object,所以它们的实例都有来自 Object.prototype 的方法。但 Array.[[Prototype]] 并不指向 Object,所以它们没有例如 Array.keys()(或 Date.keys())这些静态方法。

这里有一张 DateObject 的结构关系图:

image-20220727151429290

正如你所看到的,DateObject 之间没有连结。它们是独立的,只有 Date.prototype 继承自 Object.prototype,仅此而已。

与我们所了解的通过 extends 获得的继承相比,这是内建对象之间继承的一个重要区别。


补充

[[HomeObject]]

问题演示

首先要说的是,从我们迄今为止学到的知识来看,super 是不可能运行的。

当一个对象方法执行时,它会将当前对象作为 this。随后如果我们调用 super.method(),那么引擎需要从当前对象的原型中获取 method。但这是怎么做到的?

这个任务看起来是挺容易的,但其实并不简单。引擎知道当前对象的 this,所以它可以获取父 method 作为 this.__proto__.method。不幸的是,这个“天真”的解决方法是行不通的。

问题演示

  1. 确定或者说固定当前对象的 this

  2. 获取父类的原型中的方法,使其内部的 this 为第一步中调用者的 this

    1. 如此获取父类的原型中的方法 this.__proto__.method
    2. 如此使其内部的 this 称为第一步中调用者的 this this.__proto__.method.call(this)
  3. 事实上,这是存在漏洞的,

    但如果仅有父类和子类这两层的调用关系,并未将问题暴露出来

  4. 正如 问题演示 中的例子所示,当加到三层的时候,问题就暴露出来了

  5. 问题就出在 this.__proto__.method.call(this) 这一段代码中

    1. 使用第一个 this 的目的是通过它去获取父类的原型中的方法

    2. 使用第二个 this 的目的是为了传入调用者的所在的对象

    3. 两层调用的时候,第一个 this 和第二个 this 都是调用者本身

    4. 三层调用的时候

      1. 第一层的两个 this 都是调用者本身
      2. 注意,此时第二层的两个 this 也都成了第一层的调用者
      3. 第二层的第二个 this 的取值是我们所期望的,传递了第一层的调用者
      4. 然而,第二层的第一个 this 我们所期望的取值是第二层本身,而非第一层的调用者,因为我们原本需要通过它来获取到第三层信息,但现在被第一层传入的 this 给覆盖了,导致无法取到第三层的信息
    5. 或许我们会想到将示例中第二层的 eat() 方法删除,让第一层的调用者通过原型链去直接获取第三层的方法,也就是 this.__proto__.__proto__.method.call(this),这样一来就可以既获取到方法,又传递了自身的 this

      • 在这里我们不用对象简化表示,而是用类来还原原本的例子

        class Animal {
            name = 'Animal'
            eat() {
                alert(`${this.name} eats.`);
            }
        }
        
        class Rabbit extends Animal {
            name = 'Rabbit'
            eat() {
                super.eat();
            }
        }
        
        class LongEar extends Rabbit {
            name = 'Long Ear'
            eat() {
                super.eat();
            }
        }
        
        (new LongEar).eat(); // Long Ear eats.
        

        也就是说,我们单用 this 并没有能够模拟出 super 获取父类方法的效果,重点难以做到在于传入调用者的 this 的同时,也不丢失自身的 this 去用以获取父类的方法。


解决方案

为了提供解决方法,JavaScript 为函数添加了一个特殊的内部属性:[[HomeObject]]

当一个函数被定义为类或者对象方法时,它的 [[HomeObject]] 属性就成为了该对象。

然后 super 使用它来解析(resolve)父原型及其方法。


它基于 [[HomeObject]] 运行机制按照预期执行。一个方法,例如 longEar.eat,知道其 [[HomeObject]] 即为 longEar,并且从其原型中获取父方法。并没有使用 this


也就是说,利用 [[HomeObject]] 来取代了第一个 this 的作用,使其能够正确的获取父类原型中的方法,而不会覆盖第二个 this


红宝书:ES6 给类构造函数和静态方法添加了内部特性 [[HomeObject]],这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在 JavaScript 引擎内部访问。super 始终会定义为 [[HomeObject]] 的原型


注意

  • 在 JavaScript 语言中 [[HomeObject]] 仅被用于 super

  • [[HomeObject]] 是为类和普通对象中的方法定义的。但是对于对象而言,方法必须确切指定为 method(),而不是 "method: function()"

  • [[HomeObject]] 不能被更改,这个绑定是永久的,该方法对于对象的绑定是永久的,不同于 this

    let animal = {
        sayHi() {
            alert(`I'm an animal`);
        }
    };
    
    // rabbit 继承自 animal
    let rabbit = {
        __proto__: animal,
        sayHi() {
            super.sayHi();
        }
    };
    
    let plant = {
        sayHi() {
            alert("I'm a plant");
        }
    };
    
    // tree 继承自 plant
    let tree = {
        __proto__: plant,
        sayHi: rabbit.sayHi // (*) [[HomeObject]] 依旧指向 rabbit 而非 tree
    };
    
    tree.sayHi();  // I'm an animal
    
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值