16 липня 2023 р.

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

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

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

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

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

Багато функцій надаються середовищами 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* призначені для одноразового використання, вони створені лише для того, щоб уникнути “пекельної піраміди”. Ніхто не збирається використовувати їх повторно за межами ланцюжка дій. Таким чином, тут є деяке нагромадження в просторі імен.

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

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

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