文章

闭包定义与形成原理深度解析

闭包定义与形成原理深度解析

一句话概括

闭包 = 函数 + 它能访问的外部变量。当一个函数”记住”了它定义时所在的作用域,即使那个作用域已经执行完毕,这个函数仍然能访问里面的变量——这就是闭包。

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 的定义:闭包是指能够访问自由变量的函数。 自由变量 = 既不是函数参数、也不是函数内部声明的变量,而是来自外层作用域的变量。

上面的代码里,ainner 来说就是自由变量——inner 能访问它,靠的就是闭包。

面试时怎么说

“闭包就是一个函数能够访问它外层作用域里的变量。核心机制是:每个函数创建时会保存一个对定义时词法环境的引用,即使外层函数执行完了,被引用的变量也不会被 GC 回收。”

闭包是怎么形成的

两个条件就够:

  1. 函数嵌套 — 内部函数定义在外部函数里
  2. 内部函数引用了外部函数的变量
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); }) — 回调记住了外层的 name
  • const 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 解决。

本文由作者按照 CC BY 4.0 进行授权