
Что такое WEB Lock API ?
Представьте оживлённый перекрёсток без светофоров и знаков приоритета. Машины едут в разных направлениях, кто-то пытается проскочить первым, кто-то резко тормозит. Рано или поздно это приведёт к аварии.
Примерно так же работают современные веб-приложения: множество вкладок, фоновых процессов и асинхронных операций конкурируют за общие ресурсы, будь то отправка запроса к API, определение очередности какого-то действия или синхронизация состояния.
Долгое время разработчики обходились кустарными решениями — флагами в localStorage, хитрыми setInterval и т.д. Но с появлением Web Locks API у нас наконец появился стандартный способ расставить приоритеты в этом хаосе.
Web Lock API — это механизм, позволяющий скриптам, находящимся в рамках одного orign, блокировать доступ к ресурсу, удерживать блокировку пока выполняется необходимый код, а затем разблокировать ресурс, чтобы другие части программы могли получить к нему доступ.
Это позволяет различным контекстам выполнения (окнам, вкладкам, worker'ам) в рамках одного веб-приложения координировать очередность использования ресурса.
Основные понятия
Ресурс — это не item в localStorage, и не данные в IndexedDB. Это абстрактная сущность, которую можно представить как пространство имен, существующее в рамках одного origin. Оно содержит в себе имя ресурса, некоторые параметры и callback, вызываемый после успешного запроса на блокировку.
Блокировка (lock) — буквально ограничение доступа к ресурсу.
Имя ресурса — просто строка, выбранная непосредственно разработчиком приложения, чтобы отражать суть ресурса. Например, если мы разрабатываем аудиоплеер, которым можно управлять из разных вкладок, то именем ресурса может быть просто «auidoPlayer» или «playback».
Параметры ресурса:
mode. Может быть
exclusive
(эксклюзивный, по умолчанию) илиshared
(совместный).
Если выбран эксклюзивный тип, то все остальные запросы, не важно эксклюзивные или совместные, будут ждать пока первичный запрос не разблокирует ресурс.
Если выбран совместный тип блокировки, то все совместные запросы на блокировку ресурса будут одобрены. А эксклюзивные запросы будут ждать, пока все shared-блокировки не освободятся.ifAvailable. По умолчанию
false
. Если равенfalse
, то callback выполнится, только если блокировка возможна (ресурс никем не занят).
Еслиtrue
, то callback будет вызван, даже если блокировка невозможна, но вместо объекта Lock, в callback будет передано значениеnull
.steal. Если параметр
steal
имеет значение true, то все блокировки для ресурса будут принудительно сняты (и promise таких блокировок будет разрешены с помощью AbortError). Запрос на блокировку ресурса будет удовлетворен.signal. Позволяет отменить запрос блокировки через
AbortController
Callback — это функция , которая будет вызвана после получения запроса на блокировку.
В качестве аргумента ей передается объект Lock
.
Стоит отметить, что если параметр ifAvailable
указан как true
и блокировка невозможна, то вместо объекта Lock
будет передан null
.
Блокировка удерживается до тех пор, пока callback не завершит работу. Если передана синхронная (не-async) функция, она автоматически оборачивается в Promise, который немедленно разрешается.
Пример для ознакомления
Для работы с Web Locks API мы будет оперировать двумя функциями:
navigator.locks.request
Метод request(name, options, callback)
используется для запроса блокировки, где первый параметр представляет собой строковое имя ресурса, второй некоторые опции, а последний — callback, выполняемый после блокировки. Параметры описаны выше.
navigator.locks.query()
Метод query()
используется для получения списка всех блокировок.
Пример
Рассмотрим простой пример, демонстрирующий принцип работы.
Для начала, создадим HTML‑документ с двумя кнопками: «Заблокировать ресурс» и «Проверить статус блокировки», а затем добавим скрипт, в котором опишем 3 функции — блокировки ресурса, симуляции асинхронной операции и проверки статуса блокировки:
Скрытый текст
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Web Locks API</title>
</head>
<body>
<button onclick="lockResource()">Lock resource</button>
<button onclick="checkAvailable(RESOURCE_NAME)">Check status</button>
</body>
</html>
<script>
const RESOURCE_NAME = 'myResource';
const LOCK_TIME = 10 * 1000; // 10 seconds
function lockResource() {
console.log('%cTrying to lock resource...', 'color: yellow');
// Если ifAvailable: true, запрос на блокировку будет удовлетворен(если это возможно), а callback будет вызван не с объектом Lock, а с null
navigator.locks.request(RESOURCE_NAME, { ifAvailable: true }, async (lock) => {
if (lock !== null) {
console.log('%cRequest has been granted.', 'color: green');
await doSomething();
console.log(`%cLock ended after ${LOCK_TIME / 1000} sec.`, 'color: green')
} else {
// lock === null
console.log('%cResource was previously blocked.', 'color: red');
}
});
}
function doSomething() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Async operation ended.');
}, LOCK_TIME);
});
}
async function checkAvailable(resourceName) {
const state = await navigator.locks.query();
let isBlocked = false;
for (const lock of state.held) {
console.log(`Locked resource: name ${lock.name}, mode ${lock.mode}`);
lock.name === resourceName && (isBlocked = true);
}
console.log(`%cResource status: ${isBlocked ? 'blocked' : 'unblocked'}`, 'color: yellow')
}
</script>
Вот что получилось:
Скрытый текст

