Изучите асинхронное программирование на JavaScript

Асинхронная природа JavaScript - один из аспектов языка, который может сбить с толку многих людей. Однако хорошее понимание основных асинхронных конструкций может уменьшить путаницу в языке. Цель этого руководства - познакомить вас с асинхронным программированием на JavaScript и предоставить вам необходимые методы для написания чистого и поддерживаемого асинхронного кода. Мы начнем с изучения моделей синхронного и асинхронного выполнения и их различий. Затем мы погрузимся в функции обратного вызова и рассмотрим, как они используются для сбора результатов асинхронных операций. После этого мы исследуем обещания и узнаем, как они абстрагируют функции обратного вызова, чтобы упростить асинхронные потоки. Мы также рассмотрим генераторы и узнаем, как их можно использовать в асинхронных потоках. И в конце мы рассмотрим async функции и проиллюстрируем, как их можно использовать вместе с обещаниями для дальнейшего упрощения асинхронных операций.

Примеры кода

Все примеры кода для этого руководства доступны на Gitlab:

Https://gitlab.com/aj_meyghani/asyncjs-code-examples

Вы можете либо клонировать репо, либо загрузить его в виде файла zip.

Другие карманные ссылки

Не забудьте проверить другие мои карманные ссылки:

Вступление

Если вы новичок в асинхронном программировании на JavaScript, вы можете быть удивлены результатом следующего фрагмента кода:

setTimeout(() => console.log('1'), 0);
console.log('2');

Как вы думаете, что будет на выходе из приведенного выше кода? У вас может возникнуть соблазн сказать 1, а затем 2, но правильный ответ - 2, а затем 1. В следующих разделах мы погрузимся в асинхронную модель, используемую в JavaScript, и объясним, почему приведенный выше код печатает 2, а затем 1.

Синхронный против асинхронного

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

Представьте, что в вашем списке дел есть три задачи:

  1. Стирать
  2. Делать продукты
  3. готовить обед

В синхронной модели вы должны завершить каждую задачу, прежде чем переходить к следующей. То есть вы должны сначала закончить стирку, прежде чем переходить к покупке продуктов. Если ваша стиральная машина сломалась, вы не сможете покупать продукты. То же самое относится и к третьему заданию: вы можете приготовить ужин только в том случае, если вы закончили с продуктами (и стиркой).

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

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

В следующем разделе мы рассмотрим цикл событий и узнаем, как JavaScript работает с асинхронными задачами.

Цикл событий

Давайте посмотрим на фрагмент, который мы видели в начале этого раздела:

setTimeout(() => console.log('1'), 0); // A
console.log('2'); // B 

В строке A, когда вы вызываете метод setTimeout, функция обратного вызова () => console.log('1') будет помещена в так называемую очередь сообщений. После этого в строке B будет вставлен console.log(2), что называется стеком, он будет вызван сразу и выведет 2 на консоль. После вызова console.log(2) стек становится пустым, и JavaScript перемещается в очередь и выполняет элементы в очереди. В этом случае у нас есть функция обратного вызова () => console.log('1'), которую нужно выполнить. JavaScript выполняет его и выводит 1 на консоль.

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

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

Функции обратного вызова

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

let name = 'Tom';
hello(name);

В приведенном выше фрагменте кода мы определяем переменную с именем name и присваиваем ей строку. Затем мы передаем его функции hello в качестве аргумента. Мы можем сделать то же самое с функцией. Вместо этого мы можем определить name как функцию и передать ее hello:

let name = () => 'Tom';
hello(name);

С технической точки зрения name - это функция обратного вызова, потому что она передана другой функции, но давайте посмотрим, что такое функция обратного вызова в контексте асинхронной операции.

В асинхронном контексте функция обратного вызова - это обычная функция JavaScript, которая вызывается JavaScript после завершения асинхронной операции. По соглашению, установленному Node, функция обратного вызова обычно принимает два аргумента. Первый фиксирует ошибки, а второй фиксирует результаты. Функция обратного вызова может быть именованной или анонимной. Давайте рассмотрим простой пример, показывающий, как асинхронно читать содержимое файла с помощью узла fs.readFile:

const fs = require('fs');
const handleReading = (err, content) => {
  if(err) throw new Error(err);
  return console.log(content);
};
fs.readFile('./my-file.txt', 'utf-8', handleReading);

В модуле fs есть метод readFile. Он принимает два обязательных аргумента: первый - это путь к файлу, а последний - функция обратного вызова. В приведенном выше фрагменте используется функция обратного вызова handleReading, которая принимает два аргумента. Первый фиксирует потенциальные ошибки, а второй фиксирует содержание.

Ниже приведен еще один пример модуля https для выполнения GET запроса к удаленному серверу API:

// code/callbacks/http-example.js
const https = require('https');
const url = 'https://jsonplaceholder.typicode.com/posts/1';
https.get(url, (response) => {
  response.setEncoding('utf-8');
  let body = '';
  response.on('data', (d) => {
    body += d;
  });
  response.on('end', (x) => {
    console.log(body);
  });
});

Когда вы вызываете метод get, запрос планируется с помощью JavaScript. Когда результат будет доступен, JavaScript вызовет нашу функцию и предоставит нам результат.

