闭包是什么?先从一个问题开始
写过 JavaScript 的人,几乎都遇到过这么一个问题:
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
你觉得会输出什么?很多人以为是 0, 1, 2, 3, 4,结果跑出来是 5 个 5。
这个问题背后藏着的核心概念,就是闭包。
闭包是 JavaScript 最基础也最重要的特性之一。面试会问,工作中天天用,但真正能把它讲清楚的人不多。这篇文章不讲花哨的定义,只从一个真实开发者的角度,说清楚闭包到底是什么、为什么需要它、以及项目里怎么用。
闭包到底是个什么东西
先看一段最简单能说明闭包的代码:
function outer() {
let count = 0;
return function inner() {
count++;
return count;
};
}
const counter = outer();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
outer 执行完了,按理说它的局部变量 count 就该被回收了。但 inner 这个函数被返回了,而且它还引用着 count,所以 JavaScript 引擎不会把 count 回收掉——这就是闭包。
一句话总结:闭包 = 函数 + 它创建时能访问到的外部变量。一个函数把它外面的变量”记住”了,即便外层函数已经执行结束,它依然能访问那些变量。
换个角度理解:闭包就像是一个背包。函数创建时,把自己需要的外部变量都装进这个背包里,走到哪背到哪。
为什么会有闭包?这得从作用域说起
JavaScript 采用词法作用域(也叫静态作用域)。意思是:函数能访问哪些变量,在代码写出来的那一刻就决定了,不是运行时决定的。
或者说:函数定义的位置决定了它能访问哪些变量,而不是函数调用的位置。
function a() {
let x = 10;
function b() {
console.log(x); // b 能访问 a 里的 x
}
b();
}
a(); // 10
这段代码很正常,b 定义在 a 里面,所以能访问 a 的 x。但如果 b 被返回了,或者被传到别的地方去调用呢?
function a() {
let x = 10;
return function b() {
console.log(x);
};
}
const fn = a();
fn(); // 10 — 依然能访问 x,即使 a 已经执行完了
这就是闭包起作用的地方。函数 b 被返回后,它的”背包”里还装着 x,所以后来在全局作用域调用 fn() 时,依然能拿到 x。
项目里常见的闭包场景
1. 封装私有变量
JavaScript 在 ES6 之前没有真正的类的概念,也没有 private 关键字。闭包是最自然的”私有化”方式。
function createCounter() {
let count = 0;
return {
increment: function() { count++; },
decrement: function() { count--; },
getCount: function() { return count; }
};
}
const c = createCounter();
c.increment();
c.increment();
console.log(c.getCount()); // 2
console.log(c.count); // undefined — 外部访问不到 count
这个模式在实际项目中太常用了:状态管理、弹窗组件、表单验证器……凡是需要隐藏内部状态的场景,闭包都是最轻量的方案。
2. 防抖(debounce)和节流(throttle)
前端面试最高频的手写题之一。防抖就是等用户停止操作后再执行,节流是每隔一段时间执行一次。
function debounce(fn, delay) {
let timer = null;
return function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 使用:搜索输入框
const search = debounce(function(keyword) {
// 调接口搜索
console.log('搜索:', keyword);
}, 500);
searchInput.addEventListener('input', (e) => {
search(e.target.value);
});
这里 timer 就是闭包里记住的变量。每次调用返回的函数,它都能访问到上一次的 timer,从而取消旧的定时器。没有闭包,防抖根本实现不了。
3. 函数柯里化(currying)
把多参数函数转成一系列单参数函数,每次返回一个新函数,直到参数够了才执行。
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return function(...args2) {
return curried.apply(this, args.concat(args2));
};
};
}
// 使用
function add(a, b, c) {
return a + b + c;
}
const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
每次返回的函数都闭包记住了之前传进去的参数,直到攒齐了才真正执行。这在函数式编程风格的项目里特别有用。
4. React Hooks 中的闭包陷阱
现在写 React 项目,天天跟闭包打交道,只是很多人没意识到。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 这里永远打印 0!
setCount(count + 1); // count 永远是 0,不会按预期更新
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖数组为空
这个坑是 React 面试的经典题。原因很简单:useEffect 回调里引用了 count,而回调只在组件第一次渲染时创建了一次,闭包记住的是当时的 count(0),后续的渲染它感知不到。
正确的做法:要么给依赖数组加上 count,要么用函数式更新 setCount(c => c + 1)。
5. 迭代器和生成器模拟
在 forEach、map 等循环里传回调,本质上也依赖闭包。
function createIndexIterator(arr) {
let index = 0;
return {
next: function() {
return index < arr.length
? { value: arr[index++], done: false }
: { done: true };
}
};
}
const it = createIndexIterator(['a', 'b', 'c']);
console.log(it.next().value); // 'a'
console.log(it.next().value); // 'b'
console.log(it.next().value); // 'c'
index 被闭包记住,每次调用 next 都能更新和读取它——形成了一个”有状态的函数”。
闭包的坑:循环里那个经典的 5 个 5
回到文章开头的问题。
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
var 声明的 i 是函数作用域,不是块级作用域。for 循环结束后 i 已经是 5 了,setTimeout 里的回调执行时读取的是同一个 i,所以全是 5。
两种解决方案:
// 方案一:用 let(推荐)
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
// 方案二:用闭包创建独立作用域
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function() {
console.log(j);
}, 1000);
})(i);
}
方案二就是利用了闭包:把 i 作为参数传进 IIFE,每次循环都创建了一个新的作用域,j 的值被闭包记住,不会互相干扰。
闭包和性能
闭包不是没有代价的。只要用了闭包,被闭包引用的变量就不会被垃圾回收,这意味着额外的内存占用。
项目里需要注意的地方:
- 不要滥用闭包存大对象。比如在闭包里引用了一个巨大的 DOM 元素或数据数组,这个持有会一直持续到闭包函数本身被回收。
- 用完及时释放。如果闭包是事件监听的回调,记得在组件卸载时移除监听,让闭包也能被回收。
- 循环闭包的微妙之处。除了上面说的 var/let 问题,还要注意闭包中如果引用了 DOM 元素,可能导致内存泄漏。
总结
闭包不是什么高深莫测的技术,它只是 JavaScript 词法作用域的必然结果——函数记住它出生时的环境,把需要的变量带在身边。
理解闭包不需要背诵定义。做一个实验:写一个函数返回另一个函数,在返回的函数里引用外部变量,然后在外面调用它。如果还能拿到那个变量,你就在用闭包了。
项目里最常遇到的闭包场景就是上面这些:私有变量、防抖节流、柯里化、React Hooks、迭代器模拟。每个都是实打实的日常代码,不是抽象的理论题。
把闭包理解透之后,JavaScript 很多看起来”奇怪”的行为都能找到合理的解释。面试问闭包也不是死记硬背,而是考查你对自己每天写的代码有没有真正理解。