话很少说,先放题:前端
1 for(var i = 1; i <= 5; i++) { 2 setTimeout(function timer() { 3 console.log(i); 4 }, i * 1000); 5 }
上面这段代码相信各位必定不陌生。每一个准备过前端面试的同窗必定看到过这道题目,而且我猜你们必定能在3s内脱口而出:不会按照预期输出一、二、三、四、5,而会输出六、六、六、六、6,要想实现计时器效果,须要把var改为let,变成块级做用域。完毕。面试
而后,当面试官问:若是不使用let还能有什么方法实现预期效果呢?闭包
这时,相信你们也必定会毫无犹豫地说出“闭包”二字,而后信心满满地将修改后地答案递给面试官。函数
嗯,看起来很简单,这有什么难的?!学习
但是,随着对JavaScript学习的深刻,这背后的道理彷佛并无那么简单。下面就以这道题为例,展开谈一谈JavaScript中的做用域闭包。spa
闭包是什么?可以访问其余函数做用域中的变量的函数?函数中的函数?code
在回答这个问题以前,咱们先来看一下 词法做用域 。对象
做用域说白了就是一套规则,用于肯定在何处以及如何查找变量(标识符),而词法做用域就是定义在词法阶段的做用域。也就是说,词法做用域意味着做用域是由你书写代码时函数声明的位置来决定的。blog
那么,回答什么是闭包的问题:ip
当函数能够记住并访问所在的词法做用域时,哪怕函数是在当前词法做用域以外执行,就产生了闭包。
举个例子:
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()函数的第一个参数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 会为每次迭代都生成一个新的做用域,使得延迟函数的回调能够将新的做用域封闭在每一个迭代内部,这样每一个迭代中都会含有一个具备正确值的变量供咱们访问了。
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(上卷)》第一部分 做用域和闭包