Разработка блока «Недавно просмотренные» для интернет-магазина
Представьте: пользователь просматривает товар за товаром, но при возврате на страницу категории не может найти тот самый кед или кофеварку. История просмотров — это не просто фича, а инструмент возврата. Однако её реализация часто страдает от дублирования в массиве 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 проектов с историей просмотров. Гарантируем корректную работу на всех этапах. Свяжитесь с нами, чтобы обсудить ваш проект — оценим его за день. Закажите реализацию блока «Недавно просмотренные» и получите прирост конверсии без сложных интеграций.







