Главная / Статьи / Синхронизация корзины покупок между вкладками браузера: BroadcastChannel против localStorage

Синхронизация корзины покупок между вкладками браузера: BroadcastChannel против localStorage

Почему события хранилища localStorage передают идентичные данные, почему решения с Date.now оказываются ненадежными, и как использование BroadcastChannel вместе с надежным хранилищем решает проблему отображения индикаторов корзины между вкладками.

1658 слов

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

Сценарий

Покупатель держит открытыми две вкладки с товарами. Он добавляет товар в вкладку A. Индикатор в заголовке вкладки B должен обновиться. В противном случае вкладка B продолжает показывать старое количество, и покупатель считает, что операция добавления не удалась.

Ограничения от интервьюера: вкладки не могут делиться стеками JavaScript, а для передачи сигнала запрещены сетевые запросы туда-обратно. Уведомление должна передавать сама платформа.

Первая попытка: события хранилища

Большинство кандидатов обращаются к localStorage вместе с обработчиком событий storage. Запись в одной вкладке уведомляет соответствующие элементы в других вкладках того же источника.

// Tab that adds the item
function addToCart(sku) {
  cart.add(sku);
  renderBadge();
  localStorage.setItem('cart-sync', JSON.stringify({
    type: 'CART_ADD', sku, qty: 1
  }));
}
// Every other tab
addEventListener('storage', (e) => {
  if (e.key !== 'cart-sync') return;
  const msg = JSON.parse(e.newValue);
  if (msg.type === 'CART_ADD') { cart.add(msg.sku); renderBadge(); }
});

Этот скетч обычно работает без проблем. Затем появляется тикет с жалобой.

Отчет QA, который нарушает работу

Чтобы воспроизвести проблему: добавьте одинаковые SKU дважды. Вкладка A показывает количество 2, а вкладка B остается на уровне 1. После перезагрузки вкладки B наконец отображается значение 2.

Ничего не выдает ошибок. Журналы логов пусты. Код, отвечающий за обработку данных, кажется корректным — так почему же другой узел не получил обновление?

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

Коренная причина: игнорирование одинаковых значений

Алгоритм HTML-функции setItem прерывает свою работу, когда входящая строка совпадает с уже сохраненным значением для данного ключа — по описанию в спецификации это означает «если предыдущее значение равно новому значению, прекратить работу». Никакой записи в диск, никакого распространения сигнала, никакого пробуждения других узлов.

Идентичные действия в корзине могут привести к сериализации в одинаковые байты:

{"type":"CART_ADD","sku":"SKU-1029","qty":1}

Вкладка A по-прежнему записывает данные локально после своего собственного вызова метода add. Вкладка B никогда не получает событие storage. После перезагрузки вкладка B снова читает данные хранилища, и всё кажется в порядке — типичная проблема при периодических тестированиях.

Почему дубли кажутся редкостью, но на самом деле таковыми не являются

Последовательности, в которых чередуются значения (A, затем B, затем снова A), продолжают работать корректно. Проблемы возникают при идентичных последовательных данных:

  • Двойные клики, добавляющие один и тот же SKU
  • Сообщения о статусе, повторно отправляющие неизменный статус "ONLINE"
  • Повторяющиеся уведомления об истечении сессии, пока медленная вкладка всё ещё загружается

Именно в такие моменты коллегам наиболее необходим сигнал о проблеме.

«Уникальность» временных меток крайне хрупка

Часто используемые патчи вставляют значение Date.now() в JSON, что приводит к различиям между строками:

localStorage.setItem('cart-sync', JSON.stringify({
  type: 'CART_ADD', sku, qty: 1,
  t: Date.now()          // force the value to differ
}));

Часы в миллисекундах сталкиваются, когда два записей происходят в одну и ту же миллисекунду. Сработает ли патч в таком случае, зависит от удачи распределяющего механизма — иногда да, иногда нет. Правильность, зависящая от степени дробности обычного времени, не является настоящей правильностью. При необходимости использования хранилища предпочтительнее применять crypto.randomUUID() (или другой надежный уникальный токен).

Очистка приводит к двойной доставке

Хранение уникальных данных навсегда создает проблемы, поэтому люди сразу после setItem вызывают removeItem. Это приводит к отправке двух уведомлений: одного о записи и одного об удалении.

