CPromise. Еще один велосипед с возможностью закрытия промисов
Хотелось бы познакомить общественность со своим новорожденным велосипедом питомцем- c-promise2 пакетом, который предоставляет расширенную версию нативного Promise (наследуется от него). Основная причина его появления — эксперименты с закрытием промисов. Большинство существующих решений рассматривают этот процесс как прекращение выполнения навешанных через then колбеков.
К моему сожалению, не так давно этой философией проникся и bluebird.js, но лично мне она не по душе ввиду нарушения контракта/идеологии промисов — обещанный асинхронный переход в одно из двух возможных состояний. Потому пришла идея запилить свое решение, у которого закрытие это старый добрый реджект со специальной ошибкой и рекурсивного вызова пользовательских обработчиков, подписанных на close событие. Простое игнорирование результата промиса, оставляя при этом в фоне ненужную задачу, потребляющую IO/RAM/CPU, это точно не наш метод, хотя на же stackoverflow он в виде костылей с Promise.race встречается весьма часто.
Кроме закрытия, сюда завезли еще:
- поддержку генераторов (как вынужденная альтернатива async функциям)
- захват прогресса выполнения
- пауза/возобновление
- таймауты
- декораторы
- поддержку AbortController (как внешнего, так и внутреннего)
- сигналы/события и propagate данных
- лимит одновременно выполняющихся промисов для all, allSettled методов, поддержка маппера/редюсера и генератора в
качестве источника промисов.
Естественно, все это работает на цепочках промисов, в том числе на вложенных, а не только на одном отдельном взятом, никаких лишних телодвижений не нужно, если цепочка из CPromise не разрывается. У каждого инстанса CPromise есть метод cancel, который через внутренний механизм сигналов просит самый «глубокий» выполняющейся промис в цепочке отклонится с ошибкой CanceledError. Всплывая по цепочке, она будет приводить к генерации события cancel на каждом промисе. По умолчанию, если не установлена force опция, закрываются только обособленные ветки/цепочки, т.е у которых нет других выполняющихся ответвлений.
import CPromise from "c-promise2"; const chain = new CPromise((resolve, reject, {onCancel}) => { const timer = setTimeout(resolve, 1000, 123); onCancel(() => clearTimeout(timer)); }) .then((value) => value + 1) .then( (value) => console.log(`Done: ${value}`), (err, scope) => { console.warn(err); // CanceledError: canceled console.log(`isCanceled: ${scope.isCanceled}`); // true console.log(`isCanceled: ${chain.isCanceled}`); // true } ); setTimeout(() => chain.cancel(), 100);
В дополнение, промис или их цепочку можно подписать на сигнал от AbortController, при чем на любое их количество
одновременно.
import CPromise from "c-promise2"; const controller = new CPromise.AbortController(); new CPromise((resolve, reject, {onCancel}) => { const timer = setTimeout(resolve, 1000, 123); onCancel(() => clearTimeout(timer)); }).listen(controller.signal); setTimeout(() => controller.abort(), 100);
У каждого промиса есть внутренний AbortSignal (создается налету по требованию), на который может подписаться внутренний пользовательский код. Например, это позволяет легко обернуть стандартный fetch в CPromise и как бонус добавить timeout (немного более проработанная версия этого кода в связке с кроссплатформенным cross-fetch пока опубликована как cp-fetch).
function fetchWithTimeout(url, {timeout, ...fetchOptions} = {}) { return new CPromise((resolve, reject, {signal}) => { fetch(url, {...fetchOptions, signal}).then(resolve, reject) }, {timeout, nativeController: true}) } const promise = fetchWithTimeout('http://localhost/', {timeout: 5000}) .then(response => response.json()) .then(data => console.log(`Done: `, data), err => console.warn(`Error: `, err))
Так как async функции работают только через нативный Promise, то все цепочки внутри такой функции придется вручную подписывать на внешний AbortSignal. Это решает только проблему их согласованного закрытия.
const controller = new CPromise.AbortController(); async function asyncMethod(x) { await CPromise.delay(1000).listen(controller.signal); await CPromise.delay(1000).listen(controller.signal); await CPromise.delay(1000).listen(controller.signal); return x + 1; }; const chain = asyncMethod(123).then( value => console.log(`Done: ${value}`), err => console.warn(`Failed: ${err}`) ); setTimeout(() => controller.abort(), 2500);
Но, так как цепочка разорвана нативными промисами, то остальные функции не будут работать. В качестве обходного решения библиотека предлагает использовать генераторы, использование которых почти ничем не отличается и всем известна, благодаря библиотеке co. С ними весь функционал будет работать. Тот же самый код, но на генераторе и уже с захватом прогресса бонусом. Вес каждого промиса при подсчете прогресса можно задавать, но по умолчанию он равен единице. Метод then также поддерживает генераторы.
function cancelableAsyncMethod(x) { return CPromise.from(function* (scope) { yield CPromise.delay(1000); yield CPromise.delay(1000); yield CPromise.delay(1000); return x + 1; }).innerWeight(3); } const chain = cancelableAsyncMethod(123) .progress(value => console.log(`Progress: ${(value * 100).toFixed(1)}%`)) .then( value => console.log(`Done: ${value}`), err => console.warn(`Failed: ${err}`) ); setTimeout(() => chain.cancel(), 2500);
Progress: 33.3 % Progress: 66.7 % Failed: CanceledError: canceled
Генераторы поддерживают немного синтаксического сахара:
- yield [promise1, promise2] — ресолвит промисы через Promise.all
CPromise.resolve().then(function* () { return yield [CPromise.delay(1000, 123), CPromise.delay(1500, 456)] }).then(v => console.log(`Done: ${v}`)); // Done: 123,456
- yield [[promise1, promise2]] — ресолвит промисы через Promise.race
CPromise.resolve().then(function* () { return yield [[CPromise.delay(1000, 123), CPromise.delay(1500, 456)]] }).then(v => console.log(`Done: ${v}`)); // Done: 123
В контексте React, живая минимальная демка с прерываемым запросом JSON может выглядеть так
(Demo)
или так (Demo)
C помощью метода progress(value, data?) пользовательский код в промисе может сообщать свой прогресс с промежуточными значениями в диапазоне [0,1]. Опционально поддерживается тротлинг, одинаковые значения игнорируются. Дополнительный параметр может использоваться для передачи метаданных, что может быть полезным для создания экранов загрузки.
import CPromise from "c-promise2"; function loadResources() { return new CPromise((resolve, reject, scope) => { let counter = 0; const timer = setInterval(() => { if (++counter === 5) { clearInterval(timer); } scope.progress(counter / 5, {description: 'loading useful resources'}); }, 100); scope.onCancel(() => clearInterval(timer)); }); } const chain = loadResources() .progress((value, scope, {description}) => { console.log(`Progress: ${(value * 100).toFixed(1)}% (${description})`) }) .then( value => console.log(`Done: ${value}`), err => console.warn(`Failed: ${err}`) );
Progress: 20.0% (loading useful resources)
Progress: 40.0% (loading useful resources)
Progress: 60.0% (loading useful resources)
Progress: 80.0% (loading useful resources)
Progress: 100.0% (loading useful resources)
CPromise имеют встроенный механизм «сигналов». Сигналы «всплывают» начиная с самого глубокого выполняющегося промиса в цепочке, пока кто-то из них не обработает сигнал, вернув с обработчика true. Закрытие промисов это тоже один из предопределенных сигналов, соответственно если очень будет нужно, то реакцию на него пользовательский код может изменить.
const chain = new CPromise((resolve, reject, scope) => { scope.on('signal', (type, data) => { if (type === 'inc') { console.log(`Signal ${type} handled`); resolve(data.x + 1); return true; } }); }).then( (value) => console.log(`Done: ${value}`), (err) => console.log(`Failed: ${err}`) ) setTimeout(() => { console.log(`Inc signal result: ${chain.emitSignal('inc', {x: 2})}`); });
Signal inc handled Inc signal result: true Done: 3
Если сигналы предназначены для передачи информации «вглубь», то метод propagate широковещательно передает ее по цепочке наружу. Через него работает механизм захвата прогресса, но можно использовать для любых других нужд.
const chain = new CPromise((resolve, reject, scope) => { setTimeout(() => { scope.propagate('userMessage', 'hello'); scope.propagate('userMessage', 'there!'); }, 0) }) chain.then( (value) => console.log(`Done: ${value}`), (err) => console.log(`Failed: ${err}`) ).on('propagate', (type, scope, message) => console.log(`Propagate type '${type}' message: '${message}'`))
Propagate type 'userMessage' message: 'hello' Propagate type 'userMessage' message: 'there!'
CPromise.allSettled( ['http://localhost/1', 'http://localhost/2', 'http://localhost/3'], { mapper: (url) => fetch(url).then((response) => response.json()), concurrency: 2 } ).then((results) => { console.log(`Done: `, JSON.stringify(results)); });
И последний значимый API — pause/resume. Позволяет приостановить выполнение цепочки. Пользовательский код может опционально подписаться на соответствующие события, чтобы приостановить долгосрочный процесс (например, приостановить выгрузку файла) и тем самым избежать больших атомарных операций в контексте промисов.
const promise = new CPromise(function (resolve, reject, {onCancel, onPause, onResume}) { onPause(() => console.log(`Pause`)); onResume(() => console.log(`Resume`)); onCancel(() => console.log(`Cancel`)); }).catch(err => console.log(`Failed: ${err}`)); setTimeout(() => promise.pause(), 1000); setTimeout(() => promise.resume(), 2000); setTimeout(() => promise.cancel(), 3000);
Пожалуй, вот и все плюшки этого зверька на данный момент :)
Update: v0.10.x: поддержка декораторов (legacy + current draft)
Простейший код компонента React:
В нем Вы не получите ошибку Warning: Can’t perform a React state update on an unmounted component. даже если unmount произойдет прямо по середине запроса.
В таком случае, CPromise в лице обертки cpFetch просто автоматически прервет запрос, хотя по сути никакого дополнительного кода мы не написали.
Демка React компонента с менеджментом асинхронного кода (вложенные запросы) через декораторы
При использовании функциональных компонентов (с помощью смежного проекта-обертки с хуками React use-async-effect2):
9 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів