JavaScript 闭包学习笔记

JavaScript 闭包学习笔记

1. 闭包是什么

闭包(Closure)是“函数”和“该函数定义时所处的词法环境”的组合。

更直白地说:一个函数可以记住并访问它定义位置外部的变量。即使这个函数离开了原来的作用域,甚至外层函数已经执行结束,它仍然能够访问这些变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function createCounter() {
let count = 0;

return function () {
count += 1;
return count;
};
}

const counter = createCounter();

console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

createCounter() 执行结束后,内部函数仍然引用着变量 count,所以与 count 相关的词法环境不会立即被垃圾回收。后续每次调用 counter(),访问的都是同一个 count。

需要注意:闭包不是只有“返回内部函数”这一种写法。只要函数引用了定义位置外层作用域中的变量,就形成了闭包。返回函数、事件回调、定时器回调和异步回调只是常见表现形式。

2. 理解闭包前需要掌握的概念

2.1 词法作用域

JavaScript 使用词法作用域。一个函数能够访问哪些变量,主要由它在源代码中的定义位置决定,而不是由调用位置决定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const name = "全局作用域";

function outer() {
const name = "outer 作用域";

function showName() {
console.log(name);
}

return showName;
}

const fn = outer();
fn(); // outer 作用域

showName 定义在 outer 内部,所以它沿着定义时的作用域链找到的是 outer 中的 name。将它放到其他位置调用,不会改变它的词法作用域。

2.2 作用域链

函数访问一个变量时,会先查找自身作用域;如果没有找到,再逐层查找外层作用域,直到全局作用域。这个逐层查找的关系就是作用域链。

1
2
3
4
5
6
7
8
9
10
const globalValue = "global";

function outer() {
const outerValue = "outer";

return function inner() {
const innerValue = "inner";
console.log(innerValue, outerValue, globalValue);
};
}

inner 可以访问自己的 innerValue、外层的 outerValue 和全局的 globalValue,但外层作用域不能反向访问 innerValue。

2.3 函数执行结束不等于所有局部变量立即消失

通常,函数执行结束且其局部变量不再被引用时,相关内存可以被回收。但如果一个仍然存活的函数引用了某些局部变量,这些变量仍然可达,就不能被垃圾回收。

闭包保存的不是某个变量值的静态副本,而是对词法环境中变量绑定的引用。因此,闭包通常能够看到变量之后发生的变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createState() {
let value = 1;

const getValue = () => value;
const setValue = (nextValue) => {
value = nextValue;
};

return { getValue, setValue };
}

const state = createState();
console.log(state.getValue()); // 1
state.setValue(10);
console.log(state.getValue()); // 10

getValue 和 setValue 共享同一个词法环境,因此操作的是同一个 value。

3. 闭包是如何形成的

可以把形成过程理解为四步:

  1. 外层函数开始执行,并创建局部变量。
  2. 内层函数在这个作用域中被定义,因此能够访问外层变量。
  3. 内层函数被返回、传递或保存到外部。
  4. 外层函数执行结束后,内层函数仍然存活并引用外层变量,所以相关词法环境继续保留。
1
2
3
4
5
6
7
8
9
10
11
function createMessage(prefix) {
return function printMessage(message) {
console.log(`${prefix}: ${message}`);
};
}

const printInfo = createMessage("INFO");
const printError = createMessage("ERROR");

printInfo("请求成功");
printError("请求失败");

这里创建了两次独立的词法环境。printInfo 保存的 prefix 是 INFO,printError 保存的 prefix 是 ERROR,二者互不影响。

4. 闭包的常见使用场景

4.1 封装私有状态

闭包可以隐藏不希望被外部直接修改的数据,只暴露有限的操作接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function createWallet(initialBalance) {
let balance = initialBalance;

return {
getBalance() {
return balance;
},
deposit(amount) {
if (amount <= 0) {
throw new Error("存入金额必须大于 0");
}
balance += amount;
},
withdraw(amount) {
if (amount > balance) {
throw new Error("余额不足");
}
balance -= amount;
}
};
}

const wallet = createWallet(100);
wallet.deposit(50);
console.log(wallet.getBalance()); // 150

外部无法直接访问 balance,只能通过规定的方法读取或修改它。这有助于保护状态并集中处理校验逻辑。

不过在现代 JavaScript 中,类的私有字段 #field、模块作用域以及 WeakMap 也可以实现私有化。是否使用闭包,应根据代码结构和团队习惯决定。

4.2 函数工厂和参数预配置

函数工厂根据传入的配置生成具有特定行为的新函数。

1
2
3
4
5
6
7
8
9
10
11
function createMultiplier(factor) {
return function (number) {
return number * factor;
};
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

这类写法可以减少重复传参,也常用于权限判断、格式化函数、日志函数和请求配置。

4.3 保存多次调用之间的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function once(fn) {
let called = false;
let result;

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

return result;
};
}

