深入理解 JS —— 闭包

闭包是 JavaScript 中的一个函数,它不仅可以访问函数内部的变量,还可以访问外部函数的变量,即使外部函数已经执行完毕。

在简单的词法作用域中,内部函数只能访问外部函数的参数和局部变量。而闭包则允许你“记住”外部函数的变量,即使外部函数已经执行结束。

闭包的原理

让我们从一个简单计数器开始:

1
2
3
4
5
6
7
8
9
10
function createCounter() {
    let count = 0;
    return function() {
        return ++count;
    };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2

在这个例子中:

  • createCounter 函数定义了一个局部变量 count,并返回了一个函数。
  • 当我们调用 counter() 时,被返回的函数依然可以访问 createCounter 中的 count 变量,并且每次调用 countercount 的值都会增加,证明了 count 的值在闭包中被“记住”了。

闭包的关键在于 JavaScript 的词法作用域。当我们定义一个函数时,JavaScript 引擎会为每个函数创建一个作用域链。这个作用域链保存了当前函数的变量,以及所有外部函数的变量。即使外部函数已经执行完毕,它的作用域依然会被保留在闭包中。

作用域链的形成

每个函数执行时都会创建执行上下文,包含:

  • 变量环境(Variable Environment)
  • 词法环境(Lexical Environment)
  • this绑定

当访问变量时,JavaScript引擎会沿着作用域链逐级查找,直到全局作用域。闭包的特殊之处在于,即使外部函数执行完毕,其变量对象仍被内部函数引用,无法被垃圾回收。

闭包的应用

数据封装

实现私有变量是闭包最经典的应用。闭包可以帮助你实现数据的封装,使得外部无法直接访问内部的变量,确保数据的安全性和隐私性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function createCounter() {
  let count = 0;
  return {
    increment: function () {
      return ++count;
    },
    decrement: function () {
      return --count;
    },
    getCount: function () {
      return count;
    },
  };
}

const counter = createCounter();
console.log(counter.increment()); // 输出: 1
console.log(counter.increment()); // 输出: 2
console.log(counter.getCount()); // 输出: 2
console.log(counter.decrement()); // 输出: 1

函数柯里化

闭包在函数式编程中常常用来实现函数柯里化。函数柯里化是将一个多参数函数转化为一系列接受单一参数的函数。

1
2
3
4
5
6
7
8
function multiply(a) {
  return function(b) {
    return a * b;
  }
}

const multiplyByTwo = multiply(2);
console.log(multiplyByTwo(3)); // 输出: 6

延迟执行

闭包在 JavaScript 中常常被用来实现延迟执行,这在异步编程和定时器等场景中非常有用。延迟执行指的是某个函数并不是立即执行,而是在某个特定时间点或事件发生时才执行。

错误示范

1
2
3
for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 100); // 输出5个5
}

在上面的代码中,由于 setTimeout 内部的回调函数是一个闭包,它捕获了外部的变量 i。但是,i 是通过 var 声明的,它是函数级作用域,并且会一直更新,直到循环结束。因此,setTimeout 的回调函数会在循环结束后执行,此时 i 的值已经是 5,所以它会打印 5 三次。

闭包修正

1
2
3
4
5
for (let i = 0; i < 5; i++) {
  (function (j) {
    setTimeout(() => console.log(j), 100); // 0,1,2,3,4
  })(i);
}

在这个版本中,let 使得每次循环迭代都为 i 创建了一个新的作用域,闭包会捕获到该作用域中的 i 值,因此每次 setTimeout 的回调函数都能正确输出 i

内存管理

闭包会捕获外部函数的变量并保持这些变量的引用。这意味着,即使外部函数已经执行完毕,它的局部变量仍然会被闭包保留在内存中,直到闭包不再使用这些变量并被垃圾回收机制清理。

闭包引发的内存泄漏

内存泄漏通常发生在以下两种情况:

  • 闭包不再使用,但仍然被引用:如果闭包持有的引用没有被清理,它将一直存在内存中。
  • 长时间持有对外部变量的引用:在某些情况下,闭包的生命周期可能比外部函数更长,导致外部函数中的变量无法被垃圾回收。

如何避免闭包引发的内存泄漏?

手动清除不再需要的引用

如果一个闭包不再使用某些数据或对象,应该手动解除对这些数据的引用,以便垃圾回收能够清理它们。比如,在事件处理程序中,我们可以在适当的时候移除事件监听器:

1
2
3
4
5
6
7
8
9
10
function attachEventHandler() {
  let data = new Array(1000000).fill('Some data');
  const handler = function () {
    console.log(data[0]);
  };
  document.getElementById('btn').addEventListener('click', handler);

  // 在适当的时候移除事件监听器
  document.getElementById('btn').removeEventListener('click', handler);
}

使用 letconst 声明变量

使用 letconst 声明的变量具有块级作用域,可以确保在闭包外部的作用域结束后,变量会被清理。相比于 varletconst 会减少闭包引用的范围,避免不必要的变量保持在内存中。

避免不必要的长时间引用

确保闭包没有不必要地持有对大量数据或长时间不使用的数据的引用。如果不再需要某些数据,应该及时让其不再被闭包引用。

怎么清除引用

以简单的计数器为例:

1
2
3
4
5
6
7
8
function createCounter() {
  let count = 0;
  return function () {
    return ++count;
  };
}

const counter = createCounter();

counter 变量被显式地设为 nullundefined

1
counter = null;  // 此时 counter 不再指向闭包函数

在这种情况下,counter 不再引用 createCounter 返回的闭包函数,因此闭包会被垃圾回收。闭包中的 count 也会被清理掉。

counter 超出了作用域:

如果 counter 是在某个函数内声明的局部变量,当该函数执行结束,counter 变量将超出作用域,失去所有的引用,从而成为垃圾回收的候选对象。

1
2
3
4
5
6
7
8
function startCounting() {
  const counter = createCounter();
  counter(); // 输出: 1
  counter(); // 输出: 2
}

// 这里 counter 超出了作用域,开始成为垃圾回收的候选对象
startCounting();

在这个例子中,counterstartCounting 函数内部的局部变量。当 startCounting 函数执行完毕时,counter 超出了作用域,它不再被引用,从而可以被垃圾回收机制清理。

页面或脚本卸载时:

如果 counter 作为全局变量或者某个长时间存在的变量,直到页面或脚本完全卸载或重新加载时,它才会被清理。比如,在 SPA(单页应用)中,如果你不手动解除引用,counter 可能会在页面卸载后仍然占用内存。


深入理解 JS —— 闭包
http://xiaowhang.github.io/archives/1009490165/
作者
Xiaowhang
发布于
2025年2月19日
许可协议