Проміси та асинхронне програмування

Одним з найбільш потужних аспектів JavaScript є те, як легко він працює з асинхронним програмуванням. Як мова, яка була розроблена для Web, JavaScript від початку потребував можливості відповідати на асинхронні взаємодії користувачів, як от кліки та натискання клавіш. Node.js ще більше популяризував асинхронне програмування у JavaScript з допомогою функцій зворотнього виклику в якості альтернативи подіям. З тим, як все більше і більше програм починають використовувати асинхронне програмування, події та зворотні виклики більше не є достатньо потужними для підтримки всього того, що хочуть робити розробники. Про́міси (Promises) є вирішенням цієї проблеми.

Проміси є додатковим засобом для асинхронного програмування, і вони схожі у своїй роботі на ф’ючерси (futures) та де́фереди (deferreds) у інших мовах. Проміс визначає деякий код, який треба виконати пізніше (так само, як з подіями та зворотніми викликами), і також точно показує, коли цей код успішно або з помилкою завершує свою роботу. Ви можете складати проміси у ланцюжок, базуючись на тому, чи успішно вони виконуються, що робить ваш код легшим для розуміння та налагодження.

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

Підґрунтя асинхронного програмування

Рушії JavaScript побудовані на концепції однопоточного циклу подій. Однопоточний означає, що в один момент часу може виконуватись лише один шматок коду. Це відрізняється від мов, як от Java чи C++, в яких потоки дозволяють виконувати одночасно різні частини коду. Підтримка і захист стану, коли різні частини коду можуть мати доступ та змінювати цей стан, може бути складною проблемою та часто є джерелом багів у програмному забезпеченні, що базується на потоках.

Рушії JavaScript можуть виконувати лише один шматок коду за раз, тому для них важливо відслідковувати код, який вони мають виконати. Цей код зберігається у черзі завдань (job queue). Коли шматок коду готовий до виконання, він додається у чергу завдань. Коли рушій JavaScript завершив виконання коду, цикл подій виконує наступне завдання у цій черзі. Цикл подій (event loop) — це процес всередині рушія JavaScript, який відслідковує та керує чергою завдань. Пам'ятайте, що оскільки це черга, то завдання виконуються в ній від першого до останнього.

Подієва модель

Коли користувач клікає або натискає клавішу на клавіатурі, спрацьовує подія (event), як от onclick. Ця подія може відповісти на взаємодію додаванням нового завдання в кінець черги завдань. Це найбільш базова форма асинхронного програмування у JavaScript. Код обробника події не виконується доти, доки не станеться подія, а коли виконується, він має відповідний контекст. Наприклад:

let button = document.getElementById("my-btn");
button.onclick = function(event) {
    console.log("Clicked");
};

У цьому коді, console.log("Clicked") не виконається доти, доки не буде кліку по button. Якщо на button клікнути, то функція, присвоєна у onclick, додається в кінець черги завдань і виконається тоді, коли всі завдання перед нею будуть виконані.

Події працюють добре для простих взаємодій, проте поєднання кількох окремих асинхронних викликів є більш складним, оскільки ви мусите відслідковувати елемент (button у попередньому випадку) для кожної події. Крім того, ви маєте бути певні у тому, що всі потрібні обробники події додані до того, як ця подія відбудеться. Наприклад, якщо клікнути button до того, як присвоїти яку функцію onclick, нічого не відбудеться. Таким чином хоч події і корисні для реагування на деякі дії користувача та створення загальної простої функціональності, вони не дуже підходять для більш складних потреб.

Патерн зі зворотніми викликами

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

readFile("example.txt", function(err, contents) {
    if (err) {
        throw err;
    }

    console.log(contents);
});
console.log("Hi!");

Цей приклад використовує традиційний для Node.js стиль зворотніх викликів: «спершу помилка». Функція readFile() має читати з диска файл (заданий першим аргументом), а тоді, після закінчення, виконати зворотній виклик (другий аргумент). Якщо стається помилка, аргумент err, у функції зворотнього виклику буде об’єктом помилки; в іншому випадку, аргумент contents міститиме вміст файлу у вигляді рядка.

Використовуючи патерн зі зворотніми викликами, readFile() починає виконання негайно і зупиняється, коли починає читання з диску. Це означає, що console.log("Hi!") спрацює негайно після виклику readFile(), до того, як console.log(contents) що–небудь виведе. Коли readFile() закінчить читання, вона додасть нове завдання з функцією зворотнього виклику та її аргументами в кінець черги завдань. Це завдання виконається після завершення всіх завдань перед ним.

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

readFile("example.txt", function(err, contents) {
    if (err) {
        throw err;
    }

    writeFile("example.txt", function(err) {
        if (err) {
            throw err;
        }

        console.log("File was written!");
    });
});

У цьому коді, успішний виклик readFile() призводить до іншого асинхронного виклику — цього разу функції writeFile(). Зауважте, що схожий базовий патерн перевірки наявності err присутній у обох функціях. Коли readFile() виконується, у чергу завдань додається завдання, яке призводить до того, що викликається writeFile() (якщо немає помилок). Тоді, коли виконується writeFile(), вона додає теж завдання у чергу завдань.

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

method1(function(err, result) {

    if (err) {
        throw err;
    }

    method2(function(err, result) {

        if (err) {
            throw err;
        }

        method3(function(err, result) {

            if (err) {
                throw err;
            }

            method4(function(err, result) {

                if (err) {
                    throw err;
                }

                method5(result);
            });

        });

    });

});

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

В такому випадку, вам би знадобилось відслідковувати кілька зворотніх викликів та очищувати операції, а от проміси значно покращують схожі ситуації.

Основи промісів

Проміс — це заглушка для асинхронної операції. Замість підписки на подію або передачу зворотнього виклику у функцію, функція може повертати проміс, ось так:

// readFile повертає проміс на те, що операція виконається у майбутньому
let promise = readFile("example.txt");

У цьому коді, readFile() насправді не починає читання файлу негайно — це станеться згодом. Замість цього, функція повертає об’єкт–проміс, що відповідає асинхронній операції читання, і, таким чином, ви можете працювати з ним у майбутньому. Коли саме ви зможете працювати з результатом, залежить виключно від того, як відбуватиметься життєвий цикл проміса.

Життєвий цикл промісів

Кожен проміс проходить через короткий життєвий цикл, що починається зі стану очікування (pending), який вказує на те, що асинхронна операція ще не завершена. Проміс, що перебуває у стані очікування, вважається невстановленим (unsettled). Проміс з останнього прикладу перебуває у стані очікування, як тільки функція функція readFile() повертає його. Як тільки асинхронна операція завершується, проміс вважається встановленим (settled) і переходить один з двох можливих станів:

  1. fulfilled (завершений) — коли асинхронна операція у промісі завершена успішно;
  2. rejected (відхилений) — коли асинхронна операція не завершена успішно через помилку, або з іншої причини.

Внутрішня властивість [[PromiseState]] встановлюється у "pending", "fulfilled" або "rejected", щоб відображати стан проміса. Ця властивість не відкривається у об’єктах–промісах, тому ви не можете програмно визначити у якому стані перебуває проміс. Проте ви можете виконати певну дію, коли проміс змінить стан, з допомогою методу then().

Метод then() присутній у всіх промісів та приймає два аргументи. Перший аргумент є функцією, яка викликається, коли проміс виконаний (fulfilled). Будь–яка додаткова інформація, що стосується асинхронної операції, передається у цю функцію завершення. Другим аргументом є функція, що викликається, коли проміс відхилено (rejected). Так само, як і для функції завершення, будь–яка додаткова інформація, що стосується відхилення, передається у цю функцію.

I> Будь–який об’єкт, що реалізує метод then(), таким чином, називається промісоподібним (thenable) (тобто той, який має метод then()). Всі проміси мають об’єкт then(), проте не всі об’єкти, що мають метод then(), є промісами.

Обидва аргументи then() є опціональними, тому ви можете слухати будь–яку комбінацію завершення або відхилення. Наприклад, розгляньте такі виклики then():

let promise = readFile("example.txt");

promise.then(function(contents) {
    // завершення
    console.log(contents);
}, function(err) {
    // відхилення
    console.error(err.message);
});

promise.then(function(contents) {
    // завершення
    console.log(contents);
});

promise.then(null, function(err) {
    // відхилення
    console.error(err.message);
});

Всі три виклики then() виконуються над одним і тим же промісом. Перший виклик слухає і завершення, і відхилення. Другий слухає лише завершення (повідомлень про помилки не буде). Третій слідкує лише за відхиленнями і не повідомляє про успішне завершення.

Проміси також мають метод catch(), що поводиться так само, як і then(), якщо йому передати лише обробник відхилень. Наприклад, такі виклики catch() та then() функціонально однакові:

promise.catch(function(err) {
    // відхилення
    console.error(err.message);
});

// те саме що й:

promise.then(null, function(err) {
    // відхилення
    console.error(err.message);
});

Ви можете комбінувати then() та catch() для кращої обробки результату асинхронної операції. Така система є кращою за події та зворотні виклики, тому що вона дає чітке розуміння того, чи операція завершилась успішно, чи з помилкою. (Події не відбудуться, якщо є помилка, а зворотні виклики потребують постійної перевірки аргументу–помилки.) Просто знайте, що якщо ви не задасте обробника відхилень, усі помилки будуть відбуватись без повідомлення. Завжди додавайте обробник відхилень, навіть якщо цей обробник просто виводить повідомлення про помилки.

Обробники завершення або відхилення будуть виконуватись, навіть якщо вони додаються у чергу завдань після того, як проміс став встановленим (settled). Це дозволяє вам додавати обробники завершення та відхилення будь–коли з гарантією, що вони будуть викликані. Наприклад:

let promise = readFile("example.txt");

// первинний обробник завершення
promise.then(function(contents) {
    console.log(contents);

    // тепер додаємо ще один
    promise.then(function(contents) {
        console.log(contents);
    });
});

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

I> Кожен виклик then() або catch() створює нове завдання, яке має виконатись, коли проміс буде виконано. Проте ці завдання зрештою кладуться у окрему чергу завдань, що відведена спеціально для промісів. Детальна інформація про цю другу чергу не важлива для розуміння того, як використовувати проміси, якщо ви загалом розумієте як працює черга завдань.

Створення невстановлених промісів

Нові проміси створюються з допомогою конструктора Promise. Цей конструктор приймає один аргумент: функцію під назвою виконавець (executor), яка містить код, що ініціалізує проміс. Виконавець приймає дві функції у якості аргументів: resolve() та reject(). Функція resolve() сигналізує про те, що проміс готовий бути вирішеним, і викликається тоді, коли виконавець успішно завершився. Функція reject() вказує на те, що виконавець завершися з помилкою.

Ось приклади використання проміса в Node.js для імплементації функції readFile(), яка згадувалась раніше у цій главі:

