Аналоги умеют мигать рамкой вокруг обновившегося компонента. Этого хватает, чтобы заметить проблему, но не чтобы её починить: остаётся вопрос «а что вообще изменилось?».

Оказалось, Vue знает ответ и отдаёт его сам — просто этим почти никто не пользуется. Под катом: как устроен хук renderTriggered, почему сырое событие из него нечитаемо, и три способа уронить прод‑сборку чужого приложения дев‑инструментом, который в проде вообще не должен работать.

Чего не хватало

Есть Vue DevTools с галкой «Highlight updates». Есть react‑scan в соседней экосистеме. Оба показывают одно и то же: вот этот компонент перерисовался.

Проблема в том, что знание «перерисовался» само по себе бесполезно. Ты и так видишь, что страница дёргается. Вопрос всегда другой: что именно изменилось. Какой проп, какой реф, какое поле стора. Без этого ты сидишь и комментируешь куски шаблона, пока не найдёшь виновника методом половинного деления.

В React иначе и нельзя: реактивность там на уровне «перерисовался компонент», причину приходится реконструировать диффом пропсов. А во Vue реактивность точечная — фреймворк знает, какая именно зависимость дёрнула рендер.

И он её отдаёт.

renderTriggered

У каждого компонента есть хук renderTriggered. Vue передаёт в него объект:

{
  target,    // объект, в котором произошло изменение
  type,      // set / add / delete / clear
  key,       // ключ
  oldValue,  // значение до
  newValue,  // значение после
}

Это ровно та информация, которой не хватало. Навешиваем глобальный миксин — и на каждый рендер приходит причина.

Казалось бы, всё. Но сырое событие читать невозможно.

Сырое событие нечитаемо

Вот что реально прилетает в типичных случаях.

Реф. target — это сам объект рефа, key — строка 'value'. То есть отчёт выглядит как «изменилось value». Спасибо, очень помогло.

Имя переменной есть только в setupState компонента. Значит надо пройтись по нему и найти, под каким ключом лежит наш target:

function findRefName(container, target) {
  const raw = toRaw(container)
  const descriptors = Object.getOwnPropertyDescriptors(raw)

  for (const key of Object.keys(descriptors)) {
    const descriptor = descriptors[key]
    if (!descriptor || typeof descriptor.get === 'function') continue

    const value = descriptor.value
    if (value === target) return key
    if (isRef(value) && toRaw(value) === target) return key
  }
  return null
}

Про typeof descriptor.get === 'function' будет отдельная история ниже — эта строчка появилась не сразу и стоила упавшей гидрации.

Стор Pinia. У объекта стора есть $id. Если он строка — перед нами стор, и ключ стоит показывать как cart.items, а не просто items.

Массив. key — числовой индекс. Показывать 3 бессмысленно, [3] уже читаемо.

Пропы. target === instance.props — значит изменился проп.

После такой обработки строка в панели выглядит так:

ProductCard ×7 · 2.4ms · badge
└─ проп badge: { text } → { text } — новая ссылка, значение то же

Самая полезная строчка

Последняя часть — «новая ссылка, значение то же» — это то, ради чего всё и затевалось.

Классика:

<ProductCard :badge="{ text: 'new' }" />

Статический литерал Vue вынесет в константу, и проблемы не будет. А вот это — уже нет:

<ProductCard :badge="makeBadge()" />

Каждый рендер родителя создаёт новый объект с тем же содержимым. Для дочернего компонента это изменившийся проп, и он перерисовывается. Всегда. Мигающая рамка покажет, что он перерисовался, но не скажет, что виноват литерал в шаблоне родителя.

Ловится это не через renderTriggered, а отдельно — диффом пропсов между beforeUpdate и updated, со сравнением на поверхностное равенство:

changes.push({
  key,
  oldValue: formatValue(oldValue),
  newValue: formatValue(newValue),
  referenceOnly: isShallowEqual(oldValue, newValue),
})

Грабля первая: одна ссылка на process.env уронила прод

Первая версия определяла режим сборки сама, как все:

const isDev = process.env.NODE_ENV !== 'production'

В браузерном бандле process не существует, но это как раз ожидаемо и обходится. Сломалось другое.

Пользователь собрал свой Nuxt‑проект в прод и получил:

Unexpected token &&

Внутри нашего файла. В собранном бандле, которого никто руками не писал.

Причина оказалась такой. @rollup/plugin-commonjs (он приезжает в составе Vite) сканирует модули и решает, не является ли ESM‑файл на самом деле смешанным CommonJS. Голая ссылка на process.env.NODE_ENV — один из признаков. Плагин решил, что наш ESM — это CJS, и начал его переписывать. Результатом был синтаксически битый код.

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

Первая попытка починки — читать через globalThis.process. Не помогло: упоминания process в любом виде хватало, чтобы включить тот же механизм.

В итоге определение режима убрано целиком. Пакет не угадывает окружение. Гасить его в проде — задача вызывающего кода, зато статическим флагом:

