01. 闭包是什么——函数记住它的出生地
闭包是 JavaScript 最重要的概念之一。简单说:一个函数能够记住并访问它被创建时所在的作用域里的变量,即使那个作用域已经不存在了。
打个比方:你小时候在你家树下面埋了个罐子,长大了回到那个树下面还能挖出来。对于函数来说,闭包就是它小时候的记忆——它能访问创建它的那个作用域里的变量,哪怕那个父函数早就执行完了。
所有 JavaScript 函数本质上都是闭包——因为它们都有对创建时作用域的引用。只是我们经常说的闭包场景是指函数内部返回另一个函数,并且返回的函数用到了外层函数的变量。
javascript
// 经典的闭包例子
function createCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
// count 变量虽然定义在 createCounter 里,
// 但返回的函数一直能访问它02. 闭包的实际用途
闭包不是用来炫技的,它有很实际的用途:
1. 数据私有化——创建只有某些函数才能访问的变量,外部完全碰不到。在 JavaScript 还没有 class private 字段之前,闭包就是唯一的私有变量方案。
2. 工厂函数——创建一系列类似但行为有细微差异的函数。比如 createAdder(5) 返回一个每次加 5 的函数。
3. 回调与事件绑定——点击按钮后执行的回调函数能访问绑定时周围作用域的变量。
4. 模块模式——IIFE(立即调用的函数表达式)加闭包是最早的 JS 模块方案,把变量封在函数作用域里,只暴露需要的接口。
javascript
// 1. 私有变量
function createPerson(name) {
let age = 0;
return {
getName: () => name,
getAge: () => age,
birthday: () => { age++; }
};
// age 和 name 在外部完全访问不到
}
// 2. 工厂函数
const addFive = (x => y => x + y)(5);
addFive(10); // 15
// 3. 模块模式
const myModule = (function() {
let privateVar = 'secret';
return {
getVar: () => privateVar,
setVar: v => { privateVar = v; }
};
})();闭包的内存问题——闭包会保持对外层变量的引用,导致这些变量不会被垃圾回收。如果闭包里引用了大对象且闭包一直存在,内存就释放不掉。
03. 经典的闭包陷阱——循环里的闭包
这是面试必考题:用 var 在循环里创建函数引用循环变量 i,结果所有函数引用的都是循环结束后的同一个 i。
为什么?因为 var 是函数作用域而不是块作用域,整个循环共享同一个 i。循环结束后 i 变成了最终值。所有闭包引用的都是同一个 i 的最终值。
解决方案:1. 用 let 替代 var(let 是块作用域,每次迭代创建新的绑定)。2. 用 IIFE 创建一个新作用域把 i 的值传进去保存。
javascript
// 经典陷阱
for (var i = 0; i < 3; i++) {
setTimeout(function() { console.log(i); }, 100);
}
// 输出:3, 3, 3(不是 0, 1, 2)
// 解决方案 1:用 let
for (let i = 0; i < 3; i++) {
setTimeout(function() { console.log(i); }, 100);
}
// 输出:0, 1, 2 ✓
// 解决方案 2:IIFE
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(function() { console.log(j); }, 100);
})(i);
}
// 输出:0, 1, 2 ✓用 let 就基本不用关心这个陷阱了。但面试官喜欢考这个,理解原理还是需要的。
04. 词法作用域——作用域在写代码时就定好了
JavaScript 的作用域是静态的(词法作用域)——函数在哪定义的就继承了哪的作用域,跟在哪执行的没关系。
this 是动态的——取决于函数怎么调用的。这就是为什么闭包跟 this 的关系让人觉得迷幻:闭包记住了词法作用域的变量,但 this 的值取决于调用方式,不受闭包保护。
箭头函数的 this 是词法的——继承自外层作用域的 this,不会因为调用方式改变。用箭头函数做回调,this 指向固定,省去 bind(this) 的烦恼。
理解闭包的两个关键:词法作用域(编译时确定)、垃圾回收(闭包引用的变量不回收)。
javascript
// 词法作用域
function outer() {
const x = 10;
return function inner() {
console.log(x); // x 从 outer 的作用域里找
};
}
const f = outer();
f(); // 10,即使 outer 已经执行完了
// this 不是闭包变量
const obj = {
name: 'Alice',
greet: function() {
setTimeout(function() {
console.log(this.name); // undefined!this 是 window/global
}, 100);
},
greetArrow: function() {
setTimeout(() => {
console.log(this.name); // Alice——箭头函数继承外层 this
}, 100);
}
};05. 闭包与性能——内存管理
闭包虽好用,但滥用会导致内存泄漏:
1. 闭包持有了不必要的大对象引用——本来该被回收的 DOM 元素或大数组,因为某个闭包还引着而没法释放。
2. 全局闭包——在全局作用域创建的闭包如果引用了大变量,这些变量就跟全局变量一样永驻内存。
3. 异步回调里的闭包——setTimeout/setInterval 的闭包持有引用,timer 不清理闭包就一直存在。
如何避免泄漏:不需要时解除引用(设为 null)、用 useEffect 的 cleanup 清理(React)、在组件销毁时取消订阅。
Chrome DevTools 的 Memory 面板可以拍 heap snapshot 看哪些闭包持有了不该持有的对象。
javascript
// 潜在内存泄漏
function setup() {
const bigData = new Array(1000000);
// bigData 只在 init 里用
const btn = document.getElementById('btn');
btn.addEventListener('click', function() {
// 这个闭包引用了整个 setup 作用域
// 虽然没直接用到 bigData,但 V8 的优化可能
// 导致整个 scope 对象都被保留
console.log('clicked');
});
}
// 更好的做法:拆开作用域
function setup() {
const bigData = new Array(1000000);
// 用完 bigData 后...
bigData = null; // 解除引用
// 再绑定事件
const btn = document.getElementById('btn');
btn.addEventListener('click', () => console.log('clicked'));
}现代 JS 引擎(V8)对闭包的内存做了很多优化——不会无脑保留所有外层变量。但不代表可以随意写,养成好习惯不依赖引擎优化。
知识测验
第 1/5 题正确 0
闭包能做什么?
下一节
下一节 原型与继承