Недостатки JavaScript, основанного на событиях
Для большинства сред исполнения язык JavaScript может похвастаться большим количеством API, основанных на событиях. Это не самое удивительное развитие языка, учитывая, что JavaScript в основном используется для оркестровки динамических пользовательских интерфейсов (которые сами по себе являются событийно-ориентированными).
Множество крючков жизненного цикла
Событийно-ориентированные паттерны проектирования неизбежно просочились в ранние асинхронные API (т.е. в эпоху до Promise
). Заметной общей чертой этих API является обилие явных крючков жизненного цикла. Эти события обычно имеют форму before
—during
—after
. Сначала всегда происходит before
(для установки), затем ноль или более вызовов during
(для выполнения), и, наконец, after
(для очистки).
Рассмотрим API XMLHttpRequest
для примера. До появления обещаний и Fetch API, XMLHttpRequest
API полагался на крючки жизненного цикла для асинхронного уведомления приложения JavaScript.
Ветераны-разработчики знакомы со спагетти из событий жизненного цикла: load
, progress
, error
, и timeout
среди многих других. Одно дело — подключиться к событиям, но совсем другое — выяснить точный порядок их выполнения по мере роста взаимосвязанного состояния.
Не обработанные отказы обещания
Когда API Promise
стал общедоступным, также стало очевидно, что многие API, основанные на событиях, принципиально несовместимы с современной асинхронной моделью.
В частности, необработанные отказы обещаний произвели большой фурор в экосистеме Node.js. Раньше, когда синхронные обратные вызовы событий бросали исключения, класс EventEmitter
поглощал исключение и повторно передавал его через событие error
(по соглашению).
Проблема возникает, когда вместо этого используется асинхронный обратный вызов. Напомним, что функции async
возвращают отклоненные обещания, когда в теле функции возникает исключение. Само исключение фактически не распространяется, как это обычно происходит в синхронном контексте. Единственный способ обработать ошибку (должным образом) — это предоставить обработчик Promise#catch
. В противном случае исключение останется необработанным — даже внутри блоков try
—catch
!
async function boom() {
throw new Error('Boom!');
}
try {
// Since we do not `await` for `boom`,
// the rejected promise remains unhandled.
boom();
} catch (err) {
// This `catch` block will never run!
process.exit();
}
console.log('This line will run.');
Поскольку большинство реализаций использовали блоки try
—catch
для повторной публикации исключения в виде события error
, необработанные отказы обещаний создавали лазейку в экосистеме обработки ошибок. То есть, выброс исключений внутри функций async
никогда не вызывал событий error
.
// Hypothetical Implementation of Event Dispatch
import { getEventListeners } from 'node:events';
try {
// Invoke callback with some data. Notably, we
// do not `await` the listener. So, if the handler
// happens to be an `async` function, all rejected
// promises will not be caught.
for (const listener of getEventListeners('something'))
listener(data);
} catch (err) {
// In case an error is thrown, we re-emit it.
// Note that this is never invoked for `async`
// callback functions.
emitter.emit('error', err);
}
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
emitter.on('error', () => {
// This will never be invoked...
process.exit();
});
emitter.on('something', async () => {
// Promise rejection inside `async` context!
throw new Error('Oops!');
});
// Rejected promises do not invoke
// the `error` handler by default.
emitter.emit('something');
В настоящее время Node.js исправляет это неожиданное поведение с помощью опции captureRejections
. Если она установлена, модуль events
будет пересылать внутреннее исключение отклоненного обещания в соответствующее событие error
. По сути, патч устанавливает обработчик Promise#catch
для всех функций обратного вызова async
. Автоматически установленный обработчик обрабатывает причудливое распространение события error
для пользователя.
Более постоянное решение было введено в Node 15, где все необработанные отказы обещаний теперь будут рассматриваться как необработанные исключения по умолчанию. Это поведение может быть настроено, но делать это, как правило, не рекомендуется.
Неэргономичные API: Обратные вызовы по всему пути вниз
Одним из самых печально известных событийно-ориентированных API является IndexedDB
. API IndexedDB
, созданный по образцу реальных взаимодействий с базами данных, предоставляет асинхронный API запрос-ответ для чтения и хранения произвольно структурированных данных (включая файлы и блобы) в браузере.
К сожалению, поскольку API IndexedDB
предшествовал API Promise
, интерфейс запроса-ответа в значительной степени полагался на обратные вызовы событий success
и error
. Общая идея заключается в том, что вызов базы данных возвращает хэндл запроса к этой асинхронной операции. Затем приложение прикрепляет к этому хэндлу запроса слушатель success
, который впоследствии предоставляет доступ к полученному ответу.
Однако по мере увеличения количества зависимых запросов можно представить, что API непреднамеренно требует обратных вызовов внутри обратных вызовов после обратных вызовов на случай, если обратный вызов не сработает… Действительно, это ад обратных вызовов снова стучится в дверь.
// An exagerrated example of callback hell...
const options = { passive: true, once: true };
window.indexedDB.open('users', 1)
.addEventListener('success', evt0 => {
const db = evt0.target.result;
const store = db.createObjectStore();
store.add({ name: 'World' }, 'Hello')
.addEventListener('success', evt1 => {
store.add({ name: 'Pong' }, 'Ping')
.addEventListener('success', evt2 => {
// ...
}, options);
}, options);
}, options);
В ожидании новых обещаний
В идеале лучшим решением является доступная библиотека-обертка «обещанного» события. Однако, когда нам приходится создавать собственные обертки, есть несколько трюков и шаблонов, которые мы можем использовать, чтобы события и обещания лучше взаимодействовали друг с другом.
Нашим основным инструментом будет сам конструктор Promise
. Напомним, что конструктор принимает единственный аргумент: обратный вызов с двумя аргументами (условно названными resolve
и reject
). Обратный вызов должен вызвать либо resolve
, либо reject
, чтобы выполнить хэндл Promise
.
ПРИМЕЧАНИЕ: Для краткости в этой статье предполагается, что читатель уже знаком с использованием конструктора
Promise
.
С учетом сказанного, ключевым моментом является вызов обратного вызова resolve
внутри слушателя событий (или в качестве самого слушателя событий). При этом обещание выполняется, когда срабатывает событие.
Давайте рассмотрим практический пример. Предположим, что мы хотим, чтобы наш сценарий запускался после события DOMContentLoaded
. Затем сценарий открывает соединение WebSocket
, которое запускает дальнейший код только при срабатывании события open
. Без обещаний типичная структура кода требует вложенных обратных вызовов.
const options = { passive: true, once: true };
document.addEventListener('DOMContentLoaded', () => {
const ws = new WebSocket('wss://example.com');
ws.addEventListener('open', () => {
// ...
console.log('Ready!');
}, options);
}, options);
С помощью умного использования конструктора Promise
можно сгладить код так, что он станет выполняться сверху вниз.
/** When awaited, this function blocks until the `event` fires once. */
function blockUntilEvent(target: EventTarget, event: string) {
return new Promise(resolve => target.addEventListener(
event,
resolve,
{
// For simplicity, we will assume passive listeners.
// Feel free to expose this as a configuration option.
passive: true,
// It is important to only trigger this listener once
// so that we don't leak too many listeners.
once: true,
},
));
}
// Execution is blocked until the listener is invoked.
await blockUntilEvent(document, 'DOMContentLoaded');
// Blocked again until the connection is open.
const ws = new WebSocket('wss://example.com');
await blockUntilEvent(ws, 'open');
// ...
console.log('Ready!');
Доказательство концепции: Асинхронные генераторы с событиями
Используя наш примитив blockUntilEvent
(который инкапсулирует паттерн ожидания новых обещаний), можно также преобразовать потокоподобные события в асинхронные генераторы.
/** Waits for multiple message events indefinitely. */
async function* toStream(target: EventTarget, event: string) {
while (true)
yield await blockUntilEvent(target, event);
}
Давайте вернемся к нашему предыдущему примеру. Напомним, что API WebSocket
испускает событие message
(после open
) для каждого нового сообщения, которое получает соединение. Утилита toStream
позволяет нам прослушивать события message
, как будто мы просто итерируем их.
for await (const message of toStream(ws, 'message')) {
// Stream of `message` events...
}
Аналогичным образом мы можем рассматривать события click
для различных элементов HTML как потоки.
for await (const click of toStream(document.body, 'click')) {
// Stream of `click` events...
}
ДИСКЛЕЙМЕР: Важно отметить, что семантически это не эквивалентно использованию обычных старых слушателей. Напомним, что утилита
blockUntilEvent
регистрирует одноразовый слушатель. УтилитаtoStream
немного неэффективна, потому что она многократно вызываетblockUntilEvent
внутри, тем самым регистрируя множество одноразовых слушателей вместо одного слушателя.
Прикладной пример с WebRTC
Теперь мы применим описанные выше техники к примеру рукопожатия WebRTC. К счастью, WebRTC — это относительно современный API, который использует обещания везде, где только можно. Когда необходим поток событий, API вызывает слушателей событий.
Если говорить коротко, то ниже описаны шаги, описывающие базовое рукопожатие WebRTC. Некоторые детали были опущены для краткости.1
- Дождаться загрузки DOM (т.е. события
DOMContentLoaded
).2 - Запросите у пользователя устройство камеры.
- Открыть
WebSocket
соединение с сигнальным сервером (т.е. событиеopen
). - Добавить медиадорожки из какого-либо элемента
<video>
. - Дождаться готовности
RTCPeerConnection
(т.е. событияnegotiationneeded
) для создания предложения. - Отправьте предложение на сервер сигнализации (через соединение
WebSocket
). - Дождитесь ответа сервера сигнализации.
- Завершите квитирование.
- Установите предложение в качестве локального описания.
- Установите ответ в качестве удаленного описания.
Обратите внимание, что протокол квитирования и сигнализации может быть довольно сильно вовлечен в события, обещания и асинхронное выполнение. Очень важно, чтобы сохранялся точный порядок (чтобы наша внутренняя часть не запуталась).
Обещания позволяют выразить строгие требования, которые мы предъявляем к порядку выполнения асинхронного кода. Никаких вложенных обратных вызовов!
// Wait for the page to load before requesting camera access
await blockUntilEvent(document, 'DOMContentLoaded');
const video: HTMLVideoElement = document.getElementById('screen');
const media = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
// Open the WebSocket connection for signalling
const ws = new WebSocket('wss://example.com');
await blockUntilEvent(ws, 'open');
// Set up the video stream
const peer = new RTCPeerConnection();
for (const track of media.getVideoTracks())
peer.addTrack(track, media);
// Only create an offer once it is ready
await blockUntilEvent(peer, 'negotiationneeded');
const offer = await peer.createOffer();
ws.send(JSON.stringify(offer));
// Now we wait for the WebSocket connection
// to respond with a WebRTC answer
const { data } = await blockUntilEvent(ws, 'message');
const answer = JSON.parse(data);
// TODO: Set up `icecandidate` event listeners for sending
// new ICE candidates to the remote peer. This is beyond
// the scope of the article.
// TODO: Set up `message` event listener on the `WebSocket`
// connection for receiving new ICE candidates from the remote
// peer. This is also beyond the scope of the article.
// Finish the initial handshake
await peer.setLocalDescription(offer);
await peer.setRemoteDescription(answer);
Заключение
Чаще всего обещания и события несовместимы друг с другом. К счастью, есть способы преодолеть этот разрыв.
Наш примитив blockUntilEvent
позволяет нам разрешать обещание всякий раз, когда срабатывает событие (не более одного раза). Уже одно это обеспечивает несколько улучшений качества жизни по сравнению с необработанными обратными вызовами событий:
- Меньше глубоко вложенных обратных вызовов.
- Меньше явных крючков жизненного цикла (следовательно, меньше многословного кода для управления состоянием).
- Более тонкий контроль над порядком выполнения чередующихся событий и обещаний.
- Улучшенная читаемость асинхронного выполнения сверху вниз.
Следует подчеркнуть, однако, что эти улучшения в основном относятся к одноразовым событиям (таким как open
, DOMContentLoaded
и т.д.). Когда необходим поток событий (например, в событиях message
), все равно лучше предпочесть обычные старые слушатели событий. Просто сложнее (и довольно неэффективно) реализовать потоковые события через наш примитив blockUntilEvent
. Однако для небольших приложений затраты могут быть в любом случае незначительными.
В заключение следует отметить, что обещания и события действительно могут сосуществовать.
-
А именно, мы пока оставляем механизм обмена кандидатами ICE нереализованным.
-
Это гарантирует, что элемент
<video>
уже был разобран браузером. Технически, в этом нет необходимости из-за атрибутаdefer
. Тем не менее, для наглядности мы ждем событияDOMContentLoaded
.