Skip to main content
Tillbaka till bloggen
JavaScriptAnalyticsEvent DelegationServer ComponentsPerformance

En lyssnare i stället för en klientkomponent per knapp

I samma ögonblick som du lägger till klick-analys på en komponent blir den en klientkomponent som skickar med JavaScript. Gör det över en hel webbplats och enbart spårningen sväller upp din bundle. Här är alternativet som funnits i plattformen sedan urminnes tider: en delegerad lyssnare, deklarativa data-attribut och komponenter som förblir ren serverrenderad HTML.

Publicerad 28 juli 202610 min läsning

Här är ett förlopp som upprepar sig i nästan varje kodbas. En produktchef ber dig spåra klick på en knapp. Knappen är en fin, statisk, serverrenderad komponent. Så du lägger till en onClick-hanterare. För att ha en onClick-hanterare måste komponenten nu köra i webbläsaren — så du lägger till "use client" (eller tar till en hook, eller en wrapper). Komponenten hydreras. Den skickar med JavaScript. Och allt detta gjorde den inte för att göra något på klienten, utan för att viska en enda rad till din analys.

Multiplicera nu det med varje spårad knapp, kort, länk och flik över en stor webbplats. En betydande del av din JavaScript-bundle finns enbart för att rapportera klick. Den här artikeln handlar om alternativet — det som suttit i webbläsaren sedan 1990-talet och inte behöver något ramverk: händelsedelegering. En liten lyssnare bevakar hela sidan; dina komponenter återgår till att vara ren HTML.

Hanteraren du lägger till utan att tänka

Reflexen ser harmlös ut. Du har en länk, du vill veta när den klickas, så du kopplar på en hanterare:

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>
  );
}

En rad spårning, och hela komponenten bytte kategori. Den är inte längre statisk markup som servern kan strömma och glömma — den är en interaktiv ö som klienten måste ladda ner, tolka och hydrera innan den där onClick ens existerar. Ramverket kan inte veta att hanteraren enbart är fire-and-forget-telemetri; så vitt det vet behöver den här komponenten vara levande på klienten.

Den reflexen bär kostnader som ackumuleras över en kodbas:

  • Varje spårad komponent blir en hydreringsgräns — JavaScript som laddas ner, tolkas och exekveras för något som aldrig ändrar gränssnittet.
  • Spårningslogiken är utspridd över hundratals komponenter, som var och en importerar analys-SDK:n, så SDK:n hamnar i många bundles.
  • Analysbiblioteket laddas på den kritiska vägen och konkurrerar med den rendering som användaren faktiskt väntar på.
  • "Vilka händelser skickar vi, och varifrån?" blir ett arkeologiskt projekt — svaret är utspritt över hela komponentträdet.

Varför det känns rätt

Det är värt att vara ärlig om varför vi gör detta. Samlokalisering är en genuint god instinkt: klicket och det du vill registrera om klicket bor på samma ställe, så att lägga spårningsanropet just där läser bra och är lätt att resonera kring. Ramverken förstärker det — en onClick-prop är det uppenbara, dokumenterade sättet att reagera på ett klick, och den råkar dra med sig hydrering på köpet.

Men att samlokalisera avsikten att spåra kräver inte att man samlokaliserar koden som spårar. Du kan behålla deklarationen intill elementet — "den här knappen är ett CTA-klick" — medan själva lyssnandet sker någon helt annanstans. Webbläsaren har alltid kunnat göra detta; vi slutade bara ta till det när komponenterna fick per-element-hanterare att kännas gratis.

Händelser bubblar — det är hela knepet

När du klickar på ett element utlöser webbläsaren inte bara en händelse på just det elementet. Händelsen färdas uppåt i DOM-trädet — från elementet, till dess förälder, till dess förälder, hela vägen till document. Detta är bubbling, och det innebär att en enda lyssnare i toppen kan observera klick på allt under sig. Du behöver inte en lyssnare per knapp; du behöver en lyssnare som bevakar dokumentet och räknar ut, per klick, vad som faktiskt klickades.