// Nuxt
if (!import.meta.dev) return
const { default: VueWhyRender } = await import('vue-why-render')

// Vite
if (import.meta.env.DEV) {
  const { default: VueWhyRender } = await import('vue-why-render')
  app.use(VueWhyRender)
}

Так сборщик вырезает не только вызов, но и сам импорт — пакет физически не попадает в прод‑бандл. Что для дев‑инструмента честнее любого автоопределения.

Грабля вторая: кириллица в комментариях

Починили process — прод у потребителя снова упал. С той же ошибкой.

Оказалось, тот же плагин, переписывая код, считает смещения в байтах, а не в символах. А у нас комментарии и строки интерфейса были на русском. Каждая кириллическая буква в UTF-8 занимает два байта. Смещения разъезжались, вставки попадали в середину токенов, на выходе получался мусор.

Лечение оказалось прямолинейным: постобработка публикуемого бандла через esbuild.

await build({
  entryPoints: [file],
  outfile: file,
  allowOverwrite: true,
  bundle: false,
  format,
  target: 'es2022',
  charset: 'ascii',        // всё не-ASCII уходит в \uXXXX
  minifyWhitespace: true,
  minifySyntax: true,
  minifyIdentifiers: false, // имена не трогаем, файл остаётся читаемым
  legalComments: 'none',    // комментарии вырезаются
})

Комментарии вырезаются, не‑ASCII превращается в escape‑последовательности, байтовые смещения совпадают с символьными. Прод собирается.

Мораль, которую я вынес: если твой пакет публикуется в npm, держи в бандле только ASCII. Не из эстетики, а потому что ты не контролируешь, какие плагины будут переписывать твой код на чужой стороне.

Грабля третья: падала гидрация

Третий случай самый неприятный, потому что ломался не билд, а рантайм.

Прогон на настоящем Nuxt‑проекте с сотнями компонентов: при включённом сборе причин падала гидрация.

Виноват был тот самый поиск имени рефа. В первой версии он перечислял setupState обычным способом — а обычное перечисление вычисляет геттеры. В setupState лежат computed’ы приложения. То есть мы вычисляли чужие computed’ы прямо внутри renderTriggered, в момент, на который они не рассчитаны. Какой‑то из них обращался к данным, которых на тот момент ещё не было, и ронял гидрацию.

Отсюда та самая строчка в коде выше:

if (!descriptor || typeof descriptor.get === 'function') continue

Читаем строго через дескрипторы, геттеры не трогаем.

Заодно все хуки обёрнуты в защиту:

function safe(label, fn) {
  try {
    fn()
  }
  catch (error) {
    if (!warnedAboutCrash) {
      warnedAboutCrash = true
      console.warn(`[vue-why-render] ${label} failed, scanning continues:`, error)
    }
  }
}

Принцип простой: инструмент отладки не имеет права ронять приложение, которое отлаживает. Наша ошибка внутри хука всплыла бы как ошибка рендера чужого компонента, и человек полез бы искать баг у себя.

Почему не renderTracked

Соседний хук renderTracked срабатывает на каждое чтение реактивного значения. Звучит заманчиво — полная картина зависимостей. На практике это тысячи вызовов в секунду на среднем приложении, и профилировать с ним нечего: инструмент становится основным потребителем времени.

Используется только renderTriggered.

Сколько это стоит

Инструмент дев‑онли по трём причинам, и ни одна не про перестраховку:

  1. renderTriggered работает только в дев‑сборке Vue. В проде хук не вызывается вообще — там и измерять нечего.

  2. Колбэк дёргается на каждый триггер реактивности. Если профилируешь именно тайминги, сбор причин стоит выключить: trackReasons: false.

  3. Глобальный миксин добавляет хуки каждому компоненту приложения.

Что получилось

npm i -D vue-why-render

Рамка вокруг перерисованного компонента, цвет от зелёного к красному по частоте. Панель с топом по перерисовкам и по времени, деревом компонентов и лентой событий. Клик по строке открывает SFC в редакторе через дев‑сервер. Интерфейс на английском, русском и китайском — опция locale.

Рантайм‑зависимостей нет, лицензия MIT.

Чего он не делает

  • Не заменяет флеймграфы Vue DevTools и трейсы браузера: показывает, что и почему перерисовалось, а не куда ушло время внутри рендера.

  • Не работает в проде, и это не ограничение, а устройство.

  • Не поддерживает Vue 2: там нет renderTriggered, а лезть в vm._watcher ради экосистемы, снятой с поддержки в 2023-м, я не готов.

Если запустите на большом приложении и оно сломается — напишите в issues. Пока через него прошёл ровно один реальный проект, и это самое слабое место.

Комментарии (2)


  1. aleksandy
    23.09.2026 08:11

    На КДПВ 12 отличий


    1. ermouth
      23.09.2026 08:11

      Поля обрезаны, так-то 10. Облако-рыбка, склон, шарф, юбка, ведро, хвост удочки, лишний лист в растении, нога за ногу по-другому, рогоз вместо удочки, крыло утки.