Обробка асинхронних завдань може бути проблемною, особливо якщо мова програмування, з якою ви працюєте, не підтримує такий механізм. На щастя, JavaScript підтримує. Розглянемо його детальніше, а для закріплення інформації створимо власну функцію скасування асинхронних завдань.
Abort Signal
Потреба скасовувати асинхронні таски виникла, щойно в ES2015 з'явився об'єкт Promise
, а також після появи декількох Web API з підтримкою асинхронності. Спочатку розробники прагнули створити універсальний механізм, який пізніше міг би потрапити до стандарту ECMAScript. Однак це загальне рішення знайти так і не вдалося.
Саме тому на допомогу приходить WHATWG з власною пропозицією — AbortController
, який працює на базі DOM. Очевидний недолік — AbortController
не функціонує в Node.js, тобто все середовище залишається без офіційного механізму скасування асинхронних завдань.
У специфікації DOM AbortController
описаний досить загально. Зважаючи на це, ви можете використовувати його у будь-якому асинхронному API, навіть неофіційному. На момент написання матеріалу лише Fetch API офіційно підтримував механізм скасування, але ніщо не заважає вам застосувати AbortController
у власних рішеннях.
Перш ніж ми перейдемо до власної реалізації скасування, розглянемо детальніше принцип роботи AbortController
:
const abortController = new AbortController(); // 1
const abortSignal = abortController.signal; // 2
fetch( 'https://web.archive.org/web/20230605173144/http://example.com', {
signal: abortSignal // 3
} ).catch( ( { message } ) => { // 5
console.log( message );
} );
abortController.abort(); // 4
У фрагменті коду вище ми створюємо екземпляр AbortController
(1) та записуємо його властивість signal
у створену змінну (2). Далі ми виконуємо fetch()
і передаємо signal
як один із параметрів (3). Аби скасувати завантаження ресурсу, просто викликаємо abortController.abort()
(4). Так ми автоматично відхиляємо проміс fetch()
і управління переходить до блоку catch()
(5).
Саме властивість signal
і є головним героєм нашого матеріалу. Це екземпляр AbortController
інтерфейсу DOM, який має властивість aborted
з інформацією про виклик методу abortController.abort()
. Ви також можете створити слухача події abort
, який очікує виклику abortController.abort()
. Тобто AbortController
— це лише публічний інтерфейс для AbortSignal
.
Функція зі скасуванням
Уявімо, що у нас є асинхронна функція, яка виконує дуже складні обчислення (наприклад, асинхронно обробляє дані великого масиву). Для нашого прикладу тестова функція буде симулювати важкі обчислення за допомогою таймера, який повертатиме результат через 5 секунд:
function calculate() {
return new Promise( ( resolve, reject ) => {
setTimeout( ()=> {
resolve( 1 );
}, 5000 );
} );
}
calculate().then( ( result ) => {
console.log( result );
} );
Можливо, розробник захоче скасувати таку дорогу з погляду обчислень операцію — бажання цілком виправдане. Додамо кнопку, що починатиме та зупинятиме обчислення:
<button id="calculate">Calculate</button>
<script type="module">
document.querySelector( '#calculate' ).addEventListener( 'click', async ( { target } ) => { // 1
target.innerText = 'Зупинити обчислення';
const result = await calculate(); // 2
alert( result ); // 3
target.innerText = 'Обчислити';
} );
function calculate() {
return new Promise( ( resolve, reject ) => {
setTimeout( ()=> {
resolve( 1 );
}, 5000 );
} );
}
</script>
У фрагменті коду ми додали асинхронний слухач події click
для кнопки (1) та викликали там функцію calculate()
(2). За 5 секунд має з'явитися діалогове вікно з результатом (3). Додатково використовується атрибут script[type=module]
, аби JavaScript виконувався у суворому режимі. На думку автора, це більш елегантний спосіб, ніж директива "use strict"
.
Нарешті сам механізм скасування завдання:
{ // 1
let abortController = null; // 2
document.querySelector( '#calculate' ).addEventListener( 'click', async ( { target } ) => {
if ( abortController ) {
abortController.abort(); // 5
abortController = null;
target.innerText = 'Обчислити';
return;
}
abortController = new AbortController(); // 3
target.innerText = 'Зупинити обчислення';
try {
const result = await calculate( abortController.signal ); // 4
alert( result );
} catch {
alert( 'Навіщо ви це зробили?!' ); // 9
} finally { // 10
abortController = null;
target.innerText = 'Обчислити';
}
} );
function calculate( abortSignal ) {
return new Promise( ( resolve, reject ) => {
const timeout = setTimeout( ()=> {
resolve( 1 );
}, 5000 );
abortSignal.addEventListener( 'abort', () => { // 6
const error = new DOMException( 'Обчислення скасоване користувачем', 'AbortError' );
clearTimeout( timeout ); // 7
reject( error ); // 8
} );
} );
}
}
Як бачимо, наш код стає дедалі довшим, однак залишається легким для розуміння.
Блок коду (1) працює подібно до IIFE. Тут змінна AbortController
(2) не потрапляє в глобальну область видимості.
Спершу ми ініціалізуємо її як null
. Це значення змінюється після кліку на кнопку: створюється новий екземпляр AbortController
(3). Далі передаємо властивість signal
щойно створеного екземпляра одразу до нашої функції calculate()
(4).
Якщо користувач натисне на кнопку ще раз, перш ніж мине 5 секунд, виконається abortController.abort()
(5), тобто в екземпляра AbortSignal
, який ми передали до calculate()
(6), відбувається подія abort
.
Всередині слухача події abort
ми очищаємо таймер (7) та відхиляємо проміс з відповідною помилкою (8). Згідно зі специфікацією, це має бути DOMException
з типом AbortError
. Оскільки ми явно повернули помилку, управління передається до блоків catch
та finally
(10).
Ви також повинні підготувати код для обробки подібної ситуації:
const abortController = new AbortController();
abortController.abort();
calculate( abortController.signal );
У такому випадку подія abort
не буде оброблена, оскільки вона виникає до того, як signal
передається функції calculate()
. Зарефакторимо наш код, аби виправити це:
function calculate( abortSignal ) {
return new Promise( ( resolve, reject ) => {
const error = new DOMException( 'Обчислення скасоване користувачем', 'AbortError' ); // 1
if ( abortSignal.aborted ) { // 2
return reject( error );
}
const timeout = setTimeout( ()=> {
resolve( 1 );
}, 5000 );
abortSignal.addEventListener( 'abort', () => {
clearTimeout( timeout );
reject( error );
} );
} );
}
Тут ми виносимо помилку на початок області видимості (1). Тепер ми можемо повторно використати її у двох різних частинах коду (все ж розумніше було б просто створити «Фабрику помилок»). Ми також додаємо перевірку значення abortSignal.aborted
(2). Якщо значення true
, то функція calculate()
відхиляє проміс з відповідною помилкою без виконання додаткових кроків.
Ось і все. Ми щойно створили асинхронну функцію з підтримкою механізму скасування. Результат за посиланням.
Ще немає коментарів