◆

JavaScript class 类语法入门

2023-02-02 01:370

概述

class 是 ES6 提供的定义类的语法。它本质上是原型继承的语法糖——typeof MyClass 返回的是 'function',方法最终还是挂在 prototype 上。但它不只是「换个写法」,在语义上有若干和 function 构造器不同的强约束。

在 class 之前我们这样写「类」:

function Person(name) {
  this.name = name;
}
Person.prototype.sayHi = function () {
  console.log('hi, ' + this.name);
};

问题是构造逻辑和方法定义分散在两处,继承要手动处理 prototype 链和 constructor 指向,代码噪音很大。class 把这些收拢到一个块里:

class Person {
  constructor(name) {
    this.name = name;
  }
  sayHi() {
    console.log(`hi, ${this.name}`);
  }
}

const p = new Person('wfly');
p.sayHi(); // hi, wfly
console.log(typeof Person); // 'function'
console.log(Object.getOwnPropertyNames(Person.prototype)); // ['constructor', 'sayHi']

sayHi 定义在 Person.prototype 上,所有实例共享同一个函数对象,不会每次 new 都创建一份。

与 function 构造器的关键差异

这几条是实际写代码会撞到的:

必须用 new 调用。直接调用 Person('x') 会抛 TypeError: Class constructor Person cannot be invoked without 'new'。function 构造器直接调用不会报错,只会让 this 指向 undefined 或全局对象,静默产生 bug。

不会被提升。class 声明存在于暂时性死区(TDZ),在声明之前访问会抛 ReferenceError,而 function 声明会被提升可以提前调用。

内部代码默认是严格模式,不需要写 'use strict'。

原型上的方法不可枚举。for...in 遍历实例时不会遍历到 sayHi,而 Person.prototype.sayHi = ... 这种手写方式定义的方法是可枚举的。

实例字段与静态成员

类字段(class fields)可以直接写在类体里,等价于在 constructor 里赋值给 this:

class Counter {
  count = 0; // 实例字段,每个实例一份
  static total = 0; // 静态字段,挂在类本身上

  static create() {
    Counter.total++;
    return new Counter();
  }

  inc = () => {
    // 箭头函数字段,this 永久绑定实例
    this.count++;
  };

  dec() {
    // 原型方法,this 取决于调用方式
    this.count--;
  }
}

const c = Counter.create();
console.log(Counter.total); // 1

const { inc, dec } = c;
inc(); // 正常,count 变 1
dec(); // TypeError: Cannot read properties of undefined

这里的差别很关键:inc 是实例字段,值是一个箭头函数,箭头函数捕获定义时的 this(也就是实例),所以脱离对象调用也没问题;dec 是原型方法,解构出来之后 this 丢了。React 里写 onClick={this.handleClick} 需要 bind 或者写成箭头函数字段,原因就在这。

代价是箭头函数字段每个实例都存一份,不共享,实例多时内存开销更大。

私有字段

以 # 开头的是真正的私有字段,从类外部访问是语法错误(不是运行时报 undefined):

class Account {
  #balance = 0;

  deposit(n) {
    if (n <= 0) throw new Error('金额必须为正');
    this.#balance += n;
    return this;
  }

  get balance() {
    return this.#balance;
  }

  // 静态方法里可以访问同类实例的私有字段
  static isAccount(obj) {
    return #balance in obj; // ES2022 的 in 检测,不会抛错
  }
}

const a = new Account();
a.deposit(100);
console.log(a.balance); // 100
// console.log(a.#balance); // SyntaxError

这比早年用 _balance 加下划线约定或者用闭包模拟私有要彻底得多。注意私有字段不会出现在 Object.keys、JSON.stringify 的结果里,所以带私有字段的实例序列化后会丢这部分数据。

继承与 super

用 extends 继承,子类的 constructor 里必须先调用 super() 才能用 this:

class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    return `${this.name} 发出声音`;
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // 必须在使用 this 之前调用
    this.breed = breed;
  }
  speak() {
    return `${super.speak()}:汪`; // super 调用父类方法
  }
}

const d = new Dog('旺财', '柴犬');
console.log(d.speak()); // 旺财 发出声音:汪
console.log(d instanceof Animal); // true

super() 之前访问 this 会抛 ReferenceError,因为在 ES6 的语义里,子类的 this 是由父类构造函数创建后再返回给子类的,super() 没执行 this 就还不存在。这和 function 时代先创建 this 再修饰的顺序正好相反。

如果子类完全不写 constructor,引擎会自动补一个 constructor(...args) { super(...args); }。

getter / setter

get 和 set 定义访问器属性,配合私有字段能做校验:

class Temperature {
  #celsius = 0;

  get fahrenheit() {
    return this.#celsius * 1.8 + 32;
  }
  set fahrenheit(v) {
    if (typeof v !== 'number' || Number.isNaN(v)) {
      throw new TypeError('必须是数字');
    }
    this.#celsius = (v - 32) / 1.8;
  }
}

const t = new Temperature();
t.fahrenheit = 212;
console.log(t.fahrenheit); // 212

访问器定义在 prototype 上,用起来像普通属性但走的是函数调用。要注意 getter 里不要写重计算逻辑,因为调用方看着像读属性,很容易在循环里反复读导致性能问题。

一个容易忽略的坑

class 里的方法不能用 new 调用。class 的原型方法没有 [[Construct]] 内部方法,new p.sayHi() 会抛 TypeError: p.sayHi is not a constructor。同样它们也没有 prototype 属性。这在把老代码里 Foo.prototype.Bar = function(){} 这种「在原型上挂构造器」的写法迁移到 class 时会直接失败,得改成静态字段或者独立的类。

💬留言板0

还没有留言,来发表第一条吧~

PAGE LOAD / LIVEJavaScript class 类语法入门加载中