Помилкові способи використання промісів у JavaScript

8 хв. читання

Сучасну JavaScript неможливо уявити без промісів. Навіть зараз, з появою синтаксису async/await, проміси залишаються однією з найважливіших фіч для JS-розробників.

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

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

Promise: обертаємо все в конструктор

Мабуть, найбільш поширена помилка, яку автор спостерігав в різноманітних кодових базах.

Вивчення промісів зазвичай починається зі створення нового об'єкта Promise за допомогою конструктора. Для прикладу часто пропонується огорнути конструктором проміса якусь функцію з API браузера (наприклад, setTimeout). Саме тому більшість розробників вважають, що це єдиний спосіб створити проміс.

Все закінчується ось таким кодом:

const createdPromise = new Promise(resolve => {
  somePreviousPromise.then(result => {
    // обробка результату
    resolve(result);
  });
});

Щоб якось обробити result з проміса somePreviousPromise, використовується then. Все додатково огортається конструктором проміса, аби зберегти розрахунки у змінній createdPromise (наприклад, щоб далі обробити їх).

Така конструкція надлишкова. Коли ми використовуємо then, ми вже неявно повертаємо проміс somePreviousPromise, а тоді виконується колбек, переданий в then як аргумент. В кінці somePreviousPromise завершується зі значенням result.

Ми можемо переписати код вище в такий спосіб:

const createdPromise = somePreviousPromise.then(result => {
    // обробка результату
  return result;
});

Виглядає простіше, чи не так?

Але насправді ці дві реалізації не є ідентичними. Вся різниця в обробці помилок. А це набагато важливіше, ніж просто вигляд реалізації.

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

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

const createdPromise = new Promise((resolve, reject) => {
  somePreviousPromise.then(result => {
    // обробляємо результат
    resolve(result);
  }, reject);
});

Ми передали аргумент reject методу then як другий необов'язковий параметр для обробки помилок.

Тепер, якщо з якоїсь причини somePreviousPromise завершиться з помилкою, викличеться функція reject, що обробить помилку createdPromise. Але на цьому проблеми такого підходу не закінчуються. Ми обробили помилки, які виникли у somePreviousPromise, та ми досі не контролюємо те, що відбувається у колбеку в then.

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

Тому остаточна реалізація буде такою:

const createdPromise = new Promise((resolve, reject) => {
  somePreviousPromise.then(result => {
    // обробляємо результат
    resolve(result);
  }).catch(reject);
});

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

Як бачимо, огортання коду в конструктор проміса має багато тонкощів. Саме тому для повернення промісів варто використовувати метод then — він наочний і допоможе уникнути граничних випадків.

Послідовні then vs паралельні then

Для розробників з минулим в об'єктно-орієнтовних мовах програмування звично, що метод змінює об'єкт, а не створює новий. Саме тому таким розробникам складно зрозуміти, що саме відбувається, коли ми викликаємо then для проміса.

Порівняймо такі фрагменти коду:

const somePromise = createSomePromise();

somePromise
  .then(doFirstThingWithResult)
  .then(doSecondThingWithResult);
const somePromise = createSomePromise();

somePromise
  .then(doFirstThingWithResult);

somePromise
  .then(doSecondThingWithResult);

Як ви думаєте, чи роблять вони те саме? Так може здавати, адже в обох випадках then викликається двічі для somePromise.

Насправді це розповсюджена помилка. Два фрагменти коду кардинально відрізняються поведінкою — і часто вона призводить до непередбачуваних результатів.

Суть в тому, що метод then створює зовсім новий, незалежний проміс. Тобто в першому випадку метод then викликається не для somePromise, а для зовсім нового проміс-об'єкта, який інкапсулює код очікування somePromise і завершує своє виконання викликом doFirstThingWithResult. Вже в новому екземплярі проміса викликається колбек doSecondThingWithResult.

Тобто у нас є гарантія, що другий колбек буде викликано після першого. До того ж перший колбек отримає як аргумент значення повернене somePromise, а другий колбек отримає все, що повернула функція doFirstThingWithResult.

У другому фрагменті коду ми викликаємо метод then обидва рази для somePromise, тим самим ігноруємо повернені проміси. Оскільки обидва рази метод then викликається над тим самим екземпляром проміса, ми не можемо гарантувати, який колбек виконається першим. Такий підхід нагадує «паралельне» виконання, адже обидва колбеки незалежні один від одного. Насправді рушій JS обробляє лише одну операцію за раз — просто в такому випадку ви не знаєте, в якому саме порядку.

Ще одна відмінність в тому, що як doFirstThingWithResult, так і doSecondThingWithResult отримують однаковий аргумент — значення, з яким завершується somePromise. Значення, повернуті самими колбеками, ігноруються.

Виконання проміса одразу після створення

Така помилка також часто трапляється серед розробників, які прийшли з ООП-мов. Адже в ООП-світі існує правило: не навантажувати конструктор об'єкта певними діями. Наприклад, об'єкт БД не повинен встановлювати з'єднання в конструкторі. Зазвичай такі дії виносяться в спеціальний метод — init, наприклад. Так об'єкт не зробить незапланованих змін при ініціалізації, а буде чекати, доки потрібний метод не викличуть явно.

Проміс так не працює. Розглянемо приклад:

const somePromise = new Promise(resolve => {
  // HTTP-запит
  resolve(result);
});

Ви могли подумати, що HTTP-запит тут не здійснюється, адже сам запит огорнуто в конструктор проміса. А для того, щоб він виконався, треба викликати метод then для проміса somePromise.

Насправді все не так. Колбек виконується одразу як створюється об'єкт Promise. Тобто після створення змінної HTTP-запит міг вже виконатись, або щонайменше міг бути спланованим.

Кажуть, що проміс працює у «жадібному» режимі, адже всі його операції виконуються якнайскоріше. Помилково вважати, що проміс має ліниву ініціалізацію (тобто виконує операцію лише тоді, коли це необхідно: скажімо, коли викликається then).

Що ж робити, якщо вам треба виконати проміс пізніше? Наприклад, зачекати з HTTP-запитом? Чи є якийсь магічний вбудований механізм для пізнішого виконання промісів?

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

Знов приклад:

const createSomePromise = () => new Promise(resolve => {
  // HTTP-запит
  resolve(result);
});

Ми огорнули виклик конструктора проміса у функцію, а також змінили назву змінної на createSomePromise, адже вона більше не зберігає об'єкт. Тепер це функція, яка створює та повертає проміс.

Конструктор Promise і колбек-функція з HTTP-запитом почнуть виконання, коли ми викличемо функцію явно. Тепер у нас є проміс з лінивою ініціалізацію, який виконується лише тоді, коли нам справді потрібно. До того ж ми отримуємо можливість створити інший проміс, який виконує таку саму дію.

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

Тобто трюк з огортанням проміса у функцію (або метод) — корисний патерн для JavaScript-розробника.

Якщо ви вже читали матеріал автора, де він порівнював Promises vs Observables, ви знаєте, що деякі новачки в Rx.js роблять протилежну помилку. Вони поводяться з Observables, як з промісами, хоча насправді їхня ініціалізація лінива. Тому немає сенсу огортати Observables у функцію чи метод, це навіть може привести до помилок у вашому коді.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 6.8K
Приєднався: 6 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація