Skip to main content
Назад до блогу
JavaScriptAnalyticsEvent DelegationServer ComponentsPerformance

Один слухач замість клієнтського компонента на кожну кнопку

Щойно ви додаєте аналітику кліків до компонента, він перетворюється на клієнтський компонент, який тягне за собою JavaScript. Зробіть так по всьому сайту — і сам лише трекінг роздуває ваш бандл. Ось альтернатива, яка є в платформі від початку: один делегований слухач, декларативні data-атрибути та компоненти, що лишаються чистим серверним HTML.

Опубліковано 28 липня 2026 р.10 хв читання

Ось сценарій, який повторюється майже в кожній кодовій базі. Продакт-менеджер просить відстежувати кліки по кнопці. Кнопка — гарний, статичний, серверно відрендерений компонент. Тож ви додаєте обробник onClick. А щоб мати обробник onClick, компонент тепер має виконуватися в браузері — тож ви додаєте "use client" (або тягнете хук чи обгортку). Компонент гідратується. Він тягне JavaScript. І все це — не для того, щоб щось робити на клієнті, а щоб шепнути один рядок вашій аналітиці.

А тепер помножте це на кожну відстежувану кнопку, картку, посилання й вкладку по всьому великому сайту. Помітна частина вашого JavaScript-бандла існує виключно для того, щоб повідомляти про кліки. Ця стаття — про альтернативу, яка сидить у браузері ще з 1990-х і не потребує жодного фреймворку: делегування подій. Один невеликий слухач наглядає за всією сторінкою; ваші компоненти повертаються до того, щоб бути чистим HTML.

Обробник, який ви додаєте не замислюючись

Рефлекс виглядає безневинно. У вас є посилання, ви хочете знати, коли по ньому клікнули, тож ви чіпляєте обробник:

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

Один рядок трекінгу — і весь компонент змінив категорію. Це вже не статична розмітка, яку сервер може віддати потоком і забути, — це інтерактивний острівець, який клієнт мусить завантажити, розпарсити й гідратувати, перш ніж той onClick взагалі з'явиться. Фреймворк не може здогадатися, що цей обробник — лише телеметрія за принципом «вистрелив і забув»; наскільки йому відомо, цей компонент має жити на клієнті.

Цей рефлекс несе витрати, які накопичуються по всій кодовій базі:

  • Кожен відстежуваний компонент стає межею гідратації — JavaScript завантажується, парситься й виконується заради того, що ніколи не змінює UI.
  • Логіка трекінгу розсіяна по сотнях компонентів, кожен з яких імпортує аналітичний SDK, тож SDK опиняється в багатьох бандлах.
  • Аналітична бібліотека вантажиться на критичному шляху, конкуруючи з рендерингом, на який користувач реально чекає.
  • «Які події ми надсилаємо і звідки?» перетворюється на археологічні розкопки — відповідь розмазана по всьому дереву компонентів.

Чому це відчувається правильним

Варто чесно визнати, чому ми так робимо. Co-location — це справді гарний інстинкт: клік і те, що ви хочете про нього записати, живуть в одному місці, тож поставити виклик трекінгу просто тут — читабельно й легко для розуміння. Фреймворки це підкріплюють — проп onClick є очевидним, задокументованим способом відреагувати на клік, і він за собою тягне гідратацію.

Але тримати поруч намір відстежувати зовсім не означає тримати поруч код, який відстежує. Ви можете лишити декларацію біля елемента — «ця кнопка є кліком по CTA» — тоді як саме прослуховування відбувається деінде. Браузер завжди вмів це робити; ми просто перестали цим користуватися, щойно компоненти зробили обробники на кожен елемент відчуттям безкоштовними.

Події спливають — у цьому весь фокус

Коли ви клікаєте по елементу, браузер не лише надсилає подію на цьому елементі. Подія мандрує вгору по дереву DOM — від елемента до його батька, до його батька, аж до document. Це спливання (bubbling), і воно означає, що один слухач нагорі може спостерігати за кліками по всьому, що під ним. Вам не потрібен слухач на кожну кнопку; вам потрібен один слухач, який наглядає за документом і з'ясовує на кожен клік, по чому саме клікнули.

