Cache Components и директива 'use cache'

кеш и прод≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Кеш в Next.js больше не свойство страницы. Директива "use cache" ставится внутрь функции — и кешируется ровно её результат: строка из базы, кусок разметки или целый маршрут. Ключом становится всё, что в функцию вошло, а cacheLife тремя числами решает, когда значение обновится.

Разделы темы:·

В прошлой статье — «Статика, динамика и всё между ними» — маршрут выбирал из двух состояний: собраться целиком на этапе сборки или пересобираться на каждый запрос. Одно обращение к данным запроса переводило в динамику всю страницу — вместе с шапкой, футером и каталогом, которые у всех читателей одинаковые.

Cache Components меняют единицу измерения. Включает их флаг в настройках проекта — без него нет ни директивы, ни cacheLife, ни cacheTag.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Дальше правило простое: то, что ждёт данных и не помечено, считается на каждый запрос; помеченное — кешируется. Разметка, которой ждать нечего, как была статикой, так и осталась. Директива ставится в трёх местах: первой строкой файла (тогда кешируются все его экспортируемые функции), первой строкой компонента или первой строкой обычной функции. Во всех трёх случаях функция обязана быть асинхронной.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разница между двумя функциями выше — в том, что складывается в кеш. У getPosts это массив объектов, у PostList — уже отрисованный кусок дерева, готовый к отправке в браузер. Директива одна и та же.

Что значит «единица — функция», видно на модели. Страница собирается из трёх частей: шапка одинакова для всех, каталог общий, корзина у каждого своя. Помечены первые две. Прежде чем запускать, посчитайте: сколько раз выполнится каждая часть за два запроса от разных читателей?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Шапка и каталог посчитались по одному разу на двоих, корзина — дважды. Страница при этом не «статическая» и не «динамическая»: она смешанная, и таких страниц в приложении большинство.

Страница перестала быть единицей кеша. Единица — функция; страница просто собрана из функций с разными сроками жизни.

Запись в кеше ищется по ключу, и Next собирает его из четырёх слагаемых: идентификатор сборки (после выката все записи считаются заново), идентификатор функции — хеш её места и сигнатуры в коде, сериализованные аргументы и, только в режиме разработки, хеш горячей перезагрузки.

«Сериализованные» — это не «те же самые объекты». Разница видна на модели: два вызова с разными объектами одинакового содержания. Сколько раз мы сходим в базу?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Два раза: { category: "tea" } и второй такой же объект дали один ключ. Это ровно то поведение, которого ждёшь от кеша, — и ровно то, которого не будет, если ключом сделать сам объект-фильтр.

Тот же код, но без сериализации: объект-фильтр кладётся в Map как есть.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три вызова — три обращения в базу: каждый литерал создаёт новый объект, и попадания не будет никогда. Сериализация ключа — не мелочь реализации, а то, ради чего кеш вообще работает.

Второе слагаемое неочевиднее: переменные, захваченные из внешней области. Функция помнит окружение, в котором родилась (это замыкания из части I), и Next такие переменные тоже связывает как аргументы — они попадают в ключ. Зачем, видно на кеше, который так не делает. Что напечатает вторая строка?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отчёт первого пользователя достался второму: в ключе оказался только период, а userId остался в замыкании. У Next такого не выйдет — захваченная переменная входит в ключ, и у второго пользователя будет своя запись. Отсюда практическое следствие: чем крупнее вход, тем больше разных записей, поэтому в кешированную функцию передают идентификатор, а не весь объект пользователя.

ЗначениеАргументомВ возврате
Примитивы: строки, числа, null, undefinedдада
Обычные объекты и массивыдада
Date, Map, Set, типизированные массивыдада
Разметка JSX и серверные действиятолько насквозь, не читаяда
Экземпляры классов, URL, символы, функциинетнет

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

Строка «только насквозь» — это приём, ради которого стоит запомнить таблицу: кешированная оболочка может обнимать динамическое содержимое, если она в него не заглядывает.

Компонент получает children и ничего с ними не делает — только вставляет в разметку. Такие аргументы не сериализуются, а прокидываются ссылкой.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Снаружи в CachedShell можно передать компонент, который читает данные запроса: оболочка приедет из кеша, а начинка досчитается на месте.

