TypeScript Office - 变量声明
# 变量声明
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;
}
2
3
4
而我们也可以在其他函数中访问这些相同的变量:
function f() {
var a = 10;
return function g() {
var b = a + 1;
return b;
};
}
var g = f();
g(); // returns '11'
2
3
4
5
6
7
8
9
在上面这个例子中,g 捕获了 f 中声明的变量 a。在 g 被调用的任何时候,a 的值都将与 f 中 a 的值相联系。
function f() {
var a = 1;
a = 2;
var b = g();
a = 3;
return b;
function g() {
return a;
}
}
f(); // returns '2'
2
3
4
5
6
7
8
9
10
11
# 作用域规则
对于那些习惯于其他语言的人来说,var 声明有一些奇怪的作用域范围规则。以下面的例子为例:
function f(shouldInitialize: boolean) {
if (shouldInitialize) {
var x = 10;
}
return x;
}
f(true); // 返回 '10'
f(false); // 返回 'undefined'
2
3
4
5
6
7
8
有些读者可能会对这个例子产生怀疑。变量 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;
}
2
3
4
5
6
7
8
9
10
也许对于一些有经验的 JavaScript 开发者来说,这很容易被发现,但是内部 for-loop 会意外地覆盖变量 i,因为 i 指的是同一个函数范围的变量。正如有经验的开发者现在所知道的,类似的各种 bug 会在代码审查中溜走,并会成为无尽的挫折来源。
# 变量捕获的怪癖
花点时间猜一猜下面这段话的输出是什么:
for (var i = 0; i < 10; i++) {
setTimeout(function () {
console.log(i);
}, 100 * i);
}
2
3
4
5
对于那些不熟悉的人来说,setTimeout 将尝试在一定数量的毫秒后执行一个函数(尽管要等待其他东西停止运行)。
准备好了吗?看看吧。
10
10
10
10
10
10
10
10
10
10
2
3
4
5
6
7
8
9
10
许多 JavaScript 开发人员对这种行为非常熟悉,但如果你感到惊讶,你肯定不是一个人。大多数人都希望输出的结果是:
0
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
10
还记得我们前面提到的关于变量捕获的问题吗?我们传递给 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);
}
2
3
4
5
6
7
8
9
这种看起来很奇怪的模式其实是很常见的。参数列表中的 i 实际上是对 for 循环中声明的 i 的影子,但由于我们对它们的命名相同,所以我们不必对循环体进行过多的修改。
# let 变量声明
现在你已经发现 var 有一些问题,这正是 let 语句被引入的原因。除了使用的关键字外,let 语句的写法与 var 语句相同。
let hello = "Hello!";
关键的区别不在语法上,而在语义上,我们现在要深入研究。
# 块级作用域
当一个变量使用 let 声明时,它使用了一些人所说的词法范围或块法范围。与用 var 声明的变量不同,block-scope 变量的作用域会泄露给其包含的函数,而在其最近的包含块或 for-loop 之外是不可见的。
function f(input: boolean) {
let a = 100;
if (input) {
// 引用'a'仍然可以
let b = a + 1;
return b;
}
// 错误:这里不存在'b'。
return b;
}
2
3
4
5
6
7
8
9
10
在这里,我们有两个局部变量 a 和 b。a 的作用域仅限于 f 的主体,而 b 的作用域仅限于包含 if 语句的块。
在 catch 子句中声明的变量也有类似的作用域规则。
try {
throw "oh no!";
} catch (e) {
console.log("Oh well.");
}
// Error: 这里不存在'e'。
console.log(e);
2
3
4
5
6
7
块级作用域变量的另一个属性是,在它们被实际声明之前,它们不能被读或写到。虽然这些变量在它们的整个作用域中都是「存在」的,但是直到它们被声明之前的所有点都是它们的时间死角的一部分。这只 是一种复杂的说法,你不能在 let 语句之前访问它们,幸运的是 TypeScript 会让你知道这一点。
a++; // 在声明之前使用'a'是非法的。
let a;
2
需要注意的是,你仍然可以在声明之前捕获一个块范围的变量。唯一的问题是,在声明之前调用该函数是非法的。如果以 ES2015 为目标,现代运行时将抛出一个错误;然而,现在 TypeScript 是允许的,不会将此作为一个错误报告。
function foo() {
// 可以捕捉到 "a"。
return a;
}
// 在声明'a'之前非法调用'foo'。
// runtimes应该在这里抛出一个错误
foo();
let a;
2
3
4
5
6
7
8
# 重复声明和投影
对于 var 声明,我们提到,你声明了多少次变量并不重要,你只是得到了一个。
function f(x) {
var x; var x;
if (true) {
var x;
}
}
2
3
4
5
6
在上面的例子中,所有关于 x 的声明实际上指的是同一个 x ,这是完全有效的。这往往会成为错误的根源。值得庆幸的是,let 的声明并不那么宽容。
let x = 10;
let x = 20; // 错误:不能在同一范围内重新声明'x'。
2
变量不一定要都是块范围的,TypeScript 才会告诉我们有一个问题。
function f(x) {
let x = 100; // 错误:干扰了参数声明
}
function g() {
let x = 100;
var x = 100; // 错误:不能同时有'x'的声明
}
2
3
4
5
6
7
这并不是说一个块作用域变量永远不能和一个函数作用域变量一起声明。区块作用域变量只是需要在一个明显不同的区块中声明。
function f(condition, x) {
if (condition) {
let x = 100;
return x;
}
return x;
}
f(false, 0); // 返回 '0'
f(true, 0); // 返回 '100'
2
3
4
5
6
7
8
9
在一个更加嵌套的作用域中引入一个新名字的行为被称为投影。这是一把双刃剑,因为它可以在意外影射的情况下自行引入某些错误,同时也可以防止某些错误。例如,想象一下我们之前用 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;
}
2
3
4
5
6
7
8
9
10
这个版本的循环实际上会正确地执行求和,因为内循环的 i 会对外循环的 i 产生阴影。
为了写出更清晰的代码,通常应避免使用投影。虽然在某些情况下,利用它可能是合适的,但你应该使用你的最佳判断。
# 块级作用域变量捕获
当我们第一次触及用 var 声明捕获变量的想法时,我们简要地讨论了变量一旦被捕获是如何行动的。为了给大家一个更好的直观印象,每次运行一个作用域时,它都会创建一个变量的「环境」。这个环境和它捕获的变量甚至在它的作用域内的所有东西都执行完毕后仍然存在。
function theCityThatAlwaysSleeps() {
let getCity;
if (true) {
let city = "Seattle";
getCity = function () {
return city;
};
}
return getCity();
}
2
3
4
5
6
7
8
9
10
因为我们已经从它的环境中捕获了 city ,所以尽管 if 块已经执行完毕,我们仍然能够访问它。
回想一下,在我们之前的 setTimeout 例子中,我们最终需要使用 IIFE 来捕获 for 循环的每个迭代中的变量状态。实际上,我们所做的是为我们捕获的变量创建一个新的变量环境。这有点麻烦,但幸运的是,在 TypeScript 中你再也不用这么做了。
当声明为循环的一部分时,let 声明的行为有很大的不同。这些声明并不只是给循环本身引入一个新的环境,而是在每个迭代中创建一个新的范围。因为这就是我们在IIFE中所做的事情,我们可以改变我们以前的 setTimeout 的例子,只使用 let 声明。
for (let i = 0; i < 10; i++) {
setTimeout(function () {
console.log(i);
}, 100 * i);
}
2
3
4
5
和预期的一样,这将打印出:
0
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
10
# const 声明
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--;
2
3
4
5
6
7
8
9
10
11
12
13
14
除非你采取特定的措施来避免它,否则常量变量的内部状态仍然是可以修改的。幸运的是,TypeScript 允许你指定一个对象的成员是 readonly 的。
# let 与 const 比较
鉴于我们有两种具有类似范围语义的声明,我们很自然地会问自己应该使用哪一种。像大多数广泛的问题一样,答案是:这取决于如下原则。
根据最小特权原则,除了那些你打算修改的声明外,所有的声明都应该使用 const。其理由是,如果一个变量不需要被写入,那么在同一个代码库中工作的其他人就不应该自动能够写入该对象,他们需要考虑是否真的需要重新赋值给该变量。在推理数据流时,使用 const 也会使代码更可预测。
使用你的最佳判断,如果适用的话,请与你的团队其他成员协商此事。
# 解构
# 数组析构(解构)
最简单的解构形式是数组解构赋值。
let input = [1, 2];
let [first, second] = input;
console.log(first); // 输出 1
console.log(second); // 输出 2
2
3
4
这将创建两个新的变量,命名为 first 和 second 。这等同于使用索引,但要方便得多。
first = input[0];
second = input[1];
2
解构也适用于已经声明的变量。
// 交换变量
[first, second] = [second, first];
2
而且是带参数的函数:
function f([first, second]: [number, number]) {
console.log(first);
console.log(second);
}
f([1, 2]);
2
3
4
5
你可以使用语法 ...
为列表中的剩余项目创建一个变量。
let [first, ...rest] = [1, 2, 3, 4];
console.log(first); // 输出 1
console.log(rest); // 输出 [ 2, 3, 4 ]
2
3
当然,由于这是 JavaScript,你可以直接忽略你不关心的拖尾元素:
let [first] = [1, 2, 3, 4];
console.log(first); // outputs 1
2
或其他元素:
let [, second, , fourth] = [1, 2, 3, 4];
console.log(second); // 输出 2
console.log(fourth); // 输出 4
2
3
# 元组解构
元组可以像数组一样被去结构化;去结构化的变量得到相应元组元素的类型:
let tuple: [number, string, boolean] = [7, "hello", true];
let [a, b, c] = tuple; // a: number, b: string, c: boolean
2
对一个元组进行解构,超出其元素的范围是一个错误:
let [a, b, c, d] = tuple; // 错误,索引 3 处没有元素
和数组一样,你可以用 ...
对元组的其余部分进行解构,以得到一个更短的元组:
let [a, ...bc] = tuple; // bc: [string, boolean]
let [a, b, c, ...d] = tuple; // d: [], 空 tuple
2
或者忽略尾部元素,或者忽略其他元素:
let [a] = tuple; // a: number
let [, b] = tuple; // b: string
2
# 对象解构
你也可以做对象的结构:
let o = { a: "foo", b: 12, c: "bar",};
let { a, b } = o;
2
这就从 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;
2
属性重命名
你也可以给属性起不同的名字:
let { a: newName1, b: newName2 } = o;
这里的语法开始变得混乱了。你可以把 a: newName1
读作 "a as newName1"
。方向是从左到右,就像你写的一样:
let newName1 = o.a;
let newName2 = o.b;
2
令人困惑的是,这里的冒号并不表示类型。如果你指定了类型,仍然需要写在整个结构解构之后。
let { a, b }: { a: string; b: number } = o;
默认值
默认值让你指定一个默认值,以防一个属性未被定义。
function keepWholeObject(wholeObject: { a: string; b?: number }) {
let { a, b = 1001 } = wholeObject;
}
2
3
在这个例子中,b?
表示 b 是可选的,所以它可能是未定义的。keepWholeObject 现在有一个 wholeObject 的变量,以及属性 a 和 b,即使 b 是未定义的。
# Function 声明
去结构化在函数声明中也起作用。对于简单的情况,这是很直接的。
type C = { a: string; b?: number };
function f({ a, b }: C): void {
// ...
}
2
3
4
但是对于参数来说,指定默认值是比较常见的,而用解构的方式来获得默认值是很棘手的。首先,你需要记住把模式放在默认值之前。
function f({ a = "", b = 0 } = {}): void {
// ...
}
f();
2
3
4
然后,你需要记住在 destructured 属性上给可选属性一个默认值,而不是主初始化器。记住,C 的定义是 b 可选的。
function f({ a, b = 0 } = { a: "" }): void {
// ...
}
f({ a: "yes" }); // 正确,b = 0
f(); // 正确, 默认 { a: "" }, 然后默认为 b = 0
f({}); // 错误,如果你提供一个参数,'a'是必须的
2
3
4
5
6
小心使用解构。正如前面的例子所展示的,除了最简单的析构表达式之外,任何东西都会令人困惑。这在深度嵌套的结构化中尤其如此,即使不堆积重命名、默认值和类型注释,也会变得非常难以理解。尽量保持结构化表达式的小而简单。你总是可以自己写出解构会产生的赋值。
# 展开
展开操作符与解构相反。它允许你将一个数组分散到另一个数组中,或者将一个对象分散到另一个对象中。比如说:
let first = [1, 2];
let second = [3, 4];
let bothPlus = [0, ...first, ...second, 5];
2
3
这使 bothPlus 的值为 [0, 1, 2, 3, 4, 5]
。展开创建 first 和 second 的浅层拷贝。它们不会因为展开而改变。
你也可以展开对象。
let defaults = {
food: "spicy",
price: "$$",
ambiance: "noisy"
};
let search = {
...defaults,
food: "rich"
};
2
3
4
5
6
7
8
9
现在的 search 是 { food: "rich", price: "$$", ambiance: "noisy" }
。对象展开比数组展开更复杂。像数组展开一样,它从左到右进行,但结果仍然是一个对象。这意味着展开对象中较晚出现的属性会覆盖较早出现的属性。因此,如果我们修改前面的例子,在最后展开:
let defaults = {
food: "spicy",
price: "$$",
ambiance: "noisy"
};
let search = {
food: "rich",
...defaults
};
2
3
4
5
6
7
8
9
然后,defaults 中的食物属性覆盖了 food: "rich"
,这不是我们在这种情况下想要的。
对象传播也有其他一些令人惊讶的限制。首先,它只包括一个对象自己的、可列举的属性。基本上,这意味着当你传播一个对象的实例时,你会失去方法。
class C {
p = 12;
m() {}
}
let c = new C();
let clone = {
...c
};
clone.p; // 正确
clone.m(); // 错误!
2
3
4
5
6
7
8
9
10
TypeScript 编译器不允许从通用函数中展开类型参数。该功能预计将在未来的语言版本中出现。