闭包 (Closure):深度解剖


一、定锚

  • 定义确认:闭包是一个函数及其对其周围状态(词法环境)的引用的组合。通俗误解认为它仅仅是“函数套函数”,实际上其核心在于:即便外部函数已经执行完毕,内部函数依然能“记住”并访问外部作用域的变量。
  • 核心词素:函数(逻辑体)、环境(变量容器)、持久化(生命周期延伸)。

二、八刀剖析

  1. 历史: 起源于 1960 年代的 演算,由 Peter Landin 在 1964 年正式定义为“包含环境的表达式”。随后在 Scheme 语言中被发扬光大,最终成为现代 JavaScript、Python、Swift 等主流语言中实现函数式编程和模块化的灵魂。

  2. 辩证: 它是“函数”与“对象”的统一。在面向对象中,对象是附带过程的数据;而在闭包中,函数是附带数据的过程。它解决了纯函数的“无记忆性”与全局变量的“污染性”之间的矛盾。

  3. 现象: 闭包就像一个随身背囊。当一个函数(旅行者)从它的出生地(父作用域)出发时,它顺手抓起了一些必需品放入背囊。此后无论它走到世界的哪个角落,只要它还在,那个背囊及其中的物品就一直存在。

  4. 语言: “Closure” 源自“Closing over”,意为“包围/封闭”。它暗含了一种边界感:将原本易逝的局部变量“关”进了一个私有的时空胶囊,使其免于被垃圾回收机制(GC)销毁。

  5. 形式 失效边界:当闭包过多或引用了巨大的数据结构而未被释放时,会引发“内存泄漏”;此外,闭包捕获的是变量的引用而非当前值,这在循环赋值中常导致直觉上的逻辑错误。

  6. 存在: 闭包重塑了代码的“生命观”。它证明了:一个事物的存在不取决于它所在的空间是否坍塌,而取决于它是否仍被需要。 它允许开发者在瞬时的执行流中,创造出永恒的私有状态。

  7. 美感: 闭包之美在于留白与隐藏。它像是一枚精致的琥珀,原本透明流动的变量(树脂)在特定瞬间凝固,包裹住了其中的逻辑(昆虫),形成一种静谧而深邃的封装结构。

  8. 元反思: 我们常将闭包比作“容器”,这暗示了某种静态性。若将其替换为“引力场”:函数并非装载变量,而是产生了一种引力,强行拉住了即将坠入虚无的变量。这提醒我们,闭包的代价是它对系统资源的“拖拽感”。


三、内观

我是一个拒绝遗忘的生命体。虽然我的母体(执行上下文)已经消亡,但我依然呼吸着她留下的空气。在外部世界看来,我只是一个普通的函数调用,但在我内部,我守着一处无人知晓的私人花园。只要我不消失,那里的花(变量)就永远不会凋零。


四、压缩总结

公式 闭包 = 逻辑函数 + 存活的上下文环境

核心逻辑 打破生命周期的束缚,实现私有状态的永续。

结构图

      GLOBAL SPACE
    +---------------------------------------+
    |  (Outer Function)  - Died             |
    |    +---------------------------+      |
    |    |  Lexical Environment      |      |
    |    |  [ Var A ]  [ Var B ] <---+---+  |
    |    +-------------^-------------+   |  |
    |                  |                 |  |
    |    (Inner Function/Closure)        |  |
    |    +---------------------------+   |  |
    |    |   Logic (Code)            |   |  |
    |    |   Reference to Env  -------+   |  |
    |    +-------------+-------------+      |
    +------------------|--------------------+
                       |
             Returned to Outer World

五、先把一个误区打掉

很多人第一次学闭包时,会把它理解成“函数里 return 一个函数”。

这只是最经典的一种写法,但不是闭包的全部。

更准确地说:

只要一个函数在别处执行时,还能访问它创建时所在作用域里的变量,这里就有闭包。

所以这几种其实都是闭包:

  • 返回内部函数
  • 把函数传给 setTimeout
  • 把函数当事件回调
  • 把函数传给 Promise.then
  • 在 React 里把函数交给 useEffect

也就是说,闭包的关键不是“return”,而是函数把外层变量记住了


六、闭包在实战里到底出现在哪

1. 私有变量 / 工厂函数

这是最教科书式的用法。

function createCounter() {
  let count = 0
 
  return function () {
    count++
    return count
  }
}
 
const counter = createCounter()
counter() // 1
counter() // 2

这里的 count 本来只是 createCounter 内部的局部变量,但返回出去的函数一直还在使用它,所以它没有随着外层函数执行结束而消失。

