JavaScript作用域和闭包
作用域
用 var 关键字声明变量,作用域是当前函数或全局:
1 | |
而用 let/const 关键字声明变量或常量,作用域是当前代码块({ ... })或全局:
1 | |
1 | |
function ...() {} 和 var 类似,通常也可以穿透代码块:
1 | |
赋值给 var/let/const 声明的变量或常量的函数,则遵循对应声明方式的行为。这里不再赘述。
提升(Hoisting)
用 var 声明的变量会被提升到作用域(即当前函数或全局)的顶部。在 var 之前读取不会报错,只会得到 undefined。举个例子,下面这段代码:
1 | |
等效于:
1 | |
let/const 则不一样。在 let/const 之前读取会报错:
1 | |
1 | |
function ...() {} 和 var 关键字类似,也会被提升到作用域(通常是当前函数或全局)的顶部。下面这段代码:
1 | |
等效于:
1 | |
哪怕换成 var 也不能达到同样的效果。比如下面这段代码:
1 | |
等效于:
1 | |
可以看到执行 f() 时 f 还是 undefined。
TDZ(Temporal Dead Zone/暂时性死区)
严格地说,let/const 并非不会被提升。var 的声明和初始化(赋值为 undefined)都会被提升,而 let/const 只有声明会被提升到作用域(即当前代码块或全局)的顶部。从作用域顶部到 let/const 的区域,被称为 TDZ。
可以注意到,代码报了 ReferenceError: Cannot access '...' before initialization 而非 ReferenceError: ... is not defined。这说明解释器其实知道这些变量和常量的存在,只是它们还处在 TDZ。
闭包
理解闭包之前,先要理解词法环境(lexical environment):代码块、函数以及整个脚本都有自己的词法环境。一个词法环境保存了局部变量、常量等信息,以及对外层词法环境的引用(outer)。最外层的全局词法环境的 outer 为 null。当代码访问一个变量时,会先在当前词法环境中查找,再沿着 outer 链逐层向外查找,直至全局词法环境。
理解了词法环境,闭包其实可以用一句话概述:当一个函数被创建时,它会记住所在的词法环境;以后不论在哪里调用这个函数,函数自身词法环境的 outer 都会指向它创建时所在的词法环境。
代码块和整个脚本会在进入作用域时创建自己的词法环境,函数则是在被调用时才创建自己的词法环境。
举个最典型的例子——在函数中返回函数:
1 | |
每次调用 counter() 时,至少会涉及三层词法环境:counter() 自己的词法环境、makeCounter() 的词法环境,以及全局词法环境。counter() 自己的词法环境会在每次调用时重新创建,访问 count 时,解释器会先在这一层查找,发现找不到后,再沿着 outer 到 makeCounter() 的词法环境中查找,并在那里找到 count。由于 makeCounter() 的词法环境只在调用时被创建了一次,最终产生了计数器的效果。
常见陷阱
最常见的陷阱之一,就是在 for 循环里同时用 var 并创建闭包:
1 | |
由于 var 是函数作用域或全局作用域,多轮循环访问的是同一个 i。而 setTimeout() 里的回调函数形成了闭包。1 秒之后,回调函数执行,读取了全局词法环境中的 i,那时它已经是 3 了。
修复的方法很简单,就是把 var 换成 let:
1 | |
let 声明的 i 属于 for 循环的代码块,每轮循环都有独立的 i。哪怕闭包仍然存在,回调函数执行时,会从各轮循环的词法环境读取到正确的 i。
for 循环比较特殊,虽然 i 不在 { ... } 内,但它仍然属于 for 循环代码块的词法环境。
另一种方法是用 IIFE(Immediately Invoked Function Expression/立即执行函数表达式):
1 | |
IIFE 是一种立即执行的函数表达式。每次循环都会调用一次 IIFE,从而创建一个新的词法环境,并保存一份独立的 i,因此后面的回调函数可以读取到正确的 i。
再来讲另一个陷阱。先看下面这段代码:
1 | |
renderToPipeableStream() 函数返回了 pipe,而在它的参数中,却直接引用了 pipe。这看起来应该报错,但奇怪的是,这段代码能够正常运行。这很反直觉,但其实可以用上面的知识解释。
首先,const 会提升 pipe 的声明。在执行 renderToPipeableStream() 之前,pipe 就已经存在于词法环境中了。其次,onShellReady 是一个回调函数,会形成闭包。因此回调函数会记住外层词法环境中的 pipe,但只有在回调函数真正执行时,才会读取 pipe 的值。这个回调函数显然不是在执行 renderToPipeableStream() 时被立刻调用的——不然就会报错了——所以这段代码可以正常运行。
参考资料
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const
- https://zh.javascript.info/closure
- https://discuss.codecademy.com/t/var-and-let-in-a-loop-working-differently/550468
- https://rexng.medium.com/the-age-old-javascript-closure-pitfall-48e131b328b7
- https://www.51cto.com/article/665237.html