闭包(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 代码里的真实对象名):
你只要抓住核心因果链:
- 调用
makeCounter()会创建一个新的词法环境,里面有count inner函数在创建时,把“外部环境引用”保存到了自己的内部槽位(通常被描述为[[Environment]])- 只要
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. 小练习(建议动手写一遍)
- 用闭包实现一个
createIdGenerator():每次调用返回自增 id。 - 用闭包实现一个
limit(fn, n):前n次调用执行,之后不执行。 - 阅读并对比:防抖与节流 中的实现,指出闭包保存了哪些状态(例如
timer、lastTime)。
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:函数创建时都会关联外部词法环境;但通常只有当函数在定义作用域之外执行、并且访问了自由变量时,我们才会强调它“形成了闭包”。