// Приклад Node.js

let fs = require("fs");

function readFile(filename) {
    return new Promise(function(resolve, reject) {

        // починаємо асинхронну операцію
        fs.readFile(filename, { encoding: "utf8" }, function(err, contents) {

            // перевіряємо помилку
            if (err) {
                reject(err);
                return;
            }

            // читання завершилось успішно
            resolve(contents);

        });
    });
}

let promise = readFile("example.txt");

// чекаємо на успішне завершення або відхилення з помилкою
promise.then(function(contents) {
    // завершення
    console.log(contents);
}, function(err) {
    // відхилення
    console.error(err.message);
});

У цьому прикладі, нативний асинхронний виклик fs.readFile() з Node.js огорнутий у проміс. Виконавець передає або помилку у функцію reject(), або контент файлу у функцію resolve().

Пам’ятайте, що виконавець запускається негайно, коли викликається readFile(). Коли всередині виконавця викликається resolve() або reject(), у чергу завдань додається завдання вирішення проміса. Це називається плануванням завдань (job scheduling), і якщо ви колись використовували функції setTimeout() або setInterval(), то ви вже знайомі з цим. При плануванні завдань, ви додаєте нове завдання у чергу завдань, маючи на увазі: «Не виконуй це зараз, але виконай це пізніше.» Наприклад, функція setTimeout() дозволяє вам задавати певну затримку перед тим, як завдання потрапить у чергу завдань:

// додати цю функцію у чергу завдань після того, як спливе 500 мс.
setTimeout(function() {
    console.log("Timeout");
}, 500)

console.log("Hi!");

Цей код розплановує завдання так, щоб воно має бути додане у чергу завдань через 500 мс. Ці два виклики console.log() дадуть такий вивід:

Hi!
Timeout

Завдяки затримці у 500 мс., вивід з функції, яку ми передали у setTimeout() з’явиться після виводу від виклику console.log("Hi!").

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

let promise = new Promise(function(resolve, reject) {
    console.log("Promise");
    resolve();
});

console.log("Hi!");

Цей код виведе:

Promise
Hi!

Виклик resolve() починає асинхронну операцію. Функції, передані у then() та catch(), виконуються асинхронно, оскільки вони також додаються у чергу завдань. Ось приклад:

let promise = new Promise(function(resolve, reject) {
    console.log("Promise");
    resolve();
});

promise.then(function() {
    console.log("Resolved.");
});

console.log("Hi!");

Для цього прикладу вивід виглядає ось так:

Promise
Hi!
Resolved

Зауважте, що хоча й виклик then() знаходиться у коді до рядка з console.log("Hi!"), він відкладається на потім (на відміну від виконавця). Це тому, що обробники виконання та відхилення відкладаються в кінець черги після того, як виконавець завершить роботу.

Створення встановлених промісів

Конструктор Promise є найкращим способом створення невстановлених (unsettled) промісів через динамічну природу того, що робить виконавець проміса. Проте, якщо проміс просто відповідає єдиному відомому значенню, то немає змісту планувати завдання, яке просто передає значення у функцію resolve(). Замість цього, є два методи створення промісів з певним переданим значенням.

Використання Promise.resolve()

Метод Promise.resolve() приймає єдиний аргумент та повертає проміс зі станом «завершено». Це означає, що планування завдань не буде, а щоб отримати значення, вам потрібно лише додати до цього проміса один, або більше обробників завершення. Наприклад:

let promise = Promise.resolve(42);

promise.then(function(value) {
    console.log(value);         // 42
});

Цей код створює завершений проміс, тому обробник завершення отримує 42 в якості значення value. Якщо додати до цього проміса обробник відхилення, то цей обробник не виконався б ніколи, тому що цей проміс ніколи не матиме стан «відхилено».

Використання Promise.reject()

Ви також можете створювати відхилені проміси з допомогою методу Promise.reject(). Він працює так само, як і Promise.resolve(), з єдиною відмінністю: створений проміс матиме стан «відхилено», ось так:

let promise = Promise.reject(42);

promise.catch(function(value) {
    console.log(value);         // 42
});

Якщо додати до цього проміса додаткові обробники відхилення, то вони б викликались, а от обробники завершення не спрацюють ніколи.

I> Якщо передати проміс у метод Promise.resolve() або Promise.reject(), цей проміс повернеться без змін.

Промісоподібні (Thenables)

Як Promise.resolve(), так і Promise.reject() також можуть приймати промісободібні (thenables) у якості аргументів. Якщо передавати промісободібні, ці методи створюють новий проміс, що викликається після функції then().

Промісоподібні — це об’єкти, що мають метод then(), який приймає аргументи resolve та reject, ось так:

let thenable = {
    then: function(resolve, reject) {
        resolve(42);
    }
};

Об’єкт thenable, у цьому прикладі, не має жодних спільних з промісами характеристик, окрім методу then(). Ви можете викликати Promise.resolve(), щоб конвертувати thenable у завершений проміс:

let thenable = {
    then: function(resolve, reject) {
        resolve(42);
    }
};

let p1 = Promise.resolve(thenable);
p1.then(function(value) {
    console.log(value);     // 42
});

У цьому прикладі, Promise.resolve() викликає thenable.then(), і таким чином можна визначити стан цього проміса. thenable має стан «завершено», тому що всередині методу then() викликається resolve(42). Новий проміс p1 створюється зі станом «завершено» та значенням, яке було передано з thenable (тобто 42), а обробник завершення для p1 отримує 42 у якості значення.

