作用域和闭包是 JavaScript 中最核心也是最容易被误解的概念。理解它们,你才能真正驾驭这门语言。本文将带你从执行上下文开始,逐步深入词法作用域、作用域链,并通过数十个代码示例彻底搞懂闭包的机制、应用场景以及内存管理。无论你是正在准备面试,还是希望写出更健壮的代码,这篇文章都值得花35分钟细细品味。
什么是作用域?
作用域决定变量和函数的可访问范围。JavaScript 采用词法作用域(静态作用域),即函数的作用域在函数定义时就决定了。
var globalVar = "我在任何地方都能访问";
// 函数作用域
function demo() {
var funcVar = "只能在函数内访问";
console.log(globalVar); // ✅ 可访问
}
console.log(funcVar); // ❌ ReferenceError
// 块级作用域 (ES6)
if (true) {
let blockVar = "ES6 块级";
const alsoBlock = "常量也是块级";
var stillGlobal = "var 没有块级作用域";
}
console.log(stillGlobal); // ✅ "var 没有块级作用域"
console.log(blockVar); // ❌ ReferenceError
全局 : 生命周期贯穿整个程序,挂载在window/global。
函数 : 每次函数调用创建,参数和内部var/function声明。
块级 : let / const 在 { } 内形成,循环、条件中尤其重要。
词法作用域 & 作用域链
函数的作用域在书写时确定,内部函数可以访问外部函数的变量,这种嵌套关系形成作用域链。
var a = 1;
function outer() {
var b = 2;
function inner() {
var c = 3;
console.log(a, b, c); // 1 2 3
}
inner();
}
outer();
// 作用域链查找:从当前作用域向上直到全局
var x = 'global';
function f1() {
var x = 'f1';
function f2() {
console.log(x); // 'f1' (不是global)
}
f2();
}
f1();
⬆️ 变量访问沿着链条向上查找,直到找到或报错。
执行上下文 & 活动对象
每次执行函数都会创建新的执行上下文,包含变量对象(VO/活动对象AO)、作用域链和this。
// 全局上下文
GlobalExecutionContext = {
VO: { a: 1, fn: <reference> },
scopeChain: [GlobalContext.VO],
this: window
}
// outer函数被调用时
outerContext = {
AO: { b: 2, inner: <reference> },
scopeChain: [outerContext.AO, GlobalContext.VO],
this: ...
}
程序启动: 推入全局上下文。
调用outer: 推入outer上下文。
调用inner: 推入inner上下文。
inner返回后弹出,outer返回后弹出,最后只剩全局。
闭包 (Closure) —— 定义与原理
闭包是指内部函数可以访问外部函数作用域的变量,即使外部函数已经执行完毕。JavaScript 函数都会形成闭包,但通常我们关注被返回或传递的函数。
function createCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
// createCounter 执行完后,它的 AO 本该销毁,但因为被内部函数引用,依然存活。
- 函数嵌套函数
- 内部函数引用外部变量
- 内部函数在外部函数之外被调用(或保持可访问)
应用一:私有变量与模块模式
利用闭包可以创建真正的私有成员,这是模块模式的基础。
const bankAccount = (function() {
let balance = 0; // 私有
return {
deposit(amount) { balance += amount; },
getBalance() { return balance; }
};
})();
bankAccount.deposit(100);
console.log(bankAccount.getBalance()); // 100
console.log(bankAccount.balance); // undefined
// 现代模块模式 (ES6 之前)
var myModule = (function() {
var privateVar = 'secret';
function privateMethod() {}
return { publicMethod: function() {} };
})();
外部无法直接访问 balance,只能通过暴露的接口操作。数据封装、隐藏实现细节。
应用二:回调、事件与防抖节流
闭包在异步编程中无处不在,用于保存状态。
function debounce(fn, delay) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 使用闭包保存循环变量
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => console.log(j), 100); // 0,1,2
})(i);
}
每次循环的匿名函数都通过闭包“记住”了当时的 i 值(参数 j)。
let 则直接在块级作用域解决了这个问题。
经典闭包陷阱:循环中的var
一个让无数开发者头疼的问题,以及它的多种解决方案。
for (var i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(i); // 输出 4,4,4 (循环结束后i=4)
}, 100);
}
// 解法1: 闭包 + 立即执行函数
for (var i = 1; i <= 3; i++) {
(function(j) {
setTimeout(() => console.log(j), 100);
})(i);
}
// 解法2: 使用 let (块级作用域)
for (let i = 1; i <= 3; i++) {
setTimeout(() => console.log(i), 100); // 1,2,3
}
var 没有块级作用域,所有 setTimeout 共享同一个 i。闭包通过函数参数保存每次的副本。
let 在每次迭代都会创建一个新的绑定,更简洁。
闭包与内存管理
闭包会阻止垃圾回收,因为外部变量仍然被内部函数引用。使用不当会造成内存泄漏。
function heavy() {
let bigData = new Array(1000000).fill('*');
return function() {
console.log('闭包引用了 bigData');
};
}
const leak = heavy(); // bigData 永远不会被释放
// 手动解除
leak = null; // 现在 bigData 可以被回收
// 避免不必要的闭包引用
function good() {
let data = 'useful';
return function() { console.log(data); };
} // 如果 data 很小且必要,则合理
- 闭包只引用必要的变量,避免无意中引用大对象
- 及时将不需要的闭包置为 null
- 使用弱映射
WeakMap存储与对象关联的数据
ES6+ 对作用域的增强
let/const、箭头函数、块级作用域大大简化了闭包的使用场景。
function Person(name) {
this.name = name;
setTimeout(() => {
console.log(this.name); // 箭头函数,this 指向 Person 实例
}, 100);
}
// 块级作用域简化闭包
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), i*100); // 0,1,2,3,4
}
let/const,结合箭头函数,代码更简洁且闭包陷阱更少。
调试闭包:Chrome DevTools
在浏览器中观察闭包非常直观,可以查看 [[Scopes]] 属性。
function makeAdder(x) {
return function(y) {
debugger; // 在此处暂停
return x + y;
};
}
const add5 = makeAdder(5);
add5(3);
// 在 Sources 面板右侧 Scope 中可以看到 Closure (makeAdder) 包含 x:5
在 console 中直接输入函数名,点击展开 [[Scopes]] 可以看到闭包持有的变量。
总结与核心要点
- 作用域分为全局、函数、块级;JavaScript 采用词法作用域。
- 每次函数执行创建新的执行上下文,作用域链由当前AO和所有父级AO组成。
- 闭包 = 函数 + 函数定义时的词法环境。它使得内部函数可以访问外部变量,即使外部函数已返回。
- 闭包用途广泛:私有变量、模块化、回调、函数柯里化、防抖节流等。
- 注意闭包可能引起内存泄漏,合理管理引用,及时置 null。
- ES6 的 let/const 极大简化了块级作用域和循环闭包问题。