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):

МетрикаДоПослеУлучшение
LCP10.2s2.1s-8.1s
CLS0.180.02-0.16
TBT2190ms200ms-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.