Skip to content

根据我目前告诉你的内容,你可能认为 TypeScript 只是"带类型的 JavaScript"。JavaScript 处理运行时代码,而 TypeScript 用类型描述它。

但 TypeScript 实际上有一些在 JavaScript 中不存在的运行时特性。这些特性被编译成 JavaScript,但它们不是 JavaScript 语言本身的一部分。

在本章中,我们将看几个这些 TypeScript 独有的特性,包括参数属性、枚举和命名空间。在此过程中,我们将讨论优缺点,以及何时你可能想坚持使用 JavaScript。

类参数属性

一个在 JavaScript 中不存在的 TypeScript 特性是类参数属性。这些允许你直接从构造函数参数声明和初始化类成员。

考虑这个Rating类:

typescript
class Rating {
  constructor(public value: number, private max: number) {}
}

构造函数在value参数前包含public,在max参数前包含private。在 JavaScript 中,这会编译成将参数分配给类上属性的代码:

typescript
class Rating {
  constructor(value, max) {
    this.value = value;
    this.max = max;
  }
}

与手动处理赋值相比,这节省了大量代码并保持类定义简洁。

但与其他 TypeScript 特性不同,输出的 JavaScript 不是 TypeScript 代码的直接表示。如果你不熟悉这个特性,这可能使理解发生的事情变得困难。

枚举

你可以使用enum关键字定义一组命名常量。这些可以用作类型或值。

枚举在 TypeScript 的第一个版本中就被添加了,但它们还没有被添加到 JavaScript 中。这意味着它是一个 TypeScript 独有的运行时特性。而且,正如我们将看到的,它带有一些奇怪的行为。

枚举的一个好用例是当有一组有限的相关值,预计不会改变时。

数字枚举

数字枚举将一组相关成员组合在一起,并自动从 0 开始为它们分配数字值。例如,考虑这个AlbumStatus枚举:

typescript
enum AlbumStatus {
  NewRelease,
  OnSale,
  StaffPick,
}

在这种情况下,AlbumStatus.NewRelease将是 0,AlbumStatus.OnSale将是 1,依此类推。

要将AlbumStatus用作类型,我们可以使用它的名称:

typescript
function logStatus(genre: AlbumStatus) {
  console.log(genre); // 0
}

现在,logStatus只能接收来自AlbumStatus枚举对象的值。

typescript
logStatus(AlbumStatus.NewRelease);

带有显式值的数字枚举

你也可以为枚举的每个成员分配特定值。例如,如果你想为NewRelease分配值 1,为OnSale分配 2,为StaffPick分配 3,你可以这样做:

typescript
enum AlbumStatus {
  NewRelease = 1,
  OnSale = 2,
  StaffPick = 3,
}

现在,AlbumStatus.NewRelease将是 1,AlbumStatus.OnSale将是 2,依此类推。

自动递增的数字枚举

如果你选择只为枚举分配一些数字值,TypeScript 将自动从最后分配的值递增其余的值。例如,如果你只为NewRelease分配一个值,OnSaleStaffPick将分别是 2 和 3。

typescript
enum AlbumStatus {
  NewRelease = 1,
  OnSale,
  StaffPick,
}

字符串枚举

字符串枚举允许你为枚举的每个成员分配字符串值。例如:

typescript
enum AlbumStatus {
  NewRelease = "NEW_RELEASE",
  OnSale = "ON_SALE",
  StaffPick = "STAFF_PICK",
}

上面的相同logStatus函数现在会记录字符串值而不是数字。

typescript
function logStatus(genre: AlbumStatus) {
  console.log(genre); // "NEW_RELEASE"
}

logStatus(AlbumStatus.NewRelease);

枚举很奇怪

在 JavaScript 中没有与enum关键字等效的语法。所以,TypeScript 可以制定枚举如何工作的规则。这意味着它们有一些略微奇怪的行为。

数字枚举如何转译

枚举转换成 JavaScript 代码的方式可能感觉有点意外。

例如,枚举AlbumStatus

typescript
enum AlbumStatus {
  NewRelease,
  OnSale,
  StaffPick,
}

将被转译成以下 JavaScript:

