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

Взгляните на следующий фрагмент кода:

function fn(obj) {
  const someProp = obj.someProp
  return Promise.resolve(someProp)
}

async function asyncFn(obj) {
  const someProp = obj.someProp
  return Promise.resolve(someProp)
}

asyncFn().catch(err => console.error('Catched')) // => 'Catched'
fn().catch(err => console.error('Catched')) // => TypeError: Cannot read property 'someProp' of undefined

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

Это значит, что JavaScript гарантирует, что функция asnycFn вернет промис (либо выполнится успешно, либо выполнится с ошибкой), даже если в нем произошла ошибка, в нашем случае блок .catch() поймает ее.

Однако в случае с функцией fn движок еще не знает, что функция вернет промис, и поэтому выполнение кода не дойдет до блока .catch(), ошибка не будет поймана и вывалится в консоль.

Более жизненный пример


Я знаю, о чем вы сейчас думаете:
«Когда же, черт возьми, я совершу такую ошибку?»

Угадал?

Ну, давайте создадим простое приложение, которое делает именно это.

Допустим, у нас есть приложение, созданное с помощью Express и MongoDB, использующее драйвер MongoDB Node.JS. Если вы мне не доверяете, я разместил весь исходный код в этом репозитории Github, поэтому вы можете клонировать его и запустить локально, но я также продублирую весь код здесь.

Вот наш файл app.js:

// app.js
'use strict'

const express = require('express')
const db = require('./db')

const userModel = require('./models/user-model')
const app = express()

db.connect()

app.get('/users/:id', (req, res) => {
  return userModel
    .getUserById(req.params.id)
    .then(user => res.json(user))
    .catch(err => res.status(400).json({ error: 'An error occured' })) // <=== ВОТ ЭТОТ!
})

app.listen(3000, () => console.log('Server is listening'))

Внимательно посмотрите на блок .catch()! Вот где будет (не будет) происходить магия.

Файл db.js используется для подключения к базе данных mongo:

'use strict'

const MongoClient = require('mongodb').MongoClient

const url = 'mongodb://localhost:27017'
const dbName = 'async-promise-test'

const client = new MongoClient(url)

let db

module.exports = {
  connect() {
    return new Promise((resolve, reject) => {
      client.connect(err => {
        if (err) return reject(err)
        console.log('Connected successfully to server')

        db = client.db(dbName)
        resolve(db)
      })
    })
  },
  getDb() {
    return db
  }
}

И, наконец, у нас есть файл user-model.js, в котором на данный момент определена только одна функция getUserById:

// models/user-model.js
'use strict'

const ObjectId = require('mongodb').ObjectId
const db = require('../db')

const collectionName = 'users'

module.exports = {
  /**
   * Get's a user by it's ID
   * @param {string} id The id of the user
   * @returns {Promise<Object>} The user object
   */
  getUserById(id) {
    return db
      .getDb()
      .collection(collectionName)
      .findOne({ _id: new ObjectId(id) })
  }
}

Если вы снова посмотрите на файл app.js, вы увидите, что при переходе по адресу localhost:3000/users/<id> мы вызываем функцию getUserById, определенную в файле user-model.js, передав в качестве запроса параметр id.

Допустим, вы переходите по следующему адресу: localhost:3000/users/1. Как думаете, что произойдет дальше?

Ну, если вы ответили: «Я увижу огромную ошибку от MongoClient» — вы были правы. Чтобы быть точнее, вы увидите следующую ошибку: Error: Argument passed in must be a single String of 12 bytes or a string of 24 hex characters.

И как вы думаете, будет ли вызван блок .catch() в следующем фрагменте кода?

// app.js

// ... код ...

app.get('/users/:id', (req, res) => {
  return userModel
    .getUserById(req.params.id)
    .then(user => res.json(user))
    .catch(err => res.status(400).json({ error: 'An error occured' })) // <=== ВОТ ЭТОТ!
})

// ... код ...

Нет. Он не будет вызван.

А что произойдет, если вы измените объявление функции на это?

module.exports = {
  // Обратите внимание, что ключевое слово async должно быть именно тут!
  async findById(id) {
    return db
      .getDb()
      .collection(collectionName)
      .findOne({ _id: new ObjectId(id) })
  }
}

Ага, вы начинаете понимать, что к чему. Наш блок .catch() будет вызван, и мы сможем обработать пойманную ошибку и показать ее пользователю.

Вместо заключения


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

Я просто хотел, чтобы вы знали, что иногда промисы могут иметь большое значение, и когда (да, не «если») вы столкнетесь с ошибкой, рассмотренной в этой статье, вы будете знать возможную причину ее появления.

P.S. Прим. перев.: к оригинальной статье был оставлен полезный комментарий от пользователя Craig P Hicks, который (после замечаний в комментариях) я решил привести тут:
Хотел бы обратить внимание на одну деталь, (в моей среде разработки) ошибки, которые происходят в теле Promise.resolve({<body>}) не «ловятся»:

Promise.resolve((()=>{throw "oops"; })())
    .catch(e=>console("Catched ",e));
// блок .catch() не срабатывает и ошибка не "ловится"

но ошибки, возникающие в теле new Promise() (прим. перев.: в оригинале «proper Promise»), «ловятся»:

(new Promise((resolve,reject)=>{
    resolve((()=>{throw "oops"})())
}))
.catch(e=>console.log("Catched ",e));
// Catched  oops

Как насчет этого утверждения:

