# 什么是闭包

《你不知道的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。下面我们对如上代码进行简要分析:

  1. for 循环创建 10 个定时器,定时器在 for 循环结束执行
  2. for 循环结束后 i 的值为 11
  3. 依次执行 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)

参考:

闭包详解一 (opens new window)

JavaScript 的静态作用域链与“动态”闭包链 (opens new window)

面试 | JS 闭包经典使用场景和含闭包必刷题 (opens new window)