Асинхронне програмування на JavaScript - Блог ITVDN
ITVDN: курсы программирования
Видеокурсы по
программированию
Подписка

300+ курсов по популярным IT-направлениям

Выбери свою IT специальность

Подписка
Подписка

300+ курсов по популярным IT-направлениям

Асинхронне програмування на JavaScript

advertisement advertisement

План:

1. Різниця між синхронним та асинхронним кодом

2. Багатозадачність процеси й потоки, у чому різниця

3. Особливості багатозадачності в JavaScript

4. Асинхронні операції на практиці HTTP-запит як найпоширеніший кейс

5. Підходи до написання асинхронного коду:

6. Практичні поради

Різниця між синхронним та асинхронним кодом

Для початку давайте визначимо ці два терміни:

Синхронний код - це код, який виконується послідовно, функція за функцією.

Асинхронний код - код, який може виконуватися паралельно: наступна функція запускається, не чекаючи завершення попередньої.

Щоб провести аналогію з реального життя, уявімо кухаря. Якщо кухар працює синхронно, то поки він не завершить приготування однієї страви, не переходить до наступної. Але це неефективно й призводить до втрати часу. Якщо ж кухар діє асинхронно, то поки м’ясо запікається в духовці, а на плиті закипає вода, він нарізає овочі. Тобто він один, але не стоїть без діла - виконує інші задачі, поки щось готується саме.

Уявімо, що кухар - це процесор. А запікання м’яса в духовці - це завантаження файлу з мережі. Кухар може просто стояти й дивитись, як м’ясо готується. А може нарізати овочі, перевіряти, чи не з’явились нові замовлення, або скролити стрічку в соцмережі. Так само і з програмами: поки мережева карта завантажує файл, процесор не мусить чекати - він може малювати інтерфейс, оновлювати прогрес-бар чи виконувати обчислення у фоні. Але для цього потрібно правильно написати код - так, щоб він міг працювати асинхронно.

Код який виконується синхронно

```js

console.log("Початок");

console.log("Дія");

сonsole.log("Кінець");

```

Результат:

Початок

Дія

Кінець

 

Код який виконується асинхронно. і

``js

console.log("Початок");

setTimeout(() => { // за допомогою setTimeout ми відкладаємо запуск коду на певний час

  console.log("Дія через 2 секунди");

}, 2000);

сonsole.log("Кінець");

