在 JavaScript 的世界中,作用域(Scope) 与 闭包(Closure) 是两个核心但又常被误解的概念。它们不仅是语言设计的精妙之处,更是前端工程师必须掌握的底层逻辑。无论是日常开发中的异步回调、模块封装,还是大厂面试中的高频考点,闭包都无处不在。而 ES6 引入的 let 关键字,更是与闭包产生了奇妙的化学反应——它让原本“失控”的循环变量变得可控,让闭包行为更加符合直觉。
那么,什么是闭包?为什么 for (var i = 1; i <= 5; i++) { setTimeout(() => console.log(i), 100) } 会输出 6 个 6,而用 let 就能输出 1 到 5?闭包是如何“记住”变量的?它真的会引发内存泄漏吗?本文将系统梳理 作用域与闭包的核心知识点,结合经典案例与大厂面试题,带你彻底掌握这一 JavaScript 的灵魂机制。
一、什么是闭包?
闭包(Closure)是指:一个函数能够记住并访问其词法作用域(Lexical Scope),即使该函数在其词法作用域之外执行。
1.1 词法作用域是基础
JavaScript 采用词法作用域(也叫静态作用域),意味着函数的作用域在代码书写时就已确定,而非运行时动态决定。
js
编辑
function foo() {
var a = 2;
function bar() {
console.log(a); // bar 能访问 foo 的变量 a
}
return bar;
}
var baz = foo();
baz(); // 输出 2
bar在foo内部定义,因此它能访问foo的作用域。- 即使
foo执行完毕,bar被返回并在外部调用,它依然能访问a。 - 这种“跨越作用域边界”的能力,就是闭包。
1.2 闭包的本质:函数 + 作用域链
当函数被创建时,它会捕获当前的作用域环境,形成一个“闭包包”。这个包包含:
- 函数自身的代码
- 对外部作用域中变量的引用(不是复制!)
因此,闭包不是某个特殊语法,而是 JavaScript 函数作用域机制的自然结果。
二、闭包的经典场景
2.1 异步回调中的闭包
js
编辑
function wait(message) {
setTimeout(function timer() {
console.log(message); // message 被闭包“记住”
}, 1000);
}
wait("hello, closure!");
wait执行完后,message按理应被销毁。- 但
timer函数通过闭包保留了对message的引用,1秒后仍能正确输出。
2.2 模块模式(模拟私有变量)
js
编辑
function createCounter() {
let count = 0;
return {
increment: () => ++count,
decrement: () => --count,
getCount: () => count
};
}
const counter = createCounter();
counter.increment(); // 1
counter.getCount(); // 1
// 外部无法直接访问 count,实现“私有”
这是闭包在封装和数据隐藏中的典型应用。
三、循环与闭包:经典陷阱与解决方案
3.1 问题:var 导致的“变量共享”
js
编辑
for (var i = 1; i <= 5; i++) {
setTimeout(() => {
console.log(i); // 输出 6, 6, 6, 6, 6
}, 100);
}
原因分析:
var声明的i属于函数作用域(或全局),整个循环共享同一个i。setTimeout是异步的,等到回调执行时,循环早已结束,i = 6。- 所有回调函数通过闭包引用的是同一个
i,因此都输出 6。
3.2 解决方案一:IIFE(立即执行函数表达式)
js
编辑
for (var i = 1; i <= 5; i++) {
(function(j) {
setTimeout(() => {
console.log(j); // 输出 1, 2, 3, 4, 5
}, 100);
})(i);
}
- 每次循环创建一个新作用域,
j是该作用域的局部变量。 - 每个
setTimeout回调闭包捕获的是各自的j。
3.3 解决方案二:使用 let(推荐)
js
编辑
for (let i = 1; i <= 5; i++) {
setTimeout(() => {
console.log(i); // 输出 1, 2, 3, 4, 5
}, 100);
}
为什么 let 能解决?
let具有块级作用域。- 关键机制:在
for循环中,let会在每次迭代时创建一个新的绑定(binding) 。 - 每个
setTimeout回调闭包捕获的是当前迭代的i副本,而非共享变量。
✅ 这就是
let与闭包的“联手”:let为每次循环创建独立作用域,闭包则精准捕获该作用域中的值。
四、闭包的注意事项
4.1 内存泄漏?
闭包会延长变量的生命周期,但这不等于内存泄漏。现代 JavaScript 引擎(如 V8)能智能回收不再被引用的闭包环境。
只有当闭包意外持有大对象且长期不释放时,才可能造成内存问题。
4.2 性能影响?
闭包会增加作用域链的查找层级,但现代引擎已高度优化,正常使用无需担心性能。
五、大厂面试题精选
面试题 1:解释以下代码输出
js
编辑
var a = [];
for (var i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
};
}
a[6](); // 输出什么?
答案: 10
解析: 所有函数共享同一个 i,循环结束后 i = 10。
面试题 2:如何修改上题,使 a[6]() 输出 6?
答案(两种):
js
编辑
// 方案1:IIFE
for (var i = 0; i < 10; i++) {
a[i] = (function(j) {
return function() { console.log(j); };
})(i);
}
// 方案2:let
for (let i = 0; i < 10; i++) {
a[i] = function() { console.log(i); };
}
面试题 3:实现一个缓存函数(记忆化)
js
编辑
function memoize(fn) {
const cache = {};
return function(...args) {
const key = JSON.stringify(args);
if (key in cache) return cache[key];
return cache[key] = fn.apply(this, args);
};
}
考察点: 闭包用于封装私有缓存对象。
六、总结
- 闭包 = 函数 + 词法作用域的引用
- 它让函数能“记住”定义时的环境,是 JavaScript 异步、模块化、回调等特性的基石。
var在循环中因作用域问题导致闭包“失效”,而let通过块级作用域为每次迭代创建独立绑定,完美解决该问题。- 闭包不是 bug,而是特性;合理使用可提升代码封装性与可维护性。
记住:闭包不是你主动“写”出来的,而是你写函数时,JavaScript 自动为你“生成”的。
掌握作用域与闭包,你就掌握了 JavaScript 的灵魂。下次面试官问:“说说你对闭包的理解”,你可以自信地说: “闭包,是我最熟悉的陌生人。”