闭包:函数记住它的诞生环境
闭包是 JavaScript 中最强大也最容易让人困惑的概念之一。理解了闭包,你就理解了函数如何"记住"并访问它的诞生环境。
学习目标
读完本文,你将学会:
- 什么是闭包,以及闭包形成的条件
- 闭包的常见应用场景
- 闭包与内存的关系
- 用闭包解决循环中的常见问题
一、什么是闭包
闭包(Closure) 是指一个函数能够记住并访问它的词法作用域,即使这个函数是在它的词法作用域之外执行的。
用更简单的话说:函数"携带"着它的诞生环境一起离开。
function outer() {
const secret = "我是 outer 的变量";
function inner() {
console.log(secret); // inner 访问了 outer 的变量
}
return inner; // 把 inner 返回到 outer 外部
}
const fn = outer(); // outer 执行完毕,理论上 outer 内的变量应该消失
fn(); // 但这里仍然能访问到 secret:"我是 outer 的变量"
fn 就是一个闭包——它是在 outer 内部定义的 inner 函数,即使 outer 已经执行完毕,fn 仍然能访问 outer 中的变量。
1.1 闭包形成的条件
要形成闭包,必须同时满足三个条件:
function createCounter() {
let count = 0; // 外部函数的变量
return { // 返回的对象包含内部函数
increment() { // 内部函数 1
count++;
return count;
},
decrement() { // 内部函数 2
count—;
return count;
},
getCount() { // 内部函数 3
return count;
}
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
counter.increment();
console.log(counter.getCount()); // 2
二、闭包的常见应用
2.1 私有变量
JavaScript 没有原生的私有变量机制(ES2022 之前),闭包是实现私有变量的经典方式。
function createPerson(name) {
// _age 是"私有"变量,外部无法直接访问
let _age = 0;
return {
getName() {
return name;
},
getAge() {
return _age;
},
growOlder() {
_age++;
}
};
}
const person = createPerson("小明");
console.log(person.getName()); // "小明"
console.log(person.getAge()); // 0
person.growOlder();
console.log(person.getAge()); // 1
// 外部无法直接修改 _age
console.log(person._age); // undefined
2.2 计数器
function createCounter(start = 0) {
let count = start;
return function() {
count++;
return count;
};
}
const counter1 = createCounter(0);
const counter2 = createCounter(100);
console.log(counter1()); // 1
console.log(counter1()); // 2
console.log(counter2()); // 101
console.log(counter2()); // 102
// 两个计数器互不影响,各自维护自己的 count
2.3 函数工厂(根据不同参数创建不同函数)
function makeMultiplier(factor) {
return function(number) {
return number * factor;
};
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
2.4 延迟执行 / 回调函数
function delayMessage(message, delay) {
setTimeout(function() {
console.log(message); // message 被闭包记住
}, delay);
}
delayMessage("3秒后输出", 3000);
// 即使 delayMessage 已经执行完毕,回调函数仍然能访问 message
三、闭包与循环:经典面试题
3.1 var 导致的问题
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 预期:0, 1, 2 实际:3, 3, 3
}, 100);
}
原因:var 没有块级作用域,三个回调函数共享同一个 i。当回调执行时,循环已经结束,i 的值是 3。
3.2 解决方案一:用 let(推荐)
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 0, 1, 2
}, 100);
}
let 有块级作用域,每次循环 i 都是独立的变量,每个回调捕获了各自的 i。
3.3 解决方案二:用闭包(ES6 之前的做法)
for (var i = 0; i < 3; i++) {
(function(capturedI) {
setTimeout(function() {
console.log(capturedI); // 0, 1, 2
}, 100);
})(i);
}
立即执行函数创建了一个新的作用域,capturedI 是每次循环时 i 的副本。
四、闭包的内存注意事项
4.1 闭包会阻止垃圾回收
闭包引用的外部变量不会被垃圾回收,因为内部函数仍然持有对这些变量的引用。
function createHeavyData() {
const hugeArray = new Array(1000000).fill("data"); // 大数组
return function() {
console.log(hugeArray.length); // 闭包引用了 hugeArray
};
}
const fn = createHeavyData();
// hugeArray 不会被回收,因为 fn 还引用着它
4.2 释放闭包
如果不小心创建了闭包但不再使用,可以手动释放引用:
let fn = createHeavyData();
fn(); // 使用完毕
fn = null; // 释放引用,让 hugeArray 可以被垃圾回收
4.3 意外的闭包(常见内存泄漏)
function setupButton() {
const bigData = fetchLotsOfData();
document.getElementById('btn').addEventListener('click', function() {
console.log('clicked');
// 这个回调没有使用 bigData,但因为闭包机制,bigData 仍然被持有
});
}
如果回调函数不需要外部变量,考虑把它移到外面,或者只引用需要的数据。
五、常见误区与注意点
| 闭包是 JavaScript 独有的特性 | 很多语言都有闭包概念,但 JavaScript 中特别常用 |
| 闭包会影响性能,应该避免使用 | 闭包是正常特性,合理使用不会导致问题 |
| 闭包只是嵌套函数 | 必须满足"内部函数被返回到外部"才形成闭包 |
| 闭包引用的所有变量都会被保留 | 只有被内部函数实际引用的变量才会保留 |
| 用 IIFE 就是闭包 | IIFE 可能创建闭包,但本身不等同于闭包 |
六、动手练习
练习 1:实现私有计数器
编写一个函数,创建一个有"步长"功能的计数器:
const counter = createStepCounter(10); // 从 10 开始
console.log(counter.next()); // 11(默认步长 1)
console.log(counter.next()); // 12
console.log(counter.reset()); // 10
console.log(counter.setStep(5)); // 设置步长为 5
console.log(counter.next()); // 15
参考答案
function createStepCounter(start = 0) {
let count = start;
let step = 1;
return {
next() {
count += step;
return count;
},
reset() {
count = start;
return count;
},
setStep(newStep) {
step = newStep;
return step;
}
};
}
练习 2:只执行一次的函数
编写一个 once 函数,确保传入的函数只执行一次,后续调用返回第一次的结果。
const initialize = once(() => {
console.log("初始化中…");
return { db: "connected" };
});
initialize(); // "初始化中…" 返回 { db: "connected" }
initialize(); // 不输出,直接返回第一次的结果
参考答案
function once(fn) {
let called = false;
let result;
return function(…args) {
if (!called) {
called = true;
result = fn(…args);
}
return result;
};
}
练习 3:解释输出
以下代码输出什么?为什么?
function createFunctions() {
const functions = [];
for (var i = 0; i < 3; i++) {
functions.push(function() {
return i;
});
}
return functions;
}
const fns = createFunctions();
console.log(fns[0]());
console.log(fns[1]());
console.log(fns[2]());
参考答案
全部输出 3。
原因:var 没有块级作用域,三个函数共享同一个 i。当 createFunctions 执行完毕时,i 的值已经是 3。三个函数都闭包引用了同一个 i。
修复方法:把 var 改成 let。
七、AI 辅助学习
7.1 本节知识点的 AI 提问模板
【背景】我是 JavaScript 初学者,正在学习第 13 篇"闭包"。
我已经了解作用域和作用域链的概念。
【问题】我理解闭包是函数记住并访问外部变量的能力,但我不清楚:
1. 闭包在内存中是如何工作的?
2. 为什么有时候闭包会导致内存泄漏?
3. 现代 JavaScript 中,class 的私有字段(#private)能替代闭包实现私有变量吗?
【期望】请解释闭包的内存模型,
给出 2 个闭包导致内存泄漏的实际场景和解决方案,
对比闭包实现私有变量和 class 私有字段的优劣。
7.2 用 AI 验证你的理解
- 问 AI:“以下代码是否形成闭包?解释原因”(给出嵌套函数代码)
- 让 AI 出 3 道闭包相关的面试题
- 问 AI:“React 的 useState 是否用到了闭包?解释原理。”
7.3 警惕 AI 的常见错误
- AI 可能在解释闭包时引入过于复杂的术语,要求用简单例子解释
- AI 可能把"闭包"和"作用域"混淆,注意区分
- AI 可能推荐用 var 解决循环闭包问题,坚持用 let
八、配套代码
本文示例代码位于:CODE/13-闭包/
| closure-playground.html | 闭包练习场:创建计数器、函数工厂,观察闭包的内存行为 |
九、本章小结
- 闭包:函数记住并访问其词法作用域的能力
- 形成条件:函数嵌套 + 内部引用外部变量 + 内部函数暴露到外部
- 常见应用:私有变量、计数器、函数工厂、回调函数
- 循环问题:var 导致共享变量,用 let 或 IIFE 解决
- 内存注意:闭包引用的变量不会被垃圾回收,不需要时释放引用
十、下篇预告
下一篇进入 JavaScript 最 tricky 的概念之一:《this 关键字:指向谁,由谁决定》,你将学到:
- this 的四种绑定规则
- 箭头函数中的 this 行为
- call、apply、bind 的用法
- 实际开发中 this 相关问题的解决方案
如果本文对你有帮助,欢迎点赞、收藏、关注专栏。有任何问题可以在评论区交流!





