Система оценки качества взаимодействия пользователя с веб-интерфейсом от Google — Core Web Vitals (CWV) — является прямым фактором поискового ранжирования. В актуальной спецификации 2026 года ключевыми метриками являются:
В традиционных клиентских SPA показатели CWV страдают сильнее всего из-за фундаментальных архитектурных особенностей:
fetch() к REST/GraphQL API за данными страницы, и только после получения JSON начинает загрузку основного изображения товара. Это растягивает LCP до 4–7 секунд на медленных мобильных сетях.Анатомия задержки взаимодействия (INP) складывается из трех фаз:
INP = Input Delay + Processing Time + Presentation Delay
Пользовательский клик
|
v
+-----------------------------------------------------------------------+
| 1. Input Delay (Задержка ввода) |
| Ожидание завершения текущей задачи в основном потоке (Long Task) |
+-----------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------+
| 2. Processing Time (Время обработки) |
| Выполнение обработчиков onClick, обновление стейта React |
+-----------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------+
| 3. Presentation Delay (Задержка отрисовки) |
| Recalculate Style -> Layout -> Paint -> Композиция кадра |
+-----------------------------------------------------------------------+
|
v
Кадр отображен на экране (Next Paint)Декомпозиция длинных задач строится на возврате контроля браузерному планировщику (Yielding to Main Thread). Если разбить монолитную операцию в 150 мс на три кванта по 40 мс с помощью нативного веб-API scheduler.yield(), браузер сможет отрисовать промежуточный кадр и обработать клик пользователя между квантами:
Монолитная задача (INP > 150 мс):
+-------------------------------------------------------------+
| Task (150 мс) [Блокировка ввода] |
+-------------------------------------------------------------+
Декомпозиция через scheduler.yield() (INP < 50 мс):
+--------------+ +--------------+ +--------------+
| Квант 1 | yield | Квант 2 | yield | Квант 3 |
| (40 мс) | -------> | (40 мс) | -------> | (40 мс) |
+--------------+ +--------------+ +--------------+
^ ^
| |
[Обработка клика] [Отрисовка кадра]Браузер считывает link rel=preload в статическом HTML и начинает загрузку LCP-баннера параллельно с бандлом JS.
CSS-контейнеры применяют aspect-ratio и contain: layout paint, резервируя точное пространство под асинхронные медиа.
Тяжелые функции обработки разбиваются на чанки до 16 мс с вызовом scheduler.yield() между итерациями.
Пользовательский ввод изолируется через useTransition, обеспечивая немедленный отклик поля ввода.
В чем заключается принципиальное отличие метрики INP (Interaction to Next Paint) от устаревшей First Input Delay (FID)?
useTransition и scheduler.yield() при клиентской сортировке данных для сохранения отклика поля ввода в пределах 16 мс.<link rel="preload" as="image"> до инициализации JS-бандла.aspect-ratio и contain-intrinsic-size для полной изоляции сдвигов CLS при асинхронном монтировании сторонних компонентов.@font-face с font-display: optional или выравнивание метрик шрифта с помощью size-adjust для устранения скачков текста при загрузке кастомного шрифта.scheduler.yield() и React startTransition#В современном вебе API scheduler.yield() позволяет уступить квант времени основному потоку браузера, не сбрасывая приоритет задачи. Ниже представлена реализация полифилла и утилиты пакетной обработки больших массивов данных.
declare global {
interface Window {
scheduler?: {
yield?: () => Promise<void>;
};
}
}
// Кросс-браузерная уступка основному потоку
export async function yieldToMain(): Promise<void> {
if (typeof window !== 'undefined' && window.scheduler?.yield) {
return window.scheduler.yield();
}
// Fallback на MessageChannel (эффективнее и быстрее setTimeout(fn, 0))
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => resolve();
channel.port2.postMessage(null);
});
}
// Пакетная неблокирующая обработка массива с контролем квантов времени
export async function chunkedDataProcessing<T, R>(
items: readonly T[],
processItem: (item: T) => R,
maxTimePerChunkMs: number = 16
): Promise<R[]> {
const results: R[] = [];
let lastYieldTime = performance.now();
for (let i = 0; i < items.length; i++) {
results.push(processItem(items[i]));
// Если прошло больше 16 мс (бюджет 1 кадра при 60 FPS), уступаем поток
if (performance.now() - lastYieldTime > maxTimePerChunkMs) {
await yieldToMain();
lastYieldTime = performance.now();
}
}
return results;
}useTransition и useDeferredValue для оптимизации INP#При вводе текста в поле фильтрации мгновенное синхронное обновление тяжелого списка товаров вызывает просадку INP. Разделение приоритетов через useTransition гарантирует мгновенную отрисовку вводимого символа.
import React, { useState, useTransition, useDeferredValue, ChangeEvent } from 'react';
interface Product {
id: string;
name: string;
category: string;
}
interface FilterProps {
products: readonly Product[];
}
export function FastCatalogFilter({ products }: FilterProps) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
// Отложенное значение для некритичных вычислений
const deferredQuery = useDeferredValue(query);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const nextValue = e.target.value;
// Срочное обновление: ввод пользователя отображается без задержки
setQuery(nextValue);
// Низкоприоритетный рендеринг списка товаров
startTransition(() => {
// Интенсивная фильтрация не блокирует поле ввода
});
};
const filteredProducts = products.filter((p) =>
p.name.toLowerCase().includes(deferredQuery.toLowerCase())
);
return (
<div className="space-y-4">
<div className="relative">
<input
type="search"
value={query}
onChange={handleInputChange}
placeholder="Быстрый поиск товаров..."
className="w-full px-4 py-2 rounded-lg border border-border bg-surface text-slate-100 focus:outline-none focus:border-accent-cyan"
/>
{isPending && (
<span className="absolute right-3 top-2.5 text-xs text-accent-violet animate-pulse">
Фильтрация...
</span>
)}
</div>
<ul className="divide-y divide-border border border-border rounded-lg max-h-96 overflow-y-auto">
{filteredProducts.slice(0, 100).map((product) => (
<li key={product.id} className="p-3 text-sm hover:bg-surface/50">
{product.name}
</li>
))}
</ul>
</div>
);
}В SPA главное изображение (LCP Element) не должно ждать, пока скачается и выполнится app.bundle.js. Ссылка на критическое изображение должна быть инжектирована в <head> статически или на раннем этапе загрузки HTML.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- 1. Ранний Preload основного скрипта бандла -->
<link rel="modulepreload" href="/assets/main-bundle.js" />
<!-- 2. Немедленный Preload изображения героя (LCP) -->
<link
rel="preload"
as="image"
href="https://images.agenticlab.dev/hero-banner-1200.webp"
type="image/webp"
fetchpriority="high"
/>
<!-- 3. Preconnect к критическому домену API -->
<link rel="preconnect" href="https://api.agenticlab.dev" crossorigin />
<link rel="dns-prefetch" href="https://api.agenticlab.dev" />
<style>
/* 4. Инлайн критического CSS для каркаса LCP контейнера */
.hero-container {
aspect-ratio: 16 / 9;
background-color: #09090b;
contain: layout paint;
width: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
</body>
</html>aspect-ratio#Любой динамически подгружаемый контент (реклама, динамический баннер, скелетон) обязан иметь фиксированные геометрические ограничения в CSS.
import React, { useState } from 'react';
interface StableMediaProps {
src: string;
alt: string;
width: number;
height: number;
}
export function StableMediaContainer({ src, alt, width, height }: StableMediaProps) {
const [isLoaded, setIsLoaded] = useState(false);
return (
// Фиксация пропорций через aspect-ratio полностью предотвращает CLS
<div
className="relative overflow-hidden rounded-xl bg-slate-900 border border-slate-800"
style={{
aspectRatio: `${width} / ${height}`,
minHeight: '200px',
contain: 'layout paint',
}}
>
<img
src={src}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
onLoad={() => setIsLoaded(true)}
className={`w-full h-full object-cover transition-opacity duration-300 ${
isLoaded ? 'opacity-100' : 'opacity-0'
}`}
/>
{!isLoaded && (
<div className="absolute inset-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 animate-pulse" />
)}
</div>
);
}Почему атрибут loading='lazy' категорически запрещено применять к главному баннеру (LCP Element) первого экрана?
Скрипты Google Tag Manager, Яндекс.Метрики и пикселей ретаргетинга, запускаемые синхронно, порождают непрерывные Long Tasks в первые 3 секунды загрузки. Если пользователь пытается кликнуть по сайту в этот момент, метрика INP взлетает до 400–800 мс.
Решение: Выносите аналитику в Web Worker с помощью библиотек типа Partytown либо загружайте сторонние скрипты строго после события requestIdleCallback.
Если на главном баннере страницы висит обычный тег <img> или, что еще хуже, loading="lazy", браузер понижает приоритет сетевого потока, отдавая предпочтение второстепенным скриптам и стилям. Метрика LCP затягивается на секунды.
Решение: На ключевое LCP-изображение всегда ставьте fetchpriority="high" и никогда не применяйте loading="lazy" к первому экрану.
Рекламный блок с высотой auto вставляется в середину статьи после загрузки скрипта рекламной сети. Весь последующий текст резко сдвигается вниз на 250px прямо под пальцем читающего пользователя, генерируя катастрофический скачок CLS > 0.25.
Решение: Оборачивайте баннеры в контейнеры с жестким резервированием максимальной ожидаемой высоты: min-height: 280px; display: block;.
Значение font-display: block скрывает текст до полной загрузки веб-шрифта (Flash of Invisible Text), ухудшая LCP. Значение swap без выравнивания метрик шрифта (fallback font metrics override) вызывает резкий скачок строк при смене базового шрифта на кастомный (высокий CLS).
Решение: Используйте font-display: optional (шрифт применяется только если успел загрузиться до первого кадра) либо настраивайте @font-face с дескрипторами size-adjust, ascent-override и descent-override.