javascript
var AlbumStatus;
(function (AlbumStatus) {
  AlbumStatus[(AlbumStatus["NewRelease"] = 0)] = "NewRelease";
  AlbumStatus[(AlbumStatus["OnSale"] = 1)] = "OnSale";
  AlbumStatus[(AlbumStatus["StaffPick"] = 2)] = "StaffPick";
})(AlbumStatus || (AlbumStatus = {}));

这段相当不透明的 JavaScript 一次性做了几件事。它创建了一个对象,为每个枚举值设置属性,它还创建了值到键的反向映射。

结果将类似于以下内容:

javascript
var AlbumStatus = {
  0: "NewRelease",
  1: "OnSale",
  2: "StaffPick",
  NewRelease: 0,
  OnSale: 1,
  StaffPick: 2,
};

这种反向映射意味着枚举上可用的键比你预期的要多。所以,对枚举执行Object.keys调用将返回键和值。

typescript
console.log(Object.keys(AlbumStatus)); // ["0", "1", "2", "NewRelease", "OnSale", "StaffPick"]

如果你不期望这种情况,这可能是一个真正的陷阱。

字符串枚举如何转译

字符串枚举的行为与数字枚举不同。当你指定字符串值时,转译的 JavaScript 要简单得多:

typescript
enum AlbumStatus {
  NewRelease = "NEW_RELEASE",
  OnSale = "ON_SALE",
  StaffPick = "STAFF_PICK",
}
javascript
var AlbumStatus;
(function (AlbumStatus) {
  AlbumStatus["NewRelease"] = "NEW_RELEASE";
  AlbumStatus["OnSale"] = "ON_SALE";
  AlbumStatus["StaffPick"] = "STAFF_PICK";
})(AlbumStatus || (AlbumStatus = {}));

现在,没有反向映射,对象只包含枚举值。Object.keys调用将只返回键,正如你可能预期的那样。

typescript
console.log(Object.keys(AlbumStatus)); // ["NewRelease", "OnSale", "StaffPick"]

数字枚举和字符串枚举之间的这种差异感觉不一致,可能是混淆的来源。

数字枚举的行为像联合类型

枚举的另一个奇怪特性是字符串枚举和数字枚举在用作类型时行为不同。

让我们用数字枚举重新定义我们的logStatus函数:

typescript
enum AlbumStatus {
  NewRelease = 0,
  OnSale = 1,
  StaffPick = 2,
}

function logStatus(genre: AlbumStatus) {
  console.log(genre);
}

现在,我们可以用枚举的成员调用logStatus

typescript
logStatus(AlbumStatus.NewRelease);

但我们也可以用普通数字调用它:

typescript
logStatus(0);

如果我们用不是枚举成员的数字调用它,TypeScript 将报告错误:

ts
logStatus
(3);
Argument of type '3' is not assignable to parameter of type 'AlbumStatus'.

这与字符串枚举不同,后者只允许枚举成员用作类型:

ts
enum 
AlbumStatus
{
NewRelease
= "NEW_RELEASE",
OnSale
= "ON_SALE",
StaffPick
= "STAFF_PICK",
} function
logStatus
(
genre
:
AlbumStatus
) {
console
.
log
(
genre
);
}
logStatus
(
AlbumStatus
.
NewRelease
);
logStatus
("NEW_RELEASE");
Argument of type '"NEW_RELEASE"' is not assignable to parameter of type 'AlbumStatus'.

字符串枚举的行为感觉更自然 - 它与 C#和 Java 等其他语言中枚举的工作方式相匹配。

但它们与数字枚举不一致的事实可能是混淆的来源。

事实上,字符串枚举在 TypeScript 中是独特的,因为它们是名义上比较的。TypeScript 中的所有其他类型都是结构上比较的,这意味着如果两个类型具有相同的结构,它们被认为是相同的。但字符串枚举是基于它们的名称(名义上)比较的,而不是它们的结构。

这意味着如果两个字符串枚举具有相同的成员,但名称不同,它们被认为是不同的类型:

ts
enum 
AlbumStatus2
{
NewRelease
= "NEW_RELEASE",
OnSale
= "ON_SALE",
StaffPick
= "STAFF_PICK",
}
logStatus
(AlbumStatus2.NewRelease);
Argument of type 'AlbumStatus2.NewRelease' is not assignable to parameter of type 'AlbumStatus'.

