Один слухач замість клієнтського компонента на кожну кнопку
Щойно ви додаєте аналітику кліків до компонента, він перетворюється на клієнтський компонент, який тягне за собою JavaScript. Зробіть так по всьому сайту — і сам лише трекінг роздуває ваш бандл. Ось альтернатива, яка є в платформі від початку: один делегований слухач, декларативні data-атрибути та компоненти, що лишаються чистим серверним HTML.
Ось сценарій, який повторюється майже в кожній кодовій базі. Продакт-менеджер просить відстежувати кліки по кнопці. Кнопка — гарний, статичний, серверно відрендерений компонент. Тож ви додаєте обробник onClick. А щоб мати обробник onClick, компонент тепер має виконуватися в браузері — тож ви додаєте "use client" (або тягнете хук чи обгортку). Компонент гідратується. Він тягне JavaScript. І все це — не для того, щоб щось робити на клієнті, а щоб шепнути один рядок вашій аналітиці.
А тепер помножте це на кожну відстежувану кнопку, картку, посилання й вкладку по всьому великому сайту. Помітна частина вашого JavaScript-бандла існує виключно для того, щоб повідомляти про кліки. Ця стаття — про альтернативу, яка сидить у браузері ще з 1990-х і не потребує жодного фреймворку: делегування подій. Один невеликий слухач наглядає за всією сторінкою; ваші компоненти повертаються до того, щоб бути чистим HTML.
Обробник, який ви додаєте не замислюючись
Рефлекс виглядає безневинно. У вас є посилання, ви хочете знати, коли по ньому клікнули, тож ви чіпляєте обробник:
"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-*. Кнопка описує сама себе й не тягне жодної поведінки:
// 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 віддає їх як звичайний об'єкт:
// 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);
}Ви реєструєте його один раз, при старті застосунку, до гідратації — тож він спостерігає з першого ж кадру. Де саме живе цей виклик, залежить від вашого стека, але це завжди один-єдиний виклик:
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, і з делегуванням він більше не потрібен вам під час завантаження сторінки. Ви можете відкласти його імпорт до моменту, коли реально станеться перший клік:
// 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.