Введение в ES6 Promises, или четыре функции, которые вам нужно знать
JavaScript

Введение в ES6 Promises, или четыре функции, которые вам нужно знать

Оригинал: Introduction to ES6 Promises – The Four Functions You Need To Avoid Callback Hell, James K Nelson

Уже хорошо знакомы с обещаниями? Тогда сразу переходите к итоговому заданию.

Обещания помогают сделать код чище, уменьшить количество зависимостей от внешних библиотек и подготовиться к async и await в ES7. Разработчики, которые ругают или не используют их, не знают, что они теряют.

Тем не менее, обещания могут быть сложными для понимания. Они очень отличаются от обычных коллбэков, к которым мы привыкли, а некоторые сюрпризы в синтаксисе могут доставить новичкам много проблем при отладке.

Итак, почему я должен изучать промисы, снова?

До появления обещаний разработчики JavaScript использовали функции обратного вызова. setTimeout, XMLHttpRequest, да и в основном все браузерные асинхронные функции основаны на коллбэках.

Чтобы продемонстрировать проблему с функциями обратного вызова, давайте сделаем некоторые анимации без HTML и CSS.

Допустим, мы хотим хотим сделать следующее:

  • запустить некоторый код
  • подождать одну секунду
  • запустить другой код
  • подождать еще секунду
  • затем запустить еще один код

Такой шаблон часто используется в CSS3 анимации. Давайте реализуем его с помощью нашего верного друга setTimeout. Код будет выглядеть примерно так:


runAnimation(0);
setTimeout(function() {
    runAnimation(1);    
    setTimeout(function() {
        runAnimation(2);
    }, 1000);
}, 1000);

Выглядит ужасно, не правда ли? А представьте себе на минуту, что вам нужно сделать 10 шагов, а не 3 — какую пирамиду из отступов вам придется построить. Это настолько плохо, что люди даже придумали специальное название — callback hell. И такие пирамиды из функций обратного вызова появляются везде — в обработке HTTP запросов, при работе с базой данных, при анимации, при реализации взаимодействия между процессами, и в других местах. Но:

Они не появляются в коде, который использует обещания.

Но же что они обещают?

Возможно, самый простой способ разобраться с тем, как работают обещания — сравнить их с коллбэками. Есть четыре основных отличия:

1. Коллбэки являются функциями, обещания являются объектами

Коллбэки — это просто функции, которые выполняются в ответ на какое-либо событие, например, событие таймера или получение ответа от сервера. Любая функция может стать коллбэком, и любой коллбэк является функцией.

Обещания являются объектами, которые хранят информацию, произошли ли определенные события или нет, а если произошли — то и их результат.

2. Коллбэки передаются в качестве аргументов, обещания возвращаются

Коллбэки определяются независимо от вызывающей функции и передаются в качестве аргументов. Вызывающая функция сохраняет коллбэк и вызывает его, когда происходит определенное событие.

Обещания создаются внутри асинхронных функций и возвращаются. Когда происходит событие, асинхронная функция обновляет обещание, чтобы уведомить об этом внешний мир.

3. Коллбэки обрабатывают успешное или неуспешное завершение, обещания ничего не обрабатывают

Коллбэки, как правило, вызываются с информацией о том, успешно или неуспешно завершилась операция, и должны быть в состоянии обработать оба варианта.

Обещания ничего не обрабатывают по умолчанию, обработчики добавляются позже.

4. Коллбэки могут обрабатывать несколько событий, обещания связаны только с одним событием

Коллбэки можно вызывать несколько раз в функциях, в которые они переданы.

Обещания могут представлять только одно событие — они обратывают либо успешное его завершение, либо неуспешное только один раз.

Имея это в виду, давайте рассмотрим обещания более детально.

Четыре функции, которые вам нужно знать

1. new Promise(fn)

Обещания ES6 являются экземплярами встроенного класса Promise, и создаются путем вызова new Promise с одной функцией в качестве аргумента. Например:


// Создание экземпляра обещания, который ничего не делает.
// Не волнуйтесь, мы рассмотрим этот момент подробнее.
promise = new Promise(function() {});

Вызов new Promise немедленно вызовет функцию, переданную в качестве аргумента. Цель этой функции состоит в информировании объекта Promise, когда событие, с которым он связан, будет завершено.

