学完这篇你能搞清闭包到底是怎么产生的、为什么会有内存驻留,并且能在防抖、私有变量、缓存等场景里放心地用,而不是背一句“函数里返回函数”就完事。
第一步:先确认闭包的形成条件
闭包不是语法糖,是作用域链的必然结果。只要同时满足两点就是闭包:
- 存在嵌套函数(内层函数引用了外层函数的变量);
- 内层函数在外层函数返回之后仍然能被访问(被返回、被挂到事件上、被存进数组等)。
function outer() {
let count = 0;
return function inner() {
count++;
return count;
};
}
const fn = outer(); // outer 已执行完
fn(); // 1
fn(); // 2 —— count 没有被销毁
outer 执行完后,正常情况下它的执行上下文会出栈回收,但因为 inner 的 [[Scope]] 还引用着 count,这块内存被保留了下来。这就是闭包的本质:函数记住了它定义时所在的作用域,而不是调用时所在的作用域——这叫词法作用域(静态作用域)。
注意:闭包捕获的是“变量”而不是“值的快照”。所以循环里用 var 声明会出经典问题,因为所有回调共享同一个 i。
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3 3 3
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 0 1 2
let 每次迭代都会创建新的绑定,本质上是“每个循环体一份闭包”,这也是最省事的修法。
第二步:搞懂内存怎么被留住、什么时候释放
闭包变量存在堆上的 Context 对象里,由内层函数引用着。释放条件只有一个:没有任何可达引用。也就是说,把那个内层函数丢掉(置 null、解绑事件、清空数组),变量才会被 GC 回收。
注意:闭包本身不“泄漏”,真正泄漏的是“你以为它已经没了,其实还挂在那儿”。典型场景:window.addEventListener 绑定的回调引用了大对象却从不 removeEventListener;定时器 setInterval 里的闭包引用了 DOM 节点,定时器没清、节点删了却回收不掉。
第三步:在真实场景里用它
场景 1:私有变量与模块模式
const counter = (() => {
let n = 0; // 外部无法直接访问
return {
inc: () => ++n,
get: () => n
};
})();
这是 ES Module 之前最主流的封装手段,现在写工具库内部状态依然好用。
场景 2:防抖 / 节流
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
timer 必须活在外层作用域里,否则每次调用都是新变量,防抖直接失效。
场景 3:记忆化缓存
function memo(fn) {
const cache = new Map();
return function (key) {
if (cache.has(key)) return cache.get(key);
const val = fn(key);
cache.set(key, val);
return val;
};
}
斐波那契、接口结果缓存都用它。缺点同样明显:cache 只增不减,长期驻留会吃内存,需要设上限或做过期清理。
场景 4:柯里化与偏函数
const add = a => b => a + b;
const add5 = add(5);
场景 5:事件回调携带上下文
循环里给一排按钮绑定点击事件,需要“记住”当前是第几个按钮,用闭包或 data-* 都可以,闭包写法更直观。
第四步:避开三个高频坑
this 丢失:闭包里 this 不会自动继承外层,用 const self = this 或箭头函数解决。
- 循环里共享变量:优先用
let,老代码里用 IIFE 兜底。
- 无节制缓存:凡是闭包里存了数组、Map、DOM 引用,都要想清楚清理时机。
注意:现代浏览器对闭包的优化已经很到位,只在真正需要的地方(大量数据、长期存活的定时器/监听器)关注释放问题,普通业务闭包不必紧张。
小结
- 闭包 = 内层函数 + 它记住的外层作用域,本质是词法作用域的产物。
- 变量活在堆上的
Context 里,由内层函数的引用链维系,引用断了才回收。
- 四大常用场景:私有变量/模块封装、防抖节流、记忆化缓存、柯里化与带上下文的回调。
- 循环共享变量用
let 解决;this 用箭头函数或 self 解决;长期驻留的闭包要主动解绑、清定时器、限缓存。
- 闭包不是性能问题,失控的引用才是。