JavaScript 闭包问题

2 阅读8分钟

1. 概述

1.1 文档目的

本文档系统阐述 JavaScript 闭包(Closure)的定义、原理、应用场景及常见问题,为开发团队提供统一的技术参考标准。

1.2 术语定义

术语定义
闭包(Closure)函数与其词法作用域的组合,使函数能够访问定义时的外部变量
词法作用域(Lexical Scope)作用域在代码编写时确定,而非运行时
词法环境(Lexical Environment)由环境记录器和对外部环境的引用组成
执行上下文(Execution Context)代码执行时的运行环境,包含变量对象、作用域链、this 等
垃圾回收(GC)自动回收不再被引用的内存空间

1.3 闭包判定标准

满足以下三个条件即构成闭包:

  1. 存在嵌套函数(函数内部定义函数)
  2. 内部函数引用了外部函数的变量
  3. 内部函数在外部函数作用域之外被调用或引用

2. 核心概念

2.1 定义

闭包是指一个函数能够记住并访问它的词法作用域,即使这个函数在当前词法作用域之外执行。

公式化表达:闭包 = 函数 + 该函数能访问的外部词法环境

2.2 基础示例

function outer() {
    let count = 0;  // outer 的局部变量

    function inner() {
        count++;    // inner 访问了 outer 的变量
        return count;
    }

    return inner;   // 返回 inner 函数
}

const counter = outer();  // outer 执行完毕,count 未被销毁
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

2.3 关键特征

特征说明
变量持久化外部函数的变量在函数执行完毕后仍然存活
数据隔离每个闭包实例拥有独立的变量副本
作用域链访问内部函数可沿作用域链访问所有外部变量
私有性外部无法直接访问闭包内部的变量

3. 闭包的作用

3.1 数据封装与私有变量

JavaScript 没有原生私有属性机制,闭包是实现数据封装的经典方案。

function createPerson(name) {
    let _age = 0;  // 私有变量,外部无法直接访问

    return {
        getName: () => name,
        getAge: () => _age,
        growUp: () => _age++
    };
}

const person = createPerson('Alice');
console.log(person.getAge()); // 0
person.growUp();
console.log(person.getAge()); // 1
console.log(person._age);     // undefined,无法直接访问

适用场景

  • 模块模式(Module Pattern)
  • 类的私有属性和方法
  • 配置对象的不可变封装

3.2 函数工厂与柯里化

通过闭包创建带预设参数的函数。

function makeMultiplier(factor) {
    return function(number) {
        return number * factor;
    };
}

const double = makeMultiplier(2);
const triple = makeMultiplier(3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

适用场景

  • 参数预设(Partial Application)
  • 函数柯里化(Currying)
  • 配置化函数生成

3.3 异步回调中的状态保持

在异步操作中保持循环变量的当前值。

for (var i = 1; i <= 3; i++) {
    (function(savedI) {
        setTimeout(() => {
            console.log(savedI); // 1, 2, 3
        }, 100);
    })(i);
}

适用场景

  • 循环中的异步操作
  • 事件处理器的参数绑定
  • 定时器回调

3.4 防抖与节流

利用闭包保存中间状态,实现性能优化。

function debounce(fn, delay) {
    let timer = null;  // 闭包保存 timer 状态

    return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => fn.apply(this, args), delay);
    };
}

// 使用
const handleSearch = debounce((query) => {
    console.log('搜索:', query);
}, 300);

适用场景

  • 搜索框输入防抖
  • 滚动事件节流
  • 按钮点击防重

3.5 单例模式实现

const Singleton = (function() {
    let instance = null;

    function createInstance() {
        return { data: '唯一实例' };
    }

    return {
        getInstance: function() {
            if (!instance) {
                instance = createInstance();
            }
            return instance;
        }
    };
})();

4. 实现原理

4.1 词法作用域机制

