Владислав Власов, инженер-программист в Developer Soft и преподаватель курса Нетологии, специально для блога написал цикл статей о EcmaScript6. В первой части на примерах рассмотрели динамический анализ кода в EcmaScript с помощью Iroh.js. В этой статье расскажем, как реализовать отменяемые Promises.
Программа обучения: «Профессия frontend разработчик»
Асинхронность и планировщик событий в EcmaScript
Концепция Promise (обещаний) — одна из ключевых в современном EcmaScript. Promise позволяют обеспечить последовательное выполнение асинхронных действий за счет организации их в цепочки, которые вдобавок предоставляют перехват ошибок. Современный синтаксис async/await операторов технически также основан на Promise, и является лишь синтаксическим сахаром.
Синтаксический сахар (англ. syntactic sugar) в языке программирования — это синтаксические возможности, применение которых не влияет на поведение программы, но делает использование языка более удобным для человека. — Википедия.
Однако при всей своей богатой функциональности, Promise обладают одним недостатком — не поддерживают возможность отмены запущенного действия. Для того чтобы понять, как обойти это ограничение, необходимо рассмотреть, как возникают и функционируют асинхронные действия в EcmaScript, ведь Promise — лишь обертка для них.
Движок языка EcmaScript, будь это V8 или Chakra, является однопоточным, и позволяет в один момент времени выполнять только одно действие. В браузерной среде довольно современные движки поддерживают технологию WebWorkers, а в node.js можно создать отдельный дочерний процесс, и это позволит параллелизировать выполнение кода. Однако созданный поток исполнения — это независимый процесс, который может обмениваться информацией с создавшим его потоком только посредством сообщений, так что это сама по себе не многопоточная модель.
Вместо этого, традиционный EcmaScript основывается на модели мультиплексирования: чтобы выполнить несколько действий параллельно, они разбиваются на небольшие фрагменты, каждый из которых выполняется относительно быстро и никогда не блокирует поток исполнения. За счет перемешивания таких фрагментов, действия, ассоциированные с ними, фактически выполняются параллельно.
Так как пользовательский код и функции хост-среды, такие как рендеринг визуального интерфейса (UI) веб-страницы, выполняются в одном и том же потоке, то, к примеру, долгий или бесконечный цикл в пользовательском коде приводит к приостановке действий по рендерингу веб-страницы и ее зависанию. Для разделения отрезков времени, в которые будут выполняться те или иные фрагменты кода, применяется планировщик событий — event loop. Каким же образом может возникнуть исполняемый фрагмент в event loop?
Обычный клиентский код выполняет только последовательный набор действий, состоящий из потока исполнения с условиями, циклами и вызовами функций. Для того чтобы осуществить отложенное исполнение, необходимо зарегистрировать клиентскую функцию обратного вызова в хост-среде.
В браузерной среде это сводится, как правило, к одной из трех возможностей: таймеры, события и асинхронные запросы к ресурсам. Таймеры обеспечивают вызов функции по истечении времени (setTimeout), в первом свободном слоте в планировщике событий (setImmediate) или же даже в процессе отрисовки веб-страницы (requestAnimationFrame). События — это реакция на произошедшее действие, как правило, в DOM-модели, и могут инициироваться как пользователем (событие: щелчок по кнопке), так и внутренними процессами отображения UI-элементов (событие: пересчет стилей завершен). В отдельную категорию вынесены запросы к ресурсам, но в действительности они относятся к событиям, с той лишь разницей, что изначальным инициатором является сам клиентский код.
Это наглядно показано на схеме ниже:
Обертка асинхронный действий
Далее важно рассмотреть, как вышеуказанные асинхронные действия оборачиваются в Promise. Для того чтобы затронуть максимальное количество аспектов для отмены Promise, следующий код будет сочетать использование таймеров, событий DOM-модели и произвольного клиентского кода, который связывает их. Пример предполагает выполнение AJAX-запроса, возвращающего большой объем данных в CSV-формате, и последующую обработку в потенциально медленной функции в построчном виде для предотвращения зависания основного потока.
function fetchInformation() {
function parseRow(rawText) {
/* Some function for row parsing which works very slow */
}
const xhrPromise = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(‘GET’, ‘…/some.csv’); // API endpoint URL with some big CSV database
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(String(xhr.response));
} else {
reject(new Error(xhr.status));
}
};
xhr.onerror = () => {
reject(new Error(xhr.status));
};
xhr.send();
});
const delayImpl = window.setImmediate ? setImmediate : requestAnimationFrame;
const delay = () => new Promise(resolve => delayImpl(resolve))
const parsePromise = (response) => new Promise((resolve, reject) => {
let flowPromise = Promise.resolve();
let lastDemileterIdx = 0;
let result = [];
while(lastDemileterIdx >= 0) {
const newIdx = response.indexOf(‘\n’, lastDemileterIdx);
const row = response.substring(
lastDemileterIdx,
(newIdx > -1 ? newIdx — lastDemileterIdx : Infinity)
);
flowPromise = flowPromise.then(() => {
result.push(parseRow(row));
return delay();
});
lastDemileterIdx = newIdx;
}
flowPromise.then(resolve, reject);
});
return xhrPromise.then(parsePromise);
}
В качестве события DOM-модели используется успешное или ошибочное завершение AJAX-запроса, а таймеры обеспечивают последовательную порционную обработку большого объема данных, чтобы предоставить рабочее время UI-потоку. Легко заметить, что с внешней точки зрения такой Promise представляет собой монолитный элемент, на завершении которого вызывающей стороне доступна обработанная база данных в надлежащем формате, или же описание ошибки, если в процессе выполнения произошел сбой.
С точки зрения вызывающей стороны удобно иметь возможность отмены такого Promise, как единого целого. Например, в случае если пользователь закрыл визуальный элемент, которому требовались эти данные для отображения. Однако, с точки зрения внутреннего строения, Promise представляет собой набор синхронных и асинхронных действий, часть из которых возможно уже запущена и завершена. Поскольку эту последовательность определяет произвольный клиентский код, этапы экстренного завершения также должны быть описаны мануальным образом.
Реализация отменяемого Promise
Важно помнить, что прерывание синхронного кода, такого как циклы, не может произойти в принципе, поскольку если код уже выполняется (а движок EcmaScript — однопоточный), то в этот момент не может исполняться никакой другой код, который бы осуществил его прерывание. Таким образом, завершения требуют только действительно асинхронные действия, описанные выше: таймеры, события и обращения к внешним ресурсам.
Функции установки таймеров обладают дуальными операциями для их отмены: clearTimeout, clearImmediate и cancelAnimationFrame соответственно. Для событий DOM-модели достаточно удалить подписку на соответствующую функцию обратного вызова. Также для таймеров можно воспользоваться более простым подходом — предварительно обернуть их в Promise-объект, имеющий мануальный isCancelled-флаг. Если по истечении таймера Promise должен быть отменен, то функция обратного вызова просто не выполняется. В таком случае таймер остается в планировщике, но в случае отмены по его окончании ничего не происходит.
В случае обращения к внешним ресурсам ситуация более сложная: в любом случае можно игнорировать результат операции, выполнив отписку от соответствующего события, но прервать саму операцию не всегда возможно. С точки зрения логики выполнения Promise, это может быть несущественно, однако непрерванная операция потребляет излишние ресурсы.
В частности, метод fetch, призванный на замену классическому XMLHttpRequest для проведения AJAX-запросов, и обеспечивающий сразу возврат Promise-объекта без необходимости дополнительной обертки, не позволяет выполнить отмену запроса. По этой причине для реальной отмены HTTP-запроса необходимо использовать метод abort в объекте XMLHttpRequest.
Итоговый код с поддержкой отмены Promise может выглядеть следующим образом. Для лучшей наглядности показан только изменившийся код, а старый заменен комментарием с многоточием.
function fetchInformation() {
/* … */
let isCancelled = false;
let xhrAbort;
const xhrPromise = new Promise((resolve, reject) => {
/* … */
xhrAbort = xhr.abort.bind(xhr);
});
const delayImpl = window.setImmediate ? setImmediate : requestAnimationFrame;
const delay = () => new Promise((resolve, reject) =>
delayImpl(() => (!isCancelled ? resolve(): reject(new Error(‘Cancelled’))))
);
/* … */
const promise = xhrPromise.then(parsePromise);
promise.cancel = () => {
try { xhrAbort(); } catch(err) {};
isCancelled = true;
}
return promise;
}
Поскольку Promise — это обычный объект с точки зрения EcmaScript, то метод cancel легко может быть добавлен в него. Также, поскольку во внешнюю среду возвращается только один результирующий Promise-объект, то метод cancel добавляется только для него, а вся внутренняя логика инкапсулирована в текущем лексическом блоке генерирующей функции.
Итоги
Реализация отменяемого Promise в EcmaScript — сравнительно несложная задача, которая может быть легко выполнена даже для асинхронной цепочки, имеющей внутри нетривиальную логику последовательных вызовов: за счет сохранения флага отмены в объектах и активационных контекстов генерирующих функций. Отмена может быть как поверхностной, когда Promise прерывается с ошибкой и не производит выполнение сторонних эффектов, так и глубокой, когда все инициированные асинхронные операции (таймеры, обращения к внешним ресурсам и прочие) действительно отменяются.
Ключевой аспект отменяемых Promise — необходимость полной мануальной реализации операции отмены. Она не может быть достигнута автоматически, например, за счет реализации собственного класса Promise. Теоретически задача может быть решена при выполнении кода в виртуальной машине, при котором будут записываться все асинхронные действия, инициированные в стеке инициализации Promise и зависимых then-ветках, но это довольно нетривиальная в реализации и малополезная на практике задача.
Читать ещё: «Как правильно оформлять код»
Таким образом, отменяемые Promises в EcmaScript — это всего лишь интерфейсное соглашение, позволяющее прерывать и отменять эффекты от Promise, представляющие собой инкапсулированные цепочки логических действий. В общем же случае концепции отменяемости не существует.
Мнение автора и редакции может не совпадать. Хотите написать колонку для «Нетологии»? Читайте наши условия публикации.