0%

Как ускорить загрузку страниц сайтов на React или Vue

Как ускорить загрузку страниц сайтов на React или Vue

Настраиваем серверный рендеринг через Next.js и Nuxt, чтобы быстрее отдавать контент и лучше ранжироваться в поисковиках

Содержание:

В SPA браузер сначала загружает JavaScript, запускает приложение, запрашивает данные и только потом показывает контент. Чем тяжелее бандл и медленнее ответы API, тем дольше пользователь ждет первый экран.

Для публичных сайтов эту работу можно перенести на сервер. Next.js и Nuxt поддерживают несколько стратегий: 

  • SSR для генерации страницы при каждом запросе, 

  • ISR для кэширования и периодического обновления HTML, 

  • серверные компоненты для сокращения клиентского JavaScript.

Разберем их на одном примере — карточке товара с ценой, остатком и кнопкой «В избранное».

С чего начинается проблема: страница полностью собирается в браузере

В классическом React-приложении данные часто загружаются через useEffect:

// ProductPage.tsx

import { useEffect, useState } from 'react';

type Product = {
  id: string;
  name: string;
  price: number;
  stock: number;
};

export default function ProductPage() {
  const [product, setProduct] = useState<Product | null>(null);

  useEffect(() => {
    fetch('/api/products/42')
      .then((response) => response.json())
      .then(setProduct);
  }, []);

  if (!product) {
    return <div>Загрузка...</div>;
  }

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.price} ₽</p>
      <p>В наличии: {product.stock}</p>
    </main>
  );
}

Во Vue логика выглядит примерно так же:

script setup lang="ts">
import { onMounted, ref } from 'vue';

type Product = {
  id: string;
  name: string;
  price: number;
  stock: number;
};

const product = ref<Product | null>(null);

onMounted(async () => {
  product.value = await $fetch<Product>('/api/products/42');
});
</script>

<template>
  <div v-if="!product">
    Загрузка...
  </div>

  <main v-else>
    <h1>{{ product.name }}</h1>
    <p>{{ product.price }} ₽</p>
    <p>В наличии: {{ product.stock }}</p>
  </main>
</template>

В обоих случаях сервер сначала отдает страницу без данных, после чего браузер:

  1. Скачивает, разбирает и выполняет JavaScript.

  2. Отправляет запрос к API и получает товар.

  3. Наконец-то, строит интерфейс и отдает пользователю.

Такой сценарий подходит для административных панелей, редакторов и внутренних систем. Для карточки товара, статьи или страницы услуги он создает лишнюю задержку. Пользователь получает контент только после загрузки и выполнения JavaScript, хотя необходимые данные уже были доступны на сервере.

С чего начинается проблема: страница полностью собирается в браузере

Server-Side Rendering: формируем страницу при каждом запросе

SSR переносит получение данных и первичный рендеринг на сервер. Технология нужна, если содержимое должно быть актуальным при каждом открытии:

  • остатки и цены товаров;

  • персональные предложения;

  • результаты поиска;

  • данные личного кабинета;

  • страницы, зависящие от cookies или заголовков запроса.

Пользователь запрашивает страницу. Сервер получает актуальные данные, собирает HTML и отправляет результат браузеру. JavaScript подключается после отображения контента и отвечает за интерактивность.

SSR в Next.js

В App Router компоненты страницы по умолчанию являются серверными. Чтобы получать свежие данные при каждом запросе, можно отключить кэширование через cache: 'no-store':

// app/products/[id]/page.tsx

type Product = {
  id: string;
  name: string;
  price: number;
  stock: number;
};

async function getProduct(id: string): Promise<Product> {
  const response = await fetch(
    `https://api.example.com/products/${id}`,
    {
      cache: 'no-store',
    }
  );

  if (!response.ok) {
    throw new Error('Не удалось загрузить товар');
  }

  return response.json();
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.price} ₽</p>
      <p>В наличии: {product.stock}</p>
    </main>
  );
}

App Router построен на React Server Components и поддерживает серверные компоненты, Suspense и серверные функции. Здесь нет useEffect, состояния загрузки и дополнительного запроса из браузера. Сервер получает данные до отправки контента страницы.

SSR в Nuxt

В Nuxt серверный рендеринг включен по умолчанию. Для загрузки данных используется useFetch или useAsyncData.

