ECMAScript 6 学习笔记(十):class 语法

简介

ES6 的 class 可以看作只是一个语法糖,是传统构造函数的一个封装,事实上,类的所有方法都定义在类的 prototype 属性上面。一个类里面至少包含一个 constructor 方法,注意,定义“类”的方法的时候,前面不需要加上 function 这个关键字,直接把函数定义放进去了就可以了。另外,方法之间不需要逗号分隔,加了会报错。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }
}

typeof Point // "function"
Point === Point.prototype.constructor // true,类指向构造函数

类的属性名,可以采用表达式。类的内部所有定义的方法,都是不可枚举的(non-enumerable)。

class Point {
  constructor(x, y) {...}
  toString() {...}
  [method]() {...}
}

Object.keys(Point.prototype)
// []
Object.getOwnPropertyNames(Point.prototype)
// ["constructor","toString"]

而在 ES5 中,这些方法是可枚举的

var Point = function (x, y) {...};
Point.prototype.toString = function() {...};

Object.keys(Point.prototype)
// ["toString"]
Object.getOwnPropertyNames(Point.prototype)
// ["constructor","toString"]

构造器方法

constructor方法是类的默认方法,通过new命令生成对象实例时,自动调用该方法。一个类必须有constructor方法,如果没有显式定义,一个空的constructor方法会被默认添加。constructor方法默认返回实例对象(即this),但是可以指定返回另外一个对象。

class Foo {
  constructor() {
    return Object.create(null);
  }
}

new Foo() instanceof Foo
// false

类的实例

类必须使用new调用,否则会报错。与 ES5 一样,实例的属性除非显式定义在其本身(即定义在this对象上),否则都是定义在原型上(即定义在class上),类的所有实例共享一个原型对象。

一般来说可以通过__proto__属性获取类的原型,从而为class添加方法,但是__proto__ 并不是语言本身的特性,这是各大厂商具体实现时添加的私有属性。为了避免出错,可以使用Object.getPrototypeOf方法来代替它。

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

与 ES5 一样,在“类”的内部可以使用 get 和 set 关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。存值函数和取值函数是设置在属性的Descriptor对象上的。

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 表达式,可以写出立即执行的 Class。

let person = new class {
  constructor(name) {
    this.name = name;
  }

  sayName() {
    console.log(this.name);
  }
}('nick');

person.sayName(); // "nick"

注意

  1. 严格模式

类和模块的内部,默认就是严格模式,所以不需要使用use strict指定运行模式。

  1. 不存在变量提升

类不存在变量提升(hoist),这种规定的原因与下文要提到的继承有关,必须保证子类在父类之后定义。

new Foo(); // ReferenceError
class Foo {}
  1. name 属性

由于类的本质是函数,因此可以使用name属性返回类的名称。

  1. Generator 方法

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

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
  1. this 的指向

类的方法内部如果含有 this,它默认指向类的实例,但是,如果将这个方法提取出来单独使用,this 会指向该方法运行时所在的环境。

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

解决方法:

  • 在构造方法中绑定 this。
class Logger {
  constructor() {
    this.printName = this.printName.bind(this);
  }
  // ...
}
  • 使用箭头函数。
class Logger {
  constructor() {
    this.printName = (name = 'there') => {
      this.print(`Hello ${name}`);
    };
  }
  // ...
}
  • 使用 Proxy,获取方法的时候,自动绑定 this。
function selfish (target) {
  const cache = new WeakMap();
  const handler = {
    get (target, key) {
      const value = Reflect.get(target, key);
      if (typeof value !== 'function') {
        return value;
      }
      if (!cache.has(value)) {
        cache.set(value, value.bind(target));
      }
      return cache.get(value);
    }
  };
  const proxy = new Proxy(target, handler);
  return proxy;
}

const logger = selfish(new Logger());

静态方法

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

class Foo {
  static classMethod() {
    return 'hello';
  }
}

Foo.classMethod() // 'hello'

var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function

父类的静态方法,可以被子类继承。静态方法也是可以从super对象上调用的。

class Foo {
  static classMethod() {
    return 'hello';
  }
}

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

Bar.classMethod() // "hello, too"

实例属性

实例属性除了定义在constructor()方法里面的this上面,也可以定义在类的最顶层。

class IncreasingCounter {
  _count = 0;
  get value() {
    console.log('Getting the current value!');
    return this._count;
  }
}

静态属性

静态属性指的是 Class 本身的属性,即Class.propName,而不是定义在实例对象(this)上的属性。为了配合类的静态方法,现在已有静态属性新写法的提案:

// 老写法
class Foo {
  // ...
}
Foo.prop = 1;

// 新写法
class Foo {
  static prop = 1;
}

私有方法和私有属性

  1. 通过命名区分
class Widget {
  // 公有方法
  foo (baz) {
    this._bar(baz);
  }

  // 私有方法
  _bar(baz) {
    return this.snaf = baz;
  }
}
  1. 将私有方法移出模块
class Widget {
  foo (baz) {
    bar.call(this, baz);
  }

  // ...
}

function bar(baz) {
  return this.snaf = baz;
}
  1. 利用 Symbol 值的唯一性(Reflect.ownKeys()依然可以获取)
const bar = Symbol('bar');
const snaf = Symbol('snaf');

export default class myClass{

  // 公有方法
  foo(baz) {
    this[bar](baz);
  }

  // 私有方法
  [bar](baz) {
    return this[snaf] = baz;
  }
};
  1. 使用 #(提案)
class Point {
  #x;

  constructor(x = 0) {
    #x = +x; // 写成 this.#x 亦可
  }

  get x() { return #x }
  set x(value) { #x = +value }
}

类的继承

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

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方法中调用super方法,否则新建实例时会报错。这是因为子类自己的this对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用super方法,子类就得不到this对象。

ES5 和 ES6 继承实现的对比:

  • ES5:先创造子类的实例对象this,然后把父类的方法添加到this上;
  • ES6:先将父类实例对象的属性和方法加到this上,再用子类构造函数修改this

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

class ColorPoint extends Point {
}

// 等同于
class ColorPoint extends Point {
  constructor(...args) {
    super(...args);
  }
}

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

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; // 正确
  }
}

可以通过Object.getPrototypeOf方法来从子类上获取父类:

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

super 关键字

super有两种用法,一是作为函数使用,代表父类的构造函数,ES6 中子类的构造函数必须执行一次super函数,此时的super()只能用在子类的构造函数中,相当于Parent.prototype.constructor.call(this);第二种用法是作为对象,在普通方法中,指向父类的原型对象,而在静态方法中,指向父类。

// 用法1
class A {
  constructor() {
    console.log(new.target.name);
  }
}
class B extends A {
  constructor() {
    super();
  }
}
new A() // A
new B() // B

// 用法2
class A {
  p() {
    return 2;
  }
}

class B extends A {
  constructor() {
    super();
    console.log(super.p()); // 2
  }
}

let b = new B();

这里需要注意,由于super指向父类的原型对象,所以定义在父类实例上的方法或属性,是无法通过super调用的。

class A {
  constructor() {
    this.p = 2;
  }
}

class B extends A {
  get m() {
    return super.p;
  }
}

let b = new B();
b.m // undefined

ES6 规定,在子类普通方法中通过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

由于this指向子类实例,所以如果通过super对某个属性赋值,这时super就是this,赋值的属性会变成子类实例的属性。

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

class B extends A {
  constructor() {
    super();
    this.x = 2;
    super.x = 3;
    console.log(super.x); // undefined
    console.log(this.x); // 3
  }
}

let b = new B();

另外,在子类的静态方法中通过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();
  }
}

B.x = 3;
B.m() // 3

使用super的时候,必须显式指定是作为函数、还是作为对象使用,否则会报错。

class A {}

class B extends A {
  constructor() {
    super();
    console.log(super); // 报错
  }
}

prototype 和 __proto__

__proto__属性是大多数浏览器对prototype的实现,而 Class 同时有prototype__proto__属性,因此同时存在两条继承链。

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

class B extends A {
}

B.__proto__ === A // true
B.prototype.__proto__ === A.prototype // true

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

class A {
}

class B {
}

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

// B 的实例继承 A 的实例
Object.setPrototypeOf(B.prototype, A.prototype);

// B 继承 A 的静态属性
Object.setPrototypeOf(B, A);

const b = new B();

可以理解为:

  • 作为对象,子类的原型(__proto__)是父类;
  • 作为构造函数,子类的原型对象(prototype)是父类的原型对象的实例。

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

var p1 = new Point(2, 3);
var p2 = new ColorPoint(2, 3, 'red');

p2.__proto__ === p1.__proto__ // false
p2.__proto__.__proto__ === p1.__proto__ // true

原生构造函数的继承

ES 的原生构造函数包括以下这些:

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

ES5 中,这些原生构造函数是无法继承的,因为子类无法获得原生构造函数的内部属性。ES5 是先新建子类的实例对象this,再将父类的属性添加到子类上,由于父类的内部属性无法获取,导致无法继承原生的构造函数。ES6 允许继承原生构造函数定义子类,因为 ES6 是先新建父类的实例对象this,然后再用子类的构造函数修饰this,使得父类的所有行为都可以继承。

示例,修改数组类:

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.history // [[], [1, 2]]

x.revert();
x // [1, 2]

注意,继承Object的子类时,有一个行为差异。

class NewObj extends Object{
  constructor(){
    super(...arguments);
  }
}
var o = new NewObj({attr: true});
o.attr === true  // false

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

new.target 属性

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

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:

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 Shape {
  constructor() {
    if (new.target === Shape) {
      throw new Error('本类不能实例化');
    }
  }
}

class Rectangle extends Shape {
  constructor(length, width) {
    super();
    // ...
  }
}

var x = new Shape();  // 报错
var y = new Rectangle(3, 4);  // 正确
点赞

发表评论

电子邮件地址不会被公开。