01 · NOTES / WebGIS 开发
第 33 节 · 闭包与内存
2026 年 9 月 1 日
第 33 节 · 闭包与内存
📌 版本信息:基于 ECMAScript 2025(词法作用域多年稳定;2026-08-29 核对) 📚 来源:现代 JS 教程 · 闭包 | MDN Closures
一、这一节的目标
- 给闭包下一个可操作的定义
- 用"词法作用域"解释闭包为什么天然存在
- 认识闭包的四大实战用途(你已在用,现在知道为什么)
- 理解闭包与内存泄漏的关系、循环里的经典陷阱
- 会用 DevTools Memory 面板初查内存问题
二、闭包到底是什么
可操作定义:函数记住了它定义时所处的作用域——即使那个作用域的函数已经返回,这些变量依然可访问。
function makeCounter() {
let count = 0; // makeCounter 的局部变量
return function () {
count += 1; // 内层函数引用了它
return count;
};
}
const c = makeCounter(); // makeCounter 已执行完、返回了!
c(); // 1
c(); // 2 ← count 没有被销毁,被内层函数"闭"住了
为什么会这样(词法作用域视角):JS 的作用域在写代码时就定了(函数在哪定义,就能看到哪层变量)。引擎发现内层函数引用了外层的 count,就把 count 留在内存里不回收——闭包不是特殊语法,是词法作用域的自然结果。
三、闭包的四大实战用途(全是你在用/将用的)
// ① 私有状态:外面摸不到,只能通过暴露的函数操作
function makeWallet(initial) {
let balance = initial; // 私有:外部无法直接改
return {
deposit(n) { balance += n; return balance; },
getBalance() { return balance; },
};
}
const w = makeWallet(100);
w.deposit(50); // 150
w.balance; // undefined ← 私有生效
// ② 给回调"随身携带"参数
const cityId = 42;
button.addEventListener('click', () => fetchCity(cityId)); // 回调闭住了 cityId
// ③ 防抖/节流(地图拖动、搜索框的神器,第 15 模块会正式封装)
function debounce(fn, ms) {
let timer; // 闭包住定时器
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
const onSearch = debounce((kw) => console.log('搜', kw), 300);
// ④ 一次性初始化(单例缓存)
const loadConfig = (() => {
let cache; // 只加载一次
return () => cache ??= heavyLoad();
})();
回看旧知识:第 11 节计算器练习的 history、第 14 节的 makeCounter、第 18 节工具——全是闭包在工作。
四、闭包与内存:硬币的另一面
闭包会让被引用的变量常驻内存——这正是它的功能,也是泄漏的来源:
// 泄漏场景:大对象被一个长生命周期的闭包拽着不放
function attachHandler() {
const hugeData = new Array(1e6).fill('…'); // 大数组
document.querySelector('#btn').addEventListener('click', () => {
console.log(hugeData[0]); // 闭包引用了 hugeData
});
}
// 只要 #btn 活着,hugeData 就不能被回收——哪怕你只用它一次
三条防线:
- 闭包里别引用大对象;用完置
null - 单页应用里,组件销毁时移除监听(
removeEventListener),否则闭包链拽着整个组件 - 循环 + var 的经典陷阱(现在用 let 天然免疫,但要认得出老代码):
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i)); // 3 3 3!var 无块作用域,三个闭包共享同一个 i
}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i)); // 0 1 2 ✓ let 每轮新建一个绑定
}
DevTools Memory 面板初查:拍堆快照(Heap snapshot)→ 操作可疑流程再拍一张 → Comparison 视图看"Detached(已脱离 DOM 但没被回收)"的对象——它们十有八九被某个闭包拽着。
五、自测题
- 用"词法作用域"解释闭包为什么存在。
- makeWallet 里 balance 为什么是"私有"的?
- debounce 的闭包变量是什么?它如何实现防抖?
for (var i...)+ setTimeout 输出 3 3 3 的机制?let 为什么免疫?- 单页应用里"页面切走了但定时器还在跑"是什么问题?怎么解?
参考答案
- 作用域由代码书写位置决定;内层函数引用外层变量时,引擎为维持词法可见性而保留变量——这就是闭包。
- balance 只存在于 makeWallet 的作用域内,唯一出口是返回的两个方法(暴露了受控接口而非裸数据)。
- 闭包住 timer;每次调用先清掉上一次定时器再重设——只有"停止输入 ms 毫秒后"才真正执行 fn。
- var 的 i 是函数级唯一变量,三个回调闭包共享它,循环结束 i=3;let 每轮迭代创建新绑定,各闭包各拿各的。
- 泄漏:切走的页面组件被回调闭包引用无法回收(且逻辑还在空转);解法:卸载时 clearTimeout / removeEventListener。
六、下一步
闭包吃透 → 第 34 节:事件循环,把"JS 单线程为什么不卡"的完整机制画出图来。
TAGSweb