Інструмент для цього — Element.closest(). Починаючи звідти, куди влучив клік — а це може бути іконка чи <span>, вкладений усередину вашого посилання, — closest() піднімається вгору, доки не знайде предка, що відповідає селектору. Попросіть у нього найближчий елемент із маркером трекінгу — і ви отримаєте назад ту саму кнопку, по якій клікнули, незалежно від того, що було безпосередньо під курсором. Цей єдиний примітив замінює кожен обробник на рівні компонента.

Контракт — це data-атрибут

Якщо клік обробляє глобальний слухач, то єдина робота компонента — оголосити, що саме слід відстежувати, у розмітці, де сервер може це відрендерити. У HTML уже є для цього механізм: атрибути data-*. Кнопка описує сама себе й не тягне жодної поведінки:

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

Тут немає "use client", немає обробника, немає імпортованого SDK — нічого, що клієнту треба гідратувати. Сервер віддає потоком звичайний HTML, а намір відстежувати їде разом як атрибути. Ось що насправді доходить до браузера:

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

Один слухач на всю сторінку

Тепер поведінка живе рівно в одному місці. Один слухач кліків на document зчитує маркер із того, по чому клікнули, і повідомляє про це. Атрибут data-track компонента стає назвою події; решта атрибутів data-* стають корисним навантаженням — API dataset віддає їх як звичайний об'єкт:

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

Ви реєструєте його один раз, при старті застосунку, до гідратації — тож він спостерігає з першого ж кадру. Де саме живе цей виклик, залежить від вашого стека, але це завжди один-єдиний виклик:

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

Вантажте SDK лише на реальний клік

Тут є тонкий, другий виграш. Сам слухач крихітний — кілька рядків без жодних залежностей. Важка частина аналітики — це SDK, і з делегуванням він більше не потрібен вам під час завантаження сторінки. Ви можете відкласти його імпорт до моменту, коли реально станеться перший клік:

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

Тепер кілобайти аналітичного вендора цілком поза критичним шляхом. Ніщо в трекінгу не конкурує з першим рендером; SDK прибуває ліниво, на вимогу, тієї ж миті, коли користувач уперше взаємодіє, — а для того, хто пішов, так і не клікнувши, він не завантажиться взагалі.

Деталі, які кусаються

Делегувати кліки легко зробити на 90% правильно, а потім зламати досвід досвідчених користувачів. Звичайний лівий клік — ваш, щоб його обробити, але Cmd/Ctrl/Shift-клік чи клік середньою кнопкою — це прохання користувача до браузера відкрити посилання в новій вкладці або завантажити його. Якщо ваш слухач безумовно викликає preventDefault(), ви мовчки викрадаєте цей намір. Виходьте раніше на модифікованих і непервинних кліках і дайте нативному href посилання робити свою роботу:

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
}

Друга пастка — це час. Якщо клік уводить сторінку геть, вивантаження може обірвати аналітичний запит, який ще був у польоті, — тож саме ті кліки, які ви найбільше хочете виміряти, з найбільшою ймовірністю будуть втрачені. navigator.sendBeacon існує саме для цього: він передає невелике навантаження браузеру, щоб доставити його у фоні, переживши навігацію:

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

Для внутрішніх посилань, які обробляє клієнтський роутер, нічого з цього не стосується — вивантаження немає, тож звичайний запит цілком підходить, а делегований слухач спокійно уживається поруч із роутером. Beacon важливий для посилань, які спричиняють реальну навігацію з повним перезавантаженням сторінки. Знати, які з ваших посилань роблять що, — і є вся тонкість.

Слухач ніколи не повинен ламати посилання. Поважайте модифіковані кліки, використовуйте beacon для навігацій і ставтеся до аналітики як до строго best-effort — збій трекінгу має бути невидимим для користувача, а не мертвою кнопкою.

Не лише кліки

Кліки — найпоширеніший випадок, але патерн узагальнюється на будь-яку подію, яку може спостерігати делегований слухач. Нативний акордеон на <details>, наприклад, відкривається й закривається без жодного JavaScript — і ви все одно можете виміряти, як часто його відкривають, бо подію toggle можна делегувати так само. Єдиний нюанс: toggle не спливає, тож ви слухаєте у фазі перехоплення (capture):

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

Відправлення форм, зміни видимості, відтворення медіа — застосовується та сама декларативна форма. Компонент заявляє в розмітці, що варто записати; жменька глобальних слухачів робить сам запис. Інтерактивна поверхня вашої сторінки та її спостережуваність перестають бути одним і тим самим.

