首页 / 文章

有关闭保障的工厂:私有化国家、缓冲机制、速率限制与撤销功能

了解闭包如何在 JavaScript 工厂函数中维持私有状态,为何会在 React 定时器中导致过时值,以及防抖、限流和撤销功能是如何依赖闭包的。

发布者:REACTAPP.TOP阅读约 7 分钟1631 词

来源: Closures in JavaScript and React, Explained Simply

大量的日常 JavaScript 功能背后都离不开闭包:计数器、防抖输入处理、请求节流、撤销栈以及几乎所有的 React 钩子函数。此外,一类错误也源于闭包——回调函数返回的值已不再符合预期。本指南将给出一个可行的定义,说明其对 React 状态的影响,并详细介绍四种实用的工厂模式,包括在生产环境中使用它们前需了解的局限性。

可行的定义

闭包是指与定义它的作用域中的变量一同被封装起来的函数。即使声明这些变量的外部函数已经返回,该函数仍能继续访问这些变量。一个形象的比喻是:函数离开“家”时带了一个装有所引用变量的袋子;无论之后在何处被调用,它都会从同一个袋子里取值。

计数器工厂模式

经典的示例是一个工厂函数,它返回一个能够修改其并不拥有的变量的函数:

function makeCounter() {
  let count = 0; // lives inside makeCounter

  function increment() {
    count = count + 1;
    console.log(count);
  }

  return increment;
}

const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3

具体过程如下:

  • makeCounter()仅执行一次,并声明了count变量。
  • 它返回一个引用count的increment函数,之后makeCounter便结束执行。
  • 每次调用counter()时都会读取并更新同一个count变量,因此输出值会从1递增到3。

通常情况下,函数返回后其局部变量就会变得无法访问,此时引擎可以自由回收这些变量。但在这里,increment仍然指向count,因此只要increment存在,该变量就会保持有效。如果你想了解引擎最初是如何找到count的底层规则,文章关于作用域链如何解析变量中详细介绍了这一机制。

为何 React 状态在定时器中会失效

React 并没有在闭包之上添加任何特殊功能;它只是在各个地方都依赖闭包,尤其是在钩子函数中。正是这种依赖导致了组件代码中最常见的意外现象之一:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setTimeout(() => {
      console.log("Count is: " + count);
    }, 3000);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Add</button>
      <button onClick={handleClick}>Log count in 3 seconds</button>
    </div>
  );
}

将count的初始值设为0。点击“3秒后记录计数”,然后在计时器触发前连续点击“添加”三次。控制台输出的是0,而非3。

原因是每次渲染Counter时都会进行新的函数调用,每次调用都会有独立的count常量,并定义各自的handleClick函数。setTimeout的回调是在count值为0时创建的,因此它只能看到那个值的count。

该模型可以解释大多数“为什么我的状态过时了?”之类的问题,而这正是useEffect中依赖数组的存在目的:它告诉React该效应依赖于哪些捕获的值,这样当这些值发生变化时,React就可以使用新的值重新运行该效应。如果在延迟回调中确实需要最新的值,常见的方法是为写操作使用函数式更新,如setCount(c => c + 1),或为读操作使用需要保持同步的ref。关于这些选择之间的权衡,可在延迟回调中的快照值与实时值对比一文中了解。

你在实际代码中会遇到的四种工厂模式

无需类的封装状态

在带有 # 前缀的字段成为该语言的一部分之前,闭包是隐藏数据的标准方式,至今仍是一种轻量级的解决方案。在工厂函数中声明的任何内容都只能通过其返回的函数来访问:

function createBankAccount(balance) {
  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    }
  };
}

const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined — can't touch it directly

balance 并非返回对象的属性,因此 account.balance 的值为 undefined。修改它的唯一方法是通过 deposit 和 withdraw 函数。

搜索输入的防抖处理

防抖机制会延迟执行操作,直到输入停止了一段时间。正是它避免了搜索框在每次按键时都发送请求:

function debounce(func, delay) {
  let timer; // remembered between calls
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => func(...args), delay);
  };
}

const searchInput = document.getElementById("search");
const handleSearch = debounce((e) => {
  fetch(`/api/search?q=${e.target.value}`);
}, 300);

searchInput.addEventListener("input", handleSearch);

