使用类

JavaScript 是一种基于原型的语言——对象的特性由其自身属性和其原型的属性指定。但是,随着 的添加,对象层次结构的创建以及属性及其值的继承与其他面向对象的语言(如 Java)更加一致。在本节中,我们将演示如何从类创建对象。

在许多其他语言中,或构造函数与对象或实例有着明显的区别。在 JavaScript 中,类主要是对现有原型继承机制的抽象——所有模式都可以转换为基于原型的继承。类本身也是普通的 JavaScript 值,并且有自己的原型链。事实上,大多数普通的 JavaScript 函数都可以用作构造函数——使用构造函数和new运算符创建一个新对象。

在本教程中,我们将使用抽象良好的类模型,并讨论类提供的语义。如果您想深入了解底层的原型系统,可以阅读继承和原型链指南。

本章假设您已经对 JavaScript 有了一定的了解,并且已经使用过普通对象。

类的概述

如果您有一些使用 JavaScript 的实践经验,或者跟随指南学习过,您可能已经使用过类,即使您没有创建过类。例如,这可能对您来说很熟悉

js
const bigDay = new Date(2019, 6, 19);
console.log(bigDay.toLocaleDateString());
if (bigDay.getTime() < Date.now()) {
  console.log("Once upon a time...");
}

在第一行,我们创建了类 Date 的一个实例,并将其命名为 bigDay。在第二行,我们对 bigDay 实例调用了一个方法 toLocaleDateString(),它返回一个字符串。然后,我们比较了两个数字:一个来自 getTime() 方法的返回值,另一个直接从 Date本身调用,作为 Date.now()

Date 是 JavaScript 的内置类。从这个例子中,我们可以了解类的一些基本概念

  • 类通过 new 运算符创建对象。
  • 每个对象都有一些由类添加的属性(数据或方法)。
  • 类本身存储一些属性(数据或方法),通常用于与实例交互。

这些对应于类的三个关键特性

  • 构造函数;
  • 实例方法和实例字段;
  • 静态方法和静态字段。

声明类

类通常使用类声明创建。

js
class MyClass {
  // class body...
}

在类体中,可以使用一系列功能。

js
class MyClass {
  // Constructor
  constructor() {
    // Constructor body
  }
  // Instance field
  myField = "foo";
  // Instance method
  myMethod() {
    // myMethod body
  }
  // Static field
  static myStaticField = "bar";
  // Static method
  static myStaticMethod() {
    // myStaticMethod body
  }
  // Static block
  static {
    // Static initialization code
  }
  // Fields, methods, static fields, and static methods all have
  // "private" forms
  #myPrivateField = "bar";
}

如果您来自 ES6 之前的时代,您可能更熟悉使用函数作为构造函数。上面的模式使用函数构造函数大致可以转换为以下内容

js
function MyClass() {
  this.myField = "foo";
  // Constructor body
}
MyClass.myStaticField = "bar";
MyClass.myStaticMethod = function () {
  // myStaticMethod body
};
MyClass.prototype.myMethod = function () {
  // myMethod body
};

(function () {
  // Static initialization code
})();

注意:私有字段和方法是类中的新特性,在函数构造函数中没有简单的等价物。

构造类

声明类后,可以使用 new 运算符创建类的实例。

js
const myInstance = new MyClass();
console.log(myInstance.myField); // 'foo'
myInstance.myMethod();

典型的函数构造函数既可以用 new 构造,也可以不用 new 调用。但是,尝试在没有 new 的情况下“调用”类会导致错误。

js
const myInstance = MyClass(); // TypeError: Class constructor MyClass cannot be invoked without 'new'

类声明提升

与函数声明不同,类声明不会被提升(或者,在某些解释中,被提升但有暂时性死区限制),这意味着您不能在声明类之前使用它。

js
new MyClass(); // ReferenceError: Cannot access 'MyClass' before initialization

class MyClass {}

此行为类似于使用 letconst 声明的变量。

类表达式

与函数类似,类声明也有其表达式对应物。

js
const MyClass = class {
  // Class body...
};

类表达式也可以有名称。表达式的名称仅对类的主体可见。