Директива без cacheLife тоже работает — просто берётся профиль default, и срок жизни перестаёт быть виден в месте вызова. Поэтому в документации рекомендуют писать cacheLife в каждой кешируемой функции, даже когда числа вас устраивают.

Профиль — это три числа, и каждое отвечает за свой слой.

ЧислоГде действуетЧто означает
staleбраузерсколько роутер отдаёт значение из памяти вкладки, не спрашивая сервер; меньше 30 секунд роутер поднимает до тридцати — иначе префетч протухал бы до клика
revalidateсерверпосле какого возраста записи следующий запрос запускает обновление в фоне
expireсерверпосле какого возраста запись больше не годится: запрос ждёт свежий расчёт

Встроенные профили названы по порядку величины. Числа ниже — из Next 16; в своём проекте их видно во всплывающей подсказке над cacheLife.

ПрофильДля чегоstalerevalidateexpire
defaultкогда не указан явно5 минут15 минутне истекает
secondsкурс, счётчик, табло30 секунд1 секунда1 минута
minutesлента, новости5 минут1 минута1 час
hoursостатки на складе, погода5 минут1 час1 сутки
daysстатьи, страницы блога5 минут1 сутки1 неделя
weeksвыпуски, рассылки5 минут1 неделя30 суток
maxоферта, архив5 минут30 суток1 год

Два серверных числа проще один раз увидеть, чем прочитать. В модели ниже время двигается руками, revalidate — минута, expire — пять. Предскажите третью строку вывода: запрос пришёл на девяностой секунде, запись уже пора обновлять — что получит этот запрос?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Запрос, который запустил обновление, сам получил старое значение — и не ждал ни секунды. Ждать пришлось только последнему: к пятисотой секунде запись просрочена, отдавать нечего. Так и работает stale-while-revalidate: медленный расчёт достаётся фону, а читателю — то, что уже посчитано.

В модели нет stale: это число живёт в браузере читателя, а не в серверном кеше.

Кроме имён профиль можно задать объектом — для разового случая, — а свои имена объявляются в настройках рядом со встроенными.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Кешированные функции вкладываются друг в друга, и тут важно, объявлен ли cacheLife у внешней. Если объявлен — её срок и действует: при попадании она отдаёт весь свой результат целиком, вместе с тем, что насчитали вложенные.

Если не объявлен, внешняя работает по default, и вложенный кеш с более коротким сроком укорачивает её (более длинный — не удлиняет). Худший случай Next не пропускает: вложенный seconds внутри функции без явного cacheLife — ошибка на предрендере, потому что иначе вся внешняя запись молча стала бы секундной.

Кешированная функция выполняется в изоляции: cookies(), headers() и searchParams ей недоступны. Запрет идёт по стеку вызовов — вспомогательная функция, которую она позвала, упадёт с той же ошибкой next-request-in-use-cache. Причина простая: запись кеша одна на всех, а данные запроса у каждого свои; мы только что видели, чем это кончается.

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

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

По частям здесь происходит вот что. Заголовок и запасная разметка Suspense уходят в предрендеренную оболочку — они не зависят ни от кого. Когда приходит запрос, читаются куки, и на место запасной разметки приезжает Offers — из кеша, если кто-то из этого города заходил недавно, иначе с расчётом. Для города, а не для человека: людей много, городов десяток.

Отдельная забота — значения, которые обязаны быть разными на каждом запросе: Math.random(), Date.now(), crypto.randomUUID(). Cache Components не дают им втихую застыть в кеше: перед такими вызовами объявляют, что это дело запроса, вызвав connection(), и, как всё запросное, заворачивают в Suspense.

Есть и вариант директивы для личного: "use cache: private" кеширует на одного читателя и умеет читать данные запроса. Он нужен редко — обычно хватает приёма выше, когда из запроса достают одно значение и делают его аргументом общей записи.

Неприятность в том, что запрет проверяется на выполнении. Если маршрут и так динамический, next build его не тронет и промолчит, а упадёт всё под next start — то есть в проде.

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

На практике выбор сводится к двум вопросам: одинаков ли ответ для всех читателей и как быстро он устаревает. Первый вопрос решает, будет ли директива вообще, второй — какой профиль.

