闭包
问题
什么是闭包?闭包有什么作用和问题?
答案
闭包(Closure) 是指函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行。简单说,闭包是函数 + 其外部变量的引用。
闭包的形成
function outer(): () => number {
let count = 0; // 外部变量
function inner(): number { // 内部函数
count++;
return count;
}
return inner; // 返回内部函数
}
const counter = outer(); // outer 执行完毕,但 count 没有被销毁
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
// inner 函数形成了闭包,"记住"了 count 变量
闭包原理图解
闭包的应用
1. 数据私有化
function createCounter(): {
increment: () => number;
decrement: () => number;
getCount: () => number;
} {
let count = 0; // 私有变量,外部无法直接访问
return {
increment: () => ++count,
decrement: () => --count,
getCount: () => count,
};
}
const counter = createCounter();
console.log(counter.getCount()); // 0
counter.increment();
counter.increment();
console.log(counter.getCount()); // 2
// console.log(count); // ❌ ReferenceError
2. 模块模式
const Calculator = (function() {
// 私有变量
let result = 0;
// 私有方法
function validate(n: number): boolean {
return typeof n === 'number' && !isNaN(n);
}
// 公开接口
return {
add(n: number): typeof Calculator {
if (validate(n)) result += n;
return this;
},
subtract(n: number): typeof Calculator {
if (validate(n)) result -= n;
return this;
},
getResult(): number {
return result;
},
reset(): typeof Calculator {
result = 0;
return this;
},
};
})();
Calculator.add(10).subtract(3).add(5);
console.log(Calculator.getResult()); // 12
3. 函数工厂
function createMultiplier(multiplier: number): (n: number) => number {
return function(n: number): number {
return n * multiplier;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
4. 柯里化
function curry<T extends (...args: any[]) => any>(fn: T): any {
return function curried(...args: any[]): any {
if (args.length >= fn.length) {
return fn.apply(null, args);
}
return function(...nextArgs: any[]): any {
return curried.apply(null, [...args, ...nextArgs]);
};
};
}
function add(a: number, b: number, c: number): number {
return a + b + c;
}
const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
5. 缓存(记忆化)
function memoize<T extends (...args: any[]) => any>(fn: T): T {
const cache = new Map<string, ReturnType<T>>();
return function(...args: Parameters<T>): ReturnType<T> {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log('从缓存获取');
return cache.get(key)!;
}
const result = fn.apply(null, args);
cache.set(key, result);
return result;
} as T;
}
const fibonacci = memoize((n: number): number => {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
console.log(fibonacci(40)); // 快速计算
6. 防抖节流
// 防抖
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null;
return function(...args: Parameters<T>): void {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(null, args);
}, delay);
};
}
// 节流
function throttle<T extends (...args: any[]) => any>(
fn: T,
interval: number
): (...args: Parameters<T>) => void {
let lastTime = 0;
return function(...args: Parameters<T>): void {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(null, args);
}
};
}
7. 迭代器
function createIterator<T>(array: T[]): () => { value: T | undefined; done: boolean } {
let index = 0;
return function(): { value: T | undefined; done: boolean } {
if (index < array.length) {
return { value: array[index++], done: false };
}
return { value: undefined, done: true };
};
}
const next = createIterator([1, 2, 3]);
console.log(next()); // { value: 1, done: false }
console.log(next()); // { value: 2, done: false }
console.log(next()); // { value: 3, done: false }
console.log(next()); // { value: undefined, done: true }
经典问题
循环中的闭包
// ❌ 问题代码
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 3, 3, 3
}, 100);
}
// ✅ 解决方案 1: 使用 let(块级作用域)
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 0, 1, 2
}, 100);
}
// ✅ 解决方案 2: IIFE 创建闭包
for (var i = 0; i < 3; i++) {
((j) => {
setTimeout(() => {
console.log(j); // 0, 1, 2
}, 100);
})(i);
}
// ✅ 解决方案 3: setTimeout 第三个参数
for (var i = 0; i < 3; i++) {
setTimeout((j) => {
console.log(j); // 0, 1, 2
}, 100, i);
}
原因分析
闭包与内存
内存泄漏场景
// ❌ 可能的内存泄漏
function createHeavyClosure(): () => void {
const hugeData = new Array(1000000).fill('x'); // 大数据
return function(): void {
console.log(hugeData.length); // 引用大数据
};
}
const fn = createHeavyClosure();
// hugeData 无法被回收,因为 fn 仍然引用它
// ✅ 解决方案 1: 只保留必要数据
function createLightClosure(): () => void {
const hugeData = new Array(1000000).fill('x');
const length = hugeData.length; // 只保留需要的数据
return function(): void {
console.log(length);
};
}
// ✅ 解决方案 2: 及时清理
let closure: (() => void) | null = createHeavyClosure();
// 使用完毕后
closure = null; // 解除引用,允许 GC 回收
DOM 引用泄漏
// ❌ 内存泄漏
function setupHandler(): void {
const button = document.getElementById('btn');
const heavyData = new Array(1000000).fill('x');
button?.addEventListener('click', () => {
console.log(heavyData.length);
});
}
// 即使 button 从 DOM 移除,闭包仍引用 heavyData
// ✅ 正确做法
function setupHandlerFixed(): () => void {
const button = document.getElementById('btn');
const heavyData = new Array(1000000).fill('x');
const dataLength = heavyData.length;
const handler = (): void => {
console.log(dataLength);
};
button?.addEventListener('click', handler);
// 返回清理函数
return () => {
button?.removeEventListener('click', handler);
};
}
const cleanup = setupHandlerFixed();
// 需要时调用清理
cleanup();
闭包性能考量
// ❌ 不必要的闭包
class Counter {
count = 0;
constructor() {
// 每次创建实例都创建新的闭包函数
this.increment = () => {
this.count++;
};
}
increment: () => void;
}
// ✅ 使用原型方法
class CounterOptimized {
count = 0;
// 方法在原型上共享
increment(): void {
this.count++;
}
}
常见面试问题
Q1: 什么是闭包?
答案:
闭包是指一个函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行。
function outer(): () => void {
const message = 'Hello';
return function inner(): void {
console.log(message); // inner 可以访问 outer 的变量
};
}
const fn = outer();
fn(); // 'Hello' - 即使 outer 已执行完毕
形成条件:
- 函数嵌套
- 内部函数引用外部函数的变量
- 内部函数被返回或传递到外部
Q2: 闭包的优缺点?
答案:
| 优点 | 缺点 |
|---|---|
| 数据私有化 | 内存占用 |
| 状态保持 | 可能内存泄漏 |
| 模块化 | 调试困难 |
| 函数工厂 | 性能开销 |
Q3: 如何解决循环中闭包问题?
答案:
// 问题
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 3, 3, 3
}
// 解决方案
// 1. let 块级作用域
for (let i = 0; i < 3; i++) { ... }
// 2. IIFE
for (var i = 0; i < 3; i++) {
((j) => setTimeout(() => console.log(j), 100))(i);
}
// 3. setTimeout 参数
for (var i = 0; i < 3; i++) {
setTimeout((j) => console.log(j), 100, i);
}
Q4: 以下代码输出什么?
function createFunctions(): (() => number)[] {
const result: (() => number)[] = [];
for (var i = 0; i < 3; i++) {
result.push(function(): number {
return i;
});
}
return result;
}
const functions = createFunctions();
console.log(functions[0]()); // ?
console.log(functions[1]()); // ?
console.log(functions[2]()); // ?
答案:
console.log(functions[0]()); // 3
console.log(functions[1]()); // 3
console.log(functions[2]()); // 3
// 原因:所有函数共享同一个变量 i,循环结束后 i = 3
// 修复:使用 let 或 IIFE
Q5: 如何利用闭包实现单例模式?
答案:
const Singleton = (function() {
let instance: { name: string } | null = null;
function createInstance(): { name: string } {
return { name: 'Singleton Instance' };
}
return {
getInstance(): { name: string } {
if (!instance) {
instance = createInstance();
}
return instance;
},
};
})();
const a = Singleton.getInstance();
const b = Singleton.getInstance();
console.log(a === b); // true
Q6: 闭包在循环中的经典问题(for var vs let)
答案:
这是面试中最高频的闭包问题之一。核心在于理解 var 是函数作用域,let 是块级作用域。
// ❌ 使用 var:输出 3, 3, 3
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 所有回调共享同一个 i,循环结束后 i = 3
}, 1000);
}
// ✅ 使用 let:输出 0, 1, 2
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 每次迭代都有独立的 i
}, 1000);
}
let 在每次循环迭代时会创建一个全新的变量绑定,相当于 JavaScript 引擎在底层为每次迭代执行了类似 let i_0 = 0、let i_1 = 1、let i_2 = 2 的操作。每个 setTimeout 的回调函数捕获的是各自迭代的独立变量。
除了 let,还有三种经典的闭包解决方案:
// 方案一:IIFE(立即执行函数)创建独立作用域
for (var i = 0; i < 3; i++) {
(function (j: number) {
setTimeout(() => {
console.log(j); // 0, 1, 2
}, 1000);
})(i); // 将当前 i 的值作为参数传入
}
// 方案二:利用 setTimeout 的第三个参数
for (var i = 0; i < 3; i++) {
setTimeout(
(j: number) => {
console.log(j); // 0, 1, 2
},
1000,
i // 第三个参数会作为回调的参数传入,传入时会复制当前值
);
}
// 方案三:利用闭包函数工厂
function createLogger(value: number): () => void {
return () => console.log(value); // value 被闭包捕获
}
for (var i = 0; i < 3; i++) {
setTimeout(createLogger(i), 1000); // 0, 1, 2
}
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
let | 块级作用域,每次迭代创建新绑定 | 最简洁、语义清晰 | 需要 ES6+ 环境 |
| IIFE | 函数作用域创建独立副本 | 兼容 ES5 | 代码冗长、可读性差 |
setTimeout 第三个参数 | 参数值传递 | 简单直接 | 依赖 setTimeout 特性 |
| 函数工厂 | 闭包捕获参数值 | 可复用、语义好 | 需要额外函数 |
面试时不要只说"用 let 替换 var",要能解释 let 背后的块级作用域原理,并至少掌握一种 ES5 时代的闭包解决方案(IIFE 最为经典)。
Q7: 闭包与柯里化(currying)的关系
答案:
柯里化是将一个接收多个参数的函数转换为一系列每次只接收一个参数的函数。闭包是实现柯里化的核心机制——每次调用返回的新函数都通过闭包"记住"了之前传入的参数。
// 手动柯里化:每一层返回的函数都是一个闭包
function add(a: number): (b: number) => (c: number) => number {
return function (b: number) {
// 闭包记住了 a
return function (c: number) {
// 闭包记住了 a 和 b
return a + b + c;
};
};
}
console.log(add(1)(2)(3)); // 6
实际项目中更常用通用柯里化函数:
function curry<T extends (...args: any[]) => any>(fn: T) {
return function curried(...args: any[]): any {
if (args.length >= fn.length) {
return fn(...args); // 参数够了,执行原函数
}
return function (...nextArgs: any[]): any {
// 闭包记住已收集的 args,与新参数合并
return curried(...args, ...nextArgs);
};
};
}
// 使用示例
function multiply(a: number, b: number, c: number): number {
return a * b * c;
}
const curriedMultiply = curry(multiply);
console.log(curriedMultiply(2)(3)(4)); // 24
console.log(curriedMultiply(2, 3)(4)); // 24
console.log(curriedMultiply(2)(3, 4)); // 24
柯里化在实际开发中的典型应用:
// 1. 参数复用:创建专用函数
const log = curry(
(level: string, prefix: string, message: string): string => {
return `[${level}] ${prefix}: ${message}`;
}
);
const errorLog = log('ERROR'); // 固定 level
const apiErrorLog = errorLog('API'); // 进一步固定 prefix
console.log(apiErrorLog('请求超时'));
// "[ERROR] API: 请求超时"
// 2. 事件处理:延迟执行
const handleEvent = curry(
(eventType: string, element: HTMLElement, callback: () => void): void => {
element.addEventListener(eventType, callback);
}
);
const bindClick = handleEvent('click'); // 复用 click 事件类型
// bindClick(buttonEl, handleSubmit);
// bindClick(linkEl, handleNavigate);
- 参数记忆:每次调用产生的闭包保存已收集的参数
- 延迟执行:直到参数收集完毕才真正执行
- 参数复用:通过偏应用(partial application)创建专用函数
Q8: 闭包在 React Hooks 中的体现(useState 闭包陷阱)
答案:
React Hooks 的本质就是闭包。每次组件渲染都会创建一个新的闭包,捕获当次渲染的 state 和 props。这个设计带来了一个经典问题——闭包陷阱(Stale Closure):事件处理函数或 Effect 中捕获的是旧的 state 值。
import { useState, useEffect } from 'react';
function Counter(): JSX.Element {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log('当前 count:', count); // ❌ 永远是 0!
// 这里的 count 是第一次渲染时闭包捕获的值
setCount(count + 1); // ❌ 永远设置为 1
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖数组:Effect 只在挂载时执行一次
return <div>{count}</div>;
}
useEffect 的回调在第一次渲染时创建,形成闭包。由于依赖数组为 [],Effect 不会重新执行,回调函数中的 count 永远是初始值 0。
解决方案:
function Counter(): JSX.Element {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount((prev) => prev + 1); // ✅ 函数式更新,不依赖闭包中的 count
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
function Counter(): JSX.Element {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count; // 每次渲染同步最新值到 ref
});
useEffect(() => {
const timer = setInterval(() => {
console.log('最新 count:', countRef.current); // ✅ 始终是最新值
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
function Counter(): JSX.Element {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log('当前 count:', count); // ✅ 每次 count 变化都重新创建
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, [count]); // count 变化时重新执行 Effect(注意:会频繁清除/创建定时器)
return <div>{count}</div>;
}
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
函数式更新 setCount(prev => prev + 1) | 只需要基于旧值更新 state | 最简洁、无额外依赖 | 只能用于 setState |
useRef 存最新值 | 需要在回调中读取最新值 | 通用性强 | 需要额外同步逻辑 |
| 正确设置依赖数组 | Effect 确实需要响应变化 | 语义明确 | 可能导致频繁重建 |
React 19 引入了 React Compiler,能自动处理部分闭包陷阱场景,减少手动 useMemo/useCallback 的需要。但理解闭包陷阱的原理仍然是面试必考知识点。
Q9: 闭包与模块模式(IIFE 实现私有变量)
答案:
在 ES Module 出现之前,JavaScript 没有原生的模块系统。开发者利用IIFE(立即执行函数表达式)+ 闭包来模拟模块,实现私有变量和方法的封装。这被称为模块模式(Module Pattern)。
const UserModule = (function () {
// ======= 私有成员(外部无法访问)=======
let users: { id: number; name: string }[] = [];
let nextId = 1;
// 私有方法
function findIndex(id: number): number {
return users.findIndex((u) => u.id === id);
}
// ======= 公开 API =======
return {
add(name: string): { id: number; name: string } {
const user = { id: nextId++, name };
users.push(user);
return user;
},
remove(id: number): boolean {
const index = findIndex(id); // 调用私有方法
if (index === -1) return false;
users.splice(index, 1);
return true;
},
getAll(): { id: number; name: string }[] {
return [...users]; // 返回副本,防止外部修改
},
count(): number {
return users.length;
},
};
})();
// 使用
UserModule.add('Alice');
UserModule.add('Bob');
console.log(UserModule.count()); // 2
console.log(UserModule.getAll()); // [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
// console.log(UserModule.users); // ❌ undefined - 无法访问私有变量
// console.log(UserModule.findIndex); // ❌ undefined - 无法访问私有方法
揭示模块模式(Revealing Module Pattern) 是一种更清晰的变体:
const Calculator = (function () {
let result = 0;
// 所有方法先定义为私有函数
function add(n: number): void {
result += n;
}
function subtract(n: number): void {
result -= n;
}
function getResult(): number {
return result;
}
function reset(): void {
result = 0;
}
// 显式选择要暴露的方法
return { add, subtract, getResult, reset };
})();
模块模式与 ES Module 的对比:
| 特性 | IIFE 模块模式 | ES Module |
|---|---|---|
| 私有变量 | 通过闭包实现 | 未 export 的即为私有 |
| 静态分析 | 不支持 | 支持(Tree Shaking) |
| 循环依赖 | 需手动处理 | 引擎自动处理 |
| 加载方式 | 同步执行 | 异步加载 |
| 兼容性 | 所有环境 | 需要 ES6+ |
虽然 ES Module 已成为主流,但模块模式的闭包封装思想仍然广泛应用:
- Redux 的
createStore内部就是闭包封装 - jQuery 的
$就是 IIFE 模块模式 - 许多库的 UMD 打包格式仍使用 IIFE
- 面试中考察模块模式的核心目的是检验你对闭包封装的理解
Q10: 如何判断闭包是否导致内存泄漏?如何排查?
答案:
闭包本身不会导致内存泄漏,只有当闭包持有大量不再需要的数据的引用且无法被垃圾回收时,才会造成内存泄漏。
常见的闭包内存泄漏场景:
// 场景一:定时器中的闭包未清理
function startPolling(): void {
const hugeData = new Array(1_000_000).fill('leak');
setInterval(() => {
// hugeData 永远无法被回收,因为定时器一直在运行
console.log(hugeData.length);
}, 5000);
}
// 场景二:事件监听器中的闭包未移除
function bindEvent(): void {
const cache = new Map<string, any>();
window.addEventListener('resize', () => {
// cache 无法被回收,因为事件监听器一直存在
cache.set(Date.now().toString(), window.innerWidth);
});
}
// 场景三:被全局变量引用的闭包
const callbacks: (() => void)[] = [];
function registerCallback(): void {
const data = new Array(1_000_000).fill('leak');
callbacks.push(() => {
// data 无法被回收,因为 callbacks 数组一直持有引用
console.log(data.length);
});
}
排查步骤(使用 Chrome DevTools):
具体排查方法:
// 步骤:
// 1. DevTools → More Tools → Performance Monitor
// 2. 观察 JS Heap Size 曲线
// 3. 如果内存持续上升且不回落,说明存在泄漏
// 可以在代码中手动触发 GC 来辅助判断(仅调试用)
// DevTools Console 中勾选 "Expose garbage collection"
// 然后调用 gc() 后观察内存是否下降
// 步骤:
// 1. Memory 面板 → Take Heap Snapshot(基线快照)
// 2. 执行可疑操作
// 3. 再次 Take Heap Snapshot
// 4. 选择 Comparison 视图对比两次快照
// 5. 按 "Size Delta" 排序,找到增长最大的对象
// 6. 查看 Retainers 面板,追溯引用链找到闭包
修复闭包内存泄漏的最佳实践:
// ✅ 定时器:保存引用并及时清除
function startPolling(): () => void {
const data = new Array(1_000_000).fill('x');
const timerId = setInterval(() => {
console.log(data.length);
}, 5000);
// 返回清理函数
return () => clearInterval(timerId);
}
const stopPolling = startPolling();
stopPolling(); // 不需要时调用清理
// ✅ 事件监听:使用 AbortController 统一清理
function bindEvents(): () => void {
const controller = new AbortController();
const data = new Array(1_000_000).fill('x');
window.addEventListener(
'resize',
() => console.log(data.length),
{ signal: controller.signal } // 通过 signal 关联
);
window.addEventListener(
'scroll',
() => console.log(data.length),
{ signal: controller.signal }
);
return () => controller.abort(); // 一次性移除所有监听器
}
// ✅ 使用 WeakRef 避免强引用(ES2021+)
function createWeakClosure(): () => string | undefined {
let target = new Array(1_000_000).fill('x');
const weakRef = new WeakRef(target);
target = null as any; // 释放强引用
return (): string | undefined => {
const obj = weakRef.deref();
if (obj) {
return `数据长度: ${obj.length}`;
}
return undefined; // 对象已被 GC 回收
};
}
面试中可以用这个思路回答闭包内存泄漏排查:
- 定位:Performance Monitor 观察内存趋势是否持续增长
- 确认:Heap Snapshot 对比,找到未释放的大对象
- 追溯:Retainers 面板查看引用链,定位到具体闭包
- 修复:添加清理函数(clearInterval、removeEventListener、置 null)