深入理解 TypeScript
请支持本书:购买捐赠
(广告,请勿屏蔽。)

16 TypeScript 中的类定义



在本章中,我们将研究类定义在 TypeScript 中的工作原理。

16.1 备忘单:纯 JavaScript 中的类

本节是纯 JavaScript 中类定义的备忘单。

16.1.1 类的基本成员

class OtherClass {}

class MyClass1 extends OtherClass {

  publicInstanceField = 1;

  constructor() {
    super();
  }

  publicPrototypeMethod() {
    return 2;
  }
}

const inst1 = new MyClass1();
assert.equal(inst1.publicInstanceField, 1);
assert.equal(inst1.publicPrototypeMethod(), 2);

  以下部分介绍修饰符

最后,有一个表格显示了如何组合修饰符。

16.1.2 修饰符:static

class MyClass2 {

  static staticPublicField = 1;

  static staticPublicMethod() {
    return 2;
  }
}

assert.equal(MyClass2.staticPublicField, 1);
assert.equal(MyClass2.staticPublicMethod(), 2);

16.1.3 类似修饰符的名称前缀:#(私有)

class MyClass3 {
  #privateField = 1;

  #privateMethod() {
    return 2;
  }

  static accessPrivateMembers() {
    // Private members can only be accessed from inside class definitions
    const inst3 = new MyClass3();
    assert.equal(inst3.#privateField, 1);
    assert.equal(inst3.#privateMethod(), 2);
  }
}
MyClass3.accessPrivateMembers();

JavaScript 警告

TypeScript 从 3.8 版本开始支持私有字段,但目前不支持私有方法。

16.1.4 访问器的修饰符:get(获取器)和 set(设置器)

粗略地说,访问器是通过访问属性调用的方法。访问器有两种:获取器和设置器。

class MyClass5 {
  #name = 'Rumpelstiltskin';
  
  /** Prototype getter */
  get name() {
    return this.#name;
  }

  /** Prototype setter */
  set name(value) {
    this.#name = value;
  }
}
const inst5 = new MyClass5();
assert.equal(inst5.name, 'Rumpelstiltskin'); // getter
inst5.name = 'Queen'; // setter
assert.equal(inst5.name, 'Queen'); // getter

16.1.5 方法的修饰符:*(生成器)

class MyClass6 {
  * publicPrototypeGeneratorMethod() {
    yield 'hello';
    yield 'world';
  }
}

const inst6 = new MyClass6();
assert.deepEqual(
  [...inst6.publicPrototypeGeneratorMethod()],
  ['hello', 'world']);

16.1.6 方法的修饰符:async

class MyClass7 {
  async publicPrototypeAsyncMethod() {
    const result = await Promise.resolve('abc');
    return result + result;
  }
}

const inst7 = new MyClass7();
inst7.publicPrototypeAsyncMethod()
  .then(result => assert.equal(result, 'abcabc'));

16.1.7 计算的类成员名称

const publicInstanceFieldKey = Symbol('publicInstanceFieldKey');
const publicPrototypeMethodKey = Symbol('publicPrototypeMethodKey');

class MyClass8 {

  [publicInstanceFieldKey] = 1;

  [publicPrototypeMethodKey]() {
    return 2;
  }
}

const inst8 = new MyClass8();
assert.equal(inst8[publicInstanceFieldKey], 1);
assert.equal(inst8[publicPrototypeMethodKey](), 2);

注释

16.1.8 修饰符的组合

字段(无级别表示构造存在于实例级别)

级别 可见性
(实例)
(实例) #
静态
静态 #

方法(无级别表示构造存在于原型级别)

级别 访问器 异步 生成器 可见性
(原型)
(原型) 获取
(原型) 设置
(原型) 异步
(原型) *
(原型) 异步 *
(与原型关联) #
(与原型关联) 获取 #
(与原型关联) 设置 #
(与原型关联) 异步 #
(与原型关联) * #
(与原型关联) 异步 * #
静态
静态 获取
静态 设置
静态 异步
静态 *
静态 异步 *
静态 #
静态 获取 #
静态 设置 #
静态 异步 #
静态 * #
静态 异步 * #

方法的限制

16.1.9 幕后原理

重要的是要记住,对于类,有两条原型对象链。

请考虑以下纯 JavaScript 示例。

class ClassA {
  static staticMthdA() {}
  constructor(instPropA) {
    this.instPropA = instPropA;
  }
  prototypeMthdA() {}
}
class ClassB extends ClassA {
  static staticMthdB() {}
  constructor(instPropA, instPropB) {
    super(instPropA);
    this.instPropB = instPropB;
  }
  prototypeMthdB() {}
}
const instB = new ClassB(0, 1);

图 1 显示了由 ClassAClassB 创建的原型链的外观。

Figure 1: The classes ClassA and ClassB create two prototype chains: One for classes (left-hand side) and one for instances (right-hand side).

16.1.10 有关纯 JavaScript 中类定义的更多信息

16.2 TypeScript 中的非公共数据槽

默认情况下,TypeScript 中的所有数据槽都是公共属性。有两种方法可以保持数据的私密性。

接下来我们将分别介绍这两种方法。

请注意,TypeScript 目前不支持私有方法。

16.2.1 私有属性

私有属性是仅限 TypeScript(静态)的特性。任何属性都可以通过在其前面加上关键字 private 来设为私有(A 行)。

class PersonPrivateProperty {
  private name: string; // (A)
  constructor(name: string) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}

如果我们在错误的作用域中访问该属性,现在会收到编译时错误(A 行)。

const john = new PersonPrivateProperty('John');

assert.equal(
  john.sayHello(), 'Hello John!');

// @ts-expect-error: Property 'name' is private and only accessible
// within class 'PersonPrivateProperty'. (2341)
john.name; // (A)

但是,private 在运行时不会改变任何内容。在那里,属性 .name 与公共属性没有区别。

assert.deepEqual(
  Object.keys(john),
  ['name']);

当我们查看类编译成的 JavaScript 代码时,我们也可以看到私有属性在运行时不受保护。

class PersonPrivateProperty {
  constructor(name) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}

16.2.2 私有字段

私有字段是 TypeScript 从 3.8 版本开始支持的一项新的 JavaScript 特性。

class PersonPrivateField {
  #name: string;
  constructor(name: string) {
    this.#name = name;
  }
  sayHello() {
    return `Hello ${this.#name}!`;
  }
}

此版本的 Person 的使用方式与私有属性版本基本相同。

const john = new PersonPrivateField('John');

assert.equal(
  john.sayHello(), 'Hello John!');

但是,这一次,数据是完全封装的。在类外部使用私有字段语法甚至是一个 JavaScript 语法错误。这就是为什么我们必须在 A 行中使用 eval(),以便我们可以执行此代码。

assert.throws(
  () => eval('john.#name'), // (A)
  {
    name: 'SyntaxError',
    message: "Private field '#name' must be declared in "
      + "an enclosing class",
  });

assert.deepEqual(
  Object.keys(john),
  []);

编译结果现在要复杂得多(略有简化)。

var __classPrivateFieldSet = function (receiver, privateMap, value) {
  if (!privateMap.has(receiver)) {
    throw new TypeError(
      'attempted to set private field on non-instance');
  }
  privateMap.set(receiver, value);
  return value;
};

// Omitted: __classPrivateFieldGet

var _name = new WeakMap();
class Person {
  constructor(name) {
    // Add an entry for this instance to _name
    _name.set(this, void 0);

    // Now we can use the helper function:
    __classPrivateFieldSet(this, _name, name);
  }
  // ···
}

此代码使用了一种常见的技术来保持实例数据的私密性。

有关此主题的更多信息,请参阅“面向急于求成的程序员的 JavaScript”

16.2.3 私有属性与私有字段

16.2.4 受保护的属性

私有字段和私有属性不能在子类中访问(A 行)。

class PrivatePerson {
  private name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}
class PrivateEmployee extends PrivatePerson {
  private company: string;
  constructor(name: string, company: string) {
    super(name);
    this.company = company;
  }
  sayHello() {
    // @ts-expect-error: Property 'name' is private and only
    // accessible within class 'PrivatePerson'. (2341)
    return `Hello ${this.name} from ${this.company}!`; // (A)
  }  
}

我们可以通过在 A 行中将 private 更改为 protected 来修复前面的示例(为了保持一致性,我们也在 B 行中进行了更改)。

class ProtectedPerson {
  protected name: string; // (A)
  constructor(name: string) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}
class ProtectedEmployee extends ProtectedPerson {
  protected company: string; // (B)
  constructor(name: string, company: string) {
    super(name);
    this.company = company;
  }
  sayHello() {
    return `Hello ${this.name} from ${this.company}!`; // OK
  }  
}

16.3 私有构造函数

构造函数也可以是私有的。当我们有静态工厂方法并希望客户端始终使用这些方法,而从不直接使用构造函数时,这很有用。静态方法可以访问私有类成员,这就是为什么工厂方法仍然可以使用构造函数的原因。

在以下代码中,有一个静态工厂方法 DataContainer.create()。它通过异步加载的数据设置实例。在工厂方法中保留异步代码可以使实际类完全同步。

class DataContainer {
  #data: string;
  static async create() {
    const data = await Promise.resolve('downloaded'); // (A)
    return new this(data);
  }
  private constructor(data: string) {
    this.#data = data;
  }
  getData() {
    return 'DATA: '+this.#data;
  }
}
DataContainer.create()
  .then(dc => assert.equal(
    dc.getData(), 'DATA: downloaded'));

在实际代码中,我们将使用 fetch() 或类似的基于 Promise 的 API 在 A 行中异步加载数据。

私有构造函数阻止 DataContainer 被子类化。如果我们想允许子类,我们必须将其设为 protected

16.4 初始化实例属性

16.4.1 严格的属性初始化

如果打开了编译器设置 --strictPropertyInitialization(如果我们使用 --strict,则默认打开),则 TypeScript 会检查是否正确初始化了所有声明的实例属性。

但是,有时我们以 TypeScript 无法识别的方式初始化属性。然后,我们可以使用感叹号(*确定赋值断言*)来关闭 TypeScript 的警告(A 行和 B 行)。

class Point {
  x!: number; // (A)
  y!: number; // (B)
  constructor() {
    this.initProperties();
  }
  initProperties() {
    this.x = 0;
    this.y = 0;
  }
}
16.4.1.1 示例:通过对象设置实例属性

在以下示例中,我们还需要确定赋值断言。在这里,我们通过构造函数参数 props 设置实例属性。

class CompilerError implements CompilerErrorProps { // (A)
  line!: number;
  description!: string;
  constructor(props: CompilerErrorProps) {
    Object.assign(this, props); // (B)
  }
}

// Helper interface for the parameter properties
interface CompilerErrorProps {
  line: number,
  description: string,
}

// Using the class:
const err = new CompilerError({
  line: 123,
  description: 'Unexpected token',
});

注意

16.4.2 将构造函数参数设为 publicprivateprotected

如果我们对构造函数参数使用关键字 public,则 TypeScript 会为我们做两件事。

因此,以下两个类是等效的。

class Point1 {
  constructor(public x: number, public y: number) {
  }
}

class Point2 {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

如果我们使用 privateprotected 而不是 public,则相应的实例属性是私有的或受保护的(而不是公共的)。

16.5 抽象类

在 TypeScript 中,两种构造可以是抽象的。

以下代码演示了抽象类和方法。

一方面,有抽象超类 Printable 及其辅助类 StringBuilder

class StringBuilder {
  string = '';
  add(str: string) {
    this.string += str;
  }
}
abstract class Printable {
  toString() {
    const out = new StringBuilder();
    this.print(out);
    return out.string;
  }
  abstract print(out: StringBuilder): void;
}

另一方面,有具体子类 EntriesEntry

class Entries extends Printable {
  entries: Entry[];
  constructor(entries: Entry[]) {
    super();
    this.entries = entries;
  }
  print(out: StringBuilder): void {
    for (const entry of this.entries) {
      entry.print(out);
    }
  }
}
class Entry extends Printable {
  key: string;
  value: string;
  constructor(key: string, value: string) {
    super();
    this.key = key;
    this.value = value;
  }
  print(out: StringBuilder): void {
    out.add(this.key);
    out.add(': ');
    out.add(this.value);
    out.add('\n');
  }
}

最后,这是我们使用 EntriesEntry 的方式。

const entries = new Entries([
  new Entry('accept-ranges', 'bytes'),
  new Entry('content-length', '6518'),
]);
assert.equal(
  entries.toString(),
  'accept-ranges: bytes\ncontent-length: 6518\n');

关于抽象类的注释