JavaScript 作用域与闭包
作用域(scope)决定变量"在哪儿能被访问";闭包(closure)是函数"记住"自己诞生地的变量,即使那个地方已经离开。这两个概念是 JS 进阶的分水岭——理解了它们,你就跨过了新手的门槛。
1. 三种作用域
// JS 三种作用域:全局、函数、块级(ES6)
// 1. 全局作用域:在任何地方都能访问
const globalName = "我是全局";
// 2. 函数作用域:只在函数内有效
function test() {
const localName = "函数内的";
console.log(globalName); // ✅ 能访问外层
console.log(localName); // ✅
}
test();
// console.log(localName); // ❌ 函数外访问不到
// 3. 块级作用域(ES6 let/const):只在 {} 内有效
{
const blockName = "块内的";
}
// console.log(blockName); // ❌ ReferenceError
// var 没有块级作用域,会泄漏
if (true) {
var leaked = "泄漏了";
}
console.log(leaked); // ✅ "泄漏了"(var 的坑)一句话:let/const 是块级作用域(只在 {} 内),var 是函数作用域(无视 {}),全局变量到处都能访问。
2. 词法作用域(静态作用域)
JS 是词法作用域(也叫静态作用域):函数定义时就决定了它能访问哪些变量,跟在哪里被调用无关。这就像你的"出生地"决定了你的国籍,跟后来去哪儿无关。
// JS 是"词法作用域"(静态作用域)
// 函数的作用域在"定义时"就决定了,跟在哪调用无关
const x = "外层";
function outer() {
const x = "中层";
function inner() {
console.log(x); // "中层"(访问定义时的 x)
}
return inner;
}
const fn = outer();
fn(); // "中层"(虽然在外层调用,但 inner 记住的是定义时的作用域)
// 作用域链:由内向外找
const a = 1;
function f1() {
const b = 2;
function f2() {
const c = 3;
console.log(a, b, c); // 1 2 3(逐层往外找)
}
f2();
}
f1();函数找变量时,沿作用域链由内向外一层层找:先在自己内部找,找不到就去定义它的外层找,再找不到继续往外,直到全局。如果全局也没有,就报 ReferenceError。
3. 什么是闭包
闭包 = 函数 + 它访问的外层变量。当一个内部函数被拿到外面执行,却仍然能访问定义时的外层变量,就形成了闭包。
// 闭包:函数 + 它记住的外层变量
// 最简单的闭包:函数可以访问外层作用域的变量
function makeCounter() {
let count = 0; // 被闭包"捕获"的变量
return function () {
count++;
return count;
};
}
const c1 = makeCounter();
console.log(c1()); // 1
console.log(c1()); // 2
console.log(c1()); // 3
// 每次调用 makeCounter 都创建新的 count,互不影响
const c2 = makeCounter();
console.log(c2()); // 1(独立的计数器)
console.log(c1()); // 4(c1 不受影响)
// count 从外部访问不到,但 c1 内部一直"持有"它
// console.log(count); // ReferenceError关键现象:makeCounter() 执行完后,按理说它的局部变量 count 应该被销毁。但因为返回的函数引用了它,JS 引擎会保留 count 不销毁——这就是闭包的"记忆"效果。
每次调用 makeCounter() 都创建一个独立的 count,所以 c1 和 c2 互不影响。
4. 闭包实战:私有变量
JS 没有 private 关键字(类有 # 私有字段是 ES2022 才加的),闭包是经典实现私有变量的方式:
// 闭包实现"私有变量"(在没有 class 时的常用模式)
function createAccount(initialBalance) {
let balance = initialBalance; // 私有变量
return {
deposit(amount) {
balance += amount;
return balance;
},
withdraw(amount) {
if (amount > balance) {
console.log("余额不足");
return null;
}
balance -= amount;
return balance;
},
getBalance() {
return balance;
}
};
}
const acc = createAccount(100);
console.log(acc.getBalance()); // 100
acc.deposit(50);
console.log(acc.getBalance()); // 150
acc.withdraw(30);
console.log(acc.getBalance()); // 120
// acc.balance; // undefined(外部访问不到私有变量)外部只能通过 deposit、withdraw 这些"公开方法"操作 balance,无法直接修改。这种封装让代码更安全、更可控。
5. 模块模式
结合 IIFE(立即调用函数)和闭包,可以创建一个独立的命名空间,只暴露公开接口:
// 模块模式:用 IIFE + 闭包,创建独立命名空间
const Counter = (function () {
let privateCount = 0; // 私有
function changeBy(val) { // 私有方法
privateCount += val;
}
return {
increment() { changeBy(1); },
decrement() { changeBy(-1); },
value() { return privateCount; }
};
})();
Counter.increment();
Counter.increment();
console.log(Counter.value()); // 2
Counter.decrement();
console.log(Counter.value()); // 1
// Counter.changeBy(5); // 报错,私有方法访问不到
// Counter.privateCount; // undefined
// ES6 之后,用真正的模块(import/export)代替这是 ES6 模块(import/export)出现之前,JS 实现"模块化"的主流方式。现在新项目直接用 ES 模块,但维护老代码时仍会遇到。
6. 循环里的闭包陷阱
这是闭包最经典的面试题:
// 经典坑:循环里的闭包
// ❌ 用 var:所有闭包共享同一个 i
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出: 3 3 3(等定时器触发时,i 已经是 3)
// ✅ 解决方案 1:用 let(每轮都是新的 i)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出: 0 1 2
// ✅ 解决方案 2:用 IIFE 创建独立作用域
for (var j = 0; j < 3; j++) {
(function (k) {
setTimeout(() => console.log(k), 100);
})(j);
}
// 输出: 0 1 2为什么会输出 3 3 3:var 是函数作用域,整个循环共享同一个 i。等 setTimeout 100ms 后真正执行时,循环早已结束,i 已经是 3。三个回调函数闭包到的是同一个 i。
let 的解法:每轮循环 let 都创建一个新的 i,每个闭包捕获的是不同值。这是现代 JS 推荐写法。
7. 闭包的应用场景
- 私有变量:隐藏数据,只暴露方法。
- 回调/事件处理:所有 setTimeout、addEventListener、Promise 的回调,本质都是闭包。
- 函数柯里化:返回一系列预填参数的函数。
- 记忆化(memoization):缓存函数的计算结果。
- 防抖/节流:用闭包存定时器状态。
8. 闭包的代价
闭包会让外层变量不被垃圾回收,如果滥用会导致内存占用变高。所以:
- 用完的闭包及时解除引用(置
null),让 GC 回收。 - 不要在循环里创建过多闭包,小心内存。
- 大对象不要被闭包意外捕获。
// 函数防抖(debounce):经典闭包应用
function debounce(fn, delay) {
let timer = null; // 闭包里持有定时器
return function (...args) {
clearTimeout(timer); // 清掉上次的
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const log = debounce((msg) => console.log(msg), 300);
// 连续调用只执行最后一次
log("a");
log("b");
log("c"); // 300ms 后输出 c小结
- 作用域决定变量在哪儿能访问:全局、函数、块级。
- JS 是词法作用域,函数定义时作用域就定了。
- 闭包 = 函数 + 它记住的外层变量。
- 闭包用于私有变量、模块、回调、防抖等。
- 注意闭包的内存代价,用完释放。
下一篇我们看 JS 的对象——键值对的集合,日常开发用得最多的数据结构。
← 上一篇 JavaScript 函数
下一篇 JavaScript 对象 →