学习链接
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
通常被视为一种定义构造器及其原型方法的语法糖。
事实上,它们之间存在着重大差异:
首先,通过
class
创建的函数具有特殊的内部属性标记[[IsClassConstructor]]: true
。因此,它与手动创建并不完全相同。
编程语言会在许多地方检查该属性。例如,与普通函数不同,必须使用new
来调用它。
Table 33: Internal Slots of ECMAScript Function Objects
| Internal Slot | Type | Description | | —- | —- | —- | | [[IsClassConstructor]] | a Boolean | Indicates whether the function is a class constructor.
(If true, invoking the function’s [[Call]] will immediately throw a TypeError exception.) |类方法不可枚举。 类定义将
"prototype"
中的所有方法的enumerable
标志设置为false
。
这很好,因为如果我们对一个对象调用for..in
方法,我们通常不希望 class 方法出现。- 类总是使用
use strict
。 在类中的所有代码都将自动进入严格模式。 类不存在变量提升(hoist),这一点与 ES5 完全不同。
new Foo(); // ReferenceError
class Foo {}
因为 ES6 不会把类的声明提升到代码头部。这种规定的原因与继承有关,必须保证子类在父类之后定义。默认有两条继承链,即构造函数的继承和原型对象的继承。
- 派生类的
new
行为不同,即[[ConstructorKind]]:"derived"
的构造函数- ES5 中的继承,实例在前,继承在后
先创建子类实例,后继承父类特性,添加自身特性 - ES6 中的继承,继承在前,实例在后
先创建父类实例,再添加子类特性
- ES5 中的继承,实例在前,继承在后
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);
解决方案
- 传递一个包装函数,例如
setTimeout(() => button.click(), 1000)
。 - 将方法绑定到对象,例如在
constructor
中this.click = this.click.bind(this)
- 使用类字段搭配箭头函数,此时的
this
指向实例对象。click = () => {
console.log(this); // button
console.log(this.value); // hello
}
类继承 extends
- 子类的
[[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
指向当前的子类实例 ```javascript 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`,指向子类实例
```javascript
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.keys
,Array.isArray
等。
如我们所知道的,原生的类互相扩展。例如,Array
扩展自 Object
。
通常,当一个类扩展另一个类时,静态方法和非静态方法都会被继承。
但内建类却是一个例外。它们相互间不继承静态方法。
例如,Array
和 Date
都继承自 Object
,所以它们的实例都有来自 Object.prototype
的方法。但 Array.[[Prototype]]
并不指向 Object
,所以它们没有例如 Array.keys()
(或 Date.keys()
)这些静态方法。
这里有一张 Date
和 Object
的结构关系图:
正如你所看到的,Date
和 Object
之间没有连结。它们是独立的,只有 Date.prototype
继承自 Object.prototype
,仅此而已。
与我们所了解的通过 extends
获得的继承相比,这是内建对象之间继承的一个重要区别。
补充
[[HomeObject]]
问题演示
首先要说的是,从我们迄今为止学到的知识来看,super
是不可能运行的。
当一个对象方法执行时,它会将当前对象作为 this
。随后如果我们调用 super.method()
,那么引擎需要从当前对象的原型中获取 method
。但这是怎么做到的?
这个任务看起来是挺容易的,但其实并不简单。引擎知道当前对象的 this
,所以它可以获取父 method
作为 this.__proto__.method
。不幸的是,这个“天真”的解决方法是行不通的。
- 确定或者说固定当前对象的
this
- 获取父类的原型中的方法,使其内部的
this
为第一步中调用者的this
- 如此获取父类的原型中的方法
this.__proto__.method
, - 如此使其内部的
this
称为第一步中调用者的this
this.__proto__.method.call(this)
- 如此获取父类的原型中的方法
- 事实上,这是存在漏洞的,
但如果仅有父类和子类这两层的调用关系,并未将问题暴露出来 - 正如 问题演示 中的例子所示,当加到三层的时候,问题就暴露出来了
- 问题就出在
this.__proto__.method.call(this)
这一段代码中- 使用第一个
this
的目的是通过它去获取父类的原型中的方法 - 使用第二个
this
的目的是为了传入调用者的所在的对象 - 两层调用的时候,第一个
this
和第二个this
都是调用者本身 - 三层调用的时候
- 第一层的两个
this
都是调用者本身 - 注意,此时第二层的两个
this
也都成了第一层的调用者 - 第二层的第二个
this
的取值是我们所期望的,传递了第一层的调用者 - 然而,第二层的第一个
this
我们所期望的取值是第二层本身,而非第一层的调用者,因为我们原本需要通过它来获取到第三层信息,但现在被第一层传入的this
给覆盖了,导致无法取到第三层的信息
- 第一层的两个
- 或许我们会想到将示例中第二层的
eat()
方法删除,让第一层的调用者通过原型链去直接获取第三层的方法,也就是this.__proto__.__proto__.method.call(this)
,这样一来就可以既获取到方法,又传递了自身的this
。- 在这里我们不用对象简化表示,而是用类来还原原本的例子
``javascript 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.
<br />也就是说,我们单用 `this` 并没有能够模拟出 `super` 获取父类方法的效果,重点难以做到在于传入调用者的 `this` 的同时,也不丢失自身的 `this` 去用以获取父类的方法。
<a name="de842a6c-1"></a>
### 解决方案
为了提供解决方法,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`。
```javascript
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