Таким же чином через Promise.resolve() з промісоподібних можна створити відхилений проміс:

let thenable = {
    then: function(resolve, reject) {
        reject(42);
    }
};

let p1 = Promise.resolve(thenable);
p1.catch(function(value) {
    console.log(value);     // 42
});

Цей приклад схожий на попередній, окрім того, що thenable є відхиленим. Коли виконується thenable.then(), створюється новий проміс зі станом «відхилено» і значення 42. Тоді це значення передається у обробник відхилення для p1.

Promise.resolve() та Promise.reject() працюють так для того, щоб дозволити вам легко працювати з промісоподібними. Багато бібліотек використовували промісоподібні до того, як ECMAScript 6 ввів проміси, тому можливість конвертувати промісоподібні у звичайні проміси є важливою, щоб забезпечити зворотню сумісність з бібліотеками, що існували раніше. Коли ви невпевнені у тому, що об’єкт є промісом, передача цього об’єкту в Promise.resolve() або Promise.reject() (в залежності від можливого результату) є найкращим способом, щоб з’ясувати це, адже проміси проходять через них незміненими.

Помилки виконавця

Якщо всередині виконавця кидається помилка, тоді викликається обробник відхилення цього проміса. Наприклад:

let promise = new Promise(function(resolve, reject) {
    throw new Error("Explosion!");
});

promise.catch(function(error) {
    console.log(error.message);     // "Explosion!"
});

У цьому коді, всередині виконавця кидається помилка. Всередині кожного виконавця є неявний try-catch, тому ця помилка була впіймана і передана у обробник відхилення. Попередній приклад еквівалентний такому:

let promise = new Promise(function(resolve, reject) {
    try {
        throw new Error("Explosion!");
    } catch (ex) {
        reject(ex);
    }
});

promise.catch(function(error) {
    console.log(error.message);     // "Explosion!"
});

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

Глобальна обробка відхилення у промісах

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

Через природу промісів, не так просто визначити, чи відхилення проміса було оброблено. Наприклад, розгляньте цей приклад:

let rejected = Promise.reject(42);

// тут відхилення не оброблене

// тут теж...
rejected.catch(function(value) {
    // тепер відхилення оброблено
    console.log(value);
});

Ви можете викликати then() або catch() будь–коли, і вони будуть працювати коректно незалежно від того, чи проміс є встановленим, чи ні. Через це дуже важко точно визначити, коли проміс буде оброблений. У цьому випадку, проміс відхиляється відразу, проте обробка відбувається згодом.

Цілком можливо, що наступні версії ECMAScript вирішать цю проблему, а поки браузери та Node.js внесли зміни, що допомагають розробникам уникнути цієї проблеми. Вони не є частиною специфікації ECMAScript 6, проте є важливими засобами при використанні промісів.

Обробка відхилення у Node.js

У Node.js є дві події на об’єкті process, що відповідають обробці відхилення:

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

Ці події розроблені таким чином, щоб їх поєднання допомагало ідентифікувати проміси, які були відхилені і не оброблені.

Обробник події unhandledRejection приймає у якості аргументів причину відхилення (rejection reason) (зазвичай об’єкт помилки) та відхилений проміс. Такий код показує unhandledRejection в дії:

let rejected;

process.on("unhandledRejection", function(reason, promise) {
    console.log(reason.message);            // "Explosion!"
    console.log(rejected === promise);      // true
});

rejected = Promise.reject(new Error("Explosion!"));

Цей приклад слухає подію unhandledRejection та створює відхилений проміс з об’єктом помилки. Обробник помилки приймає об’єкт помилки в якості першого аргументу та проміс у якості другого.

Обробник події rejectionHandled має лише один аргумент: відхилений проміс. Наприклад:

let rejected;

process.on("rejectionHandled", function(promise) {
    console.log(rejected === promise);              // true
});

rejected = Promise.reject(new Error("Explosion!"));

// чекаємо, щоб додати обробник відхилення
setTimeout(function() {
    rejected.catch(function(value) {
        console.log(value.message);     // "Explosion!"
    });
}, 1000);

Тут подія rejectionHandled публікується, коли викликається обробник відхилення. Якщо обробник відхилення додати до rejected відразу після того, як rejected створюється, тоді б подія не опублікувалась. Тоді обробник відхилення викликався би протягом того ж проходу циклу подій, коли був створений rejected.

Для кращого моніторингу за потенційно необробленими відхиленнями використовуйте події rejectionHandled та unhandledRejection, щоб зберігати список потенційно необроблених відхилень. Тоді зачекайте і перевіряйте цей список з деякою періодичністю. Наприклад:

let possiblyUnhandledRejections = new Map();

// якщо відхилення необроблене, додаємо його в мапу
process.on("unhandledRejection", function(reason, promise) {
    possiblyUnhandledRejections.set(promise, reason);
});

process.on("rejectionHandled", function(promise) {
    possiblyUnhandledRejections.delete(promise);
});

setInterval(function() {

    possiblyUnhandledRejections.forEach(function(reason, promise) {
        console.log(reason.message ? reason.message : reason);

        // робимо щось, щоб обробити ці відхилення
        handleRejection(promise, reason);
    });

    possiblyUnhandledRejections.clear();

}, 60000);

