Как работает JavaScript: часть третья, окончание

Асинхронный JS: промисы, async/await и полезные советы.
08 ноября 2017225057Андрей Никифоров155404

Здравствуйте!

Это окончание перевода статьи «How JavaScript works: Event loop and the rise of Async programming + 5 ways to better coding with async/await». В начале мы разобрались с тем, как вообще работает асинхронный код в JS, как работает петля событий и коллбеки. Пришло время поговорить о том, чем плохи коллбеки и какие есть альтернативы.

Начало статьи: geekbrains.ru/posts/javascript_internals_part3

Вложенные коллбеки

Посмотрите на код:

listen('click', function (e){
    setTimeout(function(){
        ajax('https://api.example.com/endpoint', function (text){
            if (text == "hello") {
            doSomething();
        }
        else if (text == "world") {
            doSomethingElse();
            }
        });
    }, 500);
});

Тут у нас цепочка из трех функций, вложенных друг в друга, и каждая представляет один шаг асинхронной серии. Код такого типа часто называется «callback hell». Но это понятие и проблема глубже, чем просто вложение и выравнивание кода.

Сперва мы ждем события «click», затем ждем окончания таймера, и наконец ожидаем ответа AJAX, и повторяем все снова. На первый взгляд, код можно разбить на шаги так:

listen('click', function (e) {
    // ..
});

Затем так:

setTimeout(function(){
    // ..
}, 500);

Потом так:

ajax('https://api.example.com/endpoint', function (text){
    // ..
});

И наконец:

if (text == "hello") {
    doSomething();
}
else if (text == "world") {
    doSomethingElse();
}

Итак, такой последовательный способ выражения вашего асинхронного кода кажется намного более естественным, не так ли? Должен быть такой вариант, не так ли?

Промисы

Взгляните на код:

var x = 1;
var y = 2;
console.log(x + y);

Это очень понятный код: он складывает значения x и y, и печатает результат в консоль. Однако что будет, если значение x или y не задано и будет определено позже? Скажем, нам нужно получить значения с сервера, прежде чем мы сможем их использовать в выражении. Представим, что у нас есть функции loadX и loadY, загружающие значения с сервера. И представим, что у нас есть функция, складывающая значения только тогда, когда оба они доступны.

Это может выглядеть примерно так (немного уродливо):

function sum(getX, getY, callback) {
    var x, y;
    getX(function(result) {
        x = result;
        if (y !== undefined) {
            callback(x + y);
        }
    });
    getY(function(result) {
        y = result;
        if (x !== undefined) {
            callback(x + y);
        }
    });
}
// A sync or async function that retrieves the value of `x`
function fetchX() {
    // ..
}

// A sync or async function that retrieves the value of `y`
function fetchY() {
    // ..
}
sum(fetchX, fetchY, function(result) {
    console.log(result);
});

Обратите внимание вот на что: в этом примере мы определяем x и y как будущие значения, и вызываем операцию sum, которая (внешне) не заботится о том, доступны или нет значения в данный момент.

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

Ценность промисов

Давайте примерно прикинем, как мы можем переписать пример с x + y через промисы:

function sum(xPromise, yPromise) {
    // `Promise.all([ .. ])` takes an array of promises,
    // and returns a new promise that waits on them
    // all to finish
    return Promise.all([xPromise, yPromise])

    // when that promise is resolved, let's take the
    // received `X` and `Y` values and add them together.
    .then(function(values){
        // `values` is an array of the messages from the
        // previously resolved promises
        return values[0] + values[1];
    } );
}

// `fetchX()` and `fetchY()` return promises for
// their respective values, which may be ready
// *now* or *later*.
sum(fetchX(), fetchY())

// we get a promise back for the sum of those
// two numbers.
// now we chain-call `then(...)` to wait for the
// resolution of that returned promise.
.then(function(sum){
    console.log(sum);
});

Здесь мы имеем два уровня промисов.

fetchX и fetchY вызываются напрямую, и значения, которые они возвращают (промисы) передаются в sum(). Нижележащие значения, которые эти промисы представляют, могут быть доступны сейчас или позже, но каждый промис приводит свое поведение к общему знаменателю. Мы рассуждаем о значениях x и y независимо от времени. Это будущие значения, точка.

Второй уровень промисов создается функцией sum() через Promise.all([ ... ]), результат которого мы ожидаем через this(). Когда операция sum() закончит работу, мы сможем вывести наше будущее значение в консоль. Логика ожидания будущих значений x и y скрыта внутри sum().

Примечания: Внутри sum() вызов Promise.all([ … ]) создает промис, который ждет, пока promiseX и promiseY вернут значения. Вызов .then() создает отдельный промис, который вернет результат values[0] + values[1] сразу, как только он будет доступен. Таким образом, вызов then() после sum() на самом деле работает с этим вторым промисом, а не с первым, созданным Promise.all([ ... ]). Кроме того, этот then() тоже возвращает промис, и мы выбираем, как его использовать. Эта система с цепочками промисов будет объяснена детально чуть ниже.

Для промисов вызов then() на самом деле выполняет две функции: первая — заполнение, как было показано выше, а вторая — отклонение:

sum(fetchX(), fetchY())
.then(
    // fullfillment handler
    function(sum) {
        console.log( sum );
    },
    // rejection handler
    function(err) {
        console.error( err ); // bummer!
    }
);

Если что-то пошло не так при получении x или y, или что-то еще сломалось в процессе, промис, возвращаемый sum(), будет отклонен, и второй обработчик ошибки, переданный в then, получит отклоненное значение промиса.

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

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

Это действительно полезно, когда вы сцепляете промисы:

function delay(time) {
    return new Promise(function(resolve, reject){
        setTimeout(resolve, time);
    });
}

delay(1000)
.then(function(){
    console.log("after 1000ms");
    return delay(2000);
})
.then(function(){
    console.log("after another 2000ms");
})
.then(function(){
    console.log("step 4 (next Job)");
    return delay(5000);
})
// ...

Примечание: поскольку промис внешне иммутабелен после разрешения, его можно безопасно передавать куда угодно, зная, что он не может быть изменен случайно или намеренно. Это особенно важно в случае, когда за одним промисом наблюдают несколько наблюдателей: ни один из них не сможет нарушить процесс наблюдения других наблюдателей. Иммутабельность может выглядеть как академический казус, но на деле это один из фундаментальных и важных аспектов промисов, и его нельзя просто пропустить мимо ушей.

Обещать или не обещать?

Прим. переводчика: я не люблю делать сноски, указывающие на игру слов, но в оригинальном заголовке игра слов: «To Promise or not to Promise?». Однако я решил не переводить promise как обещание, потому что это уже устоявшийся термин, калькированный с английского.

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

Мы знаем, что промисы создаются как new Promise(…), и вы можете подумать, что проверки p instanceof Promise будет достаточно. Ну, не совсем.

В-основном потому что вы можете получить промис из другого окна (например, iframe), и эта проверка не сработает.

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

Поглощение исключений

Если на любом этапе создания или ожидания промиса появляется ошибка JavaScript, например TypeError или ReferenceError, выбрасывается исключение, а промис отклоняется. Например:

var p = new Promise(function(resolve, reject){
    foo.bar();    // `foo` is not defined, so error!
    resolve(374); // never gets here :(
});

p.then(
    function fulfilled(){
        // never gets here :(
    },
    function rejected(err){
        // `err` will be a `TypeError` exception object
    // from the `foo.bar()` line.
    }
);

Но что произойдет, если промис уже вернул значение, и в коллбеке then() происходит исключение? Несмотря на то, что он не будет потерян, процесс его обработки может немного удивить. Пока вы не заглянете глубже:

var p = new Promise( function(resolve,reject){
    resolve(374);
});

p.then(function fulfilled(message){
    foo.bar();
    console.log(message);   // never reached
},
    function rejected(err){
        // never reached
    }
);

Может показаться, что исключение из foo.bar() действительно поглощено. На самом деле нет. Немного глубже произошло событие, которое мы не слушаем. Вызов p.then(…) возвращает новый промис, который будет отклонен с исключением TypeError.

Обработка неперехваченных исключений

Есть другие подходы, которые могут показаться удобнее.

Частое предложение — добавить промисам метод done(), который помечает цепь промисов как законченную. done() не возвращает новый промис, так что коллбеки, переданные в done(), не связаны, чтобы не передавать исключения в несуществующую цепочку.

Проблема решается, когда вы явно ожидаете неперехваченные исключения: каждое исключение внутри обработчика done() будет выброшено как глобальное исключение (обычно в консоль разработчика):

var p = Promise.resolve(374);

p.then(function fulfilled(msg){
    // numbers don't have string functions,
    // so will throw an error
    console.log(msg.toLowerCase());
})
.done(null, function() {
    // If an exception is caused here, it will be thrown globally 
});

Что будет в ES8? Async/await

JavaScript ES8 представляет async/await, что сделает работу с промисами проще. Мы пробежимся по возможностям, предоставляемым async/await, и тому, как можно их использовать.

Так что давайте посмотрим, как это работает.

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

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

Асинхронная функция может содержать выражение await, которое приостанавливает выполнение функции и ждет разрешения соответствующего промиса, и потом продолжает выполнение.

Вы можете думать, что промисы в JavaScript — аналог Future в Java или Task в C#. Цель async/await — упростить использование промисов. Давайте посмотрим пример:

// Just a standard JavaScript function
function getNumber1() {
    return Promise.resolve('374');
}
// This function does the same as getNumber1
async function getNumber2() {
    return 374;
}

Аналогично, функции, выбрасывающие исключение эквивалентны функциям, возвращающим отклоненные промисы:

function f1() {
    return Promise.reject('Some error');
}
async function f2() {
    throw 'Some error';
}

await используется только внутри async функции, и позволяет вам синхронно ожидать результата промиса. Если вы используете промисы снаружи async, вам все еще понадобится коллбек then:

