Nuxt 3 проект e-коммерции, развёрнутый на Cloudflare Pages, показывал LCP 10.2s в PageSpeed Insights. Google Fonts, гидратация на стороне клиента, загрузка above-the-fold контента и заголовки кеша CDN — классические узкие места. С помощью self-hosted font subsetting, Vue 3 lazy hydration API, CSS content-visibility и Cloudflare edge cache TTL мы снизили LCP до 2.1s. В этой статье — техническая детализация четырёх оптимизаций и результаты бенчмарков.
Self-Hosted Font Subsetting: FCP снизился на 900ms
CSS файл Google Fonts блокировал рендеринг и занимал 320ms. WOFF2 шрифт загружался после, First Contentful Paint достигал 3.8s. Мы установили пакет @fontsource и выбрали только Latin подмножество + weight range 400-700:
npm install @fontsource-variable/inter
Импорт в app.vue:
import '@fontsource-variable/inter/wght.css';
Настройка в nuxt.config.ts:
export default defineNuxtConfig({
css: ['@fontsource-variable/inter/wght.css'],
vite: {
css: {
postcss: {
plugins: [
require('postcss-preset-env')({
features: { 'custom-properties': false }
})
]
}
}
}
});
Результат: WOFF2 файл 24KB, встроен в первый запрос. FCP улучшился: 3.8s → 2.9s. Время блокировки рендеринга: 320ms → 0ms. Мы использовали wght.css для сохранения переменных осей шрифта вместо статических файлов весов.
CDN Google Fonts имеет много edge location'ов, но DNS lookup + TLS handshake добавляли 200-300ms для каждого посетителя. Self-hosted решение исключило дополнительный DNS переход, хотя доставка с Cloudflare Pages edge'а остаётся одинаково быстрой.
Lazy Hydration: TBT снизился с 2190ms до 200ms
Nuxt 3 по умолчанию гидратирует все компоненты на стороне клиента. На странице каталога товаров 48 карточек, каждая требует 120KB JavaScript для Vue reactivity. Общее время блокировки потока (Total Blocking Time) — 2190ms. Пользователь не может скроллить страницу две секунды.
Мы использовали Vue 3.5+ defineAsyncComponent + hydration: lazy для отложенной гидратации below-the-fold компонентов:
// components/ProductCard.vue
<script setup>
defineOptions({
hydration: 'lazy'
});
</script>
С Intersection Observer гидратация запускается при входе в viewport:
// plugins/lazy-hydration.client.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.mixin({
mounted() {
if (this.$options.hydration === 'lazy') {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.$forceUpdate();
observer.disconnect();
}
});
});
observer.observe(this.$el);
}
}
});
});
Above-the-fold контент (герой + первые 6 товаров) гидратируется сразу, остальное — по требованию. Размер бандла: 480KB → 280KB initial, 200KB lazy chunk. TBT: 2190ms → 200ms. Пользователь может скроллить через одну секунду.
Компромисс: Event listener для lazy hydration может задержать инициализацию. Для компонентов с обработчиком клика (кнопка "Добавить в корзину") мы оставили hydration: 'immediate'. Для scroll-triggered контента lazy-гидратация идеальна.
Встроенный Lazy Component'и в Nuxt
Nuxt 3.0+ имеет встроенный префикс <LazyComponentName>:
<template>
<LazyProductCard v-for="product in products" :key="product.id" />
</template>
Этот метод не выполняет SSR компонента, только клиентский mount. Для SEO нужен SSR, поэтому мы выбрали подход с defineOptions.
CSS content-visibility: LCP сэкономил 1.4s
Сетка товаров из 48 карточек вызывала layout shift. Браузер рендерит каждую карточку и пересчитывает CLS, LCP задерживается. Мы использовали content-visibility: auto чтобы исключить off-screen контент из цикла рендеринга:
.product-card {
content-visibility: auto;
contain-intrinsic-size: 0 360px;
}
contain-intrinsic-size подсказывает браузеру размер элемента (360px высота) и сохраняет layout space для off-screen контента. CLS: 0.18 → 0.02.
Бенчмарк (Lighthouse 10.4, throttled 4G):
| Метрика | До | После | Улучшение |
|---|---|---|---|
| LCP | 10.2s | 2.1s | -8.1s |
| CLS | 0.18 | 0.02 | -0.16 |
| TBT | 2190ms | 200ms | -1990ms |
content-visibility поддерживается в Safari 17+, на iOS 16 работает fallback. Используйте @supports для progressive enhancement:
@supports (content-visibility: auto) {
.product-card {
content-visibility: auto;
contain-intrinsic-size: 0 360px;
}
}
Этот подход критичен в процессе UI/UX дизайна. Пользовательский опыт независим от стоимости рендеринга off-screen контента.
Cloudflare Pages Edge Cache TTL Оптимизация
Cloudflare Pages использует стандартный edge cache TTL 2 часа. Цены товаров обновляются каждые 15 минут, но визуальные ассеты (изображения, шрифты) остаются неизменными 7 дней. Файл _headers позволяет granular cache control:
# _headers
/assets/*
Cache-Control: public, max-age=604800, immutable
/_nuxt/*
Cache-Control: public, max-age=31536000, immutable
/api/*
Cache-Control: public, s-maxage=900, stale-while-revalidate=60
/*
Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=300
/assets/*и/_nuxt/*: 1 год immutable (URL меняется с хешем, новый файл = новый путь)/api/*: 15 минут edge cache, 60 сек stale-while-revalidate (если origin упал, отдай старые данные)- Root HTML: 1 час edge cache, 5 минут stale-while-revalidate
Time to First Byte с edge location'а — 40ms, с origin — 280ms. Hit rate кеша: 89% → 96%. TTFB median: 280ms → 45ms.
stale-while-revalidate критичен для UX: Если origin обновляется, кеш отдаёт старую версию пользователю, а в фоне запрашивает свежие данные. Пользователь не ждёт.
Cloudflare KV для Selective Cache Purge
Вместо purge всего кеша при обновлении цены используем Cloudflare KV + Workers для targeted invalidation:
// workers/cache-purge.js
export default {
async fetch(request, env) {
const url = new URL(request.url);
const productId = url.searchParams.get('id');
const cacheKey = `product:${productId}`;
await env.CACHE_KV.delete(cacheKey);
return new Response('Cache purged', { status: 200 });
}
};
Админ-панель → обновление цены → webhook → Cloudflare Worker → KV delete. Edge cache TTL сохраняется, только изменённые товары invalidate.
Performance Мониторинг и Regression Prevention
RUM (Real User Monitoring) с Cloudflare Web Analytics + custom Navigation Timing beacon:
// plugins/analytics.client.ts
export default defineNuxtPlugin(() => {
if (typeof window !== 'undefined') {
window.addEventListener('load', () => {
const perfData = performance.getEntriesByType('navigation')[0];
const lcp = performance.getEntriesByType('largest-contentful-paint')[0];
fetch('/api/perf', {
method: 'POST',
body: JSON.stringify({
ttfb: perfData.responseStart - perfData.requestStart,
fcp: perfData.domContentLoadedEventEnd - perfData.fetchStart,
lcp: lcp?.renderTime || 0,
pathname: window.location.pathname
})
});
});
}
});
Данные в BigQuery, дневной P75 LCP мониторится. При превышении 2.5s — Slack alert. CI/CD pipeline использует Lighthouse CI для regression check:
# .github/workflows/lighthouse.yml
- name: Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun --config=./lighthouserc.json
lighthouserc.json содержит LCP assertion:
{
"ci": {
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }]
}
}
}
}
Если LCP при развёртывании превышает 2.5s, сборка падает. Production регрессия предотвращена.
Компромиссы и Edge Cases
Lazy hydration зависит от scroll position. Быстрый скролл может отложить гидратацию и влияет на interactivity. Решение: Intersection Observer с rootMargin: '100px' — гидратация запускается за 100px до viewport.
content-visibility в grid layout'ах может вызвать CLS при изменении column count. Необходимо фиксировать grid-template-columns + contain-intrinsic-size комбинацию.
Edge cache с stale-while-revalidate создаёт риск несогласованности цен: Пользователь A видит старую цену, пользователь B — новую. Выбор зависит от требований: E-коммерция допускает 60 сек stale window, fintech — нет.
Self-hosted шрифты требуют проверки лицензии. Google Fonts использует SIL Open Font License (свободна), коммерческие шрифты требуют соглашения о лицензировании.
Эти четыре оптимизации снизили LCP на 80%. Nuxt 3 с Vue 3 reactivity идеален для lazy hydration. Cloudflare Pages edge network достаточна как CDN, для динамического контента KV + Workers дают granular cache control. Production требует RUM + Lighthouse CI для regression prevention.