Verktyget för det är Element.closest(). Med start där klicket landade — vilket kan vara en ikon eller ett <span> nästlat inuti din länk — vandrar closest() uppåt tills den hittar en förfader som matchar en selektor. Be den om det närmaste elementet som bär en spårningsmarkör, så får du tillbaka exakt den knapp som klickades, oavsett vad som låg direkt under pekaren. Den enda primitiven ersätter varje per-komponent-hanterare.

Kontraktet är ett data-attribut

Om en global lyssnare ska hantera klicket är komponentens enda uppgift att deklarera vad som ska spåras — i markupen, där en server kan rendera det. HTML har redan mekanismen: data-*-attribut. Knappen beskriver sig själv och skickar med inget beteende:

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>
  );
}

Det finns ingen "use client", ingen hanterare, ingen importerad SDK — inget för klienten att hydrera. Servern strömmar ren HTML, och spårningsavsikten följer med som attribut. Detta är vad som faktiskt når webbläsaren:

<!-- 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>

En lyssnare för hela sidan

Nu bor beteendet på exakt ett ställe. En enda klicklyssnare på document läser markören från vad som än klickades och rapporterar det. Komponentens data-track blir händelsenamnet; resten av data-*-attributen blir nyttolasten — dataset-API:t överlämnar dem som ett vanligt 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 registrerar den en gång, vid applikationens uppstart, före hydrering — så att den bevakar från allra första målningen. Var det anropet bor beror på din stack, men det är alltid ett enda anrop:

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();

Ladda SDK:n bara vid ett verkligt klick

Det finns en subtil, andra vinst här. Själva lyssnaren är pytteliten — några rader utan beroenden. Det tunga i analys är SDK:n, och med delegering behöver du den inte längre under sidladdningen. Du kan skjuta upp importen av den tills det första klicket faktiskt 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 är analysleverantörens kilobyte helt borta från den kritiska vägen. Inget kring spårning konkurrerar med första renderingen; SDK:n anländer lazily, on demand, i samma ögonblick en användare först interagerar — och för en användare som studsar iväg utan att klicka laddas den aldrig alls.

Detaljerna som biter

Att delegera klick är lätt att få 90 % rätt och sedan gå sönder för superanvändare. Ett vanligt vänsterklick är ditt att hantera, men ett Cmd/Ctrl/Shift-klick eller ett mittenklick är användaren som ber webbläsaren öppna en länk i en ny flik eller ladda ner den. Om din lyssnare anropar preventDefault() villkorslöst kapar du i tysthet den avsikten. Avbryt tidigt vid modifierade och icke-primära klick och låt ankarets egna href göra sitt jobb:

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 andra fällan är timing. Om klicket navigerar bort från sidan kan avlastningen avbryta en analysförfrågan som fortfarande var på väg — så just de klick du mest vill mäta är de som mest sannolikt går förlorade. navigator.sendBeacon finns precis för detta: den överlämnar en liten nyttolast till webbläsaren för leverans i bakgrunden, som överlever 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 }),
  );
}

För interna länkar som hanteras av en klientsideroutrar gäller inget av detta — det finns ingen avlastning, så en vanlig förfrågan duger och den delegerade lyssnaren sitter nöjt bredvid routern. Beaconen spelar roll för länkar som orsakar en verklig navigering med full sidladdning. Att veta vilka av dina länkar som gör vad är hela finessen.

Lyssnaren får aldrig ha sönder länken. Respektera modifierade klick, använd en beacon för navigeringar och behandla analys som strikt best-effort — ett spårningsfel ska vara osynligt för användaren, aldrig en död knapp.

Bortom klick

Klick är det vanliga fallet, men mönstret generaliserar till vilken händelse som helst som en delegerad lyssnare kan observera. En inbyggd <details>-dragspelsmeny, till exempel, öppnas och stängs med noll JavaScript — och du kan ändå mäta hur ofta folk öppnar den, eftersom toggle-händelsen kan delegeras på samma sätt. Den enda hakan: toggle bubblar inte, så du lyssnar 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

Formulärinlämningar, synlighetsändringar, medieuppspelning — samma deklarativa form gäller. Komponenten anger, i markupen, vad som är värt att registrera; en handfull globala lyssnare gör registreringen. Din sidas interaktiva yta och din sidas observerbarhet upphör att vara samma sak.

Varför det spelar större roll nu

Händelsedelegering är decennier gammal, och länge var den en trevlig bonus — ett sätt att koppla en lyssnare till en lista i stället för tusen. I Server Components-eran står mer på spel. När standardvalet är en komponent som renderas på servern och skickar med noll JavaScript är ett enda onClick inte längre en avrundningsfel: det är raden som vänder en hel sektion från statisk HTML till en hydrerad klient-ö.

Delegering är det som låter dig behålla det standardvalet. Analys — historiskt en av de vanligaste anledningarna till att en presentationskomponent "var tvungen" att bli en klientkomponent — slutar tvinga fram gränsen. Hela sektioner som bara behövde spåras, inte vara interaktiva, kan förbli serverrenderade och skicka med ingenting. Kostnaden för att observera ditt gränssnitt frikopplas från kostnaden för att hydrera det.

Delegering kontra per komponent, ärligt talat

AspektHanterare i varje komponentEn delegerad lyssnare
JavaScript per spårat elementSkickas med (hydreringsgräns)Ingen — ren server-HTML
Analys-SDKImporterad i många bundlesLaddas en gång, lazily, vid första klicket
På den kritiska vägenJaNej
Var spårningen borUtspridd över trädetEn fil
Granska alla händelserGreppa hela kodbasenLäs markörerna / en hanterare
Fungerar för element som läggs till senareBehöver en hanterare varAutomatiskt (delegering)
Koppling till ramverketBunden till komponentens livscykelRen DOM — ramverksoberoende

Den ärliga kostnaden för delegering är en liten dos disciplin: kontraktet är nu strängtypade attribut i stället för ett typat funktionsanrop, så ett stavfel i ett data-track-namn misslyckas i tysthet i stället för vid kompilering. En liten hjälpare som bygger attributen från ett typat argument köper tillbaka merparten av den säkerheten, och en enda vältypad lyssnare är en långt mindre yta att hålla ärlig än hanterare utspridda över hundratals filer.

Inget av detta är nytt

Om detta känns bekant är det med rätta. Det är precis så tag managers och autocapture-analys alltid har fungerat. Google Tag Manager, Segment, PostHog, Heap — under huven kopplar de på några globala lyssnare och läser attribut (eller härleder selektorer) från vad som än klickades. Du har nästan säkert redan skickat med det här mönstret; du lät bara ett tredjepartsskript äga det.

Poängen med att göra det medvetet är kontroll och vikt. Ungefär trettio rader ger dig samma delegeringsmekanik utan ett leverantörsskript på din kritiska väg, utan ogenomskinlig autocapture som skickar händelser du inte avsåg, och med ett data-attribut-kontrakt du kan läsa, typa och testa. Du behåller den ergonomi som gjorde autocapture populärt och släpper skatten.

Slutsatser

  • Att lägga till en spårande onClick på en presentationskomponent befordrar den i tysthet till en klientkomponent som skickar med JavaScript.
  • Du behöver inte en hanterare per element — klick bubblar till document, och closest("[data-track]") återfår exakt vad som klickades.
  • Låt komponenter deklarera spårning med data-*-attribut och håll beteendet i en global lyssnare som registreras en gång vid uppstart.
  • Lazy-importera analys-SDK:n inuti lyssnaren så att den håller sig borta från den kritiska vägen och aldrig laddas för användare som inte interagerar.
  • Respektera modifierade/icke-primära klick, använd sendBeacon för navigeringar och håll analys best-effort så att den aldrig kan ha sönder en länk.
  • I Server Components-eran är delegering det som hindrar analys från att förvandla statiska sektioner till hydrerade öar.

Inget av delarna här är exotiskt: händelsebubbling, closest(), data-*-attribut, en lazy import() och sendBeacon är alla tråkiga, väl understödda plattformsfunktioner. Skiftet ligger i var du placerar beteendet. Sluta skicka med en klickhanterare med varje komponent, skicka en lyssnare för hela sidan, och låt dina komponenter återgå till att vara det de renderar bäst — HTML.