作用域与闭包
作用域决定了变量在哪里可见、在哪里可以访问;闭包则让函数记住并访问其定义时的作用域。二者是理解 JavaScript 运行机制的关键,也是面试中出现频率极高的主题。本文从作用域的类型讲起,逐步深入闭包的原理、应用与内存注意事项。
一、作用域的类型
JavaScript 中有三种作用域:
| 作用域 | 产生方式 | 说明 |
|---|---|---|
| 全局作用域 | 脚本最外层 | 任何地方都能访问,浏览器中是 window,Node.js 中是 global |
| 函数作用域 | 每个函数体 | 函数内部声明的变量只能在本函数内访问 |
| 块级作用域 | {} 包裹的代码块 | 由 let/const 声明,只在该块内有效 |
const globalVar = "我是全局变量"; // 全局作用域
function outer() {
const funcVar = "我是函数变量"; // 函数作用域
if (true) {
let blockVar = "我是块级变量"; // 块级作用域
console.log(blockVar); // 块内可访问
}
// console.log(blockVar); // ReferenceError: blockVar is not defined
}
outer();
// console.log(funcVar); // ReferenceError: funcVar is not defined二、var 的函数作用域与 let/const 的块级作用域
var 声明的变量没有块级作用域,只受函数作用域约束;let 和 const 则严格遵循块级作用域:
if (true) {
var a = 1; // var 穿透块
let b = 2; // let 只在块内
const c = 3; // const 只在块内
}
console.log(a); // 1,var 穿透了 if 块
console.log(b); // ReferenceError
console.log(c); // ReferenceErrorfor (var i = 0; i < 3; i++) {
// 空循环体
}
console.log(i); // 3,var 泄漏到循环外
for (let j = 0; j < 3; j++) {
// 空循环体
}
// console.log(j); // ReferenceError,let 不泄漏| 声明方式 | 作用域 | 提升 | 可重复声明 | 暂存死区 |
|---|---|---|---|---|
var | 函数作用域 | 提升为 undefined | 可以 | 无 |
let | 块级作用域 | 提升但不初始化 | 不可以 | 有 |
const | 块级作用域 | 提升但不初始化 | 不可以 | 有(且必须初始化) |
三、作用域链查找机制
当代码访问一个变量时,引擎会从当前作用域开始逐层向外查找,直到全局作用域,这条链路就是作用域链。找到即停止;全局也没有则报 ReferenceError。
const x = "全局 x";
function outer() {
const x = "outer 的 x";
function inner() {
const y = "inner 的 y";
console.log(x); // outer 的 x:当前没有,向上找到 outer
console.log(y); // inner 的 y:当前作用域就有
}
inner();
}
outer();查找过程示意:
inner 作用域:y ✔(命中,停止)
↓ 未命中时向上
outer 作用域:x ✔(命中,停止)
↓ 未命中时向上
全局作用域:x、window/global作用域链在函数定义时就已确定,与函数在哪里被调用无关,这是下一节"词法作用域"的核心。
四、词法作用域(静态作用域)
JavaScript 采用词法作用域(也叫静态作用域):变量的可见性由代码书写位置决定,而不是调用位置。内层函数可以访问外层函数声明的变量,但反过来不行。
const name = "全局";
function a() {
console.log(name); // 全局(a 的定义处向外找)
}
function b() {
const name = "b 内部";
a(); // 调用 a,但 a 的词法作用域是全局,不访问 b 的 name
}
b(); // 全局注意 a 打印的是"全局"而不是"b 内部",因为 a 定义在全局,它的作用域链在定义时就固定了。这也是"函数声明位置决定作用域"的含义。
五、变量提升与暂时性死区
5.1 var 的提升
var 声明会被提升到所在函数(或全局)作用域顶部,但赋值不提升,因此声明前访问得到 undefined:
console.log(v); // undefined,声明已提升,赋值未提升
var v = 10;等价于:
var v;
console.log(v);
v = 10;5.2 let/const 与暂时性死区(TDZ)
let/const 同样会被提升,但进入作用域后到声明语句之间形成暂时性死区(Temporal Dead Zone),在此期间访问变量会抛出 ReferenceError:
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 1;// 更隐蔽的例子:typeof 在 TDZ 中同样报错
console.log(typeof y); // ReferenceError
let y = 1;| 对比项 | var | let/const |
|---|---|---|
| 提升 | 声明提升,初始化为 undefined | 声明提升,但不初始化 |
| TDZ | 无 | 有(声明前访问报错) |
| 声明前访问 | 得到 undefined | 抛 ReferenceError |
5.3 函数声明与提升
函数声明整体提升,可先调用后声明:
sayHi(); // 你好
function sayHi() {
console.log("你好");
}函数表达式只有变量名提升,赋值前调用报错:
sayBye(); // TypeError: sayBye is not a function
const sayBye = function () {
console.log("再见");
};六、闭包的定义与原理
6.1 什么是闭包
**闭包(Closure)**是"函数与其词法环境的组合"。当一个函数引用了其外部作用域的变量,并且这个函数被保留下来(例如被返回、被存入数组或对象),就形成了闭包。被引用的外层变量不会随外层函数执行结束而销毁,而是被"记住"了。
function createCounter() {
let count = 0; // 被 inner 引用的"自由变量"
return function () {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3createCounter 执行结束后,其局部变量 count 本应销毁,但由于返回的内部函数仍持有对它的引用,count 被保留在闭包环境中,每次调用都能继续累加。
6.2 原理:函数 + 词法环境
引擎为每个函数保存一条指向定义时环境的引用。内部函数调用时,除了自己的作用域,还会通过这条引用访问外层环境中的变量,形成作用域链。只要内部函数还存在,它引用的外部变量就不会被垃圾回收。
七、闭包的常见应用
7.1 私有变量
用闭包模拟"私有成员",外部只能通过暴露的接口操作数据:
function createBankAccount(initial) {
let balance = initial;
return {
deposit(amount) {
balance += amount;
return balance;
},
withdraw(amount) {
if (amount > balance) return "余额不足";
balance -= amount;
return balance;
},
getBalance() {
return balance;
},
};
}
const account = createBankAccount(100);
account.deposit(50); // 150
account.withdraw(30); // 120
console.log(account.getBalance()); // 120
console.log(account.balance); // undefined,外部无法直接读取7.2 计数器
每次调用独立计数,互不干扰:
function makeCounter(start = 0) {
let value = start;
return {
next: () => ++value,
reset: () => (value = start),
};
}
const a = makeCounter(10);
const b = makeCounter();
console.log(a.next()); // 11
console.log(a.next()); // 12
console.log(b.next()); // 1,a 与 b 各自持有独立的闭包环境7.3 防抖与节流
防抖:连续触发只在最后一次触发后等待 delay 毫秒再执行。节流:固定时间间隔内最多执行一次。二者都依赖闭包保存定时器状态:
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function throttle(fn, interval = 300) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
// 用法示例(如搜索输入框)
// input.addEventListener("input", debounce(onSearch, 500));
// window.addEventListener("resize", throttle(onResize, 200));7.4 柯里化工厂
闭包让"预设参数"成为可能,从而批量生成专用函数:
function multiply(a) {
return function (b) {
return a * b;
};
}
const double = multiply(2);
const triple = multiply(3);
console.log(double(5)); // 10
console.log(triple(5)); // 157.5 单例模式
闭包保存实例引用,保证多次调用返回同一个对象:
const getSingleton = (function () {
let instance = null;
return function () {
if (!instance) {
instance = { id: Math.random(), createdAt: Date.now() };
}
return instance;
};
})();
const s1 = getSingleton();
const s2 = getSingleton();
console.log(s1 === s2); // true7.6 循环中捕获变量
闭包常在循环中"记住"每次迭代的值(经典陷阱见第九节):
for (var i = 0; i < 3; i++) {
(function (n) {
setTimeout(() => console.log(n), 0); // 0 1 2,通过立即执行函数固化参数
})(i);
}八、闭包与内存
8.1 闭包会泄漏吗
闭包本身不是内存泄漏,只是让被引用的变量生命周期延长——只要闭包存在,其词法环境就存在,这是正常行为。真正的问题是不必要地长期持有闭包导致内存无法回收:
function heavyTask() {
const bigData = new Array(1000000).fill("x"); // 大对象
return function () {
console.log(bigData.length);
};
}
const keep = heavyTask(); // keep 长期持有 bigData,1MB 内存无法释放8.2 如何释放
当不再需要闭包时,把引用置空即可,之后垃圾回收会回收整个词法环境:
let handler = heavyTask();
handler(); // 使用
handler = null; // 释放闭包,bigData 可被回收合理使用闭包不会造成问题;需要警惕的是把大对象放进闭包后又长期持有闭包(如永不卸载的事件监听、缓存未清理),这属于"意外持有的引用"型泄漏。
九、经典循环陷阱:var 与 let
这是闭包章节最经典的例子。使用 var 时,所有回调共享同一个 i,循环结束后 i 已是最终值:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出 3 3 3(共享同一个 var i,循环结束时 i = 3)使用 let 时,每次迭代都会创建独立的绑定,回调捕获各自的值:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出 0 1 2(let 每次迭代产生新的词法环境)三种修复 var 陷阱的等价方式:
// 方式一:用 let 声明(最推荐)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 方式二:IIFE 固化参数
for (var i = 0; i < 3; i++) {
(function (n) {
setTimeout(() => console.log(n), 0);
})(i);
}
// 方式三:闭包工厂
function capture(n) {
return function () {
console.log(n);
};
}
for (var i = 0; i < 3; i++) {
setTimeout(capture(i), 0);
}三种方式都输出 0 1 2。理解这个陷阱,等于同时理解了块级作用域、变量提升与闭包三个概念的综合作用。
作用域与闭包是 JavaScript 进阶的分水岭:掌握作用域链能读懂"为什么变量能访问",掌握闭包则能写出封装性强、状态可控的代码,同时避开内存与循环陷阱。