闭包:JavaScript 最强魔法,也是最容易踩坑的地方

闭包不是玄学,它就是“函数 + 它创建时的词法作用域”。小明用最常见的 bug 场景带你搞懂闭包的本质、经典用法、性能与内存坑,以及面试里怎么讲最加分。

18 分钟阅读
小明

闭包:JavaScript 最强魔法,也是最容易踩坑的地方

你一定见过这种“离谱但真实”的代码:

  • 你写了一个计数器,本来想每次点按钮加 1,结果它加得像喝了红牛:一会儿跳 2,一会儿跳 10。
  • 你在 for 循环里绑点击事件,点第 1 个按钮却总打印最后一个索引。
  • 你做了一个“私有变量”,结果被同事一行代码就改掉了。

这些问题背后,有一个共同的幕后黑手:闭包(Closure)

闭包在 JavaScript 里地位非常高:它是模块化、回调、事件处理、柯里化、函数式编程的地基;但它也是内存泄漏和“我怎么又错了”的重灾区。

今天我们把闭包讲透:不靠背定义,靠场景和代码。


1. 先把概念讲人话:闭包到底是什么?

一句话版本:

闭包 = 函数 + 它创建时能访问到的那一整片“外部变量环境”(词法作用域)。

再换一句更像工程师的:

当一个函数“带着”它的外部作用域一起活下去(即使外部函数已经返回),这就叫闭包。

关键点不是“函数套函数”,而是:

  • 作用域是“词法的”:在代码写在哪,作用域就基本决定了(不是运行时才决定)。
  • 变量能被“延寿”:外层函数结束了,变量本来该销毁;但只要还有内部函数引用它,它就会继续活着。

闭包不是 JavaScript 的小众特性,反而是它的核心设计之一:让函数可以“记住”上下文。


2. 从一个最经典的例子开始:计数器为什么能记住数字?

我们想写一个计数器工厂:每次调用 createCounter(),得到一个独立的计数器。

function createCounter() {
  let count = 0; // 这个变量本来应该随着 createCounter 结束而消失

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

const counterA = createCounter();
console.log(counterA()); // 1
console.log(counterA()); // 2

const counterB = createCounter();
console.log(counterB()); // 1(它是独立的)
console.log(counterA()); // 3

你看到了什么?

  • createCounter() 早就返回了。
  • increment() 还能访问并修改 count

这就说明:increment 这个函数并不是“裸奔”的,它背后绑着一段环境:count 所在的那块作用域。

这段环境就是闭包携带的“记忆”。


3. 闭包和作用域:你必须分清的 3 件事

3.1 词法作用域(Lexical Scope)

JavaScript 的作用域主要由“代码写在哪”决定。

const x = 'global';

function outer() {
  const x = 'outer';

  function inner() {
    // 这里访问到的是 outer 里的 x
    return x;
  }

  return inner;
}

const fn = outer();
console.log(fn()); // 'outer'

inner() 访问到的 x,取决于它“写在 outer 里面”,而不是它“在哪里被调用”。

3.2 闭包让外层变量“活下去”

闭包并不会复制变量的值,它保存的是“引用关系”。

所以如果变量会变,闭包看到的也会跟着变(这也是很多坑的根源)。

3.3 不是所有“函数套函数”都值得叫闭包

只要内部函数引用了外部变量,几乎就形成了闭包。

在现代 JavaScript 里:

  • 你写回调函数、事件处理函数、setTimeout、Promise 链……大量场景默认就有闭包。

闭包不是你“选择要不要用”,而是你得学会“怎么不被它坑”。


4. 最容易踩的坑:for 循环 + 事件绑定,为什么总是最后一个?

经典面试题来了:

const buttons = document.querySelectorAll('button');

for (var i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function () {
    console.log(i);
  });
}

你点任何按钮,打印的都是 buttons.length

