Мы работаем над простой и мощной моделью кэширования для Next.js. В предыдущей статье мы рассказывали о нашем пути работы с кэшированием и о том, как мы пришли к директиве 'use cache'
.
Эта статья расскажет о дизайне API и преимуществах 'use cache'
.
Что такое 'use cache'
?
'use cache'
ускоряет ваше приложение, кэшируя данные или компоненты по мере необходимости.
Это JavaScript-директива — строковый литерал, который вы добавляете в код — она сигнализирует компилятору Next.js о переходе в другую "границу". Например, с сервера на клиент.
Это похоже на директивы React, такие как 'use client'
и 'use server'
. Директивы — это инструкции компилятору, определяющие, где должен выполняться код, позволяя фреймворку оптимизировать и оркестрировать отдельные части за вас.
Как это работает?
Начнём с простого примера:
async function getUser(id) {
'use cache';
let res = await fetch(`https://api.vercel.app/user/${id}`);
return res.json();
}
Под капотом Next.js преобразует этот код в серверную функцию благодаря директиве 'use cache'
. Во время компиляции "зависимости" этой записи кэша определяются и используются как часть ключа кэширования.
Например, id
становится частью ключа кэша. Если мы вызовем getUser(1)
несколько раз, мы вернём мемоизированный результат из кэшированной серверной функции. Изменение этого значения создаст новую запись в кэше.
Рассмотрим пример использования кэшированной функции в серверном компоненте с замыканием.
function Profile({ id }) {
async function getNotifications(index, limit) {
'use cache';
return await db
.select()
.from(notifications)
.limit(limit)
.offset(index)
.where(eq(notifications.userId, id));
}
return <User notifications={getNotifications} />;
}
Этот пример сложнее. Можете ли вы определить все зависимости, которые должны быть частью ключа кэша?
Аргументы index
и limit
очевидны — если эти значения изменятся, мы выберем другой срез уведомлений. Но что насчёт id
пользователя? Его значение приходит из родительского компонента.
Компилятор понимает, что getNotifications
также зависит от id
, и его значение автоматически включается в ключ кэша. Это предотвращает целый класс проблем кэширования из-за некорректных или пропущенных зависимостей в ключе.
Почему бы не использовать функцию кэширования?
Вернёмся к предыдущему примеру. Могли бы мы использовать функцию cache()
вместо директивы?
function Profile({ id }) {
async function getNotifications(index, limit) {
return await cache(async () => {
return await db
.select()
.from(notifications)
.limit(limit)
.offset(index)
// Ой! Где нам включить id в ключ кэша?
.where(eq(notifications.userId, id));
});
}
return <User notifications={getNotifications} />;
}
Функция cache()
не сможет заглянуть в замыкание и увидеть, что значение id
должно быть частью ключа кэша. Вам придётся вручную указывать, что id
является частью ключа. Если вы забудете это сделать или сделаете неправильно, это может привести к коллизиям кэша или устаревшим данным.
Замыкания могут захватывать различные локальные переменные. Наивный подход может случайно включить (или исключить) переменные, которые вы не планировали. Это может привести к кэшированию не тех данных или даже к утечке конфиденциальной информации в ключ кэша.
'use cache'
даёт компилятору достаточно контекста для безопасной работы с замыканиями и корректного формирования ключей кэша. Решение только на уровне выполнения, такое как cache()
, потребует ручной работы — и легко ошибиться. В отличие от этого, директива может быть статически проанализирована для надёжной обработки всех зависимостей под капотом.
Как обрабатываются несериализуемые входные значения?
У нас есть два типа входных значений для кэширования:
- Сериализуемые: Здесь "сериализуемые" означает, что входные данные могут быть преобразованы в стабильный строковый формат без потери смысла. Хотя многие сначала думают о
JSON.stringify
, мы фактически используем сериализацию React (например, через Server Components) для обработки более широкого диапазона входных данных — включая промисы, циклические структуры данных и другие сложные объекты. Это выходит за рамки возможностей обычного JSON. - Несериализуемые: Эти входные данные не являются частью ключа кэша. При попытке кэшировать эти значения мы возвращаем серверную "ссылку". Затем Next.js использует эту ссылку для восстановления исходного значения во время выполнения.
Допустим, мы не забыли включить id
в ключ кэша:
await cache(async () => {
return await db
.select()
.from(notifications)
.limit(limit)
.offset(index)
.where(eq(notifications.userId, id));
}, [id, index, limit]);
Это работает, если входные значения сериализуемы. Но если бы id
был React-элементом или более сложным значением, нам пришлось бы вручную сериализовать ключи. Рассмотрим серверный компонент, который получает текущего пользователя на основе пропса id
:
async function Profile({ id, children }) {
'use cache';
const user = await getUser(id);
return (
<>
<h1>{user.name}</h1>
{/* Изменение children не нарушает кэш... почему? */}
{children}
</>
);
}
Разберём, как это работает:
- Во время компиляции Next.js видит директиву
'use cache'
и преобразует код для создания специальной серверной функции с поддержкой кэширования. Кэширование не происходит во время компиляции, но Next.js настраивает механизм, необходимый для кэширования во время выполнения. - Когда ваш код вызывает "функцию кэша", Next.js сериализует аргументы функции. Всё, что не может быть сериализовано напрямую, например JSX, заменяется "ссылкой"-заполнителем.
- Next.js проверяет, существует ли кэшированный результат для данных сериализованных аргументов. Если результат не найден, функция вычисляет новое значение для кэширования.
- После завершения работы функции возвращаемое значение сериализуется. Несериализуемые части возвращаемого значения преобразуются обратно в ссылки.
- Код, вызвавший функцию кэша, десериализует вывод и вычисляет ссылки. Это позволяет Next.js заменить ссылки их фактическими объектами или значениями, что означает, что несериализуемые входные данные, такие как
children
, могут сохранить свои исходные, некэшированные значения.
Это означает, что мы можем безопасно кэшировать только компонент <Profile>
, а не дочерние элементы. При последующих рендерах getUser()
не вызывается снова. Значение children
может быть динамическим или отдельно кэшированным элементом с другим сроком жизни кэша. Это и есть компонуемое кэширование.
Это кажется знакомым...
Если вы думаете: "Это похоже на ту же модель композиции сервера и клиента" — вы абсолютно правы. Это иногда называют паттерном "пончик":
- Внешняя часть пончика — это серверный компонент, который обрабатывает получение данных или сложную логику.
- Дырка в середине — это дочерний компонент, который может иметь некоторую интерактивность.
export default function Page() {
return (
<ServerComponent>
{/* Создаём отверстие к клиенту */}
<ClientComponent />
<ServerComponent />
);
}
'use cache'
работает так же. Пончик — это кэшированное значение внешнего компонента, а дырка — это ссылки, которые заполняются во время выполнения. Вот почему изменение children
не аннулирует весь кэшированный вывод. Дочерние элементы — это просто ссылки, которые заполняются позже.
А как насчёт тегирования и инвалидации?
Вы можете определить срок жизни кэша с помощью различных профилей. Мы включаем набор профилей по умолчанию, но вы можете определить свои собственные значения, если это необходимо.
async function getUser(id) {
'use cache';
cacheLife('hours');
let res = await fetch(`https://api.vercel.app/user/${id}`);
return res.json();
}
Чтобы аннулировать конкретную запись кэша, вы можете пометить кэш и затем вызвать revalidateTag()
. Одна мощная особенность — вы можете пометить кэш после получения данных (например, из CMS):
async function getPost(postId) {
'use cache';
let res = await fetch(`https://api.vercel.app/blog/${postId}`);
let data = await res.json();
cacheTag(postId, data.authorId);
return data;
}
Просто и мощно
Наша цель с 'use cache'
— сделать создание логики кэширования простым и мощным.
- Простота: Вы можете создавать записи кэша с локальным рассуждением. Вам не нужно беспокоиться о глобальных побочных эффектах, таких как забытые записи ключей кэша или непреднамеренные изменения других частей вашей кодовой базы.
- Мощь: Вы можете кэшировать больше, чем просто статически анализируемый код. Например, значения, которые могут изменяться во время выполнения, но вы всё равно хотите кэшировать результат после его вычисления.
'use cache'
всё ещё является экспериментальной функцией в Next.js. Мы будем рады вашему раннему отзыву, когда вы протестируете её.