const initialize = once(() => {
console.log("执行初始化");
return { ready: true };
});

initialize(); // 执行初始化
initialize(); // 不再重复执行

called 和 result 会在多次调用之间持续保存,但外部无法直接修改它们。

4.4 防抖

防抖会在事件停止频繁触发一段时间后才执行函数,常用于搜索框输入、窗口缩放和表单校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function debounce(fn, delay) {
let timer = null;

return function (...args) {
const context = this;
clearTimeout(timer);

timer = setTimeout(() => {
fn.apply(context, args);
}, delay);
};
}

const handleSearch = debounce((keyword) => {
console.log("搜索:", keyword);
}, 300);

返回的函数通过闭包持续访问 timer。每次触发都会取消上一次计时,直到用户停止操作后才真正执行。

4.5 节流

节流限制函数在一段时间内最多执行一次,常用于滚动、拖拽和鼠标移动事件。

1
2
3
4
5
6
7
8
9
10
11
12
function throttle(fn, interval) {
let lastTime = 0;

return function (...args) {
const now = Date.now();

if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}

lastTime 被闭包保存,用于判断本次调用是否满足执行间隔。

4.6 缓存计算结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function memoize(fn) {
const cache = new Map();

return function (value) {
if (cache.has(value)) {
return cache.get(value);
}

const result = fn(value);
cache.set(value, result);
return result;
};
}

const getSquare = memoize((number) => {
console.log("执行计算");
return number * number;
});

console.log(getSquare(5)); // 执行计算,25
console.log(getSquare(5)); // 直接从缓存读取,25

闭包使 cache 在函数调用之间持续存在。真实项目中还需要考虑缓存上限、过期时间以及参数序列化方式,否则缓存本身可能造成内存增长。

4.7 事件处理和异步回调

1
2
3
4
5
6
7
8
9
10
11
function bindUserButton(button, userId) {
function handleClick() {
console.log(`查看用户:${userId}`);
}

button.addEventListener("click", handleClick);

return function unbind() {
button.removeEventListener("click", handleClick);
};
}

点击发生时,bindUserButton 可能早已执行完毕,但回调仍然能够访问 userId。同时返回清理函数,可以在不需要监听时解除引用。

4.8 模块模式

在 ES Module 普及之前,闭包经常用于创建具有私有状态和公开接口的模块。

1
2
3
4
5
6
7
8
9
10
11
12
const userStore = (() => {
const users = [];

return {
add(user) {
users.push(user);
},
getAll() {
return [...users];
}
};
})();

现在通常优先使用 ES Module,但理解这种模式有助于阅读旧项目和第三方库源码。

5. 常见误区和易错点

5.1 var 循环中的闭包问题

1
2
3
4
5
6
7
8
9
const handlers = [];

for (var i = 0; i < 3; i += 1) {
handlers.push(() => console.log(i));
}

handlers[0](); // 3
handlers[1](); // 3
handlers[2](); // 3

var 没有块级作用域,这三个函数共享同一个变量 i。循环结束时 i 已经变成 3,因此调用任何一个函数都会输出 3。

使用 let 可以让每轮循环创建新的绑定:

1
2
3
4
5
6
7
8
9
const handlers = [];

for (let i = 0; i < 3; i += 1) {
handlers.push(() => console.log(i));
}

handlers[0](); // 0
handlers[1](); // 1
handlers[2](); // 2

也可以通过额外的函数调用为每次循环创建独立作用域,但在现代代码中通常直接使用 let。

5.2 闭包保存的是变量绑定,不一定是创建时的值

1
2
3
4
5
let value = 1;
const getValue = () => value;

value = 2;
console.log(getValue()); // 2

闭包访问的是变量 value 当前的内容,而不是定义函数时复制出来的 1。如果确实需要保存某一时刻的值,应显式创建一个不会再被修改的局部绑定。

5.3 每次调用外层函数都会创建独立状态

1
2
3
4
5
6
7
8
9
10
11
function createCounter() {
let count = 0;
return () => ++count;
}

const counterA = createCounter();
const counterB = createCounter();

console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1

counterA 和 counterB 来自两次不同的函数调用,各自拥有独立的词法环境。

5.4 闭包与 this 不是同一个概念

闭包中的变量由函数的定义位置决定;普通函数的 this 通常由调用方式决定。二者不要混淆。

1
2
3
4
5
6
7
8
9
10
11
const user = {
name: "小刘",
createPrinter() {
const prefix = "用户";

return function () {
console.log(prefix); // 来自闭包
console.log(this.name); // 取决于调用方式
};
}
};

箭头函数没有自己的 this,会沿词法环境捕获外层 this。这与它捕获普通变量的方式相似,但应分别理解。

5.5 闭包不等于内存泄漏

闭包只是延长了被引用数据的生命周期。如果这些数据仍然有用,这属于正常行为。只有当数据已经不再需要,却因为某些长期引用仍无法释放时,才构成内存泄漏或不必要的内存占用。

常见风险包括:

  • 给长期存在的 DOM 元素添加事件监听,但组件销毁时没有移除。
  • 创建定时器后没有调用 clearTimeout 或 clearInterval。
  • 将闭包保存在全局数组、单例或长期缓存中。
  • 闭包只需要一个小字段,却意外引用了一个很大的对象。
  • 缓存只增加、不淘汰,导致闭包中的 Map 持续增长。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function mount(element, largeData) {
function handleClick() {
console.log(largeData.title);
}

element.addEventListener("click", handleClick);

return function cleanup() {
element.removeEventListener("click", handleClick);
};
}

const cleanup = mount(button, largeData);

// 页面或组件销毁时执行
cleanup();

清理监听器后,如果没有其他引用,相关函数和数据才有机会被垃圾回收。

5.6 React 中的陈旧闭包

在 React 函数组件中,每次渲染都会创建一套新的变量和函数。异步回调可能捕获某次旧渲染中的状态,从而产生“陈旧闭包”问题。

1
2
3
4
5
6
7
8
9
10
11
function Counter() {
const [count, setCount] = React.useState(0);

function handleDelayedIncrease() {
setTimeout(() => {
setCount(count + 1);
}, 1000);
}

return <button onClick={handleDelayedIncrease}>{count}</button>;
}

定时器回调捕获的是触发事件时那次渲染中的 count。如果等待期间状态发生变化,回调仍可能使用旧值。

当新状态依赖旧状态时,可以使用函数式更新:

1
setCount((currentCount) => currentCount + 1);

其他常见处理方式包括正确声明 Hook 依赖、使用 useRef 保存需要读取的最新值,以及在副作用清理函数中取消定时器或异步任务。

6. 如何减少闭包带来的内存问题

  1. 不要在闭包中引用用不到的大对象,只保留真正需要的数据。
  2. 组件或页面销毁时,移除事件监听器并清理定时器。
  3. 对缓存设置容量上限、过期时间或淘汰策略。
  4. 不再需要某个长期保存的闭包时,解除保存它的引用。
  5. 避免为了“私有变量”无条件使用闭包;类私有字段或模块作用域有时更清晰。
  6. 遇到疑似泄漏时,使用浏览器开发者工具的 Memory 面板和堆快照检查引用链。

将变量赋值为 null 并不是通用解决方案。只有当这个赋值确实切断了最后一条有效引用时,它才有帮助。垃圾回收的核心判断是对象是否仍然可达。

7. 闭包的优点与代价

优点

  • 可以封装私有状态,减少全局变量。
  • 可以让函数保存上下文和配置。
  • 可以在多次调用之间维护状态。
  • 便于实现函数工厂、防抖、节流、缓存和一次性执行。
  • 非常适合事件回调和异步编程。

代价

  • 被引用的数据会拥有更长的生命周期。
  • 状态隐藏在函数内部,调试时可能不够直观。
  • 过度嵌套会降低代码可读性。
  • 不正确的引用管理可能造成内存占用增长。
  • 在 React 等渲染模型中可能引发陈旧闭包问题。

8. 面试时可以怎样回答

闭包是函数与其定义时词法环境的组合。即使外层函数已经执行结束,只要内部函数仍然引用外层变量,这些变量就会继续保留,并能在后续调用中被访问。

闭包的核心用途是保存状态和封装数据,常见于函数工厂、私有变量、防抖、节流、缓存、事件处理和异步回调。闭包本身不等于内存泄漏,但它会延长相关变量的生命周期。如果事件监听器、定时器、全局对象或无限增长的缓存长期持有闭包,就可能造成不必要的内存占用,因此需要及时清理引用。

一句话总结:闭包让函数“记住”定义时的作用域,因此能够保存和封装状态,但也需要注意这些状态的生命周期。

9. 自测问题

  1. 为什么外层函数执行结束后,闭包仍然能够访问其局部变量?
  2. 闭包保存的是变量的值,还是变量绑定?
  3. 为什么使用 var 创建循环回调时,回调可能输出相同的值?
  4. 两次调用同一个外层函数创建的闭包是否共享状态?
  5. 闭包和普通函数的 this 分别由什么决定?
  6. 为什么闭包不一定会造成内存泄漏?
  7. 防抖和节流分别通过闭包保存了什么状态?
  8. React 中的陈旧闭包是怎样产生的?
  9. 事件监听器使用闭包时,为什么需要提供清理逻辑?
  10. 什么情况下应考虑用模块作用域或类私有字段代替闭包?

JavaScript 闭包学习笔记
http://example.com/2026/08/07/闭包学习笔记/
Author
John Doe
Posted on
August 7, 2026
Licensed under