«Возврат» асинхронного результата

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

function getData(options) {
  var finalResult;
  asyncTask(options, function(err, result) {
    finalResult = result;
  });
  return finalResult;
}
getData(); // -> returns undefined

В приведенном выше фрагменте кода, когда вы вызываете getData, он немедленно выполняется, и возвращается значение undefined. Это потому, что во время вызова функции finalResult ничего не установлено. Значение устанавливается только спустя более поздний момент времени. Правильный способ обернуть асинхронный вызов - передать внешней функции обратный вызов:

function getData(options, callback) {
  asyncTask(options, callback);
}
getData({}, function(err, result) {
  if(err) return console.log(err);
  return console.log(result);
});

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

Асинхронные задачи по порядку

Если у вас есть пара асинхронных задач, которые зависят друг от друга, вам придется вызывать каждую задачу в рамках обратного вызова другой задачи. Например, если вам нужно скопировать содержимое файла, вам нужно сначала прочитать содержимое файла, прежде чем записывать его в другой файл. Из-за этого вам нужно будет вызвать метод writeFile в обратном вызове readFile:

const fs = require('fs');
fs.readFile('file.txt', 'utf-8', function readContent(err, content) {
  if(err) {
    return console.log(err);
  }
  fs.writeFile('copy.txt', content, function(err) {
    if(err) {
      return console.log(err);
    }
    return console.log('done');
  });
});

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

const fs = require('fs');
fs.readFile('file.txt', 'utf-8', readCb);

function readCb(err, content) {
  if (err) {
    return console.log(err);
  }
  return fs.writeFile('copy.txt', content, writeCb);
}

function writeCb(err) {
  if(err) {
    return console.log(err);
  }
  return console.log('Done');
}

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

Упражнение: простой обратный вызов

Определите compute функцию, которая принимает два аргумента:

  1. Массив целых чисел
  2. Функция обратного вызова, которая работает с переданным массивом

Например, следующий код вернет 6 (при условии, что функция addAll определена).

const result = compute([1,2,3], addAll);

Решение

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

// code/callbacks/exercises/simple-callback.js
function compute(nums, fn) {
  if(!Array.isArray(nums)) return NaN;
  const isAnyNotInteger = nums.some(v => !Number.isInteger(v));
  if(isAnyNotInteger) return NaN;
  return fn(nums);
}

Упражнение: асинхронные обратные вызовы по порядку

В этом упражнении нам нужно выполнить HTTP-вызов GET для конечной точки, добавить результат к содержимому файла и, наконец, записать результат в другой файл. Для этого упражнения предположим, что каждая операция должна выполняться в следующем порядке:

  1. Сделайте HTTP-запрос GET, чтобы получить заголовок сообщения
  2. Прочитать содержимое файла
  3. Добавить заголовок сообщения к содержимому файла
  4. Запишите результат в файл

Решение

Ниже приведено решение, в котором для выполнения каждой операции используются именованные обратные вызовы. В разделе «Обещания» мы увидим, как использовать обещания для сбора асинхронных результатов и использования преимуществ одновременных задач. Но пока мы будем зависеть от каждого результата обратного вызова для выполнения следующего.

// code/callbacks/exercises/read-write/main.js
const fs = require('fs');
const request = require('request');
const url = 'https://jsonplaceholder.typicode.com/posts/2';

request.get(url, handleResponse);
function handleResponse(err, resp, body) {
  if(err) throw new Error;
  const post = JSON.parse(body);
  const title = post.title;
  fs.readFile('./file.txt', 'utf-8', readFile(title));
}
const readFile = title => (err, content) => {
  if(err) throw new Error(err);
  const result = title + content;
  fs.writeFile('./result.txt', result , writeResult);
}
function writeResult(err) {
  if(err) throw new Error(err);
  console.log('done');
}

Обещания

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

Обещание - это объект, который представляет результат асинхронной операции, которая может быть успешной, а может и не завершиться в какой-то момент в будущем. Например, когда вы делаете запрос к серверу API, вы можете вернуть обещание, которое будет представлять результат вызова api. Вызов api может быть успешным или неудачным, но в конечном итоге вы получите объект обещания, который сможете использовать. Функция ниже выполняет вызов API и возвращает результат в виде обещания:

// code/promises/axios-example.js
const axios = require('axios'); // A
function getDataFromServer() {
  const result = axios.get('https://jsonplaceholder.typicode.com/posts/1'); // B
  return result; // C
}
  • В строке A мы загружаем модуль axios, который является HTTP-клиентом на основе обещаний.
  • В строке B мы делаем запрос GET к общедоступной конечной точке api и сохраняем результат в константе result.
  • В строке C мы возвращаем обещание

Теперь мы можем просто вызвать функцию и получить доступ к результатам и отловить возможные ошибки:

getDataFromServer()
  .then(function(response) {
    console.log(response);
  })
  .catch(function(error) {
    console.log(error);
  });

У каждого обещания есть then и catch методы. Вы могли бы использовать метод then, чтобы зафиксировать результат операции, если она завершилась успешно (разрешенное обещание), и метод catch, если операция завершилась неудачно (отклоненное обещание). Обратите внимание, что и then, и catch получают функцию обратного вызова с одним аргументом для захвата результата. Также стоит отметить, что оба этих метода возвращают обещание, которое позволяет нам потенциально связать их.