<!-- pages/products/[id].vue -->

<script setup lang="ts">
type Product = {
  id: string;
  name: string;
  price: number;
  stock: number;
};

const route = useRoute();

const { data: product, error } = await useFetch<Product>(
  `/api/products/${route.params.id}`
);

if (error.value) {
  throw createError({
    statusCode: 500,
    statusMessage: 'Не удалось загрузить товар',
  });
}
</script>

<template>
  <main v-if="product">
    <h1>{{ product.name }}</h1>
    <p>{{ product.price }} ₽</p>
    <p>В наличии: {{ product.stock }}</p>
  </main>
</template>

Запрос выполняется на сервере при открытии страницы. Полученные данные попадают в HTML и передаются клиенту вместе с payload. Повторно запрашивать тот же товар после гидратации не требуется.

Nuxt рекомендует использовать SSR-совместимые composables useFetch, useAsyncData и useState, чтобы сервер и клиент работали с одним набором данных.

Проекты MediaTen на Nuxt →

Цена актуальности данных — нагрузка на сервер. Каждый запрос запускает их получение и генерацию HTML. Поэтому страницы, которые меняются редко, выгоднее кэшировать.

Incremental Static Regeneration: один раз создаем страницу, затем обновляем её

ISR сочетает статическую генерацию и обновление контента без полной пересборки сайта. Подходит для:

  • статей;

  • документации;

  • страниц услуг;

  • категорий каталога;

  • карточек товаров, где допустима небольшая задержка обновления;

  • маркетинговых страниц.

Страница сохраняется в кэше. Пользователи получают готовый HTML, а фреймворк обновляет его по заданному интервалу или после явного сброса кэша.

ISR в Next.js

Для периодического обновления данных можно передать интервал через next.revalidate:

// app/products/[id]/page.tsx

type Product = {
  id: string;
  name: string;
  price: number;
  description: string;
};

async function getProduct(id: string): Promise<Product> {
  const response = await fetch(
    `https://api.example.com/products/${id}`,
    {
      next: {
        revalidate: 300,
        tags: [`product-${id}`],
      },
    }
  );

  if (!response.ok) {
    throw new Error('Не удалось загрузить товар');
  }

  return response.json();
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.price} ₽</p>
      <p>{product.description}</p>
    </main>
  );
}

revalidate: 300 разрешает хранить результат до пяти минут. Пользователь получает кэшированную страницу, а данные периодически обновляются.

Next.js расширяет серверный fetch настройками кэширования и ревалидации. ISR также можно запускать по требованию через revalidatePath или revalidateTag.

Например, CMS отправляет webhook после редактирования товара:

// app/api/revalidate/route.ts

import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const body = await request.json();

  if (body.secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json(
      { error: 'Invalid secret' },
      { status: 401 }
    );
  }

  revalidateTag(`product-${body.productId}`, 'max');

  return NextResponse.json({ revalidated: true });
}

Пример выше написан для Next.js 16, где revalidateTag принимает второй аргумент с профилем кэширования. После обновления товара CMS вызывает обработчик, который помечает кэш как устаревший. Next.js обновит данные при следующем запросе к странице без ожидания полного времени жизни кэша.

ISR и SWR в Nuxt

В Nuxt стратегии задаются через routeRules:

// nuxt.config.ts

