ES6 中新出现了 let 和 const 关键字用于声明变量,想必 jym 在平常的项目中早就用得轻车熟路,表面上看 let 似乎和 var 的作用差不多,而 const 只不过声明的是个常量罢了,但其实深究起来,还是有很多细节值得一说的(接下去的内容除特别说明外,都是以 let 进行测试,但同样适用于 const)。
细节一:是否可以重复声明
let/const 声明的变量是不能像 var 声明的变量那样重复声明的:
// 例 1
var a = 1
var a = 2
console.log(a) // 2
let b = 1
let b = 2 // SyntaxError: Identifier 'b' has already been declared
在第 6 行和第 7 行定义了两次变量 b,执行代码时就会报语法错误。
细节二:是否存在作用域提升
// 例 2
console.log(a)
var a = 1
console.log(b) // ReferenceError: Cannot access 'b' before initialization
let b = 1
在之前的一篇文章中我们说过,用 var 声明的变量存在作用域提升,因为在代码的编译阶段,V8 引擎会创建一个全局对象(GO),而例 2 中的 a 变量会被放入到 GO 中,浏览器环境下也就是 window 对象,但是还没赋值,所以在执行到第 2 行打印 a 时,得到的会是 undefined。
第 4 行我们想打印变量 b,结果发现报错,可以认为 let/const 声明的变量是不存在作用域提升的。那么现在有个问题,let/const 声明的变量,是在编译阶段就已经创建?还是等到执行阶段才会创建?让我们从 ECMA 的规范中寻找答案:
在 ECMA-262 6th edition 中,有如下一段对 let 和 const 的描述:
let and const declarations define variables that are scoped to the running execution context’s LexicalEnvironment. The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable’s LexicalBinding is evaluated.
所以按照规范,用 let 和 const 声明的变量在包含它们的词法环境( Lexical Environment )实例化时就创建了,但是直到这些变量的词法绑定(LexicalBinding)被求值前,都无法被任何方式访问到。结合例 2 可以理解成当变量 b 所在的执行上下文被创建时,变量 b 就被创建出来了,而不是等到执行到第 5 行的时候才被创建,但在 b 被赋值前无法访问,所以在第 4 行的时候,b 已经存在,但是访问会报错。
暂存死区
这种通过 let/const 声明的变量在初始化前访问导致 ReferenceError 的现象还有个专门的词汇来表示,叫做暂存死区,英文为 Temporal dead zone (TDZ)。一个比较特别的例子如下:
// 例 2.1
function fn(a = b + 1, b) {
console.log(a, b)
}
fn(undefined, 2) // ReferenceError: Cannot access 'b' before initialization
我们给 fn 定义了 a 和 b 两个形参,并给 a 设定了默认参数,调用 fn 的时候特意让第一个参数为 undefined,希望 a 取默认值 b + 1,结果却是报错。这也是因为在获取 b + 1 中的 b 时,b 还没有初始化,它仍旧存在于暂存死区中。改为下面这样就不会报错了:
// 例 2.2
function fn(b, a = b + 1) {
console.log(a, b)
}
fn(2, undefined) // 3 2
细节三:浏览器环境下,全局作用域声明的变量是否会放入全局对象(window)
我们知道在浏览器环境下,在全局作用域中通过 var 声明一个变量,该变量会被自动加入到 window 中(Node.js 中则不会)。那么用 let/const 声明的变量是否也会呢?
在《js 变量和函数声明提升的原理》的后半部分有提到,所谓 GO(还有 AO) 都是较老版本(比如 ECMA-262, 3rd edition)的规范中的提法,在 ES5 及之后的规范中(比如 ECMA-262 5.1 edition),已经被 VariableEnvironment(VE)所替换了:
Every execution context has an associated VariableEnvironment. Variables and functions declared in ECMAScript code evaluated in an execution context are added as bindings in that VariableEnvironment’s Environment Record.
也就是说,按照新的规范,在调用栈中创建的执行上下文中,关联的不再是 VO(指向 GO 或 AO)的信息,而是会关联一个叫变量环境(VariableEnvironment)的东西。在执行代码中无论是用 var 或 let 还是 const 声明的变量,现在都应该作为绑定(bindings)添加到变量环境的环境记录(Environment Record)中。
规范看完了,那么 V8 引擎在实现的时候是怎么做的呢?比如有如下代码:
// 例 3
var a = 1
let b = 2
const c = 3
在全局执行上下文中,早期规范关联的 VO 替换成了 VE,它也会指向一个堆内存中创建的对象,只不过这个对象的名字叫做 variables_ ,是一个 VariableMap 类型的对象,而 VariableMap 就是一个 HashMap(基于哈希表的 Map 接口的实现)。例 3 在内存中的表现可以画个示例图如下:
在早期规范中,全局执行上下文关联 VO,VO 指向 GO,我们用 var 声明的变量都会添加到 GO 中,window 也会指向 GO,所以在全局作用域中使用 var 定义的变量都会被添加到 window 里。而依照新的规范,window 不会指向上图中的variables_了,而使用 let 或 const 声明的变量也不会被加入到 window 中。但是,为了兼容,使用 var 声明的变量依旧会在 window 中被添加或删除。
P.S. window 不是由 V8 引擎实现的,而是由浏览器实现。
细节四:是否存在块作用域(Block )
何为块作用域
ES6 开始,出现了块作用域,比如有如下代码:
// 例 4.1.1
{
var a = 1
let b = 2
}
console.log(a) // 1
console.log(b) // ReferenceError: b is not defined
第 2 行至第 5 行,由 {} 包裹的部分形成的函数块,就存在一个块作用域。在块作用域内,使用 let/const 定义的变量,都无法在外部被访问 。但是请注意,块作用域内用 var 声明的变量仍然可以在块作用域外被访问。
不仅是变量,块作用域内 class 声明的类在外部也无法访问,比如:
// 例 4.1.2
{
class Animal {}
}
new Animal() // ReferenceError: Animal is not defined
但是块作用域内声明的函数在大部分浏览器中运行时,是可以在外部调用的:
// 例 4.1.3
{
function foo() {
console.log('foo')
}
}
foo() // foo
虽然按理来说块作用域应该对 function 是有效的,但是为了兼容较老的代码,大部分浏览器让 function 没有块作用域。
何处存在块作用域
从 ES6 开始,if、switch 和 for 语句都存在着块作用域。请注意,switch 语句中,不同 case 之间是没有块作用域的,所以在某个 case 里使用 let/const 声明的变量不能在其它 case 里再次声明:
// 例 4.2.1
// if
if (true) {
let a = 1
}
console.log(a) // ReferenceError: a is not defined
// switch
const key = 'value'
switch (key) {
case 'value':
let a = 1
break
case 'value2':
a = 2 // 这里不能再次使用 let 声明变量 a
break
}
console.log(a) // ReferenceError: a is not defined
// for
for (let index = 0; index < 3; index++) {}
console.log(index) // ReferenceError: index is not defined
使用 let/const 定义的变量均不能在外部被访问。但如果是使用 var 定义则可以,比如 for 语句中:
// 例 4.2.2
for (var index = 0; index < 3; index++) {}
console.log(index) // 3
例 4.2.2 第 2 行的 index 就相当于在全局作用域定义的,所以在第 3 行可以获取。
记得刚接触 js 那阵有个类似下面这样的案例就困扰了我很久:
// 例 4.3.1
<button>按钮1</button>
<button>按钮2</button>
<button>按钮3</button>
有如上 3 个按钮,我们用一个 for 循环给每个按钮绑定一个点击事件:
// 例 4.3.2
const btns = document.getElementsByTagName('button')
for (var index = 0; index < btns.length; index++) {
btns[index].onclick = () => {
console.log(`我是第${index}个按钮`)
}
}
我们无论点击哪个按钮打印输出的都是“我是第3个按钮”,当时就强行记住了个结论 —— for 循环会在页面加载完之后立即执行,而点击事件的响应函数会在点击时才执行,此时 for 循环已经执行完毕,所以 index 等于 3。
当时我心里想,既然 for 循环已经执行完毕了,第 4 行的 btns[index] 的 index 都是对的,怎么第 5 行的 index 就不对了呢?后来随着理解的深入明白了其实和作用域有关,块作用域对 var 声明的变量无效, index 是存在于全局作用域的,始终只有那么一个。所以虽然 for 循环执行时,确实是从 0 到 2 按照 index 正确地找到了每个按钮绑定了事件,但该事件做的事情是去访问 index 的值,而在点击时,内存中保存的 index 值为 3,所以点击任何按钮打印得到的结果都一样。而使用 let 就能解决这个问题:
// 例 4.3.3
for (let index = 0; index < btns.length; index++) {
btns[index].onclick = () => {
console.log(`我是第${index}个按钮`)
}
}
此时点击按钮就能依次得到如下图所示的结果:
因为块作用域对 let 声明的变量是有效的,所以例 4.3.3 可以看成是分别在 3 个函数块形式的代码中定义了 index,它们彼此独立,只不过后一个 index 的值是前一个代码块执行完后将 index + 1 得到的:
// 例 4.3.4
{
let index = 0
// ...
}
{
let index = 1
// ...
}
{
let index = 2
// ...
}
所以当执行 console.log 时,先在箭头函数本身的作用域中找 index,没找到就去父级作用域找,找到的就是例 4.3.4 中各个代码块中的 index。
需要注意一点,例 4.3.3 中不能用 const 代替 let,因为循环结束前 index 会执行自增 1 的操作,而 const 定义的变量的值是不能修改的。但是下面这个例子则可以使用 const:
// 例 4.3.5
const arr = [1, 2, 3]
for (const index of arr) {
console.log(index)
}
原因同样在于块作用域的存在,所以每次迭代循环的时候定义的 index 都是在各自的块作用域中。