На этой странице

Поставьте на главную страницу магазина несколько товарных подборок, и на одном экране легко окажется от 60 до 200 товаров. Каждому нужны данные в браузере: цены, варианты, остатки и то, что произойдёт, когда покупатель нажмёт «В корзину». В inSales, e-commerce-платформе с 60 000+ магазинов, этим занимается CommonJS — JavaScript-библиотека, на которой работают витрины. Я несколько лет был её мейнтейнером.

Когда я начал разбираться с её кешированием, данные товаров в браузере жили по таймеру: две минуты в обычном сценарии, меньше на странице товара и несколько секунд в режиме администратора. Самый обычный кеш, и ломался он сразу в две противоположные стороны:

  1. Если товар уже изменился, покупатель мог видеть старые данные, пока не истечёт таймер.
  2. Если ничего не изменилось, таймер всё равно истекал, и библиотека заново скачивала те же самые данные.

То есть кеш был одновременно слишком долгим и слишком коротким. Была и менее очевидная цена. Когда покупатель добавлял в корзину один товар, библиотека проверяла весь список товаров, сохранённый при загрузке страницы. Если к этому моменту таймер истёк, один клик мог заново запросить данные всех товаров на странице, а не только того, с которым работал покупатель.

Другой вопрос к кешу

Таймер отвечает на вопрос «сколько лет этим данным?». Витрине возраст данных, по сути, безразличен. Ей нужно знать, тот ли это ещё товар. Ответ у сервера уже был: у каждого товара есть дата изменения. Просто до браузера она не доходила.

Я передал её через шаблон. Liquid-разметка кладёт дату в атрибут каждого товарного элемента:

<div
  data-product-id="{{ product.id }}"
  data-product-updated-at="{{ product.updated_at }}"
>

Библиотека сохраняет это значение в IndexedDB рядом с данными товара. При следующей загрузке страницы она сравнивает дату из разметки с сохранённой. Кеш при этом полностью клиентский: сервер отдаёт только дату, с которой идёт сравнение.

Решение по каждому товару свелось к небольшой таблице:

Состояние товараЧто делает библиотека
Товар в кеше, дата та же, данных хватает для этого виджетаБерёт из кеша без запроса
Товара нет в кешеЗапрашивает и сохраняет
Дата измениласьПолучает актуальные данные и обновляет запись
Виджету нужны варианты, которых нет в кешеДогружает полные данные
Старый шаблон не передаёт датуОставляет прежнюю проверку по времени

Если упростить, правило укладывается в одну функцию:

/** Решает, откуда брать данные одного товара при загрузке страницы. */
function resolveProductSource({ cached, updatedAt, needsVariants }) {
  if (!cached) return 'fetch'

  // Шаблоны без даты изменения сохраняют прежнее поведение по таймеру
  if (!updatedAt) return isExpired(cached) ? 'fetch' : 'cache'

  if (cached.updatedAt !== updatedAt) return 'refresh'
  if (needsVariants && !cached.hasAllVariants) return 'fetch-full'

  return 'cache'
}

Для корзины появилось отдельное правило. Товары, которые лежат в корзине, но отсутствуют на текущей странице, больше не заставляют перезагружать всё, что видно на экране. Для них библиотека берёт сохранённые данные, а если записи нет, запрашивает только нужные варианты. Товары, которые есть и на странице, проходят обычную проверку по дате.

Самое сложное: свежие данные ещё не значит полные

Дата изменения оказалась простой половиной задачи. Сложнее было понять, что вообще считать «товаром в кеше», если разным частям страницы нужен разный объём данных.

В крупных магазинах встречаются товары с 500–1000 вариантами. Запрос списка из 20 таких товаров мог занимать 20–30 секунд и заканчиваться таймаутом, хотя превью в слайдере полный список вариантов вообще не требовался. Я сформулировал задачу на облегчённый ответ API для превью, согласовал её с бэкендом и адаптировал под него библиотеку. Теперь превью узнаёт только, что у товара несколько вариантов, и предлагает открыть карточку или модальное окно. Товар с единственным вариантом по-прежнему можно сразу положить в корзину.

После этого один и тот же товар мог лежать в общем кеше и одновременно показываться в нескольких местах:

  • в превью без выбора вариантов;
  • в другом виджете с полноценным выбором;
  • в модальном окне, где покупатель выбирает вариант;
  • в корзине.

