Eine Debounce-Funktion von Grund auf schreiben: Timer, Argumente und das
Bauen Sie eine wiederverwendbare Debounce-Funktion Schritt für Schritt auf, erfahren Sie, warum sie Argumente weiterleitet und wann stattdessen Throttling verwendet werden sollte, sowie wie man sie mit AbortController kombiniert.
Quellen: Debouncing in JavaScript: How to Stop Sending Useless API Requests
Ein Suchfeld, das bei jeder Tastenbetätigung auf die Backend-Plattform zugreift, verwandelt ein eingegebenes Wort in eine Flut von Anfragen, von denen die meisten wieder verworfen werden. Die Lösung ist ein kleines Hilfsprogramm namens Debouncing. Hier werden Sie es implementieren, jede Zeile verstehen, zwischen Debouncing und Throttling wählen sowie es mit der Stornierung von Anfragen kombinieren, um einen Suchvorgang zu schaffen, der auch bei echtem Tippen funktioniert.
Wie Tastenbetätigungen zu verschwendeten Anfragen werden
Stellen Sie sich vor, ein Benutzer sucht nach einem Framework. Der Eingabewert ändert sich schrittweise um ein Zeichen:
r
re
rea
reac
react
Falls jede Änderung eine API-Anfrage auslöst, muss der Server fünf Anfragen pro Abfrage verarbeiten. Eine präzisere Suche verschlimmert die Situation noch weiter:
r
re
rea
reac
react
react j
react js
react js d
react js de
react js dev
Das sind zehn Anfragen, wobei nur die letzte das Ausgangswunsch des Benutzers widerspiegelt; die übrigen verbrauchen weiterhin Bandbreite, Serverzeit und das Rate-Limit.
Die Idee hinter Debouncing
Durch Debouncing wird ein Funktionsaufruf aufgeschoben, bis eine bestimmte Zeit vergangen ist, ohne weitere Aufrufe zu erfolgen: Lassen Sie den Benutzer fertig werden, bevor die Aufgabe ausgeführt wird.
Nehmen wir an, der Suchaufruf wird mit einer Verzögerung von 500 ms debounced und der Benutzer tippt erneut die gleichen fünf Zeichen ein:
r
re
rea
reac
react
Jeder Tastendruck startet die Zeitzählung neu, sodass während des Tippen nichts gesendet wird. Nach 500 ms Stille wird eine Anfrage abgesetzt:
react → API request
Die Entscheidungsschleife bei jedem Aufruf:
Typing
↓
Wait 500ms
↓
Did user type again?
↓
Yes → Reset timer
↓
No → Execute function
Ein Suchfeld ohne jeglichen Schutz
Beginnen wir mit einem einfachen Eingabeelement:
<input id="search" placeholder="Search..." />
Erhalten Sie eine Referenz darauf in JavaScript:
const searchInput = document.querySelector("#search");
Dann fügen Sie einen input-Listener hinzu, der den Wert an eine Suchfunktion übermittelt, die lediglich protokolliert, um zu zählen, wie oft sie ausgelöst wird:
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});function searchProducts(query) {
console.log("API request:", query);
}
Nun nehmen wir an, jemand tippt einen Produktnamen ein:
iphone
Das input-Event triggert einmal pro Zeichen, in etwa in dieser Reihenfolge:
i
ip
iph
ipho
iphon
iphone
Daher wird der Handler sechsmal ausgeführt und protokolliert sechs Anfragen:
API request: i
API request: ip
API request: iph
API request: ipho
API request: iphon
API request: iphone
Bei einer echten API wären das sechs Hin- und Rückreisen, während dem Benutzer nur Folgendes wichtig ist:
iphone
Debouncing in wenigen Zeilen implementieren
Die Hilfsfunktion ist eine Schließung: Sie nimmt eine Funktion und eine Verzögerungszeit entgegen und gibt einen Wrapper zurück, den man anstelle der ursprünglichen Funktion aufruft:
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
Ein Timer pro debogter Funktion beibehalten
let timer;
timer befindet sich in der Schließung, sodass jeder Aufruf des Wrappers denselben Wert sieht; so weiß ein Aufruf, dass ein früherer Aufruf noch wartet. Jeder Aufruf von debounce erhält seinen eigenen unabhängigen Timer.
Den ausstehenden Aufruf stornieren
clearTimeout(timer);
Jede Aufrufung storniert den ausstehenden Anruf, falls vorhanden. Der Übertrag von undefined an clearTimeout beim ersten Aufruf ist unbedenklich.
Einen neuen Anruf planen
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
Ein neuer Timeout führt die ursprüngliche Funktion nach der Verzögerung aus. Bei 500 ms verlaufen schnelle Aufrufe wie folgt:
Call
↓
500ms timer starts
Call again
↓
Old timer cancelled
↓
New 500ms timer starts
Call again
↓
Old timer cancelled
↓
New 500ms timer starts
Die Funktion wird nur einmal ausgeführt, wenn eine volle 500 ms-Zeitspanne ohne weiteren Aufruf vergeht, und die letzten Argumente haben Vorrang.
Die Hilfsfunktion in das Suchfeld einbinden
Ersetzen Sie den einfachen Handler durch eine abgefederte Version (dies ersetzt die frühere searchProducts-Deklaration, da beide denselben Namen verwenden):
const searchProducts = debounce((query) => {
console.log("API request:", query);
}, 500);
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});
Wenn der Benutzer die fünf bekannten Zeichen eintippt:
r
re
rea
reac
react
jeder Tastendruck storniert den vorherigen Timer, und schließlich endet die Ruhephase:
react
↓
500ms
↓
API request
Das Ergebnis ist eine einzige API-Anfrage anstelle von fünf.
Warum der Wrapper Argumente weiterleitet
Zwei Aspekte scheinen optional zu sein, sorgen aber dafür, dass das Hilfsprogramm wiederverwendbar ist. Der erste ist der Rest-Parameter:
return function (...args)
Der zweite Aspekt betrifft die Art und Weise, wie die ursprüngliche Funktion aufgerufen wird:
fn.apply(this, args);
Unterstützung beliebiger Anzahl an Argumenten
Ein generischer Debounce-Handler sollte keine Annahmen über die Signatur der umgewickelten Funktion machen. Er könnte eine Funktion zum Suchen mit einem Argument umwickeln:
const search = debounce((query) => {
console.log(query);
}, 500);
oder eine Funktion, die zwei Werte entgegennimmt:
const calculate = debounce((a, b) => {
console.log(a + b);
}, 500);
Durch die Sammlung aller Daten mithilfe der Rest-Syntax und deren Weitergabe bleibt beides funktionsfähig:
...args
Erhalt des Aufrufkontexts
Die gleiche Zeile übermittelt außerdem this:
fn.apply(this, args);
Der Wrapper ist eine normale Function, weshalb er das this des Aufrufers erhält – beispielsweise ein Objekt, wenn die verzögerte Funktion als Methode gespeichert wird. Die Pfeilfunktion innerhalb von setTimeout verfügt über kein eigenes this, weshalb sie das des Wrappers übernimmt, welches anschließend durch apply weitergegeben wird. Ein Suchhandler bemerkt dies nie, doch eine gemeinsam genutzte Hilfsfunktion, die den Kontext verliert, führt in objektorientiertem Code zu verwirrenden Fehlern. Beachten Sie, dass eine als fn übergebene Pfeilfunktion unabhängig davon ihr lexikales this beibehält.
Wo sich das Verzögern lohnt
Dieses Muster eignet sich für jedes Ereignis, das in kurzen Abständen auftritt und bei dem nur der endgültige Zustand relevant ist.
Suchvorschläge
Der naive Ansatz sendet bei jeder Tastenbetätigung eine Anfrage:
User types → API request
Der verzögerte Ansatz wartet zunächst auf die Pause:
User types
↓
Wait
↓
User stops
↓
API request
Vergrößerung/Verkleinerung des Fensters
Das Ziehen an einem Rand eines Fensters erzeugt kontinuierlich resize-Ereignisse, und ein direkter Zuhörer führt jedes Mal die aufwändige Verarbeitung aus:
window.addEventListener("resize", () => {
expensiveCalculation();
});
Das kann Hunderte von Ausführungen pro Ziehbewegung bedeuten. In einer Abdeckfunktion wird die Verarbeitung erst nach Abschluss der Größenanpassung ausgeführt:
const handleResize = debounce(() => {
expensiveCalculation();
}, 300);
window.addEventListener("resize", handleResize);
Autospeichern
In einem Editor wandelt das Verzögern einer Abfrage einen Strom von Änderungen in einen einzigen Speichervorgang um, nachdem der Benutzer aufgehört hat zu schreiben:
User types
User types
User types
User types
...
User stops
↓
Save document
Achtung: Ein solches Verzögerungssystem löst sich solange nicht aus, wie der Benutzer ununterbrochen tippt, und ein angehängter Speichervorgang geht verloren, wenn die Seite geschlossen wird. Editor fügen in der Regel eine maximale Wartezeit hinzu oder speichern bei visibilitychange.
Verzögern versus Drosseln
Die beiden Konzepte lassen sich leicht verwechseln. Beim Verzögern wird gewartet, bis die Aktivität aufhört, und anschließend einmal ausgeführt:
Typing → Typing → Typing → STOP
↓
Execute
Beim Drosseln wird die Funktion höchstens einmal pro festgelegtem Intervall ausgeführt, unabhängig davon, wie viele Ereignisse eintreffen:
Event → Execute
↓
100ms
↓
Ignore
↓
100ms
↓
Execute
Die Ausführung am Anfang oder am Ende des Zeitraums fügt Nuancen hinzu, doch diese Unterscheidung deckt die meisten Fälle ab.
Debouncing behebt nicht veraltete Antworten
Debouncing beschränkt die Anzahl der gestarteten Anfragen, nicht das Schicksal jener, die bereits unterwegs sind. Angenommen, ein Benutzer wartet lange genug, damit eine Anfrage gesendet wird, und präzisiert anschließend die Abfrage:
User searches: react
↓
Request A
User changes search: react native
↓
Request B
Falls Anfrage B vor Anfrage A zurückkommt, erreicht die ältere Antwort zuletzt und ersetzt die neueren Ergebnisse. Die Benutzeroberfläche zeigt dann Treffer für:
react
obwohl die Eingabe jetzt besagt:
react native
Die Lösung besteht darin, veraltete Anfragen mit AbortController abzubrechen, der über dem Debouncing-Verfahren implementiert wird:
Debouncing
↓
Reduce unnecessary requests
↓
AbortController
↓
Cancel outdated requests
Diese Rennbedingung wird ausführlich in „Rennbedingungen beheben – Debouncing kann das nicht lösen“ behandelt.
Zusammensetzen der beiden Schichten
Dieser Ablauf behält einen modulbezogenen controller bei. Jedes Mal, wenn die debounced Funktion ausgeführt wird, wird der vorherige Anfrage abgebrochen, ein neuer Controller erstellt und sein signal an fetch übergeben. encodeURIComponent schützt vor Sonderzeichen, und ein AbortError wird ignoriert, da er eine absichtliche Abbruchhandlung und nicht einen Fehler bedeutet:
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);
});
Die Debouncing-Funktion vermeidet eine Anfrage pro Tastendruck; der AbortController stoppt Anfragen, deren Ergebnisse nicht mehr relevant sind.
In der Produktion sollten außerdem leere Abfragen übersprungen und vor dem Parsen response.ok überprüft werden, da fetch bei HTTP-Fehlerstatus nicht abgelehnt wird.
Ein schnelles Entscheidungsmodell
Wenn ein Ereignis wiederholt ausgelöst wird, fragen Sie sich, ob Sie auf jedes einzelne reagieren müssen. Falls nicht, entscheidet eine weitere Frage. Für die Eingabe benötigen Sie den endgültigen Wert, daher verwenden Sie Debouncing:
Input events
↓
Do I need every event?
↓
No
↓
Do I care about the final state?
↓
Yes
↓
Debounce
Beim Scrollen möchten Sie während der Gestenbewegung regelmäßige Aktualisierungen, daher verwenden Sie Throttling:
Scroll events
↓
Do I need every event?
↓
No
↓
Do I need periodic updates while scrolling?
↓
Yes
↓
Throttle
Zusammenfassung
Debouncing dient dazu, zu steuern, wie oft aufwändige Aufgaben ausgeführt werden, und der Mechanismus besteht aus zwei Zeilen Code:
clearTimeout(timer);
timer = setTimeout(() => {
fn();
}, delay);
Wenden Sie Debouncing an, wenn Ereignisse in schneller Abfolge auftreten:
input
resize
search
auto-save
Mehrere Punkte, die man berücksichtigen sollte:
- Die debounced Funktion muss nur einmal erstellt und wiederverwendet werden; die Erstellung einer neuen Funktion pro Ereignis (oder pro React-Render ohne
useMemooderuseRef) führt dazu, dass jede Aufrufung ihren eigenen Timer erhält und der gewünschte Effekt deaktiviert wird. - Durch das Weitergeben von
...argsundthiswird die Hilfsfunktion sicher wiederverwendbar.
AbortController entscheidet, ob eine veraltete Anfrage abgeschlossen werden darf. Verwenden Sie beide für suchbasierte Funktionen, die über das Netzwerk ablaufen.