Javascript 零基础教程

JavaScript 闭包常见用法

闭包是 JavaScript 中一个强大但也时常让人困惑的特性。虽然我们已经涵盖了闭包的基本概念(即函数即使在外部函数执行完毕后,也能“记住”并访问其周围作用域中的变量),但在实际开发中理解它们的常用方式至关重要。

本章节将探索几个闭包大显身手的真实场景,帮助你巩固这一重要概念,并为更高级的 JavaScript 开发做好准备。

1. 用闭包模拟私有变量

JavaScript 并不像某些其他语言(如 Java 或 C++)那样拥有内置的私有变量支持(虽然现代 JS 引入了 # 私有字段,但闭包仍是经典实现方式)。然而,闭包提供了一种达到类似效果的方法。通过使用闭包,我们可以创建只能在特定函数作用域内访问的变量,从而有效地使它们成为“私有”变量。

1.1 基础示例:计数器

考虑一个简单的计数器。我们希望创建一个计数器,每次调用函数时它的值都会增加,但我们不希望计数器的值能从计数器函数外部直接访问或修改。

function createCounter() {
  let count = 0; // 这个变量对于 createCounter 函数是私有的
  return {
    increment: function() {
      count++;
      return count;
    },
    decrement: function() {
      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

// 尝试直接访问 'count' 会导致错误(或 undefined)
// console.log(counter.count); // undefined

解释:

  1. createCounter 是一个定义了变量 count 的函数。
  2. 它返回一个包含三个方法的对象:increment(增加)、decrement(减少)和 getValue(获取值)。
  3. 这每一个方法都对 count 变量形成了闭包。这意味着即使在 createCounter 执行完毕后,它们依然“记住”了 count,并可以访问和修改它。
  4. 关键在于,count 不能从 createCounter 外部直接访问。它实际上是私有的。

1.2 进阶示例:银行账户

让我们将这个概念扩展到一个更复杂的场景:银行账户。我们希望封装账户余额,并提供存款和取款的方法,同时防止直接访问余额。

function createBankAccount(initialBalance) {
  let balance = initialBalance;
  return {
    deposit: function(amount) {
      if (amount > 0) {
        balance += amount;
        return `存入 ${amount}。新余额: ${balance}`;
      } else {
        return "存款金额必须为正数。";
      }
    },
    withdraw: function(amount) {
      if (amount > 0 && amount <= balance) {
        balance -= amount;
        return `取出 ${amount}。新余额: ${balance}`;
      } else if (amount <= 0) {
        return "取款金额必须为正数。";
      }
       else {
        return "余额不足。";
      }
    },
    getBalance: function() {
      return balance;
    }
  };
}

const account = createBankAccount(100);
console.log(account.deposit(50));   // 输出: 存入 50。新余额: 150
console.log(account.withdraw(20));  // 输出: 取出 20。新余额: 130
console.log(account.getBalance());  // 输出: 130
console.log(account.withdraw(200)); // 输出: 余额不足。

// 尝试直接访问 'balance' 会失败
// console.log(account.balance); // undefined

解释:

  1. createBankAccount 接受一个 initialBalance(初始余额)作为参数。
  2. 它定义了一个 balance 变量,该变量对此函数是私有的。
  3. 返回的对象包含形成闭包的方法(deposit, withdraw, getBalance),它们可以访问 balance 变量。
  4. 这些方法可以修改 balance,但 balance 本身在 createBankAccount 函数外部是不可访问的。这保护了账户余额免受未经授权的修改。

2. 在异步 JavaScript 中保持状态

当处理 JavaScript 中的异步操作时,如 setTimeoutsetInterval 和事件监听器,闭包特别有用。这些操作不会立即执行;相反,它们被安排在稍后运行。闭包允许我们在异步操作最终执行时,“记住”来自当前作用域的值。

2.1 基础示例:setTimeout 循环陷阱

在使用 setTimeout 配合循环时,一个常见的陷阱是:由于闭包与事件循环的交互方式,所有延迟执行的函数调用往往都使用了循环变量的最终值

function delayedLogs() {
  for (var i = 1; i <= 3; i++) {
    setTimeout(function() {
      console.log("值: " + i);
    }, i * 1000);
  }
}
delayedLogs(); // 输出: 值: 4 (在 1、2、3 秒后分别打印了三次 4)

解释(为什么会出问题):

  1. 等到 setTimeout 的回调函数最终执行时,循环已经结束了,此时变量 i 的值已经是 4
  2. 因为回调函数对变量 i 本身形成了闭包,而不是对调用 setTimeouti 的值形成闭包,所以每个回调都访问的是同一个、最终的 i 值。

为了修复这个问题,我们可以使用闭包来捕获循环每次迭代时的 i 值。

function fixedDelayedLogs() {
  for (var i = 1; i <= 3; i++) {
    (function(j) { // 立即调用函数表达式 (IIFE)
      setTimeout(function() {
        console.log("修复后的值: " + j);
      }, j * 1000);
    })(i); // 将 'i' 作为参数传递给 IIFE
  }
}
fixedDelayedLogs(); // 输出: 修复后的值: 1, 修复后的值: 2, 修复后的值: 3 (分别在 1、2、3 秒后打印)

解释(修复原理):

  1. 我们将 setTimeout 调用包裹在一个 立即调用函数表达式 (IIFE) 中。
  2. IIFE 接收 i 作为参数,并将其命名为 j
  3. 在 IIFE 内部,setTimeout 的回调函数对 j 形成了闭包。因为 j 是 IIFE 的参数,它的值在循环的每次迭代中执行 IIFE 时被捕获。
  4. 因此,每个回调现在都有了它自己私有的、正确的值的副本(1, 2, 和 3)。

3. 进阶示例:循环中的事件处理程序

在循环中给元素添加事件监听器时,闭包也很有用。想象你正在创建一系列按钮,并希望每个按钮在被点击时显示其索引。

<!DOCTYPE html>
<html>
<head>
  <title>闭包示例</title>
</head>
<body>
  <div id="container"></div>
  <script>
    const container = document.getElementById('container');
    for (let i = 0; i < 3; i++) {
      const button = document.createElement('button');
      button.textContent = `按钮 ${i + 1}`;
      container.appendChild(button);
      
      (function(buttonIndex) {
        button.addEventListener('click', function() {
          alert(`这是按钮 ${buttonIndex + 1}`);
        });
      })(i);
    }
  </script>
</body>
</html>

解释:

  1. 我们在循环中创建了三个按钮。
  2. 为每个按钮添加了一个点击事件监听器。
  3. 我们使用 IIFE 对循环变量 i 创建了一个闭包,为每个按钮捕获了它的值。
  4. 当按钮被点击时,警告框会显示正确的按钮编号,因为事件监听器的回调函数通过闭包访问了被捕获的 buttonIndex 值。

4. 偏函数应用 (Partial Application) 和柯里化 (Currying)

闭包是实现偏函数应用和柯里化的基础,这是函数式编程中的两种强大技术。

4.1 偏函数应用 (Partial Application)

偏函数应用涉及通过预先填充现有函数的部分参数来创建一个新函数。这个新函数接收剩余的参数。

function multiply(x, y) {
  return x * y;
}

function createMultiplier(x) {
  return function(y) {
    return multiply(x, y);
  };
}

const double = createMultiplier(2);  // 创建一个乘以 2 的函数
const triple = createMultiplier(3);  // 创建一个乘以 3 的函数

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

解释:

  1. createMultiplier 接收一个参数 x,并返回一个新函数。
  2. 返回的函数接收另一个参数 y,并使用捕获的 x 值(来自外部函数的作用域)与 y 相乘。
  3. doubletriple 是偏函数应用的例子。它们是 multiply 函数的特化版本,其中一个参数已被预先填充。

4.2 柯里化 (Currying)

柯里化是一种将接受多个参数的函数转换为一系列只接受单个参数的函数的技术。每个函数返回另一个接受下一个参数的函数,依此类推,直到所有参数都提供完毕,此时返回最终结果。

function curryMultiply(x) {
  return function(y) {
    return function(z) {
      return x * y * z;
    };
  };
}

const multiplyX = curryMultiply(2);
const multiplyXY = multiplyX(3);
const result = multiplyXY(4);

console.log(result); // 输出: 24

解释:

  1. curryMultiply 接收一个参数 x,并返回一个函数。
  2. 返回的函数接收另一个参数 y,并返回另一个函数。
  3. 最内层的函数接收最后一个参数 z,并使用捕获的 xy 值(来自外部函数的作用域)与 z 相乘。
  4. 链中的每个函数都返回另一个函数,直到所有参数都提供完毕,此时执行最终计算。

5. 迭代器 (Iterators)

闭包可以用来创建自定义迭代器,允许你控制如何访问一系列值。

function createIterator(array) {
  let index = 0;
  return {
    next: function() {
      if (index < array.length) {
        return { value: array[index++], done: false };
      } else {
        return { value: undefined, done: true };
      }
    }
  };
}

const myArray = [10, 20, 30];
const myIterator = createIterator(myArray);

console.log(myIterator.next()); // 输出: { value: 10, done: false }
console.log(myIterator.next()); // 输出: { value: 20, done: false }
console.log(myIterator.next()); // 输出: { value: 30, done: false }
console.log(myIterator.next()); // 输出: { value: undefined, done: true }

解释:

  1. createIterator 接收一个数组作为输入。
  2. 它初始化一个 index 变量来跟踪数组中的当前位置。
  3. 它返回一个带有 next 方法的对象。
  4. next 方法使用闭包来访问和更新 index 变量。
  5. 每次调用 next 时,它都会返回数组中的下一个值以及一个表示迭代是否完成的 done 标志。