Cache Components и директива 'use cache'
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Кеш в Next.js больше не свойство страницы. Директива "use cache" ставится внутрь функции — и кешируется ровно её результат: строка из базы, кусок разметки или целый маршрут. Ключом становится всё, что в функцию вошло, а cacheLife тремя числами решает, когда значение обновится.
В прошлой статье — «Статика, динамика и всё между ними» — маршрут выбирал из двух состояний: собраться целиком на этапе сборки или пересобираться на каждый запрос. Одно обращение к данным запроса переводило в динамику всю страницу — вместе с шапкой, футером и каталогом, которые у всех читателей одинаковые.
Cache Components меняют единицу измерения. Включает их флаг в настройках проекта — без него нет ни директивы, ни cacheLife, ни cacheTag.
Дальше правило простое: то, что ждёт данных и не помечено, считается на каждый запрос; помеченное — кешируется. Разметка, которой ждать нечего, как была статикой, так и осталась. Директива ставится в трёх местах: первой строкой файла (тогда кешируются все его экспортируемые функции), первой строкой компонента или первой строкой обычной функции. Во всех трёх случаях функция обязана быть асинхронной.
Разница между двумя функциями выше — в том, что складывается в кеш. У getPosts это массив объектов, у PostList — уже отрисованный кусок дерева, готовый к отправке в браузер. Директива одна и та же.
Что значит «единица — функция», видно на модели. Страница собирается из трёх частей: шапка одинакова для всех, каталог общий, корзина у каждого своя. Помечены первые две. Прежде чем запускать, посчитайте: сколько раз выполнится каждая часть за два запроса от разных читателей?
Шапка и каталог посчитались по одному разу на двоих, корзина — дважды. Страница при этом не «статическая» и не «динамическая»: она смешанная, и таких страниц в приложении большинство.
Страница перестала быть единицей кеша. Единица — функция; страница просто собрана из функций с разными сроками жизни.
Запись в кеше ищется по ключу, и Next собирает его из четырёх слагаемых: идентификатор сборки (после выката все записи считаются заново), идентификатор функции — хеш её места и сигнатуры в коде, сериализованные аргументы и, только в режиме разработки, хеш горячей перезагрузки.
«Сериализованные» — это не «те же самые объекты». Разница видна на модели: два вызова с разными объектами одинакового содержания. Сколько раз мы сходим в базу?
Два раза: { category: "tea" } и второй такой же объект дали один ключ. Это ровно то поведение, которого ждёшь от кеша, — и ровно то, которого не будет, если ключом сделать сам объект-фильтр.
Тот же код, но без сериализации: объект-фильтр кладётся в Map как есть.
Три вызова — три обращения в базу: каждый литерал создаёт новый объект, и попадания не будет никогда. Сериализация ключа — не мелочь реализации, а то, ради чего кеш вообще работает.
Второе слагаемое неочевиднее: переменные, захваченные из внешней области. Функция помнит окружение, в котором родилась (это замыкания из части I), и Next такие переменные тоже связывает как аргументы — они попадают в ключ. Зачем, видно на кеше, который так не делает. Что напечатает вторая строка?
Отчёт первого пользователя достался второму: в ключе оказался только период, а userId остался в замыкании. У Next такого не выйдет — захваченная переменная входит в ключ, и у второго пользователя будет своя запись. Отсюда практическое следствие: чем крупнее вход, тем больше разных записей, поэтому в кешированную функцию передают идентификатор, а не весь объект пользователя.
| Значение | Аргументом | В возврате |
|---|---|---|
Примитивы: строки, числа, null, undefined | да | да |
| Обычные объекты и массивы | да | да |
Date, Map, Set, типизированные массивы | да | да |
| Разметка JSX и серверные действия | только насквозь, не читая | да |
Экземпляры классов, URL, символы, функции | нет | нет |
Проверяется это на сборке, а не типами: tsc спокойно пропустит экземпляр класса в пропсах кешированного компонента — после части III к такому надо привыкнуть заново. Компилятор знает форму значения, а не то, переживёт ли оно сериализацию.
Строка «только насквозь» — это приём, ради которого стоит запомнить таблицу: кешированная оболочка может обнимать динамическое содержимое, если она в него не заглядывает.
Компонент получает children и ничего с ними не делает — только вставляет в разметку. Такие аргументы не сериализуются, а прокидываются ссылкой.
Снаружи в CachedShell можно передать компонент, который читает данные запроса: оболочка приедет из кеша, а начинка досчитается на месте.
Директива без cacheLife тоже работает — просто берётся профиль default, и срок жизни перестаёт быть виден в месте вызова. Поэтому в документации рекомендуют писать cacheLife в каждой кешируемой функции, даже когда числа вас устраивают.
Профиль — это три числа, и каждое отвечает за свой слой.
| Число | Где действует | Что означает |
|---|---|---|
stale | браузер | сколько роутер отдаёт значение из памяти вкладки, не спрашивая сервер; меньше 30 секунд роутер поднимает до тридцати — иначе префетч протухал бы до клика |
revalidate | сервер | после какого возраста записи следующий запрос запускает обновление в фоне |
expire | сервер | после какого возраста запись больше не годится: запрос ждёт свежий расчёт |
Встроенные профили названы по порядку величины. Числа ниже — из Next 16; в своём проекте их видно во всплывающей подсказке над cacheLife.
| Профиль | Для чего | stale | revalidate | expire |
|---|---|---|---|---|
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 — пять. Предскажите третью строку вывода: запрос пришёл на девяностой секунде, запись уже пора обновлять — что получит этот запрос?
Запрос, который запустил обновление, сам получил старое значение — и не ждал ни секунды. Ждать пришлось только последнему: к пятисотой секунде запись просрочена, отдавать нечего. Так и работает stale-while-revalidate: медленный расчёт достаётся фону, а читателю — то, что уже посчитано.
В модели нет stale: это число живёт в браузере читателя, а не в серверном кеше.
Кроме имён профиль можно задать объектом — для разового случая, — а свои имена объявляются в настройках рядом со встроенными.
Кешированные функции вкладываются друг в друга, и тут важно, объявлен ли cacheLife у внешней. Если объявлен — её срок и действует: при попадании она отдаёт весь свой результат целиком, вместе с тем, что насчитали вложенные.
Если не объявлен, внешняя работает по default, и вложенный кеш с более коротким сроком укорачивает её (более длинный — не удлиняет). Худший случай Next не пропускает: вложенный seconds внутри функции без явного cacheLife — ошибка на предрендере, потому что иначе вся внешняя запись молча стала бы секундной.
Кешированная функция выполняется в изоляции: cookies(), headers() и searchParams ей недоступны. Запрет идёт по стеку вызовов — вспомогательная функция, которую она позвала, упадёт с той же ошибкой next-request-in-use-cache. Причина простая: запись кеша одна на всех, а данные запроса у каждого свои; мы только что видели, чем это кончается.
Рабочий приём — прочитать данные запроса снаружи и передать аргументом. Тогда значение честно войдёт в ключ, и общая запись появится у всех, у кого этот аргумент совпал.
По частям здесь происходит вот что. Заголовок и запасная разметка 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" | по месту |
Время — не единственный повод обновить запись. Статья меняется не по часам, а когда её отредактировали: такой функции ставят длинный профиль и вешают метку.
Метка сама ничего не сбрасывает — она даёт имя, по которому запись можно объявить устаревшей после записи в базу. Кто и когда это делает, разбирает статья «Что происходит после мутации: 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 помечен директивой и читает куки — это ошибка на выполнении. Перепишите так, чтобы кеш остался (лента для одного города одинакова у всех), а данные запроса читались вне кешированной области.
Кешированную часть отделяем от запросной: Feed получает город аргументом, а куки читает обычный компонент над ним. Он же попадает под Suspense — без границы Next не пустит такое ожидание в предрендер.
Город стал частью ключа: у читателей из одного города запись общая, у разных городов — разные записи. Это тот же приём, что в разделе про запрос, и он же ответ на вопрос «а если данные всё-таки личные»: тогда либо не кешировать, либо брать "use cache: private".
Дописать stale-while-revalidate
Модель кеша умеет только считать заново. Допишите read, чтобы она вела себя как запись с revalidate в 10 секунд и expire в 40: пока запись молода — отдавать из кеша, после revalidate — отдавать старое и пересчитывать, после expire — ждать расчёт. Комментарии справа показывают нужный вывод.
Три ветки в том же порядке, в каком их проверяет сервер: сначала «отдавать нечего», потом «пора обновиться», потом «всё свежо».
Важна именно последовательность: проверка на просроченность идёт первой, иначе запись возрастом в сто секунд попала бы в ветку фонового обновления и читатель получил бы данные, которые сервер уже считает негодными.
Выбрать профили
Три функции получили cacheLife("default") — то есть срок жизни, о котором никто не подумал. Замените профили на подходящие и объясните выбор. Подсказка в комментариях: важно, как быстро данные устаревают и насколько заметно читателю, что он видит старое.
Курс — seconds, лента — hours или minutes, оферта — max.
У выбора есть цена, о которой стоит сказать вслух. seconds — единственный встроенный профиль, который не попадает в предрендер: его expire в минуту слишком мал, и такая запись становится дыркой, считаемой на запросе, — значит, ей нужна граница Suspense. А оферте вместо длинного профиля можно было бы дать метку cacheTag и сбрасывать её при правке текста: время тут вообще не при чём.