Skip to main content
Tilbage til bloggen
JavaScriptAnalyticsEvent DelegationServer ComponentsPerformance

Én listener i stedet for én klientkomponent pr. knap

I det øjeblik du tilføjer klik-analytics til en komponent, bliver den til en klientkomponent, der sender JavaScript med. Gør det på tværs af et helt website, og tracking alene puster dit bundle op. Her er alternativet, der har ligget i platformen hele tiden: én delegeret listener, deklarative data-attributter og komponenter, der forbliver ren server-renderet HTML.

Udgivet 28. juli 202610 min læsning

Her er et forløb, der gentager sig i næsten enhver kodebase. En produktchef beder dig om at tracke klik på en knap. Knappen er en pæn, statisk, server-renderet komponent. Så du tilføjer en onClick-handler. For at have en onClick-handler skal komponenten nu køre i browseren — så du tilføjer "use client" (eller griber ud efter en hook eller en wrapper). Komponenten hydrerer. Den sender JavaScript med. Og den gjorde alt det, ikke for at gøre noget på klienten, men for at hviske én linje til din analytics.

Gang det nu med hver eneste trackede knap, kort, link og fane på et stort website. En mærkbar del af dit JavaScript-bundle findes udelukkende for at rapportere klik. Denne artikel handler om alternativet — det, der har siddet i browseren siden 1990'erne og ikke kræver noget framework: event delegation. Én lille listener holder øje med hele siden; dine komponenter bliver igen til ren HTML.

Handleren, du tilføjer uden at tænke over det

Reflexen ser harmløs ud. Du har et link, du vil vide, hvornår der klikkes på det, så du hægter en handler på:

cta-button.tsx
"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 tracking, og hele komponenten skiftede kategori. Den er ikke længere statisk markup, som serveren kan streame og glemme — den er en interaktiv ø, klienten skal downloade, parse og hydrere, før den onClick overhovedet findes. Frameworket kan ikke se, at handleren kun er fire-and-forget-telemetri; så vidt det ved, skal denne komponent være levende på klienten.

Den reflex bærer omkostninger, der hober sig op på tværs af en kodebase:

  • Hver tracket komponent bliver en hydreringsgrænse — JavaScript, der downloades, parses og eksekveres for noget, der aldrig ændrer UI'et.
  • Tracking-logik spredes ud over hundredvis af komponenter, der hver importerer analytics-SDK'et, så SDK'et ender i mange bundles.
  • Analytics-biblioteket loader på den kritiske sti og konkurrerer med den rendering, brugeren faktisk venter på.
  • "Hvilke events udløser vi, og hvorfra?" bliver et arkæologisk projekt — svaret er spredt ud over hele komponenttræet.

Hvorfor det føles rigtigt

Det er værd at være ærlig om, hvorfor vi gør det. Co-lokation er et helt ægte godt instinkt: klikket og det, du vil registrere om klikket, bor samme sted, så det læser godt og er let at ræsonnere om at placere tracking-kaldet lige der. Frameworks forstærker det — en onClick-prop er den oplagte, dokumenterede måde at reagere på et klik, og den slæber tilfældigvis hydrering med sig.

Men at co-lokere hensigten om at tracke kræver ikke at co-lokere den kode, der tracker. Du kan beholde deklarationen ved siden af elementet — "denne knap er et CTA-klik" — mens den faktiske lytning foregår et helt andet sted. Browseren har altid kunnet det her; vi holdt bare op med at gribe efter det, da komponenter fik handlere pr. element til at føles gratis.

Events bobler op — det er hele tricket

Når du klikker på et element, udløser browseren ikke kun et event på netop det element. Eventet rejser op gennem DOM-træet — fra elementet, til dets forælder, til dennes forælder, hele vejen op til document. Det kaldes bubbling, og det betyder, at en enkelt listener i toppen kan observere klik på alt nedenunder. Du behøver ikke en listener pr. knap; du behøver én listener, der holder øje med dokumentet og for hvert klik regner ud, hvad der faktisk blev klikket på.

