В 13 версии команда next.js представила новый подход к проектированию приложения - так называемый App Router. В 14 версии его сделали стабильным и основным для новых приложений.

App Router значительно расширяет функционал next.js - частичный пререндеринг, шаблоны, параллельные и перехватываемые роуты, серверные компоненты и многое другое. Однако, даже несмотря на все эти улучшения - далеко не все решили перейти на App Router. И на это есть свои причины.

Кратко о преимуществах и проблемах нового роутера я рассказал в статье “Next.js App Router. Опыт использования. Путь в будущее или поворот не туда”. Дальше же речь пойдёт не о новых абстракциях или их особенностях. На самом деле ключевым и самым спорным изменением является именно кеширование. В этой статье будет рассказано что, зачем и как кеширует самый популярный фронтенд фреймворк - Next.js.

Что кеширует next.js?

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

Любой запрос в next.js вызванный через fetch будет мемоизирован и закеширован. Тоже произойдёт и со страницами, и с функцией cache. О том как это работает под капотом будет рассказано в следующих разделах. Общий процесс сборки страницы работает по следующей схеме:

Процесс кеширования в next.js. Источник: документация next.js
Процесс кеширования в next.js. Источник: документация next.js

То есть: пользователь заходит на страницу, посылается запрос за роутом на сервер, сервер начинает рендерить роут, посылая при этом нужные запросы. Затем всё это выполняется и кешируется.

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

Мемоизация в next.js. Источник: документация next.js
Мемоизация в next.js. Источник: документация next.js

Кеширование на сервере происходит с помощью так называемого Data Cache. Удалить данные из него можно вызвав функции revalidatePath и revalidateTag. Первая обновит кеш для страницы, вторая для тага, указываемого при запросах.

Ревалидация кеша в next.js. Источник: документация next.js
Ревалидация кеша в next.js. Источник: документация next.js

Также данные кешируется и на клиентской стороне - внутри клиентского роутера.

Клиентское кеширование в next.js. Источник: документация next.js
Клиентское кеширование в next.js. Источник: документация next.js

Из неописанного в статье - next.js также кеширует реврайты и редиректы. То есть если пользователь однажды на сервере был перенаправлен со страницы / на /login - теперь он продолжит перенаправляться туда же. Это будет закешировано в клиентском роутере до тех пор, пока не будет очищен клиентский кеш.

Удалить кеш на клиенте можно с помощью router.refresh или вызвав revalidatePath и revalidateTag в серверных действиях.

'use server'

import { revalidateTag } from 'next/cache'

export default async function submit() {
  await addPost()
  revalidateTag('posts')
}

Зачем кеширование в next.js?

Fetch от next.js представляет из себя обёртку над нативным node.js fetch. Внутри обёртки настроена связка с так называемым Data Cache. Это сделано для того, чтобы каждый запрос мог обрабатываться как описано на схемах выше. За эту подмену нативного API команду next.js чаще всего критикует сообщество.

Позднее в next.js добавили возможность отключать кеширование запроса с помощью опции cache: "no-store". Но даже с этой опцией он продолжит мемоизироваться. Как итог - одно из ключевых API для разработки перестало быть контроллируемым разработчиком.

Тем не менее, и на этот шаг были причины. И маловероятно изначально этими причинами была оптимизация. Для оптимизации было бы достаточно создать новую функцию для запросов - отдельное API, коих в next.js сотни.

Схожий путь пришёл я при разработке пакета next-translation (о чём писал в предыдущей статье). Тогда появилась интересная проблема - на сервер уходило слишком много запросов (вызванных не через fetch). Разбираясь в причинах и читая исходники next.js стало понятно, что приложение теперь собирается в несколько самостоятельных потоков. Странно, почему об этом не говорили в последних релизах. Каждый поток живёт как самостоятельный процесс, как следствие сделать нормальное кеширование на всё приложение внутри пакета не удавалось.

Та же проблема встала и перед командой next.js - каждая интеграция, каждый пакет, каждый пользователь теперь начинал отправлять в несколько раз больше запросов, а настроенные ранее системы кеширования переставали корректно работать. И как способ решения - переделка fetch и скрытие этой особенности под капотом.

Как работает Data Cache?

Сохранение загруженных или сгенерированных данных происходит в так называемом cacheHandler-е. Из коробки у next.js 2 варианта cacheHandler-ов - FileSystem и Fetch. Этот cacheHandler будет использовать как для кеширования запросов, так и для кеширования страниц.

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

Особенно это проблема чувствуется при работе приложения в режиме ISR. Нужно попасть в каждую реплики и ревалидировать кеш в каждой из них. При этом проверим, что они загрузят одни и теже данные. Также если 2 реплики работают с одной папкой - могут возникнуть конфликты в файловой системе во время записи.

Вероятно именно поэтому в коде фреймворка можно найти Fetch. Он сохраняет кеш на удалённый сервер. Однако используется этот cacheHandler только при публикации приложения в Vercel, так как сохраняет данные на серверах Vercel.

Как итог решение из коробки закрывает далеко не все потребности - FileSystem не подходит если есть несколько реплик, а Fetch если приложение деплоится не в Vercel. Важной особенностью является то, что next.js позволяет написать свой cacheHandler. Для этого нужно передать в конфигурацию приложения путь до файла с классом (CacheHandler), в котором будут описаны методы get, set и revalidateTag:

// cache-handler.js
module.exports = class CacheHandler {
  constructor(options) {
    this.options = options
  }

  async get(key) {
    // ...
  }

  async set(key, data, ctx) {
    // ...
  }

  async revalidateTag(tag) {
    // ...
  }
}

И подключить его в конфигурацию приложения:

module.exports = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0, // disable default in-memory caching
}

Одним из таких cache-handler-ов является cache-handler-redis, на который ссылалась команда next.js в последнем релизе.

Ключевые моменты

Next.js кеширует большую часть процессов.

Кеширование происходит в несколько этапов - кеширование переходов и страниц в клиентском роутере, мемоизация запроса, кеширование на сервере запросов и страниц.

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

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

За кеширование отвечает cacheHandler. Next.js имеет два варианта из коробки - работающий с файловой системой и работающий с удалённым сервером, но последний доступен только внутри Vercel.

Можно написать свой cacheHandler.

Доработка кеширования

Вернёмся к пакету next-translation. Чтобы решить проблему лишних запросов я пришёл к интересному выходу - поднимать дополнительный сервер и обрабатывать запросы проходя через него - как итог все запросы уходят из одного места, а значит в нём можно настроить общее кеширование. Это принцип похожий на FetchCacheHandler и подход в Vercel в целом - когда во время сборки данные кешируются на сервер vercel, а так как сервер лежит рядом работает это быстро.

Однако, кеширование - слишком большая ответственность для библиотеки переводов. Следующей задачей стала переделка логики кеширования, чтобы объединить API next.js, библиотеки и решить общие проблемы. В результате же была создана ещё одна библиотека - next-impl-cache-adapter.

Управление кешированием

Как уже говорилось, для общего кеша между инстансами (репликами, копиями) - кеш должен лежать отдельно от каждого инстанса приложения. next-impl-cache-adapter решает это с помощью создания отдельного сервиса.

Этот сервис представляет из себя сервер в котором работает нужный cacheHandler. Каждый инстанс приложения будет обрабатывать запросы через этот сервер. При этом сервер не нужно перезапускать при каждой сборке. Устаревшие данные будут удаляться автоматически во время запуска новой версии приложения.

Код сервера:

// @ts-check
const createServer = require('next-impl-cache-adapter/src/create-server');
const CacheHandler = require('next-impl-cache-in-memory');

const server = createServer(new CacheHandler({}));
server.listen('4000', () => {
  console.log('Server is running at <http://localhost:4000>');
});

В данном примере в сервер передаётся next-impl-cache-in-memory - это базовый cacheHandler, который сохраняет данные in-memory.

В самом приложении настраивается специальный адаптер для работы с кешем:

// cache-handler.js
// @ts-check
const AppAdapter = require('next-impl-cache-adapter');
const CacheHandler = require('next-impl-cache-in-memory');

class CustomCacheHandler extends AppAdapter {
  /** @param {any} options */
  constructor(options) {
    super({
      CacheHandler,
      buildId: process.env.BUILD_ID || 'base_id',
      cacheUrl: 'http://localhost:4000',
      cacheMode: 'remote',
      options,
    })
  }
}

module.exports = CustomCacheHandler;

Созданный адаптер подключается в конфигурации next.js:

// next.config.js

module.exports = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0, // disable default in-memory caching
}

Пакет поддерживает три варианта кеширования: localremote и isomorphic.

local

Стандартное решение. Кеш обрабатывается рядом с приложением. Удобно использовать в режиме разработки и на стейджах на которых приложение запущено в одном экземпляре.

remote

Весь кеш будет записываться и читаться на созданный удалённый сервер. Удобно использовать для запущенных в нескольких репликах приложений.

isomorphic

Кеш работает рядом с приложением, но дополнительно сохраняет данные на удалённом сервере. Удобно использовать во время сборки, подготаливая кеш к моменту запуска инстансов приложения, но не тратя ресурсы на загрузку кеша из удалённого сервера.

В качестве cacheHandler может быть любой cacheHandler, поддерживаемый next.js. И наоборот cacheHandler-ы из пакета можно подключать напрямую в next.js.

Выводы

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

Сама команда next.js не спешит разрабатывать решения под конкретные задачи. Во многом поэтому, с момента релиза стабильного App Router, я продолжаю работу над имплементацией пакетов решающих проблемы next.js. Попутно рассказывая о них в статьях.

Давайте сделаем веб не только быстрее, но и понятнее.

Ссылки

next-impl-cache — решения для настройки кеширования в next.js.

next-impl-getters — реализация серверных геттеров и контекстов в React Server Components без переключения на SSR.

next-impl-config — добавление поддержки конфигурации для каждой возможной среды next.js (build, server, client и edge).

next-classnames-minifier — сжатие классов до символов (.a, .b, …, .a1).

next-translation — i18n библиотека, разработанная с учетом серверных компонентов и максимальной оптимизации.

Комментарии (0)