12k
All articles

Node.js 现已内置防抖(Debounce)与节流(Throttle)

Node.js 26.10.0和24.22.0新增util.debounce与util.throttle。了解其选项、Promise行为及与lodash的差异。

OpenReplay Team
OpenReplay Team
Node.js 现已内置防抖(Debounce)与节流(Throttle)

Node.js 26.10.0 和 24.22.0 在 node:util 模块中新增了 util.debounce() 和 util.throttle()。服务端代码无需安装 lodash 或 throttle-debounce,即可对函数进行防抖和限流。

你可能曾为文件监听器再写一个 setTimeout/clearTimeout 包装函数,或者在一个除此之外完全不用 lodash 的服务里引入了 lodash.debounce。现在这些都不需要了,这两个工具函数已成为运行时的一部分。

本文介绍支持这两个函数的发布线、它们的函数签名与选项、各自的服务端示例,以及移除 lodash 时需要做哪些改动。本文是 可替代 npm 包的 Node.js 内置 API 系列的续篇。

核心要点

  • util.debounce() 和 util.throttle() 可在 Node.js 26.10.0+ 和 24.22.0+ 中使用,二者均来自 nodejs/node PR #65899。
  • 对防抖函数的每次调用都会返回一个 Promise。同一防抖窗口内的所有调用,都会以 fn 单次执行的结果完成(settle)。
  • util.throttle(fn, limit, interval) 允许每 interval 毫秒内最多启动 limit 次执行,默认情况下多余的调用会进入队列,而不是被丢弃。
  • 被 util.throttle() 丢弃的调用会以 ERR_THROTTLED 被拒绝(reject),且 Node 会将这些拒绝标记为已处理。
  • 两个函数都没有 trailing 或 maxWait 选项,因此从 lodash 迁移过来的代码需要按 Node 的选项集进行调整。

哪些 Node.js 版本提供 util.debounce 和 util.throttle

这两个函数首次随 Node.js 26.10.0 发布,这是 2026 年 9 月 22 日发布的 Current 版本。随后它们于 2026 年 10 月 14 日被向后移植到 Node.js 24.22.0 ‘Krypton’ LTS。两项变更都来自 James M Snell 提交的 PR #65899。util 文档中两个函数均标注为 “Added in: v26.10.0”,但 24.22.0 的发布说明在 Notable Changes 中列出了它们。在 24.x 发布线上,最低版本为 24.22.0,因此之后的任何 24.x 补丁版本也都包含这两个函数。

如果你发布的库需要支持较旧的 Node 版本,请在使用前检查 util.debounce 是否存在,或保留一个回退实现。

防抖与节流有什么区别?

在 Node.js 中,util.debounce() 会在调用停止 wait 毫秒后执行一次 fn;而 util.throttle() 则限制每个 interval 内可以启动多少次 fn 调用。如需了解从零实现的原理,请参阅 通过防抖与节流提升性能。

util.debounce 在 Node.js 中如何工作?

util.debounce(fn, wait[, options]) 会等待距最近一次调用已过去 wait 毫秒后再调用 fn。在该窗口内发起的每次调用都会返回一个 Promise,并以这一次执行的结果完成。util.debounce 参考文档 列出了三个选项:

选项默认值作用
leadingfalse为 true 时,窗口内的第一次调用会立即执行 fn
rejectOnCancelfalse为 true 时,被后续调用取代的调用会以 AbortError 被拒绝
signal无中止后会取消待处理的调用并阻止后续调用。若传入的 signal 已处于中止状态,debounce() 会直接抛出异常

返回的函数还提供 cancel([reason])、flush()、pending、pendingCount、ref() 和 unref()。

需要 Node.js ≥ 24.22.0 或 ≥ 26.10.0。

import { debounce } from 'node:util';

const save = debounce(async (value) => `saved:${value}`, 100);

const calls = [save('a'), save('b'), save('c')];
console.log(save.pendingCount); // 3
console.log(await Promise.all(calls)); // [ 'saved:c', 'saved:c', 'saved:c' ]

fn 只会接收到最近一次调用的参数。若要收集一次突发中的所有事件,需将它们存储在 fn 外部,并在 fn 执行时读取。下面的文件监听器就是用一个 Set 来实现这一点的:

import { watch } from 'node:fs';
import { debounce } from 'node:util';

const changed = new Set();

const rebuild = debounce(async () => {
  const batch = [...changed];
  changed.clear();
  console.log('rebuilding for', batch);
  return batch;
}, 200).unref();

const watcher = watch('./src', (eventType, filename) => {
  changed.add(filename);
  rebuild().catch(console.error);
});

process.on('SIGINT', () => {
  rebuild.flush();
  watcher.close();
});

对防抖或节流函数调用 unref(),可以让进程在其定时器仍处于待执行状态时正常退出。这非常适合文件监听器和日志刷新器这类绝不应阻塞关闭流程的场景。unref() 会返回函数本身,因此可以直接链式调用在 debounce() 之后。在关闭时,flush() 会执行最后一批任务,避免数据丢失。

与节流的 ERR_THROTTLED 拒绝不同,防抖并不会将所有拒绝都标记为已处理。从 Node 源码来看,一旦某次调用被后续调用取代,其 Promise 会被标记为已处理,但最近一次调用的 Promise 不会。如果 cancel() 或已中止的 signal 拒绝了该 Promise 且没有任何代码捕获它,进程就会出现未处理的拒绝(unhandled rejection)。对于像上面监听器回调这样”调用后不管”(fire-and-forget)的调用,请加上 .catch。

util.throttle 如何对调用进行限流?

util.throttle(fn, limit, interval[, options]) 允许每 interval 毫秒内最多启动 limit 次 fn 执行。limit 必须大于 0。默认情况下,超出限制的调用会按到达顺序排队,而不是被丢弃。util.throttle 参考文档 定义了以下选项:

  • concurrency(默认 Infinity):同时进行中的 fn 调用的最大数量。当 fn 的返回值完成(settle)时释放一个槽位。
  • maxPending(默认 Infinity):队列大小上限。在 drop 模式下无效。
  • overflow(默认 'queue',或 'drop')。
  • strict(默认 false)。
  • signal:中止后,待处理和后续的调用都会以 AbortError 被拒绝。

速率配额在 fn 开始执行时才被消耗,而不是在调用加入队列时。该函数还提供 cancel([reason])、hasImmediateCapacity()、pending、pendingCount、activeCount、ref() 和 unref()。

import { setTimeout as sleep } from 'node:timers/promises';
import { throttle } from 'node:util';

const callApi = throttle(async (id) => {
  await sleep(50); // stand-in for a real request
  return { id };
}, 5, 1_000, { concurrency: 2, maxPending: 100 });

const calls = Array.from({ length: 12 }, (_, i) => callApi(i));
console.log('active:', callApi.activeCount, 'queued:', callApi.pendingCount);
console.log(await Promise.all(calls)); // results in call order

默认情况下,util.throttle() 按固定窗口计数执行次数。这可能导致两组调用在窗口边界处紧挨着启动。设置 strict: true 后,则会在任意滚动区间内强制执行该限制。

如果要让某个函数每秒最多执行一次,并丢弃多余的调用而非排队,可以使用 throttle(fn, 1, 1000, { overflow: 'drop' }):

import { throttle } from 'node:util';

const ping = throttle(async () => 'pong', 1, 1_000, { overflow: 'drop' });

const results = await Promise.allSettled([ping(), ping(), ping()]);
for (const r of results) {
  console.log(r.status === 'fulfilled' ? r.value : r.reason.code);
}
// pong, ERR_THROTTLED, ERR_THROTTLED

if (ping.hasImmediateCapacity()) ping(); // gate instead of catching

被 util.throttle() 丢弃的调用会以 ERR_THROTTLED 错误 被拒绝。调用被丢弃的原因有两种:overflow 设为 'drop',或 maxPending 队列已满。Node 会将这些 Promise 标记为已处理,且被丢弃的调用不占用任何速率或并发配额。cancel() 会清空队列中的调用并重置窗口,但不会停止已经开始的执行。

如何替换 lodash.debounce 和 lodash.throttle?

在大多数服务端代码中,util.debounce() 和 util.throttle() 可以替代 lodash.debounce 和 lodash.throttle,但二者的模型与 lodash 并不相同。Node 的节流限制的是每个区间内可以启动多少次调用,并将其余调用排队;而 lodash 的节流则是每个 wait 内最多允许一次调用。迁移代码时,请仅使用 node:util 的选项集:

关注点node:util 的行为需要做的调整
返回值每次调用都返回一个 Promise使用 await 或 .catch 处理 Promise
边缘选项仅有 leading,没有 trailing 或 maxWait 选项删除这些键,并重新设计任何 maxWait 逻辑,例如改用节流
节流模型每个 interval 允许启动 limit 次调用,多余的进入队列若需”跳过多余调用”的行为,使用 limit 为 1 并配合 overflow: 'drop'
手动控制cancel([reason]) 和 flush()直接在包装函数上调用
this在 fn 内部,this 指向包装函数先对方法进行绑定
// before: hand-rolled
let timer;
const saveLater = (doc) => {
  clearTimeout(timer);
  timer = setTimeout(() => store.save(doc), 500);
};
// after
import { debounce } from 'node:util';

const saveLater = debounce(store.save.bind(store), 500);
saveLater(doc).catch(console.error);
// on shutdown: saveLater.flush(); or discard: saveLater.cancel();

何时应继续使用 lodash 或自定义辅助函数

在以下两种情况下,应继续使用 lodash、throttle-debounce 或手写的辅助函数,而不是 node:util:

  • 浏览器端代码。 node:util 是服务端模块,前端打包产物仍需要自己的辅助函数;请参阅 React 防抖策略。
  • 低于 24.22.0 的 Node 版本。 这包括更早的 24.x 补丁版本以及所有更旧的发布线。需要支持这些版本的项目应保留该依赖,或添加特性检测。

总结

在 Node.js 26.10.0+ 和 24.22.0+ 中,防抖和限流已成为 node:util 的一部分。两个函数都返回 Promise,并内置了 cancel() 和 unref(),防抖函数还额外提供 flush()。下一步,你可以找出服务端代码中使用 lodash.debounce 或 lodash.throttle 的位置,先将一个文件监听器或出站客户端替换为内置实现。包装方法之前请先进行绑定,并为每个未 await 的调用添加 .catch。

常见问题

设置 leading: true 时,util.debounce 会执行两次函数吗?

只有在窗口内有更多调用到来时才会。当 leading 设为 true 时,防抖窗口内的第一次调用会立即执行 fn。只有在等待时间结束前防抖函数再次被调用,才会发生第二次(尾部)执行。因此,一次孤立的调用只会在前沿执行 fn 一次,而且也不存在可以关闭的 trailing 选项。

util.debounce 会等待异步函数执行完毕后再开启新窗口吗?

不会。在一次尾部执行之后,即使 fn 返回的 Promise 尚未完成,util.debounce 也可能开启新的防抖窗口,因此一个较慢的异步 fn 的两次执行可能会重叠。要避免执行重叠,请使用 util.throttle 并将 concurrency 设为 1。其并发槽位只会在 fn 的返回值完成后才被释放。

如何对 util.debounce 进行特性检测,同时不会在旧版 Node.js 上报错?

导入整个模块并检查该属性:使用 import util from 'node:util',然后在使用前判断 typeof util.debounce === 'function',若不存在则回退到用户态的辅助函数。在 ESM 中,对一个不存在的导出进行具名导入会在模块链接阶段抛出 SyntaxError,代码根本没有机会执行这项检查。

当 util.throttle 的 AbortSignal 被中止时,队列中的调用会怎样?

它们会被拒绝。当传给 util.throttle 的 signal 被中止时,待处理和后续的调用都会以 AbortError 被拒绝,且 signal 的 reason 会被设为该错误的 cause。已经开始的执行会继续运行。与 ERR_THROTTLED 拒绝不同,文档并未说明这些 AbortError 拒绝会被标记为已处理,因此请为每个未 await 的调用添加 catch 处理程序。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.