Галоўная / Артыкулы / Синхронізацыя кошыка пакупак між вікнамі браузера: 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.

Нічога не выклікае памылак. Журналы спакойныя. Код выглядае правільна — тады чаму іншы працэс не пазначыў апдэйт?

Ключовая інфармацыя — перзагрузка восстанавляе стан: стан «выtrывальны» быў правільным; толькі сігнал у рэальны час не працаваў.

Асновная прычына: аднаковыя значэнні ігнаруюцца

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

Ідентычныя дзеянні ў кошыку можу быць серыялізаваныя у тые ж байты:

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

Таблетка A яшчэ і пасля свайго add выконвае дзейства локальна. Таблетка B ніколі не атрымляе запуску змены storage. Пасля перзагрузкі таблетка B зноў чытае даны з аховання і вяршыць нормальна — класычная проблема пад час тэставання.

Чаму дуплікаты здаюцца рэдкімі, але такія ёсць

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

  • Двойныя клікі, якія дадаюць тое ж самае SKU
  • Сігналы стану, якія зноў прадаюць незменены статус "ONLINE"
  • Паўтаральныя паведамленні пра SESSION_EXPIRED, калі таблетка ўсё яшчэ запускаецца

Гэтыя саме моменты, калі іншыя працоўныя елементы больш за ўсё патрэбуюць адзвягання.

«Унікальнасць» часовых пазначак ўразламная

У звычных патчах у JSON дадаецца Date.now(), таму строкі становяцься разнымі:

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, партнер два разы застосоўвае зміны кашты. Дизайн, у яком збераганне выступае як шына, тепер выкалікае патрэбу ў унікальнасці, захопленні ад нуля, парсаванні дадзеных і ўсуненні зайвага — таму што 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 з таго ж вучэльніка. Неабходна адмахнуцца ад дуплікацый, якщо на одной сторунцы ёсць калькі падпісчыкаў.

Сінхронна vs асінхронная перадача. Данные практычна асінхронным спосабом кашэруюцца ў цыкле падзейнаў прыёмніка. Клонаванне пад час 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 адбудовамі; для значэнняй кращэ вжыць 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, який зноў чытае данні з надзейныга зберагчыка, памагае у тых случаях, калі паведамленне пропускаецца чытачам, калі той знаходзіцца у стане «заморожання». Спалучыце гэта з Channel для вкладак у першанай пярэдзі.

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

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

З точкі зору безпекі ніколі не кладзіце токены аутэнтыкацыі ў сообщэннях localStorage. ID тавароў у кошыку ўсё гаразд; але секрэты сесій — няўжо. Краща выкорыстоўваць незрозумелыя ідэнтыфікаторы і дазволіць кожной працоўнай чытаць прывілейованыя дакументы з каналаў httpOnly або з памяці пасля пераканальнай перацэнкі сесіі.

Якщо інтерфейс магазіна распадаецца на калькольвыя паддомены, BroadcastChannel не зможа пераводзіць данні межы ўсіх іх. Альтэрнатывамі являюцца спяльны працоўнік на аднам загальным домене-родзічы, або запуск з боку сервера падазрэнняў, ключаваных за ідэнтыфікаторам кошыка. Неабходна якомога раней падчас пераглядоў дызайна адзначыць гэтыя обмежэння.

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

На завершанне — звяржыцеся з статыстыкай: запісваўце, як часта вырабляюцца дадзеныя пра дублікаты SKU ў аналітыцы. Якщо гэтая частота значная, то «пастка равнасці значэнняў» стала бы латэнтным прыбутковым інцыдентам, які чакаў на першага аптымалнага корыстувальніка з колькома вікнамі.

Спраўленне інтэрв’ю з документам дызайна

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

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

Спісак перагляду прабавок

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

Автаматызацыя гэтага спісака прабавок у CI запобегае регрэсіям, калі хтось “спрасцавляе” прыемы звярнення да справаў сховання.

Чаму спрашальнікі любяць гэты запит

Гэта практыка адзначае ценнасць чытання тэхнічных спефікацый, а не запам’ятоввання назв API. Кандыдаты, якія толькі шукалі інфармацыю на MDN, працягваюць не зрозумець, як вяртаеся значэнне для роўнасці. Кандыдаты, якія стварылі інтерфейсы з калькулярамі, памятаюць пра BroadcastChannel і стойкіх хранальнях дадзеных без падказак. Даўнейшыя запытання пра вікна, якія зачыняюцца пазней, і лічыльнікі без блокавання паказваюць, чы рэспонс прыйшоў з артыкула на блогу чы ён быў базаваны на рэальным дазнаўце.

Для варыянтав, якія можна забраць домоў, папрасіце прыкладны репазітарый з двума маршрутамі і файлам README, які описвае выбраныя компоненты. Рэвізоры должны ачысці два вікна і нажыць — практычны прыклад кращы за абарот тэоріі.

Спадзяючыся патэрны

Індыкаторы прысутнасці, саўместныя курсоры (лёгкія) і механізмы выйшчы з системы викорыстоўваюць той самы модэль „doorbell“. Рэдагаванне дакументаў саўместна зазвычай выклікае патрэбу ў CRDT-ах або серверы; не трэба ператвараць BroadcastChannel у протакол адпаведнасці. Чыста застаўся прыклад корзіны: синхронізацыя індыкатораў у канечны момент, автарытатывная, стойкая корзіна, а пасля — неабавесць можлівае супрацоўкаванне з серверам.

У рэальных умовах трэба застаўляць всё простым: адна стойкая корзіна, адны модэль „doorbell“, чысткія тэсты на дуплікацыю дзеянняў, і без жадных хитрых способаў на базе часоўникаваў у мілісекундах. Саме такая простацкая структура дапамагае падтрымваць адпаведнасць індыкатораў у колькіх вікнах, калі пакупцы ачыняюць больш вікна, чым тое, што было практыкуема ў дэманстрацыйных сценарыях.

Гэтая комбінацыя ўсё, што трэба. Готава.