export default defineNuxtConfig({
  routeRules: {
    '/': {
      prerender: true,
    },

    '/blog/**': {
      isr: 3600,
    },

    '/catalog/**': {
      swr: 300,
    },

    '/account/**': {
      ssr: true,
    },
  },

Здесь используются разные стратегии в одном приложении:

  • главная страница генерируется заранее,

  • статьи обновляются раз в час,

  • каталог использует stale-while-revalidate с интервалом 5 минут,

  • личный кабинет рендерится динамически.

Nuxt поддерживает гибридный рендеринг. Для каждой группы маршрутов можно назначить свой режим или стратегию кэширования через routeRules. Обработку кэша берет на себя Nitro.

Разница между isr и swr зависит от платформы развертывания и выбранного preset Nitro. Поэтому перед запуском нужно проверить поддержку стратегии у конкретного хостинга.

SSR или ISR: выбираем стратегию для своего проекта

Как видно из примера выше, для реальных проектов идеально подходит комплекс стратегий. Представим интернет-магазин, где страница /products/42 содержит:

  • название, цену и описание товара — меняются редко, можно кэшировать через ISR;

  • остаток — должен быть свежим, лучше получать при запросе или обновлять отдельно;

  • отзывы — можно показывать из кэша и пересобирать раз в несколько минут;

  • персональные рекомендации — зависят от пользователя и остаются динамическими.

Next.js и Nuxt позволяют сочетать подходы на уровне маршрутов, запросов и отдельных блоков страницы.

Однако SSR и ISR не решают проблему JavaScript полностью. Технологии ускоряют появление HTML, но после загрузки страницы браузер все равно должен выполнить гидратацию клиентское приложение.

Рассмотрим обычную карточку:

function ProductCard({ product }: { product: Product }) {
  const [favorite, setFavorite] = useState(false);

  return (
    <article>
      <img src={product.image} alt={product.name} />
      <h2>{product.name}</h2>
      <p>{product.price} ₽</p>

      <button onClick={() => setFavorite(!favorite)}>
        {favorite ? 'В избранном' : 'В избранное'}
      </button>
    </article>
  );
}

Интерактивна здесь только кнопка. Но из-за useState весь компонент становится клиентским. В браузер попадает код карточки и всех ее клиентских зависимостей.

React Server Components позволяют разделить статичную и интерактивную части.

React Server Components в Next.js

Компоненты App Router являются серверными, пока разработчик явно не назначит директиву 'use client'.

Серверная карточка получает данные и формирует основную разметку:

// app/products/[id]/ProductCard.tsx

import Image from 'next/image';
import FavoriteButton from './FavoriteButton';

type Product = {
  id: string;
  name: string;
  image: string;
  price: number;
};

export default async function ProductCard({
  productId,
}: {
  productId: string;
}) {
  const response = await fetch(
    `https://api.example.com/products/${productId}`,
    {
      next: {
        revalidate: 300,
      },
    }
  );

  if (!response.ok) {
    throw new Error('Не удалось загрузить товар');
  }

  const product: Product = await response.json();

  return (
    <article>
      <Image
        src={product.image}
        alt={product.name}
        width={640}
        height={640}
        priority
      />

      <h2>{product.name}</h2>
      <p>{product.price} ₽</p>

      <FavoriteButton productId={product.id} />
    </article>
  );
}

Кнопку выносим отдельно:

// app/products/[id]/FavoriteButton.tsx

'use client';

import { useState } from 'react';

export default function FavoriteButton({
  productId,
}: {
  productId: string;
}) {
  const [favorite, setFavorite] = useState(false);

  async function toggleFavorite() {
    const nextValue = !favorite;

    setFavorite(nextValue);

    await fetch(`/api/favorites/${productId}`, {
      method: nextValue ? 'POST' : 'DELETE',
    });
  }

  return (
    <button type="button" onClick={toggleFavorite}>
      {favorite ? 'В избранном' : 'В избранное'}
    </button>
  );
}

В клиентский бандл попадет кнопка и ее зависимости. Получение данных и разметка карточки останутся на сервере.

React определяет Server Components как компоненты, которые выполняются до отправки клиентского бандла и могут запускаться во время сборки или при запросе. Директива 'use client' задает границу, ниже которой код становится клиентским.

На проектах с большим количеством контентных блоков такое разделение может уменьшить клиентский бандл на 30–50%. Это ориентир, а не гарантия: результат зависит от того, сколько компонентов и зависимостей удастся оставить на сервере.

Streaming и Suspense

Обычный SSR может ждать самый медленный запрос перед отправкой всей страницы. Например:

  • товар загружается за 100 мс;

  • рекомендации — за 900 мс;

  • отзывы — за 1,4 секунды.

Без разделения пользователь может ждать отзывы, хотя основная карточка давно готова.

В Next.js медленные части можно обернуть в Suspense:

// app/products/[id]/page.tsx

import { Suspense } from 'react';
import ProductInfo from './ProductInfo';
import ProductReviews from './ProductReviews';
import Recommendations from './Recommendations';

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  return (
    <main>
      <ProductInfo productId={id} />

      <Suspense fallback={<div>Загружаем отзывы...</div>}>
        <ProductReviews productId={id} />
      </Suspense>

      <Suspense fallback={<div>Подбираем похожие товары...</div>}>
        <Recommendations productId={id} />
      </Suspense>
    </main>
  );
}

Сервер начинает отправлять готовые части страницы, не дожидаясь остальных блоков. Пользователь раньше всего остального видит название, цену и фотографию товара.

Серверные компоненты и islands в Nuxt

В Nuxt обычная страница уже рендерится на сервере, но затем Vue гидратирует клиентское приложение. Для компонентов, которым JavaScript в браузере вообще не нужен, Nuxt предлагает server components и islands.

Пример серверного компонента:

<!-- components/ProductDescription.server.vue -->

<script setup lang="ts">
const props = defineProps<{
  productId: string;
}>();

const product = await $fetch<{
  name: string;
  description: string;
  image: string;
}>(
  `https://api.example.com/products/${props.productId}`
);
</script>

<template>
  <section>
    <NuxtImg
      :src="product.image"
      :alt="product.name"
      width="640"
      height="640"
    />

    <h2>{{ product.name }}</h2>
    <p>{{ product.description }}</p>
  </section>
</template>

Использование на странице:

<!-- pages/products/[id].vue -->

<script setup lang="ts">
const route = useRoute();
</script>

<template>
  <main>
    <ProductDescription
      :product-id="String(route.params.id)"
    />

    <FavoriteButton
      :product-id="String(route.params.id)"
    />
  </main>
</template>

Серверный компонент всегда рендерится на сервере и не отправляет собственный JavaScript клиенту. В Nuxt эта архитектура связана с компонентными islands.

Для явного использования island можно написать:

<template>
  <NuxtIsland
    name="ProductDescription"
    :props="{ productId: '42' }"
  />
</template>

<NuxtIsland> рендерит неинтерактивный компонент без загрузки его JavaScript на клиент. При изменении props Nuxt повторно запрашивает HTML этого блока с сервера.

Что оставить клиентским

Переносить на сервер все компоненты подряд не нужно. Клиентскими остаются элементы, которым нужны:

  • useState, useEffect и обработчики событий в React;

  • ref, watch, onMounted и браузерная реактивность во Vue;

  • window, document, localStorage;

  • drag-and-drop;

  • карты;

  • редакторы;

  • графики;

  • анимации;

  • мгновенная фильтрация;

  • состояние, которое меняется без перехода на новую страницу.

Пример клиентского компонента Nuxt:

<!-- components/FavoriteButton.client.vue -->

<script setup lang="ts">
const props = defineProps<{
  productId: string;
}>();

const favorite = ref(false);
const pending = ref(false);

async function toggleFavorite() {
  pending.value = true;

  try {
    favorite.value = !favorite.value;

    await $fetch(`/api/favorites/${props.productId}`, {
      method: favorite.value ? 'POST' : 'DELETE',
    });
  } finally {
    pending.value = false;
  }
}
</script>

<template>
  <button
    type="button"
    :disabled="pending"
    @click="toggleFavorite"
  >
    {{ favorite ? 'В избранном' : 'В избранное' }}
  </button>
</template>

Суффикс .client.vue ограничивает выполнение компонента клиентом. Для сторонних библиотек, которые обращаются к DOM во время инициализации, также можно использовать <ClientOnly>. Nuxt рекомендует переносить браузерный код в onMounted или изолировать его клиентским компонентом.

Как SSR, ISR и Server Components влияют на Core Web Vitals 

Рассмотрим основные метрики CWV и какой эффект можно получить от использования технологий. Без розовых очков.  

LCP — скорость загрузки сайта: оценивается время от начала перехода до отрисовки самого большого видимого элемента

SSR и ISR позволяют включить основной контент в первоначальный HTML. Заголовок, изображение или карточка товара могут появиться до завершения загрузки всего клиентского приложения.

Сам серверный рендеринг не гарантирует хороший LCP. Показатель все равно испортят тяжелые изображения, медленный сервер, отсутствующий CDN, блокирующие шрифты, большой CSS… В общем, разбираться с причинами надо комплексно.

INP — скорость отклика интерфейса на любые действия пользователя в течение всего времени его пребывания на странице

Server Components сокращают объем JavaScript, который браузер должен разобрать и выполнить. Это освобождает основной поток и может улучшить отзывчивость страницы.

Если после перехода вся интерактивность остается внутри одного большого клиентского компонента, заметного улучшения не будет. Граница 'use client' распространяется на его клиентские зависимости, поэтому ее нужно размещать как можно ближе к интерактивному элементу.

CLS — визуальная стабильность страницы, фиксирующая сдвиги контента

SSR сам по себе не решает проблему смещений. Для изображений по-прежнему нужно резервировать размеры, а для асинхронных блоков — использовать skeleton или контейнер с предсказуемой высотой.

TTFB — время между запросом страницы и получением первого байта данных

При SSR TTFB может вырасти, потому что серверу нужно получить данные и собрать HTML до начала ответа. Эту проблему решают кэширование, streaming, CDN и правильный выбор между SSR и ISR.

Что получаем в итоге? Нельзя говорить, что SSR автоматически улучшает все метрики. Он меняет точку выполнения работы. Результат зависит от запросов, кэша, инфраструктуры и структуры клиентских компонентов.

При SSR и ISR поисковый робот сразу получает:

  • заголовки, тексты, ссылки;

  • микроразметку;

  • canonical;

  • Open Graph;

  • описания товаров и категорий.

Это упрощает индексацию, но не заменяет остальные работы по SEO. Серверный рендеринг не исправит дубли страниц, неверные canonical, слабый контент, ошибки в sitemap или закрытые от индексации маршруты.

Стратегия для проектов на поддержке

SSR и ISR обычно дают заметный эффект на:

  • интернет-магазинах и маркетплейсах;

  • корпоративных сайтах;

  • медиа, блогах, каталогах;

  • документации (тут даже SSG);

  • публичных образовательных платформах.

CSR может остаться рациональным выбором для:

  • внутренних CRM;

  • административных панелей;

  • сложных редакторов;

  • аналитических интерфейсов;

  • систем, доступных только после авторизации;

  • проектов без поискового трафика.

Переписывать весь проект за один релиз необязательно.

1. Можно начать со страниц, которые:

  • получают больше всего поискового трафика,

  • имеют плохой LCP,

  • отправляют крупный JavaScript-бандл,

  • содержат много статичной разметки,

  • показывают загрузку до появления основного контента.

2. Нужно измерить до и после:

  • размер клиентского JS,

  • LCP, INP, TTFB;

  • количество клиентских компонентов;

  • время ответа API;

  • долю кэшированных запросов.

Если после переноса серверной части бандл уменьшился с 900 до 500 КБ, это сокращение примерно на 44%. Если размер почти не изменился, значит, большая часть дерева по-прежнему остается клиентской или основную массу занимают библиотеки, которые нельзя перенести на сервер.

Такие зависимости приходится:

  • загружать динамически,

  • оборачивать в клиентский компонент,

  • подключать только после загрузки страницы,

  • заменять SSR-совместимым аналогом.

После перехода на ISR может появится и новый класс ошибок: пользователь видит устаревшие данные. Для исправления команде надо перенастроить время жизни кэша или его сброс.

Поможем с оптимизацией вашего проекта, взяв на себя разработку и улучшение производительности фронтенда на React и Vue. 

Подробнее →

Будущее фронтенда

Несмотря на то, что React Server Components уже закрепились в продакшне через Next.js, огромное количество существующих корпоративных проектов все еще написаны как классические SPA — на базе Vite или старого Create React App. Переход на React Server Components требует полной перестройки архитектуры приложения, на что многие команды решаются постепенно.

Во Vue популярен полноценный серверный рендеринг через Nuxt. Но скоро нас ожидает версия 3.6 с «обещанным принцем» Vapor Mode, которой даст нам оптимизированный HTML без тяжелого виртуального DOM и приблизит производительность к ванильному JS. Массовый переход экосистемы на этот режим займет еще пару лет, пока ждем долгожданного релиза.

Для простых внутренних панелей администрирования или B2B-приложений, где SEO не важно, более выгодным решением остается рендеринг на клиенте. Нет смысла усложнять разработку, когда все работает как надо.

Похожие статьи

IconГотовы применить идеи из статьи?

Опишите вашу задачу — сделаем бесплатный экспресс-разбор и предложим 2–3 рабочих решения под ваш кейс.

MediaTen — цифровые решенияMediaTen — креативный подход