Чому це важить більше саме зараз

Делегуванню подій десятки років, і довгий час воно було приємним доповненням — способом причепити один слухач до списку замість тисячі. В епоху Server Components ставки вищі. Коли типовим є компонент, що рендериться на сервері й не тягне JavaScript, один-єдиний onClick уже не похибка округлення: це той рядок, що перекидає цілу секцію зі статичного HTML у гідратований клієнтський острівець.

Делегування — це те, що дає вам зберегти цю поведінку за замовчуванням. Аналітика — історично одна з найпоширеніших причин, чому презентаційний компонент «мусив» стати клієнтським, — перестає нав'язувати межу. Цілі секції, які потрібно було лише відстежувати, а не робити інтерактивними, можуть лишатися серверно відрендереними й не тягнути нічого. Ціна спостереження за вашим UI відв'язана від ціни його гідратації.

Делегування проти обробника в кожному компоненті, чесно

АспектОбробник у кожному компонентіОдин делегований слухач
JavaScript на кожен відстежуваний елементТягне (межа гідратації)Немає — чистий серверний HTML
Аналітичний SDKІмпортується в багатьох бандлахВантажиться один раз, ліниво, на перший клік
На критичному шляхуТакНі
Де живе трекінгРозсіяний по деревуОдин файл
Аудит усіх подійGrep по всій кодовій базіПрочитати маркери / один обробник
Працює для елементів, доданих пізнішеКожному потрібен обробникАвтоматично (делегування)
Прив'язка до фреймворкуЗав'язано на життєвий цикл компонентаЧистий DOM — незалежно від фреймворку

Чесна ціна делегування — невелика доза дисципліни: контракт тепер тримається на «рядкових» атрибутах замість типізованого виклику функції, тож одрук у назві data-track провалюється мовчки, а не на етапі компіляції. Крихітний хелпер, який будує атрибути з типізованого аргументу, повертає більшість цієї безпеки назад, а один добре типізований слухач — це набагато менша поверхня, яку треба тримати чесною, ніж обробники, розсипані по сотнях файлів.

Нічого з цього не нове

Якщо це здається знайомим — так і має бути. Саме так завжди й працювали тег-менеджери та autocapture-аналітика. Google Tag Manager, Segment, PostHog, Heap — під капотом вони чіпляють кілька глобальних слухачів і читають атрибути (або виводять селектори) з того, по чому клікнули. Ви майже напевно вже возили цей патерн у продакшн; ви просто віддали володіння ним сторонньому скрипту.

Сенс робити це свідомо — у контролі та вазі. Приблизно тридцять рядків дають вам ту саму механіку делегування без вендорського скрипту на вашому критичному шляху, без непрозорого autocapture, який надсилає події, яких ви не задумували, і з контрактом на data-атрибутах, який можна прочитати, типізувати й протестувати. Ви зберігаєте ту ергономіку, яка зробила autocapture популярним, і скидаєте податок.

Головне

  • Додавання трекінгового onClick до презентаційного компонента мовчки підвищує його до клієнтського компонента, що тягне JavaScript.
  • Вам не потрібен обробник на кожен елемент — кліки спливають до document, а closest("[data-track]") відновлює точно те, по чому клікнули.
  • Дайте компонентам оголошувати трекінг через атрибути data-* і тримайте поведінку в одному глобальному слухачі, зареєстрованому раз при старті.
  • Ліниво імпортуйте аналітичний SDK усередині слухача, щоб він лишався поза критичним шляхом і ніколи не вантажився для тих, хто не взаємодіє.
  • Поважайте модифіковані/непервинні кліки, використовуйте sendBeacon для навігацій і тримайте аналітику best-effort, щоб вона ніколи не могла зламати посилання.
  • В епоху Server Components саме делегування зупиняє аналітику від перетворення статичних секцій на гідратовані острівці.

Жодна зі складових тут не екзотична: спливання подій, closest(), атрибути data-*, лінивий import() та sendBeacon — усе це нудні, добре підтримувані можливості платформи. Зсув — у тому, куди ви кладете поведінку. Перестаньте возити обробник кліків із кожним компонентом, возіть один слухач на всю сторінку — і дайте вашим компонентам повернутися до того, що вони рендерять найкраще, — до HTML.