Це простий трекер для необроблених відхилень. Він використовує мапу для збереження промісів та причин їхнього відхилення. Кожен проміс є ключем, а причина відхилення цього проміса — значенням. Щоразу, коли публікується подія unhandledRejection, проміс та причина його відхилення додають у мапу. Щоразу, коли публікується rejectionHandled, оброблений проміс видаляється з мапи. В результаті, possiblyUnhandledRejections наповнюється та очищується в залежності від подій, що викликаються. Виклик setInterval() періодично перевіряє список можливих необроблених відхилень та виводить інформацію у консоль (в реальності, ви б, можливо, захотіли обробити ці відхилення). Замість слабкої мапи у цьому прикладі використовується звичайна, сильна мапа, тому що періодично потрібно перевіряти які проміси у ній знаходяться, а це неможливо зробити зі слабкою мапою.

Цей приклад стосується Node.js, проте браузери імплементували схожий механізм повідомлення розробників про необроблені відхилення.

Обробка відхилення у браузері

Щоб допомогти ідентифікувати необроблені події, браузери також публікують дві події. Ці події публікуються об’єктом window та є практично аналогічними до таких же у Node.js:

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

Імплементація у Node.js передає окремі параметри у обробник події, а обробники подій у браузерах передають у обробник один параметр. Обробники подій для цих браузерних подій отримують об’єкт події з такими властивостями:

  • type — ім’я події ("unhandledrejection" або "rejectionhandled");
  • promise — об’єкт проміса, що був відхилений;
  • reason — значення відхилення для проміса.

Іншою відмінністю у браузерній імплементації є те, що значення відхилення (reason) доступне для обох подій. Наприклад:

let rejected;

window.onunhandledrejection = function(event) {
    console.log(event.type);                    // "unhandledrejection"
    console.log(event.reason.message);          // "Explosion!"
    console.log(rejected === event.promise);    // true
});

window.onrejectionhandled = function(event) {
    console.log(event.type);                    // "rejectionhandled"
    console.log(event.reason.message);          // "Explosion!"
    console.log(rejected === event.promise);    // true
});

rejected = Promise.reject(new Error("Explosion!"));

Цей код присвоює обидва обробники подій через запис DOM Level 0 для onunhandledrejection та onrejectionhandled. (Ви також можете використовувати addEventListener("unhandledrejection") та addEventListener("rejectionhandled"), якщо вам так більше подобається.) Кожен обробник подій отримує об’єкт помилки, що містить інформацію про відхилений проміс. Властивості type, promise та reason доступні для обох обробників подій.

Код для моніторингу необроблених відхилень у браузері дуже схожий на такий же код для Node.js:

let possiblyUnhandledRejections = new Map();

// якщо відхилення необроблене, додаємо його в мапу
window.onunhandledrejection = function(event) {
    possiblyUnhandledRejections.set(event.promise, event.reason);
};

window.onrejectionhandled = function(event) {
    possiblyUnhandledRejections.delete(event.promise);
};

setInterval(function() {

    possiblyUnhandledRejections.forEach(function(reason, promise) {
        console.log(reason.message ? reason.message : reason);

        // робимо щось, щоб обробити ці відхилення
        handleRejection(promise, reason);
    });

    possiblyUnhandledRejections.clear();

}, 60000);

Така імплементація майже нічим не відрізняється від імплементації на Node.js. Вона використовує той же підхід для збереження промісів та їхніх значень відхилення у мапі та їх подальшого використання. Єдина відмінність — те, як ми отримуємо інформацію з обробників подій.

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

Ланцюжки промісів

До цієї миті, проміси могли здаватись лише незначним покращенням над використанням зворотніх викликів та фунції setTimeout(), проте є ще багато чого, що можуть запропонувати проміси. Зокрема, є ряд способів об’єднання промісів у ланцюжки, щоб створювати більш складну асинхронну поведінку.

Кожен виклик then() або catch() насправді створює і повертає ще один проміс. Цей проміс вирішується (resolve) лише тоді, коли попередній був або завершений (fulfilled), або відхилений (rejected). Розгляньте такий приклад:

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

p1.then(function(value) {
    console.log(value);
}).then(function() {
    console.log("Finished");
});

Цей код виведе:

42
Finished

Виклик p1.then() повертає новий проміс, у якого викликається метод then(). Наступний обробник завершення then() викликається лише після того, як перший попередній проміс буде вирішено. Якщо б ви роз’єднали цей приклад у кілька промісів, це могло б виглядати ось так:

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

let p2 = p1.then(function(value) {
    console.log(value);
})

p2.then(function() {
    console.log("Finished");
});

При такому записі, результат p1.then() зберігається у p2, а тоді викликається p2.then() і додає останній обробник завершення. Як ви могли здогадатись, виклик p2.then() також повертає проміс. У цьому прикладі цей повернений проміс не використовується.

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

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

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

p1.then(function(value) {
    throw new Error("Boom!");
}).catch(function(error) {
    console.log(error.message);     // "Boom!"
});

У цьому коді, обробник завершення для p1 кидає помилку. Виклик методу catch(), який є наступним у ланцюжку і викликається для проміса, який повертається, може отримати цю помилку у свій обробник відхилень. Так само це спрацює, якщо помилку кине обробник помилок:

let p1 = new Promise(function(resolve, reject) {
    throw new Error("Explosion!");
});

p1.catch(function(error) {
    console.log(error.message);     // "Explosion!"
    throw new Error("Boom!");
}).catch(function(error) {
    console.log(error.message);     // "Boom!"
});

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

I> Завжди додавайте обробник відхилення в кінці ланцюжка промісів, аби бути певним, що ви обобляєте всі помилки, які можуть виникнути.

