变量定义

状态: 初稿

变量定义

letconst 是 JavaScript 两种较新的变量定义语法.
前文已提到, let 在某些方面等同于 var, 但用户使用这个关键字能避免很多 JavaScript “gotchas” 问题.
constlet 的孪生兄弟, 用来定义只读变量.

作为 JavaScript 的超集, TypeScript 原生支持 letconst.
本节, 我们介绍更多这两个关键字的知识, 并告诉它们你为什么优于 var.

如果你并不是 JavaScript 的忠实用户, 下一节将刷新你对它的看法.
如果你是一名 JavaScript 高手, 了解所有 var 怪癖, 大胆跳过下节吧.

var 型

在 JavaScript 发展的很长一段时期, 人们用 var 关键字定义变量.

1
var a = 10;

在编程领域, 没有比定义一个变量更简单的了. 这条语句定义了一个变量 a, 同时赋值为 10.

在函数内定义变量也非常方便:

1
2
3
4
5
function f() {
var message = "Hello, world!";

return message;
}

其他函数可以 “看到” 这个变量.

1
2
3
4
5
6
7
8
9
10
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 结束执行, g 都能无障碍访问和修改 a.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function f() {
var a = 1;

a = 2;
var b = g();
a = 3;

return b;

function g() {
return a;
}
}

f(); // returns '2'

作用域规则

熟悉其他语言的读者看来, var 的若干作用域规则堪称古怪.
看个例子:

1
2
3
4
5
6
7
8
9
10
function f(shouldInitialize: boolean) {
if (shouldInitialize) {
var x = 10;
}

return x;
}

f(true); // returns '10'
f(false); // returns 'undefined'

这例子也许会出乎一些读者意料.
x 是在 if 语句的作用域内定义的, 而我们在 x 的作用域外访问 x.
一句话解释是: 用 var 定义的变量对它所属的整个函数, 模块, 名字空间, 或全局作用域(所有这些我们都将介绍)可见, 无关它所在的代码块(这里即 if 块).
有人称这是 var 作用域规则, 或函数作用域规则.
显然, 函数参数便遵循函数作用域规则.

过于 “宽松” 的作用域规则往往使人们犯错.
更要命的一个事实的该规则不视变量重复定义为错误.

1
2
3
4
5
6
7
8
9
10
11
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;
}

这个例子所犯错误是显而易见的, 内层 for 循环与外层 for 循环引用同一个函数作用域变量 i, 内层循环对 i 的更新会覆盖外层循环所做修改.
经验丰富的开发者早已体会, 类似错误很容易从代码校阅者眼底溜走, 造成无穷无尽的 bug.

变量捕获陷阱

花上几秒想一想, 以下代码片段的输出是什么?

1
2
3
for (var i = 0; i < 10; i++) {
setTimeout(function() { console.log(i); }, 100 * i);
}

对不熟悉 JavaScript 读者的提示: setTimeout 等待一段时间后(以及没有其他东西在运行)执行回调函数.
下面, 答案揭晓:

1
2
3
4
5
6
7
8
9
10
10
10
10
10
10
10
10
10
10
10

或许 JavaScript 开发者对此不感到惊喜, 如果你不是他们当中一员, 也并不孤单, 多数人以为输出会是

1
2
3
4
5
6
7
8
9
10
0
1
2
3
4
5
6
7
8
9

还记得我们提到的变量捕获吗?
所有作为参数传给 setTimeout 的函数表达式都引用在同一个作用域定义的同一个 i.

花点时间理解这是什么意思.
setTimeout 的确承诺在给定时间过去后执行我们的回调函数, 但条件是整个 for 循环结束运行.
for 循环运行完毕, i 的值变成了 10.
由此每个回调函数最终有机会运行时, 它们均取得不再变化的 “变量” 10.

一个常见的变通方法叫做 IIFE - 立时调用的函数表达式 - 每次迭代都捕获一次 i:

1
2
3
4
5
6
7
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 完全一样.

1
let hello = "Hello!";

两者不同之处更多表现在语义上. 我们即将带你领略.

块作用域规则

let 定义的变量遵循一种叫词法作用域, 或块作用域的规则.
遵循词法作用域规则的变量只在定义它们最小的代码块范围有效, 不像 var 那样在整个函数可见.

1
2
3
4
5
6
7
8
9
10
11
12
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;
}

这里, 我们定义了两个局部变量, ab.
a 的作用域是整个 f 函数体, b 仅在定义它的 if 代码块局部有效.

