变量声明
let 和 const 是 JavaScript 中相对较新的变量声明概念。 正如我们之前提到的,let 在某些方面与 var 类似,但允许用户避免在 JavaScript 中遇到的一些常见“陷阱”。
const 是对 let 的增强,它可以防止对变量重新赋值。
由于 TypeScript 是 JavaScript 的扩展,该语言自然支持 let 和 const。 这里我们将详细阐述这些新声明以及为什么它们比 var 更受欢迎。
如果你对 JavaScript 的使用不太深入,下一节可能是回顾记忆的好方法。 如果你非常熟悉 JavaScript 中 var 声明的所有古怪行为,你可能会发现直接跳过更容易。
var 声明
在 JavaScript 中,传统上一直使用 var 关键字来声明变量。
var a = 10;正如你可能已经想到的,我们只是声明了一个名为 a 值为 10 的变量。
我们也可以在函数内部声明变量:
function f() {
var message = "Hello, world!";
return message;
}并且我们也可以在其他函数内部访问这些相同的变量:
function f() {
var a = 10;
return function g() {
var b = a + 1;
return b;
};
}
var g = f();
g(); // 返回 '11'在上面的例子中,g 捕获了在 f 中声明的变量 a。 在 g 被调用的任何时候,a 的值将与 f 中的 a 值绑定。 即使 g 在 f 完成运行后被调用,它也能够访问和修改 a。
function f() {
var a = 1;
a = 2;
var b = g();
a = 3;
return b;
function g() {
return a;
}
}
f(); // 返回 '2'作用域规则
对于熟悉其他语言的人来说,var 声明有一些奇怪的作用域规则。 看下面的例子:
function f(shouldInitialize: boolean) {
if (shouldInitialize) {
var x = 10;
}
return x;
}
f(true); // 返回 '10'
f(false); // 返回 'undefined'有些读者可能会对这个例子感到惊讶。 变量 x 是在 if 块内部声明的,然而我们却能够从该块外部访问它。 这是因为 var 声明在其包含的函数、模块、命名空间或全局作用域内的任何位置都是可访问的——无论包含块如何,我们稍后会讨论这些。有些人称之为 var 作用域 或 函数作用域。 参数也是函数作用域的。
这些作用域规则可能导致几种类型的错误。 它们加剧的一个问题是,多次声明同一个变量并不是错误:
function sumMatrix(matrix: number[][]) {
var sum = 0;
for (var i = 0; i < matrix.length; i++) {
var currentRow = matrix[i];
for (var i = 0; i < currentRow.length; i++) {
sum += currentRow[i];
}
}
return sum;
}也许对于一些有经验的 JavaScript 开发者来说很容易发现,但内部的 for 循环会意外地覆盖变量 i,因为 i 引用的是同一个函数作用域的变量。 正如经验丰富的开发者现在所知,类似的 bug 会在代码审查中溜过去,并且可能成为无尽的挫败感来源。
变量捕获的怪癖
花一秒钟猜猜以下代码片段的输出是什么:
for (var i = 0; i < 10; i++) {
setTimeout(function () {
console.log(i);
}, 100 * i);
}对于那些不熟悉的人来说,setTimeout 会尝试在一定的毫秒数后执行一个函数(尽管会等待其他任何东西停止运行)。
准备好了吗?看:
10
10
10
10
10
10
10
10
10
10许多 JavaScript 开发者非常熟悉这种行为,但如果你感到惊讶,你绝对不是一个人。 大多数人期望的输出是
0
1
2
3
4
5
6
7
8
9还记得我们之前提到的变量捕获吗? 我们传递给 setTimeout 的每个函数表达式实际上都引用同一个作用域中的同一个 i。
花点时间思考这意味着什么。 setTimeout 将在若干毫秒后运行一个函数,但仅在 for 循环停止执行之后; 到 for 循环停止执行时,i 的值是 10。 所以每次调用给定的函数时,它都会打印出 10!
一个常见的解决方法是使用 IIFE——立即调用的函数表达式——来在每次迭代时捕获 i:
for (var i = 0; i < 10; i++) {
// 捕获 'i' 的当前状态
// 通过使用其当前值调用一个函数
(function (i) {
setTimeout(function () {
console.log(i);
}, 100 * i);
})(i);
}这种看起来奇怪的模式实际上相当常见。 参数列表中的 i 实际上遮蔽了在 for 循环中声明的 i,但由于我们将它们命名为相同的名称,我们不必过多修改循环体。
let 声明
现在你已经发现 var 有一些问题,这正是引入 let 语句的原因。 除了使用的关键字不同,let 语句的编写方式与 var 语句相同。
let hello = "Hello!";关键区别不在于语法,而在于语义,我们现在将深入探讨。
块作用域
当使用 let 声明变量时,它使用的是所谓的 词法作用域 或 块作用域。 与使用 var 声明的变量(其作用域会泄露到其包含的函数中)不同,块作用域变量在其最近的包含块或 for 循环之外是不可见的。
function f(input: boolean) {
let a = 100;
if (input) {
// 仍然可以引用 'a'
let b = a + 1;
return b;
}
// 错误:'b' 在这里不存在
return b;
}这里,我们有两个局部变量 a 和 b。 a 的作用域仅限于 f 的函数体,而 b 的作用域仅限于包含它的 if 语句块。
在 catch 子句中声明的变量也具有类似的作用域规则。
try {
throw "oh no!";
} catch (e) {
console.log("Oh well.");
}
// 错误:'e' 在这里不存在
console.log(e);块作用域变量的另一个属性是,在实际声明之前不能读取或写入它们。 虽然这些变量在整个作用域内都是“存在的”,但在声明之前的所有点都属于它们的 暂时性死区。 这只是一个复杂的方式来说明在 let 语句之前不能访问它们,幸运的是 TypeScript 会告诉你这一点。
a++; // 在声明之前使用 'a' 是非法的;
let a;需要注意的是,你仍然可以在声明之前捕获一个块作用域变量。 唯一的限制是在声明之前调用该函数是非法的。 如果目标是 ES2015,现代运行时将抛出错误;然而,目前 TypeScript 是宽容的,不会将此报告为错误。
function foo() {
// 可以捕获 'a'
return a;
}
// 在 'a' 声明之前调用 'foo' 是非法的
// 运行时应该在此处抛出错误
foo();
let a;有关暂时性死区的更多信息,请参阅 Mozilla 开发者网络 上的相关内容。
重新声明和遮蔽
对于 var 声明,我们提到过你声明变量的次数无关紧要;你只会得到一个。
function f(x) {
var x;
var x;
if (true) {
var x;
}
}在上面的例子中,所有 x 的声明实际上都引用同一个 x,并且这是完全有效的。 这常常成为 bug 的来源。 幸运的是,let 声明没有那么宽容。
let x = 10;
let x = 20; // 错误:不能在同一个作用域中重新声明 'x'变量不一定都需要是块作用域的,TypeScript 才会告诉我们有问题。
function f(x) {
let x = 100; // 错误:与参数声明冲突
}
function g() {
let x = 100;
var x = 100; // 错误:不能同时有 'x' 的两种声明
}这并不是说块作用域变量永远不能与函数作用域变量一起声明。 块作用域变量只需要在一个明显不同的块中声明。
function f(condition, x) {
if (condition) {
let x = 100;
return x;
}
return x;
}
f(false, 0); // 返回 '0'
f(true, 0); // 返回 '100'在更嵌套的作用域中引入新名称的行为称为 遮蔽。 它是一把双刃剑,一方面在意外遮蔽的情况下可能会引入某些 bug,另一方面也能防止某些 bug。 例如,假设我们使用 let 变量编写了之前的 sumMatrix 函数。
function sumMatrix(matrix: number[][]) {
let sum = 0;
for (let i = 0; i < matrix.length; i++) {
var currentRow = matrix[i];
for (let i = 0; i < currentRow.length; i++) {
sum += currentRow[i];
}
}
return sum;
}这个循环版本实际上会正确地执行求和,因为内部循环的 i 遮蔽了外部循环的 i。
为了编写更清晰的代码,通常应该避免遮蔽。 尽管在某些情况下可能适合利用它,但你应该做出最佳判断。
块作用域变量捕获
当我们第一次提到 var 声明的变量捕获概念时,我们简要地介绍了变量一旦被捕获后的行为。 为了更好地理解这一点,每次运行一个作用域时,它都会创建一个变量的“环境”。 即使在其作用域内的所有内容都已执行完毕之后,该环境及其捕获的变量仍然可以存在。
function theCityThatAlwaysSleeps() {
let getCity;
if (true) {
let city = "Seattle";
getCity = function () {
return city;
};
}
return getCity();
}因为我们从它的环境中捕获了 city,尽管 if 块已经执行完毕,我们仍然能够访问它。
回想一下我们之前的 setTimeout 示例,我们最终需要使用 IIFE 来为 for 循环的每次迭代捕获变量的状态。 实际上,我们正在为捕获的变量创建一个新的变量环境。 这有点麻烦,但幸运的是,在 TypeScript 中你再也不必这样做了。
当 let 声明作为循环的一部分时,其行为有很大不同。 这些声明不只是为循环本身引入一个新环境,而是每次迭代都会创建一个新的作用域。 由于这正是我们用 IIFE 所做的,我们可以将旧的 setTimeout 示例改为只使用 let 声明。
for (let i = 0; i < 10; i++) {
setTimeout(function () {
console.log(i);
}, 100 * i);
}正如预期的那样,这将打印出
0
1
2
3
4
5
6
7
8
9const 声明
const 声明是另一种声明变量的方式。
const numLivesForCat = 9;它们与 let 声明类似,但正如其名称所示,一旦绑定,它们的值就不能改变。 换句话说,它们具有与 let 相同的作用域规则,但你不能重新赋值。
这不应与它们引用的值是不可变的概念相混淆。
const numLivesForCat = 9;
const kitty = {
name: "Aurora",
numLives: numLivesForCat,
};
// 错误
kitty = {
name: "Danielle",
numLives: numLivesForCat,
};
// 所有这些都是“可以的”
kitty.name = "Rory";
kitty.name = "Kitty";
kitty.name = "Cat";
kitty.numLives--;除非你采取特定措施来避免,否则 const 变量的内部状态仍然是可修改的。 幸运的是,TypeScript 允许你将对象的成员指定为 readonly。 关于接口的章节中有详细信息。
let vs. const
鉴于我们有两种具有相似作用域语义的声明类型,我们自然会问该使用哪一种。 像大多数宽泛的问题一样,答案是:视情况而定。
应用最小权限原则,除了你打算修改的声明之外,所有声明都应使用 const。 理由是,如果一个变量不需要被写入,那么在同一代码库中工作的其他人不应该自动能够写入该对象,并且需要考虑他们是否真的需要重新赋值给该变量。 在使用 const 时,在推理数据流时也使代码更可预测。
请使用你的最佳判断,如果适用,与团队中的其他人协商。
本手册的大部分内容使用 let 声明。
解构
TypeScript 拥有的另一个 ECMAScript 2015 特性是解构。 有关完整的参考,请参阅 Mozilla 开发者网络上的文章。 在本节中,我们将简要概述。
数组解构
最简单的解构形式是数组解构赋值:
let input = [1, 2];
let [first, second] = input;
console.log(first); // 输出 1
console.log(second); // 输出 2这创建了两个新变量,名为 first 和 second。 这等效于使用索引,但方便得多:
first = input[0];
second = input[1];解构也适用于已声明的变量:
// 交换变量
[first, second] = [second, first];以及函数的参数:
function f([first, second]: [number, number]) {
console.log(first);
console.log(second);
}
f([1, 2]);你可以使用 ... 语法为列表中的剩余项创建一个变量:
let [first, ...rest] = [1, 2, 3, 4];
console.log(first); // 输出 1
console.log(rest); // 输出 [ 2, 3, 4 ]当然,由于这是 JavaScript,你可以忽略你不关心的尾随元素:
let [first] = [1, 2, 3, 4];
console.log(first); // 输出 1或其他元素:
let [, second, , fourth] = [1, 2, 3, 4];
console.log(second); // 输出 2
console.log(fourth); // 输出 4元组解构
元组可以像数组一样解构;解构变量获得对应元组元素的类型:
let tuple: [number, string, boolean] = [7, "hello", true];
let [a, b, c] = tuple; // a: number, b: string, c: boolean解构超出其元素范围的元组是错误的:
let [a, b, c, d] = tuple; // 错误,索引 3 处没有元素与数组一样,你可以使用 ... 解构元组的剩余部分,得到一个较短的元组:
let [a, ...bc] = tuple; // bc: [string, boolean]
let [a, b, c, ...d] = tuple; // d: [],空元组或者忽略尾随元素或其他元素:
let [a] = tuple; // a: number
let [, b] = tuple; // b: string对象解构
你也可以解构对象:
let o = {
a: "foo",
b: 12,
c: "bar",
};
let { a, b } = o;这从 o.a 和 o.b 创建了新变量 a 和 b。 注意如果你不需要 c,可以跳过它。
与数组解构一样,你可以进行不带声明的赋值:
({ a, b } = { a: "baz", b: 101 });注意我们必须将此语句用括号括起来。 JavaScript 通常将 { 解析为块的开始。
你可以使用 ... 语法为对象中的剩余项创建一个变量:
let { a, ...passthrough } = o;
let total = passthrough.b + passthrough.c.length;属性重命名
你也可以给属性指定不同的名称:
let { a: newName1, b: newName2 } = o;这里的语法开始变得混乱。 你可以将 a: newName1 解读为“a 作为 newName1”。 方向是从左到右,就好像你写成了:
let newName1 = o.a;
let newName2 = o.b;令人困惑的是,这里的冒号并不表示类型。 如果你指定类型,它仍然需要写在完整的解构之后:
let { a: newName1, b: newName2 }: { a: string; b: number } = o;默认值
默认值允许你在属性为 undefined 时指定一个默认值:
function keepWholeObject(wholeObject: { a: string; b?: number }) {
let { a, b = 1001 } = wholeObject;
}在这个例子中,b? 表示 b 是可选的,因此它可能是 undefined。 现在 keepWholeObject 拥有 wholeObject 以及属性 a 和 b 的变量,即使 b 是 undefined。
函数声明
解构也适用于函数声明。 对于简单情况,这很直接:
type C = { a: string; b?: number };
function f({ a, b }: C): void {
// ...
}但为参数指定默认值更为常见,并且使用解构正确设置默认值可能很棘手。 首先,你需要记住将模式放在默认值之前。
function f({ a = "", b = 0 } = {}): void {
// ...
}
f();上面的代码片段是类型推断的一个例子,在前面的手册中解释过。
然后,你需要记住为解构属性上的可选属性提供默认值,而不是在主初始化器上。 记住 C 是用 b 可选定义的:
function f({ a, b = 0 } = { a: "" }): void {
// ...
}
f({ a: "yes" }); // 可以,默认 b = 0
f(); // 可以,默认 { a: "" },然后默认 b = 0
f({}); // 错误,如果提供参数,'a' 是必需的谨慎使用解构。 正如前面的示例所示,除了最简单的解构表达式之外,任何其他形式都令人困惑。 对于深度嵌套的解构尤其如此,即使没有重命名、默认值和类型注解,理解起来也非常困难。 尽量保持解构表达式小而简单。 你总是可以自己编写解构将生成的赋值。
展开
展开运算符与解构相反。 它允许你将一个数组展开到另一个数组中,或者将一个对象展开到另一个对象中。 例如:
let first = [1, 2];
let second = [3, 4];
let bothPlus = [0, ...first, ...second, 5];这使 bothPlus 的值为 [0, 1, 2, 3, 4, 5]。 展开创建了 first 和 second 的浅拷贝。 它们不会被展开改变。
你也可以展开对象:
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { ...defaults, food: "rich" };现在 search 是 { food: "rich", price: "$$", ambiance: "noisy" }。 对象展开比数组展开更复杂。 与数组展开一样,它从左到右处理,但结果仍然是一个对象。 这意味着稍后在展开对象中出现的属性会覆盖之前出现的属性。 所以如果我们修改之前的例子,在最后展开:
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { food: "rich", ...defaults };那么 defaults 中的 food 属性会覆盖 food: "rich",这在我们的情况下不是我们想要的。
对象展开还有一些其他令人惊讶的限制。 首先,它只包括对象的 自有、可枚举属性。 基本上,这意味着当你展开一个对象的实例时,你会丢失方法:
class C {
p = 12;
m() {}
}
let c = new C();
let clone = { ...c };
clone.p; // 可以
clone.m(); // 错误!其次,TypeScript 编译器不允许从泛型函数展开类型参数。 该功能预计在语言的未来版本中出现。
using 声明
using 声明是 JavaScript 即将推出的一个特性,是 第 3 阶段显式资源管理提案的一部分。using 声明很像 const 声明,但它将绑定到声明的值的生命周期与变量的作用域耦合在一起。
当控制退出包含 using 声明的块时,声明值的 [Symbol.dispose]() 方法会被执行,从而允许该值执行清理:
function f() {
using x = new C();
doSomethingWith(x);
} // 调用 `x[Symbol.dispose]()`在运行时,这大致等效于以下内容:
function f() {
const x = new C();
try {
doSomethingWith(x);
}
finally {
x[Symbol.dispose]();
}
}当处理持有本地引用(如文件句柄)的 JavaScript 对象时,using 声明对于避免内存泄漏非常有用
{
using file = await openFile();
file.write(text);
doSomethingThatMayThrow();
} // 即使抛出错误,`file` 也会被释放或用于像跟踪这样的作用域操作
function f() {
using activity = new TraceActivity("f"); // 跟踪进入函数
// ...
} // 跟踪退出函数与 var、let 和 const 不同,using 声明不支持解构。
null 和 undefined
需要注意的是,值可以是 null 或 undefined,在这种情况下,块结束时不会释放任何内容:
{
using x = b ? new C() : null;
// ...
}这大致等效于:
{
const x = b ? new C() : null;
try {
// ...
}
finally {
x?.[Symbol.dispose]();
}
}这允许你在声明 using 声明时有条件地获取资源,而无需复杂的分支或重复。
定义可释放资源
你可以通过实现 Disposable 接口来指示你生成的类或对象是可释放的:
// 来自默认库:
interface Disposable {
[Symbol.dispose](): void;
}
// 用法:
class TraceActivity implements Disposable {
readonly name: string;
constructor(name: string) {
this.name = name;
console.log(`Entering: ${name}`);
}
[Symbol.dispose](): void {
console.log(`Exiting: ${name}`);
}
}
function f() {
using _activity = new TraceActivity("f");
console.log("Hello world!");
}
f();
// 打印:
// Entering: f
// Hello world!
// Exiting: fawait using 声明
某些资源或操作可能需要异步执行清理。为了适应这种情况, 显式资源管理提案还引入了 await using 声明:
async function f() {
await using x = new C();
} // 调用 `await x[Symbol.asyncDispose]()`当控制退出包含块时,await using 声明会调用并 等待 其值的 [Symbol.asyncDispose]() 方法。这允许异步清理,例如数据库事务执行回滚或提交,或者文件流在关闭前将任何待处理的写入刷新到存储。
与 await 一样,await using 只能在 async 函数或方法中使用,或者在模块的顶层使用。
定义异步可释放资源
正如 using 依赖于实现 Disposable 的对象一样,await using 依赖于实现 AsyncDisposable 的对象:
// 来自默认库:
interface AsyncDisposable {
[Symbol.asyncDispose]: PromiseLike<void>;
}
// 用法:
class DatabaseTransaction implements AsyncDisposable {
public success = false;
private db: Database | undefined;
private constructor(db: Database) {
this.db = db;
}
static async create(db: Database) {
await db.execAsync("BEGIN TRANSACTION");
return new DatabaseTransaction(db);
}
async [Symbol.asyncDispose]() {
if (this.db) {
const db = this.db;
this.db = undefined;
if (this.success) {
await db.execAsync("COMMIT TRANSACTION");
}
else {
await db.execAsync("ROLLBACK TRANSACTION");
}
}
}
}
async function transfer(db: Database, account1: Account, account2: Account, amount: number) {
using tx = await DatabaseTransaction.create(db);
if (await debitAccount(db, account1, amount)) {
await creditAccount(db, account2, amount);
}
// 如果在此行之前抛出异常,事务将回滚
tx.success = true;
// 现在事务将提交
}await using 与 await
作为 await using 声明一部分的 await 关键字仅表示资源的释放是被 await 的。它并不 await 值本身:
{
await using x = getResourceSynchronously();
} // 执行 `await x[Symbol.asyncDispose]()`
{
await using y = await getResourceAsynchronously();
} // 执行 `await y[Symbol.asyncDispose]()`await using 与 return
需要注意的是,如果在 async 函数中使用 await using 声明,并且该函数返回一个 Promise 而没有首先 await 它,那么此行为有一个小的注意事项:
function g() {
return Promise.reject("error!");
}
async function f() {
await using x = new C();
return g(); // 缺少一个 `await`
}由于返回的 promise 没有被 await,JavaScript 运行时可能会报告未处理的拒绝,因为执行在等待 x 的异步释放时暂停,而没有订阅返回的 promise。然而,这不是 await using 独有的问题,因为在使用 try..finally 的 async 函数中也可能发生:
async function f() {
try {
return g(); // 也会报告未处理的拒绝
}
finally {
await somethingElse();
}
}为了避免这种情况,建议你 await 返回值(如果它可能是 Promise):
async function f() {
await using x = new C();
return await g();
}for 和 for..of 语句中的 using 和 await using
using 和 await using 都可以在 for 语句中使用:
for (using x = getReader(); !x.eof; x.next()) {
// ...
}在这种情况下,x 的生命周期限定在整个 for 语句,并且仅在因 break、return、throw 或循环条件为假而退出循环时才会释放。
除了 for 语句外,这两种声明也可以在 for..of 语句中使用:
function * g() {
yield createResource1();
yield createResource2();
}
for (using x of g()) {
// ...
}这里,x 在循环的每次迭代结束时释放,然后使用下一个值重新初始化。这在消费生成器一次生成一个的资源时特别有用。
在较旧的运行时中使用 using 和 await using
当目标为较旧的 ECMAScript 版本时,只要使用兼容的 Symbol.dispose/Symbol.asyncDispose polyfill(例如在最新版本的 NodeJS 中默认提供的 polyfill),就可以使用 using 和 await using 声明。