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>
И таких кнопок — десятки, разбросанных по компонентам. Когда нужно было подключить новый сервис, приходилось:
- Подключать сам скрипт метрики (через плагин или вручную в
app.vue). - Идти по всему приложению и руками проставлять вызовы в нужных местах.
А когда сервис нужно было убрать — всё было ещё хуже. Удаляешь скрипт метрики, а вызовы вроде 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 и поддерживать свой плагин-эмиттер. Архитектурное решение осталось ровно тем же, изменился только инструмент под капотом.