学习链接
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 中的继承,继承在前,实例在后
先创建父类实例,再添加子类特性
-
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
指向当前的子类实例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.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
。-
在这里我们不用对象简化表示,而是用类来还原原本的例子
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