闭包:JavaScript 最强魔法,也是最容易踩坑的地方
闭包不是玄学,它就是“函数 + 它创建时的词法作用域”。小明用最常见的 bug 场景带你搞懂闭包的本质、经典用法、性能与内存坑,以及面试里怎么讲最加分。
闭包: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. 面试怎么讲闭包最加分?(小抄版)
你可以按这个结构回答:
- 定义:闭包是函数和它创建时的词法作用域的组合。
- 特性:即使外层函数执行结束,内部函数仍能访问外层变量。
- 价值:实现封装、维持状态(防抖节流)、函数式写法(柯里化)。
- 坑点:
var循环、内存占用、事件监听/定时器不清理。 - 实践:优先用
let/const,注意清理引用,避免在闭包里挂大对象。
面试官通常会追问:
- “闭包保存的是值还是引用?”(答:引用)
- “为什么
let能解决循环问题?”(答:每轮创建新的块级绑定) - “怎么排查闭包导致的内存问题?”(答:看引用链、监听器、定时器、缓存增长)
总结
- 闭包的本质是:函数 + 它创建时的词法作用域。
- 闭包让变量“延寿”,能封装状态,也能制造坑。
for + var + 回调是闭包经典陷阱,优先用let。- 写工程别忘了闭包的代价:清理监听器、定时器,缓存别无限长。
最后送你一句小明金句:
闭包像保温杯:用对了续命,用错了烫嘴。