6 березня 2025 р.

Введення: колбеки

У прикладах ми використовуємо методи браузера

Щоб продемонструвати використання колбеків, промісів та інших абстрактних понять, ми будемо використовувати деякі методи браузера: зокрема, завантажувати скрипти та виконувати прості маніпуляції з документом.

Якщо ви не знайомі з цими методами, і їх використання в прикладах викликає заплутаність, ви можете прочитати кілька розділів з наступної частини підручника.

Хоча ми все одно спробуємо все прояснити. Ми будемо використовувати лише прості можливості браузера.

Середовища 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”).

Домовленість така:

  1. Перший аргумент callback зарезервовано для помилки, якщо вона виникає. В такому випадку викликається callback(err).
  2. Другий аргумент (і наступні, якщо потрібно) – для успішного результату. В такому випадку викликається 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. Завантажуємо 1.js, продовжуємо, якщо немає помилки…
  2. Завантажуємо 2.js, продовжуємо, якщо немає помилки…
  3. Ми завантажуємо 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* призначені для одноразового використання, вони створені лише для того, щоб уникнути “пекельної піраміди”. Ніхто не збирається використовувати їх повторно за межами ланцюжка дій. Таким чином, тут є деяке нагромадження в просторі імен.

Ми б хотіли мати щось краще.

На щастя, є й інші способи уникнути таких пірамід. Один із найкращих способів – використовувати “проміси”, що описані в наступному розділі.

Навчальна карта

Коментарі

прочитайте це, перш ніж коментувати…
  • Якщо у вас є пропозиції, щодо покращення підручника, будь ласка, створіть обговорення на GitHub або одразу створіть запит на злиття зі змінами.
  • Якщо ви не можете зрозуміти щось у статті, спробуйте покращити її, будь ласка.
  • Щоб вставити код, використовуйте тег <code>, для кількох рядків – обгорніть їх тегом <pre>, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)