JavaScript 采用词法作用域(Lexical Scoping),即作用域在函数定义时确定,而非调用时。

函数在创建时会保存一个内部属性 [[Environment]],指向其定义时的词法环境。这一机制是闭包实现的底层基础。

4.2 执行过程分析

以以下代码为例:

function foo() {
    let a = 1;
    function bar() {
        console.log(a);
    }
    return bar;
}

const baz = foo();
baz(); // 1

执行步骤

步骤操作内存状态
1定义 foofoo.[[Environment]] 指向全局环境全局词法环境
2调用 foo(),创建新的执行上下文新增 foo 词法环境
3foo 内定义 barbar.[[Environment]] 指向 foo 的词法环境bar 持有对 foo 环境的引用
4foo 返回 bar 并执行完毕,执行上下文弹出foo 的执行上下文销毁
5foo 的词法环境仍被 bar 引用,因此保留a 仍存在于内存中
6调用 baz(),引擎沿作用域链找到 a正常访问 a

4.3 内存模型

┌─────────────────────────────────────────┐
│              全局执行上下文               │
│  ┌─────────────────────────────────┐    │
│  │         全局词法环境             │    │
│  │  foo: <function object>         │    │
│  │  baz: <function object>         │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────┐
│            foo 词法环境(仍存活)         │
│  ┌─────────────────────────────────┐    │
│  │  a: 1                           │    │
│  │  bar: <function object>         │    │
│  │  外部引用 → 全局词法环境         │    │
│  └─────────────────────────────────┘    │
│         ↑ 被 baz(即 bar)引用          │
└─────────────────────────────────────────┘

4.4 垃圾回收影响

正常情况下,函数执行完毕后其词法环境会被垃圾回收。但在闭包场景中:

  • 内部函数持有外部词法环境的引用
  • 只要内部函数可达,外部词法环境就不会被回收
  • 这既是闭包实现的基础,也是内存泄漏的根源

5. 常见问题与解决方案

5.1 内存泄漏

问题描述

闭包引用的变量不会被垃圾回收。如果闭包持有大量数据且长期存活,会造成内存泄漏。

// ❌ 错误示例:意外持有大量数据
function processBigData() {
    const hugeArray = new Array(1000000).fill('x');

    return function() {
        console.log('done'); // 未使用 hugeArray,但整个作用域被持有
    };
}

const leak = processBigData(); 
// hugeArray 永远不会被释放

解决方案

方案一:只引用必要变量

// ✅ 正确做法
function processBigData() {
    const hugeArray = new Array(1000000).fill('x');
    const result = hugeArray.length; // 只保存需要的数据

    return function() {
        console.log(result); // 不再引用 hugeArray
    };
}

方案二:及时释放引用

let handler = processBigData();
// 使用完毕后
handler = null; // 帮助垃圾回收

方案三:使用 WeakMap/WeakSet

const cache = new WeakMap();

function process(obj) {
    if (!cache.has(obj)) {
        const result = heavyComputation(obj);
        cache.set(obj, result);
    }
    return cache.get(obj);
}
// 当 obj 不再被引用时,缓存条目自动被回收

5.2 循环中的变量共享

问题描述

使用 var 在循环中创建闭包,所有闭包共享同一个变量。

// ❌ 错误:输出 3, 3, 3
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}

原因分析var 没有块级作用域,三个闭包共享同一个 i,循环结束时 i = 3

解决方案

方案一:使用 let(推荐)

// ✅ 推荐方案
for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100); // 0, 1, 2
}

let 具有块级作用域,每次迭代都会创建一个新的绑定。

方案二:使用 IIFE(立即执行函数)

// ✅ 兼容方案
for (var i = 0; i < 3; i++) {
    (function(savedI) {
        setTimeout(() => console.log(savedI), 100);
    })(i);
}

方案对比

方案兼容性代码量推荐度
letES6+最少⭐⭐⭐⭐⭐
IIFE全平台较多⭐⭐⭐
forEachES5+中等⭐⭐⭐⭐

