有关闭保障的工厂:私有化国家、缓冲机制、速率限制与撤销功能
了解闭包如何在 JavaScript 工厂函数中维持私有状态,为何会在 React 定时器中导致过时值,以及防抖、限流和撤销功能是如何依赖闭包的。
大量的日常 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移动到有效范围内,并返回该位置的状态。需要注意的是,存储的状态应当是不可变的快照;如果先保存了一个对象后又对其进行了修改,那么所有的历史记录都会指向同一组已被修改的数据。
识别闭包的快速方法
- 内部函数使用外部函数的变量。在另一个函数内部定义且引用该外部函数变量的函数即为闭包。
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 中,每次渲染都会生成新的变量,因此任何生命周期超过一次渲染的回调函数都会看到旧的状态,除非你特意为其提供读取当前状态的方式。