Værktøjet til det er Element.closest(). Med udgangspunkt i hvor klikket landede — hvilket kan være et ikon eller en <span> inde i dit link — går closest() opad, indtil den finder en forfader, der matcher en selector. Bed den om det nærmeste element med en tracking-markør, og du får præcis den knap tilbage, der blev klikket på, uanset hvad der lå direkte under markøren. Den ene primitive erstatter alle handlere pr. komponent.

Kontrakten er en data-attribut

Hvis en global listener skal håndtere klikket, er komponentens eneste opgave at deklarere, hvad der skal trackes — i markuppen, hvor en server kan rendere det. HTML har allerede mekanismen: data-*-attributter. Knappen beskriver sig selv og sender ingen adfærd med:

cta-button.tsx
// 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>
  );
}

Der er ingen "use client", ingen handler, intet importeret SDK — intet, klienten skal hydrere. Serveren streamer ren HTML, og tracking-hensigten kører med som attributter. Det er dette, der faktisk når frem til browseren:

<!-- 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 listener til hele siden

Nu bor adfærden præcis ét sted. En enkelt klik-listener på document aflæser markøren fra det, der blev klikket på, og rapporterer det. Komponentens data-track bliver til event-navnet; resten af data-*-attributterne bliver til payloaden — dataset-API'et overrækker dem som et almindeligt objekt:

track-delegation.ts
// 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 applikationens opstart, før hydrering — så den holder øje fra allerførste paint. Hvor det kald bor, afhænger af din stack, men det er altid ét enkelt kald:

app entry (runs once, before hydration)
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();

Load kun SDK'et ved et rigtigt klik

Der er en subtil, ekstra gevinst her. Selve listeneren er ganske lille — nogle få linjer uden afhængigheder. Den tunge del af analytics er SDK'et, og med delegation behøver du det ikke længere under sidens load. Du kan udskyde importen af det, til det første klik faktisk sker:

track-delegation.ts
// 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);
}

Nu er analytics-leverandørens kilobytes helt væk fra den kritiske sti. Intet ved tracking konkurrerer med first render; SDK'et ankommer lazily, on demand, i det øjeblik en bruger interagerer første gang — og for en bruger, der bouncer uden at klikke, loader det aldrig.

Detaljerne, der bider

Det er let at få klik-delegation 90 % rigtig og så ødelægge det for power-brugere. Et almindeligt venstreklik er dit at håndtere, men et Cmd/Ctrl/Shift-klik eller et midterklik er brugeren, der beder browseren om at åbne et link i en ny fane eller downloade det. Hvis din listener kalder preventDefault() ubetinget, kaprer du stilfærdigt den hensigt. Spring tidligt ud på modificerede og ikke-primære klik, og lad ankerets native href gøre sit arbejde:

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 anden fælde er timing. Hvis klikket navigerer siden væk, kan unload'et afbryde en analytics-forespørgsel, der stadig var undervejs — så netop de klik, du helst vil måle, er dem, der oftest går tabt. navigator.sendBeacon findes præcis til dette: den overrækker en lille payload til browseren, som leverer den i baggrunden og overlever navigationen:

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 links, der håndteres af en client-side router, gælder intet af dette — der er intet unload, så en normal forespørgsel er fin, og den delegerede listener sidder fint side om side med routeren. Beaconen betyder noget for links, der udløser en rigtig navigation med fuld sideindlæsning. At vide, hvilke af dine links gør hvad, er hele finessen.

Listeneren må aldrig ødelægge linket. Respektér modificerede klik, brug en beacon til navigationer, og behandl analytics som strengt best-effort — en tracking-fejl bør være usynlig for brugeren, aldrig en død knap.

Ud over klik

Klik er det almindelige tilfælde, men mønstret generaliserer til ethvert event, en delegeret listener kan observere. En native <details>-accordion åbner og lukker for eksempel med nul JavaScript — og du kan stadig måle, hvor ofte folk åbner den, fordi toggle-eventet kan delegeres på samme måde. Den ene krølle: toggle bobler ikke op, 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 bubble

Formularafsendelser, ændringer i synlighed, medieafspilning — den samme deklarative form gælder. Komponenten oplyser, i markuppen, hvad der er værd at registrere; en håndfuld globale listeners står for registreringen. Din sides interaktive overflade og din sides observérbarhed holder op med at være det samme.