async function loadData() {
    // `rp` is a request-promise function.
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Currently, both requests are fired, concurrently and
    // now we'll have to wait for them to finish
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}
// Since, we're not in an `async function` anymore
// we have to use `then`.
loadData().then(() => console.log('Done'));

Вы так же можете определить асинхронную функцию как асинхронное выражение. Это похоже по механизму действия и синтаксису на функцию. Главное отличие между асинхронной функцией и выражением состоит в том, что возможно определять анонимные асинхронные функции. Асинхронное выражение может быть использовано как  IIFE (Immediately Invoked Function Expression), которое будет выполнено сразу после определения.

Вот как это выглядит:

var loadData = async function() {
    // `rp` is a request-promise function.
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Currently, both requests are fired, concurrently and
    // now we'll have to wait for them to finish
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}

Но что важнее всего, async/await поддерживается всеми современными браузерами:

Кроме того, поддерживать совместимость помогают транспилеры вроде Babel и TypeScript.

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

5 советов по написанию поддерживаемого и крепкого аcинхронного кода

Чистый код
Использование async/await позволяет вам писать меньше кода. Каждый раз, используя async/await, вы пропускаете некоторые ненужные шаги: .then, создание анонимной функции для обработки ответа, именование ответа из коллбека и так далее.

// `rp` is a request-promise function.
rp(‘https://api.example.com/endpoint1').then(function(data) {
 // …
});

против

// `rp` is a request-promise function.
var response = await rp(‘https://api.example.com/endpoint1');

Обработка ошибок
Async/await позволяет перехватывать синхронные и асинхронные ошибки одним и тем же кодом, хорошо известным try-catch. Посмотрите, как это выглядит с промисами:

function loadData() {
    try { // Catches synchronous errors.
        getJSON().then(function(response) {
            var parsed = JSON.parse(response);
            console.log(parsed);
        }).catch(function(e) { // Catches asynchronous errors
            console.log(e); 
        });
    } catch(e) {
        console.log(e);
    }
}

против

async function loadData() {
    try {
        var data = JSON.parse(await getJSON());
        console.log(data);
    } catch(e) {
        console.log(e);
    }
}

Условия
Написание кода с условиями с async/await намного понятнее.

function loadData() {
  return getJSON()
    .then(function(response) {
      if (response.needsAnotherRequest) {
        return makeAnotherRequest(response)
          .then(function(anotherResponse) {
            console.log(anotherResponse)
            return anotherResponse
          })
      } else {
        console.log(response)
        return response
      }
    })
}

против

async function loadData() {
  var response = await getJSON();
  if (response.needsAnotherRequest) {
    var anotherResponse = await makeAnotherRequest(response);
    console.log(anotherResponse)
    return anotherResponse
  } else {
    console.log(response);
    return response;    
  }
}

Стек-фреймы
В отличие от async/await, стектрейс, возвращаемый из цепочки промисов, не дает понимания, где произошла ошибка. Взгляните:

function loadData() {
  return callAPromise()
    .then(callback1)
    .then(callback2)
    .then(callback3)
    .then(() => {
      throw new Error("boom");
    })
}
loadData()
  .catch(function(e) {
    console.log(err);
// Error: boom at callAPromise.then.then.then.then (index.js:8:13)
});

против

async function loadData() {
  await callAPromise1()
  await callAPromise2()
  await callAPromise3()
  await callAPromise4()
  await callAPromise5()
  throw new Error("boom");
}
loadData()
  .catch(function(e) {
    console.log(err);
    // output
    // Error: boom at loadData (index.js:7:9)
});

Отладка
Если вы используете промисы, вы знаете, что их отладка — это кошмар. Например, если вы ставите брейкпойнт внутри .then и говорите отладчику встать на этом месте, отладчик не перейдет в .then, поскольку он способен проходить только по синхронному коду. С async/await функции — это обычные синхронные функции, которые отлаживаются без всяких проблем.

Написание асинхронного JavaScript важно не только для самих приложений, но и для библиотек. Например, библиотека SessionStack записывает все происходящее в вашем приложении или сайте: изменения DOM, пользовательский ввод, исключения, стектрейсы, проблемные запросы и отладочные сообщения.

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

И это не просто библиотека! Когда вы воспроизводите пользовательскую сессию в SessionStack, мы показываем все, что происходит в браузере в момент возникновения проблемы, и воссоздаем состояние приложения, так что вы можете перематывать время в поисках проблемы. Чтобы сделать это возможным, мы активно используем асинхронные возможности JavaScript.

У нас есть бесплатный тариф, если хотите попробовать.

Ресурсы:

Пока я переводил эти четыре статьи, автор написал пятую статью про вебсокеты, HTTP/2 и как с этим работать в JavaScript. Постараюсь перевести к началу следующей недели. Оставайтесь на связи!

frontend_developerasynces8promisesasync/awaitjavascript
Нашли ошибку в тексте? Напишите нам.

Новые комментарии