Разница между асинхронной функцией и функцией, возвращающей промис

Автор оригинала: Mayer János
  • Перевод
Существует небольшая, но довольно важная разница между функцией, которая просто возвращает промис, и функцией, которая была объявлена с помощью ключевого слова 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'
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

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

    0

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

      +4
      Надо просто понимать, во что развернётся сахар async'а.
      Перепишите вашу функцию так:
      function fn(obj) {
        return new Promise((resolve, reject) => {
          try {
            const someProp = obj.someProp;
            resolve(someProp);
          } catch (e) {
            reject(e);
          }
        })
      }
      
        +4

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


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

          +1

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


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

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

            +6

            Иногда встречаются рекомендации начинать цепочку промисов с 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

              0

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


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

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

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

                  0

                  Полифилл 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 – нет.

                    0

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

                      0

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

                        0

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

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

                Самое читаемое