Hvorfor det betyder mere nu

Event delegation er årtier gammelt, og i lang tid var det en nice-to-have — en måde at hægte én listener på en liste i stedet for tusind. I Server Components-æraen står der mere på spil. Når standarden er en komponent, der renderer på serveren og ikke sender JavaScript med, er en enkelt onClick ikke længere en afrundingsfejl: det er den linje, der vender et helt afsnit fra statisk HTML til en hydreret klient-ø.

Delegation er det, der lader dig beholde den standard. Analytics — historisk en af de mest almindelige grunde til, at en præsentationskomponent "var nødt til" at være en klientkomponent — tvinger ikke længere grænsen frem. Hele afsnit, der kun skulle trackes, ikke være interaktive, kan forblive server-renderede og sende ingenting med. Omkostningen ved at observere dit UI afkobles fra omkostningen ved at hydrere det.

Delegation vs. pr. komponent, ærligt

HensynHandler i hver komponentÉn delegeret listener
JavaScript pr. tracket elementSendes med (hydreringsgrænse)Ingen — ren server-HTML
Analytics-SDKImporteret i mange bundlesLoades én gang, lazily, ved første klik
På den kritiske stiJaNej
Hvor tracking borSpredt ud over træetÉn fil
Revision af alle eventsGrep hele kodebasenLæs markørerne / én handler
Virker for elementer tilføjet senereKræver en handler hverAutomatisk (delegation)
Kobling til frameworketBundet til komponentens livscyklusRen DOM — framework-agnostisk

Den ærlige pris for delegation er en smule disciplin: kontrakten er nu stringly-typed attributter i stedet for et typet funktionskald, så en tastefejl i et data-track-navn fejler stille i stedet for ved compile-tid. En lille helper, der bygger attributterne ud fra et typet argument, køber det meste af den sikkerhed tilbage, og én veltypet listener er en langt mindre overflade at holde ærlig end handlere strøet ud over hundredvis af filer.

Intet af dette er nyt

Hvis det føles bekendt, bør det gøre det. Det er præcis, som tag managers og autocapture-analytics altid har virket. Google Tag Manager, Segment, PostHog, Heap — under motorhjelmen hægter de et par globale listeners på og aflæser attributter (eller udleder selectors) fra det, der blev klikket på. Du har næsten med sikkerhed allerede sendt dette mønster i produktion; du lod bare et tredjepartsscript eje det.

Pointen med at gøre det bevidst er kontrol og vægt. Cirka tredive linjer giver dig den samme delegationsmekanik uden et leverandørscript på din kritiske sti, uden uigennemsigtig autocapture, der udløser events, du ikke havde tænkt dig, og med en data-attribut-kontrakt, du kan læse, type og teste. Du beholder den ergonomi, der gjorde autocapture populær, og dropper skatten.

Pointer at tage med

  • At tilføje en tracking-onClick til en præsentationskomponent forfremmer den stilfærdigt til en klientkomponent, der sender JavaScript med.
  • Du behøver ikke en handler pr. element — klik bobler op til document, og closest("[data-track]") genfinder præcis, hvad der blev klikket på.
  • Lad komponenter deklarere tracking med data-*-attributter, og hold adfærden i én global listener, der registreres én gang ved opstart.
  • Lazy-importér analytics-SDK'et inde i listeneren, så det holder sig væk fra den kritiske sti og aldrig loader for brugere, der ikke interagerer.
  • Respektér modificerede/ikke-primære klik, brug sendBeacon til navigationer, og hold analytics best-effort, så det aldrig kan ødelægge et link.
  • I Server Components-æraen er delegation det, der forhindrer analytics i at forvandle statiske afsnit til hydrerede øer.

Ingen af delene her er eksotiske: event bubbling, closest(), data-*-attributter, et lazy import() og sendBeacon er alle kedelige, veludbredte platformfunktioner. Skiftet ligger i, hvor du placerer adfærden. Hold op med at sende en klik-handler med hver komponent, send én listener til hele siden, og lad dine komponenter blive det, de renderer bedst — HTML.