5.3 this 指向问题

问题描述

闭包不会继承外层函数的 this,其 this 取决于调用方式。

const obj = {
    name: 'Alice',
    getName: function() {
        return function() {
            console.log(this.name); // undefined
        };
    }
};

obj.getName()(); // this 指向全局对象

解决方案

方案一:使用箭头函数

const obj = {
    name: 'Alice',
    getName: function() {
        return () => {
            console.log(this.name); // Alice
        };
    }
};

箭头函数没有自己的 this,会继承外层函数的 this

方案二:保存 this 引用

const obj = {
    name: 'Alice',
    getName: function() {
        const self = this;
        return function() {
            console.log(self.name); // Alice
        };
    }
};

方案三:使用 bind

const obj = {
    name: 'Alice',
    getName: function() {
        return function() {
            console.log(this.name);
        }.bind(this);
    }
};

5.4 性能问题

问题描述

  • 过度使用闭包会导致作用域链过长,增加变量查找时间
  • 每个闭包实例都持有独立的作用域链副本,增加内存开销

解决方案

// ❌ 避免:嵌套过深的闭包
function a() {
    const x = 1;
    return function b() {
        const y = 2;
        return function c() {
            const z = 3;
            return function d() {
                return x + y + z; // 作用域链过长
            };
        };
    };
}

// ✅ 推荐:扁平化设计,必要时解耦
function createCalculator(base) {
    return {
        add: (n) => base + n,
        multiply: (n) => base * n
    };
}

6. 最佳实践

6.1 编码规范

  1. 最小化引用范围:闭包中只引用必要的变量,避免持有整个作用域
  2. 及时释放引用:不再使用的闭包引用应置为 null
  3. 优先使用 let/const:避免 var 导致的变量提升问题
  4. 箭头函数处理 this:在需要继承 this 的场景使用箭头函数
  5. 避免深层嵌套:超过 3 层的嵌套闭包应考虑重构

6.2 代码模板

安全计数器

function createCounter(initial = 0) {
    let count = initial;

    return {
        increment: () => ++count,
        decrement: () => --count,
        getValue: () => count,
        reset: (value = initial) => { count = value; },
        setValue: (value) => { count = value; }
    };
}

// 使用
const counter = createCounter(10);
counter.increment();
console.log(counter.getValue()); // 11

模块模式

const myModule = (function() {
    // 私有变量
    let privateVar = 0;

    // 私有方法
    function privateMethod() {
        return privateVar * 2;
    }

    // 公有接口
    return {
        publicMethod: function() {
            privateVar++;
            return privateMethod();
        },
        getVar: function() {
            return privateVar;
        }
    };
})();

缓存函数(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(n) {
    if (n < 2) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
});

6.3 调试技巧

在 Chrome DevTools 中查看闭包:

  1. 在闭包函数内设置断点
  2. 打开 Scope 面板,查看 Closure 部分
  3. 可以看到闭包引用的所有外部变量
Scope
├── Local
│   └── this: Window
├── Closure (outer)
│   └── count: 2
└── Global
    └── ...

7. 附录

7.1 常见问题速查

问题现象可能原因解决方案
内存持续增长闭包持有大对象只引用必要变量,及时释放
循环中输出相同值var 变量共享改用 let 或 IIFE
this 为 undefined闭包中 this 指向全局使用箭头函数或保存 self
闭包变量值不符合预期异步执行时变量已改变使用 IIFE 或 let 创建新绑定

7.2 相关概念

  • 作用域链(Scope Chain):变量查找的路径,由当前环境和所有外部环境组成
  • 执行栈(Call Stack):管理函数调用的后进先出结构
  • 词法环境(Lexical Environment):由环境记录器和外部引用组成的规范类型
  • 变量对象(Variable Object):存储执行上下文中变量和函数声明的对象