变量声明

let 和 const 是 JavaScript 中相对较新的两个变量声明概念。正如我们前面提到的,let 在某些方面与 var 相似,但它允许用户避开在 JavaScript 中常见的一些“坑”。

const 是对 let 的增强,因为它阻止对变量重新赋值。

由于 TypeScript 是 JavaScript 的扩展,这门语言自然支持 let 和 const。下面我们会进一步说明这些新的声明,以及为什么它们比 var 更可取。

如果你只是偶尔用用 JavaScript,下一节可以很好地帮你重温记忆。如果你对 JavaScript 中 var 声明的种种怪异行为已经了如指掌,大可以直接跳过。

var 声明

在 JavaScript 中,声明变量历来都是使用 var 关键字完成的。

ts
var a = 10;

正如你可能已经猜到的那样,我们刚刚声明了一个名为 a、值为 10 的变量。

我们也可以在函数内部声明变量:

ts
function f() {
var message = "Hello, world!";
return message;
}

而且我们还可以在其他函数中访问这些相同的变量:

ts
function f() {
var a = 10;
return function g() {
var b = a + 1;
return b;
};
}
var g = f();
g(); // returns '11'

在上面的示例中,g 捕获了在 f 中声明的变量 a。无论何时调用 g,a 的值都会与 f 中 a 的值保持关联。即使 g 是在 f 运行结束之后才被调用,它也依然能够访问并修改 a。

ts
function f() {
var a = 1;
a = 2;
var b = g();
a = 3;
return b;
function g() {
return a;
}
}
f(); // returns '2'

作用域规则

var 声明对于习惯了其他语言的人来说有一些奇怪的作用域规则。请看下面的示例:

ts
function f(shouldInitialize: boolean) {
if (shouldInitialize) {
var x = 10;
}
return x;
}
f(true); // returns '10'
f(false); // returns 'undefined'

有些读者看到这个示例可能会愣一下。变量 x 是在 if 块内部声明的,但我们却能从该块外部访问它。这是因为 var 声明在其所在的函数、模块、命名空间或全局作用域内的任何位置都可访问——这些我们稍后都会讲到——不管它位于哪个块中。有人把这称为 var 作用域或函数作用域。参数同样属于函数作用域。

这些作用域规则会导致几种错误。其中一个被加剧的问题就是:多次声明同一个变量并不算错误:

ts
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 会从代码审查中溜过去,并成为无尽挫败感的来源。

变量捕获的怪异行为

花一秒钟猜猜下面这段代码的输出是什么:

ts
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——立即调用的函数表达式(Immediately Invoked Function Expression)——在每次迭代时捕获 i:

ts
for (var i = 0; i < 10; i++) {
// capture the current state of 'i'
// by invoking a function with its current value
(function (i) {
setTimeout(function () {
console.log(i);
}, 100 * i);
})(i);
}

这种看起来奇怪的模式其实相当常见。参数列表中的 i 实际上遮蔽了 for 循环中声明的 i,但由于我们把它们命名成了相同的名字,所以不必对循环体做太多修改。

let 声明

到这里你已经明白 var 存在一些问题,而这正是引入 let 语句的原因。除了使用的关键字不同,let 语句的写法与 var 语句完全一样。

ts
let hello = "Hello!";

关键区别不在于语法,而在于语义,我们现在就来深入探讨。

块级作用域

使用 let 声明变量时,它采用的是有人称之为词法作用域或块级作用域的规则。与作用域会泄漏到所在函数的 var 变量不同,块级作用域变量在其最近的包含块或 for 循环之外不可见。

ts
function f(input: boolean) {
let a = 100;
if (input) {
// Still okay to reference 'a'
let b = a + 1;
return b;
}
// Error: 'b' doesn't exist here
return b;
}

这里我们有两个局部变量 a 和 b。a 的作用域限于 f 的函数体,而 b 的作用域限于包含它的 if 语句块。

在 catch 子句中声明的变量也遵循类似的作用域规则。

ts
try {
throw "oh no!";
} catch (e) {
console.log("Oh well.");
}
// Error: 'e' doesn't exist here
console.log(e);

块级作用域变量的另一个特性是:在真正声明它们之前,既不能读取也不能写入。虽然这些变量在整个作用域内都“存在”,但从作用域起点直到其声明之前的所有位置都属于它们的暂时性死区。这只是一种更高级的说法,意思是在 let 语句之前你无法访问它们,而幸运的是 TypeScript 会提醒你这一点。

ts
a++; // illegal to use 'a' before it's declared;
let a;

需要注意的一点是,你仍然可以在块级作用域变量声明之前捕获它。唯一的陷阱是,在声明之前调用该函数是非法的。如果目标是 ES2015,现代运行时将抛出错误;不过目前 TypeScript 比较宽容,不会把这报告为错误。

ts
function foo() {
// okay to capture 'a'
return a;
}
// illegal call 'foo' before 'a' is declared
// runtimes should throw an error here
foo();
let a;

有关暂时性死区的更多信息,请参阅 Mozilla Developer Network 上的相关内容。

重复声明与遮蔽

对于 var 声明,我们提到过声明变量多少次都无所谓;你只会得到一个变量。

ts
function f(x) {
var x;
var x;
if (true) {
var x;
}
}

在上面的示例中,x 的所有声明实际上都指向同一个x,而且这完全合法。这往往成为 bug 的来源。幸运的是,let 声明并没有这么宽容。

ts
let x = 10;
let x = 20; // error: can't re-declare 'x' in the same scope

变量并不一定都得是块级作用域,TypeScript 才会告诉我们出现了问题。

ts
function f(x) {
let x = 100; // error: interferes with parameter declaration
}
function g() {
let x = 100;
var x = 100; // error: can't have both declarations of 'x'
}

这并不是说块级作用域变量永远不能与函数作用域变量同名声明。块级作用域变量只需在一个明显不同的块中声明即可。

ts
function f(condition, x) {
if (condition) {
let x = 100;
return x;
}
return x;
}
f(false, 0); // returns '0'
f(true, 0); // returns '100'

在嵌套更深的作用域中引入新名称的行为称为遮蔽。它其实是一把双刃剑:意外的遮蔽本身会引入某些 bug,同时它也能避免某些 bug。例如,假设我们用 let 变量来编写前面的 sumMatrix 函数。

ts
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 声明进行变量捕获的概念时,我们简要介绍了变量被捕获后的行为。为了更好地理解这一点,可以认为每次执行一个作用域时,它都会创建一个变量“环境”。即使该作用域内的一切都已执行完毕,这个环境及其捕获的变量仍然可以存在。

ts
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 声明。

ts
for (let i = 0; i < 10; i++) {
setTimeout(function () {
console.log(i);
}, 100 * i);
}

正如预期,这会打印出

0
1
2
3
4
5
6
7
8
9

const 声明

const 声明是声明变量的另一种方式。

ts
const numLivesForCat = 9;

它们类似于 let 声明,但顾名思义,其值一旦绑定就不能更改。换句话说,它们与 let 具有相同的作用域规则,但不能对它们重新赋值。

不要将这一点与它们所引用的值不可变混为一谈。

ts
const numLivesForCat = 9;
const kitty = {
name: "Aurora",
numLives: numLivesForCat,
};
// Error
kitty = {
name: "Danielle",
numLives: numLivesForCat,
};
// all "okay"
kitty.name = "Rory";
kitty.name = "Kitty";
kitty.name = "Cat";
kitty.numLives--;

除非你采取特定措施来避免,否则 const 变量的内部状态仍然可以修改。幸运的是,TypeScript 允许你指定对象的成员为 readonly。关于接口的章节有详细说明。

let 与 const

既然我们有两种作用域语义相似的声明,自然会问该用哪一种。像大多数宽泛的问题一样,答案是:视情况而定。

