Если вам доводилось работать с JavaScript, то вы наверняка встречались с синтаксисом async/await. Эта функциональность позволяет прописывать асинхронную логику синхронным образом, упрощая тем самым её понимание. Некоторым ветеранам JS известно, что async/await – это просто синтаксический сахар для существующего Promises API. Это означает, что в JS должен быть способ реализации функциональности async/await без использования ключевых слов async и await, хоть и более громоздкий. Именно об этом и пойдёт речь в данной статье.

Видео от автора на ту же тему.

▍ Наша цель


Для понимания нашей задачи рассмотрим пример шаблонного кода.

function wait() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('Timeout resolved');
    }, 2000);
  });
}

async function main() {
  console.log('Entry');
  const result = await wait();
  console.log(result);
  console.log('Exit');
  return 'Return';
}

main().then(result => {
  console.log(result);
});

Вот его вывод:

Entry
// Пауза 2 секунды
Timeout resolved
Exit
Return

Имея приведённый выше код и соответствующий вывод, можем ли мы переписать функцию main() без использования async и await, по-прежнему получив тот же результат? Условия будут следующими:

  • Не использовать цепочки промисов в main(). Это бы сделало задачу тривиальной и увело нас от изначальной цели. Цепочки промисов могут сработать в примере выше, но это не отразит всю суть async/await и решаемой ими задачи.
  • Не изменять сигнатуры функций. Изменение сигнатур функций потребует обновления их вызовов, что вызовет сложности в крупных проектах со множеством взаимозависимых функций. Старайтесь этого не делать.

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

▍ Песочница


Это практическая статья, и я рекомендую вам использовать приведённую ниже песочницу для проверки собственных идей по реализации функциональности async/await без ключевых слов async и await. В оставшейся части статьи приводятся подсказки и решения для этой задачи, но в процессе вы вполне можете отвлекаться, чтобы попробовать свой подход.

function wait() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('Timeout resolved');
    }, 2000);
  });
}

async function main() {
  console.log('Entry');

  const result = await wait();
  console.log(result);

  console.log('Exit');
  return 'Return';
}

main().then(result => {
  console.log(result);
});


Вывод:

Entry
Timeout resolved
Exit
Return

Примечание переводчика: интерактивная песочница доступна в оригинале статьи.

▍ Подсказка #1: обратите внимание на приостановку и возобновление функции


Как выглядит функциональность async/await на верхнем уровне? Она заключается в приостановке асинхронной функции, когда та встречает инструкцию await, и последующем возобновлении выполнения, когда ожидаемый промис разрешается или выбрасывает ошибку. Здесь логично спросить «Как обработать приостановку/возобновление функции?» Функции стремятся к выполнению до завершения, не так ли?

Существует ли в JS возможность, имитирующая такое поведение? Да, и это генераторы.

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

Я не буду углубляться в возможности генераторов. Более подробную информацию можете найти по прикреплённой ссылке.

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

function* main() {
  console.log('Entry');

  const message = yield 'Result 1';
  console.log(message);

  console.log('Exit');
  yield 'Result 2';

  return 'Return';
}

const it = main();
/**
 * Сейчас в консоли вывода нет, несмотря на вызов main().
 * Вызов .next() для объекта, возвращённого генератором, запускает выполнение.
 */

console.log(it.next());
/**
 * Вывод:
 *   Entry
 *   { value: "Result 1", done: false }
 *
 * Генератор приостановил выполнение на строке 4 и возобновит его после очередного вызова 
 * .next().
 */

console.log(it.next('Message Passing'));
/**
 * .next() также получает аргумент, который становится доступен для инструкции yield, где
 * приостановился генератор.
 *
 * Вывод:
 *   Message Passing
 *   Exit
 *   { value: "Result 2", done: false }
 */

console.log(it.next());
/**
 * Вывод:
 *   { value: "Return", done: true }
 */

Теперь, когда вы знакомы с генераторами, предлагаю вам отвлечься и поэкспериментировать в песочнице.

▍ Подсказка #2: когда возобновлять выполнение функции?


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

function* main() {
  console.log('Entry');

  const result = wait();
  yield;
  console.log(result);

  console.log('Exit');
  return 'Return';
}

const it = main();
it.next(); // Запускает выполнение. Когда нужно снова вызвать it.next()?

Но когда генератор должен возобновить выполнение? Это должно произойти, когда разрешится промис рядом с yield. Откуда вызывающий узнает о промисе, если тот находится в генераторе? Можно ли как-то раскрыть этот промис вызывающему, чтобы он мог прикрепить к нему обратный вызов .then(), который будет вызывать .next() для объекта генератора, чтобы тот продолжил выполнение?

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

function* main() {
  console.log('Entry');

  const result = yield wait();
  console.log(result);

  console.log('Exit');
  return 'Return';
}

const it = main();
it.next().value.then(() => {
  it.next();
});

▍ Подсказка #3: сделать данные разрешившегося промиса доступными для генератора


В предыдущем фрагменте кода мы смогли успешно приостановить выполнение функции и возобновить её, когда разрешился промис. Но генератор не получает от промиса разрешившиеся данные. Переменная result в функции main должна содержать Timeout resolved, что мы видим при использовании async/await. Но в нашей реализации она не получает данные, которые предоставляет промис после своего разрешения.

Можно ли как-то передать эти данные генератору? В конце концов у вызывающего есть к ним доступ, поскольку генератор возвращает промис. Так может ли он передать эти данные обратно генератору, когда вызывающий вызывает для объекта генератора .next? Выше мы уже встречали Message Passing. Функция .next() получает аргумент, оказывающийся доступным для инструкции yield, где генератор был приостановлен. Значит, для передачи данных из разрешившегося промиса мы просто вызываем .next() с этими данными.

function* main() {
  console.log('Entry');

  const result = yield wait();
  console.log(result);

  console.log('Exit');
  return 'Return';
}

const it = main();
it.next().value.then(resolvedData => {
  it.next(resolvedData);
});

Внеся это изменение, мы получили простую реализацию async/await без использования async и await. Обратите внимание на функцию main() и сравните её с аналогичной, использующей async. Они поразительно похожи, не так ли? В нашей реализации вместо async function используется function *, а вместо await – ключевое слово yield. В этом и заключается её красота!

Мы уже значительно продвинулись, и если вам удалось реализовать какие-то из этих шагов самостоятельно, то могу вас только похвалить.

▍ Подсказка #4: расширение реализации для работы с несколькими инструкциями yield


Следующим шагом будет расширение реализации под работу с произвольным числом команд yield. Вышеприведённый фрагмент работает лишь с одной, поскольку вызывает .next() только после разрешения первого промиса. Но генератор может создавать произвольное число промисов. Как написать абстракцию, которая будет динамически ожидать разрешения любого созданного промиса, а затем вызывать .next()?

Этой абстракцией может стать функция (например, run), получающая генератор. Что run должна возвращать? Опять же, если сравнивать с альтернативной функцией, использующей async, то эта функция неявно возвращает Promise, который разрешается, когда она завершает выполнение. Мы можем сымитировать это поведение, возвращая Promise от функции run и разрешая его, только когда генератор закончил своё выполнение.

Ниже показан соответствующий код. Ваша реализация может отличаться.

run(main).then(result => {
  console.log(result);
});

function run(fn, ...args) {
  const it = fn(...args);

  return new Promise((resolve, reject) => {
    // TODO: Вызывать it.next(), пока есть, что yield.
  });
}

▍ Подсказка #5: вызов .next() произвольное число раз


А теперь сосредоточимся на реализации функции run. Она должна вызывать .next() для объекта генератора, пока для промисов выполняется yield. Можно ли сделать это с помощью циклов? Будет ли такой вариант работать ожидаемым образом при использовании промисов? Конечно, нет. Циклы не подойдут, поскольку тогда .next() будет продолжать вызываться для объекта генератора, не ожидая создания и разрешения промисов. Можно ли реализовать поочерёдное выполнение как-то более удачно?

Да, с помощью рекурсии! Используя рекурсию, мы сможем продолжать вызывать .next() для объекта генератора при каждом разрешении промиса. Каково будет условие выхода или базовый кейс для завершения рекурсии? Она должна прекращаться, когда завершается выполнение генератора. Что возвращает .next(), когда это происходит? Свойство done в возвращаемом объекте устанавливается на true.

function run(fn, ...args) {
  const it = fn(...args);

  return new Promise((resolve, reject) => {
    function step() {
      const result = it.next();

      // Условие выхода
      if (result.done) {
        return;
      }

      result.value.then(resolvedValue => {
        step();
      });
    }

    // Вызов step() для начала рекурсии
    step();
  });
}

Сейчас мы не передаём resolvedValue из промиса обратно в генератор. Для этого нужно сделать так, чтобы функция step получала аргумент. Также обратите внимание, что возвращаемый run промис никогда не разрешается, потому что мы нигде не вызываем функцию resolve(). Когда этот промис должен разрешиться? Когда генератор завершает выполнение, и выполнять больше нечего. С чем тогда должен разрешаться промис? С тем, что возвращает генератор, так как это соответствует поведению асинхронных функций.

function run(fn, ...args) {
  const it = fn(...args);

  return new Promise((resolve, reject) => {
    function step(resolvedValue) {
      const result = it.next(resolvedValue);

      // Условие выхода
      if (result.done) {
        resolve(result.value);
        return;
      }

      result.value.then(resolvedValue => {
        step(resolvedValue);
      });
    }

    // Нет необходимости передавать что-либо для начала выполнения генератора.
    step();
  });
}

Вот мы и получили функциональность async/await без async и await!

На этом наша реализация завершается. В ней асинхронные функции представлены как генераторы, а вместо await для ожидания разрешения промисов мы используем инструкции yield. Для разработчика, использующего нашу реализацию, она по-прежнему будет похожей на вариант с async/await и при этом не нарушит два обозначенных в начале статьи условия.

function* main() {
  console.log('Entry');

  const result = yield wait();
  console.log(result);

  const result2 = yield wait();
  console.log(result2);

  // ...

  console.log('Exit');
  return 'Return';
}

run(main).then(result => {
  console.log(result);
});

function run(fn, ...args) {
  const it = fn(...args);

  return new Promise((resolve, reject) => {
    function step(resolvedValue) {
      const result = it.next(resolvedValue);

      // Условие выхода
      if (result.done) {
        resolve(result.value);
        return;
      }

      result.value.then(resolvedValue => {
        step(resolvedValue);
      });
    }

Вывод:
Entry
Timeout resolved
Timeout resolved
Exit
Return

Примечание переводчика: интерактивная песочница доступна в оригинале статьи.

Именно это делают транспиляторы вроде Babel при преобразовании async/await в более старые версии JS, где этой функциональности ещё не было. Если вы взглянете на транспилированный код, то сможете провести множество параллелей с нашей реализацией.



▍ Дальнейшие шаги


Полученная реализация охватывает только успешный путь async/await. Она не обрабатывает сценарии ошибок, когда промис отклоняется. Я хочу оставить эту доработку в качестве упражнения для вас, так как она окажется аналогична реализации успешного пути, и отличаться будут только используемые функции. Для начала взгляните на Generators API, чтобы понять, есть ли способ обратной передачи ошибок в генератор по аналогии с функцией .next().

▍ Заключение


Когда я впервые пришёл к этой реализации, то был поражён её красотой и простотой. Я знал, что async/await является синтаксическим сахаром, но не знал, как эта функциональность выглядит изнутри. В статье я осветил конкретно этот аспект async/await, а также его связь с промисами и генераторами. Мне нравится временами разбираться в абстракциях, чтобы лучше понять, как всё работает изнутри. Так я узнаю новые интересные концепции. Надеюсь, что моя статья стала для вас мотивирующей, подогрев любопытство и стремление изучать что-либо на практике, а не просто через чтение готовых руководств.

Telegram-канал с розыгрышами призов, новостями IT и постами о ретроиграх ????️

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


  1. Coler95
    10.09.2023 19:05

    В чем преимущество такого подхода?


    1. dmitryprogrammer
      10.09.2023 19:05

      Перед чем?


      1. Coler95
        10.09.2023 19:05
        +1

        Перед использованием async await


    1. aamonster
      10.09.2023 19:05
      +5

      Это не подход. Это объяснение (возможного) внутреннего устройства async/await. А писать код лучше с async/await, естественно.

      Кстати, если вы возьмёте код с async/await и прогоните его через транспилятор, чтобы получить код для старого браузера – тоже получите что-то подобное. Довольно неудобно отлаживать будет :-)


  1. Gary_Ihar
    10.09.2023 19:05
    -3

    Было интересно, но:

    Именно это делают транспиляторы вроде Babel при преобразовании async/await в более старые версии JS, где этой функциональности ещё не было. Если вы взглянете на транспилированный код, то сможете провести множество параллелей с нашей реализацией.

    Так может пусть и дальше этим занимаются транспиляторы? а мы будем использовать уже всем знакомый async/await? А то в вебе и так зоопарк подходов и разных технологий...


    1. Eugene_Bubnovich
      10.09.2023 19:05

      Так статья про "Принцип работы async/await в JavaScript"


      1. Gary_Ihar
        10.09.2023 19:05

        в ней предлагается все же окунуться в это дело. Конкретнее - в разделе  *Дальнейшие шаги*. Вот поэтому я и предложил так не делать и не пытаться. А если все таки сильно хочется и есть идее лучше, чем сейчас, то тогда сделать пул-реквест в какой-нибудь транспилятор. И если его примут, то я бы с радостью почитал про то, что и как было улучшено.


    1. aamonster
      10.09.2023 19:05
      +3

      А что, кто-то предлагал так и писать? 8-O

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


      1. Coler95
        10.09.2023 19:05
        +1

        Видимо все читали ночью как и я )) Я думал это призыв к действию, а не подкапотная работа) С утра перечитал и все стало ясно)


        1. Gary_Ihar
          10.09.2023 19:05

          вы все правильно думали и внимательно прочитали.


      1. Gary_Ihar
        10.09.2023 19:05

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

        А вы читали статью ?


        1. aamonster
          10.09.2023 19:05
          +1

          Да. Я читал. Типичная статья вида "реализуем сами, чтобы понять, как оно устроено". Много я таких видел.

          ЗЫ: Типичный пример таких статей – "напишем собственную реализацию LISP". И там тоже примерно всегда оставляются какие-то упражнения для самостоятельного решения.


          1. Gary_Ihar
            10.09.2023 19:05
            -1

            Еще раз. Ваше вообщение:

            А что, кто-то предлагал так и писать? 8-O

            Моя цитата из прочитанной вами статьи:

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

            и мой ответ в самом первом комментарии:

            Так может пусть и дальше этим занимаются транспиляторы? а мы будем использовать уже всем знакомый async/await? ...

            Но да ладно, чет я и так слишком "душнила" в этом треде... Сорян, мир вам


            1. aamonster
              10.09.2023 19:05
              +1

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


              1. Gary_Ihar
                10.09.2023 19:05

                No comments... like ))


  1. antonkrechetov
    10.09.2023 19:05

    Имея приведённый выше код и соответствующий вывод, можем ли мы переписать функцию main() без использования async и await, по-прежнему получив тот же результат?
    Не использовать цепочки промисов в main().
    Очень долго пытался понять, что имел в виду автор такими условиями.
    Например:
    — В main нельзя использовать цепочки промисов и await, а в других функциях (вызываемых из main) — можно?
    — Один вызов then, результат которого не используется, — это уже цепочка? Без then() и await воспользоваться промисом, который возвращает функция wait() не получится.

    Какая реализация соответствует духу задачи? (да, я знаю, что это перевод)

    Ну вот, например, вариант, написанный за 3 минуты. «Цепочек» в main(), вроде, нет:
    async function main() {
      console.log('Entry');
    
      return new Promise(resolve => {
        wait().then(result => {
          console.log(result);
          console.log('Exit');
          resolve('Return');
        });
      });
    }


  1. valera545
    10.09.2023 19:05

    Глупый вопрос: откуда взята функция wait()?


    1. Zenitchik
      10.09.2023 19:05

      В каком смысле "откуда"? Она же в самом начале статьи объявлена.


      1. valera545
        10.09.2023 19:05

        Протупил :( Спасибо!