InquirerMt. Guiting-Guiting climbing activities suspended for three monthsThe Jerusalem PostAmericans begin voting in midterm election races with major implications for US-Israel policyZDF heuteAktuelle Pressemitteilungen des ZDFBollywood HungamaAmit Rai exits OMG 3? Director and makers reportedly part ways over creative differencesCollider‘Shrek’ Is Officially Returning With a Brand-New ReleaseDeadlineWME Signs ‘SNL’s Jeremy CulhaneThe Hollywood Reporter‘The Cat in the Hat’ Review: Bill Hader in a Dr. Seuss Adaptation That Captures the Antic Silliness but Misses the CharmAnime News NetworkDragon's Dogma II: Dark Arisen Game ReviewBillboardHilary Duff Talks Lucky Me Tour, Pre-Show Rituals & ‘With Love’ Dance Redemption | Takes Us OutRolling StoneHow to Livestream Midterm Election-Night News for Free Without CablePopular Science‘2001: A Space Odyssey’ movie prop may sell for over $1 millionGlobal NewsJury deliberations begin in Russell Brand’s rape and sexual assault trial
The Daily Newsstand · Free, Always
Tuesday, November 3, 2026

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

Translate

Мы прогнали фронт киберспортивной платформы 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 перерисовывается на каждый кадр прокрутки

До: NewsLayout перерисовывается на каждый кадр прокрутки

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

После: перерисовывается только ReadingProgress

После: перерисовывается только ReadingProgress

После: перерисовывается только 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 ×3 за загрузку

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

После: ExChanges ×2

После: ExChanges ×2

После: 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 с прода будут во второй части.

Ссылки

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.