Потоковая передача (Streaming)

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

Что такое потоковая передача?

Потоковая передача — это техника передачи данных, которая позволяет разбить маршрут на небольшие "чанки" и постепенно передавать их с сервера на клиент по мере готовности.

Диаграмма, показывающая время при последовательной и параллельной загрузке данных

Используя потоковую передачу, вы можете предотвратить блокировку всей страницы из-за медленных запросов данных. Это позволяет пользователю видеть и взаимодействовать с частями страницы, не дожидаясь загрузки всех данных перед отображением интерфейса.

Диаграмма, показывающая время при последовательной и параллельной загрузке данных

Потоковая передача хорошо сочетается с компонентной моделью React, так как каждый компонент можно рассматривать как чанк.

В Next.js есть два способа реализации потоковой передачи:

  1. На уровне страницы с помощью файла loading.tsx (который автоматически создает <Suspense>).
  2. На уровне компонента с помощью <Suspense> для более детального контроля.

Давайте посмотрим, как это работает.

Потоковая передача всей страницы с loading.tsx

В папке /app/dashboard создайте новый файл loading.tsx:

/app/dashboard/loading.tsx
export default function Loading() {
  return <div>Загрузка...</div>;
}

Обновите страницу http://localhost:3000/dashboard, и вы увидите:

Страница Dashboard с текстом 'Загрузка...'

Здесь происходит следующее:

  1. loading.tsx — это специальный файл Next.js, построенный на основе React Suspense. Он позволяет создать запасной интерфейс для отображения во время загрузки контента страницы.
  2. Поскольку <SideNav> статичен, он отображается сразу. Пользователь может взаимодействовать с <SideNav>, пока загружается динамический контент.
  3. Пользователю не нужно ждать завершения загрузки страницы перед переходом (это называется прерываемой навигацией).

Поздравляем! Вы только что реализовали потоковую передачу. Но мы можем сделать больше для улучшения пользовательского опыта. Давайте покажем скелетон загрузки вместо текста Загрузка….

Добавление скелетонов загрузки

Скелетон загрузки — это упрощенная версия интерфейса. Многие сайты используют их в качестве заполнителя (или запасного варианта), чтобы показать пользователям, что контент загружается. Любой интерфейс, добавленный в loading.tsx, будет встроен в статический файл и отправлен первым. Затем остальной динамический контент будет передаваться с сервера на клиент.

В файле loading.tsx импортируйте новый компонент <DashboardSkeleton>:

/app/dashboard/loading.tsx
import DashboardSkeleton from '@/app/ui/skeletons';
 
export default function Loading() {
  return <DashboardSkeleton />;
}

Затем обновите страницу http://localhost:3000/dashboard, и вы увидите:

Страница Dashboard со скелетонами загрузки

Исправление бага скелетона загрузки с помощью групп маршрутов

Сейчас ваш скелетон загрузки применяется и к страницам счетов.

Поскольку loading.tsx находится на уровень выше, чем /invoices/page.tsx и /customers/page.tsx в файловой системе, он применяется и к этим страницам.

Мы можем изменить это с помощью Групп маршрутов. Создайте новую папку /(overview) внутри папки dashboard. Затем переместите файлы loading.tsx и page.tsx внутрь этой папки:

Структура папок, показывающая создание группы маршрутов с использованием скобок

Теперь файл loading.tsx будет применяться только к странице обзора dashboard.

Группы маршрутов позволяют организовывать файлы в логические группы без влияния на структуру URL. Когда вы создаете новую папку с использованием скобок (), ее имя не будет включено в путь URL. Таким образом, /dashboard/(overview)/page.tsx становится /dashboard.

Здесь вы используете группу маршрутов, чтобы loading.tsx применялся только к странице обзора dashboard. Однако группы маршрутов также можно использовать для разделения приложения на секции (например, маршруты (marketing) и (shop)) или по командам для больших приложений.

Потоковая передача компонента

До сих пор вы передавали потоком всю страницу. Но вы также можете быть более детализированными и передавать конкретные компоненты с помощью React Suspense.

Suspense позволяет отложить рендеринг частей вашего приложения до выполнения определенного условия (например, загрузки данных). Вы можете обернуть динамические компоненты в Suspense. Затем передать ему запасной компонент для отображения во время загрузки динамического компонента.

Если вы помните медленный запрос данных fetchRevenue(), именно он замедляет всю страницу. Вместо блокировки всей страницы вы можете использовать Suspense для потоковой передачи только этого компонента и немедленного отображения остального интерфейса страницы.

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

Удалите все экземпляры fetchRevenue() и его данные из /dashboard/(overview)/page.tsx:

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // удаляем fetchRevenue
 
export default async function Page() {
  const revenue = await fetchRevenue() // удаляем эту строку
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    // ...
  );
}

Затем импортируйте <Suspense> из React и оберните им <RevenueChart />. Вы можете передать ему запасной компонент <RevenueChartSkeleton>.

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data';
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
 
export default async function Page() {
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="Collected" value={totalPaidInvoices} type="collected" />
        <Card title="Pending" value={totalPendingInvoices} type="pending" />
        <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
        <Card
          title="Total Customers"
          value={numberOfCustomers}
          type="customers"
        />
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

Наконец, обновите компонент <RevenueChart>, чтобы он сам запрашивал свои данные, и удалите передаваемый ему пропс:

/app/ui/dashboard/revenue-chart.tsx
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchRevenue } from '@/app/lib/data';
 
// ...
 
export default async function RevenueChart() { // Делаем компонент асинхронным, удаляем пропсы
  const revenue = await fetchRevenue(); // Запрашиваем данные внутри компонента
 
  const chartHeight = 350;
  const { yAxisLabels, topLabel } = generateYAxis(revenue);
 
  if (!revenue || revenue.length === 0) {
    return <p className="mt-4 text-gray-400">Нет доступных данных.</p>;
  }
 
  return (
    // ...
  );
}
 

Теперь обновите страницу, и вы должны увидеть информацию dashboard почти сразу, в то время как для <RevenueChart> отображается запасной скелетон:

Страница Dashboard со скелетоном графика доходов и загруженными компонентами Card и Latest Invoices

Практика: Потоковая передача <LatestInvoices>

Теперь ваша очередь! Практикуйте то, что вы только что узнали, передавая потоком компонент <LatestInvoices>.

Перенесите fetchLatestInvoices() со страницы в компонент <LatestInvoices>. Оберните компонент в границу <Suspense> с запасным компонентом <LatestInvoicesSkeleton>.

Когда будете готовы, раскройте блок, чтобы увидеть решение:

Группировка компонентов

Отлично! Вы почти у цели, теперь вам нужно обернуть компоненты <Card> в Suspense. Вы можете запрашивать данные для каждой отдельной карточки, но это может привести к эффекту появления по мере загрузки карточек, что может быть визуально раздражающим для пользователя.

Итак, как решить эту проблему?

Чтобы создать более постепенный эффект, вы можете сгруппировать карточки с помощью компонента-обертки. Это означает, что сначала будет показан статический <SideNav/>, затем карточки и т.д.

В вашем файле page.tsx:

  1. Удалите ваши компоненты <Card>.
  2. Удалите функцию fetchCardData().
  3. Импортируйте новый компонент-обертку <CardWrapper />.
  4. Импортируйте новый скелетон <CardsSkeleton />.
  5. Оберните <CardWrapper /> в Suspense.
/app/dashboard/(overview)/page.tsx
import CardWrapper from '@/app/ui/dashboard/cards';
// ...
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
  CardsSkeleton,
} from '@/app/ui/skeletons';
 
export default async function Page() {
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<CardsSkeleton />}>
          <CardWrapper />
        </Suspense>
      </div>
      // ...
    </main>
  );
}

Затем перейдите в файл /app/ui/dashboard/cards.tsx, импортируйте функцию fetchCardData() и вызовите ее внутри компонента <CardWrapper/>. Убедитесь, что раскомментировали необходимый код в этом компоненте.

/app/ui/dashboard/cards.tsx
// ...
import { fetchCardData } from '@/app/lib/data';
 
// ...
 
export default async function CardWrapper() {
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <>
      <Card title="Collected" value={totalPaidInvoices} type="collected" />
      <Card title="Pending" value={totalPendingInvoices} type="pending" />
      <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
      <Card
        title="Total Customers"
        value={numberOfCustomers}
        type="customers"
      />
    </>
  );
}

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

Определение места для границ Suspense

Место размещения ваших границ Suspense будет зависеть от нескольких факторов:

  1. Как вы хотите, чтобы пользователь воспринимал страницу во время потоковой передачи.
  2. Какой контент вы хотите приоритезировать.
  3. Зависят ли компоненты от запросов данных.

Взгляните на вашу страницу dashboard, сделали бы вы что-то иначе?

Не волнуйтесь. Нет правильного ответа.

  • Вы могли бы передавать потоком всю страницу, как мы сделали с loading.tsx... но это может увеличить время загрузки, если один из компонентов имеет медленный запрос данных.
  • Вы могли бы передавать каждый компонент по отдельности... но это может привести к появлению интерфейса на экране по мере готовности.
  • Вы также можете создать постепенный эффект, передавая секции страницы. Но вам нужно будет создать компоненты-обертки.

Место размещения границ Suspense будет варьироваться в зависимости от вашего приложения. В целом, хорошей практикой является перенос запросов данных в компоненты, которые в них нуждаются, а затем оборачивание этих компонентов в Suspense. Но нет ничего плохого в потоковой передаче секций или всей страницы, если это нужно вашему приложению.

Не бойтесь экспериментировать с Suspense и находить оптимальное решение — это мощный API, который может помочь создать более приятный пользовательский опыт.

Что дальше?

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

В следующей главе вы узнаете о Частичном Предварительном Рендеринге (Partial Prerendering), новой модели рендеринга Next.js, разработанной с учетом потоковой передачи.