Головна / Статті / Синхронізація кошика покупок між вкладками браузера: 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(); }
});

Цей скетч часто працює без проблем. Потім з’являється заява.

Щоб відтворити проблему: додайте той самий 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"
  • Повторні сповіщення про SESSION_EXPIRED, поки повільний таб все ще завантажується

Саме в ці моменти іншим пристроям найбільше потрібен сигнал про проблему.

„Унікальність“ часових позначок є крихкою

У поширених патчах функція 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() (або інший надійний унікальний токен).

Очищення створює подвійну доставку

Залишати унікальні дані назавжди є проблематичним, тому люди негайно виконують removeItem після setItem. Це призводить до двох сповіщень: одного про запис та одного про видалення.

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 — як сповіщення без бази даних.

Питання, які мають значення у продакшні

Самонадсилання. Об’єкт каналу для публікації не отримує власного повідомлення, але інша інстанція каналу з тією самою назвою в тому ж документі його отримує, так само як і iframe з того ж джерела. Необхідно усувати дублікати, якщо на одній сторінці є кілька підписників.

Синхронне та асинхронне виконання. Доставка даних відбувається у черзі подій отримувача (асинхронно). Клонування під час використання 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 не зможе їх об’єднати. Варіантами є використання спільного працівника на загальному батьківському домені або події, надсилані сервером та ідентифіковані за ID кошика. Необхідно зазначити цю обмеження на ранніх етапах перегляду дизайну.

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

Наостанок — вимірювання: фіксуйте, як часто виникають дублювання SKU у аналітиці. Якщо цей показник є значним, „пастка рівноцінного зберігання“ стала б прихованою проблемою в роботі системи, яка чекала на першого потужного користувача з кількома вкладками.

Пов’язування інтерв’ю з документом про дизайн

Під час підготовки опису для команди виділіть три рішення: (1) який надійний сховище буде використовуватися для зберігання даних кошика, (2) який механізм передачі даних буде активувати сусідні вкладки, та (3) як модуль зменшення кількості бейджів інтерпретує повідомлення від дзвінка у двері. Об’єднання цих рішень у формулювання „просто використовуйте localStorage“ і створює цю „пастку рівності“.

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

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

  • Додати двічі ідентичний SKU з дзвінком лише для зберігання (очікується помилка)
  • Додати двічі за допомогою BroadcastChannel (очікується два оновлення)
  • Відкрити третю вкладку після додавання (очікується правильна кількість з постійного читання, а не від повтору)
  • Швидке додавання протягом одного мілісекунди з унікальними часовими мітками (очікується періодичні помилки)
  • setItem + removeItem без захисту від null (очікується подвоєна кількість)

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

Чому інтерв’юерам подобається це запитання

Це оцінює здатність читати специфікації, а не запам’ятовувати назви API. Кандидати, які лише швидко проглянули MDN, не помічають функції повернення значень рівності. Ті, хто створював інтерфейси з кількома вкладками, самостійно згадують BroadcastChannel та міцні сховища даних. Додаткові запитання щодо вкладок, які з’являються пізно, та лічильників без блокування показують, чи була відповідь уривком з блогу, чи результатом особистого досвіду.

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

Пов’язані патерни

Індикатори присутності, спільні курсори (легкі) та механізм вихіду з системи використовують одну й ту саму модель doorbell. Редагування спільних документів зазвичай потребує CRDT або сервера; не намагайтеся перетворити BroadcastChannel на протокол забезпечення консистентності. Залишайте приклад кошика чесним: синхронізація ідентифікаторів у кінцевому підсумку, авторитетний стабільний кошик, опційна корекція на сервері пізніше.

У продакшені тримайте все простим: один стабільний кошик, один doorbell, чіткі тести на дублювання дій та без зайвої залежності від часомірів у мілісекундах. Саме ця простота забезпечує чесність ідентифікаторів у кількох вкладках, навіть коли користувачі відкривають більше вікон, ніж це було у демо-версії.

Цієї комбінації достатньо. Готово.