ES6的“类”——Class

前端开发
2018年10月27日
358

Class基本语法

概述

JavaScript语言的传统方法是通过构造函数定义并生成新对象。下面是一个例子:

js
function Point (x, y) { this.x = x; this.y = y; } Point.prototype.toString = function () { return '(' + this.x + ', ' + this.y + ')'; }

上面的这种写法跟传统的面向对象语言(比如C++和Java)差异很大,很容易让新学习这门语言的程序员感到困惑。

ES6提供了更接近传统语言的写法,引入了Class(类)这个概念作为对象的模板。通过Class关键字可以定义类。基本上,ES6中的class可以看作只是一个语法糖,它的绝大部分功能,ES5都可以做到,新的class的写法只是让对象原型的写法更清晰,更像面向对象编程的语法而已。上面的代码用ES6的“类”改写,就是下面这样。

js
// 定义类 class point { constructor (x, y) { this.x = x; this.y = y; } toString () { return `(${this.x}, ${this.y})`; } }

上面的定义了一个“类”,可以看到里面有一个constructor方法,这就是构造方法,而this关键字则代表实例对象。也就是说,ES5的构造函数Point对应ES6的Point类的构造方法。

Point类除了构造方法,还定义了一个toString方法。注意,定义“类”的方法时,前面不需要加上function关键字,直接把函数定义放进去就可以了。另外,方法之间不需要逗号分隔,加了会报错。

ES6的类完全可以看作构造函数的另一种写法。

js
class Point { // ... } tyoeof Point // 'function' Point === Point.prototype.constructor // true

上面的代码表明,类的数据类型就是函数,类本身就指向构造函数

构造函数的prototype属性在ES6的“类”上继续存在。事实上,类的所有方法都定义在类的prototype属性上。

js
class Point { constructor () { // ... } toString () { // ... } toValue () { // ... } } // 等同于 Point.prototype = { constructor () {}, toString () {}, toValue () {} }

在类的实例上调用方法,其实就是调用原型上的方法。

js
class B {} let b = new B(); b.constructor === B.prototype.constructor // true

上面的代码中,b是B类的实例,它的constructor方法就是B类原型的constructor方法

由于类的方法(除constructor以外)都定义在prototype对象上,所以类的新方法可以添加在prototype对象上。Object.assign方法可以很方便地一次向类添加多个方法。

js
class Point { constructor () { // ... } } Object.assign(Point.prototype, { toString () {}, toValue () {} })

prototype对象constructor属性直接指向“类”本身,这与ES5的行为是一致的。

js
Point.prototype.constructor === Point // true

另外,类的内部定义的所有方法都是不可枚举的(nonenumerable)。

js
class Point { constructor (x, y) { // ... } toString () { // ... } } Object.keys(Point.prototype) // [] Object.getOwnPropertyNames(Point.prototype) // ['constructor', 'toString']

上面的代码中,toString方法Point类内部定义的方法,它是不可枚举的。这一点与ES5的行为不一致。

js
var Point = function (x, y) { // ... } Point.prototype.toString = function () { // ... } Object.keys(Point.prototype) // ['toString'] Object.getOwnPropertyNames(Point.prototype) // ['constructor', 'toString']

上面的代码采用了ES5的写法,toString方法就是可枚举的。

类的属性名可以采用表达式。

js
let methodName = 'getArea'; class Square { constructor (length) { // ... } [methodName] () { // ... } }

上面的代码中,Square类的方法名getArea是从表达式得到的。

constructor方法

constructor方法是类的默认方法,通过new命令生成对象实例时自动调用该方法。一个类必须有constructor方法,如果没有显示定义,一个空的constructor方法会被默认添加。

constructor方法默认返回实例对象(即this,不过完全可以指定返回另一个对象。

js
class Foo { constructor () { return Object.create(null); } } new Foo() instanceof Foo // false

上面的代码中,constructor方法返回一个全新的对象,结果导致实例对象不是Foo类的实例

实例对象

生成实例对象的写法与ES5完全一样,也是使用new命令如果不加上new,像函数那样调用Class将会报错

js
// 报错 var point = Point(2, 3); // 正确 var point = new Point(2, 3);

与ES5一样,实例的属性除非显示定义在其本身(即this对象)上,否则都是定义在原型(即Class)上。

js
// 定义类 class Point { constructor (x, y) { this.x = x; this.y = y; } toString () { return '(' + this.x + ', ' + this.y + ')'; } } var point = new Point(2, 3); point.toString() // (2, 3) point.hasOwnProperty('x') // true point.hasOwnProperty('y') // true point.hasOwnProperty('toString') // false point.__proto__.hasOwnProperty('toString') // true

上面的代码中,x和y都是实例对象point自身的属性(因为定义在this变量上),所以hasOwnProperty方法返回true,而toString是原型对象的属性(因为定义在Point类上),所以hasOwnProperty方法返回false。这些都是与ES5的行为保持一致。

与ES5一样,类的所有实例共享一个原型对象。

js
var p1 = new Point(2, 3); var p2 = new Point(3, 2); p1.__proto__ === p2.__proto__ // true

上面的代码中,p1和p2都是Point类的实例,它们的原型都是Point,所以__proto__属性是相等的。

这也意味着,可以通过实例的__proto__属性为Class添加方法。

js
var p1 = new Point(2, 3); var p2 = new Point(3, 2); p1.__proto__.printName = function () { return 'Oops'; } p1.printName() // 'Oops' p2.printName() // 'Oops' var p3 = new Point(4, 2); p3.printName() // 'Oops'

上面的代码在p1的原型上添加了一个printName方法,由于p1的原型就是p2的原型,因此p2也可以调用这个方法。而且,此后新建的实例p3也可以调用这个方法。

这意味着,使用实例的__proto__属性改写原型必须相当谨慎,不推荐使用,因为这会改变Class的原始定义,影响到所有实例。

name属性

由于本质上ES6的Class只是ES5的构造函数的一层包装,所以函数的许多特性都被Class继承,包括name属性

js
class Point {} Point.name // 'Point'

name属性总是返回紧跟在Class关键字后面的类名。

Class表达式

与函数一样,Class也可以使用表达式的形式定义。

js
const MyClass = class Me { getClassName () { return Me.name; } };

上面的代码使用表达式定义了一个类。需要注意的是,这个类的名字是MyClass而不是MeMe只在Class的内部代码可以,指代当前类。

js
let inst = new MyClass(); inst.getClassName() // Me Me.name // ReferenceError: Me is not defined

如果Class内部没有用到,那么可以省略Me,也就是可以写成下面的形式:

js
const MyClass = class { /* ... */ };

采用Class表达式,可以写出立即执行的Class

js
let person = new class { constructor (name) { this.name = name; } sayName () { console.log(this.name); } }('张三'); person.sayName() // '张三'

不存在变量提升

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

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

上面的代码中,Foo类使用在前,定义在后,这样会报错,因为ES6不会把变量声明提升到代码头部。这种规定与下文要提到的继承有关,必须保证子类在父类之后定义。

js
{ let Foo = class {}; class Bar extends Foo {} }

如果存在Class的提升,上面的代码将报错,因为let命令也是不提升的。

严格模式

类和模块的内部默认就是严格模式,所以不需要使用use strict指定运行模式。只要你的代码写在类或模块中,就只有严格模式可用。

考虑到未来所有的代码其实都运行在模块中,所以ES6实际上把整个语言升级到了严格模式。

Class的继承

基本用法

Class之间可以通过extends关键字实现继承,这比ES5通过修改原型链实现继承要清晰和方便很多。

js
class ColorPoint extends Point {}

上面的代码定义了一个ColorPoint类,该类通过extends关键字继续了Point类的所有属性和方法。但是由于没有部署任何代码,所以这两个类是完全一样的,等于复制了一个Point类。下面,我们在ColorPoint内部加上代码。

js
class ColorPoint extends Point { constructor (x, y, color) { super(x, y); // 调用父类的 constructor(x, y) this.color = color; } toString () { return this.color + ' ' + super.toString() // 调用父类的 toString() } }

上面的代码中,constructor方法toString方法中都出现了super关键字,它指代父类的实例(即父类的this对象)。

子类必须在constructor方法中调用super方法,否则新建实例时会报错。这是因为子类没有自己的this对象,而是继承了父类的this对象,然后对其进行加工。如果不调用super方法,子类就得不到this对象

js
class Point { /* ... */ } class ColorPoint extends Point { constructor () { } } let cp = new ColorPoint(); // ReferenceError

上面的代码中,ColorPoint类继承了父类Point,但是它的构造函数没有调用super方法,导致新建实例时报错。

ES5的继承实质上是先创造子类的实例对象this,然后再将父类的方法添加到this上(Parent.apply(this))。ES的继承机制完全不同,实质上是先创造父类的实例对象this(所以必须先调用super方法),然后再用子类的构造函数修改this

如果子类没有定义constructor方法,那么这个方法会被默认添加,代码如下。

js
constructor (...args) { super(...args); }

另一个需要注意的地方是,在子类的构造函数中,只有调用super之后,才可以使用this关键字,否则会报错。这是因为子类实例的构建基于对父类实例的加工,只有super方法才能返回父类实例。

js
class Point { constructor (x, y) { this.x = x; this.y = y; } } class ColorPoint extends Point { constructor (x, y, color) { this.color = color; // ReferenceError; super(x, y); this.color = color; // 正确 } }

类的prototype属性和__proto__属性

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

  1. 子类的__proto__属性表示构造函数的继承,总是指向父类。
  2. 子类的prototype属性__proto__属性表示方法的继承,总是指向父类的prototype属性
js
class A {} class B extends A {} B.__proto__ === A // true B.prototype.__proto__ === A.prototype // true

这样的结果是因为类的继承按照是按照下面的模式实现的:

js
class A {} class B {} // B的实例继承A的实例 Object.setPrototypeOf(B.prototype, A.prototype); // B继承A的静态属性 Object.setPrototypeOf(B, A);

之前给出过Object.setPrototypeOf方法的实现。

js
Object.setPrototypeOf = function (obj, proto) { obj.__proto__ = proto; return obj; }

因此,就得到了上面的结果。

js
Object.setPrototypeOf(B.prototype, A.prototype); // 等同于 B.prototype.__proto__ = A.prototype; Object.setPrototypeOf(B, A); // 等同于 B.__proto__ = A;

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

js
B.prototype = new A(); // 等同于 B.prototype.__proto__ = A.prototype;

extends的继承目标

extends关键字后面可以跟很多类型的什。

js
class B extends A {}

上面的A只要是一个有prototype属性的函数,就能被B继承。由于函数都有prototype属性,因此A可以是任意函数。

下面讨论3种特殊情况。

  • 第一种情况,子类继承Object类

    js
    class A extends Object {} A.__proto__ === Object // true A.prototype.__proto__ === Object.prototype // true

    这种情况下,A其实就是构造函数Object的复制,A的实例就是Object的实例。

  • 第二种情况,不存在任何继承。

    js
    class A {} A.__proto__ === Function.prototype // true A.prototype.__proto__ === Object.prototype// true

    这种情况下,A作为一个基类(即不存在任何继承)就是一个普通函数,所以直接继承Function.prototype。但是,A调用后返回一个空对象(即Object实例),所以A.prototype.__proto__指向构造函数(Object)的prototype属性

  • 第三种情况,子类继承null

    js
    class A extends null {} A.__proto__ === Function.prototype // true A.prototype.__proto__ === undefined // true

    这种情况与第二种情况非常相似。A也是一个普通函数,所以直接继承Function.prototype。但是A调用后返回的对象不继承任何方法,所以它的__proto__指向Function.prototype,即实质上执行了下面的代码。

    js
    class C extends null { constructor () { return Object.create(null); } }

Object.getPrototypeOf()

Object.getPrototypeOf方法可以用于从子类上获取父类。

js
Object.getPrototypeOf(ColorPoint) === Point // true

因此,可以使用这个方法判断一个类是否继承了另一个类。

super关键字

上面讲过,在子类中super关键字代表父类实例。

js
class B extends A { get m () { return this._p * super._p; } set m () { throw new Error('该属性只读'); } }

上面的代码中,子类通过super关键字调用了父类的实例。

由于对象总是继承其他对象,所以可以在任意一个对象中使用super关键字

js
var obj = { toString () { return 'MyObject: ' + super.toString(); } } obj.toString(); // MyObject: [object object]

实例的__proto__属性

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

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

因此,通过子类实例的__proto__.__proto__属性可以修改父类实例的行为。

原生构造函数的继承

原生构造函数是指语言内置的构造函数,通常用来生成数据结构。ECMAScript的原生构造函数大致有下面这些:

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

以前,这些原生构造函数是无法继承的。比如,不能自己定义一个Array的子类。

js
function MyArray () { Array.apply(this, arguments); } MyArray.prototype = Object.create(Array.prototype, { constructor: { value: MyArray, writable: true, configurable: true, enumerable: true } });

上面的代码定义了一个继承Array类MyArray类。但是,这个类的行为与Array类完全不致。

js
var colors = new MyArray(); colors[0] = 'red'; colors.length // 0 colors.length = 0; colors[0] // 'red'

之所以会发生这种情况,是因为子类无法获得原生构造函数的内部属性,通过Array.apply()或者分配给原型对象都不行。ES5是先新建子类的实例对象this,再将父类的属性添加到子类上,由于父类的内部属性无法获取,导致无法继承原生的构造函数。比如Array构造函数有一个内部属性[[DefineOwnProperty]],用于定义新属性时更新length属性,这个内部属性无法在子类获取,导致子类的length属性行为不正常。

ES6允许继承原生构造函数定义子类,因为ES6是先新建父类的实例对象this,然后再用子类的构造函数修饰this,这使得父类的所有行为都可以继承。下面是一个继承Array的例子:

js
class MyArray extends Array { constructor (...args) { super(...args); } } var arr = new MyArray(); arr[0] = 12; arr.length // 1 arr.length = 0 arr[0] // undefined

这意味着,ES6可以自定义原生数据结构(比如ArrayString等)的子类,这是ES5无法做到的。

上面的这个例子也说明,extends关键字不仅可用于继承类,还要用于继承原生的构造函数。因此可在原生结构的基础上定义自己的数据结构。下面定义了一个带版本功能的数组:

js
class VersionedArray extends Array { constructor () { super(); this.history = [[]]; } commit () { this.history.push(this.slice()); } revert () { this.splice(0, this.length, ...this.history[this.history.length - 1]); } } var x = new VersionedArray(); x.push(1); x.push(2); x // [1, 2] x.history // [[]] x.commit() x.history // [[], [1, 2]] x.push(3) x // [1, 2, 3] x.revert(); x // [1, 2]

上面的代码中,VersionedArray结构会通过commit方法将自己的当前状态存入history属性,然后通过revert方法可以撤销当前版本,回到上一个版本。除此之外,VersionedArray还是一个数组,所有原生的数组方法都可以在它上面调用。

下面是一个自定义Error子类的例子:

js
class ExtendableError extends Error { constructor (message) { super(); this.message = message; this.stack = (new Error()).stack; this.name = this.constructor.name; } } class MyError extends ExtendableError { constructor (m) { super(m) } } var myerror = new MyError('ll'); myerror.message // 'll' myerror instanceof Error // true myerror.name // 'MyError' myerror.stack // Error // at MyError.ExtendableError // ...

Class的取值函数(getter)和存值函数(setter)

与ES5一样,在Class内部可以使用getset关键字对某个属性设置取值函数存值函数,拦截该属性的存取行为。

js
class MyClass { constructor () { // ... } get prop () { return 'getter' } set prop (value) { console.log('setter: ' + value) } } let inst = new MyClass(); inst.prop = 123 // 'setter: 123' inst.prop // 'getter'

上面的代码中,prop属性有对应的取值函数存值函数,因此赋值和读取行为都被自定义了。存值函数取值函数是设置在属性的descriptor对象上的。

js
class CustomHTMLElement { constructor (element) { this.element = element; } get html () { return this.element.innerHTML; } set html (value) { this.element.innerHTML = value; } } var descriptor = Object.getOwnPropertyDescriptor(CustomHTMLElement.prototype, 'html'); "get" in descriptor // true "set" in descriptor // true

Class的Generator方法

如果在某个方法前加上星号(*),就表示该方法是一个Generator函数

js
class Foo { constructor (...args) { this.args = args; } * [Symbol.iterator] () { for (let arg of this.args) { yield arg; } } } for (let x of new Foo('hello', 'world')) { console.log(x) } // hello // world

上面的代码中,Foo类的Symbol.iterator方法前面有一个星号,表示该方法是一个Generator函数Symbol.iterator方法返回一个Foo类的默认值遍历器,for...of循环会自动调用这个遍历器。

Class的静态方法

类相当于实例的原型,所有在类中定义的方法都会被实例继承。如果 在一个方法前加上static关键字,就表示该方法不会被实例继承,而是直接通过类调用,称为“静态方法”。

js
class Foo { static classMethod () { return 'hello' } } Foo.classMethod() // 'hello' var foo = new Foo(); foo.classMethod() // TypeError: undefined is not a function

上面的代码中,Foo类的classMethod方法前面有static关键字,表明该方法是一个静态方法,可以直接在Foo类上调用(Foo.classMethod()),而不是在Foo类的实例上调用。

父类的静态方法可以被子类继承。

js
class Foo { static classMethod () { return 'hello'; } } class Bar extends Foo {} Bar.classMethod() // 'hello'

静态方法也可以从super对象上调用。

js
class Foo { static classMethod () { return 'hello'; } } class Bar extends Foo { static classMethod () { return super.classMethod() + ', too'; } } Bar.classMethod();

Class的静态属性

静态属性指的是Class本身的属性,即Class.propaname,而不是定义在实例对象(this)上的属性。

js
class Foo { } Foo.prop = 1; Foo.prop // 1

上面的写法可以读/写Foo类的静态属性prop。

目前,只有这种写法可行,因为ES6明确规定,Class内部只有静态方法,没有静态属性。

js
// 以下两种写法都无效,但不会报错。 class Foo { // 写法一 prop: 2 // 写法二 static prop: 2 } Foo.prop // undefined

ES7有一个静态属性的提案(https://github.com/jeffmo/es-clas-properties),目前Babel转码器已支持。

这个提案对实例属性和静态属性都规定了新的写法:

js
// 实例属性的新写法 class MyClass { myProp = 42; constructor () { console.log(this.myProp); // 42 } } // 静态属性的新写法 class MyClass { static myStaticProp = 42; constructor () { console.log(MyClass.myStaticProp); // 42 } }

new.target属性

new是从构造函数生成实例的命令。ES6为new命令引入了new.target属性,(在构造函数中)返回new命令所作用的构造函数。如果构造函数不是通过new命令调用的,那么new.target会返回undefined,因此这个属性可以用于确定构造函数是怎样调用的。

js
function Person (name) { if (new.target !== undefined) { this.name = name } else { throw new Error('必须使用new生成实例'); } } // 另一种写法 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

js
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会返回子类

js
class Rectangle { constructor (length, width) { console.log(new.target === Rectangle); this.length = length; this.width = width; } } class Square extends Retangle { constructor (length) { super(length, length) } } var obj = new Square(3); // 输出 false

利用这个特点,可以写出不能独立使用,必须继承后才能使用的类。

js
class Shape { constructor () { if (new.target === Shape) { throw new Error('本类不能实例化'); } } } class Rectangle extends Shape { constructor () { super(); } } var x = new Shape() // 报错 var y = new Rectangle(3, 4) // 正确

注意,在函数外部,使用new.target会报错。

Mixin模式的实现

Mixin模式指的是,将多个类的接口“混入”(mix in)另一个类。它在ES6的实现如下:

js
function mix (...mixins) { class Mix {} for (let mixin of mixins) { copyProperties(Mix, mixin); copyProperties(Mix.prototype, mixin.prototype); } return Mix; } function copyProperties (target, source) { for (let key of Reflect.ownKeys(source)) { if ( key !== 'constructor' && key !== 'prototype' && key !== 'name' ) { let desc = Object.getOwnPropertyDescriptor(source, key); Object.defineProperty(target, key, desc); } } }

上面的mix函数可将多个对象合成为一个类。使用时只需要继承这个类即可。

js
class DistributedEdit extends mix(Loggable, Serializable) { // ... }

以上,摘抄自阮一峰老师的《ES6标准入门》