js
const MyClass = class MyClassLongerName {
  // Class body. Here MyClass and MyClassLongerName point to the same class.
};
new MyClassLongerName(); // ReferenceError: MyClassLongerName is not defined

构造函数

也许类的最重要的工作是充当对象的“工厂”。例如,当我们使用 Date 构造函数时,我们希望它提供一个表示我们传入的日期数据的新对象——然后我们可以使用实例公开的其他方法对其进行操作。在类中,实例创建由构造函数完成。

例如,我们将创建一个名为 Color 的类,它表示特定的颜色。用户通过传入 RGB 三元组来创建颜色。

js
class Color {
  constructor(r, g, b) {
    // Assign the RGB values as a property of `this`.
    this.values = [r, g, b];
  }
}

打开浏览器的开发者工具,将上述代码粘贴到控制台中,然后创建一个实例

js
const red = new Color(255, 0, 0);
console.log(red);

您应该会看到类似以下的输出

Object { values: (3) […] }
  values: Array(3) [ 255, 0, 0 ]

您已成功创建了一个 Color 实例,并且该实例具有一个 values 属性,该属性是您传入的 RGB 值的数组。这与以下内容几乎等效

js
function createColor(r, g, b) {
  return {
    values: [r, g, b],
  };
}

构造函数的语法与普通函数完全相同——这意味着您可以使用其他语法,例如 rest 参数

js
class Color {
  constructor(...values) {
    this.values = values;
  }
}

const red = new Color(255, 0, 0);
// Creates an instance with the same shape as above.

每次调用 new 时,都会创建一个不同的实例。

js
const red = new Color(255, 0, 0);
const anotherRed = new Color(255, 0, 0);
console.log(red === anotherRed); // false

在类构造函数中,this 的值指向新创建的实例。您可以为其分配属性,或读取现有属性(尤其是方法——我们将在后面介绍)。

this 值将自动作为 new 的结果返回。建议不要从构造函数中返回任何值——因为如果您返回非原始值,它将成为 new 表达式的值,并且 this 的值会被丢弃。(您可以在其描述中阅读有关 new 功能的更多信息。)

js
class MyClass {
  constructor() {
    this.myField = "foo";
    return {};
  }
}

console.log(new MyClass().myField); // undefined

实例方法

如果一个类只有一个构造函数,它与一个只创建普通对象的 createX 工厂函数没有太大区别。但是,类的强大之处在于它们可以用作“模板”,自动将方法分配给实例。

例如,对于Date实例,您可以使用一系列方法从单个日期值中获取不同的信息,例如年份月份星期几等。您还可以通过setX对应的函数(如setFullYear)来设置这些值。

对于我们自己的Color类,我们可以添加一个名为getRed的方法,该方法返回颜色的红色值。

js
class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
  }
  getRed() {
    return this.values[0];
  }
}

const red = new Color(255, 0, 0);
console.log(red.getRed()); // 255

如果没有方法,您可能会倾向于在构造函数中定义函数

js
class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
    this.getRed = function () {
      return this.values[0];
    };
  }
}

这也能工作。但是,问题在于每次创建Color实例时都会创建一个新的函数,即使它们都执行相同的操作!

js
console.log(new Color().getRed === new Color().getRed); // false

相反,如果您使用方法,它将在所有实例之间共享。函数可以在所有实例之间共享,但当不同实例调用它时,其行为仍然可能不同,因为this的值不同。如果您好奇此方法存储在何处——它定义在所有实例的原型上,或Color.prototype上,这将在继承和原型链中更详细地解释。

类似地,我们可以创建一个名为setRed的新方法,该方法设置颜色的红色值。

js
class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
  }
  getRed() {
    return this.values[0];
  }
  setRed(value) {
    this.values[0] = value;
  }
}

const red = new Color(255, 0, 0);
red.setRed(0);
console.log(red.getRed()); // 0; of course, it should be called "black" at this stage!

私有字段

您可能想知道:当我们可以直接访问实例上的values数组时,为什么要费心使用getRedsetRed方法呢?

js
class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
  }
}

