
# 什么是闭包
《你不知道的JavaScript》这样描述:
TIP
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
《JavaScript高级程序设计》第四版:
TIP
闭包指的是那些引用了另一个函数作用域中变量的函数。
如果换成大白话,可以这样理解:有权访问另一个函数作用域变量的函数。
function fn1() {
const content = 'hello'
return function fn2() {
console.log(msg)
}
}
const fn3 = fn1()
fn3()
上面是一个经典的闭包。下面我们来简单分析下:
- fn2 的词法作用域能访问到 fn1 的作用域
- 将 fn2 当成返回值返回
- fn1 执行后,将 fn2 的引用赋值给 fn3
- 执行 fn3 输出 'hello'
我们知道通过引用的关系,fn3 就是 fn2 函数本身。执行 fn3 能正常输出 'hello' ,说明 fn2 能记住并访问它所在的词法作用域,并且 fn2 函数的运行是在当前词法作用域之外。
一般来说,当 fn1 函数执行完毕之后,其作用域会被销毁,然后垃圾回收器会释放这段内存空间。而闭包却很神奇的将 fn1 的作用域存活了下来,fn2 依然持有该作用域的引用,这个引用就是闭包。
# 形成闭包的原因
实际上面的已经粗略描述了闭包形成的原因:一个函数执行完后本该销毁,但由于另一个函数引用了其中的变量,导致这个函数无法销毁。
# 闭包的常见形式
# 返回一个函数
这种形式上面已经介绍过,这里不再赘述。
# 当做函数参数传递
function fn1() {
const content = 'hello world'
function fn2() {
console.log(content)
}
fn3(fn2)
}
function fn3(fn) {
fn()
}
fn1()
# 回调函数
我们在编程的时候使用的任何回调函数中,基本都使用了闭包。如定时器、事件监听、Ajax请求、跨窗口通信、Web Workers等等。
// 事件监听
btn.onclick = function() {}
// 定时器
setTimeout(function timeHandler(){
console.log('timer');
},100)
# 函数
下面列举了两种不是经典闭包,但也可以认为是闭包。
《JavaScript权威指南》中说:从技术的角度讲,所有的JavaScript函数都是闭包:它们都是对象,它们都关联到作用域链。
立即执行函数
const content = 'hello'
(function() {
console.log(content)
})()
《JavaScript高级程序设计》中说: 闭包是指有权访问另一个函数作用域中的变量的函数;
function fn1() {
const content = 'hello'
// fn2 函数可以访问 fn1 函数中的 content 变量
function fn2() {
console.log(content)
}
}
fn1()
# 闭包的作用
保存:将上级作用域的引用保存下来,实现方法或者属性的私有化
保护:保护函数的私有变量不受外部干扰;形成不被销毁的栈内存;
# 闭包的的注意点
因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过多的使用闭包可能导致内存过度占用。
# 闭包真题
运行如下代码会输出什么?
for (var i = 1; i <= 10; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
可能有的同学会认为输出的是 1-10 十个数字,但实际输出的 10 个 11。下面我们对如上代码进行简要分析:
- for 循环创建 10 个定时器,定时器在 for 循环结束执行
- for 循环结束后 i 的值为 11
- 依次执行 10 个定时器 怎么修改上述代码才能输出 1-10 十个数字呢?
导致这种情况的原因:由于 i 是全局声明的,定时器的匿名函数也是全局执行的,所以输出 10 个 11 。
解决办法:让 i 每次循环的时候,都产生一个私有作用域,在这个私有作用域中保存当前 i 的值。下面列举几种常见的解决办法。
# 使用 let 取代 var
for(let i = 1;i <= 10; i++){
setTimeout(function timer(){
console.log(i)
}, 1000)
}
# 立即执行函数
for(var i = 1; i <= 10; i++){
(function(j){
setTimeout(function timer(){
console.log(j)
}, 1000)
})(i)
}
# 使用 setTimeout 第三个参数
setTimeout 可以传递第三个参数,一旦定时器到期,这些值会作为参数传递给 function 。这种方法不常用,不推荐使用这种方法,setTimeout 使用方法 (opens new window)
for(var i = 1; i <= 10; i++){
setTimeout(function timer(j){
console.log(j)
}, 1000, i)
}
下面的代码会输出什么?
var data = []
for (var i = 0; i < 3; i++) {
data[i] = function() {
console.log(i)
}
}
data[0]()
data[1]()
data[2]()
/*
3
3
3
*/
这道题和上面的基本是一样,具体解决办法可参考上面的方法,这里不再赘述。
# 闭包的实际应用
上面我们说了很多闭包相关的理论知识,但是闭包在实际开发中怎么使用呢,这里简单介绍下
# 防抖
function debounce(fn, delay) {
let timer = null;
return function() {
if (timer) clearTimeout(timer);
timer = setTimeout(function() {
fn.apply(this, arguments);
}, delay);
};
}
# 节流
function throttle(fn, interval) {
let last = 0;
return function() {
const now = Date.now();
if (now - last >= interval) {
fn.apply(this, arguments);
}
};
}
es5 解决循环问题
var i
for (i = 0; i < 10; i++) {
(function (i) {
var a = document.createElement('a')
a.innerHTML = i + '<br>'
a.addEventListener('click', function (e) {
e.preventDefault()
alert(i)
})
document.body.appendChild(a)
})(i)
}
# 函数柯里化
# compose
# 深入理解闭包
上面只是简单列举了闭包的一些基本知识。这里推荐一篇文章 JavaScript 的静态作用域链与“动态”闭包链 (opens new window) 这篇文章对闭包更深层次的一些东西进行了讲解。这里对这篇文章的重点内容进行一些摘抄。
闭包是在函数创建的时候,让函数打包带走的根据函数内的外部引用来过滤作用域链剩下的链。它是在函数创建的时候生成的作用域链的子集,是打包的外部环境。evel 因为没法分析内容,所以直接调用会把整个作用域打包(所以尽量不要用 eval,容易在闭包保存过多的无用变量),而不直接调用则没有闭包。
使用 [[Scopes]] 来放函数打包带走的用到的环境。
function fn1() {
const a = 'a'
function fn2() {
const b = 'b'
function fn3() {
const c = 'c'
console.log(a)
console.log(b)
console.log(c)
// eval('console.log(a,b,c)')
}
return fn3
}
return fn2
}
const fn2 = fn1()
const fn3 = fn2()
fn3()
console.dir(fn3)

[[Scopes]] 是v8的实现,算是一种优化。ES Spec 当中只提到了函数会对外部词法作用域有一个引用,并没有提闭包变量捕获的问题。记得 V8 的这个优化还容易搞出一个 bug,如果父函数内有两个子函数,那他们俩会共用一个 scope 对象来存放捕获的外部变量。而如果其中一个函数当返回值返回了,另一个函数并没有,也会造成根本用不着的变量被一直引用着。
function test() {
let a = 1; let b = new Array(100000).fill(0);
let fa = () => a;
let fb = () => b;
return fa;
}
const fn4 = test()
console.dir(fn4)

参考: