首页 / 文章

从零开始编写去抖工具函数:定时器、参数以及相关实现

逐行构建一个可重复使用的防抖函数,了解它为何会传递参数以及何时应使用节流机制,还有如何将其与AbortController结合使用。

发布者:REACTAPP.TOP阅读约 8 分钟1742 词

来源: Debouncing in JavaScript: How to Stop Sending Useless API Requests

每当用户输入一个字符时就会向后台发起请求的搜索框,会导致每个输入的字词都转化为大量请求,而其中大部分最终会被丢弃。解决此问题的方法是使用一种名为“防抖”的小型工具函数。在这里,你将实现它,理解每一行代码,选择在防抖与节流之间使用哪种方式,并将其与请求取消功能结合,从而打造出能够应对真实输入场景的搜索流程。

为何按键操作会生成无用请求

想象一下用户正在搜索某个框架。输入内容会逐个字符发生变化:

r
re
rea
reac
react

如果每次变化都会触发一次API调用,那么对于同一个查询,服务器就需要处理五次请求。如果搜索条件更加具体,情况会更糟:

r
re
rea
reac
react
react j
react js
react js d
react js de
react js dev

这样就会有十次请求,而只有最后一次能反映用户的真实需求;其余的请求仍然会占用带宽、服务器处理时间以及速率限制额度。

防抖的原理

防抖机制会延迟函数调用,直到经过设定时间且没有再次触发调用:等待用户操作完成后再执行相应操作。

假设搜索调用设置了500毫秒的防抖延迟,而用户再次输入相同的五个字符:

r
re
rea
reac
react

每次按键都会重新开始倒计时,因此在用户持续输入期间不会发送任何请求。经过500毫秒的静止期后,才会发出一个请求:

react → API request

每次调用时的判断循环:

Typing
  ↓
Wait 500ms
  ↓
Did user type again?
  ↓
Yes → Reset timer
  ↓
No → Execute function

没有防护措施的搜索框

首先使用一个普通的输入元素:

<input id="search" placeholder="Search..." />

在JavaScript中获取该元素的引用:

const searchInput = document.querySelector("#search");

然后为它添加一个监听器,将该值传递给搜索函数,该函数仅负责记录日志,以此统计其触发频率:

searchInput.addEventListener("input", (event) => {
    searchProducts(event.target.value);
});function searchProducts(query) {
    console.log("API request:", query);
}

现在假设有人输入了产品名称:

iphone

input 事件会随着每个字符的输入而触发一次,大致顺序如下:

i
ip
iph
ipho
iphon
iphone

因此处理函数会运行六次,并记录六次请求:

API request: i
API request: ip
API request: iph
API request: ipho
API request: iphon
API request: iphone

对于真实的 API 来说这相当于六次往返请求,而用户关心的只是:

iphone

几行代码实现防抖功能

该工具函数是一个闭包:它接收一个函数和一个延迟时间,然后返回一个包装函数供调用,以替代原始函数:

function debounce(fn, delay) {
    let timer;
return function (...args) {
        clearTimeout(timer);
        timer = setTimeout(() => {
            fn.apply(this, args);
        }, delay);
    };
}

每个防抖处理的函数对应一个计时器

let timer;

timer 存在于闭包中,因此每次调用包装函数时都能看到相同的值;这样就能知道之前的请求仍在等待处理。每次调用 debounce 都会获得一个独立的计时器。

取消待处理的请求

clearTimeout(timer);

每次调用都会取消任何正在等待执行的请求。首次使用时将 undefined 传递给 clearTimeout 并不会造成任何影响。

安排新的调用

timer = setTimeout(() => {
    fn.apply(this, args);
}, delay);

新的定时器会在延迟时间过后执行原始函数。以500毫秒为间隔时,快速连续的调用会按如下方式执行:

Call
 ↓
500ms timer starts
Call again
 ↓
Old timer cancelled
 ↓
New 500ms timer starts
Call again
 ↓
Old timer cancelled
 ↓
New 500ms timer starts

只有当完整的500毫秒时间过去且没有其他调用发生时,该函数才会执行一次,此时最后的参数值会起决定作用。

将该工具集成到搜索框中

用防抖版本替换普通的处理函数(这会替代之前的 searchProducts 声明,因为两者使用的是相同的名称):

const searchProducts = debounce((query) => {
    console.log("API request:", query);
}, 500);
searchInput.addEventListener("input", (event) => {
    searchProducts(event.target.value);
});

当用户输入那五个常见的字符时:

r
re
rea
reac
react

每次按键都会取消之前的定时器,最终静默期就会结束:

react
   ↓
500ms
   ↓
API request

最终只会发出一个API请求,而非五个。

为何包装器要传递参数

有两个看似可选的细节,却让该工具具备可重用性。首先是rest参数:

return function (...args)

其次是原始函数的调用方式:

fn.apply(this, args);

支持任意数量的参数

通用的防抖函数不应对所包装函数的签名做任何假设。它可能用于包装只接受一个参数的搜索函数:

const search = debounce((query) => {
    console.log(query);
}, 500);

或者用于包装接受两个参数的函数:

const calculate = debounce((a, b) => {
    console.log(a + b);
}, 500);

使用rest语法收集所有参数并传递过去,这样两者都能正常工作:

...args

保留调用上下文

同一行代码还会传递this:

fn.apply(this, args);

该包装函数是一个普通的function,因此它会接收调用者的this值,比如当延迟函数被作为方法存储时对应的对象。而setTimeout中的箭头函数没有自己的this,因此它会捕获包装函数的this值,再由apply方法将其传递出去。搜索处理函数不会察觉到这一点,但那些会丢失上下文的共享工具函数则会在基于对象的代码中引发令人困惑的错误。需要注意的是,无论怎样,作为fn参数传入的箭头函数都会保留其词法层面的this值。

延迟处理的优势所在

这种模式适用于那些会频繁触发且只需最终状态的场景。

搜索建议功能

简单的实现方式是每次按键都发送请求:

User types → API request

采用延迟处理的方式则会先等待暂停时间:

User types
     ↓
Wait
     ↓
User stops
     ↓
API request

窗口大小调整

拖动窗口边缘会持续触发resize事件,每次都会执行代价较高的操作:

window.addEventListener("resize", () => {
    expensiveCalculation();
});

这意味着每次拖动都可能触发数百次该操作。通过封装处理,这些操作会在尺寸调整完成后仅执行一次:

const handleResize = debounce(() => {
    expensiveCalculation();
}, 300);
window.addEventListener("resize", handleResize);

自动保存

在编辑器中,防抖机制会将连续的编辑操作在用户停止输入后合并为一次保存:

User types
User types
User types
User types
...
User stops
      ↓
Save document

注意:当用户持续输入时,尾随防抖机制不会触发;如果页面关闭,则正在处理的保存操作会丢失。编辑器通常会设置最大等待时间,或在visibilitychange事件时进行保存。

防抖与节流

这两者很容易混淆。防抖是等待操作停止后再执行一次:

Typing → Typing → Typing → STOP
                              ↓
                           Execute

节流则是无论有多少事件触发,都最多在固定间隔内执行函数一次:

Event → Execute
  ↓
100ms
  ↓
Ignore
  ↓
100ms
  ↓
Execute

在请求开始时执行与在请求结束时执行会带来细微差别,但这种区分已能涵盖大多数情况。

防抖功能无法解决过时响应问题

防抖机制限制的是新请求的发起数量,而非那些已正在传输中的请求的命运。假设用户等待足够长的时间让一个请求发出,随后又修改了查询条件:

User searches: react
       ↓
Request A

User changes search: react native
       ↓
Request B

如果请求B在请求A之前返回,较旧的响应会最后到达并替换掉新的结果。此时用户界面显示的将是:

react

尽管输入内容现在显示为:

react native

解决方法是结合防抖机制,使用AbortController取消过时的请求:

Debouncing
    ↓
Reduce unnecessary requests
    ↓
AbortController
    ↓
Cancel outdated requests

关于这种竞态条件的问题,可在“防抖无法解决的竞态条件问题及解决方案”一文中深入了解。

将两层整合在一起

该流程会保留模块级的controller。每次防抖函数被触发时,它都会取消之前的请求,创建一个新的控制器,并将其signal传递给fetch。encodeURIComponent用于保护特殊字符,而AbortError则会被忽略,因为这表示是主动取消操作而非失败:

const searchInput = document.querySelector("#search");
let controller;
const searchProducts = debounce(async (query) => {
    controller?.abort();
    controller = new AbortController();
    try {
        const response = await fetch(
            `/api/products?q=${encodeURIComponent(query)}`,
            {
                signal: controller.signal
            }
        );
        const data = await response.json();
        renderResults(data);
    } catch (error) {
        if (error.name !== "AbortError") {
            console.error(error);
        }
    }
}, 500);
searchInput.addEventListener("input", (event) => {
    searchProducts(event.target.value);
});

防抖机制避免了每次按键都发送请求;AbortController则用于停止那些结果已无关紧要的请求。

在实际应用中,还应跳过空查询,并在解析数据前检查response.ok,因为fetch不会因HTTP错误状态而拒绝请求。

简易决策模型

当某个事件频繁触发时,先考虑是否需要对每个事件都做出响应。如果不需要,则需通过另一个问题来决定处理方式。由于你需要的是最终值,因此应使用防抖机制:

Input events
    ↓
Do I need every event?
    ↓
No
    ↓
Do I care about the final state?
    ↓
Yes
    ↓
Debounce

对于滚动操作,由于需要在手势过程中持续更新,因此应使用节流机制:

Scroll events
    ↓
Do I need every event?
    ↓
No
    ↓
Do I need periodic updates while scrolling?
    ↓
Yes
    ↓
Throttle

总结

防抖的本质在于控制耗时操作的执行频率,其实现方式仅两行代码:

clearTimeout(timer);
timer = setTimeout(() => {
    fn();
}, delay);

当类似这样的事件连续快速触发时,应使用该机制:

input
resize
search
auto-save

几点需要注意的事项:

  • 防抖函数应只创建一次并重复使用;若每次事件触发或每次 React 渲染时都创建新函数(且未使用 useMemo 或 useRef),则会导致每个调用都有独立的计时器,从而使防抖效果失效。
  • 通过传递 ...args 和 this,可使该辅助函数安全地被重复使用。
  • 对于“最终状态”事件,选择去抖处理;而对于“持续更新中”的事件,则选择节流处理。
  • 去抖决定请求何时开始;AbortController则决定是否允许过时的请求完成。在基于网络的操作中,应同时使用这两种方法。