Javascript 零基础教程

JavaScript 闭包实战:从理论到真实场景应用

本章直接建立在前几章介绍的词法作用域和闭包的理解之上。

我们将重点放在实际应用上,演示如何使用闭包来解决常见的编程问题并在 JavaScript 应用程序中管理状态。

我们的目标是从理解闭包的理论过渡到在现实场景中自信地实现它们。

1. 使用闭包实现计数器

闭包最经典的例子之一就是创建一个计数器。让我们分析一下为什么这很有用,以及闭包是如何让它成为可能的。

1.1 基础计数器示例

function createCounter() {
  let count = 0; // `count` 在外部函数的作用域内初始化
  return {
    increment: function() { // `increment` 函数对 `count` 形成了闭包
      count++;
      return count;
    },
    decrement: function() { // `decrement` 函数也对 `count` 形成了闭包
      count--;
      return count;
    },
    getValue: function() {
      return count;
    }
  };
}

const counter = createCounter(); // 创建一个计数器的实例
console.log(counter.increment()); // 输出: 1
console.log(counter.increment()); // 输出: 2
console.log(counter.decrement()); // 输出: 1
console.log(counter.getValue());  // 输出: 1

解释:

  • createCounter() 函数: 这个外部函数定义了声明 count 变量的作用域。
  • count 变量: 初始化为 0,这个变量保存计数器的值。重要的是要注意 count 不是全局变量;它是 createCounter() 函数的局部变量。
  • 返回的对象: createCounter() 函数返回一个包含三个方法的对象:increment(增加)、decrement(减少)和 getValue(获取值)。
  • 闭包在行动: 每一个方法(incrementdecrementgetValue)都 闭合(close over)count 变量。这意味着即使在 createCounter() 函数执行完毕后,它们仍然保留了对 count 的访问权限。如果没有闭包,count 将无法访问,或者每次调用方法时都会重置。
  • 实例创建: const counter = createCounter(); 创建了计数器的一个特定实例。每个实例都有其自己的 count 变量,与其他计数器独立。

1.2 为什么这行得通

理解这一点的关键是词法作用域的概念。内部函数(incrementdecrementgetValue)是在 createCounter() 的词法作用域内定义的。因此,它们可以访问在该作用域中声明的变量,即使在 createCounter() 返回之后。闭包在内部函数的调用之间保留count 变量的状态。

1.3 更复杂的计数器示例

让我们扩展计数器的例子,增加设置初始值和指定增量步长的功能:

function createAdvancedCounter(initialValue = 0, step = 1) {
  let count = initialValue;
  return {
    increment: function() {
      count += step;
      return count;
    },
    decrement: function() {
      count -= step;
      return count;
    },
    getValue: function() {
      return count;
    },
    setValue: function(newValue) {
      count = newValue;
    }
  };
}

const counter1 = createAdvancedCounter(); // 使用默认的 initialValue 和 step
const counter2 = createAdvancedCounter(10); // initialValue = 10, step = 1
const counter3 = createAdvancedCounter(100, 5); // initialValue = 100, step = 5

console.log(counter1.increment()); // 输出: 1 (从 0 开始, 增加 1)
console.log(counter2.increment()); // 输出: 11 (从 10 开始, 增加 1)
console.log(counter3.increment()); // 输出: 105 (从 100 开始, 增加 5)

counter3.setValue(200);
console.log(counter3.getValue()); // 输出: 200

解释:

  • 参数: createAdvancedCounter 现在接受 initialValuestep 作为参数,提供了更大的灵活性。使用了默认参数,因此如果没有传递参数,计数器将从 0 开始并每次增加 1
  • 自定义: 每个计数器实例现在可以使用不同的起始值和增量步长进行初始化。
  • setValue 方法: 添加了一个 setValue 方法,允许你直接将计数器的值设置为一个特定的数字。

2. 使用闭包实现私有变量

闭包常被用来在 JavaScript 中模拟私有变量。JavaScript 并不像某些其他语言(如 Java 或 C++)那样拥有内置的私有变量支持。然而,闭包提供了一种机制来达到类似的效果。

2.1 问题:数据封装

在面向对象编程中,数据封装是将数据与操作该数据的方法捆绑在一起,并限制对对象某些组件的直接访问。这对于以下方面很重要:

  • 防止意外修改: 确保数据只能通过定义良好的方法进行更改。
  • 隐藏实现细节: 允许你在不影响使用它的代码的情况下更改对象的内部工作方式。

2.2 闭包作为解决方案

闭包允许你创建只能从特定函数(及其内部函数)内部访问的变量,从而有效地使它们成为“私有”变量。

2.3 示例:银行账户

function createBankAccount(initialBalance) {
  let balance = initialBalance; // `balance` 是私有的
  return {
    deposit: function(amount) {
      if (amount > 0) {
        balance += amount;
        return balance;
      } else {
        return "存款金额必须为正数。";
      }
    },
    withdraw: function(amount) {
      if (amount > 0 && amount <= balance) {
        balance -= amount;
        return balance;
      } else {
        return "余额不足或取款金额无效。";
      }
    },
    getBalance: function() {
      return balance;
    }
    // 从函数外部无法直接访问 `balance`
  };
}

const account = createBankAccount(1000);
console.log(account.deposit(500));   // 输出: 1500
console.log(account.withdraw(200));  // 输出: 1300
console.log(account.getBalance());   // 输出: 1300

// 下面的代码会导致错误或没有任何效果,
// 因为 `balance` 是私有的:
// account.balance = 0; // 这绝不会改变实际的余额
console.log(account.getBalance()); // 输出: 1300

解释:

  • balance 变量balance 变量声明在 createBankAccount 函数内部。它不能从函数外部直接访问。
  • 方法depositwithdrawgetBalance 方法定义在 createBankAccount 内部,并且它们 闭合(close over)balance 变量。
  • 隐私性createBankAccount 外部的代码只能通过这些方法与 balance 交互。没有办法直接读取或修改 balance 变量。
  • 封装: 这提供了封装:内部状态(余额)受到保护,对它的访问通过定义的接口(方法)进行控制。

2.4 私有变量的好处

  • 数据完整性: 防止意外或恶意修改 balance。只有 depositwithdraw 方法可以改变余额,并且它们可以包含验证逻辑(例如,检查金额是否为负数)。
  • 抽象: 银行账户的内部表示(余额如何存储)对外部世界是隐藏的。这允许你在以后更改实现细节,而不会破坏使用 createBankAccount 函数的代码。例如,你可以更改利息的计算方式,而不影响用户存取款的方式。
  • 可维护性: 通过控制对数据的访问,你使代码更容易理解和维护。

3. 在异步操作中保持状态

当处理 JavaScript 中的异步操作时,如 setTimeoutsetInterval 和事件监听器,闭包特别有用。它们允许你在异步操作最终执行时,“记住”来自外部作用域的值。

3.1 问题:异步代码和变量作用域

异步操作不会立即执行。它们被放入队列中,并在当前代码运行完毕后稍后执行。如果你尝试在异步回调中直接使用外部作用域的变量,这可能会导致意外行为。

3.2 示例:使用 setTimeout 循环

考虑以下代码,它试图在短暂延迟后打印数组中每个元素的索引:

function delayedLog() {
  for (var i = 0; i < 5; i++) {
    setTimeout(function() {
      console.log(i);
    }, i * 1000); // 延迟 i * 1000 毫秒
  }
}

delayedLog(); // 输出: 5, 5, 5, 5, 5 (在延迟之后)

为什么这不能按预期工作?

问题在于,等到 setTimeout 回调最终执行时,循环已经完成,而在所有的闭包中,i 的值都是 5var 关键字在函数作用域中声明 i,因此所有的回调都共享同一个 i 变量。

3.3 使用闭包捕获值

为了解决这个问题,你可以使用闭包来捕获循环每次迭代时的 i 值:

function delayedLogCorrected() {
  for (var i = 0; i < 5; i++) {
    (function(index) { // 立即调用函数表达式 (IIFE)
      setTimeout(function() {
        console.log(index);
      }, index * 1000);
    })(i); // 将 'i' 的当前值作为参数传递
  }
}

delayedLogCorrected(); // 输出: 0, 1, 2, 3, 4 (在延迟之后)

解释:

  • IIFE: 我们将 setTimeout 调用包裹在一个立即调用函数表达式 (IIFE) 中。这为循环的每次迭代创建了一个新的作用域。
  • index 参数: IIFE 接受 i 作为参数并将其赋值给参数 index。这为每次迭代创建了一个新变量 index,其中包含 i 的当前值。
  • 闭包setTimeout 回调现在闭合了 index 变量,该变量拥有该特定迭代的正确值。

3.4 使用 let(现代方法)

一个更现代、更简洁的解决方案是使用 let 关键字代替 varlet 声明了一个块级作用域变量,这意味着每次循环迭代都会自动创建一个新变量,从而无需 IIFE 即可有效地创建闭包:

function delayedLogLet() {
  for (let i = 0; i < 5; i++) {
    setTimeout(function() {
      console.log(i);
    }, i * 1000);
  }
}

delayedLogLet(); // 输出: 0, 1, 2, 3, 4 (在延迟之后)

解释:

  • let 是块级作用域: let 关键字在 for 循环的每次迭代中都为 i 创建了一个新的绑定。这意味着每个 setTimeout 回调都闭合了一个不同的 i 变量,每个变量都保存着该特定迭代的值。

虽然 let 是现代 JavaScript 中的首选方法,但理解 IIFE 方法仍然很有价值,因为它说明了闭包的基本概念以及如何使用它们来捕获变量值。