No history yet

闭包与作用域

函数如何记忆

你可能写过这样的函数:一个函数嵌套在另一个函数内部。但你是否想过,当外部函数执行完毕后,内部函数是如何访问到外部函数中的变量的?这背后隐藏着 JavaScript 最强大的特性之一:闭包。

要理解闭包,我们首先要了解代码是如何被执行的。每当一个函数被调用时,JavaScript 引擎都会为其创建一个“执行上下文”。这可以想象成一个专门为该函数调用准备的临时工作空间,里面存放着它的所有局部变量、参数以及一个指向其外部环境的链接。

执行上下文是 JavaScript 引擎追踪代码运行状态的内部机制。它决定了函数在执行期间可以访问哪些数据。

当一个函数内部又定义了另一个函数时,事情就变得有趣了。内部函数在被创建时,会获得一个特殊的属性,名为 [[Scope]],它像一条链条一样,链接着当前函数的变量对象以及所有外部函数的变量对象,一直延伸到全局作用域。这条链条就是所谓的“作用域链”。正是这条链,让内部函数能够“看到”并访问外部函数中的变量。

这种作用域规则被称为 词法作用域。简单来说,函数的作用域在它被定义时就已经确定了,而不是在它被调用时确定。无论一个函数在哪里被调用,它都只能访问定义它时所在作用域中的变量。

闭包的诞生

现在,我们可以正式定义闭包了:当一个函数能够记住并访问其所在的词法作用域,即使该函数在其词法作用域之外执行时,闭包就产生了。换句话说,闭包是函数和其周围状态(词法环境)的组合。

看一个经典的例子:

function createCounter() {
  let count = 0;

  return function() {
    count++;
    console.log(count);
  };
}

const counterA = createCounter();
const counterB = createCounter();

counterA(); // 输出: 1
counterA(); // 输出: 2

counterB(); // 输出: 1

在这里,createCounter 函数返回了一个内部的匿名函数。当我们调用 createCounter 时,它创建了一个新的执行上下文,其中包含一个局部变量 count。通常情况下,当 createCounter 执行完毕,这个上下文及其中的 count 变量会被销毁。

然而,由于返回的内部函数仍然引用着 count,JavaScript 的 垃圾回收 机制会“手下留情”,保留这个变量。counterAcounterB 各自获得了一个闭包,每个闭包都封闭了一个独立的 count 变量。它们互不干扰,实现了状态的私有化。

闭包是当一个函数“记住”其外部作用域中的变量时产生的,即使该外部函数已经执行完毕。

闭包的实际应用

闭包远不止是一个理论概念,它在日常开发中无处不在。

1. 封装与私有变量

在上面的计数器例子中,count 变量就是私有的。外部代码无法直接访问或修改它,只能通过返回的函数来间接操作。这是在 JavaScript 中模拟私有成员的常用方法,是模块化开发的基础。

function createPerson(name) {
  let privateAge = 0;

  return {
    getName: function() {
      return name;
    },
    getAge: function() {
      return privateAge;
    },
    grow: function() {
      privateAge++;
    }
  };
}

const person = createPerson('Alice');
person.grow();
console.log(person.getAge()); // 输出: 1
console.log(person.privateAge); // 输出: undefined (无法直接访问)

2. 实现防抖和节流

防抖(Debounce)和节流(Throttle)是优化高频触发事件(如窗口大小调整、输入框输入)的常用技巧。它们的核心都依赖于闭包来“记住”一个定时器 ID 或上一次执行的时间戳。

这是一个简单的防抖函数实现:

function debounce(func, delay) {
  let timeoutId; // 这个变量被闭包“记住”了

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

// 用法
window.addEventListener('resize', debounce(() => {
  console.log('Window resized!');
}, 500));

每次 resize 事件触发时,都会调用 debounce 返回的那个函数。这个函数可以访问并修改外部的 timeoutId 变量,因为它形成了一个闭包。timeoutId 在多次调用之间得以保持,从而实现了清除上一个定时器并设置新定时器的逻辑。

关于内存的思考

闭包的强大之处在于它能“记住”变量,但这也会带来潜在的内存问题。由于闭包引用的外部变量不会被垃圾回收,如果闭包本身一直存在(比如一个全局变量引用了它,或者它是一个DOM元素的事件监听器),那么它所引用的内存也永远不会被释放,这可能导致 内存泄漏。

内存泄漏是指程序中已分配的堆内存由于某种原因未被释放或无法被释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

在现代 JavaScript 引擎中,这种问题已经不那么常见了,除非在非常老旧的浏览器(如IE6)或者特定场景下。例如,一个 DOM 元素被移除了,但附加在它上面的事件监听器(一个闭包)没有被正确移除,这个监听器及其引用的作用域就可能永远留在内存中。

最佳实践是,当你不再需要一个闭包时(例如,在组件卸载时),要确保解除对它的引用,特别是那些作为事件监听器的闭包。

现在,让我们通过几个问题来检验你对闭包的理解。

Quiz Questions 1/4

什么是 JavaScript 中的闭包?

Quiz Questions 2/4

JavaScript 采用的是词法作用域(Lexical Scoping)。这意味着函数的作用域是在什么时候确定的?

理解闭包和作用域是从“会写” JavaScript 到“精通” JavaScript 的关键一步。它不仅能帮助你写出更健壮、更模块化的代码,也是理解许多高级模式和框架内部原理的基础。