async function fn() { <body> }

семантически такой вариант эквивалентен этому:

function fn() {
    return new Promise((resolve,reject)=>{
        resolve({ <body> })
    })
}

Следовательно, фрагмент кода ниже будет отлавливать ошибки, если в <body> будет new Promise() (прим. перев.: в оригинале «proper Promise»):

function fn() {
    return Promise.resolve({<body});
}

Таким образом, чтобы пример из начала статьи «ловил» ошибки в обоих случаях, нужно возвращать в функциях не Promise.resolve(), а new Promise():

function fn(obj) {
  return new Promise((resolve, reject) => {
    const someProp = obj.someProp;
    resolve(someProp);
  });
}

async function asyncFn(obj) {
  return new Promise((resolve, reject) => {
    const someProp = obj.someProp;
    resolve(someProp);
  });
}

asyncFn().catch(err => console.error("Catched")); // => 'Catched'
fn().catch(err => console.error("Catched")); // => 'Catched'

Комментарии (12)


  1. megahertz
    11.11.2019 03:47

    Для TypeScript полезно правило линтера promise-function-async. Инстинктивно стараюсь избегать вызова асинхронных функций из синхронных, в идеале then/catch остаются только в единственной точке входа.


  1. BerkutEagle
    11.11.2019 04:28
    +1

    Надо просто понимать, во что развернётся сахар async'а.
    Перепишите вашу функцию так:

    function fn(obj) {
      return new Promise((resolve, reject) => {
        try {
          const someProp = obj.someProp;
          resolve(someProp);
        } catch (e) {
          reject(e);
        }
      })
    }
    


    1. mayorovp
      11.11.2019 11:02

      Это как раз пример того как делать НЕ надо. Во-первых, конструктор Promise сам поймает ошибку, try-catch тут полностью лишний. Ну или же лишним можно считать new Promise, поскольку try-catch тут и сам справится.


      Во-вторых, написать async гораздо проще.


    1. vintage
      11.11.2019 13:50

      Он во что-то такое развернётся:


      function fn(obj) {
        return new Promise( resolve => {
          const someProp = obj.someProp;
          resolve(someProp);
        } )
      }


  1. BerkutEagle
    11.11.2019 05:23
    +1

    Перевод, это конечно круто. Но перевод ради перевода? Если хотели сделать полезное, то перевели бы и единственный комментарий к оригиналу, объясняющий, почему автор неправ.


  1. amakhrov
    11.11.2019 09:10
    +4

    Иногда встречаются рекомендации начинать цепочку промисов с Promise.resolve() — как раз чтобы избежать описанной проблемы.
    То есть вместо


    userModel.getUserById(req.params.id)
        .then(user => res.json(user))
        .catch()

    пишем


    Promise.resolve()
        .then(() => userModel.getUserById(req.params.id))
        .then(user => res.json(user))
        .catch()

    Минус — лишний код. Плюс — работает даже в случае, когда userModel.getUserById пришла из сторонней библиотеки, и мы не можем явно пометить ее как async


  1. justboris
    11.11.2019 12:28

    Выше уже заметили, что нужно помещать код внутрь new Promise((resolve, reject) => {...}), чтобы избежать такой проблемы (в случае если async-await по какой-то причине использовать нельзя, например, IE11).


    В общем-то именно ради возможности словить ошибку и появилось такое API c замыканием вместо классического Defferred.resolve/reject из jQuery и других ранних реализаций промисов. Вот тут можно почитать подробнее: https://stackoverflow.com/questions/28687566/difference-between-defer-promise-and-promise


    1. RifleR
      12.11.2019 14:23

      в случае если async-await по какой-то причине использовать нельзя, например, IE11

      ну в IE11 и Promise нативно не поддерживается, так что реальных причин не использовать везде async-await не существует.


      1. justboris
        12.11.2019 15:12

        Полифилл promise подключается один раз и весит 1Kb или около того, а транспиляция раздувает исходный код. Было


        async function loadJSON() {
          const response = await fetch('/url');
          const json = await response.json();
          return json;
        }

        стало


        function loadJSON() {
          var response, json;
          return regeneratorRuntime.async(function loadJSON$(_context) {
            while (1) {
              switch (_context.prev = _context.next) {
                case 0:
                  _context.next = 2;
                  return regeneratorRuntime.awrap(fetch('/url'));
        
                case 2:
                  response = _context.sent;
                  _context.next = 5;
                  return regeneratorRuntime.awrap(response.json());
        
                case 5:
                  json = _context.sent;
                  return _context.abrupt("return", json);
        
                case 7:
                case "end":
                  return _context.stop();
              }
            }
          });
        }

        Поэтому я могу вполне себе представить вариант, где Promise доступен, а async/await – нет.


        1. t_kanstantsin
          12.11.2019 15:21

          Не могу утверждть, работает ли это так как ожидается, но есть плагин для babel, который преобразует async-await в Promise.
          Т.е. решая проблему "в лоб" можно глобально подключить полифил + плагин для async->Promise


          1. justboris
            12.11.2019 20:19

            Интересно, хороший плагин, очень жаль, что стандартный @babel/preset-env, производит намного больше кода.


          1. mayorovp
            13.11.2019 06:48

            Судя по примеру по ссылке, этот плагин ломает код. При преобразовании URL.createObjectURL(blob) в .then(URL.createObjectURL) у метода createObjectURL теряется контест (this). И если конкретно для этого метода контекст не требуется, для других методов это не так.