4.1 原因:闭包拿到的是“同一个 i 的引用”

  • var 是函数作用域(不是块级作用域)
  • 循环结束时,i 已经变成最终值
  • 回调函数点击时才执行,它读到的是最终的 i

不是闭包错了,是你以为闭包保存的是“当时的值”。

4.2 修复方式 1:用 let(最推荐)

const buttons = document.querySelectorAll('button');

for (let i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function () {
    console.log(i);
  });
}

let 有块级作用域,每一轮循环都会产生一个新的 i 绑定。

4.3 修复方式 2:用 IIFE(老项目里你会遇到)

const buttons = document.querySelectorAll('button');

for (var i = 0; i < buttons.length; i++) {
  (function (index) {
    buttons[index].addEventListener('click', function () {
      console.log(index);
    });
  })(i);
}

这招的本质是:把每一轮的 i 作为参数传进去,形成新的作用域。


5. 闭包的 3 个高价值用法(写代码真会用到)

5.1 “私有变量”与封装:不用 class 也能做

function createUser(name) {
  let password = '123456';

  return {
    getName() {
      return name;
    },
    setPassword(newPassword) {
      password = newPassword;
    },
    checkPassword(input) {
      return input === password;
    }
  };
}

const user = createUser('小明');
console.log(user.getName()); // 小明
console.log(user.checkPassword('123456')); // true

// 你拿不到 password(只能通过方法间接访问)
console.log(user.password); // undefined

这就是闭包版的封装:把敏感状态藏在作用域里。

5.2 防抖/节流:本质是“记住上一次的状态”

以防抖为例(输入框停止输入 300ms 才触发一次):

function debounce(fn, delayMs) {
  let timerId = null;

  return function (...args) {
    if (timerId) clearTimeout(timerId);

    timerId = setTimeout(() => {
      fn.apply(this, args);
    }, delayMs);
  };
}

const onSearch = debounce((keyword) => {
  console.log('search:', keyword);
}, 300);

timerId 就是被闭包保存的状态:每次调用都能拿到上一次的定时器。

5.3 柯里化(Currying):把函数“切成小份”

function add(a) {
  return function (b) {
    return a + b;
  };
}

const add10 = add(10);
console.log(add10(3)); // 13
console.log(add10(20)); // 30

这里 a 被记住了,所以 add10 就像“预配置函数”。


6. 闭包保存的是“引用”,不是“拍照存档”

很多闭包 bug,本质都来自一句误解:

你以为闭包把当时的值“拍了张照”,实际上它拿着的是同一个变量/对象的“引用”。

看一个很能说明问题的例子:

function createLogger() {
  const state = { count: 0 };

  return {
    inc() {
      state.count += 1;
    },
    log() {
      console.log(state.count);
    }
  };
}

const logger = createLogger();
logger.inc();
logger.inc();
logger.log(); // 2

这里 log 并没有保存一个“当时的 count 值”,它保存的是 state 这个对象的引用。

同理,你在 for + var 里踩的坑,就是回调都引用了同一个 i(同一个绑定),而不是每一轮各自存一份值。

如果面试官追问“闭包保存的是值还是引用”,最稳妥的回答是:

  • 对于外层变量绑定来说,闭包持有的是绑定/引用关系
  • 变量如果指向对象,本质还是引用

7. 稍微硬核但很关键:闭包和执行上下文/作用域链是什么关系?

很多人背“闭包 = 函数 + 词法作用域”,但不知道这句话落到运行时到底是什么意思。

你可以用这张极简心智图理解:

代码写的位置(词法作用域)
函数创建时,记录外层环境的“指针”([[Environment]])
函数执行时,沿着这条指针形成作用域链(Scope Chain)
只要函数还活着,被引用到的外层环境就不会被回收

也就是说:

  • 闭包不是“复制”了外层变量,而是保留了一条能回到外层环境的路
  • JS 引擎要做垃圾回收时,会沿着引用链找“还活着的东西”。闭包把这条链延长了。

