Один слушатель вместо клиентского компонента на каждую кнопку
Как только вы добавляете аналитику кликов в компонент, он превращается в клиентский компонент, который тянет за собой 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.