这就是最标准的“私有状态”。


2. 防抖:为什么 timeoutId 不会丢?

防抖 (Debounce) 里的核心结构:

function debounce(func, wait) {
  let timeout
 
  return function (...args) {
    if (timeout) clearTimeout(timeout)
 
    timeout = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}

这里最重要的就是 timeout

如果没有闭包,每次事件触发时,函数重新执行,timeout 都应该重新创建、重新丢失,那它就不可能记住“上一次的定时器是谁”。

但因为返回出去的函数形成了闭包,它一直引用着 timeout 所在的词法环境,所以:

  • 第一次触发时,timeout 记录下第一次定时器 ID
  • 第二次触发时,还能拿到上一次的 timeout
  • 于是就可以 clearTimeout(timeout)
  • 然后重新设置一个新的定时器

所以防抖本质上是:

利用闭包,把 timeoutId 从“某次调用的局部变量”变成“多次调用共享的私有状态”。

这也是你说的那个点: timeId 能够“脱离当前这一次函数调用而继续存在”。


3. 节流:为什么它能记住“冷却状态”?

节流 (Throttle) 的实现:

function throttle(func, wait) {
  let timeout = null
 
  return function (...args) {
    if (!timeout) {
      timeout = setTimeout(() => {
        func.apply(this, args)
        timeout = null
      }, wait)
    }
  }
}

或者时间戳版:

function throttle(func, wait) {
  let lastTime = 0
 
  return function (...args) {
    const now = Date.now()
 
    if (now - lastTime >= wait) {
      lastTime = now
      func.apply(this, args)
    }
  }
}

这里的 timeoutlastTime,本质上都在做一件事:

  • 记录“上一次有没有执行”
  • 记录“上一次是什么时候执行的”

这类“跨多次调用共享的状态”,天然就是闭包的地盘。

所以:

  • 防抖记住的是“上一次定时器”
  • 节流记住的是“上一次执行资格”

它们背后都不是魔法,都是闭包在托住状态。


4. Promise:闭包到底参与了哪一层?

这个点要说严谨一点。

很多人会顺嘴说“Promise 的状态就是靠闭包保存的”,这句话不总是对

比如在 构建自己的Promise 里,你现在写的是 class 版本:

this.state = "pending"
this.value = undefined
this.reason = undefined

这里 pending / fulfilled / rejected 这些状态,严格来说是保存在 Promise 实例对象 上的,不是直接存在闭包里。

但是闭包仍然深度参与了 Promise 的实现,至少有这几层:

第一层:resolve / reject 记住当前 Promise 实例
const resolve = (value) => {
  if (this.state !== "pending") return
  this.state = "fulfilled"
  this.value = value
}

这里的 resolvereject 虽然以后才会被调用,但它们依然知道自己应该去改哪一个 Promise 的状态。

为什么知道?

因为它们在创建时,就已经“带着”那一层上下文了。

你把它理解成:

Promise 把两个“带记忆的函数”交给了执行器。

第二层:异步回调记住 resolve / reject
new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("成功")
  }, 1000)
})

这里 setTimeout 里的回调,为什么 1 秒后还能调用 resolve

因为这个回调本身就是闭包,它记住了外层的 resolve

也就是说:

  • resolve 是被 Promise 造出来的
  • setTimeout 里的函数又把 resolve 记住了

所以异步结束后,它还能回来改状态。

第三层:then 里的包装函数记住了回调和后续控制权

Promise底层讲解 里,then 通常会包装出这样的函数:

const fulfilledWrapper = (value) => {
  const result = onFulfilled(value)
  resolve(result)
}

这个 fulfilledWrapper 其实也在闭包:

  • 它记住了 onFulfilled
  • 它记住了新的 resolve
  • 它记住了新的 reject

这样等将来 Promise 真正 fulfilled 时,这个函数才能继续把链式调用往下推进。

第四层:如果不是 class 写法,状态本身也可以直接放在闭包里

比如函数式实现会写成:

function myPromise(executor) {
  let state = "pending"
  let value
  let reason
 
  function resolve(val) {
    if (state !== "pending") return
    state = "fulfilled"
    value = val
  }
}

这时候 statevaluereason 就真的是闭包里的私有变量了。

所以更准确的说法应该是:

Promise 不一定把状态存在闭包里,但 Promise 的“异步回调还能改状态、then 还能接着链下去”这件事,一定大量依赖闭包。


5. 柯里化:为什么前面的参数不会丢?