const red = new Color(255, 0, 0);
red.values[0] = 0;
console.log(red.values[0]); // 0

面向对象编程中有一种称为“封装”的理念。这意味着您不应该访问对象的底层实现,而应该使用抽象良好的方法与之交互。例如,如果我们突然决定将颜色表示为HSL

js
class Color {
  constructor(r, g, b) {
    // values is now an HSL array!
    this.values = rgbToHSL([r, g, b]);
  }
  getRed() {
    return this.values[0];
  }
  setRed(value) {
    this.values[0] = value;
  }
}

const red = new Color(255, 0, 0);
console.log(red.values[0]); // 0; It's not 255 anymore, because the H value for pure red is 0

用户假设values表示RGB值的假设突然失效,并且可能导致其逻辑中断。因此,如果您是类的实现者,您希望向用户隐藏实例的内部数据结构,这既是为了保持API的简洁,也是为了防止用户代码在您进行一些“无害的重构”时发生中断。在类中,这是通过私有字段来完成的。

私有字段是在标识符前加上#(井号)的标识符。井号是字段名称的组成部分,这意味着私有属性永远不会与公有属性发生名称冲突。为了在类的任何地方引用私有字段,您必须在类主体中声明它(您不能动态创建私有属性)。除此之外,私有字段与普通属性几乎等效。

js
class Color {
  // Declare: every Color instance has a private field called #values.
  #values;
  constructor(r, g, b) {
    this.#values = [r, g, b];
  }
  getRed() {
    return this.#values[0];
  }
  setRed(value) {
    this.#values[0] = value;
  }
}

const red = new Color(255, 0, 0);
console.log(red.getRed()); // 255

在类外部访问私有字段会导致早期语法错误。语言可以防止这种情况,因为#privateField是一种特殊的语法,因此它可以在评估代码之前执行一些静态分析并找到所有私有字段的用法。

js
console.log(red.#values); // SyntaxError: Private field '#values' must be declared in an enclosing class

注意:在Chrome控制台中运行的代码可以在类外部访问私有属性。这是DevTools对JavaScript语法限制的唯一放松。

JavaScript中的私有字段是硬私有:如果类没有实现公开这些私有字段的方法,则绝对没有机制从类外部检索它们。这意味着您可以安全地对类的私有字段进行任何重构,只要公开方法的行为保持不变即可。

在将values字段设为私有之后,我们可以在getRedsetRed方法中添加更多逻辑,而不是将它们设为简单的直通方法。例如,我们可以在setRed中添加一个检查,以查看它是否为有效的R值

js
class Color {
  #values;
  constructor(r, g, b) {
    this.#values = [r, g, b];
  }
  getRed() {
    return this.#values[0];
  }
  setRed(value) {
    if (value < 0 || value > 255) {
      throw new RangeError("Invalid R value");
    }
    this.#values[0] = value;
  }
}

const red = new Color(255, 0, 0);
red.setRed(1000); // RangeError: Invalid R value

如果我们保留values属性的公开状态,我们的用户可以通过直接赋值给values[0]来轻松绕过该检查,并创建无效的颜色。但是,通过良好封装的API,我们可以使代码更健壮并防止下游的逻辑错误。

类方法可以读取其他实例的私有字段,只要它们属于同一个类即可。

js
class Color {
  #values;
  constructor(r, g, b) {
    this.#values = [r, g, b];
  }
  redDifference(anotherColor) {
    // #values doesn't necessarily need to be accessed from this:
    // you can access private fields of other instances belonging
    // to the same class.
    return this.#values[0] - anotherColor.#values[0];
  }
}

const red = new Color(255, 0, 0);
const crimson = new Color(220, 20, 60);
red.redDifference(crimson); // 35

但是,如果anotherColor不是Color实例,则#values将不存在。(即使另一个类具有相同名称的#values私有字段,它也并非指代同一事物,并且在此处无法访问。)访问不存在的私有属性会抛出错误,而不是像普通属性那样返回undefined。如果您不知道对象上是否存在私有字段,并且希望在不使用try/catch处理错误的情况下访问它,可以使用in运算符。

js
class Color {
  #values;
  constructor(r, g, b) {
    this.#values = [r, g, b];
  }
  redDifference(anotherColor) {
    if (!(#values in anotherColor)) {
      throw new TypeError("Color instance expected");
    }
    return this.#values[0] - anotherColor.#values[0];
  }
}

注意:请记住,#是一种特殊的标识符语法,您不能像使用字符串一样使用字段名称。"#values" in anotherColor将查找字面名为"#values"的属性,而不是私有字段。

使用私有属性有一些限制:同一个名称不能在单个类中声明两次,并且不能删除它们。两者都会导致早期语法错误。

js
class BadIdeas {
  #firstName;
  #firstName; // syntax error occurs here
  #lastName;
  constructor() {
    delete this.#lastName; // also a syntax error
  }
}

方法、getter和setter也可以是私有的。当您有一些类内部需要执行的复杂操作,但代码的其他部分都不允许调用时,它们很有用。

例如,想象一下创建HTML自定义元素,这些元素在被点击/轻触/以其他方式激活时应该执行一些比较复杂的操作。此外,元素被点击时发生的复杂操作应该限制在这个类中,因为JavaScript的其他任何部分都不会(或不应该)访问它。

js
class Counter extends HTMLElement {
  #xValue = 0;
  constructor() {
    super();
    this.onclick = this.#clicked.bind(this);
  }
  get #x() {
    return this.#xValue;
  }
  set #x(value) {
    this.#xValue = value;
    window.requestAnimationFrame(this.#render.bind(this));
  }
  #clicked() {
    this.#x++;
  }
  #render() {
    this.textContent = this.#x.toString();
  }
  connectedCallback() {
    this.#render();
  }
}

