Аналоги умеют мигать рамкой вокруг обновившегося компонента. Этого хватает, чтобы заметить проблему, но не чтобы её починить: остаётся вопрос «а что вообще изменилось?».
Оказалось, 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.
Сколько это стоит
Инструмент дев‑онли по трём причинам, и ни одна не про перестраховку:
renderTriggeredработает только в дев‑сборке Vue. В проде хук не вызывается вообще — там и измерять нечего.Колбэк дёргается на каждый триггер реактивности. Если профилируешь именно тайминги, сбор причин стоит выключить:
trackReasons: false.Глобальный миксин добавляет хуки каждому компоненту приложения.
Что получилось
npm i -D vue-why-render
Рамка вокруг перерисованного компонента, цвет от зелёного к красному по частоте. Панель с топом по перерисовкам и по времени, деревом компонентов и лентой событий. Клик по строке открывает SFC в редакторе через дев‑сервер. Интерфейс на английском, русском и китайском — опция locale.
Рантайм‑зависимостей нет, лицензия MIT.
Исходники: https://github.com/dadashi44/vue‑why‑render
Живая песочница, панель на ней настоящая: https://dadashi44.github.io/vue‑why‑render/
Чего он не делает
Не заменяет флеймграфы Vue DevTools и трейсы браузера: показывает, что и почему перерисовалось, а не куда ушло время внутри рендера.
Не работает в проде, и это не ограничение, а устройство.
Не поддерживает Vue 2: там нет
renderTriggered, а лезть вvm._watcherради экосистемы, снятой с поддержки в 2023-м, я не готов.
Если запустите на большом приложении и оно сломается — напишите в issues. Пока через него прошёл ровно один реальный проект, и это самое слабое место.
aleksandy
На КДПВ 12 отличий
ermouth
Поля обрезаны, так-то 10. Облако-рыбка, склон, шарф, юбка, ведро, хвост удочки, лишний лист в растении, нога за ногу по-другому, рогоз вместо удочки, крыло утки.