闭包定义与形成原理深度解析
一句话概括
闭包 = 函数 + 它能访问的外部变量。当一个函数”记住”了它定义时所在的作用域,即使那个作用域已经执行完毕,这个函数仍然能访问里面的变量——这就是闭包。
1
2
3
4
5
6
7
8
9
function outer() {
const a = 1;
return function inner() {
console.log(a); // inner 记住了 a
};
}
const fn = outer(); // outer 执行完毕,但 a 没被销毁
fn(); // 1 —— 闭包让 a 活了下来
什么是闭包
MDN 的定义:闭包是指能够访问自由变量的函数。 自由变量 = 既不是函数参数、也不是函数内部声明的变量,而是来自外层作用域的变量。
上面的代码里,a 对 inner 来说就是自由变量——inner 能访问它,靠的就是闭包。
面试时怎么说
“闭包就是一个函数能够访问它外层作用域里的变量。核心机制是:每个函数创建时会保存一个对定义时词法环境的引用,即使外层函数执行完了,被引用的变量也不会被 GC 回收。”
闭包是怎么形成的
两个条件就够:
- 函数嵌套 — 内部函数定义在外部函数里
- 内部函数引用了外部函数的变量
1
2
3
4
5
6
7
8
9
10
function makeCounter() {
let count = 0;
return function () {
return ++count; // 引用外层 count → 形成闭包
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2 —— count 的状态被闭包保留
关键点:每次调用 makeCounter() 都会创建一个独立的闭包环境,各自的 count 互不干扰:
1
2
3
4
5
const c1 = makeCounter();
const c2 = makeCounter();
c1(); // 1
c1(); // 2
c2(); // 1 ← c2 有自己的 count
底层发生了什么? 每个 JS 函数在创建时,引擎会给它一个隐藏属性 [[Environment]],指向它被创建时的词法环境。当函数执行时,变量查找沿着这条链往外找。makeCounter 执行完了,但内部函数还通过 [[Environment]] 指着它的作用域,所以 count 不能被回收——这就是为什么每次 counter() 调用,count 还能继续累加。
其实你每天都在用
写完上面这些,你可能觉得闭包是个”刻意的技巧”。其实你每天都在写闭包,只是没意识到:
useEffect(() => { /* 用了组件里的 state */ }, [])— 回调记住了渲染时的 state,这就是闭包element.addEventListener('click', () => { console.log(name); })— 回调记住了外层的nameconst debouncedSearch = debounce(fetchData, 300)—debounce内部用闭包保存定时器- Vue 的
watch、Redux 的createStore、lodash 的_.memoize……底层全是闭包
闭包不是你要”学会用”的设计模式,它是 JS 天生自带的语言机制。 你写 return () => xxx 的那一刻,闭包就已经在那了。
核心理解:闭包捕获的是引用,不是值
这是理解闭包最关键的一句话,比背定义重要得多:
1
2
3
4
let x = 1;
const fn = () => console.log(x);
x = 2;
fn(); // 2 ← 不是 1!闭包记的是变量本身,不是快照
这个特性是好是坏取决于场景。上面 makeCounter 里的 count 正是因为捕获引用才能持续累加。但一到 var + for 循环,它就变成了陷阱。
经典面试陷阱:var + for 循环
1
2
3
4
5
// 这段代码输出什么?
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 3 3 3 ← 不是 0 1 2
原因就一句:var 没有块级作用域,整个循环共用一个 i,闭包捕获的是变量引用而不是值。100ms 后回调执行时,i 已经是 3 了。
两种主流修法:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 方案 A:用 let(ES6,最推荐)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 0 1 2 ← let 每次迭代创建新的 i
// 方案 B:IIFE(经典写法,ES6 之前)
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 100);
})(i);
}
// 0 1 2 ← IIFE 把每次的 i 锁定为参数 j
面试能加分的说辞:”本质上是因为闭包捕获的是变量引用,不是值的快照。
let为每次迭代创建了独立的块级绑定,所以每个回调都有自己的i。”
常见误解
❌ 误区一:闭包会导致内存泄漏
闭包本身不是内存泄漏——它只是按设计保留了变量的引用。只有当闭包不再需要却被全局变量持续引用时,才构成泄漏。V8 会精确标记哪些变量被闭包引用,未引用的变量照样回收。日常开发中正常使用闭包不会造成问题。
❌ 误区二:内部函数只要引用了外层变量就是”有问题”的
闭包是 JS 的基础设施。防抖节流、模块封装、柯里化、React 的 useEffect 回调——你每天都在用闭包。面试考它不是因为它”有坑”,而是因为它无处不在。
❌ 误区三:没有 return 出去的函数不是闭包
技术上,只要内部函数引用了外层变量,JS 引擎就会创建闭包——不管你有没有 return。区别只在于生命周期:return 出去的闭包长期存活,直接调用的闭包执行完就回收了。
❌ 误区四:只有 JavaScript 有闭包
Python、Go、Rust、Swift 等大量语言都支持闭包。闭包是编程语言设计中的通用概念。不同语言的区别在于变量捕获方式(引用 vs 复制)、是否可变等细节。
一句话总结
闭包 = 函数 + 它定义时的词法环境。
[[Environment]]让内部函数永远能找到外层变量,带来模块封装、状态保持等能力;但闭包捕获的是引用不是值,遇到var循环容易踩坑,用let或 IIFE 解决。