Реализация бесконечной прокрутки в Next.js с помощью действий сервера
Бесконечная прокрутка — это распространенная стратегия на платформах с большим количеством контента, которая отдает приоритет разбиению на страницы при разработке API. Эта стратегия загружает большие наборы данных постепенно небольшими управляемыми фрагментами, улучшая UX, особенно для медленных интернет-соединений.
Раньше для интеграции таких функций, как бесконечная прокрутка, в Next.js требовались внешние библиотеки, такие как SWR или Tanstack Query (ранее React Query).
Однако в более новых версиях Next.js, в частности Next.js 13 и более поздних версиях, действия сервера позволяют нам получать исходные данные непосредственно на сервере. Это повышает воспринимаемую производительность за счет немедленного рендеринга контента без внешних зависимостей.
В этом посте мы рассмотрим, как реализовать бесконечную прокрутку с помощью действий сервера Next.js, включая первоначальную выборку данных на стороне сервера и постраничный поиск данных на стороне клиента. В этой статье мы не будем углубляться в CSS, но отметим, что готовый проект использует Tailwind CSS.
Настройка приложения Next.js
Начнем с настройки нашего приложения Next.js. Я использую Create Next App с помощью pnpm, но вы можете выбрать другой метод и менеджер пакетов, если хотите:
pnpm create next-app
После запуска этой команды вы должны увидеть что-то вроде следующего:
Если вы используете ту же настройку приложения, что и моя, просто запустите pnpm dev
, чтобы запустить приложение в режиме разработки. Это создаст базовый стартовый пользовательский интерфейс Next.js.
Поскольку создание приложений Next.js с помощью TypeScript теперь является стандартным подходом, я решил использовать его с этим приложением. Я также сохраняю традиционный каталог src, хотя вам, возможно, не обязательно это делать. Ниже приведен обзор структуры проекта:
.
└── nextjs-infinite-scroll
├── node_modules
├── public
├── src
│ ├── actions
│ ├── app
│ ├── components
│ ├── config
│ ├── types
│ └── utils
├── package.json
├── tsconfig.json
└── ...
Я добавил несколько подкаталогов в каталог src
— например, config
, Types
и utils
— для эффективной организации различных типов данных. Мы углубимся в эту структуру позже в этой статье.
Объявление типов
Для реализации загрузки данных мы будем использовать фиктивный REST API под названием TypiCode, который предлагает различные типы фиктивных данных для целей разработки и тестирования. Используя этот сервис, мы получим несколько фиктивных сообщений в блоге. Структура URL-адресов, предоставляемая этим API, выглядит следующим образом:
http://jsonplaceholder.typicode.com/posts?_start=5&_limit=10
При запросе этого URL-адреса вы получите примерно следующий ответ:
[
{
"userId": 1,
"id": 1,
"title": "...",
"body": "..."
},
...
]
Каждый наш пост будет содержать четыре поля. Важно заранее настроить модель данных типа для наших данных публикации, чтобы мы могли легко использовать ее в остальной части приложения. Управление этими типами в отдельной папке типов — хороший способ систематизировать вещи:
// types/Post.ts
export interface Post {
postId: number;
id: number;
title: string;
body: string;
}
Разделительные константы
Основываясь на структуре URL-адреса нашего API, мы можем захотеть настроить его для повторного использования, чтобы мы могли легко использовать его при необходимости.
Чтобы обеспечить безопасность и организацию конкретных данных, таких как ключи API, URL-адреса, аргументы запроса и другая информация, связанная с API, крайне важно хранить их в переменных среды. Однако, поскольку в этом посте используется открытый API без конфиденциальных данных, мы будем управлять нашими постоянными значениями в отдельном файле TypeScript:
// config/constants.ts
export const API_URL = "https://jsonplaceholder.typicode.com/posts";
export const POSTS_PER_PAGE = 10;
В приведенном выше файле мы определили отдельные переменные для URL-адреса API и количества сообщений на странице, которые мы будем использовать неоднократно позже.
Настройка служебных функций
Теперь мы создадим папку utils
, чтобы определить служебную функцию для создания URL-адреса API с двумя параметрами запроса: ofset
и limit
:
// utils/getApirUrl.ts
import { API_URL } from "@/config/constants";
export const getApiUrl = (offset: number, limit: number): string => {
return `${API_URL}?_start=${offset}&_limit=${limit}`;
};
Хорошей идеей также было бы создание вспомогательной функции ошибок для чтения различных кодов ответов и вывода более качественных сообщений об ошибках в консоль. Мы будем использовать таблицу поиска и выводить разные сообщения для разных кодов ответа:
// utils/handleResponseError.ts
export async function handleError(response: Response): Promise<Error> {
const responseBody = await response.text();
const statusCode = response.status;
const errorMessages: { [key: number]: string } = {
400: `Bad request.`,
...,
...,
};
const errorMessage = ...;
console.error("Error fetching data:", errorMessage);
return new Error(errorMessage);
}
Настройка компонентов пользовательского интерфейса
Далее мы создадим и настроим компоненты пользовательского интерфейса для демонстрации полученных данных. Мы создадим два основных шаблона: PostCard
, который будет отвечать за отображение отдельных списков сообщений, и PostList
, содержащий несколько компонентов PostCard
.
Компонент «Почтовая открытка»
Компонент PostCard
прост и может использовать все четыре параметра, предлагаемые типом Post
. Я использую только заголовок и тело, чтобы упростить задачу. Мы не будем указывать его как компонент, специфичный для клиента, поскольку нам придется использовать его как на клиенте, так и на сервере:
// components/PostCard.tsx
import { Post } from "@/types/Post";
type PostProps = {
post: Post;
};
export default function PostCard({ post }: PostProps) {
return (
<div className="...">
<h2 className="...">
{post.title}
</h2>
<p className="...">{post.body}</p>
</div>
);
}
Ниже представлен краткий предварительный просмотр компонента PostCard
. Мы позаботились о его внешнем виде с помощью Tailwind CSS:
Компонент PostList
Компонент PostList
также не будет слишком сложным. Однако на данный момент это может не иметь особого смысла, поскольку нам нужно будет перебирать полученные данные и предоставлять PostCard
соответствующие данные для каждого индекса.
На данный момент давайте создадим его вот так, а оптимизируем позже:
// components/PostList.tsx
import { Post } from "@/types/Post";
type PostListProps = {
initialPosts: Post[];
};
export default function PostList({ initialPosts }: PostListProps) {
return (
<>
<div className="...">
...
</div>
</>
);
}
Вышеупомянутый компонент принимает в качестве реквизита массив сообщений, которые можно считать начальными сообщениями, полученными с сервера. Мы будем работать над дальнейшей загрузкой данных в следующих нескольких сегментах.
Компонент PostList
будет выглядеть примерно так, как показано ниже. Мы будем следовать одному и тому же шаблону во всех списках публикаций, которые будем реализовывать:
Настройка действия сервера
Серверное действие Next.js — это, по сути, специализированная функция, которая позволяет нам выполнять код на стороне сервера в ответ на взаимодействие пользователя на стороне клиента. Эта возможность упрощает такие задачи, как выборка данных, проверка ввода пользователя и другие операции на стороне сервера.
Давайте настроим действие сервера для загрузки наших сообщений на сервер. Мы будем использовать это действие непосредственно для загрузки исходных данных в PostList
, а затем делегируем постоянную ответственность за загрузку дополнительного контента на стороне клиента компоненту PostList
, инициируемому определенными событиями:
// actions/getPosts.ts
"use server";
import { getApiUrl } from "@/utils/getApiUrl";
import { handleError } from "@/utils/handleError";
export const getPosts = async (
offset: number,
limit: number
): Promise<Post[]> => {
const url = getApiUrl(offset, limit);
try {
const response = await fetch(url);
const data = (await response.json()) as Post[];
if (!response.ok) {
throw await handleError(response);
}
return data;
} catch (error: unknown) {
console.error(error);
throw new Error(`An error occurred: ${error}`);
}
};
В этом действии используются две служебные функции, которые мы определили ранее: getApirUrl
и handleError
. Также обратите внимание, что каждое действие сервера Next.js начинается с директивы «use server
».
На следующем этапе мы улучшим компонент PostList
, чтобы он мог загружать данные с помощью только что определенного действия сервера getPosts
.
Загрузка данных в компонент PostList
Теперь мы будем использовать хук useState для управления существующими сообщениями и номерами страниц, полученными с сервера. Используя свойства offset
и POST_PER_PAGE
, мы установим необходимую логику для загрузки следующего груза сообщений.
Обратите внимание, что нам нужно назначить этот компонент PostList как клиентский компонент для обновлений данных, управляемых пользователем, инициируемых определенными событиями, такими как прокрутка страницы вниз или нажатие кнопки:
// components/PostList.tsx
"use client";
import { useState } from 'react';
...
export default function PostList({ initialPosts }: PostListProps) {
const [offset, setOffset] = useState(POSTS_PER_PAGE);
const [posts, setPosts] = useState<Post[]>(initialPosts);
const [hasMoreData, setHasMoreData] = useState(true);
const loadMorePosts = async () => {
if (hasMoreData) {
const apiPosts = await getPosts(offset, POSTS_PER_PAGE);
if (apiPosts.length == 0) {
setHasMoreData(false);
}
setPosts((prevPosts) => [...prevPosts, ...apiPosts]);
setOffset((prevOffset) => prevOffset + POSTS_PER_PAGE);
}
};
return (
<>
<div className="...">
{posts?.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</>
);
}
В функции loadMorePosts
у нас есть три переменные состояния, которые вместе выполняют разные обязанности: смещение, сообщения и hasMorePosts
.
Переменная messages содержит начальные сообщения, которые мы ожидаем получить от сервера в компоненте PostList
при каждой загрузке страницы. Мы добавляем новые данные в этот массив на основе логического значения hasMoreData
, для которого по умолчанию установлено значение true
.
Допустим, мы используем действие getPosts
и получаем пустой ответ. В этом случае мы устанавливаем логическое значение hasMorePosts
в значение false
. Это прекратит запросы на загрузку дополнительного контента. В противном случае мы добавляем новые сообщения в переменную Posts
, а значение POST_PER_PAGE
увеличивает текущее значение смещения.
Чтобы функция loadMorePosts
работала должным образом, мы должны запускать ее с помощью такого события, как нажатие кнопки или прокрутка вниз. А пока давайте добавим кнопку в компонент PostList
, которую пользователь сможет нажать, чтобы загрузить больше сообщений. Со временем загрузка по клику будет заменена функцией бесконечной прокрутки.
Наконец, видимость этой кнопки-триггера контролируется логическим значением hasMoreData
. Вот полученный код:
// components/PostList.tsx
export default function PostList({ initialPosts }: PostListProps) {
const [offset, setOffset] = useState(POSTS_PER_PAGE);
const [posts, setPosts] = useState<Post[]>(initialPosts);
const [hasMoreData, setHasMoreData] = useState(true);
...
return (
<>
<div className="...">
{posts?.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
<div className="...">
{hasMoreData ? (
<button
className="..."
onClick={loadMorePosts}
>
Load More Posts
</button>
) : (
<p className="...">No more posts to load</p>
)}
</div>
</>
);
}
Последний шаг — интеграция компонента PostList
в файл page.tsx
в корне каталога приложения.
Как обсуждалось ранее, компоненту PostList
требуется аргумент с именем InitialPosts
для заполнения списка некоторыми исходными данными. Мы получаем эти данные с помощью действия сервера getPosts
и загружаем сообщения от 0
до значения, указанного нами для нашей константы POST_PER_PAGE
:
...
export default async function Home() {
const initialPosts = await getPosts(0, POSTS_PER_PAGE);
return (
<>
<div className="...">
<PostList initialPosts={initialPosts} />
</div>
</>
);
}
Вот и все! Теперь мы можем загрузить дополнительные сообщения, нажав кнопку Load More Posts
. Ниже показано, как реализация будет выглядеть в действии:
Код компонента вы можете найти здесь, а его применение — в корневом файле page.tsx. В следующем сегменте мы расширим нашу текущую реализацию, включив в нее бесконечную прокрутку для загрузки данных вместо кнопки «Загрузить еще».
Реализация бесконечной прокрутки
Основная идея реализации бесконечной прокрутки здесь заключается в замене кнопки, реализованной в компоненте PostList
, элементом триггера прокрутки, например счетчиком или текстом, указывающим загрузку.
Когда этот элемент появляется в окне просмотра, мы запускаем загрузку следующего пакета данных. По сути, именно так работает функция бесконечной прокрутки. Мы можем обнаружить пересечение элемента с помощью API JavaScript Intersection Observer.
Мы рассмотрим два метода реализации бесконечной прокрутки. Один из них предполагает использование небольшой зависимости, которая упрощает использование API Intersection Observer в React. Другой метод предполагает непосредственное использование API Intersection Observer, который немного сложнее реализовать в React.
Использование пакета React-Intersection-Observer
Добавьте пакет react-intersection-observer как обычную зависимость:
pnpm install react-intersection-observer
Давайте создадим копию компонента PostList
и назовем её PostListInfiniteRIO
или любое другое подходящее имя. Основная функция загрузки loadMorePosts
останется неизменной.
Мы будем использовать хук useInView
, предоставляемый библиотекой React-Intersection-Observer
, который деструктурирует возвращаемые значения в переменные, которые мы предоставили ниже:
const [scrollTrigger, isInView] = useInView();
Переменная ScrollTrigger
— это ссылочный объект, который мы прикрепим к элементу, который хотим наблюдать. Между тем, isInView
— это логическое значение, указывающее, виден ли элемент в данный момент в области просмотра.
В компоненте PostList
все происходило синхронно, поэтому нам не пришлось использовать хук useEffect
. Однако, поскольку мы хотим запускать loadMorePosts
при асинхронной прокрутке вниз к нашему элементу ScrollTrigger
, нам нужно использовать хук useEffect
для наблюдения за нашим элементом загрузки:
useEffect(() => {
if (isInView && hasMoreData) {
loadMorePosts();
}
}, [isInView, hasMoreData]);
Теперь, когда элемент загрузки пересекает область просмотра, loadMorePosts
будет запускаться с новыми значениями для логических значений hasMoreData
и isInView
.
Наконец, нам нужно реализовать наш элемент ScrollTrigger
следующим образом:
export default function PostListInfiniteRIO({ initialPosts }: PostListProps) {
...
return (
<>
<div className="...">
{posts?.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
<div className="...">
{(hasMoreData && <div ref={scrollTrigger}>Loading...</div>) || (
<p className="...">No more posts to load</p>
)}
</div>
</>
);
}
Теперь мы можем легко реализовать этот новый компонент в файле page.tsx
, заменив компонент PostList
. Альтернативно мы можем создать новый маршрут и вместо этого использовать этот компонент в его корневом файле page.tsx
.
Вот как это будет выглядеть в действии в окне браузера:
Найдите реализацию в компоненте PostListInfiniteRIO и каталоге Infinite-Scroll-rio в маршрутизаторе приложений.
Использование API JavaScript Intersection Observer напрямую
Вместо использования дополнительной библиотеки давайте воспользуемся API JavaScript Intersection Observer непосредственно в нашем компоненте.
Новый компонент, который мы собираемся создать, похож на PostListInfiniteRIO
, который мы настроили в предыдущем разделе. Отличается только часть useEffect
, поскольку именно здесь реализуется API Intersection Observer.
Как обсуждалось в последнем разделе, нам необходимо использовать хук useEffect
, поскольку для реализации функции бесконечной прокрутки будут задействованы некоторые асинхронные задачи.
Поскольку у нас нет встроенной логики ссылок, предлагаемой библиотекой React-Intersection-Observer в предыдущем компоненте, нам нужно установить ссылку для нашего элемента ScrollTrigger
, используя хук useRef
:
// components/PostListInfinite.tsx
export default function PostListInfinite({ initialPosts }: PostListProps) {
const [offset, setOffset] = useState(POSTS_PER_PAGE);
const [posts, setPosts] = useState<Post[]>(initialPosts);
const [hasMoreData, setHasMoreData] = useState(true);
const scrollTrigger = useRef(null);
// ...
return (
<>
<div className="...">
{posts?.map((post) => ())}
</div>
<div className="...">
{hasMoreData ? (
<div ref={scrollTrigger}>Loading...</div>
) : (
<p className="...">No more posts to load</p>
)}
</div>
</>
);
}
Далее в хуке useEffect
мы должны проверить, существует ли объект окна, а также доступен ли объект IntersectionObserver
:
useEffect(() => {
if (typeof window === "undefined" || !window.IntersectionObserver) {
return;
}
// ...
}, [hasMoreData]);
Хук useEffect
, который мы использовали здесь, опирается исключительно на состояние hasMoreData
. Это связано с тем, что у нас нет ничего подобного логическому значению isInView
, как в предыдущем компоненте.
Представленная выше проверка совместимости имеет решающее значение, поскольку оконные и веб-API обычно недоступны на стороне сервера во время первоначального рендеринга, что может привести к ошибкам. Если какой-либо из этих двух параметров окажется неподдерживаемым, хук useEffect
завершает работу раньше, чтобы предотвратить ненужные операции.
Если поддерживается, объект окна создает экземпляр IntersectionObserver для отслеживания видимости нашего элемента ScrollTrigger
. Функция loadMorePosts
срабатывает всякий раз, когда пороговое значение достигает 0,5
— другими словами, когда элемент ScrollTrigger
становится видимым как минимум на 50
процентов в области просмотра, что указывает на то, что пользователь прокручивает к нему:
useEffect(() => {
// ...
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMorePosts();
}
},
{ threshold: 0.5 }
);
if (scrollTrigger.current) {
observer.observe(scrollTrigger.current);
}
// Cleanup
return () => {
if (scrollTrigger.current) {
observer.unobserve(scrollTrigger.current);
}
};
}, [hasMoreData, offset]);
В конце мы представили функцию очистки, позволяющую прекратить наблюдение за элементом при размонтировании компонента или изменении зависимости. Это гарантирует, что наблюдатель не будет терять память и обновляет свое поведение в зависимости от текущих условий.
Наконец, либо замените компонент PostList
в файле page.tsx
в корне приложения, либо создайте отдельный маршрут и реализуйте его в собственном файле page.tsx
.
Как показано ниже, это будет выглядеть идентично предыдущей реализации, но без использования дополнительных зависимостей:
Вы можете найти весь связанный код в компоненте PostListInfinite и каталоге бесконечной прокрутки внутри App Router.
Заключение
Включение функции бесконечной прокрутки в насыщенный контентом проект Next.js — отличный способ улучшить UX за счет постепенной загрузки больших наборов данных, страница за страницей. В этом уроке мы рассмотрели, как реализовать бесконечную прокрутку с помощью действий сервера Next.js.
Сначала мы начали с загрузки данных по требованию, а затем рассмотрели два разных подхода к реализации бесконечной прокрутки в Next.js. Теперь у вас должно быть четкое представление о загрузке данных с разбивкой на страницы по требованию и настройке реализации бесконечной прокрутки в приложении с большим количеством контента.
Вы можете найти весь код, который мы обсуждали выше, в этом репозитории GitHub. Если у вас есть какие-либо вопросы, не стесняйтесь задавать их в разделе комментариев ниже.