Распространенные ошибки использования promise
Promise правят JavaScript. Даже в наши дни, с введением async/await, они по-прежнему являются обязательными знаниями для любого разработчика JS.
Но JavaScript отличается от других языков программирования тем, как он справляется с асинхронностью. Из-за этого даже разработчики с большим опытом иногда могут попасть в его ловушки. Я лично видел, как великие программисты на Python или Java совершали очень глупые ошибки при написании кода для Node.js или браузеры.
Promise в JavaScript имеют много тонкостей, о которых нужно знать, чтобы избежать этих ошибок. Некоторые из них будут чисто стилистическими, но многие могут привести к фактическим, трудно отслеживаемым ошибкам. Из-за этого я решил составить краткий список из трех наиболее распространенных ошибок, которые, как я видел, делают разработчики при программировании с promise.
Обертывание всего в конструкторе promise
Эта первая ошибка является одной из самых очевидных, и все же я видел, как разработчики делают это на удивление часто. Когда вы впервые узнаете о Promise, вы читаете о конструкторе, который можно использовать для создания новых promise.
Возможно, потому, что люди часто начинают учиться, оборачивая некоторые API браузера (например, setTimeout) в конструктор Promise, в их сознании укоренилось, что единственный способ создать promise-это использовать конструктор. Поэтому в результате они часто получают такой код:
constcreatedPromise = new Promise(resolve => {
somePreviousPromise.then(result => {
// do something with the result
resolve(result);
});
});
Вы можете видеть, что для того, чтобы что-то сделать с результатом somePreviousPromise, кто-то использовал тогда, но позже решил снова обернуть его в конструктор Promise, чтобы сохранить это вычисление в переменной createdPromise, предположительно для того, чтобы позже выполнить еще несколько манипуляций с этим promise.
В этом, конечно, нет необходимости. Весь смысл метода then заключается в том, что он сам возвращает promise, которое представляет собой выполнение somePreviousPromise, а затем выполнение обратного вызова, переданного в then в качестве аргумента, после того, как somePreviousPromise будет разрешен со значением.
Таким образом, предыдущий фрагмент примерно эквивалентен:
constcreatedPromise = somePreviousPromise.then(result => {
// do something with result
return result;
});
Гораздо приятнее, не правда ли? Но почему это только приблизительно эквивалентно? В чем разница?
Это может быть трудно заметить неподготовленному глазу, но на самом деле существует огромная разница в обработке ошибок, гораздо более важная, чем уродливая многословность первого фрагмента. Допустим, что какой-то проект выходит из строя по какой-либо причине и выдает ошибку. Возможно, это Promise делало запрос HTTP ниже, и API ответил ошибкой 500.
Оказывается, что в предыдущем фрагменте, где мы заключаем promise в другоеpromise, у нас вообще нет возможности поймать эту ошибку. Чтобы исправить это, нам придется внести следующие изменения:
constcreatedPromise = new Promise((resolve, reject) => {
somePreviousPromise.then(result => {
// do something with the result
resolve(result);
}, reject);
});
Мы просто добавили аргумент reject в функцию обратного вызова, а затем использовали его, передав его в качестве второго параметра методу then. Очень важно помнить, что метод then принимает второй, необязательный параметр для обработки ошибок.
Теперь, если по какой-либо причине произойдет сбой somePreviousPromise, будет вызвана функция reject, и мы сможем обработать ошибку на createdPromise, как обычно. Так решает ли это все проблемы? К сожалению, нет.
Мы обработали ошибки, которые могут возникнуть в самом somePreviousPromise, но мы все еще не контролируем, что происходит в функции, переданной методу then в качестве первого аргумента. Код, который выполняется в том месте, где мы поместили // сделать что-то с комментарием результата, может содержать некоторые ошибки. Если код в этом месте выдает какую-либо ошибку, он не будет пойман методом отклонения, помещенным в качестве второго параметра метода then.
Это связано с тем, что метод обработки ошибок, передаваемый в качестве второго аргумента, реагирует только на ошибки, которые происходят ранее в нашей цепочке методов. Поэтому правильное (и окончательное) исправление будет выглядеть следующим образом:
constcreatedPromise = new Promise((resolve, reject) => {
somePreviousPromise.then(result => {
// do something with the result
resolve(result);
}).catch(reject);
});
Обратите внимание, что на этот раз мы использовали метод catch, который — поскольку он вызывается после первого, то — будет ловить любые ошибки, которые будут брошены в цепочку выше него. Так что независимо от того, будет ли какое — то promise или обратный вызов в этом случае провалены-наше promise будет обрабатывать его так, как задумано в обоих этих случаях.
Как вы можете видеть, существует много тонкостей при обертывании кода в конструктор Promise. Вот почему лучше просто использовать метод then для создания новых promise, как мы показали во втором фрагменте. Это не только будет выглядеть лучше, но мы также избежим этих угловых случаев.
Последовательные против параллельных
Поскольку многие программисты имеют опыт объектно-ориентированного программирования, для них естественно, что метод мутирует объект, а не создает новый. Вероятно, именно поэтому я вижу, как люди путаются в том, что именно происходит, когда вы вызываете метод then по promise.
Сравните эти два фрагмента кода:
Первый
constsomePromise = createSomePromise();
somePromise
.then(doFirstThingWithResult)
.then(doSecondThingWithResult);
Второй
constsomePromise = createSomePromise();
somePromise
.then(doFirstThingWithResult);somePromise
.then(doSecondThingWithResult);
Они делают то же самое? Может показаться, что это так. В конце концов, оба фрагмента кода включают в себя вызов, а затем дважды на somePromise, верно? Нет, это очень распространенное заблуждение. На самом деле, эти два фрагмента кода имеют совершенно разное поведение. Не полное понимание того, что происходит в них обоих, может привести к сложным ошибкам.
Как мы писали в предыдущем разделе, метод then создает совершенно новое, независимое promise. Это означает, что в первом фрагменте второй метод then вызывается не для somePromise, а для нового объекта Promise, который инкапсулирует (или представляет) ожидание разрешения somePromise, а затем вызывает doFirstThingWithResult сразу после этого. А затем мы добавим обратный вызов doSecondThingWithResult к этому новому экземпляру Promise.
По сути, два обратных вызова будут выполняться один за другим — у нас есть гарантия, что второй обратный вызов будет вызван только после того, как первый обратный вызов завершит выполнение без каких-либо проблем. Более того, первый обратный вызов получит в качестве аргумента значение, возвращаемое somePromise, но второй обратный вызов получит в качестве аргумента все, что возвращается из функции doFirstThingWithResult.
С другой стороны, во втором обрезанном коде мы вызываем метод then на somePromise дважды и в основном игнорируем два новых promise, которые возвращаются из этого метода. Поскольку затем был вызван дважды в одном и том же экземпляре promise, мы не получаем никаких гарантий относительно того, какой обратный вызов будет выполнен первым. Порядок выполнения здесь не определен.
Я иногда думаю об этом как о “параллельном” выполнении, в том смысле, что два обратных вызова должны быть независимыми и не полагаться на то, что какой-либо из них был вызван ранее. Но, конечно, на самом деле двигатели JS выполняют только одну функцию за раз — вы просто не знаете, в каком порядке они будут вызваны.
Второе отличие заключается в том, что и doFirstThingWithResult, и doSecondThingWithResult во втором фрагменте получат один и тот же аргумент — значение, к которому будет разрешен somePromise. Значения, возвращаемые обоими обратными вызовами, в этом примере полностью игнорируются.
Выполнение promise сразу после создания
Это заблуждение также связано с тем, что большинство программистов часто имеют опыт в объектно-ориентированном программировании.
В этой парадигме часто считается хорошей практикой убедиться, что конструктор объектов не выполняет никаких действий сам по себе. Например, объект, представляющий базу данных, не должен инициировать соединение с базой данных, когда его конструктор вызывается с ключевым словом new.
Вместо этого лучше предоставить специальный метод — например, init — который явно создаст соединение. Таким образом, объект не выполняет никаких непреднамеренных действий только потому, что он был инициирован. Он терпеливо ждет, пока программист явно попросит выполнить действие. Но Promise работают не так.
Рассмотрим пример:
constsomePromise = new Promise(resolve => {
// make HTTP request
resolve(result);
});
Вы можете подумать, что функция, выполняющая HTTP-запрос, не вызывается здесь, потому что она завернута в конструктор Promise. На самом деле, многие программисты ожидают, что он будет вызван только после того, как метод then будет выполнен на каком-то объекте.
Но это неправда. Обратный вызов выполняется сразу же после создания этого promise. Это означает, что когда вы находитесь в следующей строке после создания переменной somePromis, ваш HTTP-запрос, вероятно, уже выполняется или, по крайней мере, запланирован.
Мы говорим, что Promise “нетерпеливо”, потому что оно выполняет связанное с ним действие как можно быстрее. В отличие от этого, многие люди ожидают, что Promise будут “ленивыми”, то есть выполнять действие только тогда, когда это абсолютно необходимо (например, когда затем вызывается в первый раз по Promise). Это заблуждение. Promise всегда нетерпеливы и никогда не ленивы.
Но что вы должны делать, если хотите выполнить Promise позже? Что делать, если вы хотите отложить выполнение этого HTTP-запроса? Есть ли какой-то волшебный механизм, встроенный в Promise, который позволил бы вам сделать что-то подобное?
Ответ более очевиден, чем иногда ожидали бы разработчики. Функции-это ленивый механизм. Они выполняются только тогда, когда программист явно вызывает их с помощью синтаксиса скобок (). Простое определение функции на самом деле еще ничего не делает. Так что лучший способ сделать promiseленивым-это… просто обернуть его в функцию!
Посмотрите:
constcreateSomePromise = () => new Promise(resolve => {
// make HTTP request
resolve(result);
});
Теперь мы завернули один и тот же вызов конструктора promise в функцию. Из — за этого пока ничего толком не называется. Мы также изменили имя переменной с somePromise на createSomePromise, потому что на самом деле это больше не promise — это функция, создающая и возвращающая promise.
Конструктор Promise — и, следовательно, функция обратного вызова с HTTP — запросом-будет вызываться только тогда, когда мы выполним эту функцию. Итак, теперь у нас есть ленивое Promise, которое выполняется только тогда, когда мы действительно этого хотим.
Более того, обратите внимание, что бесплатно мы получили еще одну возможность. Мы можем легко создать другое Promise, которое выполняет то же самое действие.
Если по какой-то странной причине мы хотели бы дважды выполнить один и тот же HTTP-вызов и выполнить эти вызовы одновременно, мы можем просто вызвать функцию createSomePromise дважды, один сразу за другим. Или, если запрос по какой-либо причине не выполняется, мы можем повторить его, используя ту же функцию.
Это показывает, что очень удобно заключать promise в функции (или методы), и, следовательно, это шаблон, который должен стать естественным для разработчика JavaScript.
Вывод
В этой статье вы увидели три типа ошибок, которые часто всего встречаются у разработчиков, которые знали о promiseв JavaScript только поверхностно. Есть ли какие-либо интересные типы ошибок, с которыми вы столкнулись в своем коде или в коде других? Если да, поделитесь ими в комментарии.