customElements.define("num-counter", Counter);

在这种情况下,几乎每个字段和方法都是类的私有方法。因此,它向代码的其余部分提供了一个接口,该接口本质上就像一个内置的HTML元素。程序的任何其他部分都没有能力影响Counter的任何内部内容。

访问器字段

color.getRed()color.setRed()允许我们读取和写入颜色的红色值。如果您来自Java等语言,您将非常熟悉这种模式。但是,在JavaScript中,使用方法来简单地访问属性仍然有些不符合人体工程学。访问器字段允许我们操作某个东西,就好像它是一个“实际属性”一样。

js
class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
  }
  get red() {
    return this.values[0];
  }
  set red(value) {
    this.values[0] = value;
  }
}

const red = new Color(255, 0, 0);
red.red = 0;
console.log(red.red); // 0

看起来对象有一个名为red的属性——但实际上,实例上不存在这样的属性!只有两个方法,但它们以getset为前缀,这使得它们可以像属性一样进行操作。

如果一个字段只有getter而没有setter,它将有效地成为只读的。

js
class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
  }
  get red() {
    return this.values[0];
  }
}

const red = new Color(255, 0, 0);
red.red = 0;
console.log(red.red); // 255

严格模式下,red.red = 0行将抛出一个类型错误:“无法设置仅具有getter的#<Color>的red属性”。在非严格模式下,赋值会被静默忽略。

公共字段

私有字段也有其公有对应项,允许每个实例都具有一个属性。字段通常被设计为独立于构造函数的参数。

js
class MyClass {
  luckyNumber = Math.random();
}
console.log(new MyClass().luckyNumber); // 0.5
console.log(new MyClass().luckyNumber); // 0.3

公有字段几乎等同于将属性赋值给this。例如,上面的例子也可以转换为

js
class MyClass {
  constructor() {
    this.luckyNumber = Math.random();
  }
}

静态属性

Date示例中,我们还遇到了Date.now()方法,该方法返回当前日期。此方法不属于任何日期实例——它属于类本身。但是,它被放在Date类上,而不是作为全局DateNow()函数公开,因为它在处理日期实例时最有用。

注意:以其处理的对象为前缀的实用程序方法称为“命名空间”,被认为是一种良好的实践。例如,除了较旧的、未加前缀的parseInt()方法之外,JavaScript还在以后添加了加前缀的Number.parseInt()方法,以表明它是用于处理数字的。

静态属性是一组在类本身而不是在类的各个实例上定义的类特性。这些特性包括

  • 静态方法
  • 静态字段
  • 静态getter和setter

所有内容也都有私有对应项。例如,对于我们的Color类,我们可以创建一个静态方法来检查给定的三元组是否为有效的RGB值

js
class Color {
  static isValid(r, g, b) {
    return r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255;
  }
}

Color.isValid(255, 0, 0); // true
Color.isValid(1000, 0, 0); // false

静态属性与其实例对应项非常相似,只是

  • 它们都以static为前缀,并且
  • 它们无法从实例访问。
js
console.log(new Color(0, 0, 0).isValid); // undefined

还有一种称为静态初始化块的特殊构造,它是一段在类首次加载时运行的代码块。

js
class MyClass {
  static {
    MyClass.myStaticProperty = "foo";
  }
}

console.log(MyClass.myStaticProperty); // 'foo'

静态初始化块几乎等同于在声明类后立即执行某些代码。唯一的区别是它们可以访问静态私有属性。

扩展和继承

类带来的一个关键特性(除了使用私有字段进行符合人体工程学的封装之外)是继承,这意味着一个对象可以“借用”另一个对象的大部分行为,同时使用自己的逻辑覆盖或增强某些部分。

例如,假设我们的Color类现在需要支持透明度。我们可能会倾向于添加一个新的字段来指示其透明度

js
class Color {
  #values;
  constructor(r, g, b, a = 1) {
    this.#values = [r, g, b, a];
  }
  get alpha() {
    return this.#values[3];
  }
  set alpha(value) {
    if (value < 0 || value > 1) {
      throw new RangeError("Alpha value must be between 0 and 1");
    }
    this.#values[3] = value;
  }
}

但是,这意味着每个实例——即使是绝大多数不透明的实例(那些alpha值为1的实例)——都必须具有额外的alpha值,这并不是很优雅。此外,如果特性不断增加,我们的Color类将变得非常臃肿且难以维护。

相反,在面向对象编程中,我们将创建一个派生类。派生类可以访问父类的所有公有属性。在JavaScript中,派生类是用extends子句声明的,该子句指示它扩展自哪个类。

js
class ColorWithAlpha extends Color {
  #alpha;
  constructor(r, g, b, a) {
    super(r, g, b);
    this.#alpha = a;
  }
  get alpha() {
    return this.#alpha;
  }
  set alpha(value) {
    if (value < 0 || value > 1) {
      throw new RangeError("Alpha value must be between 0 and 1");
    }
    this.#alpha = value;
  }
}

有几件事立即引起了注意。首先是在构造函数中,我们正在调用super(r, g, b)。在访问this之前调用super()是语言要求。super()调用调用父类的构造函数来初始化this——在这里它大致等同于this = new Color(r, g, b)。您可以在super()之前添加代码,但您不能在super()之前访问this——语言会阻止您访问未初始化的this

在父类完成修改this后,派生类可以执行自己的逻辑。在这里,我们添加了一个名为#alpha的私有字段,还提供了一对getter/setter来与它们交互。

派生类继承其父类中的所有方法。例如,虽然ColorWithAlpha本身没有声明get red()访问器,但您仍然可以访问red,因为此行为由父类指定

js
const color = new ColorWithAlpha(255, 0, 0, 0.5);
console.log(color.red); // 255

派生类也可以覆盖父类中的方法。例如,所有类都隐式继承Object类,该类定义了一些基本方法,如toString()。但是,基本toString()方法非常无用,因为它在大多数情况下都会打印[object Object]

js
console.log(red.toString()); // [object Object]

相反,我们的类可以重写它来打印颜色的RGB值。

js
class Color {
  #values;
  // …
  toString() {
    return this.#values.join(", ");
  }
}

console.log(new Color(255, 0, 0).toString()); // '255, 0, 0'

在派生类中,可以使用super访问父类的的方法。这允许你构建增强方法并避免代码重复。

js
class ColorWithAlpha extends Color {
  #alpha;
  // …
  toString() {
    // Call the parent class's toString() and build on the return value
    return `${super.toString()}, ${this.#alpha}`;
  }
}

console.log(new ColorWithAlpha(255, 0, 0, 0.5).toString()); // '255, 0, 0, 0.5'

当你使用extends时,静态方法也会相互继承,因此你也可以重写或增强它们。

js
class ColorWithAlpha extends Color {
  // ...
  static isValid(r, g, b, a) {
    // Call the parent class's isValid() and build on the return value
    return super.isValid(r, g, b) && a >= 0 && a <= 1;
  }
}

console.log(ColorWithAlpha.isValid(255, 0, 0, -1)); // false

派生类无法访问父类的私有字段——这是 JavaScript 私有字段为“硬私有”的另一个关键方面。私有字段的作用域仅限于类体本身,并且不会向任何外部代码授予访问权限。

js
class ColorWithAlpha extends Color {
  log() {
    console.log(this.#values); // SyntaxError: Private field '#values' must be declared in an enclosing class
  }
}

一个类只能扩展自一个类。这可以防止多重继承中出现像菱形问题这样的问题。但是,由于 JavaScript 的动态特性,仍然可以通过类组合和mixin来实现多重继承的效果。

派生类的实例也是基类的实例

js
const color = new ColorWithAlpha(255, 0, 0, 0.5);
console.log(color instanceof Color); // true
console.log(color instanceof ColorWithAlpha); // true

为什么要使用类?

到目前为止,本指南一直很实用:我们专注于类如何使用,但有一个问题没有回答:为什么要使用类?答案是:视情况而定。

类引入了一种范式,或者说是一种组织代码的方式。类是面向对象编程的基础,面向对象编程建立在诸如继承多态(尤其是子类型多态)等概念之上。但是,许多人从哲学上反对某些面向对象编程实践,因此不使用类。

例如,使Date对象臭名昭著的一件事是它们是可变的

js
function incrementDay(date) {
  return date.setDate(date.getDate() + 1);
}
const date = new Date(); // 2019-06-19
const newDay = incrementDay(date);
console.log(newDay); // 2019-06-20
// The old date is modified as well!?
console.log(date); // 2019-06-20

可变性和内部状态是面向对象编程的重要方面,但通常会使代码难以理解——因为任何看似无害的操作都可能产生意想不到的副作用,并改变程序其他部分的行为。

为了重用代码,我们通常诉诸于扩展类,这可能会创建大型的继承模式层次结构。

A typical OOP inheritance tree, with five classes and three levels

但是,当一个类只能扩展另一个类时,通常很难清晰地描述继承关系。通常,我们希望获得多个类的行为。在 Java 中,这是通过接口实现的;在 JavaScript 中,可以通过 mixin 实现。但归根结底,这仍然不是很方便。

从好的方面来看,类是一种非常强大的方式,可以在更高层次上组织我们的代码。例如,如果没有Color类,我们可能需要创建十几种实用程序函数。

js
function isRed(color) {
  return color.red === 255;
}
function isValidColor(color) {
  return (
    color.red >= 0 &&
    color.red <= 255 &&
    color.green >= 0 &&
    color.green <= 255 &&
    color.blue >= 0 &&
    color.blue <= 255
  );
}
// ...

但是有了类,我们可以将它们全部集中在Color命名空间下,从而提高可读性。此外,私有字段的引入允许我们向下游用户隐藏某些数据,从而创建干净的 API。

通常,当你想创建存储自身内部数据并公开大量行为的对象时,应该考虑使用类。以内置的 JavaScript 类为例。

  • MapSet类存储元素的集合,并允许你使用get()set()has()等方法通过键访问它们。
  • Date类将日期存储为 Unix 时间戳(一个数字),并允许你格式化、更新和读取单个日期组件。
  • Error类存储有关特定异常的信息,包括错误消息、堆栈跟踪、原因等。它是少数几个带有丰富继承结构的类之一:有多个内置类,如TypeErrorReferenceError扩展了Error。在错误的情况下,这种继承允许细化错误的语义:每个错误类表示特定类型的错误,可以使用instanceof轻松检查。

JavaScript 提供了以规范的面向对象方式组织代码的机制,但是否以及如何使用它完全取决于程序员的酌情决定。