# 变量声明
let
和 const
是 JavaScript 中变量声明的两个相对较新的概念。正如我们前面提到的, let
在某些方面与 var
相似,但允许用户避开一些用户在 JavaScript 中遇到的常见 "gotchas"。
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(); // returns '11'
在上述示例中,g
捕获了在 f
中声明的变量 a
。在 g
被调用的任何时候,a
的值将与 f
中的 a
的值绑定。即使在 f
运行完成后调用 g
,它也可以访问和修改 a
。
function f() {
var a = 1;
a = 2;
var b = g();
a = 3;
return b;
function g() {
return a;
}
}
f(); // returns '2'
# 作用域规则
var
声明对于那些用于其他语言的声明有一些奇怪的作用域规则。举个例子:
function f(shouldInitialize: boolean) {
if (shouldInitialize) {
var x = 10;
}
return x;
}
f(true); // returns '10'
f(false); // returns '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
指的是同一个函数作用域的变量。正如有经验的开发人员现在所知道的那样,类似的错误会从代码审查中溜走,并且可能会导致无穷无尽的挫败感。
# 变量获取杂项
花点时间猜一下以下代码段的输出是什么:
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++) {
// 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
语句相同。
let hello = "Hello!";
关键的区别不在于语法,而在于语义,我们现在将深入探讨。
# 块作用域
当使用 let
声明一个变量时,它使用一些所谓的词法作用域或块作用域。与使用 var
声明的变量的作用域泄漏到其包含函数不同,块作用域变量在其最近的包含块或 for
循环之外不可见。
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
子句中声明的变量也有类似的作用域规则。
try {
throw "oh no!";
} catch (e) {
console.log("Oh well.");
}
// Error: 'e' doesn't exist here
console.log(e);
块作用域变量的另一个特性是它们在实际声明之前不能被读取或写入。虽然这些变量在其整个作用域内都是 "present",但直到它们声明之前的所有点都是它们临时死区的一部分。这只是在 let
语句之前表示您无法访问它们的一种复杂方式,幸运的是 TypeScript 会让您知道这一点。
a++; // illegal to use 'a' before it's declared;
let a;
需要注意的是,您仍然可以在声明之前捕获块作用域的变量。唯一的问题是在声明之前调用该函数是非法的。如果以 ES2015 为目标,现代运行时将抛出错误;但是,现在 TypeScript 是允许的,不会将其报告为错误。
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 开发者网络
上的相关内容。
# 重新声明和阴影
在 var
声明中,我们提到过声明变量的次数无关紧要。你刚得到一个。
function f(x) {
var x;
var x;
if (true) {
var x;
}
}
在上面的例子中,x
的所有声明实际上都指向同一个 x
,这是完全有效的。这通常最终成为错误的来源。值得庆幸的是,let
声明并不那么宽容。
let x = 10;
let x = 20; // error: can't re-declare 'x' in the same scope
对于 TypeScript 来说,变量不一定都需要在块作用域内才能告诉我们存在问题。
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'
}
这并不是说块作用域的变量永远不能用函数作用域的变量声明。块作用域的变量只需要在一个明显不同的块中声明。
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
函数。
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
声明进行变量捕获的想法时,我们简要介绍了变量在捕获后的行为方式。为了更好地理解这一点,每次运行作用域时,它都会创建 "environment" 个变量。即使在其作用域内的所有内容都完成执行之后,该环境及其捕获的变量仍然存在。
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
9
# const 声明
const
声明是另一种声明变量的方式。
const numLivesForCat = 9;
它们类似于 let
声明,但顾名思义,一旦绑定,它们的值就不能更改。换句话说,它们具有与 let
相同的作用域规则,但您不能重新分配给它们。
这不应与它们引用的值是不可变的想法相混淆。
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 开发者网络上的文章
。在本节中,我们将给出一个简短的概述。
# 数组解构
最简单的解构形式是数组解构赋值:
let input = [1, 2];
let [first, second] = input;
console.log(first); // outputs 1
console.log(second); // outputs 2
这将创建两个名为 first
和 second
的新变量。这相当于使用索引,但更方便:
first = input[0];
second = input[1];
解构也适用于已经声明的变量:
// swap variables
[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); // outputs 1
console.log(rest); // outputs [ 2, 3, 4 ]
当然,由于这是 JavaScript,你可以忽略你不关心的尾随元素:
let [first] = [1, 2, 3, 4];
console.log(first); // outputs 1
或其他元素:
let [, second, , fourth] = [1, 2, 3, 4];
console.log(second); // outputs 2
console.log(fourth); // outputs 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; // Error, no element at index 3
与数组一样,您可以使用 ...
解构元组的其余部分,以获得更短的元组:
let [a, ...bc] = tuple; // bc: [string, boolean]
let [a, b, c, ...d] = tuple; // d: [], the empty tuple
或忽略尾随元素或其他元素:
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
as newName1
"。方向是从左到右的,就好像你写过:
let newName1 = o.a;
let newName2 = o.b;
令人困惑的是,这里的冒号并不表示类型。类型,如果你指定它,仍然需要在整个解构之后编写:
let { a: newName1, b: newName2 }: { a: string; b: number } = o;
# 默认值
默认值允许您在未定义属性的情况下指定默认值:
function keepWholeObject(wholeObject: { a: string; b?: number }) {
let { a, b = 1001 } = wholeObject;
}
在这个例子中,b?
表示 b
是可选的,所以它可能是 undefined
。keepWholeObject
现在具有 wholeObject
的变量以及属性 a
和 b
,即使 b
未定义。
# 函数声明
解构也适用于函数声明。对于简单的情况,这很简单:
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" }); // ok, default b = 0
f(); // ok, default to { a: "" }, which then defaults b = 0
f({}); // error, 'a' is required if you supply an argument
谨慎使用解构。正如前面的例子所展示的,除了最简单的解构表达式之外,任何东西都是令人困惑的。对于深度嵌套的解构尤其如此,即使没有重命名、默认值和类型注释,也很难理解。尽量保持解构表达式小而简单。您始终可以编写解构将自己生成的分配。
# 展开
扩展运算符与解构相反。它允许您将一个数组传播到另一个数组中,或者将一个对象传播到另一个对象中。例如:
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; // ok
clone.m(); // error!
其次,TypeScript 编译器不允许从泛型函数传播类型参数。该功能预计将出现在该语言的未来版本中。