对于习惯于结构类型的我们来说,这可能有点令人惊讶。但对于习惯于其他语言中枚举的开发人员来说,字符串枚举将感觉最自然。

const枚举

const枚举的声明与其他枚举类似,但首先有const关键字:

typescript
const enum AlbumStatus {
  NewRelease = "NEW_RELEASE",
  OnSale = "ON_SALE",
  StaffPick = "STAFF_PICK",
}

你可以使用const枚举声明数字或字符串枚举 - 它们与常规枚举具有相同的行为。

主要区别是const枚举在 TypeScript 转译为 JavaScript 时会消失。不是创建一个带有枚举值的对象,转译的 JavaScript 将直接使用枚举的值。

例如,如果创建一个访问枚举值的数组,转译的 JavaScript 将最终包含这些值:

typescript
let albumStatuses = [
  AlbumStatus.NewRelease,
  AlbumStatus.OnSale,
  AlbumStatus.StaffPick,
];

// 上面转译为:
let albumStatuses = ["NEW_RELEASE", "ON_SALE", "STAFF_PICK"];

const枚举确实有一些限制,特别是在声明文件中声明时(我们稍后会介绍)。TypeScript 团队实际上建议在你的库代码中避免使用const枚举,因为它们对你的库的消费者可能表现得不可预测。

你应该使用枚举吗?

枚举是一个有用的特性,但它们有一些怪癖,可能使它们难以使用。

有一些枚举的替代方案你可能想考虑,比如普通的联合类型。但我更喜欢的替代方案使用了我们还没有介绍的一些语法。

我们将在第 10 章的as const部分讨论你是否应该一般使用枚举。

命名空间

命名空间是 TypeScript 的早期特性,试图解决当时 JavaScript 的一个大问题 - 缺乏模块系统。它们是在 ES6 模块标准化之前引入的,它们是 TypeScript 组织代码的尝试。

命名空间让你指定可以导出函数和类型的闭包。这允许你使用不会与全局作用域中声明的其他东西冲突的名称。

考虑一个场景,我们正在构建一个 TypeScript 应用程序来管理音乐收藏。可能有函数来添加专辑、计算销售额和生成报告。使用命名空间,我们可以逻辑地分组这些函数:

typescript
namespace RecordStoreUtils {
  export namespace Album {
    export interface Album {
      title: string;
      artist: string;
      year: number;
    }
  }

  export function addAlbum(title: string, artist: string, year: number) {
    // 添加专辑到收藏的实现
  }

  export namespace Sales {
    export function recordSale(
      albumTitle: string,
      quantity: number,
      price: number
    ) {
      // 记录专辑销售的实现
    }

    export function calculateTotalSales(albumTitle: string): number {
      // 计算专辑总销售额的实现
      return 0; // 占位符返回
    }
  }
}

在这个例子中,AlbumCollection是主命名空间,Sales是嵌套命名空间。这种结构有助于按功能组织代码,并明确应用程序的每个函数属于哪一部分。

AlbumCollection内的东西可以用作值或类型:

typescript
const odelay: AlbumCollection.Album.Album = {
  title: "Odelay!",
  artist: "Beck",
  year: 1996,
};

AlbumCollection.Sales.recordSale("Odelay!", 1, 10.99);

命名空间如何编译

命名空间编译成相对简单的 JavaScript。例如,RecordStoreUtils命名空间的一个更简单版本...

typescript
namespace RecordStoreUtils {
  export function addAlbum(title: string, artist: string, year: number) {
    // 添加专辑到收藏的实现
  }
}

...将被转译成以下 JavaScript:

javascript
var RecordStoreUtils;
(function (RecordStoreUtils) {
  function addAlbum(title, artist, year) {
    // 添加专辑到收藏的实现
  }
  RecordStoreUtils.addAlbum = addAlbum;
})(RecordStoreUtils || (RecordStoreUtils = {}));

与枚举类似,这段代码创建了一个对象,为命名空间中的每个函数和类型设置属性。这意味着命名空间可以作为对象访问,其属性可以作为方法或属性访问。

合并命名空间

就像接口一样,命名空间可以通过声明合并合并。这允许你将两个或更多个单独的声明组合成一个单一的定义。

