Щоб продемонструвати використання колбеків, промісів та інших абстрактних понять, ми будемо використовувати деякі методи браузера: зокрема, завантажувати скрипти та виконувати прості маніпуляції з документом.
Якщо ви не знайомі з цими методами, і їх використання в прикладах викликає заплутаність, ви можете прочитати кілька розділів з наступної частини підручника.
Хоча ми все одно спробуємо все прояснити. У браузері не буде нічого складного.
Багато функцій надаються середовищами JavaScript, які дозволяють планувати асинхронні дії. Тобто дії, які ми починаємо зараз, але закінчуємо пізніше.
Наприклад, однією з таких функцій є setTimeout
.
Є й інші реальні приклади асинхронних дій, наприклад завантаження скриптів і модулів (ми розглянемо їх у наступних розділах).
Розгляньмо функцію loadScript(src)
, яка завантажує скрипт із заданим src
:
function loadScript(src) {
// створює тег <script> і додає його до сторінки
// це призводить до того, що скрипт із заданим src починає завантажуватися, і після завершення він запускається
let script = document.createElement('script');
script.src = src;
document.head.append(script);
}
Вона вставляє в документ новий, динамічно створений тег <script src="…">
із заданим src
. Браузер автоматично почне завантажувати його, після завершення чого запустить.
Ми можемо використовувати цю функцію таким чином:
// завантажує та виконує скрипт за заданим шляхом
loadScript('/my/script.js');
Скрипт виконується “асинхронно”, оскільки він починає завантажуватися зараз, але запускається пізніше, коли функція вже завершить виконання.
Якщо нижче loadScript(...)
буде будь-який код, він не чекатиме, доки завершиться завантаження скрипту.
loadScript('/my/script.js');
// код нижче loadScript
// не чекає завершення завантаження скрипту
// ...
Скажімо, нам потрібно використовувати новий скрипт, як тільки він завантажиться. Він оголошує нові функції, і ми хочемо їх запустити.
Але якщо ми зробимо це відразу після виклику loadScript(...)
, це не спрацює:
loadScript('/my/script.js'); // скрипт містить "function newFunction() {…}"
newFunction(); // немає такої функції!
Природно, браузер, ймовірно, не встиг завантажити скрипт. На даний момент функція loadScript
не надає можливості відстежувати завершення завантаження. Скрипт завантажується та зрештою запускається, ось і все. Але ми хотіли б знати, коли це станеться, використовувати нові функції та змінні з цього скрипту.
Додаймо callback
-функцію як другий аргумент до loadScript
, яка має виконуватися, коли скрипт завантажується:
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(script);
document.head.append(script);
}
Подія onload
описана в статті Завантаження ресурсів: onload та onerror,
і це спосіб виконати функцію після завантаження та виконання скрипту.
Тепер, якщо ми хочемо викликати нові функції зі скрипту, то повинні написати це у колбеку:
loadScript('/my/script.js', function() {
// колбек запускається після завантаження скрипту
newFunction(); // тож тепер все працює
...
});
Ідея така: другий аргумент – це функція (зазвичай анонімна), яка запускається після завершення дії.
Ось приклад із реальним скриптом, який можна виконати:
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(script);
document.head.append(script);
}
loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => {
alert(`Cool, the script ${script.src} is loaded`);
alert( _ ); // _ функція, що оголошена в завантаженому скрипті
});
Такий стиль називається “асинхронним програмуванням на основі колбеків” (“callback-based”). Функція, яка виконує щось асинхронно, повинна містити аргумент callback
, де ми запускаємо функцію після завершення асинхронної дії.
Тут ми зробили це в loadScript
, але, звичайно, це поширений підхід.
Колбек у колбеку
Як ми можемо завантажити два скрипти послідовно: перший, а потім другий після нього?
Природним рішенням було б помістити другий виклик loadScript
усередину колбека, наприклад:
loadScript('/my/script.js', function(script) {
alert(`Круто, ${script.src} завантажився, завантажмо ще один`);
loadScript('/my/script2.js', function(script) {
alert(`Круто, другий скрипт завантажився`);
});
});
Після завершення зовнішньої функції loadScript
колбек ініціює внутрішню.
А якщо ми хочемо завантажити ще один скрипт…?
loadScript('/my/script.js', function(script) {
loadScript('/my/script2.js', function(script) {
loadScript('/my/script3.js', function(script) {
// ...продовжується після завантаження всіх скриптів
});
});
});
Отже, кожна нова дія знаходиться всередині колбека. Це добре для кількох дій, але погано для багатьох, тому незабаром ми побачимо інші варіанти.
Обробка помилок
У вищенаведених прикладах ми не врахували помилки. Що робити, якщо завантажити скрипт не вдається? Наш колбек повинен мати можливість реагувати на це.
Ось покращена версія loadScript
, яка відстежує помилки завантаження:
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(null, script);
script.onerror = () => callback(new Error(`Помилка завантаження скрипту для ${src}`));
document.head.append(script);
}
Так код викликає callback(null, script)
для успішного завантаження та callback(error)
в іншому випадку.
Використання:
loadScript('/my/script.js', function(error, script) {
if (error) {
// обробляємо помилку
} else {
// скрипт успішно завантажено
}
});
Знову ж таки, рецепт, який ми використовували для loadScript
, насправді досить поширений. Такий стиль називається “колбек з першим аргументом-помилкою” (“error-first callback”).
Домовленість така:
- Перший аргумент
callback
зарезервовано для помилки, якщо вона виникає. В такому випадку викликаєтьсяcallback(err)
. - Другий аргумент (і наступні, якщо потрібно) – для успішного результату. В такому випадку викликається
callback(null, result1, result2…)
.
Таким чином, єдина callback
-функція використовується як для повідомлення про помилки, так і для повернення результатів.
Пекельна піраміда
З першого погляду це життєздатний спосіб асинхронного кодування. І це дійсно так. Для одного або, можливо, двох вкладених викликів це виглядає добре.
Але для кількох асинхронних дій, які ідуть одна за одною, ми матимемо такий код:
loadScript('1.js', function(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('2.js', function(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('3.js', function(error, script) {
if (error) {
handleError(error);
} else {
// ...продовжується після завантаження всіх скриптів (*)
}
});
}
});
}
});
- Завантажуємо
1.js
, продовжуємо, якщо немає помилки… - Завантажуємо
2.js
, продовжуємо, якщо немає помилки… - Ми завантажуємо
3.js
, продовжуємо, якщо немає помилки – робимо щось інше(*)
.
if (error) { handleError(error); } else { // … loadScript(‘2.js’, function(error, script) { if (error) { handleError(error); } else { // … loadScript(‘3.js’, function(error, script) { if (error) { handleError(error); } else { // … } }); } }); } }); –>
“Піраміда” вкладених викликів зростає вправо з кожною асинхронною дією. Незабаром це виходить з-під контролю.
Так що цей спосіб кодування не дуже хороший.
Ми можемо спробувати зменшити проблему, зробивши кожну дію окремою функцією, наприклад:
loadScript('1.js', step1);
function step1(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('2.js', step2);
}
}
function step2(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('3.js', step3);
}
}
function step3(error, script) {
if (error) {
handleError(error);
} else {
// ...продовжується після завантаження всіх скриптів (*)
}
}
Бачите? Код робить те саме, і тепер немає глибокого вкладення, тому що ми зробили кожну дію окремою функцією верхнього рівня.
Це працює, але код виглядає розірваним на частини. Його важко читати, і ви, напевно, помітили, що під час читання потрібно стрибати між частинами. Це незручно, особливо якщо читач не знайомий з кодом і не знає, що за чим слідує.
Крім того, всі функції під назвою step*
призначені для одноразового використання, вони створені лише для того, щоб уникнути “пекельної піраміди”. Ніхто не збирається використовувати їх повторно за межами ланцюжка дій. Таким чином, тут є деяке нагромадження в просторі імен.
Ми б хотіли мати щось краще.
На щастя, є й інші способи уникнути таких пірамід. Один із найкращих способів – використовувати “проміси”, що описані в наступному розділі.