到底什么是闭包?

我心飞翔 分类:javascript

闭包(closure)在 JavaScript 中可以说是无处不在,但由于我们无法直接观测到它,所以就导致我们经常忽视了它的存在,以致于难以掌握。

事实上,每当我们创建一个函数闭包也会随之产生,它是基于词法作用域书写代码时自然产生的结果。

const a = 1

function foo() {
  console.log(a)
}
 

上面这段代码有闭包吗?如果有,你能说说它具体是什么吗?

接下来,让我们一起来探究一下到底什么是闭包。

基本概念

我们先来看看 MDN 文档是如何描述闭包这个概念的:

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

MDN 把闭包的基本概念描述得已经非常清楚了,但是还缺少一条关键信息,那就是在什么情况下闭包会产生作用。

在《你不知道的 JavaScript(上卷)》一书中是这么定义闭包的:

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

这句话的后半段非常重要,一来加强说明了闭包一定会在函数创建时产生,再者是指出了闭包在什么情况下发挥作用。

这句话前半段中的词法作用域可以理解为函数所在位置的作用域,但实际上还包含了在该函数中能够通过作用域访问到的所有变量(引用)。

综上所述,我们可以将闭包的基本概念总成为两点:

  1. 闭包会在函数被创建时,自动根据所在的词法作用域产生。

  2. 闭包主要是在函数不在所在词法作用域中执行的情况下起作用。

若隐若现的闭包

让我们先来看这段代码:

function foo() {
  const a = 1

  function bar() {
    console.log(a)
  }

  bar() // 1
}

foo()
 

调用 foo() 函数时,bar() 函数也被执行,基于词法作用域的查找规则,bar() 函数可以访问外部作用域(foo() 函数的作用域)中的变量 a

这段代码中有闭包吗,从上文中闭包的基本概念来看,肯定产生了闭包,只是我们很难找到它们的踪迹。

第一个闭包是 foo() 函数在创建时产生的,它引用了外部全局作用域,第二个闭包是 bar() 函数在创建时产生的,它引用了 foo() 函数的作用域以及外部全局作用域。

再来看这段代码:

function foo() {
  const a = 1

  function bar() {
    console.log(a)
  }

  return bar
}

const baz = foo()

baz() // 1
 

与上一段代码不同,在调用 foo() 函数时,我们将内部的 bar() 函数引用作为返回值赋值给了变量 baz,当我们调用 baz() 函数时,实际上调用的就是 bar() 函数本身。

一般来说,当 foo() 函数执行完时,内部作用域会被销毁,垃圾回收机制会被触发,变量 a 占用的内存会被释放。但是当我们在全局作用域中调用 bar() 函数后,还是打印出了变量 a 的值,这就说明 foo() 函数的内部作用域没有被销毁,变量 a 还在内存中。

这就是闭包的强大之处,bar() 函数产生的闭包阻止了 foo() 函数的内部作用域被销毁。这也佐证了闭包基本概念中的第二点,闭包主要是在函数不在所在词法作用域中执行的情况下起作用。

我们再来看看其他情况:

function sleep(fn, delay, name) {
  setTimeout(function bar() {
    fn(name)
  }, delay)
}

sleep((who) => {
  console.log( `${who} wake up`) 
}, 1000, 'jack')

// 约一秒后
// jack wake up
 

这段代码中,当 sleep() 函数执行完后,它的作用域也不会立即消失,这是因为 bar() 函数依然保持着有 sleep() 函数作用域的闭包。

1000 毫秒后,bar() 函数顺利地从 sleep() 函数作用域中找到了变量 fn 和变量 data

像类似 setTimeout() 这样的异步任务中,我们很容易发现闭包的影子,因为它们都使用了回调函数,该函数中又引用了外层作用域的变量。

const foo = (function (param) {
  return function bar() {
    console.log(param)
  }
})('A')

foo() // A
 

上面这段代码使用了 IIFE 立即执行函数,当该函数执行完时,它创建的作用域会一直保留,因此 bar() 函数能够访问到变量 param,当然这也是闭包的作用。

原来如此,IIFE 也是容易发现闭包的地方,闭包真是无处不在。

闭包的作用

上文中我们理清了闭包的概念及特点,现在我们来总结一下闭包的作用。

在上文中也提及到,闭包可以阻止外部函数执行完后,其作用域不被销毁,继而保证内部函数依然可以访问外部函数作用域中的变量。

我们来看看这个例子:

for (var i = 0; i <= 10; i++) {
  (function (i) {
    setTimeout(() => console.log(i), i * 1000)
  })(i)
}
 

每次循环 IIFE 都会创建一个独立的作用域,每个作用域下的变量 i 都不一样。由于传给 setTimeout() 函数的箭头函数会产生一个闭包,引用了 IIFE 创建的作用域,该作用域的生命周期得到了延长,所以上面这段代码会依次打印 0 到 10,大约一秒钟打印一次。

总而言之,闭包最大作用就是可以延长作用域的生命周期。

例如我们常用的防抖(debounce)函数:

function debounce(fn, delay, ...curries) {
  let timer = null
  const callback = function (params) {
    fn.apply(this, [...curries, ...params])
  }

  return function () {
    if (timer !== null) {
      clearTimeout(timer)
    }

    timer = setTimeout(callback.bind(this, arguments), delay)
  }
}

const handleClick = debounce((...data) => console.log(data), 500, 'A')
 

这个简单实现的防抖函数例子中,debounce() 函数返回的匿名函数产生了一个引用 debounce() 函数作用域的闭包,因此 debounce() 函数作用域的生命周期被延长,里面的变量 timer 和变量 callback 都能在匿名函数中访问到。

闭包的副作用?

长久以来,闭包一直被一部分人认为是最容易造成内存泄露的罪魁祸首。

const foo = (function () {
  const a = 1
  return function () {
    console.log(a)
  }
})()
 

这段代码中,只要变量 foo 指向的匿名函数存在,分配给变量 a 的内存就永远不会得到释放。

的确,滥用闭包会有内存泄露的危险,但是更加值得我们关注的是它带来的好处。

总之,我们应该去认识和拥抱它,并利用好这一特性带给我们的魔力,用过的人都说好!

小结

本文主要探究了 JavaScript 中闭包这一晦涩难懂的特性,总结了它的基本概念、特点以及作用。

以上就是本文的全部内容,希望对你有所帮助。

本人水平有限,如有错误或有争议的地方,请及时指出。

最后,感谢阅读,欢迎交流分享!

回复

我来回复
  • 暂无回复内容