Для того, чтобы сделать это, функция, которую вы передаете в конструктор, может принимать два параметра, которые сами являются функциями — resolve и reject. Вызов resolve(value) пометит обещание как успешно завершенное и вызовет обработчик успешного завершения. Вызов reject(error) вызовет обработчик неуспешного завершения. Нельзя вызывать обе эти функции одновременно. Функции resolve и reject обе принимают один аргумент, который содержит в себе данные о событии.

Применим это к нашему примеру с анимацией. Приведенный выше пример использует функцию setTimeout, которая принимает коллбэк, — вместо этого мы хотим вернуть обещание. Конструктор new Promise позволяет нам это сделать:


/* Возвращаем обещание, которое резолвится через определенный промежуток времени */
function delay(interval) {
    return new Promise(function(resolve) {
        setTimeout(resolve, interval);
    });
}

var oneSecondDelay = delay(1000);

Отлично, теперь у нас есть обещание, которое резолвится через секунду. Я знаю, вам, вероятно, не терпится узнать, как сделать что-то по прошествии секунды — мы вернемся к этому позже, когда будем рассматривать вторую функцию promise.then.

Функция, которую мы передаем в new Promise в приведенном примере, принимает только параметр resolve, мы опустили параметр reject. Это потому, что setTimeout выполняется всегда и, таким образом, нет сценария, где мы он мог бы завершить неуспешно.

Допустим, мы хотим проверить, поддерживается ли определенная анимация браузером, и если анимация не поддерживается, узнать об этом заранее, а не после таймаута. Если функция isAnimationSupported(step) будет проверять поддерку, мы можем реализовать это с помощью reject:


function animationTimeout(step, interval) {
    new Promise(function(resolve, reject) {
        if (isAnimationSupported(step)) {
            setTimeout(resolve, interval);
        } else {
            reject('animation not supported');
        }
    });
}

var firstKeyframe = animationTimeout(1, 1000);

Наконец, важно отметить, если в переданной функции возникнет исключение, обещание будет автоматически помечено как неуспешное. В качестве результата будет передан объект исключения, как если бы вы его передали функцию reject в качестве аргумента.

Чтобы лучше понять это, можно представить, что содержимое каждой функции, которую вы передаете в конструктор Promise, оборачивается в try/catch, например, так:


var promise = new Promise(function(resolve, reject) {
    try {
        // your code
    }
    catch (e) {
        reject(e)
    }
});

Так. Теперь вы понимаете, как создать обещание. Но когда оно у нас есть, как добавить обработчики событий успеха/неудачи? Для этого мы используем метод then.

2. promise.then(onResolve, onReject)

Метод promise.then(onResolve, onReject) позволяет назначить обработчики событий обещания. В зависимости от аргументов, вы можете обработать событие успешного завершения, отказ, или оба:


// Только обработчик успеха
promise.then(function(details) {
    // handle success
});

// Только обработчик отказа
promise.then(null, function(error) {
    // handle failure
});

// Обработчики успеха и отказа
promise.then(
    function(details) { /* handle success */ },
    function(error) { /* handle failure */ }
);

Не пытайтесь обработать ошибки, возникающие в функции onResolve, в функции onError в том же вызове then. Это не работает.


// Это вызовет слезы и ужас
promise.then(
    function() {
        throw new Error('tears');
    },
    function(error) {
        // Не будет вызван
        console.log(error)
    }
);

Если это все, что делает promise.then, то он действительно не имеет никаких преимуществ перед функциями обратного вызова. К счастью, это не так: обработчики, переданные в promise.then не просто обрабатывают результат предыдущего обещания — то, что они возвращают, передается в следующие обещание.

promise.then всегда возвращает обещание

Это работает с числами, строками и другими типами:


delay(1000)
    .then(function() {
        return 5;
    })
    .then(function(value) {
        console.log(value); // 5
    });

Но что еще более важно, это работает с другими обещаниями — возвращение обещания из обработчика then передает это обещание в качестве возвращаемого значения then. Это позволяет реализовывать цепочки обещаний:


delay(1000)
    .then(function() {
        console.log('1 second elapsed');
        return delay(1000);
    })
    .then(function() {
        console.log('2 seconds elapsed');
    });

И, как вы видите, цепочка обещаний позволяет избежать пирамиды из коллбэков. Независимо от того, как много уровней коллбэков, эквивалент на обещаниях будет плоским.

Можете ли вы теперь, используя то, о чем мы говорили выше, изменить пример с анимацией с помощью функции delay? Для вашего удобства я повторю первый пример. Чтобы проверить свое решение, наведите курсор мыши над пустым полем ниже.


