Просматривая процесс сборки своего текущего проекта, я обратил внимание на то, что при генерировании страниц с использованием getStaticProps
выдается много запросов к одной конечной точке. Больших бед мне это не сулит, так как наш бэкенд довольно быстрый. Но, обратившись к поисковику с этой проблемой, я обнаружил, что у некоторых возникают такие же трудности. Например, проблема становится серьезной в случае медленной базы данных или когда бэкенд не справляется с сотней запросов в секунду. В общем я попробовал решить эту проблему и уменьшить время сборки проекта.
Почему так много запросов?
Прежде чем подробно разбирать процесс сборки Next.js, рассмотрим, как обстоят дела с этим у еще одного генератора статических сайтов — Gatsby. У Gatsby совсем другой механизм, ведь он осуществляет генерацию статических сайтов, тогда как Next.js изначально — фреймворк, использующий принцип отрисовки на стороне сервера.
В Gatsby процесс сборки проходит в несколько этапов. Среди них выделяются два основных: извлечение данных и заполнение ими компонентов, а затем отрисовка. Плагины Gatsby обычно используют слой данных GraphQL для хранения данных из источников данных (задействуют, конечно же, и неструктурированные данные, обходясь без слоя GraphQL). Gatsby запускает плагины данных, после чего они извлекают данные и добавляют их в слой данных. Затем он получает от страниц запросы GraphQL и подготавливает данные для шаблонов. А дальше заполняет компоненты. Этот способ создания страниц кажется предельно простым. Но Next.js работает по-другому.
Изначально это был фреймворк исключительно с отрисовкой на стороне сервера. Но позже в него была добавлена функция генератора статических сайтов. У Next.js есть API, где данные получаются и передаются на каждую страницу, но этот метод мешает оптимизации генератора статических сайтов. Здесь имеется в виду getInitialData
, когда он используется внутри _app.js
.
Получение страниц с применением генератора статических страниц происходит только с помощью getStaticProps
, или страницы просто создаются без извлечения данных. Когда Next.js создает страницы, он запускает функцию извлечения данных, если она находится внутри файла страницы, а затем отображает страницу с этими данными. И этот процесс повторяется страница за страницей. Поэтому общие данные будут запрашиваться каждый раз для каждой страницы. С увеличением количества страниц в приложении растет и число запросов.
Моему приложению на каждой странице требуется отображать список городов, и эти города должны быть определены на сервере. Я знал, как работает Gatsby, и хотел добавить подобный механизм, но более простой и без GraphQL.
Мой первый подход
Первая идея была очень проста. Например, когда первая страница вызывает fetchCities
, я сохраняю возвращаемый из fetch
промис в переменной с областью видимости, ограниченной модулем. При вызове fetchCities
следующей страницей я проверяю, указывает ли эта переменная на промис. Когда указывает, возвращаю его:
const { PUBLIC_NEXT_API_URL } = process.env;
let citiesRequest;
export function fetchCities() {
if (!citiesRequest) {
citiesRequest = fetch(`${PUBLIC_NEXT_API_URL}/cities`);
}
return citiesRequest;
}
Пример показывает, как сохранить запрос в области видимости модуля.
Здесь всё просто: сохраняем промис запроса в области видимости модуля, а затем возвращаем его всем следующим вызовам. Проблема в том, что этот подход не работает. Почему? Из-за воркеров. Next.js запускает сборку каждой страницы в собственном воркере. Это означает, что у меня нет возможности обмениваться данными со страницами и нет доступа к такому месту, где без огромных усилий возможен обмен данными с воркерами. Кроме того, я проверяю, работает ли запрос в непрерывной интеграции, потому что fetchCities
вызывается и при повторной проверке. И в этом случае мне каждый раз нужно получать свежие данные.
Возможно, поможет поисковик?
У меня было еще несколько идей, но я решил заглянуть в поисковик. Может, не у меня одного возникает такая проблема? Так и есть: нашлось несколько человек, задававшихся тем же вопросом, но по разным причинам. Кто-то только начал использовать Next.js, и его бэкенд не справился с большим количеством запросов. А кто-то пробовал перейти от Gatsby к Next и понял, что его бэкенд не так быстр для 300 запросов в секунду.
Я знал, как работает Gatsby, потому что имел с ним дело на предыдущей работе. У Gatsby к этому другой подход. Прежде всего, он подготавливает сборку, собирая данные в локальный кеш. В этом случае все ресурсы извлекаются только один раз, так что каждая страница получает данные из этого кеша. Мой подход был аналогичен, только добавлять кеш в Next.js у меня не было необходимости.
Данные кеша в файлах
Один из возможных способов решения этой проблемы — кеширование в файлах. Для этого добавим в процесс сборки еще один этап и напишем что-то вроде этого:
const path = require("path");
const crypto = require("crypto");
const fs = require("fs/promises");
const fetch = require("isomorphic-unfetch");
const { API_URL, CACHE_DIR } = process.env;
const RESOUCES = ["/posts", "/albums"];
function getFilename(str) {
const hash = crypto.createHash("sha1").update(str).digest("hex");
return `${hash}.json`;
}
function writeResponseToFile(filepath, data) {
return fs.writeFile(filepath, data, "utf-8");
}
function cacheRequests([endpoint, ...r]) {
return new Promise((resolve, reject) => {
if (!endpoint) {
console.log("\nResources have been succesfully cached 📦");
return resolve();
}
const url = `${API_URL}${endpoint}`;
const filename = getFilename(endpoint);
const filepath = path.resolve(__dirname, CACHE_DIR, filename);
fetch(url)
.then((r) => r.text())
.then((d) => writeResponseToFile(filepath, d))
.then(() => {
console.log(`${endpoint} -> ${filepath} 💾`);
cacheRequests(r);
})
.catch((e) => reject(e));
});
}
cacheRequests(RESOUCES);
Мы запускаем скрипт, который извлекает данные и сохраняет их в файлы. Недостаток этого метода в том, что для извлечения данных нужно собрать в скрипте все конечные точки.
Собрав данные, запускаем сервер, который будет работать с кешированными данными для создания приложения:
const http = require("http");
const path = require("path");
const crypto = require("crypto");
const fs = require("fs/promises");
const { HOST, PORT, CACHE_DIR } = process.env;
function getFilename(str) {
const hash = crypto.createHash("sha1").update(str).digest("hex");
return `${hash}.json`;
}
async function handler(req, res) {
const filename = getFilename(req.url);
const filepath = path.resolve(__dirname, CACHE_DIR, filename);
const data = await fs.readFile(filepath);
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.write(data);
res.end();
console.log(`📤 ${req.method} ${req.url}`);
}
http.createServer(handler).listen(PORT, HOST, () => {
console.log(`Server is running on 🌎 http://${HOST}:${PORT}`);
});
Эти варианты хороши, но не лишены недостатков. Например, для кеширования нужно собирать все конечные точки и обновлять их. Или же нужно ждать, пока все ресурсы не будут кешированы. С другой стороны, все ресурсы кешируются на диске и не остаются в памяти. Хотелось поделиться этим фрагментом кода, чтобы показать вам все варианты.
Кешируем прямо в памяти при сборке
Это самый простой и быстрый способ кеширования данных, и ничего ждать не нужно. Просто передаем другой API_PATH
на этапе сборки и запускаем сервер кеша перед запуском next build
(следующей сборки).
Код сервера будет выглядеть так:
const http = require("http");
const fetch = require("isomorphic-unfetch");
const { API_URL, PORT, HOST } = process.env;
const map = new Map();
async function handler(req, res) {
if (map.has(req.url)) {
const data = await map.get(req.url);
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.write(data);
res.end();
console.log(`📦 Cached request to: ${req.url}`);
return;
}
const request = fetch(`${API_URL}${req.url}`).then((res) => res.text());
map.set(req.url, request);
const data = await request;
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.write(data);
res.end();
console.log(`📥 GET ${req.url}`);
}
http.createServer(handler).listen(PORT, HOST, () => {
console.log(`Server is running on 🌎 http://${HOST}:${PORT}`);
});
Этот подход аналогичен моему первому подходу, только данные сохраняем на другом уровне. Все запросы будут вызывать прокси-сервер кеширования. При получении запроса он сохраняется в map
, и все последующие запросы к одному и тому же ресурсу будут получать один и тот же ответ. Это очень быстрый подход, но при запрашивании большого объема данных нужно быть осторожным с памятью. Иначе получится гибрид этих двух подходов: кеширование данных по необходимости и сохранение их на диске, а не в памяти.
Подведем итоги
На самом деле команда Next.js планировала добавить возможность использования getStaticProps
в _app.js
. В случае реализации этой возможности обозначенная проблема будет решена без лишних усилий. Я слежу за этой дискуссией, но она еще не завершена.
Возможно, у вас аналогичные трудности с источником данных. Надеюсь, что материал, изложенный в этой статье, поможет вам их преодолеть. Но ее цель не только в том, чтобы решить проблему, но и попробовать применить различные подходы. Описанные в статье подходы пригодятся и для решения других задач, связанных с кешированием данных. Используйте их в соответствии с вашими потребностями.
Примеры в полном объеме хранятся на GitHub.
Читайте также:
- Маршрутизация и получение данных в Next.js
- Создаем собственный блог с помощью Next.js и Strapi
- 10 видов шаблонного кода на NextJS
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Pavel Mineev: Fetch Shared Data in Next.js With Single Request