类似结论可推广到定义在 catch 块的变量.

1
2
3
4
5
6
7
8
9
try {
throw "oh no!";
}
catch (e) {
console.log("Oh well.");
}

// Error: 'e' doesn't exist here
console.log(e);

另一个块作用域规则的特性是你不能在实际定义前读写一个变量.
为了贴合一个变量呈现在整个作用域的描述, 我们要把它定义前的区域称为现时盲区.
换句话说, 你不能在定义一个变量的 let 语句前访问这个变量, TypeScript 也能检测该问题.

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

我们想说明你依然可以在变量定义之前捕获它.
只要别调用这个捕获函数, 捕获不意味着立即访问.
如果面向 ES2015, 现代的运行时将抛出一个异常; 现阶段 TypeScript 不把它当作错误.

1
2
3
4
5
6
7
8
9
10
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 不介意你多次定义同一个变量; 你永远只能得到一个.

1
2
3
4
5
6
7
8
function f(x) {
var x;
var x;

if (true) {
var x;
}
}

上例完全符合语法要求, 所有 x 定义指向同一个变量.
根据经验, 类似案例是很多 bug 的源头.
所幸, let 要严格得多.

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

TypeScript 检测到的冲突不一定都发生在两个块作用域变量之间.

1
2
3
4
5
6
7
8
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'
}

不是说, 你一定不能在一个函数作用域变量的作用域内定义同名的块作用域变量.
把块作用域变量定义在自己独占的块即可.

1
2
3
4
5
6
7
8
9
10
11
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 改写了旧 subMatrix 函数.

1
2
3
4
5
6
7
8
9
10
11
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 那一节中, 我们首次接触捕获 var 定义的变量, 并且简单谈到被捕获变量的行为特征.
为了获得更直观的理解, 我们提出环境(environment)的概念, TypeScript 每执行到一个作用域, 便建立一个该作用域内变量的环境.
即使其作用域已经结束执行, 环境依然能够独立存在.

1
2
3
4
5
6
7
8
9
10
11
12
function theCityThatAlwaysSleeps() {
let getCity;

if (true) {
let city = "Seattle";
getCity = function() {
return city;
}
}

return getCity();
}

由于我们在它的环境中捕获了 city, 无关 if 块已经结束运行的事实, getCity 函数依然能访问这个环境中的 city.

回忆下早些时候那个 setTimeout 例子, 最终, 我们要用 IIFE 技巧捕获 for 循环每次迭代中 i 的状态.
实际上, 我们每次迭代都为捕获的变量创建了一个新环境.
不得不说, 这个变通方法的代价有点大. 所幸, TypeScript 给你另一种选择.

循环里的 letvar 有截然不同的表现.
var 为循环本身创建一个新环境不同, let 每次迭代都创建一个新环境.
因为 IIFE 追求的正是此效果, 现在我们可以只改一个关键字更新旧 setTimeout 例子.

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

不出所料, 修改完的代码输出如下

1
2
3
4
5
6
7
8
9
10
0
1
2
3
4
5
6
7
8
9

const 定义

let 一样, const 也用来定义变量.

1
const numLivesForCat = 9;

顾名思义, const 定义的变量一经绑定初值, 就不再改变.
换句话说, 它的作用域规则与 let 一样, 但你不可以重新赋值.

不要混淆不可重新赋值, 与值不可改变之间的区别.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 也允许你声明一个对象的内部状态是只读的.
我们接口一章详细讨论.

let 对比 const

我们现在认识了两种作用域规则一致的语法, letconst, 你可能会问, 如何在这两种语法中做选择呢?
和大多数开放问题一样, 我们的答案是: 取决于具体情况.

应用最少特权级原理, 最好用 const 定义你不打算修改的所有变量.
我们考虑, 用 const 定义一个不需要重新赋值的变量, 基于这变量的其他人员就不会自动拥有修改该变量的能力, 他们需要思考是否真的有必要对这个变量重新赋值.
使用 const 同样让数据流变得容易预测, 使推理更简单.

依靠自己的判断力, 而且如果可行的话, 和你同组的人讨论.

这本手册主要使用 let.

解构

TypeScript 另一项 ECMAScript 2015 特性是 — 解构.
关于该特性的完整参考: the article on the Mozilla Developer Network.
在本节, 我们给出简要概括.

数组的情况

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

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

上例创建了 first, 和 second 两个变量.
你也可以手动取数组元素来初始化它们, 两者是等同的, 但解构看起来显然更直观:

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

解构也可以对已定义的变量赋值:

1
2
// swap variables
[first, second] = [second, first];

向函数参数解构:

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

... 语法创建的变量一揽子收入所有数组剩余元素:

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

当然, 这是 JavaScript, 你可以随意舍弃末尾不想要的值:

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

或特定值:

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

元组的情况

元组也能解构; 创建的变量类型与元组相应元素类型一致:

1
2
3
let tuple: [number, string, boolean] = [7, "hello", true];

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

你一次解构的元素数量不能大于元组元素个数:

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

我们借鉴数组 ... 语法解构剩余元素创建更短的元组:

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

同样, 你根据需要舍弃多余元素, 特定元素:

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

对象的情况

来看对象解构:

1
2
3
4
5
6
let o = {
a: "foo",
b: 12,
c: "bar"
};
let { a, b } = o;

上例, 变量 ab 从对象 o 中提取.
如果不需要 o.c, 可以忽略.

如同数组解构, 不经定义(译注: 使用字面量)直接赋值:

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

注意这条语句要用括号环绕.
这是因为 JavaScript 通常假定左花括号标志着代码块的开始.

... 语法创建一个包含被解构对象所有多余属性的变量.

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

属性重命名

你可以重命名解构后的属性:

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

这条语法需要重点分析.
a: newName1 读作: 把 newName1 作为 a 的新名字.
读法是从右往左的, 等同于写作:

1
2
let newName1 = o.a;
let newName2 = o.b;

这里的冒号不代表类型注解.
如果你愿意显式指定类型, 它应该出现在整个解构声明之后:

1
let { a, b }: { a: string, b: number } = o;

默认值

考虑到源属性的值有可能是 undefined, 你可以为目的属性设定默认值.

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

上例, b? 表示 b 是可选的, 所以它的值有可能是 undefined.
通过指定默认值, 即使 wholeObject.bundefined, keepWholeObject 也能从 wholeObject 解构出一个完整的对象, 同时具有 ab.

函数的情况

最后来看函数的情况.
一个容易理解的简单例子如下:

1
2
3
4
type C = { a: string, b?: number }
function f({ a, b }: C): void {
// ...
}

译注: type{ a: string, b?: number } 起别名 C.

为函数参数指定默认值更加常见, 然而, 协调默认值与解构具有挑战性.
首先, 要把解构模板放在默认值之前.

1
2
3
4
function f({ a="", b=0 } = {}): void {
// ...
}
f();

以上代码片段包含类型推导, 本手册后面会提到.

其次, 不是在初始值中指定默认值, 而是在解构模板中.
记住 b 是可选的.

1
2
3
4
5
6
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

谨慎使用解构.
上例让我们意识到, 即使是最简单的解构表达式也不易理解.
更不用说深度嵌套解构, 即使不涉及重命名, 默认值, 类型注解, 也极其难以理解.
保持解构表达式小巧, 清晰.
blah blah.

扩展

扩展是解构的逆操作.
它允许你把一个列表展开为另一个列表的一部分, 或者把一个对象展开为另一个对象的一部分.
请看下例:

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

bothPlus 包含 0, 1, 2, 3, 4, 5 六个元素.
其中, 1, 2, 3, 4 分别来自 firstsecond, 扩展把它们的元素拷贝到新列表.
修改 bothPlus 不会影响 firstsecond 自身.

下例演示对象展开:

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

现在, search 变成了 { food: "rich", price: "$$", ambiance: "noisy" }.
对象扩展相对列表复杂一点.
和列表一样, 它从左到右依次展开, 其结果依然是一个对象.
依照该顺序, 后出现的同名属性将覆盖更早出现的那个属性.
如果我们把对象展开放在最后:

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

defaultsfood 属性现在覆盖了最左的 food, 不再符合我们的意愿 (译注: defaults 代表缺省值的集合).

除此之外, 对象展开还有许多局限性.
第一, 它只对对象属性感兴趣.
own, enumerable properties.
亦即, 你会失去被展开对象的所有方法.

1
2
3
4
5
6
7
8
9
class C {
p = 12;
m() {
}
}
let c = new C();
let clone = { ...c };
clone.p; // ok
clone.m(); // error!

第二, TypeScript 不允许展开泛型函数的类型参数.
这个特性将出现在未来的 TypeScript 中.

如果这篇文章对您有用,可以考虑打赏:)