Én lytter i stedet for én klientkomponent per knapp
I det øyeblikket du legger klikk-analyse på en komponent, blir den en klientkomponent som sender med JavaScript. Gjør det på tvers av et helt nettsted, og sporingen alene blåser opp bundelen din. Her er alternativet som har ligget i plattformen fra tidenes morgen: én delegert lytter, deklarative data-attributter og komponenter som forblir ren, tjenerrendret HTML.
Her er et forløp som gjentar seg i nesten hver eneste kodebase. En produktsjef ber deg spore klikk på en knapp. Knappen er en fin, statisk, tjenerrendret komponent. Så du legger til en onClick-behandler. For å ha en onClick-behandler må komponenten nå kjøre i nettleseren — så du legger til "use client" (eller griper til en hook, eller en wrapper). Komponenten hydreres. Den sender med JavaScript. Og alt dette gjorde den ikke for å gjøre noe på klienten, men for å hviske én linje til analysen din.
Gang nå det med hver eneste sporede knapp, kort, lenke og fane på tvers av et stort nettsted. En betydelig del av JavaScript-bundelen din finnes utelukkende for å rapportere klikk. Denne artikkelen handler om alternativet — det som har sittet i nettleseren siden 1990-tallet og ikke trenger noe rammeverk: hendelsesdelegering. Én liten lytter overvåker hele siden; komponentene dine går tilbake til å være ren HTML.
Behandleren du legger til uten å tenke
Refleksen ser harmløs ut. Du har en lenke, du vil vite når den klikkes, så du kobler på en behandler:
"use client"; // ← the moment analytics arrives, so does this line
import { track } from "@/lib/analytics";
export function CtaButton({ href, label, place }: Props) {
return (
<a
href={href}
onClick={() =>
track("cta_clicked", { place, label })
}
>
{label}
</a>
);
}Én linje med sporing, og hele komponenten byttet kategori. Den er ikke lenger statisk markup som tjeneren kan strømme og glemme — den er en interaktiv øy som klienten må laste ned, tolke og hydrere før den onClick-en i det hele tatt finnes. Rammeverket kan ikke vite at behandleren bare er fire-and-forget-telemetri; så vidt det vet, må denne komponenten være i live på klienten.
Den refleksen bærer kostnader som hoper seg opp over en kodebase:
- Hver sporet komponent blir en hydreringsgrense — JavaScript som lastes ned, tolkes og kjøres for noe som aldri endrer grensesnittet.
- Sporingslogikken er spredt utover hundrevis av komponenter, som hver importerer analyse-SDK-en, så SDK-en havner i mange bundler.
- Analysebiblioteket lastes på den kritiske stien og konkurrerer med renderingen brukeren faktisk venter på.
- "Hvilke hendelser sender vi, og fra hvor?" blir et arkeologisk prosjekt — svaret er spredt utover hele komponenttreet.
Hvorfor det føles riktig
Det er verdt å være ærlig om hvorfor vi gjør dette. Samlokalisering er en genuint god instinkt: klikket og det du vil registrere om klikket bor på samme sted, så å legge sporingskallet nettopp der leser godt og er lett å resonnere rundt. Rammeverkene forsterker det — en onClick-prop er den åpenbare, dokumenterte måten å reagere på et klikk, og den drar tilfeldigvis med seg hydrering på kjøpet.
Men å samlokalisere intensjonen om å spore krever ikke at man samlokaliserer koden som sporer. Du kan beholde deklarasjonen ved siden av elementet — "denne knappen er et CTA-klikk" — mens selve lyttingen skjer et helt annet sted. Nettleseren har alltid kunnet gjøre dette; vi sluttet bare å gripe til det da komponentene fikk behandlere per element til å føles gratis.
Hendelser bobler — det er hele trikset
Når du klikker på et element, utløser ikke nettleseren bare en hendelse på nettopp det elementet. Hendelsen reiser oppover i DOM-treet — fra elementet, til forelderen, til dens forelder, helt opp til document. Dette er bobling, og det betyr at én enkelt lytter på toppen kan observere klikk på alt under seg. Du trenger ikke én lytter per knapp; du trenger én lytter som overvåker dokumentet og regner ut, per klikk, hva som faktisk ble klikket.
Verktøyet for det er Element.closest(). Med start der klikket landet — som kan være et ikon eller et <span> nestet inne i lenken din — vandrer closest() oppover til den finner en forfar som matcher en selektor. Be den om det nærmeste elementet som bærer en sporingsmarkør, og du får tilbake nøyaktig den knappen som ble klikket, uansett hva som lå rett under pekeren. Den ene primitiven erstatter hver eneste behandler per komponent.
Kontrakten er et data-attributt
Hvis en global lytter skal håndtere klikket, er komponentens eneste jobb å deklarere hva som skal spores — i markupen, der en tjener kan rendre det. HTML har allerede mekanismen: data-*-attributter. Knappen beskriver seg selv og sender med ingen atferd:
// No "use client". No handler. No hooks. Pure server-rendered HTML.
export function CtaButton({ href, label, place }: Props) {
return (
<a
href={href}
data-track="cta_clicked"
data-place={place}
data-label={label}
>
{label}
</a>
);
}Det finnes ingen "use client", ingen behandler, ingen importert SDK — ingenting for klienten å hydrere. Tjeneren strømmer ren HTML, og sporingsintensjonen følger med som attributter. Dette er hva som faktisk når nettleseren:
<!-- What the server sends. The browser needs nothing else to make it work. -->
<a href="/pricing" data-track="cta_clicked" data-place="hero" data-label="Start free">
Start free
</a>Én lytter for hele siden
Nå bor atferden på nøyaktig ett sted. Én enkelt klikklytter på document leser markøren av det som ble klikket og rapporterer det. Komponentens data-track blir hendelsesnavnet; resten av data-*-attributtene blir nyttelasten — dataset-API-et overleverer dem som et vanlig objekt:
// The entire client-side cost of analytics for the whole site.
function handleClick(e: MouseEvent) {
// Find the nearest tracked element from wherever the click landed —
// works even if the user clicked an icon or <span> inside the link.
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>("[data-track]");
if (!el) return;
const { track: event, ...data } = el.dataset;
send(event!, data);
}
export function registerTracking() {
document.addEventListener("click", handleClick);
}Du registrerer den én gang, ved oppstart av applikasjonen, før hydrering — så den overvåker fra aller første maling. Hvor det kallet bor avhenger av stacken din, men det er alltid ett enkelt kall:
import { registerTracking } from "./track-delegation";
// Next.js: instrumentation-client.ts · Vite/SPA: main.ts · plain HTML: a <script>.
// One call, once, for the entire application.
registerTracking();Last SDK-en bare ved et ekte klikk
Det er en subtil, andre gevinst her. Selve lytteren er bitteliten — noen få linjer uten avhengigheter. Det tunge i analyse er SDK-en, og med delegering trenger du den ikke lenger under sidelastingen. Du kan utsette importen av den til det første klikket faktisk skjer:
// The listener ships ~1 KB. The heavy SDK is pulled only when a real
// click happens — never during page load.
async function send(event: string, data: Record<string, string | undefined>) {
const { track } = await import("@/lib/analytics"); // code-split, on demand
track(event, data);
}Nå er analyseleverandørens kilobyte helt borte fra den kritiske stien. Ingenting rundt sporing konkurrerer med første rendering; SDK-en ankommer lazily, on demand, i det øyeblikket en bruker først interagerer — og for en bruker som spretter videre uten å klikke, lastes den aldri i det hele tatt.
Detaljene som biter
Å delegere klikk er lett å få 90 % riktig og deretter ødelegge for superbrukere. Et vanlig venstreklikk er ditt å håndtere, men et Cmd/Ctrl/Shift-klikk eller et midtklikk er brukeren som ber nettleseren åpne en lenke i en ny fane eller laste den ned. Hvis lytteren din kaller preventDefault() betingelsesløst, kaprer du i det stille den intensjonen. Bryt ut tidlig ved modifiserte og ikke-primære klikk, og la ankerets egen href gjøre jobben sin:
function handleClick(e: MouseEvent) {
// Cmd/Ctrl/Shift/Alt-click and middle-click express a native intent
// (open in new tab, download). Let the browser handle those untouched.
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
// ...find [data-track], send event
}Den andre fellen er timing. Hvis klikket navigerer bort fra siden, kan avlastingen avbryte en analyseforespørsel som fortsatt var underveis — så nettopp de klikkene du mest vil måle, er de som mest sannsynlig går tapt. navigator.sendBeacon finnes nettopp for dette: den overleverer en liten nyttelast til nettleseren for levering i bakgrunnen, og overlever navigeringen:
function send(event: string, data: Record<string, string | undefined>) {
// A click that unloads the page can abort an in-flight fetch. sendBeacon
// hands the request to the browser to deliver even as navigation happens.
navigator.sendBeacon(
"/track",
JSON.stringify({ event, ...data }),
);
}For interne lenker som håndteres av en ruter på klientsiden, gjelder ingenting av dette — det er ingen avlasting, så en vanlig forespørsel duger, og den delegerte lytteren sitter fornøyd ved siden av ruteren. Beaconen betyr noe for lenker som forårsaker en ekte navigering med full sidelasting. Å vite hvilke av lenkene dine som gjør hva, er hele finessen.
Lytteren må aldri ødelegge lenken. Respekter modifiserte klikk, bruk en beacon for navigeringer, og behandle analyse som strengt best-effort — en sporingsfeil skal være usynlig for brukeren, aldri en død knapp.
Utover klikk
Klikk er det vanlige tilfellet, men mønsteret generaliserer til enhver hendelse som en delegert lytter kan observere. En innebygd <details>-trekkspillmeny, for eksempel, åpnes og lukkes med null JavaScript — og du kan likevel måle hvor ofte folk åpner den, fordi toggle-hendelsen kan delegeres på samme måte. Den ene hemskoen: toggle bobler ikke, så du lytter i capture-fasen:
// The same pattern, a different event. A native <details> accordion needs
// zero JavaScript to open — and one delegated listener to be measurable.
document.addEventListener("toggle", (e) => {
const el = e.target as HTMLElement;
if (el.matches("[data-track-open]") && (el as HTMLDetailsElement).open) {
send(el.dataset.trackOpen!, { ...el.dataset });
}
}, true); // capture: the toggle event does not bubbleSkjemainnsendinger, synlighetsendringer, medieavspilling — samme deklarative form gjelder. Komponenten angir, i markupen, hva som er verdt å registrere; en håndfull globale lyttere gjør registreringen. Sidens interaktive flate og sidens observerbarhet slutter å være det samme.
Hvorfor dette betyr mer nå
Hendelsesdelegering er flere tiår gammel, og lenge var den en fin bonus — en måte å koble én lytter til en liste i stedet for tusen. I Server Components-æraen står mer på spill. Når standardvalget er en komponent som rendres på tjeneren og sender med null JavaScript, er ett enkelt onClick ikke lenger en avrundingsfeil: det er linjen som vender en hel seksjon fra statisk HTML til en hydrert klientøy.
Delegering er det som lar deg beholde det standardvalget. Analyse — historisk en av de vanligste grunnene til at en presentasjonskomponent "måtte" bli en klientkomponent — slutter å tvinge frem grensen. Hele seksjoner som bare trengte å bli sporet, ikke å være interaktive, kan forbli tjenerrendret og sende med ingenting. Kostnaden ved å observere grensesnittet ditt frikobles fra kostnaden ved å hydrere det.
Delegering versus per komponent, ærlig talt
| Aspekt | Behandler i hver komponent | Én delegert lytter |
|---|---|---|
| JavaScript per sporet element | Sendes med (hydreringsgrense) | Ingen — ren tjener-HTML |
| Analyse-SDK | Importert i mange bundler | Lastet én gang, lazily, ved første klikk |
| På den kritiske stien | Ja | Nei |
| Hvor sporingen bor | Spredt utover treet | Én fil |
| Revidere alle hendelser | Grep gjennom hele kodebasen | Les markørene / én behandler |
| Fungerer for elementer lagt til senere | Trenger en behandler hver | Automatisk (delegering) |
| Kobling til rammeverket | Bundet til komponentens livssyklus | Ren DOM — rammeverksuavhengig |
Den ærlige kostnaden ved delegering er en liten dose disiplin: kontrakten er nå strengtypede attributter i stedet for et typet funksjonskall, så en skrivefeil i et data-track-navn feiler i det stille i stedet for ved kompilering. En liten hjelper som bygger attributtene fra et typet argument kjøper tilbake det meste av den sikkerheten, og én enkelt veltypet lytter er en langt mindre flate å holde ærlig enn behandlere strødd utover hundrevis av filer.
Ingenting av dette er nytt
Hvis dette føles kjent, er det med rette. Det er nøyaktig slik tag managers og autocapture-analyse alltid har fungert. Google Tag Manager, Segment, PostHog, Heap — under panseret kobler de på noen få globale lyttere og leser attributter (eller utleder selektorer) av det som ble klikket. Du har nesten helt sikkert allerede sendt med dette mønsteret; du lot bare et tredjepartsskript eie det.
Poenget med å gjøre det bevisst er kontroll og vekt. Omtrent tretti linjer gir deg den samme delegeringsmekanikken uten et leverandørskript på den kritiske stien din, uten ugjennomsiktig autocapture som sender hendelser du ikke hadde til hensikt, og med en data-attributt-kontrakt du kan lese, type og teste. Du beholder ergonomien som gjorde autocapture populært, og dropper skatten.
Hovedpunkter
- Å legge til en sporende
onClickpå en presentasjonskomponent forfremmer den i det stille til en klientkomponent som sender med JavaScript. - Du trenger ikke en behandler per element — klikk bobler til
document, ogclosest("[data-track]")gjenoppretter nøyaktig hva som ble klikket. - La komponenter deklarere sporing med
data-*-attributter, og hold atferden i én global lytter som registreres én gang ved oppstart. - Lazy-importer analyse-SDK-en inne i lytteren så den holder seg borte fra den kritiske stien og aldri lastes for brukere som ikke interagerer.
- Respekter modifiserte/ikke-primære klikk, bruk
sendBeaconfor navigeringer, og hold analyse best-effort så den aldri kan ødelegge en lenke. - I Server Components-æraen er delegering det som hindrer analyse i å gjøre statiske seksjoner om til hydrerte øyer.
Ingen av delene her er eksotiske: hendelsesbobling, closest(), data-*-attributter, en lazy import() og sendBeacon er alle kjedelige, godt støttede plattformfunksjoner. Skiftet ligger i hvor du plasserer atferden. Slutt å sende med en klikkbehandler med hver komponent, send én lytter for hele siden, og la komponentene dine gå tilbake til å være det de rendrer best — HTML.