600 перерисовок в минуту ради одной цифры
Как мы прогнали фронт киберспортивной платформы через vue-why-render. Семь правок, каждая в несколько строк или один вынесенный компонент, убрали от 70 до 98% лишней работы там, где она была. Главные проверены на продовых данных. Ещё выяснилось, что перерисовку из-за рефа можно искать не в том компоненте, а тест на «ноль перерисовок» может проходить, ничего не проверяя.
Рамка вокруг компонента — это ещё не причина
Подсветку перерисованных компонентов умеет и Vue DevTools. Но мигающая рамка отвечает только на вопрос «что обновилось». Чтобы починить, нужно знать, почему: какой проп, реф или поле стора дёрнули рендер. У Vue для этого есть хук renderTriggered. Он отдаёт объект, ключ, старое и новое значение, и vue-why-render строит вокруг него панель, отчёты и проверки в тестах.
Перед нами была конкретная задача: пройти основные экраны BIGPLAY, найти лишние перерисовки, разложить их по критичности, критичные починить и доказать эффект цифрами до и после.
Один флаг, и в прод ничего не уезжает
Раньше пакет висел в проекте ручным плагином с enabled: false. Мы перешли на Nuxt-модуль и спрятали его за переменную окружения:
// nuxt.config.ts
const whyRender = !isProd && ['1', 'true'].includes(process.env.NUXT_WHY_RENDER ?? '')
const WHY_RENDER_OPTIONS = {
locale: 'ru',
exclude: [/^Nuxt/, /^RouterLink$/, /^RouterView$/, /^Transition/, /^KeepAlive$/, /^ClientOnly$/],
budgets: [{ maxRenders: 5, per: 1000 }],
}
modules: [
// …
...(whyRender ? [['vue-why-render/nuxt', WHY_RENDER_OPTIONS]] : []),
],
Опции передаются кортежем вместе с модулем, а не ключом whyRender верхнего уровня. Без флага модуль не подключён, его схема не попадает в типы Nuxt, и typecheck падает на неизвестном ключе.
NUXT_WHY_RENDER=1 npm run dev — и появляются рамки, панель и вкладка Why render в Nuxt DevTools. Без флага модуль не грузится. В прод-сборке сканера нет: по бандлу .output/public/_nuxt ноль вхождений.
Бюджет «не больше пяти перерисовок в секунду» оказался полезнее, чем казалось: нарушитель сам пишет о себе в консоль, и для поиска «критичного» не нужно смотреть в панель постоянно.
Сценарий, reset, отчёт
Каждый сценарий проходил одинаково: reset() → действие → пауза → getReport() в JSON. Отчёты «до» и «после» сравнивались через diffReports. Пройдены главная (загрузка, простой, прокрутка), страница турнира (простой и все пять вкладок), навигация по разделам и смены query. Во втором проходе — блог, статья, рейтинг, биржа, трансляции, промокоды. Прокрутку крутили из консоли: 60 шагов по кадру через requestAnimationFrame, по три прогона на «до» и «после», чтобы цифры воспроизводились.
Финальная проверка шла на продовых данных: локальная сборка с правками, но API прода, только на чтение. Скриншоты снимал headless Chrome через puppeteer-core. Перед снимком «до» файлы временно возвращались к старым версиям из git, так что «до» и «после» — один и тот же экран с одними и теми же данными.
Часть сценариев пришлось мерить в Vitest с фейковыми часами. Посреди аудита у тестового бэкенда кончилось место на диске, турнира с открытой регистрацией там не было, а чат поддержки без аккаунта не открыть. Число перерисовок от среды не зависит. Время в happy-dom меньше браузерного, поэтому сравнивать стоит доли, а не абсолютные миллисекунды.
Критично то, что перерисовывает без вас
Клик, после которого перерисовалась вкладка, почти всегда честный: данные сменились. Дорого обходится другое — работа, которая идёт с частотой, пока пользователь ничего не делает или делает что-то мелкое. Поэтому во втором проходе мы искали не компоненты, а источники частоты, и только потом смотрели, кого они будят:
setInterval— секундные отсчёты;- обработчики
scroll,resize,touchmove,requestAnimationFrame— до 60–120 раз в секунду; v-modelполя рядом с длинным списком — каждое нажатие клавиши;- общий реф сообщений WebSocket — каждое событие с сервера.
Кандидатов набралось около сорока, подтвердились четыре. Остальное отсеялось на замере или при чтении кода: resize-обработчики пишут булевы флаги, а повторная запись того же значения Vue не будит. Сокет у нас отдаёт сообщения колбэками, общего рефа нет. Таймеры розыгрышей тикают раз в минуту и отдают строку. На простое и прокрутке чистыми оказались список блога, турниры, рейтинг, биржа, раздел трансляций и промокоды.
Список заявок: каждая карточка — раз в секунду
- Компонент
applications/card/index.vue, карточка заявки на турнир- Действие
- Никакого. Пользователь просто смотрит на «Мои заявки»
- Что видно
- «Осталось на подтверждение: 01 DAY 02 HOUR 59 MINUTES»
−98% перерисовок, −98% времени · 10 карточек, 60 секунд
Почему Vue считал, что карточку нужно обновить
Под карточкой работает общий композабл useCountdownTimer. Раз в секунду он кладёт в реф новый объект dayjs. Секундный тик нужен честно: флаг isTimeFinished должен переключиться ровно в момент дедлайна. От рефа зависит timeDuration, а от него — блоки для шаблона:
const confirmTimeBlocks = computed(() => [
{ title: 'DAY', value: safe(confirmTime.value.days) },
{ title: 'HOUR', value: safe(confirmTime.value.hours) },
{ title: 'MINUTES', value: safe(confirmTime.value.minutes) },
])
Каждую секунду computed пересчитывается и возвращает новый массив. Vue сравнивает результат computed через Object.is, видит другую ссылку и будит шаблон. Шаблон у карточки — полсотни узлов, кнопки, картинки, таблица состава. Видимых изменений за минуту ровно одно: сменились минуты.
Правка
С Vue 3.4 getter у computed получает предыдущее значение. Если значения совпали, возвращаем старый массив, и для Vue «ничего не изменилось»:
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, [ /* … */ ]))
Дженерик у computed здесь обязателен: без него TypeScript выводит prev как unknown. Мы это заметили не сразу, только когда vue-tsc насчитал девять ошибок.
Таймер по-прежнему тикает раз в секунду, поведение не изменилось. Перерисовка теперь одна в минуту. По пути нашлась утечка: setInterval в композабле и в самой карточке никогда не очищался, и каждый заход на страницу оставлял вечный тик. Добавили clearInterval при размонтировании.
Таймер регистрации на странице турнира
- Компонент
FinishOfRegistrationTimer.vue, стоит на странице дважды (десктоп и мобилка)- Действие
- Открыта страница турнира, регистрация идёт
−98% · 2 экземпляра, 60 секунд
Та же болезнь в одиночном экземпляре: 60 перерисовок в минуту на каждый таймер. Сам компонент дешёвый, поэтому в миллисекундах это немного. Но это ровно «компонент перерисовывается на каждый тик», и правка та же — keepTimeBlocks.
Шапка перерисовывается на любую смену query
- Компоненты
navbar,ui-logo,footer, layoutdefault- Действие
- Клик по вкладке турнира (
?tab=grid), UTM-метка, любая навигация
navbar, ui-logo, footer: ×40 → ×0 · корень layout: −74% времени · 10 смен query
Почему
useRoute() отдаёт один реактивный объект на всё приложение. Любое чтение его поля из шаблона подписывает компонент на весь маршрут. Так и было, и в шапке, и в логотипе:
<li v-for="link in links"
:class="{ 'nav-link--active': route.path.includes(link.path) }">
<UiLogo :to="localePath('/')" /> <!-- localePath читает route внутри -->
Логотип BIGPLAY рисуется 49 слоями букв ради glitch-анимации и стоит в шапке и в футере. Отсюда ×20. На вкладках турнира это стоило 7–9 мс на клик только на шапке.
Правка
Маршрут читается только внутри computed, а наружу отдаётся то, что не меняется без причины: строка, булев флаг или массив, который сохраняет ссылку:
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('/')) // меняется только со сменой локали
В Vue 3.5 computed, вернувший прежнее значение, не будит подписчиков. Шапка и логотип перестали реагировать на query совсем. Остаток — ×10 у layout: его перерисовывает сам NuxtLayout, который на каждую навигацию отдаёт layoutProps: {'{'} ref {'}'} новой ссылкой. Это внутренности Nuxt 3.14, мы их не трогали. Но дешёвым такой рендер стал как раз потому, что дети больше не тянутся следом.
Через неделю эта правка чуть не потерялась. Параллельно в основной ветке логотип переделали: адрес главной стал пропсом, и сам логотип маршрут больше не читал. Но шапка и футер стали передавать этот проп как localePath('/') прямо в шаблоне. Конфликта при мерже не было, подписка на маршрут просто переехала на уровень выше. Нашлось только потому, что после мержа мы перечитали шаблоны каркаса на чтение маршрута.
И баг, который нашёлся заодно
Активный пункт меню на русском не подсвечивался никогда. Ссылки строились как /ru/news, а у маршрута на языке по умолчанию префикса нет, и route.path.includes(link.path) всегда давал ложь. Кроме того, каждая такая ссылка стоила лишнего редиректа 302 с /ru/… на адрес без префикса. Теперь путь для языка по умолчанию строится без префикса.
Статья блога: каждый кадр прокрутки перерисовывает layout
- Компонент
- страница статьи
News/[slug].vueи её layoutNewsLayout - Действие
- Пользователь читает статью и крутит страницу
- Что видно
- Тонкая полоса прогресса чтения вверху экрана
−93% времени · 60 кадров прокрутки, браузер, медиана трёх прогонов. После правки ×59 перерисовывается только полоса
NewsLayout ×53, причина — безымянный ref. Полоса прогресса видна вверху экрана. Пункт «Блог» в шапке не подсвечен — это баг из кейса 3.
ReadingProgress, у причины появилось имя — progress. «Блог» подсвечен.На продовой статье (интервью, 53 кадра до конца текста): NewsLayout 118 мс → ReadingProgress 5,7 мс.
Перерисовывался не тот, чей реф
В отчёте виноват NewsLayout, причина — безымянный ref. Но у layout нет ничего, что меняется при прокрутке. Владельца выдали только значения: дробное число, растущее от кадра к кадру, — это прогресс чтения. Реф объявлен на странице статьи, а полоса стоит в её разметке:
<NewsLayout>
<div class="post-progress">
<span :style="{ transform: `scaleX(${readingProgress})` }" />
</div>
<article ref="articleRef">…</article>
</NewsLayout>
Всё содержимое тега <NewsLayout> — это слот. Функция слота выполняется во время рендера того компонента, который этот слот рисует. Значит, и подписка на readingProgress достаётся layout. Каждое событие scroll будит NewsLayout, а он перерисовывает слот целиком: статью, блок похожих материалов, комментарии. До 6,8 мс на кадр, и сканер записал 120 fps — бюджет кадра на таком экране 8,3 мс.
Правка
Полоса переехала в маленький компонент ядра ReadingProgress со своим обработчиком scroll. Страница передаёт только элемент статьи:
<NewsLayout>
<ReadingProgress :target="articleRef" />
<article ref="articleRef">…</article>
</NewsLayout>
Перерисовок столько же, но теперь перерисовывается полоска из двух узлов. Правило, которое мы для себя вывели: реф, который читает разметка слота, принадлежит не вашему компоненту, а тому, кто рисует слот. Чем больше этот компонент, тем дороже частый реф внутри слота.
Чат поддержки: каждая буква перерисовывает всю историю
- Компонент
GlobalTicketChat.vue, 1100 строк. Висит вapp.vueу каждого залогиненного- Действие
- Пользователь пишет в поддержку, в истории 100 сообщений
−90% времени на ввод · история при наборе: ×0 · Vitest + happy-dom
Классика: v-model="inputText" и v-for по истории в одном шаблоне. Каждое нажатие клавиши меняет inputText, компонент перерисовывается целиком и заново прогоняет linkifyText (регулярки по тексту) для каждого сообщения. Это задержка ввода, которая растёт с длиной переписки: долгий тикет печатается медленнее короткого.
Историю вынесли в TicketChatHistory. Теперь буква перерисовывает только поле ввода, а у списка пропсы те же, и Vue его пропускает. Новое сообщение по-прежнему будит историю, это проверено отдельным тестом.
Две детали переноса. Стили у чата scoped, поэтому вместе с разметкой переехал и её CSS. А корень у нового компонента — фрагмент без обёртки: сообщения должны остаться прямыми детьми контейнера, на них держатся его flex и gap. Лишний <div> вокруг списка тихо сломал бы отступы.
Трансляция: секундный отсчёт в большом компоненте
У прогноза на матч и у голосования есть отсчёт до конца. Строка отсчёта жила в рефе самого большого компонента, рядом с сеткой карточек команд и списком вариантов. Раз в секунду перерисовывался весь блок — 60 раз в минуту. У голосования ещё и шапка, куда готовая строка шла пропсом.
Здесь keepTimeBlocks не поможет: на экране секунды, строка действительно меняется каждую секунду. Правка другая — отсчёт стал листовым компонентом CountdownText. Он принимает дату, тикает сам и сообщает родителю только о конце событием expired:
<CountdownText :ends-at="prediction.ends_at" @expired="isExpired = true" />
В тестах: WatchPredictions и WatchVoting — 60 → 0 перерисовок за минуту, тикает один текстовый узел. На живой трансляции не проверено: на тестовом стенде не нашлось эфира с активным прогнозом.
БигМэтч: лента перерисовывается трижды, пока грузится
На простое и прокрутке биржа игроков была чистой, и в первом проходе мы её пропустили. Проблема оказалась в окне, которое мы не смотрели, — в первой секунде после открытия. Отчёт без reset(), с момента загрузки, на продовых данных:
| Время | Кто | Стоит | Причина |
|---|---|---|---|
| 280 мс | SubmitAgent | 34,5 мс | проп activeGame: undefined → "mlbb" |
| 281 мс | ExChanges | 34,6 мс | игра опроса из localStorage, loading false → true |
| 367 мс | ExChanges | 3,3 мс | пришли варианты фильтров |
| 909 мс | ExChanges | 23,6 мс | пришли анкеты, loading true → false |
Последние две строки честные: пришли данные. Первые две — компонент монтируется с неверным начальным состоянием и тут же его исправляет. И это не только миллисекунды, это видно глазами:
loadingстартовал сfalse, а пустое состояние показывается при!loading && listings.length === 0. Первый кадр — «Анкет пока нет. Загляните позже!», следующий — лоадер, потом анкеты.- Страница передавала игру в переключатель через экземпляр ленты:
:active-game="exchangesRef?.activeGame". Template ref появляется только после монтирования, поэтому первый кадр переключатель рисовал PUBG и тут же прыгал на MLBB.
// было: первый кадр — «анкет нет»
const loading = ref(false)
// стало: если загрузка начнётся при монтировании — лоадер с первого кадра
const loading = ref(!!props.initialListing || !showGameModal.value)
// страница знает начальную игру сама, а не ждёт ref на ленту
const initialGame = useExchangeInitialGame() ?? undefined
<SubmitAgent :active-game="exchangesRef?.activeGame ?? initialGame" />
ExChanges ×3 за загрузку.
ExChanges ×2: остались только приход фильтров и анкет.Лента и переключатель на загрузке: ~96 мс → ~30 мс, без мигания «анкет нет» и PUBG → MLBB
Урок для поиска: «простой» и «прокрутка» не покрывают первую секунду жизни страницы. Именно там живут перерисовки из-за неверного начального состояния, а их сканер видит, только если не делать reset() после загрузки.
Типовые причины в нашем проекте
- Секундный тик и минутная точность. Отсчёт нужен посекундно, а показывается поминутно, и computed пересобирает объект на каждый тик. Это самая дорогая находка аудита.
- Частый источник в большом компоненте. Секундный таймер, прогресс прокрутки, поле ввода. Если значение действительно меняется часто, его нужно опустить в маленький листовой компонент. Тогда тикает он, а не весь экран.
- Неверное начальное состояние.
loading = falseдо загрузки, значение из хранилища вonMounted, данные через template ref соседа. Первый кадр рисует не то, второй исправляет — и пользователь видит мигание. - Реф в слоте будит того, кто рисует слот. Неочевидно, и сканер в отчёте показывает именно этого «невиновного».
- Маршрут прямо в шаблоне.
route.path.includes(…),localePath(),route.nameв разметке — подписка на весь маршрут. Чаще всего это встречается в общем каркасе: шапка, футер, layout. - Новый массив из computed при тех же значениях. Vue не сравнивает содержимое, только ссылку.
- Интервалы без очистки. Сами по себе они не перерисовывают отмонтированный компонент, но копятся с каждым заходом на страницу.
Перерисовка на каждый символ — это норма?
Для компонента, который владеет значением поля, — да: так работает v-model. Вопрос в том, что перерисовывается вместе с ним и сколько это стоит. Форма промокода — 2,4 мс на букву, ничего делать не нужно. Чат с историей — 8 мс на букву и растёт с длиной переписки, это проблема. Ориентир: до 1–3 мс на нажатие, и цифра не растёт с объёмом данных. Если больше — держать значение поля в маленьком компоненте, для значения, нужного только при отправке, — v-model.lazy, для поиска и фильтров — debounce на запрос, а не на поле.
Что дало эффект
- Сравнение с предыдущим значением в computed:
computed((prev) => …). Пять строк, −98%. - Чтение маршрута только в computed с примитивным результатом.
- Вынос часто меняющегося значения в листовой компонент:
CountdownText,ReadingProgress,TicketChatHistory.
Что не имело смысла
v-memoна карточке. Сканер на неё намекал («родитель перерисовал без причин»), но причина была внутри самой карточки. Мемоизация закрыла бы симптом и оставила бы лишний пересчёт.- Переводить таймер на минутный тик. Перерисовок стало бы так же мало, но «Регистрация завершена» появлялась бы с опозданием до 59 секунд. Это изменение поведения.
- Чинить
LayoutLoader. Это код Nuxt, а после правок детей он стоит ~2,7 мс на навигацию. - Вкладки турнира (
Tabs,InformationSections) и форма промокода. Они перерисовываются, потому что действительно сменились данные: 12 букв — 12 рендеров по 2,4 мс.
Производительность — да, SEO — почти нет
Лишняя перерисовка — это работа главного потока именно тогда, когда пользователь что-то делает. Поэтому она бьёт по тому, что он чувствует руками:
- INP — задержка отклика на клик и ввод. Это чат поддержки и шапка на вкладках турнира.
- Плавность прокрутки. В INP прокрутка не входит, и метрики её не видят, а глаза видят. Статья блога тратила до 6,8 мс из 8,3 доступных на кадр.
- CPU и батарея на телефонах: секундные тики, пока страница просто открыта.
С SEO связь слабая, и продавать аудит перерисовок как SEO-работу было бы нечестно. Поисковик индексирует HTML, который отдал сервер, а перерисовки происходят уже после загрузки. Из Core Web Vitals они задевают только INP. LCP и CLS решаются в другом месте: SSR, картинки, шрифты, гидрация. Гидрацию сканер в нашей конфигурации не считает вовсе (includeMounts: false).
Но аудит задел SEO другим способом: нашлись ссылки, которые вели через лишний редирект 302 (/ru/…). В шапке их починили, на странице трансляций такие же ещё остались. Лишние редиректы расходуют краулинговый бюджет. Эффект небольшой, зато настоящий.
Доказать эффект для пользователей можно только с прода: RUM у нас уже собирает INP, и после выкладки сравним значения до и после у тех, кто открывал чат поддержки. Это будет во второй части.
expectRenders и тест, который проходил впустую
it('за минуту каждая карточка перерисовывается один раз', async () => {
mountList(10)
await tick(1)
await expectRenders('card').toBe(10, () => tick(60))
})
На старом коде тест падает с понятным текстом:
Подвох вылез на карточке. Сначала цель была задана самим компонентом: expectRenders(Card). Файл называется card/index.vue, поэтому __name у компонента — index, а сканер называет экземпляр по папке — card. Совпадений ноль, и проверка toBe(0) зелёная при любом поведении.
Во втором проходе ловушка сработала снова, уже по-другому. Тест «ввод не перерисовывает TicketChatHistory» прошёл и на старом коде, где такого компонента ещё не было: нет экземпляров — ноль перерисовок — зелёный. Теперь каждый тест на toBe(0) сначала проверяет, что цель существует, и каждый новый тест мы прогоняем на старом коде. Если он там зелёный, он ничего не защищает.
expect(wrapper.findComponent({ name: 'TicketChatHistory' }).exists()).toBe(true)
await expectRenders('TicketChatHistory').toBe(0, () => type(wrapper, 'Здравствуйте, где приз'))
Чего не хватило на живом аудите
- Цель в expectRenders. Если цель не совпала ни с одним экземпляром, проверка должна падать, а не считать ноль. Мы наступили на это дважды. Ещё лучше, если имя для
index.vueразрешается так же, как в реестре. - Реф в слоте. Перерисовку отчёт приписывает компоненту, который рисует слот, и называет реф безымянным
ref. Нужны имя переменной и компонент-владелец: «readingProgressизNews/[slug], прочитан в слотеNewsLayout». - Причина «computed» шире, чем нужно. В список попадают все computed, ставшие «грязными», включая вернувшие прежнее значение (
isTimeFinishedрядом сtimeBlocks). У layout причиной записанcomputed:showFooter, хотя рендер пришёл от родителя. - useRoute из nuxt/app. В layout маршрут опознан как безымянный
setup:ref, а в компонентах сvue-router— какroute:query.*. - Перемонтирование невидимо. При
includeMounts: falseпересозданный компонент просто исчезает из отчёта. У нас так пропала шапка при переходе на страницу с другим layout, хотя перемонтирование дороже перерисовки. Нужен счётчик «mounted again» и подсказка. - Self time.
totalDurationродителя включает детей, и сумма по отчёту завышена в разы. - Метки сценариев. Хочется
mark('вкладка «Сетка»')внутри одной сессии вместо ручных reset и сохранения на каждый шаг. - Пути в отчёте. Абсолютные пути с именем пользователя перед коммитом приходится вырезать. Пригодилась бы опция
root. - Сохранение в проект. Модуль Nuxt мог бы поднять dev-эндпоинт, который пишет отчёт прямо в репозиторий. Для серии из пятнадцати отчётов мы написали свой приёмник.
Все находки
| Компонент | Сценарий | Причина | До | После | Уровень | Статус |
|---|---|---|---|---|---|---|
applications/card | 10 карточек, 60 с | новый массив на каждый тик | ×600 · 1347 мс | ×10 · 27 мс | Критично | Исправлено |
NewsLayout (статья) | 60 кадров прокрутки | реф прогресса в слоте layout | ×59 · 150 мс | ×0 · полоса 11 мс | Критично | Исправлено, прод |
GlobalTicketChat | ввод 22 букв, 100 сообщений | v-model рядом с историей | ×22 · 180 мс | история ×0 · 19 мс | Критично | Исправлено |
ExChanges, SubmitAgent | загрузка БигМэтча | неверное начальное состояние, игра через template ref | ×4 · ~96 мс | ×2 · ~30 мс | Важно | Исправлено, прод |
FinishOfRegistrationTimer | 2 экз., 60 с | новый массив на каждый тик | ×120 · 9 мс | ×2 · 0,4 мс | Критично | Исправлено, прод |
navbar | 10 смен query | route.path в шаблоне | ×10 · 101 мс | ×0 | Критично | Исправлено |
WatchPredictions, WatchVoting | 60 с | секундный реф в большом компоненте | ×60 | ×0 | Важно | Исправлено, не проверено вживую |
ui-logo | 10 смен query | localePath() в шаблоне | ×20 · 30 мс | ×0 | Важно | Исправлено |
footer | 10 смен query | route.name в шаблоне | ×10 · 20 мс | ×0 | Важно | Исправлено |
layout default | 10 смен query | route.path + layoutProps от Nuxt | ×10 · 101 мс | ×10 · 27 мс | Важно | Частично |
useCountdownTimer, карточка заявки | турнир, заявки | интервал без очистки | — | — | Важно | Исправлено |
| шапка, сайдбары, футер | список турниров ↔ другие | отдельный layout, каркас перемонтируется | не видно | — | Важно | Задача |
LayoutRating | мобилка, открыта подсказка | позиция подсказки пишется на каждый scroll | не замерено | — | Важно | Задача |
| ссылки шапки | любая страница на ru | /ru/ в ссылке: нет подсветки, лишний 302 | — | — | Баг | Исправлено |
LayoutLoader | любая навигация | внутренности Nuxt | ×10 · 27 мс | — | Мелочь | Оставлено |
UiLoader | любой лоадер | анимация на setInterval | 5 в секунду | — | Мелочь | Оставлено |
GiveawayGiftTeaser | загрузка главной | computed после гидрации | ×2 · 15 мс | — | Мелочь | Оставлено |
Время за сценарий, в браузере (dev-сборка) или в happy-dom для таймеров и чата. Время родителя включает детей. Не пройдены, потому что нужен аккаунт: профиль, страница команды, live-обновления Watch, login и WebSocket. Их, вместе с INP с прода, стоит снять для второй части статьи.