event 1 → { key:'cart-sync', oldValue: null,    newValue: '{"type":"CART_ADD",…}' }
event 2 → { key:'cart-sync', oldValue: payload, newValue: null }

Если обработчик не игнорирует условие newValue === null, партнер дважды применяет изменения к корзине. Дизайн, где хранилище выступает в роли шины передачи данных, теперь требует обеспечения уникальности, проверок на значение null, парсинга и очистки — поскольку API представляет собой хранилище ключей/значений, которое время от времени отправляет сообщения, а не очередь сообщений.

Предпочтительный инструмент: BroadcastChannel

Когда речь идет о обмене сообщениями, а не о сохранении данных, используйте API для обмена сообщениями:

const bus = new BroadcastChannel('cart-sync');
// send — the same message, as many times as you like
bus.postMessage({ type: 'CART_ADD', sku, qty: 1 });// receive
bus.onmessage = (e) => {
  if (e.data.type === 'CART_ADD') { cart.add(e.data.sku); renderBadge(); }
};addEventListener('pagehide', () => bus.close());

Повторные одинаковые объекты все равно будут доставлены. Нет сокращения проверки на равенство. Структурированное клоны поддерживает более разнообразные типы, чем JSON (Date, Map, Set, типизированные массивы). Рассматривайте хранилище как базу данных с необязательными уведомлениями о изменениях, а BroadcastChannel — как уведомление без базы данных.

Вопросы, важные при работе в производственных условиях

Самодоставка сообщений. Объект канала для отправки не получает собственное сообщение, но другой экземпляр канала с тем же именем в том же документе — получает его, как и iframes из одного источника. Необходимо устранять дубликаты, если на одной странице есть несколько подписчиков.

Синхронное и асинхронное взаимодействие. Доставка данных происходит в очереди событий получателя (асинхронно). Клонирование во время вызова postMessage является синхронным и немедленно отклоняет значения, которые нельзя скопировать (ошибка DataCloneError для функций).

Вкладки, открытые позже. Каналы не воспроизводят историю действий. Вкладка, открытая после добавления элемента, продолжает показывать старый индикатор, если только она не читает данные из постоянного хранилища. Используемый подход:

// The shape that actually ships:
// durable store = the truth, channel = the doorbell
async function addToCart(sku) {
  await idbPut('cart', sku);                     // atomic, survives reloads
  bus.postMessage({ type: 'CART_CHANGED' });     // just a signal
}
bus.onmessage = async () => renderBadge(await idbCount('cart'));

Постоянные данные считаются истинными; канал служит лишь сигналом о том, что эта истина изменилась.

Счётчик в хранилище. Чтение, изменение и запись данных между вкладками приводит к потере обновлений; платформа не предоставляет механизмов блокировки. Не создавайте распределённый счётчик в localStorage.

Где хранилище по-прежнему имеет преимущество. Параметры, такие как тема или регион, которые необходимо синхронно считать перед отрисовкой, редко меняются. Для значений используйте события storage; для событий — BroadcastChannel.

Самопроверка

При примитивном подходе к хранению с одинаковым объемом данных, сколько событий между двумя идентичными операциями, выполняемыми с интервалом в одну секунду?

localStorage.setItem('cart-sync', '{"sku":"SKU-1029"}');
// … one second later, same product added again …
localStorage.setItem('cart-sync', '{"sku":"SKU-1029"}');

Ответ: одно событие. Прошедшее время не имеет значения; уведомление генерируется только при изменении строки.

Заключение интервью

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

Основные выводы

Синхронизация интерфейса между вкладками — это проблема обмена сообщениями, маскирующаяся под вопросы хранения данных. Рассматривайте стабильное состояние и уведомления как отдельные слои, выбирайте API, соответствующие каждому слою, и тестируйте одинаковые последовательные действия — в учебных примерах этого не делают, а реальные пользователи сталкиваются с проблемами при двойном клике.

Дополнительные замечания для производственной среды

Мобильные браузеры могут агрессивно закрывать фоновые вкладки; обновление индикатора при событии visibilitychange, в ходе которого снова считывается данные из стабильного хранилища, позволяет решить проблему упущенных сообщений во время заморозки вкладки. Сочетайте это с использованием BroadcastChannel для обмена данными между активными вкладками.

