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.