Нажатие на кнопку "Lock resource", приводит к блокировке ресурса:

Повторная эксклюзивная блокировка ресурса с одним названием невозможна, пока не завершилась предыдущая(callback метода navigator.locks.request).

Теперь проверим на нескольких вкладках: на 1-й заблокируем ресурс, а на 2-й кликнем по кнопке «Check status»:


Как видно из примера, на 2-й вкладке состояние ресурса отображается как «заблокирован».
Код в песочнице.
Практический пример
В качестве практического примера можно рассмотреть добавление товаров в корзину на маркетплейсе. Представим, что у нас очень плохое соединение (эмулируем задержку в 5 сек.) и мы не блокируем кнопку добавления на время выполнения запроса.
В случае, если пользователь нажмет кнопку несколько раз, то в корзину добавится несколько экземпляров товара. А если наш пользователь вдобавок обладает недюжинной скоростью и реакцией, он может отправить один и тот же товар в корзину практически одновременно, но с разных вкладок.
При использовании Web Locks API мы можем избежать дублирования товара, управляя очередностью добавления в корзину.
Как и в предыдущем примере, сначала создадим HTML-документ. Корзина будет представлена простым div
'ом, отображающим количество товаров. Так же, не забудем разместить кнопку для добавления (код примера в песочнице):
Скрытый текст
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Web Locks API</title>
</head>
<body>
<div id="cart">Items in cart: </div>
<button onclick="addToCart()">Add to cart</button>
</body>
</html>
Затем добавим тег script и несколько строчек наверх:
const RESOURCE_NAME = 'shoppingCart';
const PING = 5 * 1000; // 5 seconds
// Get initial cart state and update cart element
updateCartElement();
// Add listener to storage event to update cart element immediately
window.addEventListener('storage', ({ key }) => {
key === 'cartItems' && updateCartElement();
});
Функция updateCartElement
будет обновлять состояние div
'а c id="cart"
. Опишем ее логику чуть позже, но отмечу, что хранить товары мы будет в LocalStorage и, соответственно, брать оттуда же.
Так же, стоит обратить внимание на addEventListener
, с параметром storage
. Если у нас открыто несколько вкладок, то отображаемое количество товара стоит обновить на всех.
Добавим остальной код ниже и откроем страницу:
// Get cart state from LS
function getCartState () {
return JSON.parse(localStorage.getItem('cartItems')) || [];
}
// Update cart element state
function updateCartElement () {
const element = document.querySelector('#cart');
element.innerHTML = `Items in cart: ${getCartState().length}`;
console.log('%cCart has been updated.', 'color: green');
}
// Simulate slow network
function sendDataOverSlowNetwork () {
return new Promise((resolve) => {
setTimeout(() => {
const newState = getCartState();
newState.push({ name: 'good' });
localStorage.setItem('cartItems', JSON.stringify(newState));
resolve('Data has been sent.')
}, PING);
})
}
// Add item to cart
function addToCart () {
navigator.locks.request(
RESOURCE_NAME,
{ ifAvailable: true },
async (lock) => {
console.log('%cTrying to lock resource...', 'color: yellow');
if (lock !== null) {
console.log('%cRequest has been granted.', 'color: yellow');
console.log('%cSending to server...', 'color: green');
await sendDataOverSlowNetwork()
.then(res => console.log('%c' + res, 'color: green'))
.then(updateCartElement);
console.log(`%cLock ended after ${PING / 1000} sec.`, 'color: yellow')
} else {
// lock === null
console.log('%cResource was previously blocked.', 'color: red');
}
});
}
Страница выглядит так:

Попробуем что-нибудь добавить:

После "успешной отправки на сервер", количество товаров в корзине увеличилось на 1.
Но что будет, если открыть несколько вкладок? Осторожно, гифка.
Скрытый текст

Как видно, блокировка повторных запросов работает; количество товара обновляется на всех вкладках практически синхронно.
Необходимое уточнение: если убрать ifAvailable
в методе request
, то запрос на блокировку будет создан, даже если предыдущий запрос еще не завершил работу — каждый новый запрос просто станет в очередь.
Получится такая ситуация(осторожно, гифка):
Скрытый текст

Способы синхронизации и обмена данными
Web Locks API можно использовать для организации очередности доступа к ресурсам в рамках одного origin. Для полноты картины хотелось бы кратко отметить существующие способы синхронизации и обмена данными между вкладками, которые можно использовать как отдельно, так и в паре с Web Locks API.
localStorage и storage-event
В предыдущем примере мы уже встречались с событием storage
. Там мы изменяли локальное хранилище в одной вкладке, и благодаря возникновению события storage
в других вкладках, синхронизировали состояние корзины.
Другими словами, storage-event это событие, которое срабатывает при изменении localStorage в других вкладках/окнах того же origin. Благодаря этому мы можем «отслеживать» эти изменения (код в песочнице):
Скрытый текст
setTimeput(() => localStorage.setItem('localKey', 'data'), 2500); // Вносим изменения
// Отслеживаем изменения в других вкладках
window.addEventListener('storage', ({ key, newValue }) => {
console.log('key', key);
console.log('newValue', newValue);
});
Важное уточнение: с sessionStorage так не получится. Он изолирован для каждой вкладки. Поэтому, если в коде предыдущего примера заменить localStorage.setItem
на sessionStorage.setItem
, то событие storage
никогда не возникнет. А изменения будут видны только в той вкладке, где происходил вызов метода.
BroadcastChannel API
BroadcastChannel API — это механизм для обмена сообщениями между разными вкладками, iframe, worker'ами одного и того же origin. Как и storage-event, может использоваться для синхронизации состояния приложения. Работает по простому принципу вещания события и подписки на это событие. Подписка на событие НЕ сработает на той вкладке, откуда отправляется сообщение.
Пример(код в песочнице):
Скрытый текст
<button onclick="sendMessage()">Send message</button>
<script>
const CHANNEL_NAME = 'testChannel';
// Создаем канал. Все вкладки подключаются к каналу с именем, указанном в конструкторе
const channel = new BroadcastChannel(CHANNEL_NAME);
// Функция для отправки сообщения
function sendMessage() {
channel.postMessage('Hello, World!'); // Так же можно отправить объект
}
// Подписываемся на все сообщения для этого канала
channel.addEventListener('message', (e) => {
console.log('message', e.data);
});
</script>
Если канал больше не нужен его можно закрыть, вызвав channel.close()
.
Как мы видим, в целом, BroadcastChannel очень похож на LocalStorage с storage-event. Но есть отличия: при помощи BroadcastChannel можно пересылать и строки и объекты и blob, но в отличие от LocalStorage, это не требует дополнительной обработки в виде сериализации. Плюс, концепция локального хранилища это скорее «хранение», а не «вещание».
Другие способы
Существуют и другие способы синхронизации и обмена данными. Приводить их в этой статье я не буду: на мой взгляд она и так оказалась слишком большой. Но тем не менее, их стоит упомянуть: WebSockets, Service Worker, IndexedDB.
Итого
Веб давно перестал быть набором статичных страниц. Современные приложения — это сложные системы с фоновой синхронизацией, параллельными процессами и работой в нескольких вкладках. Именно здесь Web Locks API проявляет себя, предлагая решение проблем, которые раньше требовали создания костылей. Его можно применять для для организации очередности доступа к ресурсам, синхронизации данных, отправки сообщений другим контекстам т.д.
Простое, но мощное решение для современных веб-приложений.