```

Результат:

Початок

Кінець

Дія через 2 секунди

Це не та багатозадачність, як у деяких інших мовах програмування. Тут не використовуються додаткові потоки, а все працює завдяки механізму подій. Але про це детальніше дал

Багатозадачність: процеси й потоки, у чому різниця

Багатозадачність в операційній системі - це можливість запускати та керувати кількома задачами одночасно. Наприклад, працювати в браузері, слухати музику, завантажувати файл і паралельно редагувати код у Visual Studio.

На практиці процесор дуже швидко перемикається між усіма цими задачами, створюючи ілюзію одночасного виконання. Якщо процесор багатоядерний - деякі задачі справді можуть виконуватись паралельно.

Багатозадачність тісно пов'язана з двома важливими поняттями - процесами та потоками.

Процес (process) - це окремий екземпляр програми у пам'яті, який має власні ресурси: виділену область оперативної пам'яті, дескриптори файлів, змінні оточення тощо. 

Потік (thread) - це одиниця виконання всередині процесу. Потоки одного процесу працюють незалежно, але мають спільний доступ до пам'яті та ресурсів процесу.

Процеси дозволяють запускати різні програми одночасно - наприклад, Google Chrome, Visual Studio Code і т.д. 

Потоки дають змогу виконувати кілька задач усередині однієї програми. Наприклад, у Visual Studio Code один потік відповідає за оновлення інтерфейсу, інший перевіряє помилки в коді, ще один формує підказки під час написання. Це, звісно, спрощений приклад - у реальності VS Code використовує ще й окремі процеси для розширень і мовних серверів.

Операційна система керує як процесами, так і потоками. Вона розподіляє процесорний час між ними, ставить у чергу, може призупиняти виконання або відновлювати його за потреби.

Давайте трохи адаптуємо наш приклад з кухарем із попереднього посту. Уявімо, що процес - це ресторан, а потік - це кухар. Ресторан має все необхідне для приготування їжі: кухонне приладдя, продукти, рецепти (це можна розглядати як пам’ять і доступ до інших ресурсів). Кухар читає рецепт і, використовуючи ресурси ресторану, готує страву - так само, як потік виконує інструкції нашої програми, використовуючи ресурси процесу. Якщо ресторан хоче готувати кілька страв одночасно, йому потрібно більше кухарів, які працюють паралельно на одній кухні. Аналогічно, якщо програма повинна виконувати кілька задач одночасно - завантажувати файли, обробляти введення, оновлювати інтерфейс - вона може використовувати кілька потоків.

Коли ми створюємо програму і хочемо зробити її зручною для користувача, а також ефективною з точки зору використання ресурсів, які виділяє операційна система на процес, ми іноді починаємо використовувати потоки та прийоми багатопотокового програмування. Це велика окрема тема, і ми її зараз чіпати не будемо. Одна з причин - у JavaScript немає прямого доступу до потоків.

Уточнення. Якщо ви хочете використовувати JavaScript і все ж таки працювати з потоками - у вас є Web Workers: 

https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers 

А якщо JavaScript виконується не в браузері (наприклад, у Node.js), тоді можна використовувати модуль `worker_threads`. Але варто розуміти, що це не частина стандарту мови, а можливість середовища виконання.

Додаткові корисні ресурси по цій темі:

https://www.youtube.com/@CoreDumpped - канал з короткими відео про те як працює комп'ютер.

Modern Operating System by Andrew Tanenbaum - принцип побудови та роботи операційних систем (може бути викликом для новачка, але нормально як для технічної книжки)

Особливості багатозадачності в JavaScript

JavaScript працює в одному потоці - це означає, що в будь-який момент часу виконується лише один фрагмент коду. Увесь код, який ми пишемо, виконується у call stack: це структура, в яку потрапляють усі функції, що викликаються.

Якщо одна з функцій виконується довго (наприклад, важке обчислення), усі інші задачі - включно з обробкою кліків, рендерингом чи відповідями від сервера - будуть чекати, поки call stack не звільниться.

Щоб не блокувати цей єдиний потік, браузер надає асинхронні API (setTimeout, fetch, Web API). Коли ми викликаємо, скажімо, fetch(), у стек додається лише короткий виклик цієї функції. Власне мережевий запит виконується в окремому потоці, який створює браузер. Тобто, один потік виконує задачі які є у call stack, а інший потік чекає поки відповідь поверне сервер.

Але асинхронна операція колись завершиться і треба механізм який віддасть нашому головному потоку результат роботи іншого потоку. Коли це стається колбек або проміс‑резолвер не додається одразу у call stack. Спершу він потрапляє до черги подій (task queue). За роботою черги стежить event loop. Його правило просте - поки стек порожній дістати першу задачу із черги і покласти у стек.

Так ми досягаємо псевдобагатозадачності: основний потік виконує короткі шматки коду послідовно, а довгі операції «живуть» поза стеком. Коли довгі операції завершуються вони формують чергу задач, які треба виконати а event loop ці задачі закидає до стеку, коли call stack стає порожнім.

Це максимально спрощене пояснення, і без візуалізації може здатися складним. Якщо хочете краще зрозуміти, дуже раджу подивитись відео Jake Archibald — "In The Loop" на YouTube (англійською).  https://www.youtube.com/watch?v=8aGhZQkoFbQ

Або приходьте на мій курс JavaScript Поглиблений, де ми розбираємо це на практиці. 

Також корисна стаття на MDN, де ці процеси описані докладніше.

Асинхронні операції на практиці: HTTP-запит як найпоширеніший кейс

Один з прикладів асинхронної операції - це запит на сервер через HTTP-протокол. Якщо організовувати запит через JavaScript у браузері без використання React, Angular або Vue.js, то це можна зробити за допомогою:

Ось так буде виглядати простий код написаний на fetch

```js

