600 перерисовок в минуту ради одной цифры: аудит Vue-фронта с vue-why-render

Мы прогнали фронт киберспортивной платформы BIGPLAY (Nuxt 3.14, Vue 3.5, Pinia) через vue-why-render. Каждая из пяти главных правок — несколько строк или один вынесенный компонент. Они убрали от 74 до 98% лишней работы. Ниже — что нашли, почему так вышло и как мы это починили. Здесь сокращённая версия, полная — с итоговой таблицей и всеми замерами — на сайте проекта.
Зачем ещё один инструмент
Vue DevTools подсвечивает перерисованные компоненты, но рамка показывает только, что обновилось. Чтобы починить, нужно знать, почему: какой проп, реф или поле стора вызвали рендер. Хук renderTriggered отдаёт объект, ключ, старое и новое значение. vue-why-render строит на нём панель, отчёты и проверки для тестов.
Подключение — Nuxt-модуль за флагом, в прод не попадает:
// nuxt.config.ts
const whyRender = !isProd && ['1', 'true'].includes(process.env.NUXT_WHY_RENDER ?? '')
modules: [
...(whyRender ? [['vue-why-render/nuxt', {
locale: 'ru',
budgets: [{ maxRenders: 5, per: 1000 }],
}]] : []),
],
Бюджет «не больше 5 перерисовок в секунду» оказался самым полезным: компонент, который его превышает, сам пишет об этом в консоль.
Что искать
Перерисовка после клика почти всегда честная: данные действительно сменились. Дорого обходится то, что перерисовывается с частотой, когда пользователь ничего не делает. Поэтому мы искали источники частоты: setInterval, обработчики scroll и requestAnimationFrame, v-model рядом с длинным списком. Из примерно сорока кандидатов подтвердились четыре.
1. Таймер: 600 перерисовок в минуту ради одной цифры
Карточка заявки показывает «осталось 01 DAY 02 HOUR 59 MINUTES». Таймер тикает раз в секунду: флаг «время вышло» должен сработать вовремя. При этом computed с блоками времени каждую секунду возвращал новый массив. Vue сравнивает результат по ссылке и перерисовывает всю карточку, хотя на экране за минуту меняются только минуты.
С Vue 3.4 getter у computed получает предыдущее значение. Если значения не изменились, возвращаем старый массив:
export const keepTimeBlocks = (prev, next) =>
prev?.length === next.length &&
prev.every((b, i) => b.title === next[i].title && b.value === next[i].value)
? prev
: next
const confirmTimeBlocks = computed<ITimeBlock[]>((prev) => keepTimeBlocks(prev, [/* … */]))
10 карточек, минута: ×600 / 1347 мс → ×10 / 27 мс (−98%). Дженерик у computed обязателен, иначе prev получает тип unknown. По пути нашли setInterval без clearInterval: каждый заход на страницу оставлял работающий таймер.

Та же правка на таймере регистрации, прод: два экземпляра — по одной перерисовке за минуту вместо 60.
2. Шапка перерисовывается на любую смену query
useRoute() возвращает один реактивный объект. Достаточно прочитать в шаблоне route.path.includes(...) или localePath('/'), и компонент подписывается на весь маршрут. Клик по вкладке или UTM-метка перерисовывали шапку, футер и логотип. Логотип у нас из 49 слоёв ради glitch-анимации.
const activeLinkPaths = computed((prev) => {
const next = links.value.filter((l) => route.path.includes(l.path)).map((l) => l.path)
return prev?.length === next.length && prev.every((p, i) => p === next[i]) ? prev : next
})
const homePath = computed(() => localePath('/'))
navbar, logo, footer: ×40 → ×0, layout: −74% времени. Заодно нашёлся баг: на русском активный пункт меню не подсвечивался никогда. Ссылки вели на /ru/…, а у языка по умолчанию нет префикса. Из-за этого каждая ссылка ещё и шла через лишний 302-редирект.
3. Реф в слоте перерисовывает не тот компонент
При прокрутке статьи блога сканер показывал, что NewsLayout перерисовывается на каждый кадр, причина — безымянный ref. В самом layout при прокрутке ничего не меняется. Это был прогресс чтения со страницы статьи:
<NewsLayout>
<span :style="{ transform: `scaleX(${readingProgress})` }" />
<article ref="articleRef">…</article>
</NewsLayout>
Содержимое тега — это слот, а функция слота выполняется во время рендера того, кто его рисует. Подписка на readingProgress достаётся layout, и на каждый scroll он перерисовывает всю статью с комментариями: до 6,8 мс на кадр при бюджете 8,3 мс на 120 Гц.
Полосу вынесли в компонент <ReadingProgress :target="articleRef" /> со своим обработчиком scroll. 60 кадров: 150 мс → 11 мс (−93%), проверено на проде.
Правило: реф, который читается в разметке слота, будит компонент, который рисует этот слот.

До: NewsLayout ×53, причина — безымянный ref.

После: перерисовывается только ReadingProgress, причина — progress.
4. Чат поддержки: каждая буква перерисовывает историю
Классическая ошибка: v-model="inputText" и v-for по ста сообщениям в одном компоненте. Каждое нажатие заново прогоняло регулярки linkifyText по всей истории. Чем длиннее переписка, тем медленнее отзывается поле ввода.
Историю вынесли в TicketChatHistory. Пропсы у списка не меняются, и Vue его пропускает. 22 символа: 180 мс → 19 мс (−90%). Новый компонент — фрагмент без обёртки: flex и gap контейнера держатся на прямых детях.
5. Неверное начальное состояние
На бирже игроков лента при загрузке перерисовывалась лишний раз, и это было видно глазами. loading стартовал с false, поэтому первый кадр показывал «Анкет пока нет», а следующий — лоадер. Переключатель игры получал значение через template ref соседа, который появляется только после монтирования, поэтому мигал PUBG → MLBB.
const loading = ref(!!props.initialListing || !showGameModal.value)
<SubmitAgent :active-game="exchangesRef?.activeGame ?? initialGame" />
~96 мс → ~30 мс и без мигания. Такие проблемы видны только в первую секунду, поэтому не вызывайте reset() сразу после загрузки.

До: ExChanges ×3 за загрузку.

После: ExChanges ×2 — только приход фильтров и анкет.
Что не сработало
v-memoна карточке: он скрыл бы симптом, а причина была внутри компонента.Минутный тик вместо секундного: «Регистрация завершена» появлялась бы с опозданием до 59 секунд.
Оптимизация вкладок и формы промокода: они перерисовываются, потому что данные действительно меняются (2,4 мс на букву — это нормально).
Тест, который проходит, ничего не проверяя
await expectRenders('card').toBe(10, () => tick(60))
Сначала мы написали expectRenders(Card). Файл называется card/index.vue, значит __name у компонента index, а сканер называет его card. Совпадений ноль, и toBe(0) зелёный при любом поведении. Второй раз то же случилось с тестом на компонент, которого в старом коде ещё не было. Теперь каждый тест на ноль сначала проверяет, что цель существует, и каждый новый тест мы прогоняем на старом коде: если он там проходит, он ничего не защищает.
Итог
Computed с предыдущим значением:
computed((prev) => …). Пять строк, −98%.Маршрут читаем только внутри computed с примитивным результатом.
Часто меняющееся значение переносим в маленький листовой компонент.
Перерисовки влияют на INP, плавность прокрутки и батарею. На SEO почти не влияют: поисковик видит серверный HTML. Замеры INP с прода будут во второй части.
Ссылки
Полная версия кейса: vue-why-render.js.org/cases/bigplay
Репозиторий: github.com/dadashi44/vue-why-render
Сайт и документация: vue-why-render.js.org
npm: vue-why-render
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.