ES6-Class

Class 的基本语法

ECMAScript 的原生构造函数大致有下面这些:

  • Boolean()
  • Number()
  • String()
  • Array()
  • Date()
  • Function()
  • RegExp()
  • Error()
  • Object()

ES6 的 class 可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,新的 class 写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class Point {
// 实例属性也可以定义在类的最顶层
x = 0;
y = 0;
// 一个类必须有 constructor 方法,如果没有显式定义,一个空的 constructor 方法会被默认添加
constructor(x, y) {
this.x = x;
this.y = y;
}
get prop() {
return 'getter';
}
set prop(value) {
console.log('setter: ' + value);
}
// 类的属性名可以采用表达式
toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}
// 等同于
// Point.prototype = {
// constructor() {},
// toString() {},
// };
// 类必须使用 new 调用
var point = new Point(2, 3);
point.prop = 123; // setter: 123
point.prop; // 'getter'
point.toString(); // (2,3)
// ES6 的类,完全可以看作构造函数的另一种写法
Point === Point.prototype.constructor // true
// 在类的实例上面调用方法,其实就是调用原型上的方法
point.constructor === Point.prototype.constructor // true
// prototype 对象的 constructor 属性,直接指向“类”的本身,与 ES5 是一致的
Point.prototype.constructor === Point // true
// 类的内部所有定义的方法,都是不可枚举的,与 ES5 不一致
Object.keys(Point.prototype) // []
Object.getOwnPropertyNames(Point.prototype)// ["constructor","toString"]
// 实例的属性除非显式定义在其本身(this对象上),否则都是定义在原型上(class上)
point.hasOwnProperty('x') // true
point.hasOwnProperty('y') // true
point.hasOwnProperty('toString') // false
point.__proto__.hasOwnProperty('toString') // true
// 类的所有实例共享一个原型对象
var p1 = new Point(2,3);
var p2 = new Point(3,2);
p1.__proto__ === p2.__proto__ // true
// 存值函数和取值函数是设置在属性的 Descriptor 对象上的
var descriptor = Object.getOwnPropertyDescriptor(
Point.prototype, "prop"
);
"get" in descriptor // true
"set" in descriptor // true

类的所有实例共享一个原型对象,这也意味着,可以通过实例的 __proto__ 属性为“类”添加方法。__proto__ 并不是语言本身的特性,这是各大厂商具体实现时添加的私有属性,虽然目前很多现代浏览器的 JS 引擎中都提供了这个私有属性,但依旧不建议在生产中使用该属性,避免对环境产生依赖。使用实例的 __proto__ 属性改写原型,必须相当谨慎,不推荐使用,因为这会改变“类”的原始定义,影响到所有实例。生产环境中,我们可以使用 Object.getPrototypeOf 方法来获取实例对象的原型,然后再来为原型添加方法/属性。

与函数一样,类也可以使用表达式的形式定义,如下。需要注意的是,这个类的名字是 Me,但是 Me 只在 Class 的内部可用,指代当前类。在 Class 外部,这个类只能用 MyClass 引用。如果类的内部没用到的话,可以省略 Me

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const MyClass = class Me {
getClassName() {
return Me.name;
}
};
let inst = new MyClass();
inst.getClassName() // Me
// 采用 Class 表达式,可以写出立即执行的 Class
let person = new class {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
}('张三');
person.sayName(); // "张三"

注意:

  • 类和模块的内部,默认就是严格模式,考虑到未来所有的代码,其实都是运行在模块之中,所以 ES6 实际上把整个语言升级到了严格模式
  • 类不存在变量提升(hoist),这一点与 ES5 完全不同
  • name 属性总是返回紧跟在 class 关键字后面的类名
  • 如果某个方法之前加上星号(*),就表示该方法是一个 Generator 函数
  • 类的方法内部如果含有 this,它默认指向类的实例。但是,一旦单独使用该方法,很可能报错,可以在构造方法中绑定 this 或者使用箭头函数来解决
1
2
3
4
5
6
7
8
9
10
11
12
13
class Logger {
printName(name = 'there') {
this.print(`Hello ${name}`);
}
print(text) {
console.log(text);
}
}
const logger = new Logger();
const { printName } = logger;
printName(); // TypeError: Cannot read property 'print' of undefined

