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
的值绑定。即使在 f
运行完毕后调用 g
,它也能够访问和修改 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
指的是同一个函数作用域的变量。正如经验丰富的开发人员现在所知,类似的错误会通过代码审查,并可能成为无休止的挫折来源。
变量捕获的怪癖
花几秒钟猜一下以下代码片段的输出是什么
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 - 立即调用的函数表达式 - 在每次迭代中捕获 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 herereturn b;}
这里,我们有两个局部变量 a
和 b
。a
的范围仅限于 f
的主体,而 b
的范围仅限于包含的 if
语句的块。
在 catch
子句中声明的变量也具有类似的作用域规则。
ts
try {throw "oh no!";} catch (e) {console.log("Oh well.");}// Error: 'e' doesn't exist hereconsole.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 herefoo();let a;
有关时间死区的更多信息,请参阅 Mozilla 开发者网络 上的相关内容。
重新声明和遮蔽
在 var
声明中,我们提到过,声明变量的次数并不重要,你只会得到一个变量。
ts
function f(x) {var x;var x;if (true) {var x;}}
在上面的例子中,所有对 x
的声明实际上都指向同一个 x
,这是完全有效的。但这往往会导致错误。幸运的是,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'
在更嵌套的作用域中引入新名称的行为被称为遮蔽。这有点像一把双刃剑,它本身可能会在意外遮蔽的情况下引入某些错误,但也能够防止某些错误。例如,想象一下我们使用 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
声明作为循环的一部分声明时,其行为与var
声明截然不同。这些声明不是简单地为循环本身引入一个新的环境,而是为每次迭代创建一个新的作用域。由于这正是我们使用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,};// Errorkitty = {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 开发者网络上的文章。在本节中,我们将简要概述。
数组解构
最简单的解构形式是数组解构赋值
ts
let input = [1, 2];let [first, second] = input;console.log(first); // outputs 1console.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 1console.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 2console.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: numberlet [, 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;
默认值
默认值允许您在属性未定义的情况下指定默认值。
ts
function keepWholeObject(wholeObject: { a: string; b?: number }) {let { a, b = 1001 } = wholeObject;}
在这个例子中,b?
表示 b
是可选的,所以它可能是 undefined
。keepWholeObject
现在有一个 wholeObject
的变量,以及 a
和 b
的属性,即使 b
是未定义的。
函数声明
解构也可以在函数声明中使用。对于简单的情况,这很简单。
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 = 0f(); // ok, default to { a: "" }, which then defaults b = 0f({}); // 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; // okclone.m(); // error!
其次,TypeScript 编译器不允许从泛型函数中展开类型参数。该功能预计将在该语言的未来版本中提供。
using
声明
using
声明是 JavaScript 的一项即将推出的功能,它是 第 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]();}}
using
声明在处理包含本地引用(如文件句柄)的 JavaScript 对象时非常有用,可以避免内存泄漏。
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) {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 backtx.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
需要注意的是,如果您在返回 Promise
的 async
函数中使用 await using
声明,而没有先 await
它,则这种行为有一个小小的注意事项。
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();}
using
和 await using
在 for
和 for..of
语句中
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 最新版本中默认提供的 polyfill),就可以在针对旧的 ECMAScript 版本时使用 using
和 await using
声明。