JS之闭包
1. 先说定义:
当函数可以记住并访问所在的作用域时,就产生了闭包。即使函数是在当前作用域之外执行。
2. 理解闭包需要知道的知识:作用域
闭包产生的前置条件是作用域
。JS的有两种作用域:全局作用域
和函数作用域
,且作用域之间可以相互嵌套。就像下面这样:
这里有三级作用域:全局作用域
- foo()函数的作用域
- bar()函数的作用域
,分别对应 1 - 2 - 3
3. 再放一段经典的产生闭包的代码:
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); //2
4. 一个产生闭包的关键角色:垃圾回收器
正常情况下foo()
函数在执行结束以后,foo()
整个的作用域都会被垃圾回收器销毁。但是var baz = foo()
这里变量baz
是foo()
函数返回的bar()函数的引用,且bar()
中还使用了foo()
的变量,因此foo()
函数的作用域会一直存在,且可以在foo()
函数作用域之外还可以访问foo()
的作用域。这样就产生了一个闭包。
5. 闭包的一种使用场景
先看一个比较经典的面试题:
for (var i = 1; i <= 5; i++) {
setTimeout(() => {
console.log(i);
}, 1000)
}
接触过这道题的人应该都知道这段代码执行结果是:1s后同时输出5个6。
但是我们期望的结果肯定不是这样的,我们希望的结果是1s后输出1 2 3 4 5
。
产生这种结果的原因是:
- 为什么是6: 使用var关键字是可以声明重复的变量,且for循环的
{ .. }
内又不属于一个与全局作用域区开来的作用域,因此当这个for循环结束以后,会在全局声明一个名称为i变量,它的值是6。 - 为什么都是6:因为
setTimeout
定时器属于事件循环的消息队列的内容,它并不会在JS主线程中同步执行,所以在for循环结束以后才会执行这5次setTimeout
定时器,但是此时console.log(i)
中的 i 此时访问的是全局的i变量,也就是固定的6。
那么,如何通过闭包解决这个问题?
有两种常用的解决方案。先放代码:
// 最好解决方法:
for (let i = 1; i <= 5; i++) {
setTimeout(() => {
console.log(i);
}, 1000)
}
// 用IIFE也可以实现
for (var i = 1; i < 5; i++) {
(function (i) {
setTimeout(() => {
console.log(i);
}, 1000)
})(i)
}
这是如何解决问题的?
第一种方法:
第一种方法,直接将for循环中的var关键字改成了let关键字。
let关键字声明变量有以下几个特点:
- let关键字可以将变量绑定到当前所在的作用域(通常是
{ .. }
内部) - 不存在变量提升。即:使用let声明的变量的使用一定要在声明之后使用。
- 在块级作用域以外的地方无法访问声明的变量。
- 不允许使用let关键字多次声明同一个变量。
在方法一中起作用的就是以上let声明变量四个特点中的1和3,
- 改成let关键字,每次循环的i都被绑定在当前for循环的的块级作用域中,且这个i是无法被for循环以外的作用域访问到的。这样就形成一个封闭的作用域,for循环结束以后也就不会在全局作用域出现一个i变量。
- 同时由于
setTimeout
定时器又引用了当前for循环的块级作用域的i,因此在每次循环结束以后,这个for循环的{ .. }
的块级作用域并不会马上被垃圾回收器
释放,此时就形成了一个闭包。在for循环结束以后就会形成5个闭包,且每个闭包都有一个i变量,每个i变量也都是正确的值。
第二种方法:
使用IIFE(立即执行函数),虽然for循环结束以后,会有一个全局的i = 6
,但是执行定时器的时候访问的i却不是这个全局的i。
在每次循环时,将当前的i通过参数的方式传递给IIFE中的匿名函数,每次for循环时,立即执行函数执行结束,但是其中的匿名函数的作用域由于setTimeout定时器
还使用着匿名函数的形参,因此没有立即释放,这样也就形成了闭包。在JS主线程执行结束,执行到5个定时器任务时,因此也可以实现相同的功能。
6. 闭包的其他使用场景
本质上,如果将函数当作第一级的值类型并到处传递,就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其它的异步任务中,只要使用了回调函数,实际上就是在使用闭包。