fetch('https://jsonplaceholder.typicode.com/users')

  .then(res => res.json())

  .then(data => console.log(data));

```

А так на axios (axios одразу повертає розпарсений JSON як response.data, на відміну від fetch, де потрібно викликати .json() вручну)

```js

axios.get('https://jsonplaceholder.typicode.com/users')

  .then(response => console.log(response.data));

```

Якщо розглянути саме fetch то ось що відбулося під капотом:

  • fetch створює HTTP запит вказавши HTTP метод, заголовки, тіло тощо
  • Цей запит передається у вбудовану систему Web API - окрему від JavaScript середу, яка працює в іншому потоці.
  • JavaScript не чекає відповіді - основний потік продовжує виконувати інший код
  • fetch повертає Promise - об'єкт, що представляє асинхронну операцію, результат якої з’явиться пізніше
  • Коли відповідь від сервера приходить, Web API кладе callback в чергу.
  • Event Loop перевіряє, чи call stack порожній, і виконує цю мікрозадачу.

Така поведінка дозволяє браузеру одночасно виконувати інші задачі, не чекаючи завершення запиту.

Про використання асинхронного коду в JavaScript є [безкоштовний урок на YouTube](https://www.youtube.com/watch?v=cvR1EQ1R0EQ)

Також більше про синтаксис Promise можна дізнатися в уроці [Асинхронний код. Promise](https://itvdn.com/ua/video/javascript-essential-ua/js-promise-ua) на ITVDN, а детальніше про варіанти організації такого коду буде написано далі.

Підходи до написання асинхронного коду

Складність роботи з асинхронним кодом полягає в тому, що обробка результату операції відбувається не відразу, а через певний час після її запуску. Ми ініціюємо асинхронну операцію й можемо виконувати інші завдання, але все одно маємо якось дізнатися про її завершення та обробити результат. Проблема в тому, що в цей момент програма вже виконує інші дії.

Тому для обробки асинхронних операцій використовується push-модель взаємодії: отримувача даних (наш код) викликає провайдер даних - окремий механізм, який керує асинхронною операцією. По суті, розробнику потрібно відреагувати на подію завершення асинхронної операції. Для цього існує кілька підходів:

  • callback-функція
  • Promise
  • async/await (синтаксичний цукор над Promise)
  • Observables

Використання функцій зворотнього виклику (callback)

Почнемо з callback-функцій. Це найпростіший підхід, але він може призвести до заплутаного коду, особливо коли одна асинхронна операція запускає іншу, і так утворюється ланцюг.

Уявімо, що маємо функцію downloadImage(url, callback), яка завантажує зображення асинхронно, не блокуючи основний потік. Перший параметр - це адреса зображення, яке потрібно завантажити, а другий - функція, яку буде викликано після завершення завантаження. При цьому саме зображення буде передане як параметр у callback.

Приклад використання:

```js

downloadImage(url1, image => document.body.append(image))

```

На перший погляд усе просто. Але якщо потрібно завантажити кілька зображень послідовно, код стає менш зрозумілим:

```js

downloadImage(url1, image => {

            document.body.append(image);

            downloadImage(url2, image => {

                        document.body.append(image);

                        downloadImage(url3, image => {

                                    document.body.append(image);

                        })

            })

});

```

Така вкладена структура швидко ускладнюється, особливо якщо замість одного рядка з DOM-змінами з’являється додаткова логіка. Подібний стиль називають "Pyramid of Doom", і його краще уникати.

Один зі способів спростити обробку асинхронних викликів - це використання Promise.

Використання Promise

Promise - це об’єкт, який представляє асинхронну операцію. У перекладі з англійської promise означає «обіцянка». Можна уявити, що це обіцянка від браузера надати в майбутньому або результат операції, або помилку, пов’язану з її виконанням.

Приклад використання: перепишемо попередню функцію downloadImage, щоб вона повертала Promise.

```js

let promise = downloadImage(url1);

promise.then(image => document.body.append(image));

```

Тут ми все одно використовуємо callback-функцію, але передаємо її вже в метод .then() об’єкта promise. Це важливий момент:

  • тепер асинхронна операція має об’єктне представлення, яке можна передавати як параметр у різні частини коду;
  • можна будувати ланцюжки промісів, позбуваючись вкладеності, яка виникала з callback.

Приклад:

```js

downloadImage(url1)                          // отримуємо проміс

.then(image => {                             // вказуємо що робити коли promise перейде в стан resolved

            document.body.append(image);

            return downloadImage(url2);                // виконуємо метод, який повертає promise

})

.then(image => {                             // результат роботи попереднього промісу передається як значення

            document.body.append(image);

            return downloadImage(url3);

})

.then(image => {

            document.body.append(image);

});

```

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

У прикладах вище ми не розглядали, як саме створюється Promise, адже важливо зрозуміти мотивацію використання цих об’єктів. Тим більше, що більшість API браузера вже повертають готові проміси. Наприклад:

```js

fetch('<https://jsonplaceholder.typicode.com/users>')

  .then(res => res.json());

```

Якщо хочете детальніше розібратися зі створенням Promise вручну - перегляньте документацію на MDN або мій відео урок на ITVDN.

Async/await

Супроводжувати синхронний код завжди простіше, ніж асинхронний. У 2012 році в мові C# з’явився синтаксичний цукор, який значно спростив роботу з асинхронними операціями: замість вкладених callback можна було використовувати послідовний синтаксис з новими ключовими словами async та await. Згодом цю концепцію перейняли й інші мови програмування, зокрема Python та JavaScript. В JavaScript підтримку async/await додали у 2017 році.

Призначення ключових слів

  • async - додається до функції та вказує, що вона завжди повертає Promise.
  • await - використовується перед об’єктом-промісом, щоб "дочекатися" результату перед виконанням наступних рядків коду.

Перепишемо попередній приклад із завантаженням зображень, використовуючи async/await:

```js

let image = null;

 

image = await downloadImage(url1);

document.body.append(image);

 

image = await downloadImage(url2);

document.body.append(image);

 

image = await downloadImage(url3);

document.body.append(image);

```

Тепер код виглядає як звичайний синхронний: немає вкладених callback, усе читається рядок за рядком. Можна подумати, що await "зупиняє" виконання, очікуючи на результат промісу. Насправді ж код не блокує основний потік - під капотом він перетворюється на машину станів, де кожен стан описує дію до або після await.

Ще одна перевага async/await - знайомий синтаксис для обробки помилок:

```js

try {

            let image = null;

 

            image = await downloadImage(url1);

            document.body.append(image);

 

            image = await downloadImage(url2);

            document.body.append(image);

 

            image = await downloadImage(url3);

            document.body.append(image);

} catch (ex) {

// обробка помилки

}

```

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

Observable

Observable - це ще один підхід до організації асинхронного коду. Назва походить від однойменного патерна проєктування (Observer pattern), який описує створення об’єктів, за якими можна «спостерігати», отримуючи від них сповіщення. Тобто це реалізація подієвої моделі за допомогою ООП.

У сучасному JavaScript ця ідея пішла далі й стала основою реактивного програмування та бібліотеки RxJS.

Якщо Promise представляє одне майбутнє значення (успішне або помилкове), то Observable - це потік значень, які можуть з’являтися з часом.

Можна уявити:

  • Promise - це одна посилка, яку ви отримаєте в майбутньому;
  • Observable - це підписка на журнал, нові випуски якого надходитимуть регулярно.

Щоб створити Observable, використовують конструктор або готові оператори RxJS. Наприклад, функція downloadImages(urls) може завантажувати кілька картинок і відправляти їх «у потік» по мірі завантаження:

```js

import { Observable } from 'rxjs';

 

function downloadImages(urls) {

  return new Observable(subscriber => {

    urls.forEach(url => {

      downloadImage(url, image => {

        subscriber.next(image); // надсилаємо картинку у потік

      });

    });

    subscriber.complete(); // повідомляємо, що потік завершено

  });

}

```

Щоб використати Observable, на нього треба підписатися за допомогою subscribe:

```js

downloadImages([url1, url2, url3])

  .subscribe({

    next: image => document.body.append(image), // що робити з новим значенням

    error: err => console.error(err),           // обробка помилок

  });

```

Переваги Observable

  • працюють із потоком даних, а не з одним результатом;
  • підтримують підключення операторів трансформації (фільтрація, мапінг, комбінування потоків);
  • можна легко скасувати виконання (відписатися від потоку).

Нижче приклад обробки даних через оператори. В RxJS оператори підключаються через метод pipe:

```js

import { filter, map } from 'rxjs/operators';

downloadImages([url1, url2, url3])

  .pipe(

    filter(image => image.width > 100), // пропускаємо лише великі картинки

    map(image => {

      image.classList.add('highlight');

      return image;

    })

  )

  .subscribe({

    next: image => document.body.append(image),

    error: err => console.error(err),

    complete: () => console.log('Готово')

  });

```

Таким чином, як і у випадку з Promise, можна будувати ланцюжки обробки. Але Observable значно гнучкіші: вони дозволяють працювати не лише з одним значенням, а з динамічною послідовністю даних у часі.

Для глибшого занурення рекомендую офіційний гайд Observable на RxJS.dev. та відео уроки Observables. Частина 1, та Observables. Частина 2[1] 

Практичні поради по работі за асинхроним кодом

  • Не змішуйте підходи без потреби
    Якщо почали писати з async/await, не вставляйте всередину .then() без особливої причини. Це ускладнює читання.
  • Обробляйте помилки
    Використовуйте try/catch для async/await або .catch() для Promise. У випадку Observable завжди додавайте обробку error у subscribe().
  • Скасовуйте непотрібні операції
    Для Observable використовуйте unsubscribe(), коли потік більше не потрібен. Для fetch можна застосувати AbortController, щоб зменшити навантаження й уникнути витоків пам’яті.
  • Уникайте "Pyramid of Doom"
    Замість вкладених callback застосовуйте Promise, async/await або Observable.
  • Використовуйте паралельне виконання
    Якщо операції незалежні, запускайте їх одночасно через Promise.all().
  • Ізолюйте логіку обробки
    Розділяйте завантаження даних та маніпуляції з DOM. Це спростить тестування й повторне використання коду.
  • Логуйте стан і помилки
    Під час розробки виводьте у консоль ключові події асинхронних операцій, щоб відстежувати їх послідовність.
  • Пам’ятайте про event loop
    Розуміння різниці між мікрозадачами й макрозадачами допоможе прогнозувати порядок виконання коду.
  • Перевіряйте сумісність середовища
    Деякі API можуть бути відсутні у певних середовищах (наприклад, fetch у Node.js доступний лише починаючи з версії 18).
КОММЕНТАРИИ И ОБСУЖДЕНИЯ
advertisement advertisement

Покупай подпискус доступом ко всем курсам и сервисам

Библиотека современных IT знаний в удобном формате

Выбирай свой вариант подписки в зависимости от задач, стоящих перед тобой. Но если нужно пройти полное обучение с нуля до уровня специалиста, то лучше выбирать Базовый или Премиум. А для того чтобы изучить 2-3 новые технологии, или повторить знания, готовясь к собеседованию, подойдет Пакет Стартовый.

Стартовый
  • Все видеокурсы на 3 месяца
  • Тестирование по 10 курсам
  • Проверка 5 домашних заданий
  • Консультация с тренером 30 мин
59.99 $
Оформить подписку
Премиум Plus
  • Все видеокурсы на 1 год
  • Тестирование по 24 курсам
  • Проверка 20 домашних заданий
  • Консультация с тренером 120 мин
  • Скачивание видео уроков
149.99 $
199.99 $
Оформить подписку
Акция
Базовый
  • Все видеокурсы на 6 месяцев
  • Тестирование по 16 курсам
  • Проверка 10 домашних заданий
  • Консультация с тренером 60 мин
89.99 $
Оформить подписку
Notification success