“TypeScript 不会添加到
JavaScript 运行时。”
这都是谎言吗?!
当 TypeScript 于 2012 年首次发布时,Web 应用程序的复杂性增长速度快于普通 JavaScript 添加支持深度复杂性的功能的速度。当时最流行的 JavaScript 语言风格,CoffeeScript,通过引入新的和令人兴奋的语法结构,使它与 JavaScript 不同。
如今,使用特定于超集语言(如 TypeScript)的新运行时特性来扩展 JavaScript 语法被认为是不好的实践,原因如下:
- 最重要的是,运行时语法扩展可能与较新版本的 JavaScript 中的新语法冲突。
- 它们使刚接触该语言的程序员更难理解 JavaScript 的结束和其他语言的开始。
- 它们增加了采用超集语言代码并生成 JavaScript 的转译器的复杂性。
因此,我怀着沉重的心情和深深的遗憾通知你,早期的 TypeScript 设计人员在 TypeScript 语言中为 JavaScript 引入了三个语法扩展:
- 类,在规范获得批准时与 JavaScript 类保持一致
- 枚举,一个简单的语法糖,类似于键和值的普通对象
- 命名空间,一种早于现代模块的解决方案,用于构建和组织代码
幸运的是,TypeScript 对 JavaScript 的运行时语法扩展的“原罪”并不是该语言自早期以来所做的设计决策。TypeScript 不会添加新的运行时语法结构,直到它们通过批准过程取得重大进展,以添加到 JavaScript 本身。
TypeScript 类最终的外观和行为几乎与 JavaScript 类相同(呸!),除了 useDefineForClassFields
的行为(本书中未涵盖的配置选项)和参数属性(此处介绍)。枚举仍然在某些项目中使用,因为它们偶尔有用。实际上,没有新项目再使用命名空间了。
TypeScript 还采用了一个针对 JavaScript “装饰器”的实验性提案,我也将介绍该提案。
类参数属性
我建议避免使用类参数属性,除非你在大量使用类或框架的项目中工作,这些类或框架将从中受益。
在 JavaScript 类中,想要在构造函数中接受参数并立即将其分配给类属性是很常见的。
此 Engineer
类接受 string
类型的单个 area
参数,并将其分配给类型为 string
的 area
属性:
class Engineer {
readonly area: string;
constructor(area: string) {
this.area = area;
console.log(`I work in the ${area} area.`);
}
}
// Type: string
new Engineer("mechanical").area;
TypeScript 包含一个简洁语法,用于声明这些类型的“参数属性”:在类构造函数的开头分配给相同类型的成员属性的属性。将 readonly
和/或隐私修饰符之一(public
、 protected
或 private
)放在构造函数的参数前面,指示 TypeScript 也声明相同名称和类型的属性。
前面的 Engineer
示例可以使用 area
的参数属性在 TypeScript 中重写:
class Engineer {
constructor(readonly area: string) {
console.log(`I work in the ${area} area.`);
}
}
// Type: string
new Engineer("mechanical").area;
参数属性是在类构造函数的最开始部分进行赋值的(或者在 super()
调用之后,如果该类是从基类派生而来)。它们可以与类上的其他参数和/或属性混合使用。
下面的 NamedEngineer
类声明了一个常规属性 fullName
,一个常规参数 name
,以及一个参数属性area
:
class NamedEngineer {
fullName: string;
constructor(
name: string,
public area: string,
) {
this.fullName = `${name}, ${area} engineer`;
}
}
它和没有参数属性的等效 TypeScript 看起来很相似,但还有几行代码来显式分配 area
:
class NamedEngineer {
fullName: string;
area: string;
constructor(
name: string,
area: string,
) {
this.area = area;
this.fullName = `${name}, ${area} engineer`;
}
}
参数属性在 TypeScript 社区中是一个有时争论的问题。大多数项目更喜欢绝对避免它们,因为它们是运行时语法扩展,因此具有我前面提到的相同缺点。它们也不能与较新的 #
类私有字段语法一起使用。
另一方面,当它们用于非常有利于创建类的项目时,它们非常好。参数属性解决了需要声明参数属性名称和类型两次的便利问题,这是 TypeScript 而不是 JavaScript 固有的。
实验性装饰器
我建议尽可能避免使用装饰器,直到具有装饰器语法的 ECMAScript 版本被正式批准。如果你正在使用 Angular 或 NestJS 等框架的某个版本,并且该框架推荐使用 TypeScript 装饰器,框架的文档将指导如何使用它们。
许多其他包含类的语言允许通过某种运行时逻辑对这些类及其成员进行注解或修饰。装饰器函数是一个为 JavaScript 提出的方案,它允许通过在函数名前面加上 @
符号来注解类和成员。
例如,以下代码片段仅展示了在类 MyClass
上使用装饰器的语法:
@myDecorator
class MyClass { /* ... */ }
实验性装饰器尚未在 ECMAScript 中得到批准,因此截至 TypeScript 4.7.2 版本,它们不会被默认支持。然而,TypeScript 包含了一个 experimentalDecorators
编译选项,允许在代码中使用旧的实验性版本。它可以通过 tsc
命令行或在 TSConfig 文件中启用,就像其他编译选项一样,如下所示:
{
"compilerOptions": {
"experimentalDecorators": true
}
}
例如,这个应用在 Greeter
类方法上的 logOnCall
装饰器接收到的参数包括 Greeter
类本身、属性的键("log"
)以及描述符对象 descriptor
,用于描述该属性。通过修改 descriptor.value
在调用 Greeter
类的原始 greet
方法之前添加日志,可以对 greet
方法进行“装饰”:
function logOnCall(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
console.log("[logOnCall] I am decorating", target.constructor.name);
descriptor.value = function (...args: unknown[]) {
console.log(`[descriptor.value] Calling '${key}' with:`, ...args);
return original.call(this, ...args);
}
}
class Greeter {
@logOnCall
greet(message: string) {
console.log(`[greet] Hello, ${message}!`);
}
}
new Greeter().greet("you");
// 输出日志:
// "[logOnCall] I am decorating", "Greeter"
// "[descriptor.value] Calling 'greet' with:", "you"
// "[greet] Hello, you!"
我不会详细介绍旧的 experimentalDecorators
在每种可能的装饰器类型上的工作细节和具体用法。TypeScript 的装饰器支持是实验性的,不与 ECMAScript 提案的最新草案保持一致。在任何 TypeScript 项目中,编写自定义装饰器通常是不必要的。
枚举
我建议不要使用枚举,除非你有一组经常重复出现、可以用一个通用名称描述,并且如果切换到枚举后,代码将更容易阅读的字面值。
大多数编程语言都包含“枚举”(enum)或枚举类型的概念,用于表示一组相关的值。枚举可以被视为是存储在对象中的一组字面量值,每个值都有一个友好名称。
JavaScript 本身没有枚举语法,因为传统的对象可以替代它们的功能。例如,尽管 HTTP 状态码可以作为数字存储和使用,但许多开发人员发现将它们存储在以友好名称作为键的对象中更易读:
const StatusCodes = {
InternalServerError: 500,
NotFound: 404,
Ok: 200,
// ...
} as const;
StatusCodes.InternalServerError; // 500
在TypeScript中,枚举对象的一个棘手之处在于没有很好的类型系统方法来表示一个值必须是它们值中的一个。一种常见的方法是使用第 9 章“类型修饰符”中介绍的 keyof
和 typeof
类型修饰符来构建一个类型,但这需要输入相当数量的语法。
下面的 StatusCodeValue
类型使用之前的 StatusCodes
值来创建可能的状态码数值的类型联合:
// Type: 200 | 404 | 500
type StatusCodeValue = (typeof StatusCodes)[keyof typeof StatusCodes];
let statusCodeValue: StatusCodeValue;
statusCodeValue = 200; // Ok
statusCodeValue = -1;
// 错误:不能将类型“-1”分配给类型“StatusCodeValue”。
TypeScript 提供 enum
语法,用于创建字面量值类型为 number
或 string
的对象。以 enum
关键字开头——然后是对象的名称(通常用 Pascal式)——然后是枚举中包含逗号分隔键的 {}
object。每个键都可以选择在初始值之前使用 =
。
前面的 StatusCodes
对象如下所示 StatusCode
枚举:
enum StatusCode {
InternalServerError = 500,
NotFound = 404,
Ok = 200,
}
StatusCode.InternalServerError; // 500
与类名一样,枚举名称(如 StatusCode
)可以用作类型注解中的类型名称。在这里,StatusCode
类型的 statusCode
变量可以给出 StatusCode.Ok
或数字值:
let statusCode: StatusCode;
statusCode = StatusCode.Ok; // Ok
statusCode = 200; // Ok
TypeScript 允许将任何数字赋值给数值枚举值,以方便使用,但会稍微牺牲一些类型安全性。在上面的代码片段中,statusCode = -1
也是允许的。
枚举在输出的编译 JavaScript 中会编译为等效的对象。它们的每个成员都会成为对象的成员键和对应的值,反之亦然。
上面的 enum StatusCode
将大致生成以下JavaScript代码:
var StatusCode; (function (StatusCode) {
StatusCode[StatusCode["InternalServerError"] = 500] = "InternalServerError";
StatusCode[StatusCode["NotFound"] = 404] = "NotFound";
StatusCode[StatusCode["Ok"] = 200] = "Ok";文本
})(StatusCode || (StatusCode = {}));
在 TypeScript 社区中,枚举是一个有一定争议的话题。一方面,它们违反了 TypeScript 的一般原则,即不向 JavaScript 中添加新的运行时语法结构。它们为开发人员提供了一种新的非JavaScript语法,需要学习,并且在一些选项(如后面在本章中介绍的 preserveConstEnums
)方面存在一些怪异之处。
另一方面,它们对显式声明已知的值集合非常有用。在 TypeScript 和 VS Code 的源代码库中都广泛使用枚举!
自动数值
枚举成员不需要具有显式的初始值。当值被省略时,TypeScript会从 0
开始为第一个值,并逐个递增 1
。当值在意义上只需要是唯一的,并与键名关联时,让TypeScript 选择枚举成员的值是一个很好的选择。
这个 VisualTheme
枚举允许 TypeScript 完全选择值,结果是三个整数:
enum VisualTheme {
Dark, // 0
Light, // 1
System, // 2
}
生成的 JavaScript 看起来与显式设置的值相同:
var VisualTheme; (function (VisualTheme) {
VisualTheme[VisualTheme["Dark"] = 0] = "Dark";
VisualTheme[VisualTheme["Light"] = 1] = "Light";
VisualTheme[VisualTheme["System"] = 2] = "System";
})(VisualTheme || (VisualTheme = {}));
在具有数值的枚举中,任何缺少显式值的成员都将比前一个值大 1
。
例如,Direction
枚举可能只关心其 Top
成员的值为 1
,其余值也是正整数:
enum Direction {
Top = 1,
Right,
Bottom,
Left,
}
它的输出 JavaScript 看起来也与其余成员具有显式值 2
、3
和 4
相同:
var Direction; (function (Direction) {
Direction[Direction["Top"] = 1] = "Top";
Direction[Direction["Right"] = 2] = "Right";
Direction[Direction["Bottom"] = 3] = "Bottom";
Direction[Direction["Left"] = 4] = "Left";
})(Direction || (Direction = {}));
修改枚举的顺序会导致底层的数字发生变化。如果你将这些值持久化存储在某个地方,比如数据库,小心修改枚举的顺序或删除条目。因为保存的数字将不再代表代码所期望的值,你的数据可能会突然损坏。
字符串值枚举
枚举也可以为其成员使用字符串而不是数字。这个 LoadStyle
枚举对其成员使用友好的字符串值:
enum LoadStyle {
AsNeeded = "as-needed",
Eager = "eager",
}
具有字符串成员值的枚举的输出 JavaScript 在结构上看起来与具有数字成员值的枚举相同:
var LoadStyle; (function (LoadStyle) {
LoadStyle["AsNeeded"] = "as-needed";
LoadStyle["Eager"] = "eager";
})(LoadStyle || (LoadStyle = {}));
字符串值枚举对于将共享常量赋予易读名称非常有用。与使用字符串字面量的类型联合相比,字符串值枚举可以提供更强大的编辑器自动完成和重命名功能,正如第12章“使用IDE功能”中所介绍的那样。
字符串成员值的一个缺点是它们不能由 TypeScript 自动计算。只有在数字值成员后面的成员才允许自动计算。
在这个枚举的 ImplicitNumber
中,TypeScript将能够提供 9001
的隐式值,因为前一个成员的值是数字 9000
,但它的 NotAllowed
成员会抛出一个错误,因为它跟在一个字符串成员值后面:
enum Wat {
FirstString = "first",
SomeNumber = 9000,
ImplicitNumber, // Ok (value 9001)
AnotherString = "another",
NotAllowed,
// 错误:枚举成员必须具有初始化表达式。
}
理论上,你可以同时使用数字和字符串成员值创建枚举。在实践中,该枚举可能会造成不必要的混淆,因此你可能不应该这样做。
Const 枚举
因为枚举创建了一个运行时对象,所以使用枚举会产生比使用字面值联合更多的代码。TypeScript 允许在枚举前面加上 const
修饰符来告诉 TypeScript 在编译后的 JavaScript 代码中省略它们的对象定义和属性查找。
这个 DisplayHint
枚举被用作 displayHint
变量的值:
const enum DisplayHint {
Opaque = 0,
Semitransparent,
Transparent,
}
let displayHint = DisplayHint.Transparent;
输出编译的 JavaScript 代码将完全缺少枚举声明,并将使用注释作为枚举的值:
let displayHint = 2 /* DisplayHint.Transparent */;
对于仍然希望创建枚举对象定义的项目,存在一个 preserveConstEnums
编译选项,它可以保留枚举声明本身的存在。值仍然直接使用字面值,而不是通过枚举对象进行访问。
上一个代码片段在编译后的 JavaScript 输出中仍将省略属性查找:
var DisplayHint; (function (DisplayHint) {
DisplayHint[DisplayHint["Opaque"] = 0] = "Opaque";
DisplayHint[DisplayHint["Semitransparent"] = 1] = "Semitransparent";
DisplayHint[DisplayHint["Transparent"] = 2] = "Transparent";
})(DisplayHint || (DisplayHint = {}));
let displayHint = 2 /* Transparent */;
preserveConstEnums
可以帮助减小生成的 JavaScript 代码的大小,尽管并非所有转译 TypeScript 代码的方法都支持它。有关 isolatedModules
编译选项以及何时不支持 const
枚举的更多信息,请参见第 13 章 “配置选项”。
命名空间
除非你正在为现有包编写 DefinitelyTyped 类型定义,否则请不要使用命名空间。命名空间与现代 JavaScript 模块语义不匹配。它们的自动成员赋值可能会使代码难以阅读。我只是提到它们是因为你可能会在 .d.ts 文件中遇到它们。
在 ECMAScript 模块被认可之前,将 Web 应用程序的大部分输出代码捆绑到一个由浏览器加载的单个文件中是很常见的。这些巨大的单文件通常创建全局变量来保存项目不同部分之间的重要值的引用。与设置旧的模块加载器(如 RequireJS)相比,包含那个文件对于页面来说更简单——而且通常更高效,因为许多服务器尚未支持HTTP/2下载流。为单文件输出而设计的项目需要一种组织代码和全局变量的方法。
TypeScript 语言提供了一种解决方案,即“内部模块”的概念,现在称为命名空间。命名空间是一个全局可用的对象,其中“导出”的内容可以作为该对象的成员进行调用。命名空间使用 namespace
关键字后跟一个代码块 {}
来定义。该命名空间块中的所有内容都在一个函数闭包内进行评估。
这个 Randomized
命名空间创建了一个 value
变量,并在内部使用它:
namespace Randomized {
const value = Math.random();
console.log(`My value is ${value}`);
}
它的输出 JavaScript 创建一个 Randomized
对象并计算函数内块的内容,因此 value
变量在命名空间之外不可用:
var Randomized;
(function (Randomized) {
const value = Math.random();
console.log(`My value is ${value}`);
})(Randomized || (Randomized = {}));
在TypeScript中,命名空间和namespace
关键字最初分别被称为“modules”和“module”。从现在来看,这是一个令人遗憾的选择,因为现代模块加载器和ECMAScript模块的兴起。在非常旧的项目中仍然偶尔可以找到module
关键字,但可以(而且应该)安全地用namespace
替换它。
命名空间导出
使用命名空间的关键特性是它可以通过将其作为命名空间对象的成员来“导出”内容。然后,代码的其他部分可以通过名称引用该成员。
在这个例子中,一个名为 Settings
的命名空间导出了 describe
、name
和 version
这些在命名空间内外部都使用的值:
namespace Settings {
export const name = "My Application";
export const version = "1.2.3";
export function describe() {
return `${Settings.name} at version ${Settings.version}`;
}
console.log("Initializing", describe());
}
console.log("Initialized", Settings.describe());
输出的 JavaScript 显示这些值在内部和外部的使用中始终被引用为 Settings
的成员(例如,Settings.name
):
var Settings; (function (Settings) {
Settings.name = "My Application";
Settings.version = "1.2.3";
function describe() {
return `${Settings.name} at version ${Settings.version}`;
}
Settings.describe = describe;
console.log("Initializing", describe());
})(Settings || (Settings = {}));
console.log("Initialized", Settings.describe());
通过将输出对象用 var
声明,并将导出的内容作为这些对象的成员进行引用,命名空间在跨多个文件时可以很好地工作。上述的 Settings
命名空间可以在多个文件中进行重写:
// settings/constants.ts
namespace Settings {
export const name = "My Application";
export const version = "1.2.3";
}
// settings/describe.ts
namespace Settings {
export function describe() {
return `${Settings.name} at version ${Settings.version}`;
}
console.log("Initializing", describe());
}
// index.ts
console.log("Initialized", Settings.describe());
拼接在一起的输出 JavaScript 大致如下所示:
// settings/constants.ts
var Settings;
(function (Settings) {
Settings.name = "My Application";
Settings.version = "1.2.3";
})(Settings || (Settings = {}));
// settings/describe.ts
(function (Settings) {
function describe() {
return `${Settings.name} at version ${Settings.version}`;
}
Settings.describe = describe;
console.log("Initialized", describe());
})(Settings || (Settings = {}));
console.log("Initialized", Settings.describe());
无论是单文件形式还是多文件形式的声明,运行时的输出对象都具有三个键。大致如下所示:
const Settings = {
describe: function describe() {
return `${Settings.name} at version ${Settings.version}`;
},
name: "My Application",
version: "1.2.3",
};
使用命名空间的关键区别是它可以跨不同的文件进行拆分,并且成员仍然可以在命名空间的名称下相互引用。
嵌套命名空间
通过从一个命名空间内部导出一个命名空间或在名称中使用一个或多个 .
号,可以无限级别地嵌套命名空间。
以下两个命名空间声明将具有相同的行为:
namespace Root.Nested {
export const value1 = true;
}
namespace Root {
export namespace Nested {
export const value2 = true;
}
}
它们都编译为结构相同的代码:
(function (Root) {
let Nested;
(function (Nested) {
Nested.value2 = true;
})(Nested || (Nested = {}));
})(Root || (Root = {}));
嵌套命名空间是在使用命名空间组织的较大项目中更好地区分各个部分的便捷方式。许多开发人员选择使用以项目名称命名的根命名空间,可能在公司和/或组织的命名空间内,并使用子命名空间表示项目的每个主要部分。
类型定义中的命名空间
如今,命名空间唯一具有价值的特性,也是我选择在本书中包含它们的唯一原因,是它们对于 DefinitelyTyped 类型定义非常有用。许多JavaScript库,特别是旧的Web 应用程序基础库(如 jQuery),被设置为通过传统的非模块 <script>
标签在 Web 浏览器中引入。它们的类型定义需要指示它们创建了一个对所有代码可用的全局变量,而这种结构正好可以由命名空间完美地表示。
此外,许多适用于浏览器的JavaScript库既可以在更现代的模块系统中导入,也可以创建一个全局命名空间。TypeScript允许模块类型定义包含一个export as namespace
,后跟全局名称,以指示该模块在全局范围内也以该名称可用。
例如,以下模块的声明文件导出了一个value
,并可在全局范围内使用:
// nodemodules/@types/my-example-lib/index.d.ts
export const value: number;
export as namespace libExample;
类型系统将知道 import("my-example-lib")
和 window.libExample
都将返回具有类型为 number
的 value
属性的模块:
// src/index.ts
import * as libExample from "my-example-lib"; // Ok
const value = window.libExample.value; // Ok
首选模块而不是命名空间
上述示例中的 settings/constants.ts 文件和 settings/describe.ts 文件可以使用 ECMAScript 模块进行重写,以符合现代标准:
// settings/constants.ts
export const name = "My Application";
export const version = "1.2.3";
// settings/describe.ts
import { name, version } from "./constants";
export function describe() {
return `${Settings.name} at version ${Settings.version}`;
}
console.log("Initializing", describe());
// index.ts
import { describe } from "./settings/describe";
console.log("Initialized", describe());
使用命名空间组织的 TypeScript 代码在现代构建工具(如 Webpack)中无法轻松进行树摇(删除未使用的文件),因为命名空间创建的是隐式的文件之间关联,而不是像 ECMAScript 模块那样显式声明的关联。通常强烈建议使用 ECMAScript 模块编写运行时代码,而不是使用 TypeScript 命名空间。
截至2022年,TypeScript 本身是使用命名空间编写的,但 TypeScript 团队正在努力迁移到模块。也许在你阅读这篇文章时,他们已经完成了这个转换!希望如此。
仅类型导入和导出
我希望以积极的态度结束这一章节。最后一个语法扩展是仅类型导入和导出,它们非常有用,且不会增加输出的 JavaScript 的复杂性。
TypeScript 的编译器会将仅在类型系统中使用的值从导入和导出语句中删除,因为它们在运行时的 JavaScript 中不会被使用。
例如,下面的 index.ts 文件创建了一个 action
变量和一个 ActivistArea
类型,然后通过独立的导出语句将它们导出。当将其编译为 index.js 时,TypeScript 的编译器会知道在独立导出语句中删除 ActivistArea
:
// index.ts
const action = { area: "people", name: "Bella Abzug", role: "politician" };
type ActivistArea = "nature" | "people";
export { action, ActivistArea };
// index.js
const action = { area: "people", name: "Bella Abzug", role: "politician" };
export { action };
知道如何删除重新导出的类型(如 ActivistArea
)需要了解 TypeScript 的类型系统。只对单个文件进行处理的转译器(如 Babel)无法访问 TypeScript 的类型系统,无法确定每个名称是否仅在类型系统中使用。TypeScript 的 isolatedModules
编译选项(在第 13 章“配置选项”中介绍)有助于确保代码在 TypeScript 以外的工具中进行转译。
TypeScript 允许在单个导入名称或 {...}
对象的 export
和 import
声明之前添加 type
修饰符。这样做表示它们仅在类型系统中使用。也可以将默认导入的包标记为 type
。
在下面的代码片段中,当 index.ts 转译为输出的 index.js 时,只保留了 value
的导入和导出:
// index.ts
import { type TypeOne, value } from "my-example-types";
import type { TypeTwo } from "my-example-types";
import type DefaultType from "my-example-types";
export { type TypeOne, value };
export type { DefaultType, TypeTwo };
// index.js
import { value } from "my-example-types";
export { value };
有些 TypeScript 开发人员甚至更喜欢选择使用仅限类型的导入方式,以明确哪些导入仅用作类型。如果将导入标记为仅限类型,尝试将其用作运行时值将触发 TypeScript 错误。
下面的 ClassOne
是正常导入的,可以在运行时使用,但 ClassTwo
不能,因为它被作为类型导入:
import { ClassOne, type ClassTwo } from "my-example-types";
new ClassOne(); // Ok
new ClassTwo();
// ~~~~~~~~
// Error: 'ClassTwo' cannot be used as a value
// because it was imported using 'import type'.
与给输出的 JavaScript 添加复杂性不同,仅限类型的导入和导出可以清楚地告诉 TypeScript 之外的转译器在哪些代码可以被移除。因此,大多数 TypeScript 开发人员不会像本章中之前介绍的语法扩展一样对其感到厌恶。
总结
在本章中,你使用了 TypeScript 中包含的一些 JavaScript 语法扩展:
- 在类构造函数中声明类参数属性
- 使用装饰器增强类及其字段
- 使用枚举表示值的组合
- 使用命名空间在文件中创建分组或在类型定义中使用
- 仅限类型的导入和导出
现在你已经读完了这一章,你最好练习一下学到的东西 https://learningtypescript.com/syntax-extensions。
你怎么称呼在 TypeScript 中支持传统 JavaScript 扩展的成本?
“罪恶税。”
评论 (0)