Оба наивных правила здесь что-нибудь ломают. Если облегчённый ответ перезапишет полную запись, следующий виджет потеряет нужные ему варианты. Если считать любую запись в кеше достаточной, модальное окно откроется с половиной данных. Одной свежести мало. Правила кеша должны учитывать, что нужно конкретному месту на странице: облегчённый ответ не должен стирать варианты, на которые рассчитывает другой виджет, а виджет, которому не хватает данных, догружает недостающее. Это четвёртая строка таблицы.

Даже у свежести оказалось больше одного измерения. После изменения остатков в кеше мог сохраниться прежний порядок вариантов, и превью показывало цену первого варианта, которого уже не было в наличии. Пришлось работать над обновлением данных варианта и его положения в сохранённом списке, а не только даты.

Выкатка в шаблоны, которые я не контролирую

Библиотека работает внутри шаблонов, которые владельцы магазинов и их разработчики дорабатывают под себя. Если бы новая версия вдруг начала требовать data-product-updated-at, сломался бы каждый доработанный шаблон, где этого атрибута нет.

Поэтому выкатка шла в два этапа. Сначала вышла версия библиотеки, которая понимает дату изменения, а для разметки без неё оставляет прежний таймер. Это последняя строка таблицы. Потом атрибут появился в разметке виджетов. Ни одному магазину не нужно было, чтобы оба изменения пришли одновременно.

Сценарии для QA шли по той же таблице: переходы между страницами, изменение цены в админке и повторное открытие страницы, открытие после того, как старый таймер уже истёк бы, смесь полных и сокращённых данных о вариантах, товары в корзине и один товар в нескольких виджетах.

Что изменилось

Когда я сравнил количество запросов за данными товаров до и после изменений, их стало примерно на 70% меньше, то есть примерно в 3,3 раза. Товары, которые не менялись, перестали загружаться заново только потому, что прошло две минуты.

Вместе с AJAX-рендерингом товарных блоков, который запускается после основного содержимого, тяжёлые страницы с большим количеством товаров стали заметно быстрее. В характерных замерах время загрузки сократилось с более чем 5 до 2–3 секунд. Я проверял это на более чем 10 стандартных шаблонах и на реальных страницах клиентов, в том числе с 60–200 товарами. Это характерный результат, и в конкретном магазине он может отличаться: многое зависит от количества виджетов, устройства, сети и состояния кеша. Особенно тяжёлые страницы раньше могли грузиться около 15 секунд.

После релиза

Продакшен нашёл то, что не нашёл QA, и два таких бага оказались об одном и том же.

В декабре 2024 года товар на мобильном Safari перестал инициализироваться после перехода из каталога в карточку. Выглядело это как зависание хранилища на странице товара. Пошаговое воспроизведение показало, что проблема начиналась раньше. По кнопке «Загрузить ещё» в каталоге библиотека запускала инициализацию отдельно для каждого товара, и каждый запуск заново готовил список и снова шёл в IndexedDB. На странице клиента было два виджета каталога, так что повторной работы становилось вдвое больше. Я сделал инициализацию пакетной: загруженные товары обрабатываются вместе, без отдельного прохода по хранилищу для каждой карточки.

В мае 2025 года ответ с ошибкой для части товаров ронял инициализацию всей библиотеки. Переставали работать даже товары, данные которых загрузились нормально. Я поправил обработку ошибок, и теперь успешно загруженные товары инициализируются в любом случае.

Общий кеш разносит данные одного товара по всей странице. Работа и ошибки, которые относятся к одному товару, должны к нему и относиться, иначе одна карточка утянет за собой всю страницу.

Если ваш кеш живёт по таймеру

Спросите себя, что вам на самом деле нужно знать. Обычно вопрос в том, изменился ли источник с момента сохранения, а возраст данных тут вторичен. Если у сервера уже есть дата изменения или версия, донести её до клиента часто дешевле, чем подбирать TTL: перестаёшь выбирать между устаревшими данными и лишними запросами.

Перед релизом задайте второй вопрос: может ли двум потребителям понадобиться разный объём одной и той же записи? Если может, полнота данных должна быть в правилах кеша наравне со свежестью, а облегчённый ответ никогда не должен молча побеждать.

И если вы не контролируете все места, где рендерится ваша разметка, сначала выпускайте код, который читает новый сигнал, и только потом код, который его записывает. Новая версия должна понимать сигнал и продолжать работать без него.

Подробнее о проекте — в кейсе о загрузке товаров на витрине.

  • Caching
  • IndexedDB
  • Storefront
  • Liquid
  • Performance
Александр Ануфриев
Александр Ануфриев

Senior frontend-разработчик