一、什么是闭包?------从直观现象入手
1.1 一个经典例子
先看一段代码:
scss
function outer() {
let count = 0;
function inner() {
count++;
console.log(count);
}
return inner;
}
const counter = outer();
counter(); // 输出: 1
counter(); // 输出: 2
counter(); // 输出: 3
这里发生了什么?
outer函数执行完毕后,按理说其内部变量count应该被销毁。- 但通过
counter()调用inner函数时,count不仅存在,还能被修改并保留状态。
这种 "函数即使在其词法作用域外被调用,仍能访问并操作其创建时所在作用域中的变量" 的现象,就是闭包。
1.2 官方定义
MDN 对闭包的定义是:
"闭包是指那些能够访问自由变量的函数。自由变量是指在函数中使用,但既不是函数参数也不是函数局部变量的变量。"
更通俗地说:闭包 = 函数 + 其创建时所处的词法环境(Lexical Environment)的引用。
二、理解基础:作用域与词法环境
要真正理解闭包,必须先掌握 JavaScript 的作用域机制。
2.1 作用域(Scope)
作用域决定了变量的可访问范围。JavaScript 采用 词法作用域(Lexical Scoping) ,即变量的作用域在代码编写时就已确定,而非运行时。
scss
let a = 1;
function foo() {
console.log(a); // 会输出 1,因为 foo 定义在全局作用域内
}
function bar() {
let a = 2;
foo(); // 仍然输出 1!不是 2
}
bar();
尽管 foo 是在 bar 内部调用的,但它访问的是定义时 所在的作用域(全局),而非调用时的作用域。这就是词法作用域的核心。
2.2 词法环境(Lexical Environment)
ES6 规范引入了 词法环境(Lexical Environment) 来精确描述作用域。
每个词法环境包含两个部分:
- 环境记录(Environment Record) :存储变量和函数的映射(如
{ count: 0 }) - 对外部词法环境的引用(Outer Environment Reference) :指向父级作用域
当函数被创建时,它会捕获(capture) 当前的词法环境,并将其保存在内部属性 [[Environment]] 中。
✅ 关键点 :闭包的本质,就是函数通过
[[Environment]]引用"记住"了它出生时的环境。
三、闭包的形成机制:内存模型解析
让我们通过内存模型,可视化闭包的形成过程。
3.1 执行上下文与作用域链
当 JavaScript 引擎执行代码时,会为每个函数调用创建一个 执行上下文(Execution Context) ,其中包含:
- 变量对象(Variable Object)
- 作用域链(Scope Chain)
- this 绑定
作用域链是一个从当前作用域逐级向上查找的链表,直到全局作用域。
3.2 闭包的内存结构
以之前的 counter 为例:
lua
function outer() {
let count = 0; // 存储在 outer 的词法环境中
function inner() { // inner 的 [[Environment]] 指向 outer 的词法环境
count++;
console.log(count);
}
return inner;
}
当 outer() 执行时:
- 创建
outer的执行上下文,初始化count = 0 - 定义
inner函数,其内部属性[[Environment]]指向outer的词法环境 - 返回
inner函数引用
当 outer() 执行完毕:
outer的执行上下文被弹出调用栈- 但
inner仍持有对outer词法环境的引用 - 因此,
count不会被垃圾回收,继续存在于内存中
📌 重要结论:
闭包导致外部函数的变量不会被释放,直到闭包本身不再被引用。
3.3 多个闭包共享同一环境
javascript
function createCounter() {
let count = 0;
return {
increment: () => ++count,
decrement: () => --count,
value: () => count
};
}
const counter = createCounter();
console.log(counter.value()); // 0
counter.increment();
console.log(counter.value()); // 1
counter.decrement();
console.log(counter.value()); // 0
这里 increment、decrement、value 三个函数都形成了闭包,共享同一个 count 变量 。它们的 [[Environment]] 都指向 createCounter 的词法环境。
四、常见误区与陷阱
4.1 误区一:"只有返回函数才算闭包"
错误! 任何函数只要访问了其外部作用域的变量,就形成了闭包,无论是否被返回。
ini
let globalVar = 'global';
function outer() {
let outerVar = 'outer';
function inner() {
console.log(globalVar, outerVar); // 访问了外部变量 → 闭包
}
inner(); // 即使没有返回,inner 也是闭包
}
outer();
4.2 误区二:"闭包会导致内存泄漏"
不完全正确。 闭包确实会延长变量的生命周期,但这不是内存泄漏,而是预期行为。
真正的内存泄漏是指:无用的数据因错误引用而无法被垃圾回收。
例如:
javascript
function setup() {
const largeData = new Array(1000000).fill('*');
document.getElementById('button').onclick = function() {
console.log('Clicked');
// 即使没用到 largeData,闭包仍会持有它!
};
}
这里点击事件处理函数形成了闭包,无意中持有了 largeData 的引用,导致本可释放的大数组一直驻留内存。
解决方案:显式断开引用
javascript
function setup() {
const largeData = new Array(1000000).fill('*');
document.getElementById('button').onclick = function() {
console.log('Clicked');
};
// 不再需要 largeData
largeData = null;
}
4.3 经典陷阱:循环中的闭包
css
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出: 3, 3, 3
}, 100);
}
原因 :var 声明的 i 是函数作用域,所有闭包共享同一个 i。当 setTimeout 执行时,循环早已结束,i = 3。
解决方案:
方案一:使用 let(块级作用域)
css
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出: 0, 1, 2
}, 100);
}
let 为每次迭代创建新的绑定,每个闭包捕获的是不同的 i。
方案二:IIFE(立即调用函数表达式)
javascript
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => {
console.log(j); // 输出: 0, 1, 2
}, 100);
})(i);
}
方案三:bind 传参
css
for (var i = 0; i < 3; i++) {
setTimeout(console.log.bind(null, i), 100);
}
五、闭包的实际应用场景
5.1 模块模式(Module Pattern)
利用闭包实现私有变量和公共接口:
javascript
const CounterModule = (function() {
let count = 0; // 私有变量
return {
increment: () => ++count,
decrement: () => --count,
getCount: () => count
};
})();
// 外部无法直接访问 count
console.log(CounterModule.getCount()); // 0
CounterModule.increment();
console.log(CounterModule.getCount()); // 1
这是 ES6 模块出现前最流行的封装方式。
5.2 函数柯里化(Currying)
javascript
function multiply(a) {
return function(b) {
return a * b;
};
}
const double = multiply(2);
console.log(double(5)); // 10
// 或使用箭头函数
const multiply = a => b => a * b;
每个返回的函数都闭包了 a 的值。
5.3 防抖(Debounce)与节流(Throttle)
javascript
function debounce(func, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
const debouncedSearch = debounce(searchAPI, 300);
input.addEventListener('input', debouncedSearch);
debounce 返回的函数闭包了 timeoutId 和 func,实现了状态保持。
5.4 事件处理器中的参数传递
javascript
function attachListeners() {
const buttons = document.querySelectorAll('.btn');
buttons.forEach((button, index) => {
button.addEventListener('click', function() {
console.log(`Button ${index} clicked`); // 闭包捕获 index
});
});
}
若不用闭包,很难在事件回调中获取循环索引。
5.5 缓存(Memoization)
javascript
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const fib = memoize(function(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
});
缓存对象 cache 被闭包保护,避免全局污染。
六、闭包与 this 的交互
闭包会捕获变量,但不会捕获 this。this 的绑定取决于调用方式。
javascript
const obj = {
name: 'Alice',
greet: function() {
const sayHello = function() {
console.log(this.name); // undefined! this 指向全局
};
sayHello();
}
};
obj.greet();
解决方案:
使用箭头函数(继承外层 this)
javascript
const obj = {
name: 'Alice',
greet: function() {
const sayHello = () => {
console.log(this.name); // 'Alice'
};
sayHello();
}
};
显式绑定
javascript
const obj = {
name: 'Alice',
greet: function() {
const self = this; // 闭包捕获 self
const sayHello = function() {
console.log(self.name); // 'Alice'
};
sayHello();
}
};
七、性能考量与最佳实践
7.1 内存占用
闭包会阻止变量被垃圾回收,因此:
- 避免不必要的闭包:如果函数不需要访问外部变量,不要嵌套定义
- 及时释放大对象引用 :如前述
largeData = null的例子
7.2 调试困难
闭包中的变量在调试器中可能显示为 [[Scopes]],不易查看。建议:
- 使用有意义的变量名
- 避免过深的嵌套
7.3 最佳实践总结
- 理解作用域链:清楚知道变量从哪里来
- 谨慎使用闭包:只在需要保持状态或封装私有数据时使用
- 注意循环陷阱 :优先使用
let而非var - 管理内存:及时解除对大型数据的引用
- 利用现代语法 :箭头函数简化
this问题
八、闭包在现代 JavaScript 中的演进
8.1 与块级作用域的协同
ES6 的 let/const 与闭包结合,解决了经典循环问题,使代码更安全。
8.2 与模块系统的融合
ES6 模块(ESM)本质上是顶级闭包:
javascript
// math.js
let privateVar = 0; // 模块作用域,外部不可见
export function increment() {
return ++privateVar; // 闭包访问 privateVar
}
每个模块文件形成独立作用域,天然支持私有状态。
8.3 在 React Hooks 中的应用
React 的 useState、useEffect 等 Hook 依赖闭包实现状态管理:
scss
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1); // 闭包捕获 setCount
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖项为空,只在挂载时执行
return <div>{count}</div>;
}
若在 setInterval 回调中直接使用 count,会因闭包捕获旧值导致 bug,因此需使用函数式更新。