Пользователь открывает список заказов, затем карточку одного из них, потом возвращается назад и снова открывает ту же карточку. Данные уже загружались дважды, но интерфейс показывает крутящийся индикатор и ждёт ответа. Между тем ничего не изменилось, и всё нужное лежит в памяти. Беда не в скорости сервера, а в том, что клиент не умеет узнавать одни и те же данные, пришедшие разными запросами.
Графовый запрос лишён адреса и потому не кэшируется средствами сети
Обычный веб-запрос удобно кэшировать, потому что у него есть адрес. Один и тот же товар всегда живёт по одному адресу, и достаточно запомнить ответ на этот адрес. Соответствие адреса и сущности делает кэш простым и предсказуемым.
В графовом языке запросов адреса нет. Клиент описывает, какие поля ему нужны, и один и тот же объект может прийти в ответ на десять разных запросов. Сервер видит только текст запроса и переменные, но не понимает, что в двух ответах описана одна и та же сущность.
Отсюда рекомендация, которая на первый взгляд кажется формальностью, но на деле определяет всю работу кэша. Схема должна давать каждому объекту глобально уникальный идентификатор. Чаще всего для этого служит поле с идентификатором, а если сервер использует собственные номера, значение дополняют названием типа и делают непрозрачным, например кодируя в основание шестьдесят четыре.
Если уникального идентификатора нет, клиент может собрать его сам из названия типа и локального номера. Для этого ему нужно знать тип объекта, а тип сообщается служебным полем, которое сервер добавляет в ответ.
Нормализация раскладывает ответ на отдельные сущности
Основная идея кэша состоит в том, что ответ не хранится целиком. Он разбирается на части, и каждая сущность укладывается отдельно под своим ключом.
Представим ответ со списком заказов. Внутри есть сам список, а внутри него несколько объектов заказа с полями номера, даты и суммы. После нормализации в памяти появятся отдельные записи: одна для сущности заказа с идентификатором сорок два, другая для заказа с идентификатором сорок три, и отдельно список, содержащий ссылки на них.
Ключ записи собирается из названия типа и идентификатора. Получается что-то вроде названия типа заказа и номера сорок два. Именно поэтому требование уникальности идентификатора так важно: при совпадении ключей данные разных объектов перезапишут друг друга.
Что это даёт на практике. Когда пользователь открывает карточку заказа из списка, клиент может не ходить на сервер: запись с этим ключом уже лежит в кэше, хотя карточка запрашивалась другим запросом. Достаточно, чтобы в кэше были все запрошенные поля. Если каких-то полей не хватает, клиент запросит недостающие, но уже не будет тянуть всё заново.
Второе следствие касается согласованности. Изменение названия заказа в одном месте интерфейса автоматически отражается во всех остальных, потому что везде используется одна и та же запись. Это избавляет от классической проблемы, когда список показывает одно, а карточка другое.
import { InMemoryCache } from "@apollo/client";
const cache = new InMemoryCache({
typePolicies: {
Order: {
keyFields: ["orderNumber"],
},
Product: {
keyFields: ["sku", "region"],
},
},
});
Второй пример показывает составной ключ. Товар с одним артикулом в разных регионах это разные сущности с точки зрения цены и наличия, поэтому ключ собирается из двух полей.
Политики полей определяют, как сливать новые данные со старыми
Второй слой управления связан с тем, что происходит при повторном получении тех же полей. По умолчанию новые значения заменяют старые. Для простых полей это правильно, для списков и вложенных структур часто нет.
Возьмём список заказов с постраничной загрузкой. Клиент запросил первую страницу, потом вторую. Если слить ответы наивно, вторая страница затрёт первую, и пользователь потеряет уже загруженные данные. Политика слияния решает эту задачу явно.
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
orders: {
keyArgs: ["status", "customerId"],
merge(existing, incoming, { args }) {
const merged = existing ? existing.slice(0) : [];
const offset = args?.offset ?? 0;
for (let i = 0; i < incoming.length; i += 1) {
merged[offset + i] = incoming[i];
}
return merged;
},
},
},
},
},
});
Здесь видно сразу две настройки. Список ключевых аргументов отделяет один вариант списка от другого: заказы со статусом оплаченных и заказы со статусом доставленных не должны смешиваться. Функция слияния вставляет новые элементы по смещению, не затирая уже загруженные.
Третья настройка касается вытеснения. Кэш не должен расти бесконечно, поэтому старые и редко используемые записи удаляются по правилам, которые задаёт тот же объект политик. Обычно настраивается предельное число записей для конкретного типа, а также общий предел.
Четвёртая настройка связана с данными, которые не должны попадать в кэш вовсе. Если результат зависит от времени или от одноразового значения, его хранение только запутает интерфейс. Такие поля помечаются как не подлежащие сохранению.
Чтение и запись напрямую дают контроль над обновлением
Библиотека позволяет работать с кэшем как с хранилищем, а не только получать данные через запросы. Это пригодится, когда сервер прислал изменение и нужно обновить конкретную запись.
import { gql, useApolloClient } from "@apollo/client";
const GET_ORDER = gql`
query Order($id: ID!) {
order(id: $id) {
id
status
total
}
}
Чтение конкретной сущности выполняется по её ключу в кэше. Для этого применяется идентификатор, полученный из нормализации. Если записи нет, вернётся пустое значение, и это повод пойти на сервер.
Запись выполняется двумя способами. Первый описывает изменение в терминах запроса и требует наличия данных в кэше. Второй работает напрямую со сущностями и не требует предварительной загрузки.
Отдельный приём это оптимистичное обновление при изменении. Пользователь нажимает кнопку, интерфейс мгновенно показывает ожидаемый результат, а затем ответ сервера подтверждает или отменяет его. Для кнопок с мгновенным откликом это заметно улучшает впечатление.
const [updateOrder] = useMutation(UPDATE_ORDER, {
optimisticResponse: {
updateOrder: {
__typename: "Order",
id: orderId,
status: "delivered",
},
},
update(cache, { data }) {
cache.modify({
id: cache.identify({ __typename: "Order", id: orderId }),
fields: {
status: () => data.updateOrder.status,
},
});
},
});
Служебное поле типа в оптимистичном ответе обязательно. Без него клиент не сможет построить ключ записи и не поймёт, что обновлять.
Запросы по сети тоже требуют управления политикой
Помимо локального кэша существует сетевой запрос, и у него своя логика. По умолчанию при первом показе компонента данные берутся из кэша, а запрос в сеть отправляется, чтобы освежить значения.
Такое поведение подходит не всегда. Три разумных режима покрывают большинство случаев.
Первый режим предписывает брать данные только из кэша и не обращаться к сети. Подходит для справочников, которые меняются редко.
Второй режим требует обратиться к сети и обновить кэш, даже если данные есть. Применяется, когда важна свежесть: баланс, состояние заказа, очередь задач.
Третий режим сначала берёт из кэша, а затем обновляет в фоне. Это золотая середина для большинства списков.
const { data, loading, error } = useQuery(GET_ORDERS, {
variables: { status: "paid", offset: 0, limit: 20 },
fetchPolicy: "cache-and-network",
nextFetchPolicy: "cache-first",
});
Вторая настройка в примере управляет поведением после первого запроса. Первый раз данные обновляются в фоне, а при последующих обращениях клиент довольствуется кэшем, пока компонент не смонтируется заново.
Третья настройка это опрос с интервалом. Он включается для данных, которые меняются постоянно. Опрос стоит ограничивать по времени и останавливать при уходе со страницы, иначе автоматические запросы продолжат идти в фоне.
Инвалидация после изменений определяет, увидит ли пользователь правду
Самый сложный вопрос в кэшировании звучит так: что именно устарело после изменения. Ответ зависит от устройства графа.
Для одной сущности всё просто. Изменился статус заказа, обновляется запись этого заказа, и все места интерфейса, где она используется, показывают новое значение автоматически.
Сложность начинается со списками. Список заказов хранится как отдельная запись со своими аргументами, и изменение одного заказа не меняет сам список. Если фильтр по статусу, то заказ, у которого статус поменялся, должен исчезнуть из одного списка и появиться в другом. Автоматически это не произойдёт, и логику нужно описать.
Практический приём состоит в том, чтобы после изменения перезапрашивать активные списки, содержащие изменённую сущность. Библиотека предоставляет способ найти такие запросы и обновить их.
const [cancelOrder] = useMutation(CANCEL_ORDER, {
refetchQueries: ["Orders", "OrderSummary"],
});
Более экономный вариант предполагает ручное обновление конкретных записей вместо полного перезапроса. Он быстрее, но требует точного знания структуры кэша, и при ошибке интерфейс показывает устаревшие данные.
Граница между двумя подходами определяется ценой ошибки. Если показать устаревший список заказов просто неприятно, достаточно ручного обновления. Если речь о балансе или остатках, лучше перезапросить.
Диагностика кэша экономит часы поиска ошибок
Большая часть проблем с кэшем выглядит как загадка, пока не посмотришь внутрь. Инструменты разработчика для этой библиотеки показывают содержимое кэша в реальном времени.
Первое, что стоит проверить, это ключи. Если сущности с разными идентификаторами получили одинаковый ключ, данные будут перепутаны. Обычно это следствие составного ключа, в котором одно из полей не задано.
Второе это отсутствие нормализации. Если запись видна как единый блок без разбивки на сущности, значит, у типа нет идентификатора. Так бывает при работе с типами, у которых нет поля с уникальным значением.
Третье это расхождение разметки. Один запрос просит у заказа поле с датой, другой не просит. После обновления из второго запроса поле может исчезнуть из кэша, и интерфейс потеряет часть данных. Лекарство состоит в единообразии запросов: если поле нужно в нескольких местах, оно запрашивается во всех фрагментах, использующих тип.
Четвёртое это рост памяти. Долгая работа с приложением без вытеснения приводит к тому, что кэш занимает сотни мегабайт. Настройка пределов и периодическая очистка решают проблему.
Сравнение двух подходов показывает, где нормализация выигрывает
Полезно поставить рядом два способа работы и увидеть разницу на конкретных числах.
Первый способ это хранение ответов по ключу из текста запроса и переменных. Он прост: получил ответ, положил под ключ, вернулся на экран, взял готовое. Никакой нормализации, никаких политик.
Слабое место обнаруживается быстро. Приложение из десяти экранов делает тридцать запросов, и многие возвращают одни и те же сущности. Каждый ответ хранится отдельно, копии расходятся, и после изменения одного заказа список продолжает показывать старые данные, пока не истечёт срок или пока пользователь не обновит страницу вручную.
Посчитаем расход памяти. Ответ со списком из двадцати заказов, где каждый заказ весит около двух килобайт, занимает примерно сорок килобайт. Три списка с разными фильтрами это уже сто двадцать. Если те же заказы открывались в карточках, добавятся ещё копии. При активной работе с сотней заказов набегает несколько мегабайт на то, что можно было хранить один раз.
Второй способ это нормализация. Те же сто заказов лежат по одной копии, а списки хранят только порядок ссылок. Память экономится заметно, а согласованность появляется сама: изменение записи видно везде.
Плата за это тоже конкретна. Нужен уникальный идентификатор у каждого типа. Нужны политики для списков и пагинации. Нужно понимание, как устроен кэш, иначе отладка превращается в угадывание.
Выбор определяется характером приложения. Для набора независимых страниц с редкими общими данными хватит простого кэша ответов. Для интерфейса, где одни и те же сущности видны на нескольких экранах одновременно, нормализация окупается с первого дня.
Порядок внедрения экономит силы и снижает риск ошибок
Переходить на нормализованный кэш стоит по шагам.
Первым делом настраивается идентификация типов. Сервер должен отдавать уникальные идентификаторы, а если это невозможно, клиент собирает ключ из типа и локального номера. Проверить результат просто: в инструментах разработчика записи должны быть видны отдельными сущностями, а не сплошными блоками.
Вторым шагом описываются политики для полей со списками. Пагинация встречается почти в каждом проекте, и без правильного слияния она работает неверно. Здесь важно проверить границы: переход на первую страницу, на последнюю, возврат назад.
Третьим шагом настраиваются ограничения по памяти. Можно задать предельное число записей для конкретных типов и общий порог. Это защищает от постепенного роста потребления на длинной сессии.
Четвёртым шагом пересматриваются запросы. Поля, которые запрашиваются в разных местах для одного типа, должны совпадать. Расхождение приводит к тому, что после обновления из одного запроса поле исчезает из кэша, и компонент, ожидающий его, ломается.
Пятым шагом проверяется поведение после изменений. Каждое действие, меняющее данные, должно привести интерфейс в согласованное состояние. Это самая трудоёмкая часть, и здесь помогает тест: выполнить действие и пройти по экранам, убедившись, что нигде не осталось старого значения.
Нормализованный кэш получился инструментом с честной ценой. Он требует дисциплины в описании схемы и внимания к деталям слияния, зато взамен убирает целый класс ошибок, которые в другом подходе лечатся бесконечными перезапросами. Когда идентификаторы заданы, а политики продуманы, интерфейс перестаёт показывать противоречивые данные. Пользователь этого не замечает, а это лучший признак того, что кэш настроен правильно.
Кэширование в графовом клиенте получилось не украшением, а сменой взгляда на данные. Вместо набора ответов приложение держит в памяти небольшую базу сущностей, а запросы превращаются в выборки из неё. Когда ключи заданы правильно, а политики описаны честно, эта база сама поддерживает согласованность интерфейса. Всё остальное это детали настройки, которые всплывают наружу ровно там, где их не предусмотрели.