如果在一个方法前,加上 static 关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Foo {
// 提案,静态属性
static myStaticProp = 42;
// 静态方法
static classMethod() {
return 'hello';
}
// 如果静态方法包含this关键字,这个this指的是类,而不是实例,静态方法可以与非静态方法重名
static bar() {
this.baz();
}
static baz() {
console.log('hello');
}
baz() {
console.log('world');
}
}
Foo.classMethod() // 'hello'
var foo = new Foo();
foo.classMethod() // TypeError: foo.classMethod is not a function
Foo.bar() // hello
// 父类的静态方法,可以被子类继承
class Bar extends Foo {
static classMethod() {
// 静态方法也是可以从 super 对象上调用的
return super.classMethod() + ', too';
}
}
Bar.bar() // 'hello'
Bar.classMethod() // "hello, too"
// 静态属性
Foo.prop = 1;
Foo.prop // 1

私有方法和私有属性, ES6 不提供,只能通过变通方法模拟实现。私有属性和私有方法前面,也可以加上 static 关键字,表示这是一个静态的私有属性或私有方法,只能在类的内部调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class IncreasingCounter {
// 在命名上加以区别
_count = 0;
// 提案
#count = 0;
get value() {
console.log('Getting the current value!');
return this.#count;
}
increment() {
this.#count++;
}
}
// 私有属性不限于从 this 引用,只要是在类的内部,实例也可以引用私有属性
class Foo {
#privateValue = 42;
static getPrivateValue(foo) {
return foo.#privateValue;
}
}
Foo.getPrivateValue(new Foo()); // 42

ES6 为 new 命令引入了一个 new.target 属性,该属性一般用在构造函数之中,返回 new 命令作用于的那个构造函数。如果构造函数不是通过 new 命令或 Reflect.construct() 调用的,new.target 会返回 undefined,因此这个属性可以用来确定构造函数是怎么调用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function Person(name) {
if (new.target === Person) {
this.name = name;
} else {
throw new Error('必须使用 new 命令生成实例');
}
}
var person = new Person('张三'); // 正确
var notAPerson = Person.call(person, '张三'); // 报错
// Class 内部调用 new.target,返回当前 Class
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
this.length = length;
this.width = width;
}
}
var obj = new Rectangle(3, 4); // 输出 true
// 子类继承父类时,new.target 会返回子类,利用这个特点,可以写出不能独立使用、必须继承后才能使用的类
class Square extends Rectangle {
constructor(length, width) {
super(length, width);
}
}
var obj = new Square(3); // 输出 false

Class 的继承

Class 可以通过 extends 关键字实现继承,这比 ES5 的通过修改原型链实现继承,要清晰和方便很多。父类的静态方法,也会被子类继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Point { /* ... */ }
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y); // 调用父类的 constructor(x, y)
this.color = color;
}
toString() {
return this.color + ' ' + super.toString(); // 调用父类的 toString()
}
}
let cp = new ColorPoint(25, 8, 'green');
cp instanceof ColorPoint // true
cp instanceof Point // true
// Object.getPrototypeOf 方法可以用来从子类上获取父类,可以使用这个方法判断,一个类是否继承了另一个类
Object.getPrototypeOf(ColorPoint) === Point // true

子类必须在 constructor 方法中调用 super 方法,否则新建实例时会报错。这是因为子类自己的 this 对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用 super 方法,子类就得不到 this 对象。ES5 的继承,实质是先创造子类的实例对象 this,然后再将父类的方法添加到 this 上面 (Parent.apply(this))。ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到 this 上面(所以必须先调用super 方法),然后再用子类的构造函数修改 this

如果子类没有定义 constructor 方法,这个方法会被默认添加,代码如下。也就是说,不管有没有显式定义,任何一个子类都有 constructor 方法。

1
2
3
4
5
6
7
8
9
10
class ColorPoint extends Point {
}
// 等同于
class ColorPoint extends Point {
constructor(...args) {
// 只有调用 super 之后,才可以使用 this 关键字
super(...args);
}
}

super 这个关键字,既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。使用 super 的时候,必须显式指定是作为函数、还是作为对象使用,否则会报错。第一种情况,super 作为函数调用时,代表父类的构造函数,super() 只能用在子类的构造函数之中。ES6 要求,子类的构造函数必须执行一次 super 函数,super 虽然代表了父类的构造函数,但是返回的是子类的实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A {
constructor() {
console.log(new.target.name);
}
}
class B extends A {
constructor() {
super();
}
}
new A() // A
new B() // B

