Разработка блока «Недавно просмотренные» для интернет-магазина
Представьте: пользователь просматривает товар за товаром, но при возврате на страницу категории не может найти тот самый кед или кофеварку. История просмотров — это не просто фича, а инструмент возврата. Однако её реализация часто страдает от дублирования в массиве ID, N+1 запросов и проблем с порядком товаров. Мы интегрируем блок «Недавно просмотренные» с клиентским кешированием в localStorage для гостей и серверной синхронизацией для авторизованных пользователей, используя один batch-запрос для загрузки данных. Разработка занимает 1–2 дня, а возвраты к карточкам вырастают до 15%. Свяжитесь с нами — оценим ваш проект за день.
Хранение истории в localStorage
Для неавторизованных пользователей история хранится в localStorage. Мы используем хук useRecentlyViewed, который при каждом просмотре добавляет ID товара в начало массива, удаляя дубликаты. Максимум — 20 записей. Вот реализация:
// hooks/useRecentlyViewed.ts const STORAGE_KEY = 'recently_viewed'; const MAX_ITEMS = 20; export const useRecentlyViewed = () => { const [items, setItems] = useState<number[]>(() => { const stored = localStorage.getItem(STORAGE_KEY); return stored ? JSON.parse(stored) : []; }); const addProduct = useCallback((productId: number) => { setItems(prev => { const filtered = prev.filter(id => id !== productId); const updated = [productId, ...filtered].slice(0, MAX_ITEMS); localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); return updated; }); }, []); const clearHistory = useCallback(() => { localStorage.removeItem(STORAGE_KEY); setItems([]); }, []); return { productIds: items, addProduct, clearHistory }; }; На странице товара вызываем addProduct(product.id).
Серверная синхронизация для авторизованных
Если пользователь авторизован, история синхронизируется с сервером через Sanctum. При логине мы мержим localStorage с серверной историей, при закрытии вкладки отправляем текущее состояние через navigator.sendBeacon. Эндпоинт для синхронизации:
// routes Route::middleware('auth:sanctum')->post('/me/recently-viewed', [RecentlyViewedController::class, 'sync']); Route::middleware('auth:sanctum')->get('/me/recently-viewed', [RecentlyViewedController::class, 'index']); public function sync(Request $request): JsonResponse { $request->validate(['product_ids' => 'required|array|max:20', 'product_ids.*' => 'integer']); $user = $request->user(); // Обновляем порядок: переданный список — актуальное состояние $user->recentlyViewed()->sync( collect($request->product_ids)->mapWithKeys(fn($id, $pos) => [ $id => ['position' => $pos, 'viewed_at' => now()] ]) ); return response()->json(['synced' => count($request->product_ids)]); } Такой подход сохраняет историю между устройствами и не теряет данные при смене браузера.
Batch-загрузка данных
История хранит только product_id. Для отображения блока нужны данные — один batch-запрос к API. Мы используем React Query с staleTime 5 минут, чтобы не перегружать сервер при повторных посещениях:
const RecentlyViewedBlock = () => { const { productIds } = useRecentlyViewed(); const visibleIds = productIds.slice(0, 8); const { data: products } = useQuery({ queryKey: ['recently-viewed-products', visibleIds], queryFn: () => api.get('/products/batch', { params: { ids: visibleIds.join(',') } }), enabled: visibleIds.length > 0, staleTime: 300_000, }); if (!products?.length) return null; // Сохраняем порядок из истории const ordered = visibleIds .map(id => products.find((p: Product) => p.id === id)) .filter(Boolean); return ( <section> <div className="flex justify-between items-center mb-4"> <h2 className="text-lg font-semibold">Вы недавно смотрели</h2> <button onClick={clearHistory} className="text-sm text-gray-400 hover:text-gray-600">Очистить историю</button> </div> <ProductCarousel products={ordered} /> </section> ); }; Endpoint загружает ровно те поля, которые нужны для карточки, и возвращает товары в порядке переданных ID:
public function batch(Request $request): JsonResponse { $request->validate(['ids' => 'required|string']); $ids = array_filter(array_map('intval', explode(',', $request->ids))); $ids = array_slice($ids, 0, 20); $products = Product::whereIn('id', $ids) ->where('is_active', true) ->select(['id', 'name', 'slug', 'price', 'sale_price', 'rating_avg', 'rating_count']) ->with('thumbnail') ->get() ->keyBy('id'); $ordered = collect($ids)->map(fn($id) => $products->get($id))->filter()->values(); return response()->json(ProductCardResource::collection($ordered)); } Почему batch-загрузка быстрее?
Если запрашивать каждый товар отдельно, получите N запросов к БД — для 8 товаров это 8 раунд-трипов. Batch-эндпоинт возвращает все за один раз, что снижает нагрузку на сервер в 8 раз. На клиенте мы сохраняем порядок через сортировку по исходному массиву ID. Такой подход ускоряет загрузку блока в 3–5 раз, а блокочие метрики (LCP, FID) не страдают.
| Подход | Время загрузки (8 товаров) | Нагрузка на сервер |
|---|---|---|
| N+1 запросы | 800–1200 мс | 8 запросов |
| Batch-запрос | 150–300 мс | 1 запрос |
Как исключить текущий товар?
На странице товара X из блока «Недавно просмотренные» исключается сам товар X — иначе он неизбежно окажется первым в списке. Делается простым фильтром: productIds.filter(id => id !== currentProductId). Это предотвращает ситуацию, когда пользователь видит карточку того же товара рядом с основным контентом.
Сравнение методов синхронизации
| Метод | Гости | Авторизованные | Скорость | Серверная нагрузка |
|---|---|---|---|---|
| Только localStorage | Да | Нет | Мгновенно | Нет |
| Серверная | Нет | Да | ~5 мс | Средняя |
| Гибрид (localStorage + сервер) | Да | Да | Мгновенно + 5 мс | Низкая |
Гибридный подход — оптимальный выбор: он сочетает скорость локального хранилища с устойчивостью серверного.
Где размещать блок
- Главная страница: для вернувшихся пользователей — вместо или рядом с популярными товарами
- Страница категории: в нижней части, после основной сетки товаров
- Страница товара: под блоком «Похожие товары»
- Корзина: в боковой панели на десктопе
- Пустые результаты поиска: «Может, вы ищете что-то из просмотренного?»
Приватность и контроль
Кнопка «Очистить историю» даёт пользователю контроль над данными. Срок хранения в localStorage можно ограничить, добавив TTL вручную — например, 30 дней. Подробнее о localStorage читайте в документации MDN.
Типичные ошибки при реализации
- Не исключают текущий товар из блока — пользователь видит его первым.
- Используют N+1 запросы вместо batch — загрузка блока тормозит.
- Не очищают историю при логине — данные гостя смешиваются с данными пользователя.
- Синхронизируют историю слишком часто — избыточная нагрузка на сервер.
Что входит в работу
- анализ текущей архитектуры и мест размещения блока
- реализация хука
useRecentlyViewedс localStorage - API-эндпоинты для серверной синхронизации (Laravel)
- batch-загрузка данных с кешированием на клиенте
- UI-компонент блока (карусель) с кнопкой очистки
- инструкция по деплою и тестированию
Наш опыт — более 50 проектов с историей просмотров. Гарантируем корректную работу на всех этапах. Свяжитесь с нами, чтобы обсудить ваш проект — оценим его за день. Закажите реализацию блока «Недавно просмотренные» и получите прирост конверсии без сложных интеграций.