这里我们有两个RecordStoreUtils的声明 - 一个带有Album命名空间,另一个带有Sales命名空间:

typescript
namespace RecordStoreUtils {
  export namespace Album {
    export interface Album {
      title: string;
      artist: string;
      year: number;
    }
  }
}

namespace RecordStoreUtils {
  export namespace Sales {
    export function recordSale(
      albumTitle: string,
      quantity: number,
      price: number
    ) {
      // 记录专辑销售的实现
    }

    export function calculateTotalSales(albumTitle: string): number {
      // 计算专辑总销售额的实现
      return 0; // 占位符返回
    }
  }
}

因为命名空间支持声明合并,两个声明自动组合成一个单一的RecordStoreUtils命名空间。AlbumSales命名空间都可以像以前一样访问:

typescript
const loaded: RecordStoreUtils.Album.Album = {
  title: "Loaded",
  artist: "The Velvet Underground",
  year: 1970,
};

RecordStoreUtils.Sales.calculateTotalSales("Loaded");

合并命名空间内的接口

命名空间内的接口也可以合并。如果我们有两个不同的RecordStoreUtils,每个都有自己的Album接口,TypeScript 会自动将它们合并成一个包含所有属性的单一Album接口:

typescript
namespace RecordStoreUtils {
  export interface Album {
    title: string;
    artist: string;
    year: number;
  }
}

namespace RecordStoreUtils {
  export interface Album {
    genre: string[];
    recordLabel: string;
  }
}

const madvillainy: RecordStoreUtils.Album = {
  title: "Madvillainy",
  artist: "Madvillain",
  year: 2004,
  genre: ["Hip Hop", "Experimental"],
  recordLabel: "Stones Throw",
};

当我们稍后查看命名空间的关键用例:全局作用域类型时,这些信息将变得至关重要。

你应该使用命名空间吗?

想象一下,如果 ES 模块,带有importexport,从未存在。在这个世界中,你声明的所有东西都在全局作用域中。你必须小心命名事物,并且必须想出一种组织代码的方法。

这就是 TypeScript 诞生的世界。像 CommonJS(require)和 ES 模块(importexport)这样的模块系统还不流行。所以,命名空间是避免命名冲突和组织代码的关键方式。

但现在 ES 模块被广泛支持,你应该使用它们而不是命名空间。命名空间在现代 TypeScript 代码中几乎没有相关性,除了一些例外,我们将在全局作用域章节中探讨。

何时偏好 ES vs. TS

在本章中,我们研究了几个 TypeScript 独有的特性。这些特性有两个共同点。首先,它们在 JavaScript 中不存在。其次,它们很

在 2010 年 TypeScript 刚开始构建时,JavaScript 被认为是一门有问题的语言,需要修复。枚举、命名空间和类参数属性就是在这样一种氛围下添加的,当时人们认为向 JavaScript 添加新的运行时特性是件好事。

但现在,JavaScript 本身已经健康得多。TC39 委员会是决定向 JavaScript 添加哪些特性的机构,它更加活跃和高效。每年都有新特性被添加到该语言中,并且该语言正在迅速发展。

TypeScript 团队本身现在对自己的角色有了截然不同的看法。他们不再向 TypeScript 添加新特性,而是尽可能地紧跟 JavaScript。TypeScript 的项目经理 Daniel Rosenwasser 是 TC39 委员会的联席主席。

如今思考 TypeScript 的正确方式是将其视为"带类型的 JavaScript"。

鉴于这种态度,我们应该如何对待这些 TypeScript 独有的特性就很清楚了:将它们视为过去的遗物。如果枚举、命名空间和类参数属性是今天提出的,它们甚至都不会被考虑。

但问题依然存在:你应该使用它们吗?TypeScript 很可能永远不会停止支持这些特性。这样做会破坏太多现有的代码。所以,它们可以安全地继续使用。

但我更喜欢以我正在使用的语言的精神来编写代码。编写"带类型的 JavaScript"可以使 TypeScript 和 JavaScript 之间的关系清晰明了。

然而,这只是我个人的偏好。如果你正在开发一个已经使用这些特性的大型代码库,那么移除它们是不值得的。团队达成一致并保持一致性才是关键。