从一道经典的setTimeout面试题谈作用域闭包

前言

话不多说,先放题:

1 for(var i = 1; i <= 5; i++) {
2   setTimeout(function timer() {
3         console.log(i);
4     }, i * 1000);        
5 }

上面这段代码相信各位一定不陌生。每个准备过前端面试的同学一定看到过这道题目,并且我猜大家一定能在3s内脱口而出:不会按照预期输出1、2、3、4、5,而会输出6、6、6、6、6,要想实现计时器效果,需要把var改成let,变成块级作用域。完毕。

然后,当面试官问:如果不使用let还能有什么方法实现预期效果呢?

这时,相信大家也一定会毫无犹豫地说出“闭包”二字,然后信心满满地将修改后地答案递给面试官。

嗯,看起来很简单,这有什么难的?!

可是,随着对JavaScript学习的深入,这背后的道理似乎并没有那么简单。下面就以这道题为例,展开谈一谈JavaScript中的作用域闭包。

 

到底什么是闭包?

闭包是什么?能够访问其他函数作用域中的变量的函数?函数中的函数?

在回答这个问题之前,我们先来看一下 词法作用域 

作用域说白了就是一套规则,用于确定在何处以及如何查找变量(标识符),而词法作用域就是定义在词法阶段的作用域。也就是说,词法作用域意味着作用域是由你书写代码时函数声明的位置来决定的。

那么,回答什么是闭包的问题:

当函数可以记住并访问所在的词法作用域时,哪怕函数是在当前词法作用域之外执行,就产生了闭包。

举个例子:

 1 function foo() {
 2   var a = 2;
 3   function bar() {
 4         console.log(a);
 5   }
 6   return bar;  
 7 }
 8 
 9 var baz = foo();
10 
11 baz();

基于词法作用域的查找规则,函数bar()可以访问foo()的内部作用域。然后我们将bar()函数本身当作一个值类型进行传递,即把bar所引用的函数对象本身当作foo()的返回值。

第9行,在foo()执行后,其返回值赋值给变量baz,然后在第11行调用baz()。这里实质上只是通过不同的标识符引用调用了foo()内部的函数bar()。

bar()显然可以被正常执行,控制台输出2。这恰好应证了上面的定义:bar()在自己定义的词法作用域以外的地方(此处是在全局作用域中)执行了。

正常来说,如果内部没有bar(),那么在foo()执行完之后,其内部作用域会被销毁,占用的内存空间会被垃圾回收器回收。然而,根据前面的分析,我们知道,bar()拥有涵盖foo()内部作用域的闭包。也就是说,foo()的内部作用域由于被bar()使用因此不会被垃圾回收器回收,它依然存在在内存中,以供bar()在之后任何时间进行引用。

bar()依然持有对该作用域的引用,而这个引用就叫做 闭包 。 

因此,当不久之后变量baz被实际调用(调用内部函数bar())时,可以正常访问定义时的词法作用域,即可以正常访问变量a。

这就是闭包的神奇之处:闭包使得函数可以继续访问定义时的词法作用域 。并且,无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

理解setTimeout和闭包

搞清楚闭包是什么之后,对于setTimeout()函数的第一个参数func,我们就很好理解了。

1 function wait(message) {
2     setTimeout(function timer() {
3       console.log(message);
4     }, 1000);
5 }
6 
7 wait("This is closure!");

作为wait()的内部函数,timer()具有涵盖wait()作用域的闭包,因此在第7行的wait()执行1000ms后,timer函数依然保有wait()作用域的闭包,即保有对变量message的引用。这也就是在前面说的“无论在何处执行这个函数都会使用闭包”。

词法作用域在引擎调用setTimeout()的过程中保持完整。

循环和闭包

下面我们回到前言中那道经典的面试题:

1 for(var i = 1; i <= 5; i++) {
2   setTimeout(function timer() {
3         console.log(i);
4     }, i * 1000);        
5 }

我们要弄懂一个关键点,那就是当定时器运行时,无论每个迭代中设置的延迟时间是多长(即使是setTimeout(func, 0)),所有的回调函数都是在循环结束后才会被执行。这道题目中,for循环终止的条件是 i = 6。因此输出显示的是循环结束时i的最终值,也就是我们看到的66666!

那么到底是什么缺陷导致了这段代码的行为同语义所暗示的不一致呢?

缺陷是我们想当然地以为循环中的每个迭代在运行时都会给自己“捕获”一个i的副本。但根据作用域的工作原理,实际上尽管循环中的每个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i(所有函数共享一个i的引用)。这段循环代码和重复定义五次延迟函数的回调是完全等价的。

怎么解决这个缺陷呢?很明显,我们需要为每个timer()创建属于它们自己的闭包作用域。也就是说在循环的过程中每个迭代都需要一个闭包作用域。

IIFE (Immediately Invoked Function Expression) 会通过声明并立即执行一个函数来创建作用域,那这样改造呢?

1 for(var i = 1; i <= 5; i++) {
2     (function () {
3         setTimeout(function timer() {
4             console.log(i);
5         }, i * 1000);  
6     })();
7 }    

上面的改法确实可以拥有更多的词法作用域了 —— 每个延迟函数都会将IIFE在每次迭代中创建的作用域封闭起来。不过,这些作用域都是空的,并不能产生什么实际效果。

我们需要让这些空的封闭的作用域包含一些实质性的东西。比如每次迭代创建闭包的时候,用一个临时变量 j 来储存循环中的 i 的值:

1 for(var i = 1; i <= 5; i++) {
2     (function () {
3         var j = i;
4         setTimeout(function timer() {
5             console.log(j);
6         }, j * 1000);  
7     })();
8 }  

就像下图这样,现在改造后的代码可以如期输出12345了!

我们在 IIFE 中 var 出来的 j 变量实际上只是起到了“传值”的作用,完全可以用参数来替代,没必要单独抽出来。所以,我们把 j 放到参数列表里,稍微改造后的简洁写法是:

1 for(var i = 1; i <= 5; i++) {
2     (function (j) {
3         setTimeout(function timer() {
4             console.log(j);
5         }, j * 1000);  
6     })(i);
7 } 

这里,j 的命名并不重要,因为它只是一个函数的参数,取名叫 i 也可以。

总结一下,在 for 循环内使用 IIFE 会为每次迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,这样每个迭代中都会含有一个具有正确值的变量供我们访问了。

 

let块作用域

ES6之前,要想在JavaScript中使用块作用域,基本上都是通过 IIFE 来操作的。ES6中新增了一种变量声明方式:let,它可以用来劫持块级作用域,并且在这个块作用域中声明一个变量。本质上这是将一个块转换成一个可以被关闭的作用域。

1 for(var i = 1; i <= 5; i++) {
2     let j = i;
3     setTimeout(function timer() {
4         console.log(j);
5     }, j * 1000);  
6 } 

使用let之后,我们就不需要用 IIFE 来包裹 setTimeout() 了!

不过这似乎还是不够简洁...... 实际上,for循环头部的 let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

1 for(let i = 1; i <= 5; i++) {
2     setTimeout(function timer() {
3         console.log(i);
4     }, i * 1000);  
5 } 

下面的图示可以用来理解循环中的 let:

let i0 = 1;
setTimeout(function timer() {
    console.log(i);
}, i * 1000);  
//1000ms后输出1
--------------------------------- let i1 = i0 + 1 = 2; setTimeout(function timer() { console.log(i); }, i * 1000);
//2000ms后输出2
--------------------------------- let i2 = i1 + 1 = 3; setTimeout(function timer() { console.log(i); }, i * 1000);
//3000ms后输出3
--------------------------------- let i3 = i3 + 1 = 4; setTimeout(function timer() { console.log(i); }, i * 1000);
//4000ms后输出4
--------------------------------- let i4 = i3 + 1 = 5; setTimeout(function timer() { console.log(i); }, i * 1000);
//5000ms后输出5
--------------------------------- let i5 = i4 + 1 = 6 > 5; //退出循环

好了,这就是终极版本了!最简单的代码,实现语义和预期相一致的输出。

参考:

《你不知道的JavaScript(上卷)》第一部分 作用域和闭包

原文地址:https://www.cnblogs.com/barryyeee/p/13491600.html