闭包实际应用场景深度解析
一句话概括
闭包的真正威力不是”能访问外部变量”,而是保存状态——防抖存定时器、模块存私有数据、柯里化存参数、记忆化存缓存,所有”函数带着数据跑”的模式,底层全是闭包。
1
2
3
4
5
6
7
8
9
10
11
12
13
// 闭包最核心的价值:函数 + 私有状态
function createCounter(init = 0) {
let count = init; // ★ 状态被闭包保护
return {
inc: () => ++count, // 三个方法共享同一个 count
dec: () => --count,
get: () => count,
};
}
const c = createCounter(10);
c.inc(); c.inc(); console.log(c.get()); // 12
// count 对外部完全不可见,只能通过暴露的方法操作
核心知识点
1. 防抖与节流 —— 保存定时器/状态标记
防抖:高频触发只执行最后一次。闭包保存 timer,每次调用时清除上一次的定时器。
1
2
3
4
5
6
7
8
9
10
function debounce(fn, delay = 300) {
let timer = null; // ★ 闭包存定时器 ID
return function (...args) {
clearTimeout(timer); // 清掉上次的
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 搜索框输入 300ms 后才真正发请求
input.addEventListener('input', debounce(e => fetch(`/search?q=${e.target.value}`)));
节流:固定间隔内只执行一次。闭包保存 lastTime(时间戳版)或 canRun(标记版)。
1
2
3
4
5
6
7
8
9
10
11
12
13
function throttle(fn, delay = 200) {
let lastTime = 0; // ★ 闭包存上次执行时间
return function (...args) {
const now = Date.now();
if (now - lastTime >= delay) {
fn.apply(this, args);
lastTime = now;
}
};
}
// 滚动时每 200ms 最多执行一次
window.addEventListener('scroll', throttle(() => console.log(window.scrollY)));
面试能加分的说辞:”防抖的闭包保存的是
timer引用,让多次调用能清除同一个定时器;节流的闭包保存的是时间戳或标记,让多次调用能判断是否在冷却期内。两者本质都是’用闭包在多次函数调用之间维护状态’。”
2. 模块封装 —— 用 IIFE + 闭包实现私有变量
JavaScript 原生没有 private 关键字(ES2022 之前),IIFE + 闭包是最经典的模块方案。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const UserStore = (() => {
const users = new Map(); // ★ 私有:外部完全无法访问
let nextId = 1;
return {
add(name) {
const id = nextId++;
users.set(id, { id, name });
return id;
},
get(id) { return users.get(id); },
remove(id) { return users.delete(id); },
get count() { return users.size; },
};
})();
UserStore.add('张三');
console.log(UserStore.count); // 1
console.log(UserStore.users); // undefined ← 私有变量
关键认知:IIFE 只执行一次,返回的对象里的方法共享同一个闭包作用域——所以 add、get、remove 操作的是同一份 users。
3. 柯里化 —— 逐层”记住”参数
柯里化不是炫技,而是把多参数函数变成”分批接收参数”的函数工厂,每一层都用闭包记住已经传过的参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const curry = fn =>
function curried(...args) {
return args.length >= fn.length
? fn(...args) // 参数够了,执行
: (...rest) => curried(...args, ...rest); // 不够,返回新函数继续收
};
// 实际应用:预设日志等级
const log = (level, module, msg) => console.log(`[${level}][${module}] ${msg}`);
const warn = curry(log)('WARN');
const apiWarn = warn('API');
apiWarn('请求超时'); // [WARN][API] 请求超时
apiWarn('重试失败'); // [WARN][API] 重试失败
每调用一次返回的新函数,都通过闭包”记住”了之前传入的参数。 最终调用时一次性把所有参数拼起来执行。
4. 函数记忆 —— 用闭包缓存计算结果
对纯函数做缓存:参数相同 → 直接返回缓存结果,跳过重复计算。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const memoize = fn => {
const cache = new Map(); // ★ 闭包持有缓存
return (...args) => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
return cache.set(key, result), result;
};
};
// 递归 fib 加上记忆化之后从 O(2ⁿ) 变 O(n)
const fib = memoize(n => (n <= 1 ? n : fib(n - 1) + fib(n - 2)));
console.log(fib(40)); // 计算一次
console.log(fib(40)); // 命中缓存
关键细节:memoize 返回的函数里递归调用的 fib 也是记忆化版本,所以每次递归子问题都会被缓存——这才是性能质变的原因。
5. 单例模式 —— 闭包保存唯一实例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const getDB = (() => {
let instance = null; // ★ 闭包存实例引用
return () => {
if (!instance) {
instance = { connected: true, query(sql) { /* ... */ } };
console.log('创建数据库连接');
}
return instance;
};
})();
const db1 = getDB(); // 创建数据库连接
const db2 = getDB(); // 不输出,返回同一个实例
console.log(db1 === db2); // true
其实你每天都在用
写完上面这些,你可能觉得闭包是”刻意写的模式”。其实闭包已经渗透到你写的每一行代码里了:
- React 的
useEffect:useEffect(() => { fetchData(id); }, [id])— 回调记住了当前渲染时的id,清理函数记住了上一次的 timer。整个 Hooks 体系就是建立在闭包之上的。 - 事件监听:
btn.addEventListener('click', () => removeItem(id))— 回调函数”记住”了id,你删的就是你点的那一行。没有闭包,你根本做不到”每个按钮记住自己的数据”。 - Vue 的
computed/watch:computed(() => list.filter(i => i.active))— 这个箭头函数就是闭包,它记住了list。响应式系统通知重新计算时,闭包让回调总能访问到最新的list。 Promise.then链:.then(res => process(res, config))— 每个then的回调都是闭包,带着当时作用域里的config往下传。异步编程的”上下文传递”本质就是闭包。- 高阶组件 / 高阶函数:
connect(mapState)(Component)、withRouter(Page)— 返回新组件/新函数的模式,返回的东西通过闭包持有传入的参数。整个高阶函数范式就是闭包的产物。
结论:闭包不是你”决定要用”的设计模式,而是 JavaScript 的函数从诞生之日起就天然具备的能力。你能用 addEventListener 写回调的那一刻,闭包就已经在工作了。
常见误解
❌ 误区一:闭包会导致内存泄漏,能不用就不用
闭包本身不是内存泄漏,它只是按设计保留了变量引用。V8 会精确标记——只有被闭包实际引用的变量才保留,没引用的照常回收。防御性地”避免使用闭包”相当于说”因为指针可能野指针,所以不用变量”。真正该注意的是:闭包被全局变量长期引用、事件监听未移除、大对象被闭包误捕获这些具体场景。
❌ 误区二:只有 return 出去的才是闭包
技术上,只要内部函数引用了外部变量,JS 引擎就会创建闭包,跟 return 无关。区别只在于生命周期:return 出去的闭包长期存活,直接调用的(如 arr.map(item => item + base))执行完就回收了。你在 map/filter/forEach 里写的每一个箭头函数,那一刻都是闭包。
❌ 误区三:React Hooks 的闭包陷阱是框架的 bug
不是 bug,是闭包的固有行为——捕获的是引用,而在 useEffect([], []) 里这个引用永远指向第一次渲染时的值。Hooks 的设计者很清楚这一点,所以提供了函数式更新 setCount(c => c + 1) 和 useRef 来绕过。理解闭包才能理解 Hooks,而不是反过来把锅甩给框架。
❌ 误区四:防抖/节流用第三方库就行,不用理解闭包怎么实现的
面试官问”手写防抖”不是在考你记不记得 lodash 的 API,而是在考你是否真正理解闭包——timer 为什么不能被外部清理?多次调用为什么共享同一个 timer?这些本质问题只有理解了闭包才能回答。理解了实现,你才能调参(delay 设多少?leading/trailing 怎么选?),出问题时才能定位。
一句话总结
闭包的本质价值是”函数带着状态走”:防抖节流带定时器、模块带私有数据、柯里化带参数、记忆化带缓存。你写的每一个事件回调、每一个
useEffect、每一个map箭头函数都依赖闭包——它不是设计模式,是 JavaScript 的呼吸。