Представьте: пользователь открывает ваше SPA в метро при нестабильном соединении, быстро переходит между разделами — и интерфейс мгновенно реагирует, несмотря на лаги сети. Магия? Нет, продуманное кеширование. Но реализовать его корректно — задача со звёздочкой.
Проблема не в самом факте кеширования, а в его согласованности, актуальности и реактивности. Нативные подходы (localStorage
, ручные решения) быстро упираются в сложности инвалидации данных и ограничения. Рассмотрим современные техники и подводные камни на реальных примерах.
Стратегии — не религия, а инструмент
Stale-While-Revalidate (SWR) — фаворит динамических данных:
Идея проста: отдаём клиенту кеш (даже "протухший"), одновременно запуская фоновый запрос за свежими данными. Обновление интерфейса происходит после ответа сервера. Библиотеки как swr
или TanStack Query
абстрагируют рутину:
import useSWR from 'swr';
function UserProfile({ id }) {
const { data, error, isLoading } = useSWR(
`/api/user/${id}`,
fetcher,
{
revalidateOnFocus: true, // Авто-ревалидация при возврате на вкладку
refreshInterval: 30000, // Периодический опрос
}
);
if (error) return <ErrorPage />;
if (isLoading) return <Skeleton />;
return <ProfileCard data={data} />;
}
Почему это работает: Пользователь мгновенно видит контент (пусть и устаревший), а фоновое обновление поддерживает актуальность без блокировки UI.
Инвалидация — большая боль:
Триггеры обновления должны быть семантичными. Простая "инвалидация по ключу" — путь в ад устаревших данных.
Пример бизнес-логики с тегами в TanStack Query:
import { useQuery, useQueryClient } from '@tanstack/react-query';
function AddPost() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (postData) => axios.post('/api/posts', postData),
onSuccess: () => {
// Инвалидируем ВСЕ ключи с тегом 'posts'
queryClient.invalidateQueries({ queryKey: ['posts'] });
}
});
}
Подводный камень: Избыточная инвалидация влечёт ложные рефетчи. Используйте уточнённые теги (['posts', 'list']
, ['posts', 'detail', id]
).
Сервисворкеры: Кеширование выходит на новый уровень
Cache API
+ Workbox
— закэшировать можно всё: статику, API-ответы, графы зависимостей.
Конфиг примера для Workbox с разделением стратегий:
// workbox-config.js
workbox.routing.registerRoute(
({ request }) => request.destination === 'document',
new workbox.strategies.NetworkFirst() // Для HTML: приоритет сети
);
workbox.routing.registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'api-cache',
plugins: [
new workbox.expiration.Plugin({ maxEntries: 100 })
]
})
);
Опасности сервисворкеров:
- Зомби-кэши: Используйте
self.skipWaiting()
иclients.claim()
аккуратно. Лучше — явное обновление черезpostMessage
. - Складирование устаревших данных: Реализуйте версионирование кэша с плавной миграцией (e.g.,
workbox.core.setCacheNameDetails({ suffix: 'v1' })
) - Утечки памяти: Всегда удаляйте старые кэши в
activate
-событии:
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== currentCacheName)
.map(name => caches.delete(name))
);
})
);
});
Когда данные огромны: стратегии для сложных сценариев
Дифференциальное обновление:
Запрос на получение дельты изменений вместо полных данных. Серверный ответ может выглядеть так:
{
"lastUpdated": "2025-04-15T12:00:00Z",
"patches": [
{ "op": "replace", "path": "/users/42/name", "value": "New Name" },
{ "op": "add", "path": "/posts", "value": [{ "id": 999, "title": "New Post" }] }
]
}
Клиентский код аппликации патчей (библиотеки как jsonpatch
):
import { applyPatch } from 'fast-json-patch';
let currentData = { ... }; // Текущий кеш
applyPatch(currentData, serverResponse.patches);
Проблема: Ручная реализация сложна. Рассмотрите GraphQL Subscriptions или Delta Queries на сервере из коробки.
Оптимистичное обновление (Optimistic UI):
Демонстрация изменений ДО ответа сервера. Критично для отзывчивости.
Пример с TanStack Query:
const queryClient = useQueryClient();
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Отмена текущих запросов, чтобы избежать конфликтов
await queryClient.cancelQueries(['todos']);
const previousTodos = queryClient.getQueryData(['todos']);
// Оптимистичное обновление
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
// Откат при ошибке
queryClient.setQueryData(['todos'], context.previousTodos);
}
});
Грабли: Всегда предусматривайте откат. Не используйте для мутаций с высокой вероятностью ошибки (например, платежей).
Серверная сторона пазла: заголовки, которые вы обязаны знать
ETag
/Last-Modified
: Для условных запросов. Клиент шлётIf-None-Match
/If-Modified-Since
— сервер отвечает304 Not Modified
при идентичных данных. Резко снижает трафик.Cache-Control: max-age=0, must-revalidate
: Динамические данные, которые нельзя кешировать на долго, но можно валидировать.Cache-Control: private, max-age=3600
: Персональные данные, кешируемые только для одного клиента.
Проверьте свою конфигурацию CDN: агрессивное кеширование статики (public, max-age=31536000, immutable
) критично для скорости.
Заключение: баланс и инженерный прагматизм
Идеального решения для всех сценариев не существует. Ошибка, которую совершают даже опытные разработчики — стремление кешировать всё любой ценой. Это приводит к синхронизационным кошмарам.
Правила выбора стратегии:
- Статика:
Cache-Control
с длинным TTL + хэши имен файлов. - Пользовательские данные: SWR + оптимистичные апдейты + точная инвалидация.
- Реалтайм (чаты, уведомления): WebSockets/SSE, асинхронные апдейты.
- Крупные наборы данных (таблицы): Пагинация + бесконечный скролл + предварительная выборка (prefetch).
Тестируйте ваше кеширование не только при Wi-Fi 500 Мбит/с, но и в режиме "Slow 3G" через инструменты разработчика. Инвалидация на атомарном уровне, минимизация запросов и грамотная работа с состояниями ожидания — то, что отделяет разочаровывающее приложение от того, в котором комфортно жить. Кешируйте со смыслом.