第二种情况,super 作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。ES6 规定,在子类普通方法中通过 super 调用父类的方法时,方法内部的 this 指向当前的子类实例。由于 this 指向子类实例,所以如果通过 super 对某个属性赋值,这时 super 就是 this,赋值的属性会变成子类实例的属性。在子类的静态方法中通过 super 调用父类的方法时,方法内部的 this 指向当前的子类,而不是子类的实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class A {
constructor() {
this.x = 1;
}
print() {
console.log(this.x);
}
static myMethod(msg) {
console.log('static', msg);
}
myMethod(msg) {
console.log('instance', msg);
}
}
class B extends A {
constructor() {
super();
this.x = 2;
super.x = 3;
console.log(super.x); // undefined
console.log(this.x); // 3
}
m() {
super.print();
}
static myMethod(msg) {
super.myMethod(msg);
}
myMethod(msg) {
super.myMethod(msg);
}
}
let b = new B();
b.m() // 2
B.myMethod(1); // static 1
var b = new B();
b.myMethod(2); // instance 2

大多数浏览器的 ES5 实现之中,每一个对象都有 __proto__ 属性,指向对应的构造函数的 prototype 属性。Class 作为构造函数的语法糖,同时有 prototype 属性和 __proto__ 属性,因此同时存在两条继承链:

  • 子类的 __proto__ 属性,表示构造函数的继承,总是指向父类
  • 子类 prototype 属性的 __proto__ 属性,表示方法的继承,总是指向父类的 prototype 属性

这两条继承链,可以这样理解:作为一个对象,子类(B)的原型(__proto__ 属性)是父类(A);作为一个构造函数,子类(B)的原型对象(prototype 属性)是父类的原型对象(prototype 属性)的实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A {}
class B extends A {}
B.__proto__ === A // true
B.prototype.__proto__ === A.prototype // true
// B 的实例继承 A 的实例
Object.setPrototypeOf(B.prototype, A.prototype);
// 等同于
B.prototype.__proto__ = A.prototype;
// B 继承 A 的静态属性
Object.setPrototypeOf(B, A);
// 等同于
B.__proto__ = A;

extends 关键字后面可以跟多种类型的值,只要是一个有 prototype 属性的函数,就能被继承.由于函数都有 prototype 属性(除了 Function.prototype 函数),因此可以继承任意函数,还可以用来继承原生的构造函数。

1
2
3
4
5
6
7
8
9
10
11
// 子类继承 Object 类,A其实就是构造函数 Object 的复制,A的实例就是 Object 的实例
class A extends Object {}
A.__proto__ === Object // true
A.prototype.__proto__ === Object.prototype // true
// 不存在任何继承,A作为一个基类(即不存在任何继承),就是一个普通函数,所以直接继承 Function.prototype。但是,A调用后返回一个空对象(即Object实例),所以 A.prototype.__proto__ 指向构造函数(Object)的 prototype 属性
class A {}
A.__proto__ === Function.prototype // true
A.prototype.__proto__ === Object.prototype // true

子类实例的 __proto__ 属性的 __proto__ 属性,指向父类实例的 __proto__ 属性。也就是说,子类的原型的原型,是父类的原型。

1
2
3
4
5
var p1 = new Point(2, 3);
var p2 = new ColorPoint(2, 3, 'red');
p2.__proto__ === p1.__proto__ // false
p2.__proto__.__proto__ === p1.__proto__ // true

以前,这些原生构造函数是无法继承的,比如,不能自己定义一个 Array 的子类,因为这个类的行为与 Array 完全不一致,子类无法获得原生构造函数的内部属性,通过 Array.apply() 或者分配给原型对象都不行。原生构造函数会忽略 apply 方法传入的 this,也就是说,原生构造函数的 this 无法绑定,导致拿不到内部属性。ES6 允许继承原生构造函数定义子类,因为 ES6 是先新建父类的实例对象 this,然后再用子类的构造函数修饰 this,使得父类的所有行为都可以继承,这是 ES5 无法做到的。

注意,继承 Object 的子类,有一个行为差异。下面代码中,NewObj 继承了 Object,但是无法通过 super 方法向父类 Object 传参。这是因为 ES6 改变了 Object 构造函数的行为,一旦发现 Object 方法不是通过 new Object() 这种形式调用,ES6 规定 Object 构造函数会忽略参数。