2026-06-10

Pub/Sub для метрик в Nuxt: как навести порядок с трекингом

Когда-то я работал в rabota.ru, и там назрела задача — навести порядок со всякими счётчиками. Метрики, ретаргетинговые пиксели, рекламные сети: OTM-пиксели, Facebook, Google, Яндекс.Метрика. Плюс куча кастомных событий поверх всего этого.

Как было

Всё это было размазано по приложению. Берём, например, кнопку «Откликнуться на вакансию» — и в её компоненте висит сразу пачка вызовов:

<script setup>
function onApply() {
  // основная логика
  applyToVacancy()

  // и трекинг вперемешку
  ym(12345678, 'reachGoal', 'apply_click')
  fbq('track', 'Lead')
  gtag('event', 'apply_click')
  otmPixel.send('apply')
}
</script>

И таких кнопок — десятки, разбросанных по компонентам. Когда нужно было подключить новый сервис, приходилось:

  1. Подключать сам скрипт метрики (через плагин или вручную в app.vue).
  2. Идти по всему приложению и руками проставлять вызовы в нужных местах.

А когда сервис нужно было убрать — всё было ещё хуже. Удаляешь скрипт метрики, а вызовы вроде ym(...) остаются висеть в компонентах. В лучшем случае — тихая ошибка в консоли. В худшем — приложение падало, потому что глобальная функция, на которую ссылались, больше не существовала.

Решение: Pub/Sub через эмиттер и плагины

Я переделал это на Pub/Sub. Идея простая:

  • При действии пользователя (клик по кнопке, переход на страницу, успешная отправка формы) компонент эмиттит одно доменное событие — без знания о том, кто и как его обработает.
  • Каждый трекинговый сервис живёт в своём ~/plugins/*.client.ts, подписывается на нужные события и сам решает, что с ними делать.

Компонент превращается в это:

<script setup>
const { $emitter } = useNuxtApp()

function onApply() {
  applyToVacancy()
  $emitter.emit('vacancy:apply', { vacancyId: vacancy.id })
}
</script>

Эмиттер — это просто общий инстанс event-эмиттера (например, mitt), который кладётся в Nuxt-контекст:

// plugins/emitter.ts
import mitt from 'mitt'

export default defineNuxtPlugin(() => {
  const emitter = mitt()

  return {
    provide: {
      emitter,
    },
  }
})

А плагин конкретной метрики подписывается на события и сам решает, что с ними делать:

// plugins/metrika.client.ts
export default defineNuxtPlugin(() => {
  const { $emitter } = useNuxtApp()

  // подключаем скрипт счётчика
  loadYandexMetrikaScript()

  $emitter.on('vacancy:apply', ({ vacancyId }) => {
    ym(12345678, 'reachGoal', 'apply_click', { vacancyId })
  })
})
// plugins/facebook-pixel.client.ts
export default defineNuxtPlugin(() => {
  const { $emitter } = useNuxtApp()

  loadFacebookPixelScript()

  $emitter.on('vacancy:apply', () => {
    fbq('track', 'Lead')
  })
})

Теперь, чтобы убрать сервис, достаточно удалить один файл-плагин. Компоненты ничего не знают про конкретные метрики и не ломаются, если какой-то из счётчиков пропал.

Велосипед?

Когда я это всё доделал, меня кольнула мысль: а не изобрёл ли я Google Tag Manager? Концептуально это очень похоже — единая точка входа для событий, к которой подключаются независимые "теги".

У самописного решения были свои плюсы:

  • Типизация. События и их payload-ы можно типизировать на уровне TypeScript — никаких "магических строк" в dataLayer.push.
  • Код-ревью. Любое изменение трекинга проходит через тот же процесс, что и остальной код.

Но и минусы были ощутимые:

  • Деплой ради каждой мелочи. Любое изменение события — это релиз фронтенда, а не правка в визуальном интерфейсе.
  • Маркетологи не могут сами добавлять события. В GTM это делается без разработчиков; здесь — нет.

Однако был контекст, который перевешивал всё остальное: Россия, 2022 год. GTM в тот момент использовать было нельзя по понятным причинам. Так что на тот момент решение было полностью оправданным — не велосипед, а вынужденная и вполне рабочая замена.

А как сделать это сегодня

Сейчас для такой задачи самописный эмиттер уже не понадобился бы — в Nuxt 4 для этого есть встроенный механизм Custom Events (Hooks).

Та же логика, но без своего плагина-эмиттера:

// компонент эмиттит событие через хуки Nuxt
const nuxtApp = useNuxtApp()

function onApply() {
  applyToVacancy()
  nuxtApp.callHook('vacancy:apply', { vacancyId: vacancy.id })
}
// plugins/metrika.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  loadYandexMetrikaScript()

  nuxtApp.hook('vacancy:apply', ({ vacancyId }) => {
    ym(12345678, 'reachGoal', 'apply_click', { vacancyId })
  })
})

Типы для своих хуков можно объявить через расширение интерфейса RuntimeNuxtHooks:

// types/hooks.d.ts
declare module '#app' {
  interface RuntimeNuxtHooks {
    'vacancy:apply': (payload: { vacancyId: string }) => void
  }
}

Получаем тот же Pub/Sub, ту же типизацию — но без необходимости тащить отдельный пакет вроде mitt и поддерживать свой плагин-эмиттер. Архитектурное решение осталось ровно тем же, изменился только инструмент под капотом.

© 2026 Илья Элланский