作用域与闭包
What — 是什么
作用域(Scope)决定了变量的可访问范围;闭包(Closure)是函数与其词法环境的组合,使内部函数可以访问外部函数的变量即使外部函数已返回。
核心概念:
- 词法作用域:函数定义时确定作用域,不是调用时
- 三种作用域:全局作用域、函数作用域、块级作用域(
let/const) - 闭包:函数 + 其创建时的词法环境
- 作用域链:变量查找沿作用域链向上查找直到全局
关键特性:
var声明提升(hoisting),函数作用域let/const块级作用域,暂时性死区(TDZ)- 闭包变量是引用,不是快照
执行上下文与作用域的关系
JavaScript 代码执行时,引擎会创建执行上下文(Execution Context),它是作用域的运行时载体。每一段可执行代码都会对应一个执行上下文,作用域链正是在执行上下文的创建阶段构建完成的。
三种执行上下文:
| 类型 | 创建时机 | 绑定的作用域 | 特点 |
|---|---|---|---|
| 全局执行上下文 | 脚本首次执行 | 全局作用域 | 只有一个,this 指向 window/globalThis |
| 函数执行上下文 | 函数被调用时 | 函数作用域 | 每次调用创建新的,调用结束出栈 |
| Eval 执行上下文 | eval() 执行时 | Eval 作用域 | 有独立作用域,严格模式下不可访问外层 var |
执行上下文的生命周期:
┌──────────────────────────────────────────────────┐
│ 执行上下文生命周期 │
├──────────────────┬───────────────────────────────┤
│ 创建阶段 │ 1. 创建变量对象(VO) │
│ (Creation) │ 2. 构建作用域链(Scope Chain) │
│ │ 3. 确定 this 指向 │
├──────────────────┼───────────────────────────────┤
│ 执行阶段 │ 1. 变量赋值 │
│ (Execution) │ 2. 函数引用 │
│ │ 3. 执行其他代码 │
├──────────────────┼───────────────────────────────┤
│ 回收阶段 │ 1. 执行上下文出栈 │
│ (Cleanup) │ 2. 变量对象等待 GC 回收 │
└──────────────────┴───────────────────────────────┘
// 执行上下文栈的变化过程
var a = 1; // ① 全局执行上下文入栈
function foo() { // ② 全局上下文中函数声明提升
var b = 2; // ④ foo 执行上下文入栈
bar(); // ⑤ bar 执行上下文入栈
// ⑧ bar 执行上下文出栈
} // ⑨ foo 执行上下文出栈
function bar() { // ③ 全局上下文中函数声明提升
var c = 3; // ⑥ bar 执行上下文创建
console.log(a + b + c); // ⑦ 沿作用域链查找 a、b、c
}
foo(); // 触发 ④-⑨
// ⑩ 全局执行上下文出栈(程序结束)
// 执行上下文栈(Call Stack)变化:
// [全局] → [全局, foo] → [全局, foo, bar] → [全局, foo] → [全局]
变量对象(VO)与活动对象(AO)
在执行上下文的创建阶段,引擎会创建变量对象(Variable Object, VO),用于存储该上下文中的所有变量和函数声明。当执行上下文进入执行阶段后,变量对象就变成了活动对象(Activation Object, AO)——此时变量已被赋值,函数可以被调用。
VO/AO 的创建规则:
- 创建 arguments 对象(仅函数上下文)
- 检查函数声明:在 VO 中创建属性,值为函数引用(函数声明提升)
- 检查变量声明:在 VO 中创建属性,值为
undefined(变量声明提升,let/const除外)
// VO/AO 创建过程示例
function foo(a, b) {
var c = 10;
function d() {}
var e = function () {};
}
foo(1, 2);
// 进入函数执行上下文时,AO 为:
// AO(foo) = {
// arguments: { 0: 1, 1: 2, length: 2 },
// a: 1,
// b: 2,
// c: undefined, // var 声明提升,值为 undefined
// d: function d() {}, // 函数声明提升,已有值
// e: undefined, // var 声明提升,值为 undefined(不是函数)
// }
// 执行阶段,AO 更新:
// AO(foo) = {
// arguments: { 0: 1, 1: 2, length: 2 },
// a: 1,
// b: 2,
// c: 10, // 赋值完成
// d: function d() {},
// e: function () {}, // 赋值完成
// }
var/let/const 在 VO 中的区别:
| 维度 | var | let | const |
|---|---|---|---|
| 声明提升 | 是(初始化为 undefined) | 是(但未初始化,在 TDZ 中) | 是(但未初始化,在 TDZ 中) |
| VO/AO 中创建时机 | 进入上下文时 | 进入上下文时 | 进入上下文时 |
| 可访问时机 | 声明之前可访问(值为 undefined) | 声明语句执行后才可访问 | 声明语句执行后才可访问 |
| 重复声明 | 允许 | 不允许 | 不允许 |
作用域链的构建过程
作用域链在函数定义时就已经确定,而不是在函数执行时。每个函数在创建时,都会保存一个内部属性 [[Environment]],指向其创建时的执行上下文的变量对象。当函数被调用时,引擎会将当前上下文的 AO 放在作用域链的最前端,然后依次引用外层上下文的 VO,直到全局上下文的 VO。
// 作用域链在定义时确定
const a = 'global';
function outer() {
const b = 'outer';
function inner() {
const c = 'inner';
console.log(a, b, c); // 沿作用域链查找:inner AO → outer AO → global VO
}
return inner;
}
const fn = outer();
fn(); // "global outer inner"
// 即使在全局作用域调用 inner,它仍然能访问 outer 的变量 b
// 因为作用域链在 inner 定义时就已确定,与调用位置无关
// 作用域链示意:
// inner 的作用域链 = [inner.AO, outer.AO, global.VO]
// 查找变量时从链头开始,找到即停止
// 作用域链不受调用位置影响
const x = 10;
function foo() {
console.log(x); // 10,不是 20
}
function bar() {
const x = 20;
foo(); // foo 定义在全局,作用域链是 [foo.AO, global.VO]
}
bar(); // 输出 10,不是 20
词法作用域 vs 动态作用域
| 维度 | 词法作用域(Lexical Scope) | 动态作用域(Dynamic Scope) |
|---|---|---|
| 确定时机 | 函数定义时确定 | 函数调用时确定 |
| 查找规则 | 沿定义时的外层作用域查找 | 沿调用栈查找 |
| 可预测性 | 高(只看代码结构即可判断) | 低(依赖运行时调用链) |
| 代表语言 | JavaScript、Python、Java | Bash、Perl(部分)、Emacs Lisp |
this 的行为 | this 是动态绑定(例外) | 自然支持 |
// 假设 JavaScript 是动态作用域,下面代码会输出什么?
const x = 10;
function foo() {
console.log(x);
}
function bar() {
const x = 20;
foo();
}
// 词法作用域(实际行为):输出 10
// foo 定义在全局,查找 x 时沿定义时的作用域链 → 全局 x = 10
// 动态作用域(假设行为):输出 20
// foo 在 bar 中被调用,查找 x 时沿调用栈 → bar 中 x = 20
bar(); // 实际输出:10
块级作用域的完整机制
ES6 引入的 let 和 const 创建了块级作用域。其核心机制不仅仅是”花括号内的作用域”,更关键的是词法环境(Lexical Environment) 的切换和暂时性死区(TDZ) 的实现。
块级作用域的底层实现:
// let/const 的块级绑定机制
{
// --- TDZ 开始 ---
// 引擎已识别到 x 的声明,但未初始化
// 访问 x 会抛出 ReferenceError
// --- TDZ 结束 ---
let x = 1; // 从这里开始,x 才可访问
console.log(x); // 1
}
// 块级作用域内部的词法环境变化:
// 进入块:创建新的词法环境 { x: <uninitialized> }
// 执行 let x = 1:词法环境更新 { x: 1 }
// 离开块:词法环境被回收(若无闭包引用)
let/const 绑定的底层原理:
// for 循环中的 let 绑定机制
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出:0, 1, 2
// V8 引擎的内部行为:
// 每次循环迭代都会创建一个新的词法环境
// 迭代 0:{ i: 0 } → 回调捕获此环境的 i
// 迭代 1:{ i: 1 } → 回调捕获此环境的 i
// 迭代 2:{ i: 2 } → 回调捕获此环境的 i
// 所以每个回调引用的是不同词法环境中的 i
// 对比 var:所有迭代共享同一个变量对象中的 i
// 循环结束后 i = 3,所有回调输出 3
暂时性死区(TDZ)的原理:
// TDZ 的本质:变量已声明但未初始化
console.log(typeof x); // ReferenceError! 不是 "undefined"
// TDZ 从块开头开始,到 let x 声明语句结束
let x = 42;
console.log(typeof x); // "number",声明之后正常
// TDZ 也存在于函数参数默认值中
function foo(a = b, b = 2) {
// ↑ TDZ! b 还未声明就使用了
}
foo(); // ReferenceError: Cannot access 'b' before initialization
// 正确写法:参数默认值不依赖同位置后面的参数
function bar(a = 1, b = 2) {
console.log(a, b);
}
bar(); // 1 2
闭包的 V8 内存模型
在 V8 引擎中,闭包变量的存储与普通局部变量不同。普通局部变量存储在栈上,随函数调用结束自动回收;而闭包引用的变量会被提升到堆上分配的Context 对象中,只有当所有闭包引用都被释放后,GC 才能回收这些变量。
闭包在 V8 中的内存结构:
┌───────────────────────────────────────────────┐
│ V8 闭包内存模型 │
├───────────────────────────────────────────────┤
│ │
│ Stack(栈) Heap(堆) │
│ ┌──────────┐ ┌──────────────┐ │
│ │ foo() │ │ Context 对象 │ │
│ │ │ ───→ │ count: 2 │ │
│ │ 返回闭包 │ │ name: "x" │ │
│ └──────────┘ └──────┬───────┘ │
│ │ │
│ ┌──────────┐ │ │
│ │ increment │ ─────────────────┘ │
│ │ (闭包函数) │ 引用 Context 对象 │
│ └──────────┘ │
│ ┌──────────┐ │
│ │ getCount │ ─────────────────┘ │
│ │ (闭包函数) │ 引用 Context 对象 │
│ └──────────┘ │
│ │
└───────────────────────────────────────────────┘
// 闭包变量存储在堆上的证据
function createHeavy() {
const bigData = new Array(1000000).fill('*'); // 大数组
let count = 0;
return {
increment: () => ++count,
getCount: () => count,
// 即使不暴露 bigData,只要闭包存在,bigData 就不会被 GC
};
}
const obj = createHeavy();
// bigData 被 Context 对象持有,Context 对象被闭包持有
// 即使我们只使用 increment/getCount,bigData 也无法被回收
// 这就是闭包内存泄漏的典型原因
闭包的经典定义和现代定义:
| 维度 | 经典定义 | 现代定义(ES6+) |
|---|---|---|
| 核心表述 | 函数可以访问其外层函数的变量,即使外层函数已执行完毕 | 函数与其关联的词法环境(Lexical Environment)的组合 |
| 理论依据 | 作用域链 + 引用计数 | [[Environment]] 内部槽 + Lexical Environment 规范类型 |
| 存储模型 | 闭包变量存储在堆上 | 词法环境分为 Environment Record + outer 引用 |
| 适用范围 | 仅函数 | 函数、块级作用域、模块、with/catch 块等 |
// 现代定义:不只是函数,块级作用域也构成闭包
{
let x = 1;
{
let y = 2;
console.log(x + y); // 3,内层块访问外层块的变量
}
// console.log(y); // ReferenceError,y 不在外层块的作用域中
}
// 这本质也是闭包——内层块的词法环境引用了外层块的词法环境
// 模块作用域也构成闭包
// math.js
const privateVar = 'secret'; // 模块级私有变量
export function getPrivate() {
return privateVar; // 闭包引用模块词法环境中的变量
}
运行机制总结:
- 内存模型:闭包变量存储在堆上(不会被 GC 回收)
- 执行模型:词法分析阶段确定作用域链,运行时沿链查找变量
- 并发模型:闭包变量被多个回调共享时需注意竞态
Why — 为什么
闭包的 7 大应用场景
1. 数据私有化
闭包最经典的用途是实现数据私有化,外部无法直接访问内部变量,只能通过暴露的方法操作。
function createUser(name) {
let loginCount = 0; // 私有状态
return {
getName: () => name,
login: () => {
loginCount++;
console.log(`${name} logged in (count: ${loginCount})`);
},
getLoginCount: () => loginCount,
};
}
const user = createUser('Alice');
user.login(); // "Alice logged in (count: 1)"
user.login(); // "Alice logged in (count: 2)"
user.getLoginCount(); // 2
// loginCount 无法从外部直接访问或修改
2. 函数工厂
闭包可以用来创建一系列相关但不同的函数,工厂函数通过参数定制行为。
function makeMultiplier(factor) {
return (number) => number * factor;
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
double(5); // 10
triple(5); // 15
// 每个"子函数"都闭包捕获了自己的 factor
3. 回调上下文保存
在异步编程中,闭包可以保存回调执行时所需的上下文信息。
function fetchData(url) {
const requestId = Date.now(); // 保存请求标识
const startTime = performance.now(); // 保存开始时间
return fetch(url)
.then(res => res.json())
.then(data => {
// 闭包捕获 requestId 和 startTime
console.log(`[${requestId}] took ${performance.now() - startTime}ms`);
return data;
});
}
4. 防抖/节流
防抖和节流都依赖闭包来维持定时器引用和上次执行时间的状态。
// 防抖:频繁触发只执行最后一次
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 节流:固定时间间隔执行一次
function throttle(fn, interval) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
return fn.apply(this, args);
}
};
}
5. Memoization 缓存
闭包可以缓存函数的计算结果,避免重复计算,在递归和计算密集型场景中非常有用。
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 fibonacci = memoize(function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
});
fibonacci(40); // 第一次计算
fibonacci(40); // 第二次直接从缓存返回,极快
6. 模块模式
IIFE + 闭包实现模块的公有/私有接口分离,是 ES Module 出现前的主流模块化方案。
const CounterModule = (function () {
let count = 0; // 私有变量
function increment() { // 私有方法
return ++count;
}
return { // 公有接口
increment,
getCount: () => count,
reset: () => { count = 0; },
};
})();
CounterModule.increment(); // 1
CounterModule.increment(); // 2
CounterModule.getCount(); // 2
CounterModule.reset();
CounterModule.getCount(); // 0
7. 偏应用/柯里化
闭包保存部分已提供的参数,返回一个接受剩余参数的新函数。
// 偏应用:固定部分参数
function partial(fn, ...presetArgs) {
return function (...laterArgs) {
return fn(...presetArgs, ...laterArgs);
};
}
function log(level, time, message) {
console.log(`[${level}] ${time}: ${message}`);
}
const logError = partial(log, 'ERROR', new Date().toISOString());
logError('Something went wrong'); // [ERROR] 2026-05-12T...: Something went wrong
// 柯里化:逐步接收参数
const curry = (fn) => {
const arity = fn.length;
return function curried(...args) {
if (args.length >= arity) return fn(...args);
return (...moreArgs) => curried(...args, ...moreArgs);
};
};
const add = curry((a, b, c) => a + b + c);
add(1)(2)(3); // 6
add(1, 2)(3); // 6
闭包 vs 其他封装方案对比
| 维度 | 闭包 | WeakMap 私有字段 | #private(ES2022) | Symbol |
|---|---|---|---|---|
| 封装强度 | 强(完全不可访问) | 中(需约定不暴露 WeakMap) | 强(语法级保护) | 弱(可通过反射获取) |
| 语法复杂度 | 中(需 IIFE 或工厂函数) | 高(需维护 WeakMap) | 低(加 # 前缀即可) | 中(需创建 Symbol) |
| 实例间共享 | 不共享(每个实例独立闭包) | 可共享(同一 WeakMap) | 不共享(每个实例独立) | 不共享 |
| 性能 | 略差(闭包变量在堆上) | 好(直接属性查找) | 好(引擎优化) | 好(直接属性查找) |
| 内存影响 | 可能泄漏 | 弱引用,不阻止 GC | 正常 GC | 正常 GC |
| 兼容性 | ES3+ | ES6+ | ES2022+ | ES6+ |
| DevTools 可见性 | 不可见 | 可见(属性可枚举) | 可见(灰色显示) | 可见 |
// 方案一:闭包
function PersonClosure(name) {
let _age = 0; // 私有
return {
name,
getAge: () => _age,
setAge: (val) => { _age = val; },
};
}
// 方案二:WeakMap
const ageMap = new WeakMap();
class PersonWeakMap {
constructor(name) {
this.name = name;
ageMap.set(this, 0);
}
getAge() { return ageMap.get(this); }
setAge(val) { ageMap.set(this, val); }
}
// 方案三:#private
class PersonPrivate {
#age = 0; // 真正的私有字段
constructor(name) {
this.name = name;
}
getAge() { return this.#age; }
setAge(val) { this.#age = val; }
}
// 方案四:Symbol
const _age = Symbol('age');
class PersonSymbol {
constructor(name) {
this.name = name;
this[_age] = 0; // 可通过 Object.getOwnPropertySymbols 访问
}
getAge() { return this[_age]; }
setAge(val) { this[_age] = val; }
}
// Symbol 方案不是真正私有:
const p = new PersonSymbol('Alice');
Object.getOwnPropertySymbols(p); // [Symbol(age)],可以访问
优缺点:
- ✅ 优点:
- 实现数据私有化
- 保持状态,无需全局变量
- 函数式编程基础(柯里化、偏应用)
- 灵活控制接口暴露粒度
- ❌ 缺点:
- 闭包变量无法被 GC,可能导致内存泄漏
- 循环中的闭包容易踩坑
- 过度使用使代码难以理解
- 每个实例独立闭包,内存开销比原型方法大
How — 怎么用
快速上手
// 闭包实现计数器
function createCounter() {
let count = 0; // 私有变量
return {
increment: () => ++count,
getCount: () => count,
};
}
const counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.getCount(); // 2
// count 无法从外部直接访问
代码示例
1. 循环闭包陷阱与修复
// ❌ 经典陷阱:所有输出 3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:3, 3, 3(共享同一个 i)
// ✅ 修复 1:用 let 块级作用域
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:0, 1, 2
// ✅ 修复 2:IIFE 创建新作用域
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => console.log(j), 100);
})(i);
}
// ✅ 修复 3:setTimeout 第三参数(传值)
for (var i = 0; i < 3; i++) {
setTimeout((j) => console.log(j), 100, i);
}
// ✅ 修复 4:bind 传值
for (var i = 0; i < 3; i++) {
setTimeout(function(j) { console.log(j); }.bind(null, i), 100);
}
2. 柯里化
const curry = (fn) => {
const arity = fn.length;
return function curried(...args) {
if (args.length >= arity) return fn(...args);
return (...moreArgs) => curried(...args, ...moreArgs);
};
};
const add = curry((a, b, c) => a + b + c);
add(1)(2)(3); // 6
add(1, 2)(3); // 6
3. 防抖(debounce)
function debounce(fn, delay) {
let timer = null; // 闭包保持 timer 引用
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const handleSearch = debounce((query) => {
fetch(`/api/search?q=${query}`);
}, 300);
4. 节流(throttle)
function throttle(fn, interval) {
let lastTime = 0; // 闭包保存上次执行时间
let timer = null;
return function (...args) {
const now = Date.now();
const remaining = interval - (now - lastTime);
if (remaining <= 0) {
// 时间间隔已到,立即执行
if (timer) { clearTimeout(timer); timer = null; }
lastTime = now;
fn.apply(this, args);
} else if (!timer) {
// 保证最后一次触发也能执行(尾部调用)
timer = setTimeout(() => {
lastTime = Date.now();
timer = null;
fn.apply(this, args);
}, remaining);
}
};
}
const handleScroll = throttle(() => {
console.log('Scroll position:', window.scrollY);
}, 200);
window.addEventListener('scroll', handleScroll);
5. Memoization 缓存函数
// 通用 memoize
function memoize(fn, resolver = (...args) => JSON.stringify(args)) {
const cache = new Map(); // 闭包保持缓存
return function (...args) {
const key = resolver(...args);
if (cache.has(key)) {
console.log(`cache hit: ${key}`);
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// 示例:缓存 API 请求结果
const fetchUser = memoize(
async (id) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
},
(id) => `user:${id}` // 自定义缓存 key
);
// 示例:缓存复杂计算
const expensiveCalc = memoize((n) => {
console.log('computing...');
return Array.from({ length: n }, (_, i) => i).reduce((a, b) => a + b, 0);
});
expensiveCalc(10000); // "computing..." → 49995000
expensiveCalc(10000); // "cache hit: [10000]" → 49995000(从缓存返回)
6. 偏应用函数
// 通用偏应用
function partial(fn, ...presetArgs) {
return function (...laterArgs) {
return fn.apply(this, [...presetArgs, ...laterArgs]);
};
}
// 带占位符的偏应用
const _ = Symbol('placeholder');
function partialWith(fn, ...presetArgs) {
return function (...laterArgs) {
const args = presetArgs
.map(arg => (arg === _ ? laterArgs.shift() : arg))
.concat(laterArgs);
return fn.apply(this, args);
};
}
// 使用示例
function greet(greeting, name, punctuation) {
return `${greeting}, ${name}${punctuation}`;
}
const sayHelloTo = partial(greet, 'Hello');
sayHelloTo('World', '!'); // "Hello, World!"
const greetJapanese = partialWith(greet, 'Konnichiwa', _, '~');
greetJapanese('Tanaka'); // "Konnichiwa, Tanaka~"
7. once 函数(只执行一次)
function once(fn) {
let called = false;
let result;
return function (...args) {
if (called) return result; // 闭包保存 called 状态和结果
called = true;
result = fn.apply(this, args);
return result;
};
}
// 示例:确保初始化只执行一次
const initialize = once(() => {
console.log('Initializing...');
return { status: 'ready' };
});
initialize(); // "Initializing..." → { status: 'ready' }
initialize(); // 直接返回 { status: 'ready' },不再执行
// 示例:确保支付只扣款一次
const charge = once((amount) => {
console.log(`Charged $${amount}`);
return true;
});
charge(100); // "Charged $100" → true
charge(200); // 直接返回 true,不会重复扣款
8. 模块模式(IIFE + 闭包)
// 完整的模块模式示例
const UserModule = (function () {
// 私有变量
const users = new Map();
let nextId = 1;
// 私有方法
function generateId() {
return nextId++;
}
function validateName(name) {
if (!name || typeof name !== 'string') {
throw new Error('Invalid name');
}
}
// 公有接口
return {
create(name) {
validateName(name);
const id = generateId();
const user = { id, name, createdAt: new Date() };
users.set(id, user);
return Object.freeze({ ...user }); // 返回副本,防止外部修改
},
findById(id) {
const user = users.get(id);
return user ? Object.freeze({ ...user }) : null;
},
findAll() {
return Array.from(users.values()).map(u => Object.freeze({ ...u }));
},
get count() {
return users.size;
},
};
})();
UserModule.create('Alice'); // { id: 1, name: 'Alice', createdAt: ... }
UserModule.create('Bob'); // { id: 2, name: 'Bob', createdAt: ... }
UserModule.count; // 2
UserModule.findById(1); // { id: 1, name: 'Alice', createdAt: ... }
// users 和 nextId 无法从外部访问
9. 闭包内存泄漏的排查与修复
// ❌ 典型内存泄漏:闭包持有 DOM 引用
function leakyHandler() {
const hugeData = new Array(1000000).fill('data');
const element = document.getElementById('target');
element.addEventListener('click', () => {
console.log(hugeData.length); // 闭包捕获 hugeData
});
}
// 即使 element 被从 DOM 移除,闭包仍持有 element 和 hugeData 的引用
// GC 无法回收它们 → 内存泄漏
// ✅ 修复方式一:移除事件监听
function fixedHandler() {
const hugeData = new Array(1000000).fill('data');
const element = document.getElementById('target');
const handler = () => {
console.log(hugeData.length);
};
element.addEventListener('click', handler);
// 在合适的时机移除
return function cleanup() {
element.removeEventListener('click', handler);
// handler 被移除后,闭包解除,hugeData 可被 GC
};
}
const cleanup = fixedHandler();
// 用完后清理
cleanup();
// ✅ 修复方式二:使用 WeakRef(ES2021)
function weakRefHandler() {
const element = document.getElementById('target');
const weakElement = new WeakRef(element);
element.addEventListener('click', () => {
const el = weakElement.deref();
if (el) {
console.log('clicked', el.id);
}
});
// WeakRef 不会阻止 GC 回收 element
}
使用 Chrome DevTools Memory 面板排查步骤:
1. 打开 DevTools → Memory 面板
2. 选择 "Heap snapshot" → 点击 "Take snapshot"
3. 操作页面(触发可能泄漏的闭包)
4. 再次 "Take snapshot"
5. 选择第二个快照,筛选 "Comparison" 视图
6. 关注 "Delta" 列中增长的对象
7. 在 Retainers 面板中查看引用链,确认是否被闭包持有
8. 找到持有闭包的代码位置,修复引用
10. 事件监听器中的闭包陷阱与修复
// ❌ 陷阱:循环中给多个元素绑定事件
const buttons = document.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function () {
console.log(`Button ${i} clicked`); // 始终输出最后一个 i
});
}
// ✅ 修复 1:使用 let
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function () {
console.log(`Button ${i} clicked`);
});
}
// ✅ 修复 2:事件委托(推荐,减少监听器数量)
document.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') {
const index = Array.from(buttons).indexOf(e.target);
console.log(`Button ${index} clicked`);
}
});
// ❌ 陷阱:在 useEffect 中忘记清理事件监听
// React 示例
useEffect(() => {
const data = fetchSomeData();
window.addEventListener('resize', () => {
console.log(data); // 闭包捕获 data
});
// 忘记返回 cleanup → 组件卸载后监听器仍在,data 无法释放
}, []);
// ✅ 修复:返回清理函数
useEffect(() => {
const data = fetchSomeData();
const handler = () => {
console.log(data);
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
常见问题与踩坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 循环闭包共享变量 | var 是函数作用域,循环结束后 i 为最终值 | 使用 let 或 IIFE |
| 内存泄漏 | 闭包持有 DOM/大对象引用,无法被 GC | 移除事件监听,置空引用 |
| this 指向丢失 | 回调中 this 不再指向原对象 | 用箭头函数(继承外层 this)或 .bind(this) |
| 闭包捕获的是引用 | 闭包保存的是变量的引用而非值的快照 | 需要快照时用 IIFE 或 let 创建副本 |
| React 闭包陷阱 | useEffect/useState 的闭包捕获旧 state | 用 useRef 或 useEffect 依赖数组 |
| 过度闭包导致性能问题 | 每个闭包都持有独立的词法环境 | 优先用原型方法或 ES Module |
| setTimeout 中的 this | setTimeout 回调中 this 指向 window | 使用箭头函数或 bind |
| 异步闭包值过时 | 异步回调执行时,闭包变量可能已被修改 | 在回调创建时捕获当前值,或使用 useRef |
最佳实践
- 优先用
let/const,避免var - 闭包持有大对象引用时,用完及时置
null - 用 ES Module 替代 IIFE 模块模式
- 理解作用域链,避免过多嵌套闭包
- 事件监听器必须配对
removeEventListener - React 中注意
useEffect的闭包陷阱,善用useRef - 优先使用事件委托减少闭包数量
- 在 DevTools 中定期检查内存快照,排查泄漏
面试题
Q1: 什么是闭包?请举例说明闭包的用途。
闭包是函数与其词法环境的组合,使内部函数可以访问外部函数的变量即使外部函数已返回。常见用途包括:数据私有化(模块模式)、函数工厂(柯里化/偏应用)、回调中保存上下文(防抖/节流)、缓存(memoization)等。
Q2: 什么是词法作用域?与动态作用域有什么区别?
词法作用域在函数定义时确定,而非调用时。函数的作用域链由其书写位置决定,与调用位置无关。动态作用域则在运行时根据调用栈决定,Bash 等语言使用动态作用域,JavaScript 使用词法作用域。需要注意,
this的绑定更接近动态作用域的行为——它取决于调用方式,而非定义位置。
Q3: 以下代码输出什么?如何修复?for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); }
输出 3, 3, 3。因为
var是函数作用域,循环结束后i为 3,三个回调共享同一个i。修复方式:将var改为let(块级作用域,每次迭代创建新绑定),或用 IIFE 包裹创建独立作用域,或使用setTimeout第三参数传值。
Q4: 闭包为什么会导致内存泄漏?如何避免?
闭包持有外部函数变量的引用,使其无法被 GC 回收(存储在堆上)。若闭包长期存在且持有大对象或 DOM 引用,就会造成内存泄漏。避免方式:用完及时将引用置
null,移除不需要的事件监听,优先用 ES Module 替代 IIFE 模块模式,使用WeakRef替代强引用。
Q5: 什么是暂时性死区(TDZ)?let 和 var 的提升有什么区别?
TDZ 是指从代码块开始到
let/const声明语句执行之间的区域,在此期间访问变量会抛出ReferenceError。let和var都会发生声明提升,但var在提升时初始化为undefined,而let/const在提升时不会初始化——它们处于”未初始化”状态,直到声明语句执行时才被初始化。这就是 TDZ 的本质:变量已存在于词法环境中,但不可访问。
Q6: 以下代码输出什么?解释原因:let a = 1; function foo() { console.log(a); let a = 2; } foo();
抛出
ReferenceError: Cannot access 'a' before initialization。原因是 TDZ:函数foo内部有let a = 2的声明,这会在函数的词法环境中创建a变量但未初始化。当执行console.log(a)时,引擎在当前作用域找到了a(因为声明提升),但a还未初始化(还在 TDZ 中),所以抛出错误。注意,引擎不会去外层作用域查找a = 1,因为当前作用域已经有同名声明了。
Q7: 如何检测和排查闭包导致的内存泄漏?
排查步骤:(1) 使用 Chrome DevTools Memory 面板,拍摄堆快照;(2) 操作页面触发可能泄漏的闭包;(3) 再次拍摄快照,对比两次快照的 “Delta” 列;(4) 关注增长的对象,在 Retainers 面板中查看引用链;(5) 如果发现对象被闭包的 Context 对象持有,即可定位泄漏源头。预防措施:始终配对移除事件监听器,闭包中避免持有不必要的 DOM 引用,使用
WeakRef替代强引用,定期用 DevTools 检查。
Q8: 闭包和 ES6 的 #private 字段都可以实现私有化,各自有什么优劣?
闭包的优势:(1) 兼容性好(ES3+ 即可使用);(2) 封装强度高——完全无法从外部访问,即使通过反射也不行;(3) 灵活,可在运行时动态创建。闭包的劣势:(1) 每个实例独立创建闭包,内存开销更大;(2) 私有方法无法共享,不能使用原型链优化;(3) DevTools 中难以调试私有状态。
#private的优势:(1) 语法简洁,只需加#前缀;(2) 私有方法可通过原型共享,性能更好;(3) DevTools 中可见(灰色显示),便于调试;(4) 属于类的正式语法,语义清晰。#private的劣势:(1) 需要 ES2022+ 支持;(2) 子类无法直接访问父类的#private字段;(3) 仍可通过 DevTools 查看(不是绝对安全)。推荐策略:现代项目优先使用
#private,需要兼容旧环境时用闭包。
相关链接: