Краткая история асинхронных возможностей Javascript

По мере изучения Javascript я раз за разом натыкался на многочисленные статьи о асинхронных функциях и операциях. Несмотря на несомненные достоинства подобного функционала, каждый раз в затруднение меня вводил листинг, приводимый авторами. Слова менялись, суть оставалась той же, в голове заваривалась каша. Под катом — небольшой гайд по историческому развитию и версиям ECMA.

Зачем нужны асинхронные операции?


Компьютерная программа может выполнять неограниченное количество задач. Не секрет что веб-приложения должны работать со множеством различающихся задач, которые, зачастую, должны использовать одни и те же данные. В частности, одним из самых распространённых примеров является вывод информации для пользователя (UI) и получение информации с помощью запросов к серверу. Неудивительно, ведь с этим сталкивается практически каждый веб-разработчик: работа с базой данный, предоставление пользовательского интерфейса, организация некоторого API – все это есть буквально в каждом тестовом задании не только JS программистов.

Почему не выполнять команды последовательно?

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

  1. Получение информации с сайта https:/some/api/item/1
  2. Вывод информации о первом предмете на экран.

возникнут серьезные затруднения с отрисовкой страницы и созданием приятного впечатления на пользователя (так называемый user experience). Просто представьте: странице, скажем, Netflix или Aliexpress придется получить данные сотен баз данных, прежде чем начать отображать содержимое пользователю. Подобная задержка будет подобна загрузке уровня 3D игры, и если игрок готов подождать, то пользователь веб-сайта хочет получить максимум информации в данный момент.

Решение было найдено: асинхронные операции. Пока основной поток программы занят инициализацией и выводом на канвас элементов веб-сайта, он так же выводит в другие потоки задачи в духе «получиТоварыДляПользователя». Как только этот поток завершает свою работу, информация «оседает» в главном потоке, и становится доступной для отображения, а на самой веб-странице находится определенный placeholder – объект, занимающий место для будущей информации.

image

В этот момент страничка уже отображается, несмотря на то, что некоторые запросы еще не прошли.

image

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

ES5 и ранее: Callback


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

Функцией высшего порядка в JS называется функция, принимающая в качестве аргумента другую функцию. Приведем пример:

objectIsString(objectRef) {
	return typeof(objectRef) === ‘String’;
}
listOfObjects.filter(objectIsString);

Таким образом, в функцию высшего порядка – filter — была передана функция objectIsString, позволяющая отфильтровать listOfObjects и оставить в списке только обьекты типа string.
Похожим образом работают и колбэки. Это функция, передаваемая в качестве аргумента другой функции. Чаще всего в качестве примера функции, обрабатывающей callback, приводят функцию setTimeout. В общем виде это используется как setTimeout(function, timeoutValue), где function – это callback функция, исполняемая браузером через период времени, заданный в timeout.

setTimeout(console.log(1), 2000);
console.log(2);


Выведет 2 1.

ES 6: Обещания (Promises)


В стандарте 6 был представлен новый тип – Promise (обещание, далее – промис). Промис – это тип, объекты которого имеют одно из трех состояний: pending, fulfilled, rejected. Более того, с двумя последними состояниями можно «ассоциировать» функции – коллбэки. Как только асинхронный процесс, описанный в рамках самого промиса придет к успеху/отказу, будет вызвана связанная с этим функция. Этот процесс называют «навешивание коллбэков, и выполняется он с помощью методов then и catch самого промиса. Различие состоит в том, что при вызове then аргументами передаются две функции – на случай успеха (onFullfillment) и провала (onRejected), а catch же принимает, как не трудно догадаться, только функцию для обработки ошибки в промисе. Для того чтобы определить успешно ли выполнен промис в том или ином случае, а так же параметризовать возвращаемый результат

Давайте поэтапно создадим и используем промис.

//Обьявим переменную:
let promise;

//Определим переменную как объект подтипа Promise.
let promise = new Promise((resolve, reject) => {
});
//Заполним функцию промиса, выполняемую асинхронно. 
let promise = new Promise((resolve, reject) => {

  setTimeout(() => {
    resolve("result");
  }, 1000);
});

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

promise
  .then(
    result => {
    },
    error => {
    }
  );
 
// Определим непосредственно работу функций – обработчиков. 
promise
  .then(
    result => {
      // первая функция-обработчик - запустится при вызове resolve
      alert("Fulfilled: " + result); // result - аргумент resolve
    },
    error => {
      // вторая функция - запустится при вызове reject
      alert("Rejected: " + error); // error - аргумент reject
    }
  );

Готово!

Итак, опишем еще раз процесс создания промиса кратко:

  1. Инициализируем объект (new Promise)
  2. Передаем в конструктор в качестве единственного аргумента функцию от resolve и/или reject. В функции должна присутствовать как минимум 1 асинхронная операция
  3. Добавляем с помощью методов then/catch функции – обработчики результата.

Генераторы. Yield


Также в стандарте ES6 был определен новый вид функций – генераторы. Эти функции имеют возможность при идентичных на первый взгляд вызовах несколько раз возвращать разное значение. Давайте разберемся, как они это делают и зачем этим пользоваться.

Стандартный вид генератора: function* functionName() {}. В теле самих функций для возвращения промежуточного значения используется слово yield.

В качестве примера рассмотрим следующий генератор:

function* generateNumber() {
	yield 1;
	yield 2;
	return 3;
}

В данный момент генератор находится в начале своего выполнения. При каждом вызове метода генератора next будет выполнен код, описанный до ближайшего yield (или return), а так же будет возвращено значение, указанное в строке с одним из этих слов.

Let one = generateNumber.next(); // {value: 1, done: false}

Следующий вызов аналогичным образом вернет значение 2. Третий вызов вернет 3 значение, и закончит исполнение функции.

Let two = generateNumber.next(); // {value: 2, done: false}
Let three = generateNumber.next(); // {value: 3, done: false}

Несмотря на это, к генератору все еще можно будет обратиться через функцию next. Он, впрочем, будет возвращать одно и то же значение: объект {done: true}.

ES7. Async/await


Вместе со стремлением угодить любителям ООП с помощью синтаксического сахара классов и имитации наследований, создатели ES7 пытаются облегчить понимание javascript и для любителей писать синхронный код. С помощью конструкций async/await пользователь имеет возможность писать асинхронный код максимально похожий на синхронный. При желании можно избавиться от недавно изученных промисов и переписать код с минимальными изменениями.
Рассмотрим пример:

Используя промисы:

requestBook(id) {
		return bookAPIHelper.getBook(id).then(book => {console.log(book)});
}

С помощью async/await.

async requestBook(id) {
	Const book = await bookAPIHelper.getBook(id);
	Console.log(book);
}

Давайте опишем увиденное:

1) Async – ключевое слово, добавляемое при объявлении асинхронной функции
2) Await – ключевое слово, добавляемое при вызове асинхронной функции.

ES8. Асинхронная Итерация


Синхронно итерироваться по данным стало возможно еще в ES5. Спустя две спецификации было решено добавить возможность асинхронной итерации, работающей в асинхронных источниках данных. Теперь при вызове next() возвращаться будет не {value, done}, а промис (см. ES6).

Давайте рассмотрим функцию createAsyncIterable(iterable).

async function* createAsyncIterable(iterable) {
  for (const elem of iterable) {
      yield elem;
  }
}

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

const asyncIterable = createAsyncIterable(['async 1', 'async 2']);
const asyncIterator = asyncIterable[Symbol.asyncIterator]();
asyncIterator.next()
.then(result => {
    console.log(result);
    // {
    //   value: 'async 1',
    //   done: false,
    // }
    return asyncIterator.next();
})
.then(result => {
    console.log(result);
    // {
    //   value: 'async 2',
    //   done: false,
    // }
    return asyncIterator.next();
})
.then(result => {
    console.log(result);
    // {
    //   value: 'undefined',
    //   done: true,
    // }
});

Более того, в новом стандарте был определен удобный для подобных операций цикл for-await-of.

for await (const x of createAsyncIterable(['a', 'b']))

TL;DR


Вовсе не обязательно знать и наизусть помнить к какой версии ECMAScript относится тот или иной синтаксис, особенно если вы только начали своё знакомство с асинхронным поведением в JS. В то же время, изучение асинхронности именно в порядке, предлагаемом историей развития спецификаций позволит программисту не только в совершенстве понять синтаксис и инструкции, передаваемые JS движку, но и проследить логику совершенствования ECMAScript как продукта, понять тенденции, диктуемые JS разработчиками, разделить их и принять.

Если коротко, то:

Callbacks <= ES5
Promises, Yield (Генераторы): ES6
Async/await: ES7
Async Iterators: ES8
Поделиться публикацией

Похожие публикации

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

    –1
    По мне так промисы нужно отправить на свалку, а оставить колбэки, yield, async/await, и Worker-ы еще допилить, чтобы быстрее стартовали, и могли общую память читать. И будет шикарный язык.
      0
      И как же у вас async/await будет без промисов работать-то?
        0
        Синтаксис промисов устарел, да изначально он был костылем, ключевые слова async/await по крайней мере выделяются редакторами, за них цепляется взгляд, а then() выглядит как простая функция.
      0

      Ну не надо в промисах в then ошибки обрабатывать, а… catch же для того и придумали, чтоб не громоздить по две функции в одну скобку.

        0
        Ну, иногда catch неприменим — когда важно, чтобы обрабатывались лишь ошибки исходного промиса, а не ошибки возникающие в then-ветке. Хотя чаще всего как раз поведение catch более правильное.
          0
          А если сделать `Promise(...).catch(...).then(...)`, то then отработает и после нормального завершения промиса, и после завершения catch, если я правильно понимаю?
            0
            Да.
        +4
        setTimeout(console.log(1), 2000);
        console.log(2);
        Выведет 2 1.
        Нет, эта программа выведет
        1
        2
          +1
          Скорее всего автор хотел показать такой пример:
          setTimeout(function() { console.log(1); }, 2000);
          console.log(2);
          
            0
            setTimeout(() => console.log(1), 2000);
            console.log(2);
              0
              Поскольку пример с обратными вызовами — для ES5 и ранее, ваш вариант не заработает.
              Ну и да, я прекрасно понимаю, что автор имел ввиду. Просто удручают такие ошибки, даже в относительно нетехнических статьях.

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

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