Автоматизированные тесты должны работать в двух контекстах (вкладки Playwright) и проверять как сохранение одинаковых данных в хранилище, так и доставку копий через BroadcastChannel. Документируйте выбранную схему ключей для стабильного хранилища, чтобы в будущем синхронизация серверов происходила без необходимости создания дополнительного источника информации.

Флаги функций иногда контролируют поведение «живого бейджа». Сохраняйте постоянные записи без условий; только элемент звонка может быть факультативным. В противном случае вкладка с отключенным флагом навсегда отличается от аналогичных вкладок с включенным флагом.

С точки зрения безопасности никогда не храните токены аутентификации в сообщениях localStorage. SKU из корзины подходят; секреты сессии — нет. Лучше использовать нечитаемые идентификаторы и позволять каждой вкладке читать привилегированные данные из каналов httpOnly или памяти после проверки сессии.

Если интернет-магазин расположен на нескольких поддоменах, BroadcastChannel не сможет их объединить. Варианты включают использование общего рабочего процесса на общем родительском домене или события, отправляемые сервером и связанные с идентификатором корзины. Указывайте на этот ограничение сразу во время обсуждения дизайна.

Интернационализация бейджа (правила для множественного числа) должна выполняться в каждой вкладке после получения данных о количестве — не передавайте заранее сформатированные строки, если нельзя гарантировать идентичность для всех локалей.

Наконец, проведите измерения: зафиксируйте в аналитике, как часто происходят добавления дублирующихся SKU. Если этот показатель значителен, ловушка одинакового хранения стала бы скрытым производственным инцидентом, ожидающим первого многооконного опытного пользователя.

Связь интервью с документацией по дизайну

При составлении отчета для команды выделите три ключевых решения: (1) какое постоянное хранилище будет использоваться для данных корзины, (2) какой механизм передачи данных пробуждает сопоставленные окна, и (3) как устройство сокращения бейджей интерпретирует сообщения от дверного звонка. Сведение этих решений к формуле «просто используйте localStorage» и создает ловушку одинаковости.

В краткой документации по дизайну можно включить схему последовательности действий: клик пользователя → изменение данных в IndexedDB → отправка сообщения через BroadcastChannel → аннулирование запросов на получение бейджей со стороны сопоставленных окон. Укажите возможные сценарии сбоев: отсутствие поддержки канала (редко в современных браузерах, но стоит проверить), особенности режима приватности и изоляция хранилища в браузерах с несколькими профилями.

Чек-лист тестирования

  • Дважды добавить идентичные SKU с дверным звонком, использующим только хранилище (ожидается пропуск операции)
  • Дважды добавить с использованием BroadcastChannel (ожидается две обновления)
  • Открыть третью вкладку после добавлений (ожидается правильное количество на основе надежного считывания, а не повторной обработки)
  • Быстрое добавление в течение одной миллисекунды с уникальными временными метками (ожидается периодический пропуск операции)
  • setItem + removeItem без защиты от значений null (ожидается удвоенное количество записей)

Автоматизация этого чек-листа в CI предотвращает регрессии, когда кто-то «упрощает» логику обработки до использования только событий хранилища.

Почему интервьюерам нравится этот вопрос

Этот тест оценивает умение читать спецификации, а не запоминание названий API. Кандидаты, которые лишь бегло просмотрели MDN, не знают о возможности возврата значения типа equality. Те, кто реализовывал интерфейсы с несколькими вкладками, упоминают BroadcastChannel и постоянные хранилища данные без дополнительных указаний. Вопросы о поздних вкладках и счётчиках без блокировки позволяют определить, был ли ответ фрагментом из блога или результатом личного опыта.

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

Связанные паттерны

Индикаторы присутствия, совместные курсоры (легковесные) и механизмы выхода из сессии используют одну и ту же модель doorbell. Для совместной редакции документов обычно требуются CRDT или сервер; не стоит превращать BroadcastChannel в протокол обеспечения согласованности. Давайте останемся честными на примере корзины: синхронизация индикаторов происходит по принципу eventual consistency, используется авторитетная и долговечная корзина, а согласование с сервером может быть добавлено позже по желанию.

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

Этой комбинации достаточно. Готово.