Что за данныеЧем помечатьПрофиль
Каталог, статья, тарифы — одно на всехдиректива на функции или компонентеhours, days
Курс, счётчик мест, таблодиректива с коротким срокомseconds — в предрендер такая запись не попадёт
Корзина, профиль, всё из cookiesничего, но под Suspense—
Личное и дорогое в расчёте"use cache: private"по месту

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Метка сама ничего не сбрасывает — она даёт имя, по которому запись можно объявить устаревшей после записи в базу. Кто и когда это делает, разбирает статья «Что происходит после мутации: revalidatePath и revalidateTag»: сброс по метке живёт рядом с серверным действием, а не рядом с кешем.

И последнее, что стоит держать в голове: по умолчанию записи лежат в памяти процесса. На своём сервере они переживают запросы, а в бессерверной среде каждый запрос может достаться новому экземпляру — тогда общего кеша между запросами почти нет, и польза остаётся в основном от предрендера. Для общего хранилища есть отдельный вариант директивы, "use cache: remote", но это уже разговор про площадку, а не про React.

  • Cache Components включаются флагом cacheComponents, и после этого кешируется не страница, а функция: директива "use cache" ставится в файл, в компонент или в обычную асинхронную функцию.
  • Ключ записи собирается из идентификатора сборки, идентификатора функции и сериализованных аргументов; захваченные из замыкания переменные Next добавляет к аргументам сам.
  • Аргументами и результатом ходят сериализуемые значения; JSX и серверные действия можно только прокидывать насквозь, не заглядывая внутрь.
  • cacheLife задаёт три числа: stale — для кеша в браузере, revalidate — возраст, после которого запись обновляется в фоне, expire — возраст, после которого запрос ждёт свежий расчёт.
  • Данные запроса — cookies, headers, searchParams — внутри кеша недоступны: их читают снаружи и передают аргументом, а всё запросное живёт под Suspense.
  • Одна страница спокойно совмещает предрендеренную оболочку, кешированные куски с разными сроками и динамику, приезжающую на запросе.

1/4Кешированная функция getProducts(filter) вызвана дважды: getProducts({ category: "tea" }) и ещё раз с точно таким же объектным литералом. Сколько записей в кеше появится?

2/4Внутри компонента с директивой "use cache" написано const theme = (await cookies()).get("theme"). Что будет?

3/4Функция помечена cacheLife("hours"): revalidate — час, expire — сутки. Запрос пришёл через 70 минут после того, как запись посчитали. Что получит этот запрос?

4/4Директива "use cache" стоит первой строкой в app/blog/layout.tsx. Что кешируется?

Убрать запрос из кеша

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

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Кешированную часть отделяем от запросной: Feed получает город аргументом, а куки читает обычный компонент над ним. Он же попадает под Suspense — без границы Next не пустит такое ожидание в предрендер.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Город стал частью ключа: у читателей из одного города запись общая, у разных городов — разные записи. Это тот же приём, что в разделе про запрос, и он же ответ на вопрос «а если данные всё-таки личные»: тогда либо не кешировать, либо брать "use cache: private".

Дописать stale-while-revalidate

Модель кеша умеет только считать заново. Допишите read, чтобы она вела себя как запись с revalidate в 10 секунд и expire в 40: пока запись молода — отдавать из кеша, после revalidate — отдавать старое и пересчитывать, после expire — ждать расчёт. Комментарии справа показывают нужный вывод.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три ветки в том же порядке, в каком их проверяет сервер: сначала «отдавать нечего», потом «пора обновиться», потом «всё свежо».

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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

Выбрать профили

Три функции получили cacheLife("default") — то есть срок жизни, о котором никто не подумал. Замените профили на подходящие и объясните выбор. Подсказка в комментариях: важно, как быстро данные устаревают и насколько заметно читателю, что он видит старое.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Курс — seconds, лента — hours или minutes, оферта — max.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

У выбора есть цена, о которой стоит сказать вслух. seconds — единственный встроенный профиль, который не попадает в предрендер: его expire в минуту слишком мал, и такая запись становится дыркой, считаемой на запросе, — значит, ей нужна граница Suspense. А оферте вместо длинного профиля можно было бы дать метку cacheTag и сбрасывать её при правке текста: время тут вообще не при чём.