遵循最小权限原则,除了你计划修改的声明外,所有声明都应使用 const。其道理是:如果某个变量不需要被写入,那么同一代码库中的其他人就不应能自动写入该对象,并且需要考虑他们是否真的需要重新赋值该变量。在推理数据流时,使用 const 还能让代码更具可预测性。

请做出最佳判断,如果适用,与团队其他成员商议此事。

本手册的大部分内容使用 let 声明。

解构

TypeScript 还支持 ECMAScript 2015 的另一项特性:解构。完整参考请见Mozilla Developer Network 上的文章。本节将给出简要概述。

数组解构

最简单的解构形式是数组解构赋值:

ts
let input = [1, 2];
let [first, second] = input;
console.log(first); // outputs 1
console.log(second); // outputs 2

这会创建两个新变量,分别名为 first 和 second。这等同于使用索引,但要方便得多:

ts
first = input[0];
second = input[1];

解构也适用于已声明的变量:

ts
// swap variables
[first, second] = [second, first];

也适用于函数参数:

ts
function f([first, second]: [number, number]) {
console.log(first);
console.log(second);
}
f([1, 2]);

你可以使用 ... 语法为列表中的剩余项创建一个变量:

ts
let [first, ...rest] = [1, 2, 3, 4];
console.log(first); // outputs 1
console.log(rest); // outputs [ 2, 3, 4 ]

当然,由于这是 JavaScript,你可以直接忽略不关心的末尾元素:

ts
let [first] = [1, 2, 3, 4];
console.log(first); // outputs 1

或其他元素:

ts
let [, second, , fourth] = [1, 2, 3, 4];
console.log(second); // outputs 2
console.log(fourth); // outputs 4

元组解构

元组可以像数组一样解构;解构变量会获得相应元组元素的类型:

ts
let tuple: [number, string, boolean] = [7, "hello", true];
let [a, b, c] = tuple; // a: number, b: string, c: boolean

解构元组时超出其元素范围会报错:

ts
let [a, b, c, d] = tuple; // Error, no element at index 3

与数组一样,你可以用 ... 解构元组的其余部分,得到一个更短的元组:

ts
let [a, ...bc] = tuple; // bc: [string, boolean]
let [a, b, c, ...d] = tuple; // d: [], the empty tuple

或者忽略末尾元素,或其他元素:

ts
let [a] = tuple; // a: number
let [, b] = tuple; // b: string

对象解构

你也可以解构对象:

ts
let o = {
a: "foo",
b: 12,
c: "bar",
};
let { a, b } = o;

这会从 o.a 和 o.b 创建新变量 a 和 b。注意,如果不需要 c,可以跳过它。

与数组解构一样,你也可以在不声明的情况下进行赋值:

ts
({ a, b } = { a: "baz", b: 101 });

注意,我们必须用括号将此语句括起来。JavaScript 通常会把 { 解析为块的开始。

你可以使用 ... 语法为对象中的剩余项创建一个变量:

ts
let { a, ...passthrough } = o;
let total = passthrough.b + passthrough.c.length;

属性重命名

你还可以为属性指定不同的名称:

ts
let { a: newName1, b: newName2 } = o;

这里的语法开始令人困惑。你可以把 a: newName1 读作“a 作为 newName1”。方向是从左到右,就像你写了:

ts
let newName1 = o.a;
let newName2 = o.b;

令人困惑的是,这里的冒号并不表示类型。如果你要指定类型,仍然需要写在整个解构之后:

ts
let { a: newName1, b: newName2 }: { a: string; b: number } = o;

默认值

默认值让你可以在属性为 undefined 时指定默认值:

ts
function keepWholeObject(wholeObject: { a: string; b?: number }) {
let { a, b = 1001 } = wholeObject;
}

在这个示例中,b? 表示 b 是可选的,因此它可能是 undefined。现在 keepWholeObject 既有 wholeObject 的变量,也有属性 a 和 b,即使 b 是 undefined。

函数声明

解构在函数声明中同样有效。对于简单的情况,这很直接:

ts
type C = { a: string; b?: number };
function f({ a, b }: C): void {
// ...
}

但对于参数来说,指定默认值更为常见,而要在解构中正确处理默认值可能很棘手。首先,你需要记得把模式放在默认值之前。

ts
function f({ a = "", b = 0 } = {}): void {
// ...
}
f();

上面的片段是类型推断的一个例子,本手册前面已经解释过。

其次,你需要记得在解构属性上为可选属性提供默认值,而不是在主初始化器上。请记住,C 定义时 b 是可选的:

ts
function f({ a, b = 0 } = { a: "" }): void {
// ...
}
f({ a: "yes" }); // ok, default b = 0
f(); // ok, default to { a: "" }, which then defaults b = 0
f({}); // error, 'a' is required if you supply an argument

请谨慎使用解构。正如上一个示例所示,除了最简单的解构表达式之外,其他都很容易让人困惑。深度嵌套的解构尤其如此,即使不叠加重命名、默认值和类型注解,也已经非常难以理解。尽量让解构表达式保持小巧简单。你总是可以自己写出解构会生成的赋值语句。

展开

展开运算符与解构相反。它允许你将一个数组展开到另一个数组中,或将一个对象展开到另一个对象中。例如:

ts
let first = [1, 2];
let second = [3, 4];
let bothPlus = [0, ...first, ...second, 5];

这使 bothPlus 的值为 [0, 1, 2, 3, 4, 5]。展开会创建 first 和 second 的浅拷贝。它们不会被展开操作改变。

你也可以展开对象:

ts
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { ...defaults, food: "rich" };

现在 search 是 { food: "rich", price: "$$", ambiance: "noisy" }。对象展开比数组展开更复杂。与数组展开一样,它从左到右进行,但结果仍然是一个对象。这意味着展开对象中靠后的属性会覆盖靠前的属性。因此,如果我们修改上一个示例,把展开放在末尾:

ts
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { food: "rich", ...defaults };

随后 defaults 中的 food 属性会覆盖 food: "rich",而这并非我们在此场景下想要的结果。

对象展开还有几个其他令人意外的限制。首先,它只包含对象的自有可枚举属性。基本上,这意味着展开某个对象的实例时会丢失其方法:

ts
class C {
p = 12;
m() {}
}
let c = new C();
let clone = { ...c };
clone.p; // ok
clone.m(); // error!

其次,TypeScript 编译器不允许展开泛型函数的类型参数。该特性预计会在该语言的未来版本中提供。

using 声明

using 声明是 JavaScript 即将推出的一项特性,属于 Stage 3 显式资源管理提案的一部分。using 声明与 const 声明非常相似,区别在于它会把绑定到该声明的值的生命周期与变量的作用域关联起来。

当控制流离开包含 using 声明的代码块时,会执行所声明值的 [Symbol.dispose]() 方法,从而使该值能够执行清理工作:

ts
function f() {
using x = new C();
doSomethingWith(x);
} // `x[Symbol.dispose]()` is called

在运行时,其效果大致等同于以下代码:

ts
function f() {
const x = new C();
try {
doSomethingWith(x);
}
finally {
x[Symbol.dispose]();
}
}

在处理持有文件句柄等原生引用的 JavaScript 对象时,using 声明对于避免内存泄漏非常有用

ts
{
using file = await openFile();
file.write(text);
doSomethingThatMayThrow();
} // `file` is disposed, even if an error is thrown

或是像跟踪这类作用域受限的操作

ts
function f() {
using activity = new TraceActivity("f"); // traces entry into function
// ...
} // traces exit of function

与 var、let 和 const 不同,using 声明不支持解构。

null 与 undefined

需要注意的是,该值可以是 null 或 undefined,此时在代码块结束时不会释放任何内容:

ts
{
using x = b ? new C() : null;
// ...
}

它大致等同于:

ts
{
const x = b ? new C() : null;
try {
// ...
}
finally {
x?.[Symbol.dispose]();
}
}

这样一来,在编写 using 声明时,你就可以按条件获取资源,而无需复杂的分支或重复代码。

定义可释放的资源

你可以通过实现 Disposable 接口,来表明自己生成的类或对象是可释放的:

ts
// from the default lib:
interface Disposable {
[Symbol.dispose](): void;
}
// usage:
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();
// prints:
// Entering: f
// Hello world!
// Exiting: f

await using 声明

某些资源或操作的清理工作需要异步执行。为此,显式资源管理提案还引入了 await using 声明:

ts
async function f() {
await using x = new C();
} // `await x[Symbol.asyncDispose]()` is invoked

当控制流离开包含 await using 声明的代码块时,该声明会调用并等待其值的 [Symbol.asyncDispose]() 方法。这支持异步清理,例如数据库事务执行回滚或提交,或者文件流在关闭前把挂起的写入刷入存储。

与 await 一样,await using 只能用在 async 函数或方法中,或用在模块顶层。

定义可异步释放的资源

正如 using 依赖 Disposable 对象,await using 依赖 AsyncDisposable 对象:

ts
// from the default lib:
interface AsyncDisposable {
[Symbol.asyncDispose]: PromiseLike<void>;
}
// usage:
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) {
await using tx = await DatabaseTransaction.create(db);
if (await debitAccount(db, account1, amount)) {
await creditAccount(db, account2, amount);
}
// if an exception is thrown before this line, the transaction will roll back
tx.success = true;
// now the transaction will commit
}

await using 与 await

await using 声明中的 await 关键字只表示资源的释放会被 await。它不会 await 值本身:

ts
{
await using x = getResourceSynchronously();
} // performs `await x[Symbol.asyncDispose]()`
{
await using y = await getResourceAsynchronously();
} // performs `await y[Symbol.asyncDispose]()`

await using 与 return

需要注意的是,如果你在 async 函数中使用 await using 声明,并返回一个未先 await 的 Promise,那么这一行为有一个小细节值得留意:

ts
function g() {
return Promise.reject("error!");
}
async function f() {
await using x = new C();
return g(); // missing an `await`
}

由于返回的 promise 没有被 await,JavaScript 运行时可能会报告未处理的拒绝,因为在 await 对 x 的异步释放期间,执行会暂停,而此时尚未订阅返回的 promise。不过,这并不是 await using 独有的问题,使用 try..finally 的 async 函数也可能出现同样的情况:

ts
async function f() {
try {
return g(); // also reports an unhandled rejection
}
finally {
await somethingElse();
}
}

为避免这种情况,建议在返回值可能是 Promise 时先 await 它:

ts
async function f() {
await using x = new C();
return await g();
}

for 和 for..of 语句中的 using 与 await using

using 和 await using 都可以用在 for 语句中:

ts
for (using x = getReader(); !x.eof; x.next()) {
// ...
}

在这种情况下,x 的生命周期作用于整个 for 语句,只有因 break、return、throw 导致控制流离开循环,或者循环条件为假时,它才会被释放。

除了 for 语句,这两种声明也可以用在 for..of 语句中:

ts
function * g() {
yield createResource1();
yield createResource2();
}
for (using x of g()) {
// ...
}

在这里,x 会在循环的每次迭代结束时被释放,然后用下一个值重新初始化。这在逐个消费生成器产生的资源时特别有用。

旧版运行时中的 using 与 await using

只要为 Symbol.dispose/Symbol.asyncDispose 使用了兼容的 polyfill(例如近期版本的 NodeJS 默认提供的那个),就可以在目标为较旧的 ECMAScript 版本时使用 using 和 await using 声明。

TypeScript 文档是一个开源项目。欢迎发送 Pull Request 帮助我们改进这些页面 ❤

最后更新于:2026 年 10 月 23 日