Синхронізуйте завантаження кількох вкладок за допомогою API Web Locks
Зупиніть дублювання завантажень, які можна продовжити, між вкладками браузера, шляхом заміни хаків localStorage та BroadcastChannel на navigator.locks та координатор завантажень у React.
Конкурентність означає можливість виконувати кілька завдань одночасно — або створювати таке враження — щоб програмне забезпечення залишалося реактивним. Кухар, який нарізає інгредієнти, поки одна сковорода готується, а інша — запікається, одночасно виконує кілька завдань, замість того щоб виконувати їх суто по черзі.
Раніше робота з фронтендом здавалася далекою від класичних багатопотокових систем із блокуваннями, семафорами та застряганнями. Сучасна веб-платформа змінила це. Завдяки технологіям типу Workers, WebGL, Canvas та Service Workers браузерні додатки тепер стикаються з проблемами координації, які дуже схожі на конкуренцію за спільну пам’ять.
Проблема
Команда з інженерії маркетплейсу потребувала надійного завантаження дуже великих файлів — обсягом близько десятків гігабайт, до 20 ГБ. Прохання користувачів тримати вкладку відкритою протягом усього процесу передачі є ненадійним рішенням. Метою продукту було створення можливості відновлення завантаження: закриття та повторне відкриття вкладки мало дозволяти продовжувати передачу без необхідності ручного запуску заново.
Можливість відновлення створює новий спосіб збою. Якщо кілька вкладок відновлюють одне й те саме завантаження, кожна з них може почати його заново окремо. У результаті сховище об’єктів отримує дублікати потоків, витрачається пропускна здатність, зростають витрати на обробку, а інтерфейс відстеження прогресу може ставати нестабільним через конкуренцію вкладок за одне завдання.
Проблема полягає у тому, що кожна вкладка окремо виконує щось цілком логічне. Функція відновлення сесії знаходить неповну інформацію про завантаження, клієнт продовжує багатокомпонентну передачу даних, а інтерфейс відображає статус просування роботи. Якщо відкрити дві такі вкладки, той самий „логічний“ шлях перетворюється на два процеси запису даних проти одного ключа віддаленого об’єкта.
Без координатора обидві відновлені вкладки вважають, що саме вони контролюють процес завантаження, і обидві надсилають дані. Для вирішення цієї проблеми необхідна взаємна ексклюзія між вкладками; браузери надають засоби для цього у вигляді Web Locks.
Перш ніж перейти до деталей реалізації, корисно згадати, для чого потрібні блокування.
По-перше, що таке блокування?
Блокування серіалізує доступ до спільного ресурсу, щоб користувачі бачили послідовний стан. У класичному прикладі банку кілька банкоматів одночасно читають та записують баланс одного рахунку. Без синхронізації операції зняття та внесення грошей відбуваються небезпечно одна за одною.
За допомогою блокування перший користувач, який його отримує, виконує операцію читання або запису; інші чекають, поки він його звільнить. Та сама ідея застосовується до правила «лише одна вкладка може продовжити цей завантаження».
Ландшафт координації між вкладками
Кілька старіших підходів браузерів намагаються координувати роботу вкладок. Вони працюють доти, доки не виникають ситуації конкуренції, збої чи помилки протоколу.
Варіант 1: localStorage + Polling
Поширений домашній мьютекс зберігає флаг у localStorage:
// Simplified example - DON'T use this
if (!localStorage.getItem('upload-lock')) {
localStorage.setItem('upload-lock', Date.now());
// Start upload
}
Проблеми з’являються швидко:
- Умови конкуренції: операції читання та запису не є атомарними
- Застарілі блокування: збита вкладка залишає флаг увімкненим назавжди
- Надлишкові витрати від polling: інші вкладки повинні постійно перевіряти стан
- Обмеження подій: події зберігання відбуваються в інших вкладках, а не у тій, що записує
Варіант 2: API Broadcast Channel
Таби можуть взаємодіяти між документами через повідомлення за допомогою BroadcastChannel:
const channel = new BroadcastChannel('upload-coordination');
channel.postMessage({ type: 'REQUEST_UPLOAD_PERMISSION' });
Проте залишаються проблеми:
- Відсутність примітиву мьютекса: доводиться самостійно створювати машину станів
- Умови змагання: перекриваючіся повідомлення все одно спричиняють конфлікти
- Складний протокол: механізми перевірки стану, виявлення збоїв та правила розв’язання конфліктів потрібно реалізовувати вручну
- Схильність до помилок: поширені проблеми з порядком виконання та таймінгом
З’являється API Web Locks: вбудований мьютекс браузера
Вбудовані Web Locks забезпечують атомарний мьютекс браузера. Серед корисних властивостей:
- Атомарне отримання блокування — уникає проблем зі змаганням за доступ до localStorage
- Автоматичне очищення — закриття таба чи його збій звільняють блокування
- Неблокуючі опції — зберігають реактивність інтерфейсу
Наша реалізація: useUploadCoordinator
Власний хук React огортає стратегію керування процесом завантаження.
Основна стратегія
Інтерактивне початок завантаження потрібно розглядати інакше, ніж беззвучне відновлення. Коли хтось явно обирає файл, слід використовувати безблокуючий спосіб отримання контролю, щоб інтерфейс не чекав на іншу вкладку. Якщо блокування вільне, його потрібно отримати та завантажити; якщо воно вже зайняте, рішення все одно має ґрунтуватися на правилах продукту, а не призводити до заморожування сторінки.
При відновленні завантажень необхідно дотримуватися суворіших правил: лише перша вкладка, яка повертає собі контроль, має продовжувати передачу даних. Пізніші вкладки, які бачать, що блокування недоступне, повинні залишити існуючого власника у спокої, а не відкривати другий потік.
// 1. Fresh uploads (user-initiated): Non-blocking
// Try to get the lock, but proceed regardless
navigator.locks.request(LOCK_NAME,
{ ifAvailable: true },
async (lock) => {
if (lock) {
// Hold the lock until upload completes
await holdLockUntilComplete(abortController);
}
// Upload proceeds either way - never block the user
}
);
// 2. Restored uploads: Conditional
// Only proceed if we get the lock
navigator.locks.request(LOCK_NAME,
{ ifAvailable: true },
async (lock) => {
if (lock) {
uploader.emit("restore-confirmed"); // Proceed with restore
await holdLockUntilComplete(abortController);
}
// If no lock, skip silently - another tab is handling it
}
);
Ключові деталі реалізації
Утримуйте блокування до завершення передачі або скасування її:
const holdLockUntilComplete = (abortController: AbortController) => {
return new Promise<void>((resolve) => {
const cleanup = () => {
// Remove all listeners and resolve (releasing the lock)
uploader.off("complete", cleanup);
uploader.off("error", cleanup);
uploader.off("cancel-all", cleanup);
resolve();
};
// Release lock on any terminal event
uploader.on("complete", cleanup);
uploader.on("error", cleanup);
uploader.on("cancel-all", cleanup);
// Also release if component unmounts
abortController.signal.addEventListener("abort", cleanup);
});
};
Виявляйте наявність підтримки та безпечно переходьте на альтернативний режим у разі відсутності API:
const checkWebLocksAvailable = (): boolean => {
return typeof navigator !== 'undefined' && 'locks' in navigator;
};
// Fallback behavior
if (!checkWebLocksAvailable()) {
// Allow restores to proceed (accept risk of duplicates)
uploader.emit("restore-confirmed");
return;
}
Переваги на практиці
Після випуску координатора команда спостерігала наступне:
- Жодних дублікатів завантажень через конкуренцію під час відновлення даних у кількох вкладках
- Кращий досвід користувача — координація не зупиняла інтерфейс
- Зниження витрат на інфраструктуру — менше дублікатів передач та збережених об’єктів
- Простіша структура коду — відсутність циклів опитування чи вручну створених машин станів
Лише та вкладка, яка отримує контроль над блокуванням, продовжує завантаження; інші вкладки виявляють, що блокування утримується, та припиняють роботу.
Аспекти, які потрібно враховувати у реальних умовах
Коли використовувати цю схему:
- Завантаження файлів, які відновлюються після перезавантаження
Вплив на продуктивність:
- Мінімальний — відсутня циклічна перевірка
- Автоматичне очищення обмежує кількість „втрачених“ очікувачів
- Неблокуючі запити зберігають відповідність інтерфейсу
Стратегія тестування:
- Перевіряти кожен підтримуваний браузер, який ви використовуєте
- Імітувати раптове завершення вкладок під час утримання блокування
- Перевіряти поведінку резервного варіанту у випадках, коли Web Locks недоступні
Висновок
Координація між вкладками не вимагає крихкого користувацького протоколу. Користуйтеся механізмом mutex Web Locks платформи: він автоматично очищує ресурси після закриття вкладки та підтримує неблокуюче отримання блокувань. Деталі API можна знайти у документації Web Locks.
Практичні правила, які виявилися ефективними у продакшені:
- Ніколи не блокуйте дії користувача — використовуйте неблокуючі механізми блокування для дій, які ініціює користувач
- Безпечна невдача — погіршуйте функціонал, але не зупиняйте завантаження повністю
- Залишайте все простим — використовуйте вбудовані інструменти платформи замість їх повторної реалізації
Ці три правила мають більше значення, ніж будь-яка конкретна назва допоміжного інструменту. Вдосконалений координатор, який зупиняє виконання дій після кліку, буде працювати гірше, ніж простіший алтернативний варіант, який час від часу просить користувача спробувати знову. Так само розумний протокол обміну повідомленнями, який ігнорує необхідність очищення після збою, може створити ту саму проблему застрягання блокувань, через яку localStorage був настільки незручним у використанні.
Та сама схема застосовується щоразу, коли вкладки мають обрати одного власника для спільної роботи, а не лише під час завантаження великих файлів. Бекграундні черги синхронізації, засоби виведення даних поза мережею та скрипти одноразової міграції — усі вони отримують перевагу від тієї ж схеми „один переможець, багато ввічливих програшників“.