关键在于timer的声明位置:只在一次,即在debounce函数内部、返回的函数外部。每次按键都会调用该返回函数,该函数会清除即将到期的超时并安排新的超时,因此只有在300毫秒内没有输入时才会发送请求。如果timer被声明在返回的函数内部,那么每次调用都会得到一个新的变量,之前的超时将无法被取消,这样每次按键仍会生成请求。

在React组件中,逻辑相同,但多了一个步骤:必须只创建一次去抖函数并保持其稳定性(例如使用useMemo或ref),否则每次渲染都会生成带有新timer的新闭包,导致去抖功能悄然失效。

一个简单的请求限制器

同样的技术也可限制端点被调用的频率:

function createRateLimiter(maxRequests, windowMs) {
  let requestCount = 0;
  setInterval(() => (requestCount = 0), windowMs);

  return function () {
    requestCount++;
    return requestCount <= maxRequests;
  };
}

const rateLimiter = createRateLimiter(5, 60000); // 5 requests per minute

app.get("/api/data", (req, res) => {
  if (!rateLimiter()) {
    return res.status(429).send("Too many requests");
  }
  res.send("Here's your data");
});

requestCount 会被每次调用 rateLimiter 的实例共享,且会在每个时间窗口重置。此版本是在内存中维护的固定时间窗口计数器,因此需注意其限制:

  • 它会将所有调用者合并计数,因此一个客户端就可能耗尽所有人的调用额度;真正的限流器会按用户、令牌或 IP 对计数进行区分
  • 状态存储在单个进程中,因此多个服务器实例各自都能达到最大限流值
  • 固定时间窗口会导致在时间边界处出现流量激增,短时间内调用次数可能达到限流值的两倍
  • setInterval 永远不会被清除,这在测试或动态创建限流器时很重要

撤销与重做历史记录

历史记录管理器会为每个实例独立维护其栈结构和光标位置:

function createHistoryManager() {
  let history = [];
  let currentIndex = -1;

  return {
    save(state) {
      history = history.slice(0, currentIndex + 1);
      history.push(state);
      currentIndex++;
    },
    undo() {
      if (currentIndex > 0) currentIndex--;
      return history[currentIndex];
    },
    redo() {
      if (currentIndex < history.length - 1) currentIndex++;
      return history[currentIndex];
    }
  };
}

save函数会首先丢弃当前位置之后的所有内容,因此在执行撤销操作后再记录新的更改时,就会删除重做分支,这与大多数编辑器的行为一致。undo和redo函数只是将currentIndex移动到有效范围内,并返回该位置的状态。需要注意的是,存储的状态应当是不可变的快照;如果先保存了一个对象后又对其进行了修改,那么所有的历史记录都会指向同一组已被修改的数据。

识别闭包的快速方法

  • 内部函数使用外部函数的变量。在另一个函数内部定义且引用该外部函数变量的函数即为闭包。
  • 它捕获的是绑定值,而非新渲染结果的实时更新。在 React 中,每次渲染都会创建新的绑定,因此旧的回调函数会保留其自身渲染时的值。严格来说,闭包保存的是变量本身,所以如果该变量后来被重新赋值——就像计数器工厂中的 count 那样——闭包会看到新的值。
  • 返回的函数是一个重要线索。当一个函数返回另一个函数时,几乎总是涉及闭包。
  • 循环中的 var 陷阱

    循环变量最能清晰地体现绑定规则:

    for (var i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 1000);
    }
    // Logs: 3, 3, 3 — var is shared across the whole loop
    
    for (let i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 1000);
    }
    // Logs: 0, 1, 2 — let creates a new i on every iteration
    

    使用 var 时,整个函数只有一个 i 变量,当超时触发时循环已经将其值设为 3,因此三个回调都会输出 3。而使用 let 时,每次迭代都会有独立的 i 值绑定,每个回调捕获的都是不同的值,因此分别输出 0、1 和 2。

    总结

    闭包是实现私有状态、防抖输入、请求限制器、撤销栈和自定义钩子等功能背后的机制,同时也与许多状态过时错误有关。两个问题就能解决大部分困惑:该函数捕获了哪个变量?此后这个变量是否被新的变量替换过?在普通的 JavaScript 工厂函数中,捕获的变量是共享的并且会就地更新。而在 React 中,每次渲染都会生成新的变量,因此任何生命周期超过一次渲染的回调函数都会看到旧的状态,除非你特意为其提供读取当前状态的方式。