变量定义
状态: 初稿
变量定义
let
和 const
是 JavaScript 两种较新的变量定义语法.
前文已提到, let
在某些方面等同于 var
, 但用户使用这个关键字能避免很多 JavaScript “gotchas” 问题.const
是 let
的孪生兄弟, 用来定义只读变量.
作为 JavaScript 的超集, TypeScript 原生支持 let
和 const
.
本节, 我们介绍更多这两个关键字的知识, 并告诉它们你为什么优于 var
.
如果你并不是 JavaScript 的忠实用户, 下一节将刷新你对它的看法.
如果你是一名 JavaScript 高手, 了解所有 var
怪癖, 大胆跳过下节吧.
var 型
在 JavaScript 发展的很长一段时期, 人们用 var
关键字定义变量.
1 | var a = 10; |
在编程领域, 没有比定义一个变量更简单的了. 这条语句定义了一个变量 a
, 同时赋值为 10
.
在函数内定义变量也非常方便:
1 | function f() { |
其他函数可以 “看到” 这个变量.
1 | function f() { |
上例, 我们说, g
捕获了在 f
中定义的 a
.
无论何时 g
被调用, a
始终代表 f
中的那个 a
.
即使 f
先于 g
结束执行, g
都能无障碍访问和修改 a
.
1 | function f() { |
作用域规则
熟悉其他语言的读者看来, var
的若干作用域规则堪称古怪.
看个例子:
1 | function f(shouldInitialize: boolean) { |
这例子也许会出乎一些读者意料.x
是在 if
语句的作用域内定义的, 而我们在 x
的作用域外访问 x
.
一句话解释是: 用 var
定义的变量对它所属的整个函数, 模块, 名字空间, 或全局作用域(所有这些我们都将介绍)可见, 无关它所在的代码块(这里即 if
块).
有人称这是 var
作用域规则, 或函数作用域规则.
显然, 函数参数便遵循函数作用域规则.
过于 “宽松” 的作用域规则往往使人们犯错.
更要命的一个事实的该规则不视变量重复定义为错误.
1 | function sumMatrix(matrix: number[][]) { |
这个例子所犯错误是显而易见的, 内层 for
循环与外层 for
循环引用同一个函数作用域变量 i
, 内层循环对 i
的更新会覆盖外层循环所做修改.
经验丰富的开发者早已体会, 类似错误很容易从代码校阅者眼底溜走, 造成无穷无尽的 bug.
变量捕获陷阱
花上几秒想一想, 以下代码片段的输出是什么?
1 | for (var i = 0; i < 10; i++) { |
对不熟悉 JavaScript 读者的提示:
setTimeout
等待一段时间后(以及没有其他东西在运行)执行回调函数.
下面, 答案揭晓:
1 | 10 |
或许 JavaScript 开发者对此不感到惊喜, 如果你不是他们当中一员, 也并不孤单, 多数人以为输出会是
1 | 0 |
还记得我们提到的变量捕获吗?
所有作为参数传给 setTimeout
的函数表达式都引用在同一个作用域定义的同一个 i
.
花点时间理解这是什么意思.setTimeout
的确承诺在给定时间过去后执行我们的回调函数, 但条件是整个 for
循环结束运行.
等 for
循环运行完毕, i
的值变成了 10
.
由此每个回调函数最终有机会运行时, 它们均取得不再变化的 “变量” 10
.
一个常见的变通方法叫做 IIFE - 立时调用的函数表达式 - 每次迭代都捕获一次 i
:
1 | for (var i = 0; i < 10; i++) { |
这看似古怪的方法格外常用.
参数列表里的 i
屏蔽了 for
循环的 i
(译注: 两者分属不同函数作用域), 以较少的修改解决了我们的问题.
let 型
现在你相信 var
存在不少问题, 这也是我们如此推崇 let
语句的确切原因.
先不提功能, let
语句的语法与 var
完全一样.
1 | let hello = "Hello!"; |
两者不同之处更多表现在语义上. 我们即将带你领略.
块作用域规则
以 let
定义的变量遵循一种叫词法作用域, 或块作用域的规则.
遵循词法作用域规则的变量只在定义它们最小的代码块范围有效, 不像 var
那样在整个函数可见.
1 | function f(input: boolean) { |
这里, 我们定义了两个局部变量, a
和 b
.a
的作用域是整个 f
函数体, b
仅在定义它的 if
代码块局部有效.
类似结论可推广到定义在 catch
块的变量.
1 | try { |
另一个块作用域规则的特性是你不能在实际定义前读写一个变量.
为了贴合一个变量呈现在整个作用域的描述, 我们要把它定义前的区域称为现时盲区.
换句话说, 你不能在定义一个变量的 let
语句前访问这个变量, TypeScript 也能检测该问题.
1 | a++; // illegal to use 'a' before it's declared; |
我们想说明你依然可以在变量定义之前捕获它.
只要别调用这个捕获函数, 捕获不意味着立即访问.
如果面向 ES2015, 现代的运行时将抛出一个异常; 现阶段 TypeScript 不把它当作错误.
1 | function foo() { |
想了解更多关于现时盲区的信息, 参看这篇文章相关讨论 - Mozilla Developer Network
重定义和屏蔽
我们知道, var
不介意你多次定义同一个变量; 你永远只能得到一个.
1 | function f(x) { |
上例完全符合语法要求, 所有 x
定义指向同一个变量.
根据经验, 类似案例是很多 bug 的源头.
所幸, let
要严格得多.
1 | let x = 10; |
TypeScript 检测到的冲突不一定都发生在两个块作用域变量之间.
1 | function f(x) { |
不是说, 你一定不能在一个函数作用域变量的作用域内定义同名的块作用域变量.
把块作用域变量定义在自己独占的块即可.
1 | function f(condition, x) { |
在更内层嵌套块内部定义同名变量导致 “屏蔽”.
就像一把双刃剑, 一方面, 如果内部变量意外地屏蔽了外层变量, 一个新 bug 就产生了; 另一方面, 正是对重复定义的解决, 由重复定义引起的 bug 将不再发生.
设想我们用 let
改写了旧 subMatrix
函数.
1 | function sumMatrix(matrix: number[][]) { |
由于内层 i
屏蔽了外层 i
, 这个版本能算出矩阵和.
本着可读代码的追求, 人们通常避免利用 “屏蔽”.
也不可否认, 屏蔽在某些情形有其优点, 在实践中, 多依靠自己的判断力.
捕获块作用域变量
在 var
那一节中, 我们首次接触捕获 var
定义的变量, 并且简单谈到被捕获变量的行为特征.
为了获得更直观的理解, 我们提出环境(environment)的概念, TypeScript 每执行到一个作用域, 便建立一个该作用域内变量的环境.
即使其作用域已经结束执行, 环境依然能够独立存在.
1 | function theCityThatAlwaysSleeps() { |
由于我们在它的环境中捕获了 city
, 无关 if
块已经结束运行的事实, getCity
函数依然能访问这个环境中的 city
.
回忆下早些时候那个 setTimeout
例子, 最终, 我们要用 IIFE 技巧捕获 for
循环每次迭代中 i
的状态.
实际上, 我们每次迭代都为捕获的变量创建了一个新环境.
不得不说, 这个变通方法的代价有点大. 所幸, TypeScript 给你另一种选择.
循环里的 let
和 var
有截然不同的表现.
与 var
为循环本身创建一个新环境不同, let
每次迭代都创建一个新环境.
因为 IIFE 追求的正是此效果, 现在我们可以只改一个关键字更新旧 setTimeout
例子.
1 | for (let i = 0; i < 10 ; i++) { |
不出所料, 修改完的代码输出如下
1 | 0 |
const 定义
和 let
一样, const
也用来定义变量.
1 | const numLivesForCat = 9; |
顾名思义, const
定义的变量一经绑定初值, 就不再改变.
换句话说, 它的作用域规则与 let
一样, 但你不可以重新赋值.
不要混淆不可重新赋值, 与值不可改变之间的区别.
1 | const numLivesForCat = 9; |
除非你采取特殊措施, const
变量引用的值的内部状态是可以改变的.
TypeScript 也允许你声明一个对象的内部状态是只读的.
我们接口一章详细讨论.
let 对比 const
我们现在认识了两种作用域规则一致的语法, let
和 const
, 你可能会问, 如何在这两种语法中做选择呢?
和大多数开放问题一样, 我们的答案是: 取决于具体情况.
应用最少特权级原理, 最好用 const
定义你不打算修改的所有变量.
我们考虑, 用 const
定义一个不需要重新赋值的变量, 基于这变量的其他人员就不会自动拥有修改该变量的能力, 他们需要思考是否真的有必要对这个变量重新赋值.
使用 const
同样让数据流变得容易预测, 使推理更简单.
依靠自己的判断力, 而且如果可行的话, 和你同组的人讨论.
这本手册主要使用 let
.
解构
TypeScript 另一项 ECMAScript 2015 特性是 — 解构.
关于该特性的完整参考: the article on the Mozilla Developer Network.
在本节, 我们给出简要概括.
数组的情况
最简单的解构形式是数组的解构赋值:
1 | let input = [1, 2]; |
上例创建了 first
, 和 second
两个变量.
你也可以手动取数组元素来初始化它们, 两者是等同的, 但解构看起来显然更直观:
1 | first = input[0]; |
解构也可以对已定义的变量赋值:
1 | // swap variables |
向函数参数解构:
1 | function f([first, second]: [number, number]) { |
用 ...
语法创建的变量一揽子收入所有数组剩余元素:
1 | let [first, ...rest] = [1, 2, 3, 4]; |
当然, 这是 JavaScript, 你可以随意舍弃末尾不想要的值:
1 | let [first] = [1, 2, 3, 4]; |
或特定值:
1 | let [, second, , fourth] = [1, 2, 3, 4]; |
元组的情况
元组也能解构; 创建的变量类型与元组相应元素类型一致:
1 | let tuple: [number, string, boolean] = [7, "hello", true]; |
你一次解构的元素数量不能大于元组元素个数:
1 | let [a, b, c, d] = tuple; // Error, no element at index 3 |
我们借鉴数组 ...
语法解构剩余元素创建更短的元组:
1 | let [a, ...bc] = tuple; // bc: [string, boolean] |
同样, 你根据需要舍弃多余元素, 特定元素:
1 | let [a] = tuple; // a: number |
对象的情况
来看对象解构:
1 | let o = { |
上例, 变量 a
和 b
从对象 o
中提取.
如果不需要 o.c
, 可以忽略.
如同数组解构, 不经定义(译注: 使用字面量)直接赋值:
1 | ({ a, b } = { a: "baz", b: 101 }); |
注意这条语句要用括号环绕.
这是因为 JavaScript 通常假定左花括号标志着代码块的开始.
用 ...
语法创建一个包含被解构对象所有多余属性的变量.
1 | let { a, ...passthrough } = o; |
属性重命名
你可以重命名解构后的属性:
1 | let { a: newName1, b: newName2 } = o; |
这条语法需要重点分析.a: newName1
读作: 把 newName1
作为 a
的新名字.
读法是从右往左的, 等同于写作:
1 | let newName1 = o.a; |
这里的冒号不代表类型注解.
如果你愿意显式指定类型, 它应该出现在整个解构声明之后:
1 | let { a, b }: { a: string, b: number } = o; |
默认值
考虑到源属性的值有可能是 undefined
, 你可以为目的属性设定默认值.
1 | function keepWholeObject(wholeObject: { a: string, b?: number }) { |
上例, b?
表示 b
是可选的, 所以它的值有可能是 undefined
.
通过指定默认值, 即使 wholeObject.b
是 undefined
, keepWholeObject
也能从 wholeObject
解构出一个完整的对象, 同时具有 a
和 b
.
函数的情况
最后来看函数的情况.
一个容易理解的简单例子如下:
1 | type C = { a: string, b?: number } |
译注:
type
为{ a: string, b?: number }
起别名C
.
为函数参数指定默认值更加常见, 然而, 协调默认值与解构具有挑战性.
首先, 要把解构模板放在默认值之前.
1 | function f({ a="", b=0 } = {}): void { |
以上代码片段包含类型推导, 本手册后面会提到.
其次, 不是在初始值中指定默认值, 而是在解构模板中.
记住 b
是可选的.
1 | function f({ a, b = 0 } = { a: "" }): void { |
谨慎使用解构.
上例让我们意识到, 即使是最简单的解构表达式也不易理解.
更不用说深度嵌套解构, 即使不涉及重命名, 默认值, 类型注解, 也极其难以理解.
保持解构表达式小巧, 清晰.
blah blah.
扩展
扩展是解构的逆操作.
它允许你把一个列表展开为另一个列表的一部分, 或者把一个对象展开为另一个对象的一部分.
请看下例:
1 | let first = [1, 2]; |
bothPlus
包含 0, 1, 2, 3, 4, 5
六个元素.
其中, 1, 2, 3, 4
分别来自 first
和 second
, 扩展把它们的元素拷贝到新列表.
修改 bothPlus
不会影响 first
或 second
自身.
下例演示对象展开:
1 | let defaults = { food: "spicy", price: "$$", ambiance: "noisy" }; |
现在, search
变成了 { food: "rich", price: "$$", ambiance: "noisy" }
.
对象扩展相对列表复杂一点.
和列表一样, 它从左到右依次展开, 其结果依然是一个对象.
依照该顺序, 后出现的同名属性将覆盖更早出现的那个属性.
如果我们把对象展开放在最后:
1 | let defaults = { food: "spicy", price: "$$", ambiance: "noisy" }; |
defaults
的 food
属性现在覆盖了最左的 food
, 不再符合我们的意愿 (译注: defaults
代表缺省值的集合).
除此之外, 对象展开还有许多局限性.
第一, 它只对对象属性感兴趣.
own, enumerable properties.
亦即, 你会失去被展开对象的所有方法.
1 | class C { |
第二, TypeScript 不允许展开泛型函数的类型参数.
这个特性将出现在未来的 TypeScript 中.