跳到主要内容

闭包(Closure):函数为什么能“记住”外部变量?

你可能写过这样的代码:

function makeCounter() {
let count = 0;
return function () {
count++;
return count;
};
}

const counter = makeCounter();
counter(); // 1
counter(); // 2

makeCounter() 早就执行完了,按理说它里面的 count 也该“消失”了。
但事实是:counter() 还能继续访问并修改 count ——这就是 闭包(closure)

这篇文档你会收获什么
  • 用一句话讲清楚闭包是什么、为什么存在
  • 画出“作用域链/词法环境”的图,把机制讲明白
  • 掌握闭包的高频用法(私有变量、工厂函数、柯里化、一次性函数、缓存等)
  • 避开闭包最常见的坑(循环、异步、内存占用、React stale closure 等)
  • 文末附 面试高频问答(直接背也行)

1. 先用一句话定义闭包

闭包 = 函数 + 它创建时所在的词法环境(Lexical Environment)。

更口语一点:

  • 函数会“记住”它定义时能访问到的变量,而不是只看它在哪里被调用。

这句话里最关键的是:定义时(lexical),不是调用时(dynamic)。


2. 闭包的前置知识:词法作用域(Lexical Scope)

JavaScript 是词法作用域语言:一个变量能不能访问到,主要由代码写在哪里决定。

看一个最经典的对比题:

const x = 1;

function foo() {
console.log(x);
}

function bar() {
const x = 2;
foo();
}

bar(); // 输出多少?

输出是 1,不是 2。原因:

  • foo 定义在全局,它的“外部作用域”就是全局
  • bar 里虽然也有 x,但那是 bar 的局部变量,不会改变 foo 的外部作用域
口诀

看变量去哪找:先看当前作用域,找不到就沿着定义位置决定的作用域链往外找。


3. 闭包到底“闭”住了什么?

闭包不是把变量“复制”一份塞进函数里,而是让函数持有一条到外部词法环境的引用(你可以理解为“指针”)。

用图把关系画出来(注意:图里的名字是为了理解,不是 JS 代码里的真实对象名):

你只要抓住核心因果链:

  1. 调用 makeCounter() 会创建一个新的词法环境,里面有 count
  2. inner 函数在创建时,把“外部环境引用”保存到了自己的内部槽位(通常被描述为 [[Environment]]
  3. 只要 inner 还被外部变量引用着(比如赋给了 counter),这个外部环境就不会被垃圾回收

所以 count 能一直“活着”,并且多次调用共享同一个 count


4. 一个闭包=一份“私有状态”:为什么这很有用

闭包让我们可以用函数把状态封装起来,形成“外部不能直接摸到、只能通过 API 操作”的结构。

4.1 私有变量:最小可用的模块

function createBankAccount(initialBalance = 0) {
let balance = initialBalance;

return {
deposit(amount) {
balance += amount;
return balance;
},
withdraw(amount) {
balance -= amount;
return balance;
},
getBalance() {
return balance;
},
};
}

const a = createBankAccount(100);
a.deposit(50); // 150
a.balance; // undefined(拿不到)

这里的 balance 就是“私有状态”,只能通过 deposit/withdraw/getBalance 间接访问。

4.2 工厂函数:多实例互不干扰

const c1 = makeCounter();
const c2 = makeCounter();

c1(); // 1
c1(); // 2
c2(); // 1(注意:c2 有自己的一份 count)

同一个函数 makeCounter,每调用一次就创建一份新环境,所以每个实例互不影响。


5. 闭包的常见实战用法(面试 + 工作都常见)

5.1 once:函数只允许执行一次

function once(fn) {
let called = false;
let result;

return function (...args) {
if (called) return result;
called = true;
result = fn.apply(this, args);
return result;
};
}

const init = once(() => {
console.log("init");
return 42;
});

init(); // 打印 init,返回 42
init(); // 不再打印,仍返回 42

5.2 缓存(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 value = fn.apply(this, args);
cache.set(key, value);
return value;
};
}
注意

缓存会增长:如果入参种类很多,cache 可能越来越大。要考虑清理策略(LRU/容量上限/过期时间)。

5.3 柯里化/偏函数:把“多个参数”拆成“多次传”

function add(a) {
return function (b) {
return a + b;
};
}

const add10 = add(10);
add10(5); // 15

你可以把它理解为:第一次调用把 a 记住(闭包),第二次调用再用它完成计算。

5.4 事件处理:回调里天然需要闭包

function bindClick(button, trackName) {
button.addEventListener("click", () => {
console.log("track:", trackName);
});
}

trackName 不是全局变量,但点击发生在未来;回调之所以还能用到它,就是闭包在工作。


6. 闭包最常见的坑(一定要会)

6.1 for + var:经典“全都打印同一个值”

for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 3 3 3

原因:var 是函数作用域,循环结束时 i 变成了 3,三个回调闭包里引用的是同一个 i

修复方式 1:用 let(块级作用域,每轮一个新的绑定)

for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 0 1 2

修复方式 2:用 IIFE 把当次的值“参数化”

for (var i = 0; i < 3; i++) {
((j) => setTimeout(() => console.log(j), 0))(i);
}
// 0 1 2

6.2 异步里的“旧值”问题(stale closure)

闭包会记住“当时的变量绑定”,这在异步里可能带来“拿到旧值”的错觉。

let status = "idle";

function request() {
const local = status;
setTimeout(() => {
console.log("local:", local);
console.log("status:", status);
}, 100);
}

request();
status = "loading";

输出会是:

  • local: idle(闭包里拿的是当时保存的 local
  • status: loading(闭包里访问的是同一个外层变量 status,它已经被改了)
实战建议

需要“固定当时的值”时:把值存到局部常量(像上面的 local)或作为参数传进去。
需要“永远取最新值”时:避免把值复制到局部常量,改为直接读外层变量,或把最新值放到可变容器里(例如对象属性、ref)。

6.3 内存占用:闭包不是泄漏,但可能“留住”大对象

闭包会让它引用到的外部变量更晚被回收。如果你在外部环境里放了大对象,且闭包长期存活,就可能造成内存压力。

function createHandler() {
const big = new Array(1_000_000).fill("x");

return function onClick() {
console.log(big.length);
};
}

这里 big 会被 onClick “带着走”。如果你不再需要这个 handler:

  • 记得移除事件监听(removeEventListener
  • 或者断开引用(让外部不再持有 onClick

7. 如何判断一段代码有没有闭包?

更精确地说:函数在创建时都会关联一个外部词法环境;当函数在其定义作用域之外执行,并且还要访问外层变量时,我们通常就称它“形成了闭包”。

你可以用这个简单标准做题:

  • 只要一个函数用到了它自己作用域里没有定义的变量(自由变量),它就依赖外部环境
  • 如果这个函数还能在外部存活并被调用,那这段外部环境就会被“闭”住

8. 小练习(建议动手写一遍)

  1. 用闭包实现一个 createIdGenerator():每次调用返回自增 id。
  2. 用闭包实现一个 limit(fn, n):前 n 次调用执行,之后不执行。
  3. 阅读并对比:防抖与节流 中的实现,指出闭包保存了哪些状态(例如 timerlastTime)。

9. 高频面试问答(背这组就够用)

Q1:什么是闭包?

A:闭包是函数与其创建时的词法环境的组合,函数可以在定义作用域之外访问并持续使用外层变量。

Q2:闭包和作用域有什么区别?

A:作用域是“变量可见性规则”;闭包是“函数持有外部环境引用”带来的结果/现象,两者相关但不等价。

Q3:闭包常见应用场景有哪些?

A:私有变量/模块模式、工厂函数、多实例状态、缓存(memoize)、函数只执行一次(once)、柯里化/偏函数、事件回调与异步回调。

Q4:为什么 for(var i...) setTimeout 会打印 3 3 3?

A:var 是函数作用域,三个回调闭包里引用同一个 i;循环结束 i=3,所以都打印 3。

Q5:怎么修复上题?

A:用 let(每轮一个块级绑定),或用 IIFE/函数参数把当次 i 固定下来。

Q6:闭包里的变量是“值拷贝”还是“引用”?

A:更接近“引用到变量绑定”。读取时取当前值;但如果你把值复制到局部常量里,那局部常量就固定了当时的值。

Q7:闭包会导致内存泄漏吗?

A:闭包本身不是泄漏,但它会延长被引用变量的生命周期;如果闭包长期存活并引用大对象,可能导致内存占用过高,看起来像泄漏。

Q8:闭包里的变量什么时候被回收?

A:当没有任何可达引用指向该闭包及其外部环境时,垃圾回收器才会回收对应的环境记录与数据。

Q9:闭包会“把变量放到堆上”吗?

A:规范层面描述为“词法环境/环境记录”;在很多引擎实现里,被闭包捕获的变量往往会从“栈上临时存储”转为“可长期存活的上下文对象(通常在堆上)”,以保证外部函数返回后仍可访问。

Q10:闭包和 this 有什么关系?

A:闭包解决的是“外部变量怎么被记住”;this 由调用方式决定,默认不会被闭包“固定”。但箭头函数this 是词法绑定(从外层继承),所以常被误以为是闭包。

Q11:什么是 stale closure?怎么避免?

A:异步回调里拿到“旧的快照/旧的绑定”或把值复制到局部常量导致后续不更新。常见做法:把需要固定的值参数化;需要最新值就直接读外层变量,或把最新值放到可变容器中统一读取。

Q12:是不是所有函数都有闭包?

A:函数创建时都会关联外部词法环境;但通常只有当函数在定义作用域之外执行、并且访问了自由变量时,我们才会强调它“形成了闭包”。