闭包与作用域
闭包不是一种特殊语法,而是函数与其词法环境的组合体。理解了词法作用域,闭包就是顺理成章的副产物:函数记住了它在哪出生,而不是它在哪被调用。
词法作用域决定变量去哪里找
JavaScript 的作用域在编写时就确定了,由代码的物理位置决定。查找变量时沿作用域链向外逐层寻找,找到就停,找不到就报 ReferenceError。
js
const name = '南终'
function greet() {
// 这里看不到 inner 的 message
return name // 往外层找到 '南终'
}
function outer() {
const message = 'hi'
function inner() {
return message // 往外层找到 'hi'
}
return inner
}
var会变量提升且不产生块级作用域;let/const产生块级作用域,存在"暂时性死区"。新代码一律用let/const。
闭包本质:函数携带它的词法环境
当一个函数被拿到它定义的作用域之外执行,它依然能访问定义时的变量——这就是闭包。
js
function makeCounter() {
let count = 0
return {
inc() { count += 1; return count },
get() { return count }
}
}
const counter = makeCounter()
counter.inc() // 1
counter.inc() // 2
counter.get() // 2count 既不是全局变量,也无法从外部直接访问——它被闭包"私有化"了。这是 JavaScript 实现封装和数据隐藏的标准手段。
IIFE 与模块模式
在 ESM 普及之前,IIFE(立即执行函数)是隔离作用域、避免污染全局的主要方式。模块模式在它的基础上封装私有状态:
js
const userRepo = (function () {
const users = [] // 私有
return {
add(u) { users.push(u) },
size() { return users.length }
}
})()现代项目用 ESM 的
import/export即可天然隔离作用域。IIFE 主要在写 SDK 入口、为老环境兼容时才需要。
循环里的闭包陷阱
var 没有块级作用域,循环中创建的闭包共享同一个变量,导致所有回调拿到的都是循环结束后的值:
js
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
// 输出 3 3 3,而不是 0 1 2原因:三个箭头函数引用的是同一个 i,循环结束时它已经是 3。
解法:用 let(每轮迭代都是新的绑定),或者用 IIFE 当场拷贝当前值:
js
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0) // 0 1 2
}闭包与内存
闭包会让它引用的外层变量一直存活在内存里,只要闭包本身还可达。这既是特性也是风险:
| 情况 | 后果 | 处理 |
|---|---|---|
| 长生命周期对象持有大闭包 | 内存不释放 | 用完置 null,或缩小闭包引用范围 |
| 闭包内引用了 DOM 节点 | DOM 卸载后仍被引用,无法回收 | 解绑事件、清除引用 |
| 定时器回调捕获大对象 | 对象常驻 | 在结束时 clearInterval 并释放 |
不是所有闭包都会泄漏。只有当闭包被长生命周期的东西(全局变量、未解绑的事件、未清理的定时器)持有时,内存才会迟迟不释放。
何时用闭包,何时别用
闭包适合:私有状态封装、工厂函数、回调中保留上下文、柯里化与函数式组合。
不适合:单纯为了"避免全局污染"包一层(用模块化代替)、把简单逻辑包装成多层闭包导致难调试、需要被序列化或跨上下文传递的函数(闭包无法被 JSON.stringify 带走)。
一个判断标准:如果外层变量除了被这个函数使用之外没有别的用途,那它就是闭包的合理用法;如果外层变量本来就该是普通模块状态,就别硬塞进闭包里。
检查表
- 变量声明统一用
let/const,循环变量用let避免闭包共享。 - 需要私有数据时优先用闭包或模块封装,而不是挂在全局或
this上。 - 长生命周期对象持有闭包时,确认有明确的释放路径(解绑、清理、置空)。
- 事件回调和定时器在组件/页面销毁时统一解绑,避免闭包持有 DOM 或大对象。
- 不要为了用闭包而把简单代码层层包裹;可读性优先于"看起来高级"。