Ниже приведены еще несколько примеров асинхронных задач, которые могут возвращать обещание:

  • Чтение содержимого файла: возвращаемое обещание будет включать содержимое файла
  • Вывод списка содержимого каталога: возвращаемое обещание будет включать список файлов.
  • Разбор CSV-файла: возвращаемое обещание будет включать проанализированный контент
  • Выполнение некоторого запроса к базе данных: возвращенное обещание будет включать результат запроса

На рисунке ниже показаны состояния, которые может иметь обещание.

Обещающие преимущества

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

Обещание

Мы можем создать обещание, используя глобальный конструктор Promise:

const myPromise = new Promise();

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

const myPromise = new Promise(function(resolve, reject) {
  if(someError) {
    reject(new Error(someError));
  } else {
    resolve('ok');
  }
});

И, как упоминалось ранее, мы можем использовать метод then для использования результатов при выполнении обещания и метод catch для обработки ошибок:

myPromise
  .then(function(result) {
    console.log(result);
  })
  .catch(function(error) {
    console.log(error);
  });

Стоит упомянуть, что мы можем заключить любую асинхронную операцию в обещание. Например, fs.readFile - это метод, который асинхронно считывает содержимое файла. Метод fs.readFile используется следующим образом:

fs.readFile('some-file.txt', 'utf-8', function(error, content) {
  if(error) {
    return console.log(error);
  }
  console.log(content);
});

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

// code/promises/wrap-readfile1.js
const fs = require('fs');
function readFile(file, format) {
  format = format || 'utf-8';
  function handler(resolve, reject) {
    fs.readFile(file, format, function(err, content) {
      if(err) {
        return reject(err);
      }
      return resolve(content);
    });
  }
  const promise = new Promise(handler);
  return promise;
}

Тот же код можно более кратко переписать следующим образом:

// code/promises/wrap-readfile2.js
const fs = require('fs');
function readFile(file, format = 'utf-8') {
  return new Promise((resolve, reject) => {
    fs.readFile(file, format, (err, content) => {
      if(err) return reject(err);
      resolve(content);
    });
  });
}

Теперь мы можем просто вызвать нашу функцию и зафиксировать результат в методе then, а также отловить ошибки с помощью метода catch:

readFile('./example.txt')
  .then(content => console.log(content))
  .catch(err => console.log(err));

Еще более кратко мы можем переписать приведенный выше код, используя метод util.promisify, который был представлен в Node 8:

// code/promises/promisify-example.js
const fs = require('fs');
const util = require('util');
const readFile = util.promisify(fs.readFile);

readFile('./example.txt', 'utf-8')
  .then(content => console.log(content))
  .catch(err => console.log(err));

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

Обещать статические методы

Конструктор Promise имеет несколько полезных статических методов, которые стоит изучить. Все фрагменты кода находятся в code/promises/static-methods.js. Некоторые известные из них перечислены ниже:

Promise.resolve: ярлык для создания объекта обещания, разрешенного с заданным значением

function getData() {
  return Promise.resolve('some data');
}
getData()
  .then(d => console.log(d));

Promise.reject: ярлык для создания объекта обещания, отклоненного с заданным значением

function rejectPromise() {
  return Promise.reject(new Error('something went wrong'));
}
rejectPromise()
  .catch(e => console.log(e));

Promise.all: раньше ожидал выполнения пары обещаний

const p1 = Promise.resolve('v1');
const p2 = Promise.resolve('v2');
const p3 = Promise.resolve('v3');

const all = Promise.all([p1, p2, p3]);

all.then(values => console.log(values[0], values[1], values[2]));

Обратите внимание, что Promise.all принимает массив объектов обещания, оценивает их в случайном порядке и «ждет», пока все они не будут разрешены. В конце концов он вернет объект обещания, содержащий все значения в массиве в том порядке, в котором они были отправлены, но не в том порядке, в котором они были обработаны. Обратите внимание, что Promise.all не обрабатывает обещания по порядку, он будет оценивать их в том порядке, в котором он предпочитает. В следующем разделе мы рассмотрим выполнение обещаний по порядку.

Обещания в порядке

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

const promiseChain = task1()
  .then(function(task1Result) {
    return task2();
  })
  .then(function(task2Result) {
    return task3();
  })
  .then(function(task3Result){
    return task4();
  })
  .then(function(task4Result) {
    console.log('done', task4Result);
  })
  .catch(function(err) {
    console.log('Error', err);
  });

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

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

// code/promise/promise-in-sequence.js
const promiseChain = readFile('example.txt')
  .then(function(content) {
    return removeInvalidChracters(content);
  })
  .then(function(cleanContent) {
    return writeToFile('./clean-file.txt', cleanContent);
  })
  .then(function() {
    console.log('done');
  })
  .catch(function(error) {
    console.log(error);
  });

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

getUserData()
  .then(info => {
    authenticate(info)
    .then(authResult => {
      doSomething(authResult);
    });
  });

Вместо этого используйте правильную цепочку и убедитесь, что возвращает значение на каждом шаге:

getUserData()
.then(info => authenticate(info))
.then(authResult => doSomething(authResult))

В приведенном выше фрагменте обратите внимание, что, поскольку мы используем стрелочные функции без блока {}, неявно возвращается правая часть =>.

Выполнение обещаний одновременно

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

function runAll() {
  const p1 = taskA();
  const p2 = taskB();
  const p3 = taskC();
}

runAll();

Теперь, если вы хотите что-то сделать, когда все эти операции будут завершены, вы можете использовать Promise.all:

// code/promises/run-all.js
function runAll() {
  const p1 = taskA();
  const p2 = taskB();
  const p3 = taskC();
  return Promise.all([p1, p2, p3]);
}

runAll()
  .then(d => console.log(d, 'all done'))
  .catch(e => console.log(e));

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

Объединение обещаний

Основная мотивация для этого раздела в основном связана с типом задач, которые необходимо выполнять одновременно и последовательно. Допустим, у вас есть куча файлов, с которыми вам нужно работать асинхронно. Возможно, вам придется выполнить операцию A, B, C, D по порядку для 3 разных файлов, но вас не волнует порядок, в котором файлы обрабатываются. Все, что вас волнует, это то, что операции A, B, C, и D происходят в правильном порядке. Для этого мы можем использовать следующий шаблон:

  1. Составьте список обещаний
  2. Каждое обещание представляет собой последовательность асинхронных задач A, B, C, D
  3. Используйте Promise.all для обработки всех обещаний. Обратите внимание, что, как упоминалось ранее, метод all одновременно обрабатывает обещания:
const files = ['a.txt', 'b.txt', 'c.txt'];

function performInOrder(file) {
  const promise = taskA(file)
  .then(taskB)
  .then(taskC)
  .then(taskD);
  return promise;
}

const operations = files.map(performInOrder);
const result = Promise.all(operations);

result.then(d => console.log(d)).catch(e => console.log(e));

Ниже приведен реальный код, который вы можете запустить, предполагая, что у вас есть три файла a.txt, b.txt и c.txt:

// code/promises/read-write-multiple-files/main.js
const fs = require('fs');
const util = require('util');
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);

const copyFile = (file) => (content) => (writeFile(file + '-copy.txt', content));
const replaceContent = input => (Promise.resolve(input.replace(/-/g, 'zzzz')));
const processEachInOrder = file => {
  return readFile(file, 'utf-8')
    .then(replaceContent)
    .then(copyFile(file));
}

const files = ['./a.txt', './b.txt', './c.txt'];
const promises = files.map(processEachInOrder);
Promise.all(promises)
  .then(d => console.log(d))
  .catch(e => console.log(e));

Стоит отметить, что такая обработка может вызвать большую нагрузку на ЦП, если размер входных данных большой. Лучшим подходом было бы ограничить количество задач, которые обрабатываются одновременно. В библиотеке async есть метод qeueue, который ограничивает количество одновременно обрабатываемых асинхронных задач, уменьшая дополнительную рабочую нагрузку на ЦП.

Упражнение

В качестве упражнения напишите сценарий, который считывает содержимое каталога (на уровне 1 уровня) и копирует только файлы в другой каталог с именем output.

Совет. В качестве отправной точки можно использовать пример из предыдущего раздела.

Подсказка. Вот общая идея: прочтите содержимое папки, используйте метод stat, чтобы выяснить, какая запись является файлом, создайте папку вывода, прочтите каждый файл и запишите в папку вывода.

Решение

Ниже приведено одно возможное решение, использующее шаблон Promise.all для обработки обещаний чтения-записи:

// code/promises/exercise/main.js
/*
  List the content of the folder, filter out the files only
  then copy to the output folder.
 */
const fs = require('fs');
const path = require('path');
const util = require('util');
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);
const readdir = util.promisify(fs.readdir);
const stat = util.promisify(fs.stat);
const mkdir = util.promisify(fs.mkdir);
const outputFolder = './output';

function isFile(f) {
  return stat(f).then(d => d.isFile() ? f : '');
}

function filterFiles(list) {
  return Promise.all(list.map(isFile))
    .then(files => files.filter(v => v));
}

function readWrite(result) {
  const files = result[1];
  return Promise.all(files.map(f => {
    return readFile(f)
    .then(content => writeFile(path.join(outputFolder, f), content));
  }));
}

const getFiles = readdir('./').then(filterFiles);

Promise.all([mkdir(outputFolder), getFiles])
  .then(readWrite)
  .then(_ => console.log('done!'))
  .catch(e => console.log(e));

Генераторы

Генераторы обычно возникают, когда говорят об асинхронном программировании в JavaScript. Знание того, что они собой представляют, закладывает основу для понимания других абстракций, таких как async / await.

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

Создание генераторов

Вы можете создать генератор, поместив * после ключевого слова функции:

function* myGenerator() {
 //...
}

Затем в теле функции генератора мы можем сгенерировать значения с помощью оператора yield:

// code/generators/simple.js
function* simpleGenerator() {
  yield 1;
  yield 5;
}
const g = simpleGenerator();
const v1 = g.next().value; // --> 1
const v2 = g.next().value; // --> 5
const v3 = g.next().value; // --> undefined

Вы даже можете определить бесконечный цикл и генерировать значения:

// code/generators/inf-loop.js
function* myGenerator() {
  let i = 0;
  while(true) {
    i += 1;
    yield i;
  }
}

Если бы это была обычная функция, она бы застряла в бесконечном цикле. Но поскольку это генератор, мы можем читать значения, сгенерированные вызовом next для возвращенного объекта генератора:

const g = myGenerator();
const v1 = g.next(); // --> { value: 1, done: false }
const v2 = g.next(); // --> { value: 2, done: false }
const v3 = g.next(); // --> { value: 3, done: false }
// and so on...

По сути, мы входим в функцию и выходим из нее каждый раз, когда вызываем next, и продолжаем с того места, где остановились в последний раз. Обратите внимание, как значение i «запоминается» каждый раз, когда мы вызываем следующий. Теперь давайте обновим приведенный выше код и заставим генератор завершить генерацию значений. Сделаем так, чтобы он не генерировал никаких значений, если i больше 2:

function* myGenerator() {
  let i = 0;
  while(true) {
    i += 1;
    if(i > 2) {
      return;
    }
    yield i;
  }
}

или мы можем упростить приведенный выше код и переместить условие в цикл while:

// code/generators/inf-loop-terminate.js
function* myGenerator() {
  let i = 0;
  while(i < 2) {
    i += 1;
    yield i;
  }
}

Теперь, если мы прочитаем сгенерированные значения, мы получим только два значения:

const g = myGenerator();
const v1 = g.next(); // --> { value: 1, done: false }
const v2 = g.next(); // --> { value: 2, done: false }
const v3 = g.next(); // --> { value: undefined, done: true }

Обратите внимание, что после второго значения, если мы продолжим вызывать следующий, мы получим тот же результат. То есть объект-генератор со значением undefined и свойством done, установленным на true, что указывает на то, что больше не будет генерироваться значений.

Заявления о возврате

Оператор return в генераторе отмечает последнее значение, и после этого значения не будут сгенерированы:

// code/generators/return-statement.js
function* withReturn() {
  yield 1;
  yield 55;
  return 250;
  yield 500;
}
const g = withReturn();
const v1 = g.next().value; // --> 1
const v2 = g.next().value; // --> 55
const v3 = g.next().value; // --> 250
const v4 = g.next().value; // --> undefined

Приведенный выше код сгенерирует 1, 55 и 250. Он не дойдет до последнего оператора yield, потому что оператор return отмечает конец генератора.

Передача значений в следующий

Используя генераторы, вы можете передать значение обратному вызову next, чтобы использовать его вместо ранее вычисленного оператора yield. Давайте посмотрим на простой пример, чтобы продемонстрировать, что это значит.

// code/generators/pass-next.js
function* myGenerator(n) {
  const a = (yield 10) + n;
  yield a;
}

const g = myGenerator(1);
g.next().value; // --> 10
g.next(100).value; // --> 101

Давайте рассмотрим приведенный выше фрагмент и пошагово разберемся, что происходит:

  • Сначала мы вызываем генератор, передаем 1 вместо n и сохраняем объект итератора в g. Здесь ничего нового.
  • Затем мы вызываем g.next, чтобы запустить генератор. Функция выполняется до тех пор, пока не достигнет первого yield оператора: const a = (yield 10). На этом этапе создается значение рядом с yeild, которое равно 10.
  • Затем мы вызываем g.next и передаем 100. Функция возобновляется с того места, где была остановлена: + n, но она заменит 100 на (yield 10), в результате получится const a = 100 + n, где n равно 1. Так будет продолжаться до следующего yield. В этом случае yield a, который будет генерировать 100 + 1 = 101.

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

Вызов другого генератора в генераторе

Вы можете использовать yield* внутри генератора, если хотите вызвать другой генератор. В приведенном ниже примере у нас есть два генератора: g1 и g2. Мы хотим вызвать g2 внутри g1 и прочитать сгенерированные значения:

// code/generators/call-another.js
function* g2() {
  yield 2;
  yield 3;
}
function* g1() {
  yield 1;
  yield* g2();
  yield 4;
}

const vals = [...g1()];

console.log(vals); // -> [1,2,3,4]

В приведенном выше фрагменте мы вызываем генератор g1, а ниже приводится краткое описание того, что происходит:

  • Значение 1 генерируется из первого оператора yield
  • Затем мы нажимаем yield* g2(), который сгенерирует все значения, которые будет генерировать g2, то есть 2 и 3
  • Затем мы вернемся к g1 и сгенерировали окончательное значение, равное 4.

Итерация значений

Использование for-of

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

// code/generators/use-for-of.js
function* myGenerator() {
  let i = 0;
  while(i < 2) {
    i += 1;
    yield i;
  }
}

const g = myGenerator();
for(const v of g) {
  console.log(v);
}

Приведенный выше код выведет 1, а затем 2.

Использование while Loop

Вы также можете использовать цикл while для итерации по объекту генератора:

// code/generators/use-while-loop.js
const g = myGenerator();
let next = g.next().value;
while(next) {
  console.log(next);
  next = g.next().value;
}

В приведенном выше цикле while сначала мы получаем первое сгенерированное значение и присваиваем его next. Затем в цикле while мы устанавливаем next на следующее сгенерированное значение. Цикл while будет продолжаться до тех пор, пока next не станет неопределенным, когда генератор выдаст последнее значение.

Оператор распространения и Array.from

Поскольку объект-генератор является итерируемым, вы также можете использовать оператор распространения для чтения значений:

// code/generators/use-spread.js
function* myGenerator() {
  let i = 0;
  while(i < 2) {
    i += 1;
    yield i;
  }
}
const vals = [...myGenerator()]; // -> [1, 2]

В приведенном выше примере сначала мы вызываем генератор myGenerator() и помещаем его в массив. И, наконец, мы используем оператор распространения прямо перед ним, чтобы по сути считать каждое значение. Результат сохраняется в переменной vals в виде массива с двумя значениями [1, 2].

В дополнение к оператору распространения вы также можете использовать метод Array.from для чтения значений и помещения их в массив:

// code/generators/use-array-from.js
function* myGenerator() {
  let i = 0;
  while(i < 2) {
    i += 1;
    yield i;
  }
}
const vals = Array.from(myGenerator()); // --> [1, 2]

В приведенном выше фрагменте мы вызываем генератор и передаем его Array.from, который считывает каждое значение и сохраняет их в массиве, в результате чего получается [1, 2].

Стоит упомянуть, что если вы выполняете итерацию через объект-генератор, который включает оператор return, завершающий последовательность, вы не сможете прочитать последнее значение, если используете какие-либо внутренние методы итерации, такие как цикл for-of или оператор распространения:

function* withReturn() {
  yield 1;
  yield 55;
  return 250;
  yield 500;
}
for(const v of withReturn()) {
  console.log(v);
}

Приведенный выше код выведет 1, а затем 55, но не будет выводить 250. Это также верно, если вы используете оператор распространения:

function* withReturn() {
  yield 1;
  yield 55;
  return 250;
  yield 500;
}
const vals = [...withReturn()];
console.log(vals);

Приведенный выше код выведет [1, 55] и не будет включать 250. Но обратите внимание, что если мы используем цикл while, мы можем читать все значения вплоть до значения в операторе return:

function* withReturn() {
  yield 1;
  yield 55;
  return 250;
  yield 500;
}

const g = withReturn();
let next = g.next().value;

while(next) {
  console.log(next);
  next = g.next().value;
}

Цикл while выше будет читать все значения, включая значение в операторе return, записывать 1, 55 и 250 в консоль.

Генерация бесконечных последовательностей

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

Последовательность Фибоначчи - это последовательность чисел, которая начинается с 0, и

  1. А остальные числа в последовательности вычисляются путем сложения текущего значения с предыдущим:
  2. 0, 1, 1, 2, 3, 5, 8, 13, 21, …

или рекурсивно последовательность может быть определена как:

fib(n) = fib(n - 1) + fib(n - 2)

Мы можем использовать приведенное выше определение и определить генератор для создания n числа значений:

// code/generators/fibo.js
function* fibo(n, prev = 0, current = 1) {
  if (n === 0) {
    return prev;
  }
  yield prev;
  yield* fibo(n - 1, current, prev + current);
}

let vals = [...fibo(5)];
console.log(vals); //-> [ 0, 1, 1, 2, 3 ]

В приведенном выше фрагменте мы определяем первые два числа как значения аргументов по умолчанию, используя prev = 0 и current = 1. Ниже приводится краткое описание того, что происходит с n = 5:

  1. Первый yield будет генерировать предыдущее значение, то есть 0. Обратите внимание, что n сейчас 4.
  2. Затем fibo(4 - 1, 1, 0 + 1) = fib(3, 1, 1) сгенерирует 1.
  3. Затем fibo(3 - 1, 1, 1 + 1) = fibo(2, 1, 2) сгенерирует 1.
  4. Затем fibo(2 - 1, 2, 1 + 2) = fibo(1, 2, 3) сгенерирует 2.
  5. Затем fibo(1 - 1, 3, 2 + 3) = fibo(0, 3, 5) сгенерирует 3, обозначив конец, поскольку n это 0, и мы нажимаем на оператор возврата.

Генераторы и асинхронные операции

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

function* myGenerator(n) {
  const a = (yield 10) + n;
  yield a;
}

const g = myGenerator(1);
g.next().value; // --> 10
g.next(100).value; // --> 101

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

const asynTask1 = () => new Promise((r, j) => setTimeout(() => r(1), 1000));

Эта функция возвращает обещание, которое преобразуется в значение 1 через 1 секунду. Теперь давайте создадим функцию генератора и вызовем внутри нее нашу асинхронную функцию:

const asynTask1 = () => new Promise((r, j) => setTimeout(() => r(1), 1000));

function* main() {
  const result = yield asynTask1();
}

const g = main();
console.log(g.next());

Как вы думаете, что выведет приведенный выше код? Давайте пройдемся через это и разберемся, что будет дальше:

  • Сначала мы вызываем генератор и сохраняем объект генератора в g.
  • Затем мы вызываем next, чтобы получить первый результат yield. В этом случае это будет обещание, поскольку asynTask1 возвращает обещание.
  • Наконец, мы записываем значение в консоль: { value: Promise { <pending> }, done: false }.
  • Через 1 секунду программа завершится.

После завершения программы мы не получим доступа к разрешенному значению. Но представьте, если бы мы могли снова вызвать next и передать ему разрешенное значение в «нужное» время. В этом случае yield asynTask1() будет заменено на разрешенное значение и ему будет присвоено result! Давайте обновим приведенный выше код и сделаем это одним обещанием:

const asynTask1 = () => new Promise((r, j) => setTimeout(() => r(1), 1000));

function* main() {
  const result = yield asynTask1();
  return result; //<-- return the resolved value and mark the end.
}

const g = main();
const next = g.next();
console.log(next); // --> { value: Promise { <pending> }, done: false }
next.value.then(v => { // Resolve promise.
  const r = g.next(v); // passing the resolved value to next.
  console.log(r); // -> { value: 1, done: true }
});

В приведенном выше фрагменте мы добавили оператор return в генератор, чтобы просто вернуть разрешенное значение. Но важная часть - это когда мы выполняем обещание. Когда мы разрешаем обещание, мы вызываем g.next(v), который заменяет yield asynTask1() разрешенным значением и присваивает его result. Теперь мы готовы написать нашу вспомогательную функцию. Эта вспомогательная функция будет принимать генератор и делать то, что мы обсуждали выше. Он вернет разрешенное значение, если больше нет значений, которые нужно сгенерировать. Начнем с определения вспомогательной функции:

const helper = (gen) => {
  const g = gen();
};

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

const helper = (gen) => {
  const g = gen();
  function callNext(resolved) {
    const next = g.next(resolved); // replace the last yield with the resolved value
    if(next.done) return next.value; // return the resolved value if not more items
    return next.value.then(callNext); // pass `callNext` back again.
  }
};

Эта функция будет принимать единственный аргумент - разрешенное значение обещания. Затем мы вызываем g.next с разрешенным значением и присваиваем результат переменной next. После этого мы проверим, готов ли генератор. Если это так, мы просто вернем значение. И, наконец, мы вызываем next.value.then() и передаем ему callNext, чтобы рекурсивно вызывать следующий для нас, пока не останется больше значений для генерации. Теперь, чтобы использовать эту вспомогательную функцию, мы просто вызовем ее и передадим ей наш генератор:

helper(function* main() {
  const a = yield asynTask1();
  console.log(a);
});

Теперь, если вы запустите приведенный выше код, вы не увидите зарегистрированного результата, потому что у нас отсутствует один элемент. Функция callNext в нашем помощнике должна быть немедленно вызвана самостоятельно, иначе никто ее не вызовет:

const helper = (gen) => {
  const g = gen();
  (function callNext(resolved) {
    const next = g.next(resolved);
    if(next.done) return next.value;
    return next.value.then(callNext);
  }()); // <-- self invoking
};

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

const helper = (gen) => {
  const g = gen();
  (function callNext(resolved) {
    const next = g.next(resolved);
    if(next.done) return next.value;
    return next.value.then(callNext)
    .catch(err => g.throw(err)); // <-- throw error
  }());
};

Блок catch выдаст ошибку от генератора, если какое-либо из обещаний выдает ошибку. И мы можем просто использовать try-catch в переданной функции генератора для обработки ошибок. Собирая все вместе, мы получим:

// code/generators/async-flow.js
const asynTask1 = () => new Promise((r, j) => setTimeout(() => r(1), 1000));
const asynTask2 = () => new Promise((r, j) => setTimeout(() => j(new Error('e')), 500));

const helper = (gen) => {
  const g = gen();
  (function callNext(resolved) {
    const next = g.next(resolved);
    if(next.done) return next.value;
    return next.value.then(callNext)
    .catch(err => g.throw(err));
  }());
};

helper(function* main() {
  try {
    const a = yield asynTask1();
    const b = yield asynTask2();
    console.log(a, b);
  } catch(e) {
    console.log('error happened', e);
  }
});

Если вам интересно, вы можете заглянуть в библиотеку co для более полной реализации. Однако в следующем разделе мы рассмотрим async-await абстракцию, которая является собственной абстракцией над генераторами для обработки асинхронных потоков.

Асинхронное ожидание

Асинхронные функции - это абстракции более высокого уровня вокруг генераторов и обещаний, которые можно использовать для упрощения асинхронных потоков. Любое определение функции JavaScript можно пометить ключевым словом async. Когда функция помечена как async, возвращаемое значение всегда будет заключено в обещание. Рассмотрим следующую простую функцию:

function add(a, b) {
  return a + b;
}

Мы можем просто пометить функцию как асинхронную, используя ключевое слово async в определении функции:

async function add(a, b) {
  return a + b;
}

Вы также можете пометить стрелочную функцию как асинхронную:

const add = async (a, b) => a + b;

Теперь, когда мы вызываем функцию, мы получаем обещание, которое оборачивает фактическое значение. Чтобы получить доступ к фактическому значению, мы используем метод then и считываем значение через аргумент обратного вызова:

const result = add(1, 2);
result.then(function(sum) {
  console.log(sum);
});

Оператор ожидания

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

// code/async-await/read-write-file.js
async function readWrite() {
  const content = await readFile('./example.txt', 'utf-8');
  const result = await writeFile('./example-copy.txt', content);
  return result;
}

Функция readWrite помечена как async, что означает, что мы можем использовать оператор await в теле функции. В строке 1 мы ждем завершения чтения содержимого файла. Затем в строке 2 мы записываем файл и ждем, пока он не закончится. В строке 3 мы просто возвращаем результат операции записи.

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

  1. Сделайте запрос GET к общедоступной конечной точке, чтобы получить объект сообщения
  2. Перечислите содержимое локального каталога и выберите файл с расширением .txt.
  3. Прочтите содержимое этого файла и добавьте его в текст сообщения, полученного на шаге 1.
  4. Запишите результат в файл с именем final.txt локально.

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

// code/async-await/post-body-example/main.js
async function main() {
  const response = await axios.get('https://jsonplaceholder.typicode.com/posts/1');
  const postBody = response.data.body;
  const localFolderList = await readdir('.');
  const textFiles = localFolderList.filter(onlyTextFiles);
  const localFileContent = await readFile(textFiles[0], 'utf-8');
  const finalResult = localFileContent + postBody;
  const writeResult = await writeFile('./final.txt', finalResult);
  return writeResult;
}

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

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

  1. Прочитать содержимое папки, получить текстовый файл, прочитать содержимое текстового файла
  2. Сделайте запрос GET, чтобы получить объект сообщения

Если задуматься, то для чтения содержимого локального текстового файла не требуется никакой информации из HTTP-вызова. Однако для чтения содержимого локального текстового файла необходимо сначала получить содержимое папки и отфильтровать текстовые файлы. Теперь, когда мы сгруппировали задачи, мы можем использовать шаблон Promise.all, который мы использовали в разделе «Обещания», для выполнения всех задач:

// code/async-await/post-body-example/main-group.js
async function readLocalContent() {
  const localFolderList = await readdir('.');
  const textFiles = localFolderList.filter(onlyTextFiles);
  const localFileContent = await readFile(textFiles[0], 'utf-8');
  return localFileContent;
}

async function getPostObject() {
  const response = await axios.get('https://jsonplaceholder.typicode.com/posts/1');
  return response.data.body;
}

async function main() {
  try {
    const results = await Promise.all([readLocalContent(), getPostObject()]);
    const finalContent = results[0] + results[1];
    return writeFile('./final2.txt', finalContent);
  } catch(e) {
    return Promise.reject(e);
  }
}

В приведенном выше фрагменте мы разделили две задачи на отдельные async функции, что позволяет нам эффективно выполнять две группы задач одновременно. В функции main мы использовали Promise.all, чтобы дождаться завершения обеих операций и использовать результат для записи окончательного содержимого в файл.

Базовая обработка ошибок

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

// code/async-await/read-write-file-catch-error.js
async function readWrite() {
  try {
    const content = await readFile('./example.txt', 'utf-8');
    const result = await writeFile('./example-copy.txt', content);
    return result;
  } catch (error) {
    console.log('An error happened while copying the file.');
    return Promise.reject(error);
  }
}

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

Async / Await внутри циклов

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

const files = ['./a.txt', './b.txt', './c.txt'];
files.forEach(file => {
  const r1 = await task1(file);
  const r2 = await task2(r1);
});

В приведенном выше коде есть две проблемы:

  1. Анонимная функция, переданная в forEach, не помечена async, поэтому мы не можем использовать оператор await.
  2. Даже если мы сделаем анонимную функцию async, метод forEach не будет ждать завершения всех задач.

Вместо метода forEach у нас есть два варианта, которые будут работать:

  • c-стиль для цикла
async function main() {
  const files = ['./a.txt', './b.txt', './c.txt'];
  for (let i = 0; i<files.length; i++) {
    const r1 = await task1(files[i]);
    const r2 = await task2(r1);
  }
  return 'done';
}
  • for of петля
async function main() {
  const files = ['./a.txt', './b.txt', './c.txt'];
  for (const file of files) {
    const r1 = await task1(file);
    const r2 = await task2(r1);
  }
  return 'done';
}

Обратной стороной вышеупомянутых подходов является то, что мы не сможем запускать задачи одновременно, даже если каждая задача является асинхронной. Например, если первый файл огромен, а последний файл очень мал, функция будет приостановлена ​​до тех пор, пока все операции с первым файлом не будут завершены. Более эффективным подходом было бы определять каждую операцию как обещание, а затем обрабатывать их все с помощью метода Promise.all:

const files = ['./a.txt', './b.txt', './c.txt'];

async function operation(f) {
  const r1 = await task1(f);
  const r2 = await task2(r1);
  return r2;
}

const tasksPromises = files.map(operation);

Promise.all(tasksPromises)
  .then(r => console.log(r))
  .catch(e => console.log(e))
  .then(_ => console.log('all done'));

В приведенном выше фрагменте мы определяем асинхронную функцию, которая выполняет две задачи в нужном нам порядке. Затем мы создаем массив обещаний, используя метод карты. Наконец, мы обрабатываем обещания с помощью метода Promise.all. Обратите внимание, что обещания будут решаться в другом порядке, но задачи в функции operation будут выполняться в том порядке, в котором мы хотели. Вы можете взглянуть на реальный пример в папке кода репо по адресу code/async-await/loop/main.js.

Заключение

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

Обязательно ознакомьтесь с другими моими карманными справочниками: Функции JavaScript, Прототипы JavaScript.