先看一段几乎每个 JavaScript 面试都会出现的代码,如果你能一眼说清它的输出,以及为什么,那这篇文章对你而言是复习;如果你还会犹豫,那它会帮你把"闭包"这件事彻底钉死在脑子里。
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 0);
}
// 输出:3 3 3 —— 而不是 0 1 2
很多人知道"答案是 3 3 3",但说不清机制。讲清楚它,需要把作用域、作用域链、执行上下文、闭包这四个概念串成一条线。本文就沿着这条线走一遍,然后落到真实工程里:闭包能帮你做什么,又会在哪里悄悄吃掉你的内存。
作用域:变量"住"在哪里
JavaScript 采用词法作用域(lexical scope,也叫静态作用域)。"词法"的意思是:一个变量的作用域,在你写代码的那一刻就已经确定了,跟函数在哪里被调用无关,只跟它在哪里被定义有关。这一点是理解闭包的地基。
var value = 'global';
function outer() {
var value = 'outer';
function inner() {
console.log(value); // 'outer',不是 'global'
}
return inner;
}
var fn = outer();
fn(); // 即使在全局调用,inner 找的依然是 outer 里的 value
inner 在查找 value 时,先看自己的作用域(没有),再看它定义时所在的外层作用域 outer(找到了)。这条"自己 → 外层 → 再外层 → 全局"的查找链路,就是作用域链。注意:它是按代码书写的嵌套结构连起来的,不是按调用栈连起来的。
函数作用域 vs 块级作用域
ES6 之前,JavaScript 只有函数作用域和全局作用域,var 声明的变量会被提升到所在函数的顶部。ES6 引入 let / const 带来了块级作用域 —— 一对花括号 {} 就是一个独立作用域。这正是文章开头那段代码的关键。
function f() {
console.log(a); // undefined(var 提升了声明,没提升赋值)
var a = 1;
{
let b = 2;
}
console.log(b); // ReferenceError: b is not defined
}
执行上下文与变量对象
函数每次被调用,引擎都会为这次调用创建一个"执行上下文"(Execution Context),里面包含:这次调用的变量环境(局部变量、参数)、对外层作用域的引用、this 绑定等。函数执行完,这个上下文通常会被销毁,里面的局部变量随之被垃圾回收。
"通常"两个字是重点。如果一个内层函数在外层函数执行完之后仍然能被访问到(比如被返回出去、被赋值给外部变量、被注册成回调),那么它依赖的那部分外层变量环境就不能被销毁 —— 引擎必须把它"留住"。这个"被留住的、内层函数 + 它引用的外层变量环境"的组合,就是闭包。
function createCounter() {
let count = 0; // 本该随 createCounter 调用结束被回收
return function () {
count += 1; // 但这里还在用它,所以它被"留住"了
return count;
};
}
const next = createCounter();
console.log(next()); // 1
console.log(next()); // 2
console.log(next()); // 3 —— count 一直活着,且外部无法直接访问
所以一句话定义:闭包是函数和其词法环境的捆绑。只要一个函数"记得"并能访问它出生时所在的作用域,哪怕在别处执行,它就带着一个闭包。
回到开头:为什么是 3 3 3
现在拆解开头那段代码。var i 是函数作用域(这里是全局),整个循环只有一个 i。三个 setTimeout 的回调都是闭包,它们捆绑的是同一个 i。循环跑完,i 变成 3。等到事件循环把回调取出来执行时,它们读到的自然都是 3。
修复方式有三种,本质都是"为每次迭代制造一个独立的绑定":
// 方案 1:用 let,块级作用域,每次迭代是一个新的 i
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 0 1 2
}
// 方案 2:用 IIFE 立即执行函数,手动制造一层作用域
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 0); // 0 1 2
})(i);
}
// 方案 3:利用 setTimeout 的第三个参数把当前 i 传进去
for (var i = 0; i < 3; i++) {
setTimeout((j) => console.log(j), 0, i); // 0 1 2
}
方案 1 之所以有效,是因为 let 在 for 循环里有特殊处理:每一次迭代,引擎都会创建一个新的词法环境,并把上一轮的 i 值拷贝进来。于是三个回调捆绑的是三个不同的 i。
闭包在工程里到底拿来干什么
1. 模块模式与私有变量
ES6 模块出现之前,闭包是 JavaScript 实现"私有成员"的唯一手段,至今在很多库里还能见到。
const userStore = (function () {
let users = []; // 私有,外部拿不到
let nextId = 1;
return {
add(name) {
const u = { id: nextId++, name };
users.push(u);
return u.id;
},
remove(id) {
users = users.filter((u) => u.id !== id);
},
count() {
return users.length;
},
};
})();
userStore.add('Alice');
userStore.add('Bob');
console.log(userStore.count()); // 2
console.log(userStore.users); // undefined —— 真正的封装
2. 函数工厂与柯里化
闭包让函数可以"记住"配置,从而批量生产定制化的函数。
function makeMultiplier(factor) {
return (n) => n * factor;
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
console.log(double(10), triple(10)); // 20 30
// 柯里化:把多参函数变成一串单参函数
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) return fn.apply(this, args);
return (...rest) => curried.apply(this, args.concat(rest));
};
}
const add3 = curry((a, b, c) => a + b + c);
console.log(add3(1)(2)(3)); // 6
console.log(add3(1, 2)(3)); // 6
3. 防抖与节流
前端最常见的两个闭包应用。它们都需要在多次调用之间"记住"某个状态(定时器 ID、上次执行时间),这个状态必须私有,闭包正合适。
function debounce(fn, delay) {
let timer = null; // 被闭包留住的私有状态
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function throttle(fn, interval) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
const onScroll = throttle(() => console.log('scrolling'), 200);
window.addEventListener('scroll', onScroll);
4. 记忆化(memoization)
用闭包缓存计算结果,避免重复运算。
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const slowFib = (n) => (n < 2 ? n : slowFib(n - 1) + slowFib(n - 2));
const fastFib = memoize(function fib(n) {
return n < 2 ? n : fastFib(n - 1) + fastFib(n - 2);
});
console.log(fastFib(40)); // 瞬间出结果,而 slowFib(40) 会卡很久
闭包的另一面:内存泄漏
闭包"留住变量"的能力是把双刃剑。被留住的变量不会被垃圾回收,如果留住的是大对象、DOM 节点,而闭包本身又长期存在,内存就会一直涨。
典型场景 1:闭包意外捕获大对象
function createHandler() {
const bigData = new Array(1000000).fill('x'); // 占用大量内存
const id = bigData.length;
// 这个返回的函数只用到了 id,但因为和 bigData 在同一个作用域,
// 很多引擎实现会把整个作用域留住,bigData 也跟着活下来
return function () {
return id;
};
}
// 更安全的写法:只把需要的值提取出来
function createHandlerSafe() {
const bigData = new Array(1000000).fill('x');
const id = bigData.length;
return (function (capturedId) {
return () => capturedId;
})(id);
}
典型场景 2:定时器忘记清理
function startPolling(node) {
const timer = setInterval(() => {
// 闭包引用了 node,只要 timer 没清,node 就无法回收
node.textContent = new Date().toISOString();
}, 1000);
// 必须提供一个停止入口,否则 node 即使从 DOM 移除也泄漏
return () => clearInterval(timer);
}
const stop = startPolling(document.getElementById('clock'));
// 组件卸载时务必调用 stop()
典型场景 3:事件监听与 DOM 引用循环
function bind() {
const el = document.getElementById('btn');
const data = { huge: new Array(500000) };
el.addEventListener('click', function () {
console.log(data.huge.length); // 闭包持有 data 和 el
});
// el 移除前要 removeEventListener,否则 el 和 data 都泄漏
}
规律很清晰:凡是"长寿命"的闭包(定时器回调、事件监听、全局缓存),都要警惕它捕获了什么。捕获了 DOM 或大对象,就要有对应的清理路径。
用 DevTools 排查闭包泄漏
Chrome DevTools 的 Memory 面板能直接看到闭包占了多少内存。操作步骤:
- 打开 Memory 面板,选择 "Heap snapshot",拍一张快照。
- 在快照的过滤框里输入
Closure,会列出所有闭包及其 Retained Size(它"留住"的总内存)。 - 怀疑泄漏时,做"三快照法":操作前拍一张 → 执行可疑操作并触发 GC → 再拍一张 → 重复操作再拍。对比 Retained Size 是否持续增长。
- 展开某个闭包,看它的
context引用了哪些变量,定位到具体是哪个大对象没被释放。
// 在控制台手动触发 GC(需在启动 Chrome 时加 --js-flags="--expose-gc")
if (window.gc) window.gc();
// 或者直接用 performance.memory 粗略观察
console.log(performance.memory.usedJSHeapSize);
性能与认知误区
误区一:闭包很慢。 现代 V8 对闭包做了大量优化,创建闭包的开销极小,绝大多数场景下完全不用担心。真正的成本不在"快慢",而在"内存留存"。
误区二:每个函数都是闭包。 严格说,只有"引用了外层作用域变量"的函数才形成有意义的闭包。一个不依赖任何外部变量的纯函数,虽然理论上也带着作用域链,但引擎会把它优化掉,不构成内存负担。
误区三:闭包里的变量是"拷贝"。 不是。闭包捕获的是变量的引用,不是某一刻的值。这正是开头 3 3 3 的根源 —— 三个闭包引用同一个 i,读到的是它最终的值。
常见面试追问
问:闭包和作用域链是什么关系? 作用域链是机制,闭包是结果。函数定义时就确定了作用域链;当函数在其定义作用域之外被执行、且仍能沿着这条链访问外层变量时,我们称之为闭包。
问:闭包一定会导致内存泄漏吗? 不会。闭包"留住"变量是设计行为,不是泄漏。只有当这种留存是"非预期且无法解除"时才叫泄漏。合理使用、及时清理,闭包完全安全。
问:箭头函数和闭包有关系吗? 箭头函数同样能形成闭包,捕获外层变量的规则一致。区别只在 this:箭头函数不绑定自己的 this,而是捕获定义时外层的 this —— 这本身也是一种"闭包式"的捕获。
问:如何手动"断开"一个闭包? 把闭包引用置为 null(例如 fn = null),或者在闭包内部把不再需要的大对象引用置空。一旦没有任何引用指向这个闭包函数,它和它留住的环境就会被回收。
闭包与 this:两种不同的"捕获"
初学者常把闭包和 this 搅在一起,其实它们是两套独立机制。闭包捕获的是变量,规则是词法的(看定义位置);this 的绑定是动态的,规则看调用方式。下面这段代码同时踩中两者:
const obj = {
name: 'mores',
greetLater() {
setTimeout(function () {
console.log('hi, ' + this.name); // this 是 undefined/window,不是 obj
}, 100);
},
};
obj.greetLater(); // hi, undefined
问题在于:传给 setTimeout 的普通函数,被调用时不是作为 obj 的方法调用的,this 自然不指向 obj。传统修复是用闭包"把 this 存进一个变量":
greetLater() {
const self = this; // 用闭包捕获当前的 this
setTimeout(function () {
console.log('hi, ' + self.name); // self 是被闭包捕获的变量,稳定
}, 100);
}
箭头函数让这件事变得自然 —— 箭头函数不绑定自己的 this,它会沿作用域链去找,等价于自动帮你做了 const self = this:
greetLater() {
setTimeout(() => {
console.log('hi, ' + this.name); // 箭头函数捕获了外层的 this
}, 100);
}
所以可以这样理解:箭头函数对 this 的处理,本身就是一种"闭包式捕获"。把"变量捕获(词法)"和"this 绑定(动态)"这两条线分开看,很多迷惑就消失了。
实战:用闭包写一个 200 行内的状态管理
Redux 的核心思想用闭包可以极简地表达出来 —— store 内部维护一个私有的 state 和监听器列表,外部只能通过 dispatch 改它、通过 subscribe 监听它。私有性正是靠闭包保证的。
function createStore(reducer, initialState) {
let state = initialState; // 私有状态,外部永远拿不到引用
let listeners = []; // 私有监听器列表
function getState() {
return state;
}
function dispatch(action) {
state = reducer(state, action); // 用 reducer 算出新 state
listeners.forEach((fn) => fn()); // 通知所有订阅者
}
function subscribe(listener) {
listeners.push(listener);
// 返回一个取消订阅的函数 —— 又一个闭包,它记住了自己是哪个 listener
return function unsubscribe() {
listeners = listeners.filter((fn) => fn !== listener);
};
}
return { getState, dispatch, subscribe };
}
// 使用
function counterReducer(state, action) {
switch (action.type) {
case 'INC': return { count: state.count + 1 };
case 'DEC': return { count: state.count - 1 };
default: return state;
}
}
const store = createStore(counterReducer, { count: 0 });
const unsub = store.subscribe(() => console.log('新状态:', store.getState()));
store.dispatch({ type: 'INC' }); // 新状态: { count: 1 }
store.dispatch({ type: 'INC' }); // 新状态: { count: 2 }
unsub(); // 取消订阅
store.dispatch({ type: 'DEC' }); // 不再打印,但 state 已变成 { count: 1 }
这段代码里出现了三层闭包:getState / dispatch / subscribe 共享并捕获了 state 和 listeners;unsubscribe 又额外捕获了它对应的 listener。没有闭包,你就得把 state 暴露成全局变量或对象属性,封装性立刻崩塌。这就是为什么说闭包是 JavaScript 模块化的基石 —— 它用最小的语法成本,实现了"私有"这个面向对象里需要专门关键字才能表达的概念。
写在最后
闭包不是一个孤立的"语法特性",它是 JavaScript 词法作用域 + 函数是一等公民这两个设计共同的自然产物。理解它的正确路径是:先认清"变量住在哪、怎么找"(作用域与作用域链),再认清"函数调用时发生了什么"(执行上下文),最后水到渠成地得到"为什么有些变量没被回收"(闭包)。
工程上记住两句话就够用:需要私有状态、需要让函数记住配置时,主动用闭包;写长寿命回调时,警惕它捕获了什么,并给出清理路径。把这两点做到位,闭包就只会是你的工具,而不会是你线上内存曲线里那条诡异的上升线。
—— 别看了 · 2026