Система оцінки якості взаємодії користувача з веб-інтерфейсом від 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 мс) |
+--------------+ +--------------+ +--------------+
^ ^
| |
[Обробка кліку] [Відтворення кадру]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="uk">
<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>
);
}Скрипти 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 без вирівнювання метрик шрифту викликає різкий стрибок рядків при зміні базового шрифту на кастомний (високий CLS).
Рішення: Використовуйте font-display: optional (шрифт застосовується тільки якщо встиг завантажитися до першого кадру) або налаштовуйте @font-face з дескрипторами size-adjust, ascent-override та descent-override.
Браузер зчитує link rel=preload у статичному HTML і починає завантаження LCP-банера паралельно з бандлом JS.
CSS-контейнери застосовують aspect-ratio та contain: layout paint, резервуючи точний простір під асинхронні медіа.
Важкі функції обробки розбиваються на чанки до 16 мс із викликом scheduler.yield() між ітераціями.
Введення користувача ізолюється через useTransition, забезпечуючи негайний відгук поля введення.