作用域与闭包

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 的创建规则:

  1. 创建 arguments 对象(仅函数上下文)
  2. 检查函数声明:在 VO 中创建属性,值为函数引用(函数声明提升)
  3. 检查变量声明:在 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 中的区别:

维度varletconst
声明提升是(初始化为 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、JavaBash、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 引入的 letconst 创建了块级作用域。其核心机制不仅仅是”花括号内的作用域”,更关键的是词法环境(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 的闭包捕获旧 stateuseRefuseEffect 依赖数组
过度闭包导致性能问题每个闭包都持有独立的词法环境优先用原型方法或 ES Module
setTimeout 中的 thissetTimeout 回调中 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 声明语句执行之间的区域,在此期间访问变量会抛出 ReferenceErrorletvar 都会发生声明提升,但 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,需要兼容旧环境时用闭包。


相关链接: