🤔

彻底搞懂 JavaScript 闭包:从“前台保管柜”说起

闭包(Closure)是 JS 里最容易“会用但说不清”的概念之一。你可能写过它、踩过它的坑、甚至靠它做过封装——但如果要你解释“闭包到底是什么”,又容易卡壳。

这篇不背定义,咱们用一个生活类比把它讲透:闭包就像前台的保管柜


1. 为什么需要闭包?

先看一个现实需求:你想做一个计数器:

  • 外部不能随便改计数值(防止被乱改)
  • 但你可以通过函数去“加一”“读取”

如果没有闭包,你可能只能把 count 暴露在全局,或者挂到对象上——可控性差、容易污染命名空间

闭包出现,就是为了解决这类“需要私有状态,但又要持续使用”的需求。


2. 类比:前台保管柜 = 闭包

想象你去健身房:

  • 你把手机、钥匙放进一个柜子(私有变量)
  • 前台给你一张柜子钥匙(一个函数)
  • 你离开前台很久了,但只要你还拿着钥匙,你就能随时打开柜子取东西
  • 别人没有钥匙,就算知道柜子里有东西,也拿不到

对应到 JS:

  • “柜子”是某个函数内部的变量(词法环境)
  • “钥匙”是引用这些变量的内层函数
  • “柜子不会消失”是因为:只要钥匙还在用,JS 就会保留那份环境

3. 闭包到底是什么?一句话说清

闭包 = 函数 + 它创建时所在的词法作用域的引用。

关键词是 “创建时”(不是调用时)。
也就是说:函数会记住它“出生”的地方,而不是它“被叫去干活”的地方。


4. 代码实战:最经典的闭包例子(计数器)

function createCounter() {
  let count = 0 // 私有变量:外部拿不到

  return function () {
    count += 1
    return count
  }
}

const counter = createCounter()

console.log(counter()) // 1
console.log(counter()) // 2
console.log(counter()) // 3

这里发生了什么?

  • createCounter() 执行完,按理说内部变量该“下班”
  • 但它返回的函数一直在引用 count
  • 所以 count 被“保管”起来,持续存在

这就是闭包的“保管柜效应”。


5. 你会预测输出吗?(闭包与“变量共享”)

很多闭包坑都来自“循环 + 异步”。

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

你以为输出:0 1 2
实际输出:3 3 3

原因(用保管柜解释):

  • var i 只有一个柜子
  • 三个回调函数拿到的是同一把柜子钥匙
  • 等定时器执行时,循环早结束了,i 已经是 3

修正方案 A:用 let(每轮一个新柜子)

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

修正方案 B:用 IIFE 手动创建新柜子(老派但好懂)

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

6. 闭包的常见用途:它不是“技巧”,是“结构能力”

A. 做私有变量(封装)

function createUser(name) {
  let score = 0

  return {
    getName: () => name,
    addScore: (n) => (score += n),
    getScore: () => score,
  }
}

const u = createUser('Alice')
u.addScore(10)
console.log(u.getScore()) // 10

外部无法直接改 score,只能通过你提供的接口改。

B. 函数工厂(带配置的函数)

function createLogger(prefix) {
  return function log(msg) {
    console.log(`[${prefix}]`, msg)
  }
}

const apiLog = createLogger('API')
apiLog('request start')

prefix 就是被闭包“记住”的配置。

C. 模块化(在没有打包工具的年代尤其常见)

闭包天然就能做模块隔离,避免全局污染。


7. 闭包的代价与坑:别把柜子当垃圾桶

闭包不是“免费午餐”,它会让某些变量活得更久

A. 内存占用:引用不释放,环境就不释放

如果你在闭包里引用了很大的对象(DOM、数组、缓存),而闭包又长期存在,就容易造成内存压力。

实践建议:

  • 用完就把引用置空(尤其在事件监听器、定时器里)
  • 避免在闭包里无意捕获大对象(比如把整个 data 都抓进来,只用其中一个字段)

B. 闭包 + 事件监听:最容易“不小心常驻”

function bind() {
  const big = new Array(1e6).fill('x')

  const handler = () => {
    console.log(big[0])
  }

  window.addEventListener('click', handler)

  // 如果你永远不 remove,这个 big 可能也会跟着长期存在
}

8. 总结:闭包是“记住过去”的能力

用“前台保管柜”记忆闭包:

  • 函数是钥匙
  • 作用域变量是柜子里的东西
  • 只要钥匙还在,柜子就不会被清空
  • 多个钥匙可能开同一个柜子(var 循环坑)
  • 别把柜子塞太满(注意内存与释放)