闭包,不只是面试题
闭包是 JavaScript 里被讨论最多、又最容易被误解的概念之一。很多人面试前背得住定义,真到写业务代码时,却不知道它应该出现在哪里。这篇文章不从理论入手,而是用七个实战场景,看闭包到底在解决什么问题。你会发现,闭包之所以被一些人称为 JS 的灵魂,是因为它几乎渗透在回调、模块化、状态管理和函数式编程的每一个关键位置。

七个实战场景:闭包真正在解决什么问题
1. 私有变量:让状态待在该待的地方
最早让开发者感受到闭包价值的场景,是私有变量。我们希望某些状态只被特定函数读写,而不暴露成全局属性。
function createCounter() {
let count = 0;
return {
increment: () => ++count,
decrement: () => --count,
getCount: () => count
};
}
const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.getCount()); // 2
这里 count 没有挂到 window 或任何全局对象上,外部代码拿不到修改入口。闭包形成了一个变量访问边界。后来很多状态库的雏形,都能看到这种模式的影子。
2. 工厂函数:把重复配置沉淀成生成器
第二个场景和配置复用有关。如果你的代码里反复需要带同一个基础参数的请求函数,闭包可以把这份配置保存下来。
function createApiClient(baseURL) {
return function (path) {
return fetch(baseURL + path).then(res => res.json());
};
}
const request = createApiClient('https://api.example.com');
request('/users'); // 请求 https://api.example.com/users
baseURL 被返回的函数记住,后续调用只需关注路径。这种工厂函数在 SDK 封装、多环境切换、依赖注入时都很实用。
3. 柯里化:让参数分步到位
柯里化是闭包在函数式编程里的经典形态。它把多参数函数变成单参数链,每一步都在产生一个新闭包。
function add(a) {
return function (b) {
return function (c) {
return a + b + c;
};
};
}
add(1)(2)(3); // 6
实际项目中不一定要实现这种纯柯里化,但“提前冻结一部分参数”的思路经常出现在工具函数中,例如给统一上报逻辑增加事件类型参数。
4. 事件回调:记住当时的循环变量
在框架普及之前,直接在循环里绑定事件是前端最常见的需求。闭包可以保存每次循环时的变量值,避免回调执行时只剩最后一个值。
const buttons = document.querySelectorAll('.btn');
buttons.forEach(function (button, index) {
button.addEventListener('click', function () {
console.log('clicked', index);
});
});
这个匿名回调捕获了 index,形成一个独立的作用域快照。虽然 let 解决了 var 的经典问题,但理解闭包依然能帮你定位很多事件绑定类的 bug。
5. 防抖与节流:跨调用保存定时器状态
前端性能优化绕不开防抖和节流。它们的共通点是需要一个变量来保存定时器 ID,并且这个变量要在多次调用之间持续存在。
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
timer 就是闭包里的记忆点。每次触发都清理上一次定时器,重新计时。类似的节流、串行请求、输入状态暂存,都依赖这种跨调用保存能力。
6. 记忆化:用缓存记住计算结果
当一个函数会被反复调用且参数结果相同,我们可以用闭包把计算结果缓存起来。这种模式叫 memoize,本质是在闭包内部维护一个数据结构。
function memoize(fn) {
const cache = new Map();
return function (arg) {
if (cache.has(arg)) {
return cache.get(arg);
}
const result = fn(arg);
cache.set(arg, result);
return result;
};
}
缓存对象只通过返回的函数访问,外面拿不到重建入口。要注意的是,缓存需要配合明确的有效期,否则运行时间一长就会占满内存。
7. 模块模式:给函数一个隔离的容器
ES Module 普及前,开发者常用 IIFE 配合闭包做模块隔离,返回的接口是外部访问状态的唯一入口。
const store = (function () {
let state = {};
return {
set(key, value) {
state[key] = value;
},
get(key) {
return state[key];
}
};
})();
state 是受保护区,外部只能通过 set/get 操作。这个模式现在是手写小型状态容器、跨文件桥接或处理全局配置时常用的方案。
闭包的边界和常见误区
七个场景看下来,闭包的本质始终一致:函数在定义时把外层作用域的变量保留了下来。但这里有几个容易踩的坑。
- 内存泄漏:闭包会长期持有外层变量。当这个变量引用了 DOM 节点或大对象时,如果闭包未被释放,内存就回收不了。
- 过度封装:有时不需要额外闭包,直接传参更清晰。强行嵌套会降低代码可读性。
- 性能误解:闭包并不天然慢,现代引擎已经做了大量优化。真正需要关注的是在热路径中频繁创建返回函数导致的临时对象开销。
一个很典型的场景是:组件内部用闭包保存了订阅回调,但组件销毁时没有清掉这个闭包引用的资源。等页面上反复创建销毁,内存占用会慢慢涨上去。
| 场景 | 核心价值 | 常见风险 |
|---|---|---|
| 私有变量 | 状态隔离 | 接口暴露过深 |
| 工厂函数 | 配置复用 | 分层过多 |
| 柯里化 | 参数分步 | 难以调试 |
| 事件回调 | 捕获正确值 | 重复绑定与释放问题 |
| 防抖节流 | 状态保存 | this 丢失 |
| 记忆化 | 结果缓存 | 缓存无失效机制 |
| 模块模式 | 命名空间隔离 | 状态被无意共享 |
实际项目里怎么用闭包
判断标准可以很简单:你是否需要一份只能由特定逻辑访问、但又必须跨调用存活的状态?如果是,闭包是合适的工具。如果状态需要被多个不相关模块直接读写,或者生命周期跟着整个应用走,更应该考虑模块级变量、类属性或状态管理库。
落地时建议分三步走:
- 画出状态边界:这个状态到底属于哪个函数、哪个模块。
- 选出最简单的实现:闭包、类属性、模块变量,谁简单用谁。
- 检查释放路径:闭包在哪里被创建、在哪里失效,避免长生命周期持有短生命周期数据。
在代码评审时,可以主动追问那些返回函数的函数:它保存了什么变量,调用方能否及时销毁它。这比背定义更能反映开发者对闭包的理解。
闭包是 JS 工程思维的起点
回到文章标题的问题。为什么说闭包是 JS 的灵魂?因为它不是某个特定 API,而是 JavaScript 作用域规则的产物。你写的每个回调,每个中间件,每个 Hooks 的内部包装,几乎都在利用闭包。它不神奇,但很重要。理解了闭包,你会更容易读懂框架源码,也更容易发现状态泄漏、时序错乱的根源。
如果你正在学习它,不要停留在“函数里面返回一个函数”这个表面,试着去想想:这个闭包捕获了什么,它什么时候被释放,它会在哪些调用之间留下记忆。想清楚这三个问题,你对 JavaScript 的理解会上一个台阶。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/529/