Повернення значень у ланцюжку промісів

Іншим важливим аспектом у ланцюжках промісів є можливість передавати дані від одного проміса до наступного. Ви вже бачили, що значення, яке передається у обробник resolve() всередині виконавця, буде передане у обробник завершення цього проміса. Ви можете продовжити передачу даних по ланцюжку промісів повернувши значення з обробника завершення. Для прикладу:

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

p1.then(function(value) {
    console.log(value);         // "42"
    return value + 1;
}).then(function(value) {
    console.log(value);         // "43"
});

Обробник завершення для p1 повертає value + 1 при виконанні. Оскільки value дорівнює 42 (всередині виконавця), обробник завершення повертає 43. Це значення передається у обробник завершення наступного проміса, який виводить результат у консоль.

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

let p1 = new Promise(function(resolve, reject) {
    reject(42);
});

p1.catch(function(value) {
    // перший обробник завершення
    console.log(value);         // "42"
    return value + 1;
}).then(function(value) {
    // другий обробник завершення
    console.log(value);         // "43"
});

Тут виконавесь викликає reject() зі значенням 42. Це значення передається у обробник відхилення проміса, а там повертається значення value + 1. Навіть хоча це повернене значення приходить з обробника відхилення, воно буде використовуватись у обробнику завершення наступного проміса у ланцюжку. Таким чином, при потребі можна відновити ланцюжок промісів, якщо сталась помилка в одному з них.

Повернення промісів у ланцюжках промісів

Поверення примітивних значення з обробників відхилення та завершення дозволяє передавати дані між промісами, проте, що, якщо ви повернете об’єкт? Якщо об’єкт є промісом, тоді знадобиться ще один додатковий крок для визначення як з ним поводитись. Розгляньте такий приклад:

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

let p2 = new Promise(function(resolve, reject) {
    resolve(43);
});

p1.then(function(value) {
    // перший обробник завершення
    console.log(value);     // 42
    return p2;
}).then(function(value) {
    // другий обробник завершення
    console.log(value);     // 43
});

У цьому коді, p1 планує завдання, яке повертає 42. Обробник завершення для p1 повертає p2, проміс, який вже вирішений (resolved). Другий обробник завершення викликається тому, що p2 вже завершений. Якщо б p2 був відхиленим, тобі обробник відхилення (за наявності) викликався б замість обробника завершення.

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

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

let p2 = new Promise(function(resolve, reject) {
    resolve(43);
});

let p3 = p1.then(function(value) {
    // перший обробник завершення
    console.log(value);     // 42
    return p2;
});

p3.then(function(value) {
    // другий обробник завершення
    console.log(value);     // 43
});

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

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

let p2 = new Promise(function(resolve, reject) {
    reject(43);
});

p1.then(function(value) {
    // перший обробник завершення
    console.log(value);     // 42
    return p2;
}).then(function(value) {
    // другий обробник завершення
    console.log(value);     // ніколи не викличеться
});

У цьому прикладі, другий обробник завершення не викличеться ніколи, тому що p2 відхилений. Однак, ви можете замість цього додати обробник відхилення:

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

let p2 = new Promise(function(resolve, reject) {
    reject(43);
});

p1.then(function(value) {
    // перший обробник завершення
    console.log(value);     // 42
    return p2;
}).catch(function(value) {
    // обробник відхилення
    console.log(value);     // 43
});

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

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

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

p1.then(function(value) {
    console.log(value);     // 42

    // створюємо новий проміс
    let p2 = new Promise(function(resolve, reject) {
        resolve(43);
    });

    return p2
}).then(function(value) {
    console.log(value);     // 43
});

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

Робота з кількома промісами

До цього моменту кожен приклад у цій главі працював лише з обробкою результату одного проміса за раз. Однак часом, для визначення подальших дій, вам потрібно слідкувати за прогресом виконання кількох промісів. ECMAScript 6 надає два метод, що відслідковують кілька промісів: Promise.all() та Promise.race().

Метод Promise.all()

Метод Promise.all() приймає один аргумент який є ітерабельним об’єктом (наприклад масивом) промісів, які потрібно відслідковувати. Він повертає проміс, що буде вирішений (resolved) тільки тоді, коли кожен проміс у цьому ітерабельному об’єкті буде вирішений. Проміс–результат буде завершений тоді, коли всі проміси у ітерабельному об’єкті будуть завершені, як у цьому прикладі:

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

let p2 = new Promise(function(resolve, reject) {
    resolve(43);
});

let p3 = new Promise(function(resolve, reject) {
    resolve(44);
});

let p4 = Promise.all([p1, p2, p3]);

p4.then(function(value) {
    console.log(Array.isArray(value));  // true
    console.log(value[0]);              // 42
    console.log(value[1]);              // 43
    console.log(value[2]);              // 44
});

Кожен проміс тут вирішується з числом. Виклик Promise.all() створює проміс p4, який буде остаточно завершено, коли проміси p1, p2 та p3 будуть завершені. Результат передається у обробник завершення для p4 у вигляді масиву, що містить значення результатів: 42, 43 та 44. Значення зберігаються у порядку вирішення промісів, тому ви можете співставити проміс та результат, що відповідає цьому промісу.

Якщо хоч один проміс з переданих у Promise.all() буде відхилено, проміс–результат негайно буде відхилений без очікування завершення всіх інших промісів:

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

let p2 = new Promise(function(resolve, reject) {
    reject(43);
});

let p3 = new Promise(function(resolve, reject) {
    resolve(44);
});

let p4 = Promise.all([p1, p2, p3]);

p4.catch(function(value) {
    console.log(Array.isArray(value))   // false
    console.log(value);                 // 43
});

У цьому прикладі, p2 відхилений зі значенням 43. Обробник відхилення для p4 викликається миттєво без очікування на заваршення виконання p1 або p3 (вони продовжать очікувати на завершення, проте p4 не буде на них чекати).

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

Метод Promise.race()

Метод Promise.race() надає дещо інший спосіб моніторингу за групою промісів. Цей метод також приймає ітерабельний об’єкт з промісами, за якими потрібно слідкувати та повертає проміс, проте проміс, який повернеться в результаті завершиться, як тільки завершиться один з промісів. Замість того, щоб чекати на завершення всіх промісів як метод Promise.all(), метод Promise.race() повертає проміс як тільки хоч один з усіх з промісів у масиві завершиться. Наприклад:

let p1 = Promise.resolve(42);

let p2 = new Promise(function(resolve, reject) {
    resolve(43);
});

let p3 = new Promise(function(resolve, reject) {
    resolve(44);
});

let p4 = Promise.race([p1, p2, p3]);

p4.then(function(value) {
    console.log(value);     // 42
});

У цьому коді, p1 створюється як завершений проміс, тоді як інші відкладають завдання. Тоді обробник завершення для p4 викликається зі значенням 42 та ігнорує інші проміси. Проміси, які передаються у Promise.race(), насправді перебувають у гонитві, хто з них першим встановиться. Якщо перший встановлений проміс матиме стан «fulfilled» (буде завершеним), тоді проміс, що повернеться у результаті, матиме стан «fulfilled». Якщо ж перший встановлений проміс матиме стан «rejected» (буде відхиленим), тоді проміс, який повернеться матиме стан «rejected». Ось приклад з відхиленням:

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

let p2 = Promise.reject(43);

let p3 = new Promise(function(resolve, reject) {
    resolve(44);
});

let p4 = Promise.race([p1, p2, p3]);

p4.catch(function(value) {
    console.log(value);     // 43
});

Тут p4 є відхиленим тому, що коли викликається Promise.race(), проміс p2 вже має стан «rejected». Навіть хоча й p1 та p3 є завершеними, їхні результати ігноруються через те, що вони отримуються після того, як p2 перейшов у стан «rejected».

Наслідування від промісів

Як і з іншими вбудованими типами, ви можете використовувати проміси в якості основи для похідних класів. Це дозволяє вам створювати власні варіанти промісів, які будуть розширювати можливості вбудованих промісів. Припустимо, наприклад, що вам хотілося б створити проміс, що мав би методи success() та failure() на додачу до звичних методів then() та catch(). Ви можете зробити такий проміс таким чином:

class MyPromise extends Promise {

    // використовуємо конструктор за замовчуванням

    success(resolve, reject) {
        return this.then(resolve, reject);
    }

    failure(reject) {
        return this.catch(reject);
    }

}

let promise = new MyPromise(function(resolve, reject) {
    resolve(42);
});

promise.success(function(value) {
    console.log(value);             // 42
}).failure(function(value) {
    console.log(value);
});

У цьому прикладі, MyPromise отримується з Promise та має два додаткові методи. Метод success() імітує метод resolve(), а failure() — метод reject().

Обидва додані методи використовують this для виклику метода, який вони імітують. Функції отриманого проміса такі ж, як у вбудованого, однак тепер ви, якщо хочете, можете викликати success() та failure().

Оскільки статичні методи успадковуються, методи MyPromise.resolve(), MyPromise.reject(), MyPromise.race() та MyPromise.all() також присутні у отриманому промісі. Останні два методи поводяться так само, як і вбудовані, проте перші два є дещо іншими.

І MyPromise.resolve(), і MyPromise.reject() повернуть екземпляр MyPromise незалежно від переданого значення, тому що ці методи використовують властивість Symbol.species (описана у Главі 9) для визначення типу проміса, який треба повернути. Якщо вбудований проміс передати до одного з цих методів, проміс буде вирішений або відхилений, а метод поверне новий MyPromise, тому ви можете присвоїти обробники завершення та відхилення. Наприклад:

let p1 = new Promise(function(resolve, reject) {
    resolve(42);
});

let p2 = MyPromise.resolve(p1);
p2.success(function(value) {
    console.log(value);         // 42
});

console.log(p2 instanceof MyPromise);   // true

Тут p1 є вбудованим промісом, що передається у метод MyPromise.resolve(). Результат, p2, є екземпляром MyPromise, в якому значенням завершення є значення з p1, яке було передано у обробник завершення.

Якщо екземпляр MyPromise передати у методи MyPromise.resolve() або MyPromise.reject(), він буде просто повернений без вирішення. У всіх інших аспектах ці методи поводяться так само, як і Promise.resolve() та Promise.reject().

Асинхронний запуск завдань

У Главі 8 я розповів про генератори та показав, як ви можете використовувати їх для асинхронного запуску завдань, ось так:

let fs = require("fs");

function run(taskDef) {

    // створюємо ітератор, що буде доступний усюди
    let task = taskDef();

    // починаємо виконувати завдання
    let result = task.next();

    // рекурсивна функція для постійного виклику next()
    function step() {

        // якщо є що виконувати
        if (!result.done) {
            if (typeof result.value === "function") {
                result.value(function(err, data) {
                    if (err) {
                        result = task.throw(err);
                        return;
                    }

                    result = task.next(data);
                    step();
                });
            } else {
                result = task.next(result.value);
                step();
            }

        }
    }

    // починаємо процес
    step();

}

// Визначаємо функцію, яка буде використовуватись з обробником завдань

function readFile(filename) {
    return function(callback) {
        fs.readFile(filename, callback);
    };
}

// Запускаємо завдання

run(function*() {
    let contents = yield readFile("config.json");
    doSomethingWith(contents);
    console.log("Done");
});

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

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

let fs = require("fs");

function run(taskDef) {

    // створюємо ітератор, що буде доступний усюди
    let task = taskDef();

    // починаємо виконувати завдання
    let result = task.next();

    // рекурсивна функція для постійного виклику next()
    (function step() {

        // якщо є що виконувати
        if (!result.done) {

            // вирішуємо проміс, щоб зробити це простішим
            let promise = Promise.resolve(result.value);
            promise.then(function(value) {
                result = task.next(value);
                step();
            }).catch(function(error) {
                result = task.throw(error);
                step();
            });
        }
    }());
}

// Визначаємо функцію, яка буде використовуватись з обробником завдань

function readFile(filename) {
    return new Promise(function(resolve, reject) {
        fs.readFile(filename, function(err, contents) {
            if (err) {
                reject(err);
            } else {
                resolve(contents);
            }
        });
    });
}

// Запускаємо завдання

run(function*() {
    let contents = yield readFile("config.json");
    doSomethingWith(contents);
    console.log("Done");
});

У цій версії коду, загальна функція run() виконує генератор, щоб створити ітератор. Вона викликає task.next(), щоб стартувати виконання завдання і рекурсивно викликає step(), доки ітератор не завершить роботу.

Всередині функції step(), якщо є робота, яку потрібно виконувати, тоді result.done дорівнює false. У цій точці, result.value має бути промісом, проте ми викликаємо Promise.resolve() на той випадок, якщо функція не повертає проміс. (Пам'ятайте, що Promise.resolve() просто прокидає будь–який переданий проміс, а всі інші значення огортає у нього.) Тоді, обробник завершення додається, щоб діставати значення проміса і передавати це значення назад до ітератора. Після цього, result присвоюється наступне отримане через yield значення, перед тим як функція step() викличе сама себе.

Обробник відхилення зберігає будь–які результати відхилення у об’єкт помилки. Метод task.throw() передає цей об’єкт помилки назад у ітератор, і якщо помилка ловиться всередині завдання, result присвоюється наступне отримане через yield значення. На завершення, step() викликається всередині catch() для продовження процесу.

Така функція run() може запускати будь–який генератор, що використовує yield для роботи з асинхронним кодом, і при цьому приховувати проміси (або зворотні виклики) від розробника. Більше того, оскільки значення, яке повертається з функції завжди огортається у проміс, функція може навіть повертати щось інше замість проміса. Це означає, що синхронні та асинхронні методи будуть працювати правильно при виклику з yield, і вам не потрібно перевіряти, чи значення, яке повертається, є промісом.

Єдина вимога: щоб асинхронні функції, як от readFile(), повертали проміс, який правильно визначає свій стан. Для вбудованих методів Node.js це означає, що ви маєте перетворити ці методи таким чином, щоб вони повертали проміс замість зворотніх викликів.

A> Майбутнє асинхронного запуску завдань

A> На момент написання, продовжується робота щодо внесення у JavaScript більш простого синтаксису для запуску асинхронних завдань. Робота триває над await–синтаксисом, що дуже схожий на приклад з попереднього розділу, що використовував проміси. Основною ідеєю є використання функції, яка замість генератора позначатиметься async, а при виклику функцій використовуватиме await замість yield, ось так:

(async function() {
    let contents = await readFile("config.json");
    doSomethingWith(contents);
    console.log("Done");
});

A> Ключове слово async перед function вказує, що функція має виконуватись асинхронно. Ключове слово await сигналізує про те, що виклик фунції readFile("config.json") має повернути проміс, і якщо він не повертає проміс, тоді результат має буде огорнений у проміс. Точно як і у імплементації run() з попереднього розділу, await кине помилку, якщо проміс буде відхилено, і поверне результат, якщо все буде гаразд. Кінцевим результатом є те, що ви можете писати асинхронний код так, наче він є синхронним без зайвої роботи з машиною станів, яка базується на ітераторах.

A> Очікується, що await–синтаксис буде завершено у ECMAScript 2017 (ECMAScript 8).

Підсумок

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

Проміси мають три стани: «pending» (очікується), «fulfilled» (завершений) та «rejected» (очікується). Проміси починають роботу з стану «penging» і переходять у стан «fulfilled», при успішному виконанні, або у стан «rejected» при помилці. Для обох випадків можна додати обробники, що будуть вказувати коли проміс встановиться (settled). Метод then() дозволяє вам присвоїти обробники завершення та відхилення, а метод catch() дозволяє вам присвоїти лише обробник відхилення.

Ви можете різними способами об’єднувати проміси у ланцюжки і передавати інформацію між ними. Кожен виклик then() створює та повертає новий проміс, який вирішується тоді, коли вирішується попередній. Такі ланцюжки можна використовувати для створення серій асинхронних подій. Ви також можете використовувати Promise.race() та Promise.all() для того, щоб відслідковувати декілька промісів та відповідно реагувати в залежності від результату.

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

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

results matching ""

    No results matching ""