TypeScript 独有特性

8 阅读12分钟

TypeScript 独有特性

探索TypeScript独有特性:类参数属性、枚举和命名空间。理解其优点、JavaScript转译以及集成技巧。

9

根据我目前所讲的,你可能会认为 TypeScript 仅仅是“带类型的 JavaScript”。JavaScript 处理运行时代码,而 TypeScript 用类型来描述它。

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

在本章中,我们将探讨几个 TypeScript 独有的特性,包括参数属性、枚举和命名空间。在此过程中,我们将讨论其优点和权衡,以及何时你可能更倾向于坚持使用 JavaScript。

类参数属性

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

思考这个 Rating 类:

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

注意构造函数在 value 参数前使用了 public,在 max 参数前使用了 private。在 JavaScript 中,这会编译成将参数赋值给类属性的代码:

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

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

但与其他 TypeScript 特性不同,输出的 JavaScript 并非 TypeScript 代码的直接表示。如果你不熟悉这个特性,这可能会让你难以理解到底发生了什么。

枚举

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

枚举是在 TypeScript 的最初版本中添加的,但它们尚未被添加到 JavaScript 中。这意味着它是一个 TypeScript 独有的运行时特性。而且,正如我们将看到的,它带有一些古怪的行为。

枚举的一个好用例是当存在一组有限且不期望改变的相关值时。

数字枚举

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

enum AlbumStatus {
  NewRelease,
  OnSale,
  StaffPick,
}

在这种情况下,AlbumStatus.NewRelease 的值是 0,AlbumStatus.OnSale 的值是 1,以此类推。

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

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

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

logStatus(AlbumStatus.NewRelease);
带有显式值的数字枚举

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

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

现在,AlbumStatus.NewRelease 的值是 1,AlbumStatus.OnSale 的值是 2,以此类推。

自动递增的数字枚举

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

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

字符串枚举

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

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

上面那个 logStatus 函数现在会打印字符串值而不是数字。

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

logStatus(AlbumStatus.NewRelease);

枚举的怪异之处

JavaScript 中没有与 enum 关键字等效的语法。因此,TypeScript 可以自己制定枚举的工作规则。这意味着它们有一些略显古怪的行为。

数字枚举如何转译

枚举转换成 JavaScript 代码的方式可能让人感觉有些出乎意料。

例如,枚举 AlbumStatus

enum AlbumStatus {
  NewRelease,
  OnSale,
  StaffPick,
}

会被转译成以下 JavaScript 代码:

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

这段相当晦涩的 JavaScript 代码一次性做了几件事情。它创建了一个对象,其属性对应每个枚举值,并且它还创建了从值到键的反向映射。

结果将类似于以下内容:

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

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

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

如果你没预料到这一点,这可能会是一个真正的陷阱。

字符串枚举如何转译

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

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

现在,没有反向映射了,对象只包含枚举值。如你所料,Object.keys 调用将只返回键。

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

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

数字枚举的行为类似于联合类型

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

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

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

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

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

logStatus(AlbumStatus.NewRelease);

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

logStatus(0);

如果我们用一个不属于枚举成员的数字调用它,TypeScript 会报错:

logStatus(3);
// Argument of type '3' is not assignable to parameter of type 'AlbumStatus'.2345
// 类型 '3' 的参数不能赋给类型 'AlbumStatus' 的参数。2345

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

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'.2345
// 类型 '"NEW_RELEASE"' 的参数不能赋给类型 'AlbumStatus' 的参数。2345

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

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

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

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

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'.2345
// 类型 'AlbumStatus2.NewRelease' 的参数不能赋给类型 'AlbumStatus' 的参数。2345

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

const 枚举

const 枚举的声明方式与其他枚举类似,但在前面加上 const 关键字:

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

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

主要区别在于,当 TypeScript 转译为 JavaScript 时,const 枚举会消失。转译后的 JavaScript 不会创建一个包含枚举值的对象,而是直接使用枚举的值。

例如,如果创建一个访问枚举值的数组,转译后的 JavaScript 最终会得到这些值:

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 应用程序来管理音乐收藏。可能会有添加专辑、计算销售额和生成报告的函数。使用命名空间,我们可以逻辑地组织这些函数:

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; // 占位符返回
    }
  }
}

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

RecordStoreUtils 内部的内容可以用作值或类型:

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

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

命名空间如何编译

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

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

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

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

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

合并命名空间

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

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

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 命名空间都可以像以前一样访问:

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

RecordStoreUtils.Sales.calculateTotalSales("Loaded");
合并命名空间内的接口

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

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 模块 (import, export) 这样的模块系统当时还不流行。因此,命名空间是避免命名冲突和组织代码的关键方式。

但现在 ES 模块得到了广泛支持,你应该使用它们而不是命名空间。命名空间在现代 TypeScript 代码中几乎没有什么意义,除了一些我们将在关于全局作用域的章节中探讨的例外情况。

何时优先选择 ES 而非 TS

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

2010 年,当 TypeScript 正在构建时,JavaScript 被视为一个需要修复的有问题的语言。枚举、命名空间和类参数属性是在一种认为向 JavaScript 添加新的运行时特性是件好事的氛围中添加的。

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

TypeScript 团队现在对自身角色的看法已大不相同。他们不再向 TypeScript 添加新特性,而是尽可能地贴近 JavaScript。

今天思考 TypeScript 的正确方式是将其视为“带类型的 JavaScript”。

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

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

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

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

想成为 TypeScript 大神吗?

解锁专业精华版

TypeScript 专业精华版

查看原文上一课

下一课 推导类型