这也是为什么你在工程里要警惕“闭包里引用 DOM/大对象/全局缓存”:

  • 不是因为闭包邪恶
  • 而是因为你的引用链把它们吊着不放

8. 工程级闭包写法:让它“可取消、可回收、可调试”

闭包最常见的工程用途之一是防抖/节流。但很多人的版本只考虑“能用”,没考虑“能收”。

8.1 可取消的防抖(真实项目很需要)

function debounce(fn, delayMs) {
  let timerId = null;

  function debounced(...args) {
    if (timerId) clearTimeout(timerId);
    timerId = setTimeout(() => {
      timerId = null;
      fn.apply(this, args);
    }, delayMs);
  }

  debounced.cancel = () => {
    if (timerId) clearTimeout(timerId);
    timerId = null;
  };

  return debounced;
}

这个版本的意义是:

  • 组件卸载/页面切换/弹窗关闭时,你可以显式 cancel(),避免“幽灵回调”。

8.2 闭包里不要顺手挂 DOM(尤其是老代码)

浏览器里一个常见泄漏链是:

window → 某个全局数组/单例 → 闭包 → DOM 节点

DOM 节点被吊住,页面看起来“换了”,但内存一直涨。

建议做法:

  • 事件监听能移除就移除(配套 removeEventListener
  • 定时器能清就清(配套 clearTimeout/clearInterval
  • 缓存能设上限就设上限(别无限 push)

8.3 怎么定位“闭包相关”的内存问题?

如果你在 Chrome DevTools 里看 Heap Snapshot,经常会看到 “Closure”、“(closure)” 这类字样。

排查时你要盯的不是“有闭包”,而是:

  • 是哪个对象通过闭包把东西引用住了?
  • 这条引用链的根(root)是谁?(通常是全局、单例、事件监听)

9. 闭包的代价:性能与内存,别装作没看见

闭包很强,但它确实可能让你多背一点“内存债”。

6.1 为什么闭包可能更耗内存?

因为闭包会让外层作用域里的变量“无法被回收”。

如果你在闭包里引用了一个很大的对象(比如缓存、DOM 节点、巨大的数组),它就可能一直活着。

function createCache() {
  const big = new Array(100_000).fill('🍉');

  return function getFirst() {
    // big 会一直被引用,直到 getFirst 没有人再引用
    return big[0];
  };
}

const fn = createCache();
console.log(fn());

6.2 什么时候算“泄漏”?

  • 你本来不需要它继续活着
  • 但某个引用链让它活着

常见现场:

  • 事件监听器没移除
  • 定时器没清理
  • 单例里缓存无限增长

解决思路也很朴素:

  • 及时 removeEventListener
  • 及时 clearTimeout/clearInterval
  • 缓存做上限和淘汰策略(LRU 等)

10. 面试怎么讲闭包最加分?(小抄版)

你可以按这个结构回答:

  1. 定义:闭包是函数和它创建时的词法作用域的组合。
  2. 特性:即使外层函数执行结束,内部函数仍能访问外层变量。
  3. 价值:实现封装、维持状态(防抖节流)、函数式写法(柯里化)。
  4. 坑点var 循环、内存占用、事件监听/定时器不清理。
  5. 实践:优先用 let/const,注意清理引用,避免在闭包里挂大对象。

面试官通常会追问:

  • “闭包保存的是值还是引用?”(答:引用)
  • “为什么 let 能解决循环问题?”(答:每轮创建新的块级绑定)
  • “怎么排查闭包导致的内存问题?”(答:看引用链、监听器、定时器、缓存增长)

总结

  • 闭包的本质是:函数 + 它创建时的词法作用域
  • 闭包让变量“延寿”,能封装状态,也能制造坑。
  • for + var + 回调 是闭包经典陷阱,优先用 let
  • 写工程别忘了闭包的代价:清理监听器、定时器,缓存别无限长。

最后送你一句小明金句:

闭包像保温杯:用对了续命,用错了烫嘴。