函数柯里化

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args)
    } else {
      return function (...args2) {
        return curried.apply(this, args.concat(args2))
      }
    }
  }
}

这里最关键的是:

  • 第一次调用拿到 args
  • 第二次调用还能拿到上一次的 args
  • 然后和新的 args2 合并

为什么后一次还能记住前一次的参数?

因为返回出去的函数闭包住了前一次的 args

所以柯里化本质上也是:

用闭包把“之前已经传过的参数”保存起来,等后面凑齐再执行。


6. 模块化 / IIFE:为什么有些变量像“私有成员”?

早期 JavaScript 在没有 ESM、CommonJS 之前,经常会这样做:

const counterModule = (function () {
  let count = 0
 
  return {
    inc() {
      count++
    },
    get() {
      return count
    },
  }
})()

这里的 count 外面拿不到,但 incget 永远能拿到。

这就是闭包在做“模块私有变量”。

也正因为这个思路,才有了后来的模块化封装演进。可以结合 从前端模块化历史到大厂面试题_牛客网 一起看。


7. React:为什么会有“闭包陷阱”?

在 React 里,闭包既是能力来源,也是常见坑点来源。

比如 React生命周期 里提到的这个例子:

useEffect(() => {
  setCount(count + 1)
}, [])

这里的问题是:

  • useEffect 里的函数形成了闭包
  • 它拿到的是那次渲染时的 count
  • 依赖数组又写成 []
  • 所以后面不会随着新的 count 自动更新这份闭包里的值

这就是常说的“拿到旧值”或“stale closure”。

再结合 state 如同一张快照 看,就更好理解:

React 每次渲染都会生成一份新的“当次快照”,而闭包抓住的,正是某一次快照里的变量。

所以在 React 里常见两个结论:

  • 为什么异步回调里经常拿到旧 state?因为闭包记住的是旧渲染快照
  • 为什么函数式更新 setCount(c => c + 1) 更稳?因为它不依赖旧闭包里的 count

七、闭包到底帮我们解决了什么

如果把上面这些场景揉在一起看,会发现闭包几乎一直在解决同一类问题:

  1. 让状态跨调用存在 比如防抖里的 timeoutId、节流里的 lastTime

  2. 让数据对外部隐藏 比如模块化里的私有变量、工厂函数里的内部计数器

  3. 让异步回调还能回来继续工作 比如 setTimeout、Promise、事件回调

  4. 让函数能分步执行 比如柯里化、偏函数、bind 场景里的参数预置

所以一句更实战的话是:

闭包的价值,不只是“记住变量”,而是“让状态跨越当前执行栈继续活着,并且只暴露给需要它的函数”。


八、常见风险和误区

1. 闭包捕获的是“变量”,不是“那一刻的值”

这也是为什么循环里经常出问题:

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i)
  }, 0)
}
// 3 3 3

因为三个回调闭包引用的是同一个 i

如果想隔离每一次的值,可以用 let 或额外包一层函数作用域。

2. 闭包不是越多越好

闭包会延长变量生命周期。

如果闭包里挂着很大的对象、DOM 引用、定时器、订阅器,而你又没有清理,就会带来额外内存占用,严重时形成泄漏风险。

典型清理动作包括:

  • clearTimeout
  • clearInterval
  • 取消事件监听
  • 断开订阅
  • 把不再需要的闭包引用设为 null

3. 不是所有“访问外部变量”都值得用闭包包装

闭包很好用,但如果只是为了“能访问一个值”,却让作用域层级越来越深,代码会更难读。

所以闭包最值得出现的地方,往往是:

  • 你真的需要私有状态
  • 你真的需要跨调用保留数据
  • 你真的需要异步回调在未来继续访问当前环境

九、把这篇笔记压成一句人话

闭包不是“函数套函数”这么简单,它真正厉害的地方在于:

它能让一个函数把自己出生时周围的变量一起带走,让这些变量即使脱离了原来的函数作用域,依然继续存在,并在未来的调用中持续发挥作用。

所以你在项目里看到的:

  • 防抖记住 timeoutId
  • 节流记住 lastTime
  • Promise 回调在异步完成后还能改状态
  • 柯里化记住前面的参数
  • 模块化保留私有变量
  • React 回调拿到某次渲染的快照

背后都能看见闭包的影子。

reference

Js中闭包的概念 防抖 (Debounce) 节流 (Throttle) 防抖与节流 构建自己的Promise Promise底层讲解 函数柯里化 React生命周期 state 如同一张快照 从前端模块化历史到大厂面试题_牛客网