runAnimation(0);
setTimeout(function() {
    runAnimation(1);    
    setTimeout(function() {
        runAnimation(2);
    }, 1000);
}, 1000);

runAnimation(0);
setTimeout(function() {
    runAnimation(1);    
    setTimeout(function() {
        runAnimation(2);
    }, 1000);
}, 1000);

Какой из этих примеров вам проще понять? Версию с обещаниями, или версию с функциями обратного вызова? Дайте мне знать в комментариях!

До этого все было относительно просто, но есть несколько сложных моментов. Например:

Обработчик reject в функции promise.then возвращает успешно завершенное обещание.

Тот факт, что обработчики отказа возвращают по умолчанию успешное обещание, заставил меня помучаться, когда я только изучал эту тему — я не позволю этому случиться с вами. Вот пример того, за чем стоит следить:


new Promise(function(resolve, reject) {
    reject(' :( ');
})
    .then(null, function() {
        // Handle the rejected promise
        return 'some description of :(';
    })
    .then(
        function(data) { console.log('resolved: '+data); },
        function(error) { console.error('rejected: '+error); }
    );

Что выведется в консоли? Проверьте ответ, наведя курсор мыши над полем ниже:


resolved: some description of :(

Если вы хотите обработать ошибку, возникающую в reject, убедитесь, что не просто возвращаете значение, а возвращаете отклоненное обещание. Т.е. вместо:


return 'some description of :('

Используйте волшебный прием, возвращающий отклоненное обещание с заданным значением:


// Я расскажу об этом позже
return Promise.reject({anything: 'anything'});

Кроме того, вы можете бросить исключение, при этом поможет тот факт, что

promise.then возвращает исключения в отклоненное обещание

Это означает, что вы можете в обработчике (успеха или неудачи) вернуть отклоненное обещание, сделав следующее:


throw new Error('some description of :(')

Имейте в виду, что, как и в функции, переданной в new Promise, любое исключение, брошенное в обработчиках, переданных в promise.then, будет возвращено в отклоненное обещание — вместо того, чтобы отобразиться в консоли, как вы могли бы ожидать. Из-за этого важно убедиться, что вы закончите всю цепочку только обработчиком reject — в противном случае вы можете потратить часы, пытаясь понять, где же ошибка (другое решение вы можете посмотреть в статье Are JavaScript Promises swallowing your errors? ).

Пример, демонстрирующий это решение:


delay(1000)
    .then(function() {
        throw new Error("oh no.");
    })
    .then(null, function(error) {
        console.error(error);
    });

3. promise.catch(onReject)

Здесь все просто. promise.catch(handler) — это эквивалент promise.then(null, handler).

Нет, если серьезно, это не все, что он делает.

Один из паттернов — добавлять catch в конце каждой цепочки обещаний. Давайте вернемся к примеру с анимацией для демонстрации.

Допустим, у нас есть три шага анимации, с секундным отставанием между ними. Каждый шаг может бросить исключение, — например, из-за отсутствия поддержки браузером — после каждого then мы добавим блок catch, в котором сделаем нужные изменения, но без анимации.

Можете ли вы написать это, используя функцию delay, при условии, что на каждом шаге анимации можно назвать функцию runAnimation(number), а в качестве резерва можно вызвать runBackup(number)? Проверять нужно каждый шаг в отдельности, а не все, на случай, если бразуер все же может выполнить какие-то из шагов. Для проверки ответа наведите курсор на блок ниже.


try {
    runAnimation(0);
}
catch (e) {
    runBackup(0);
}

delay(1000)
    .then(function() {
        runAnimation(1);
        return delay(1000);
    })
    .catch(function() {
        runBackup(1);
    })
    .then(function() {
        runAnimation(2);
    })
    .catch(function() {
        runBackup(2);
    });

На сколько ваше решение похоже на мое? Если у вас есть вопросы, почему я сделал именно так, оставляйте комментарии!

В приведенном выше примере интересно сходство между блоком try/catch и обещаниями. Некоторые люди думают об общаниях как об отложенных блоках try/catch — я так не делаю, но думаю, это не повредит.

Итак, это три функции, которые вам нужно знать, чтобы использовать обещания. Но чтобы использовать из прекрасно, вы должны знать четвертую.

4. Promise.all([promise1, promise2, …])

Функция Promise.all действительно удивительна. То, что доставляло столько боли при реализации на коллбэках, что я даже не решился привести пример, с ее помощью сделать очень просто.

Что она делает? Она возвращает обещание, которое успешно, если все аргументы успешны, и отклонен, когда любой из его аргументов отклонен. В случае успеха результирующее обещание содержит массив результатов каждого обещания, а в случае неудачи — ошибку первого неуспешного обещания.

Для чего это может быть полезно? Например, если мы хотим выполнить две анимации параллельно.


parallel animation 1  \
                                      + - subsequent animation         
parallel animation 2  /

Вы можете поломать голову и сделать это на коллбэках. Или найти, скачать и подключить библиотеку, которая сделает это немного проще.

Или можете использовать Promise.all.

Допустим, у нас есть три функции, первые две parallelAnimation1() и parallelAnimation2() возвращают обещания, когда анимация завершится, а третья finalAnimation() должна вызваться, когда завершатся первые две. Реализовать такую логику мы можем следующим образом:


Promise.all([
    parallelAnimation1(),
    parallelAnimation2()
]).then(function() {
    finalAnimation();
});

Просто, не правда ли?

Другие случаи для использования Promise.all — загрузка нескольких HTTP-запросов одновременно, запуск нескольких процессов одновременно, или несколько одновременных запросов к базе данных. С Promise.all сделать это все легко.

Проверьте свои знания

Теперь, когда вы знаете четыре функции для работы с обещаниями, проверим ваши знания.

Ваша задача:

  • Скачать два файла с сервера
  • Извлечь из них некоторые данные
  • Использовать из, чтобы загрузить третий файл с сервера
  • Отобразить данные из третьего файла с помощью alert() или вызвать alert() с сообщением об ошибке.

Функции скачивания первых двух файлов возвращают обещания, функция скачивания третьего файла, к сожалению, требует функцию обратного вызова.

Доступны следующие функции:

  • initialRequestA() - возвращает первое обещание A
  • initialRequesВ() - возвращает второе обещание B
  • getOptionsFromInitialData(a, b) возвращает аргумент options для функции finalRequest
  • finalRequest(options, callback) запрашивает третий файл с сервера, вызывает callback(error, data) после выполнения. Объект data принимает значение undefined в случае ошибки, объект error принимает значение undefined в случае успеха (распространенный паттерн в node.js).

Вы можете проверить свои знания здесь, где я уже написал эти функции для вас.

Когда напишете свое решение, вы можете проверить его, наведя курсо мыши на блок ниже.


function finalRequestPromise(options) {
    return new Promise(function(resolve, reject) {
        finalRequest(options, function(error, data) {
            if (error) {
                reject(error);
            }
            else {
                resolve(data);
            }
        });
    });
}

Promise
    .all([initialRequestA(), initialRequestB()])
    .then(function(results) {
        var options = getOptionsFromInitialData(results[0], results[1]);
        return finalRequestPromise(options);
    })
    .then(
        function(file) { alert(file); },
        function(error) { alert('ERROR: '+error); } 
    );

Бонус: две невероятно полезные функции, которые вы можете узнать с минимальными усилиями

Теперь, когда вы знаете четыре функции, которые вам нужно знать, рассмотрим две функции, которые невероятно полезно знать.

Promise.resolve(value)

Функция Promise.resolve возвращает обещание, которое успешно завершено с переданным значением. Эта функция является эквивалентом следующего кода:


new Promise(function(resolve) {
    resolve();
}).then(function() {
    return value;
});

Promise.reject(value)

Функция Promise.reject(value) возвращает неуспешное обещание с переданным значением. Она является эквивалентом следующего кода:


new Promise(function(resolve, reject) {
    reject(value);
});

Это невероятно полезная функция, если вы хотите обработать ошибку, но не хотите возвращать успешное обещание после этого.

Шпаргалка

В статье я привел довольно много информации. Ключ к запоминанию — использовать все это. Но если вы что-то забудете, искать это по статье неудобно.

Поэтому я сделал шпаргалку. Ее можно распечатать и повесить рядом с монитором или на двери туалета.

Полезные ссылки

Больше узнать о ES6 можно в моей статье The Bits You’ll Actually Use .

Хотите почитать больше об обещаниях? Вам может быть интересно:

Рассылка
Подпишитесь на рассылку и получайте дайджест новостей и статей.
Никакого спама!
Подписаться