谈到变量作用域以及闭包,总感觉在谈一些非常八股的东西。但是在 JS 中我们无法避免去接触到这些八股。因为大部分时间 JS应用于前端开发,在处理事件的时候我们不可避免的会应用闭包去捕获外部变量。在不了解 JS 的闭包机制的情况下往往会遇到不少的坑。
变量作用域
在 ES6 之前,js 中的变量可以通过声明式和非声明式来定义。例如:
var a = 1; // 声明式
b = 2; // 非声明式
一般来说建议采用声明式来使用变量,因为非声明式实际上是定义了一个全局变量。例如:
function c(){
var a = 1;
b = 2;
}
c()
console.info(b) // 2
console.info(a) // ReferenceError: a is not defined
但是,即始通过声明式来定义变量同样会有问题。因为使用 var 定义的变量并不存在真正的块级作用域。仅仅能通过函数来隔离,例如:
function c(){
console.info(d); // undefined
if(true){
var d = 1;
}
console.info(d); // 1
}
c()
console.info(d) // ReferenceError: a is not defined
可以看出,在 if 代码块中定义的变量在代码块结束之后仍然能够访问。并且甚至在代码未运行之前也能够访问。直到退出该函数之后变量才失效。这是因为 js 对使用 var 的变量进行了变量提升。即把变量的声明提升到了当前上下文的最开始进行(如果是在函数中,则提提升到函数的最开始,如果是在整个文件中,则提升到整个文件的最开始)。我们可以理解为 js 帮我们把代码修改成了这样:
function c(){
var d;
console.info(d);
if(true){
d = 1;
}
console.info(d);
}
c()
console.info(d)
jser 对这种奇怪的特性感到非常的头疼,因为这产生了许多预料之外的行为。例如循环中绑定事件这个问题相信大部分的开发者都遇到过。因此在 ES6 中有了新的定义变量的方式 let 以及 const,这两种方式都具有正真的块级作用域,例如:
function a(){
if(true){
let b = 1;
}
console.info(b) // ReferenceError: b is not defined
}
a()
可以看出在 if 块结束之后定义的变量 b 不可访问。
词法环境域以及闭包
闭包陷阱
jser 为什么不喜欢变量提升,一个原因是这使得代码变得奇怪,毕竟一个变量在定义之前就可以访问总让人有点费解。但是还有一个原因是会使得闭包变得难以控制。例如老生常谈的问题:
for(var i = 0; i++; i < 6){
setTimeout(() => console.info(i), 1000)
}
我们希望这段代码在一秒之后打印0-5,但实际上只会打印 5 个 6。许多人只会提到这是因为变量提升引起的,使用立即执行函数或者把 var 改成 let 构建块级作用域就可以解决这个问题。但是很多人没有说清楚为什么。
词法作用域
为了搞清楚这个闭包陷阱,我们必须了解词法作用域来知道闭包是如何捕获外部变量。在 js 中对于每一个代码块(控制块,函数)以及整个代码文件都会有一个词法环境。词法环境中包含了两部分主要内容:
- 环境记录(Environment Record) —— 一个存储所有局部变量作为其属性(包括一些其他信息,例如 this 的值)的对象。
- 对外部词法环境的引用
我们可以简单的把词法环境理解为一个字典,里面存储了变量名以及对应值。当 js 在寻找某个变量时,会先寻找自己的词法环境是否存在整个变量,如果找不到,则会去外部的词法环境中寻找,知道找到或者外部词法环境为空。由于这种逐级查找的特性,使得闭包得以实现,例如:
function countFactory(){
var count = 0;
return function(){
count += 1;
return count;
}
}
counter = countFactory()
console.info(counter()) //1
console.info(counter()) //2
console.info(counter()) //3
在 countFactory 定义的匿名函数在 countFactory 结束之后仍然可以访问 count 变量并且还可以持续修改。这里不得不提到词法环境的一个重要特性即每当代码块执行的时候会生成新的词法环境。我们假设 countFactory 的词法环境为 a, 匿名函数运行 3 次生成词法环境分布为 b1 b2 b3。实际上在这 3 个词法环境中都是对 a 中的 count 进行修改。因此可以看出,捕获更像是一个运行时的行为,而不是在闭包定义时就就决定了捕获的值。
现在我们再来看闭包陷阱的问题:
function addCallback(){
for(var i = 0; i++; i < 6){
setTimeout(() => console.info(i), 1000)
}
}
由于变量提升的原因,i 实际上存在于 addCallback 的词法环境中。因此当这几个匿名回调函数需要打印 i 时,会去 addCallback 词法环境中寻找 i 而此时其值已经为 6。我们将 var 改为 let 后不存在变量提升,因此 i 存在于循环块的词法环境中,当次循环执行都会产生一个新的词法环境,而每个匿名函数也是捕获的对应循环块词法环境中的 i。这样每个匿名函数都能打印正确的值。这就是为什么 let 可以产生预期效果,而 var 不能的原因。当然,使用立即执行函数也是同样的效果。
当然闭包有各种各样的情况,但你只需要记住:
- 块执行时会产生新的词法环境
- 当前词法环境找不到的变量会继续外外部词法环境寻找
这两点,你就能解决大部分的闭包问题。
参考
本文参考于 https://zh.javascript.info/closure#ci-fa-huan-jing,文章中关于闭包和作用域做出了更详细的解释。建议每个对作用域和闭包有疑问的人都看看。
网友评论