返回笔记列表
01 · NOTES / WebGIS 开发

第 33 节 · 闭包与内存

2026 年 9 月 1 日

第 33 节 · 闭包与内存

📌 版本信息:基于 ECMAScript 2025(词法作用域多年稳定;2026-08-29 核对) 📚 来源:现代 JS 教程 · 闭包MDN Closures

一、这一节的目标

  1. 给闭包下一个可操作的定义
  2. 用"词法作用域"解释闭包为什么天然存在
  3. 认识闭包的四大实战用途(你已在用,现在知道为什么)
  4. 理解闭包与内存泄漏的关系、循环里的经典陷阱
  5. 会用 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 就不能被回收——哪怕你只用它一次

三条防线:

  1. 闭包里别引用大对象;用完置 null
  2. 单页应用里,组件销毁时移除监听removeEventListener),否则闭包链拽着整个组件
  3. 循环 + 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 但没被回收)"的对象——它们十有八九被某个闭包拽着。


五、自测题

  1. 用"词法作用域"解释闭包为什么存在。
  2. makeWallet 里 balance 为什么是"私有"的?
  3. debounce 的闭包变量是什么?它如何实现防抖?
  4. for (var i...) + setTimeout 输出 3 3 3 的机制?let 为什么免疫?
  5. 单页应用里"页面切走了但定时器还在跑"是什么问题?怎么解?

参考答案

  1. 作用域由代码书写位置决定;内层函数引用外层变量时,引擎为维持词法可见性而保留变量——这就是闭包。
  2. balance 只存在于 makeWallet 的作用域内,唯一出口是返回的两个方法(暴露了受控接口而非裸数据)。
  3. 闭包住 timer;每次调用先清掉上一次定时器再重设——只有"停止输入 ms 毫秒后"才真正执行 fn。
  4. var 的 i 是函数级唯一变量,三个回调闭包共享它,循环结束 i=3;let 每轮迭代创建新绑定,各闭包各拿各的。
  5. 泄漏:切走的页面组件被回调闭包引用无法回收(且逻辑还在空转);解法:卸载时 clearTimeout / removeEventListener。

六、下一步

闭包吃透 → 第 34 节:事件循环,把"JS 单线程为什么不卡"的完整机制画出图来。

TAGSweb