[Hexlet] JS: Синхронная асинхронность

Первое, с чем мы сталкиваемся в js при работе с IO – это коллбеки, сложность использования которых резко нарастает с увеличением зависимостей.

import fs from 'fs';

const myFile = '/tmp/test';
fs.readFile(myFile, 'utf8', (err, txt) => {
  if (err) {
    return console.log(err);
  }

  const newTxt = `${txt}'\nAppended something!`;
  fs.writeFile(myFile, newTxt, err => {
    if (err) {
      return console.log(err);
    }
    console.log('Appended text!');
  });
});

И мы знаем уже как минимум один способ борьбы с этой сложностью и даже написали реализацию нескольких функций библиотеки async .

async.filter(['file1', 'file2'], (filePath, callback) => {
  fs.access(filePath, err => {
    callback(null, !err)
  });
}, (err, results) => {
    // results now equals an array of the existing files
});

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

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

  • Promise (Futures)
  • Coroutines (using Generators)
  • Async/Await

В этом курсе будут подробно рассмотрены перечисленные концепции, которые стали неотъемлемой частью современной разработки на js . В процессе знакомства с ними мы построим библиотеку для выполнения http запросов. Она будет основана на промисах, а использовать ее можно будет с генераторами и async/await конструкциями.

import { get } from 'hexlet-http-request';

export default async () => {
  const hostname = 'localhost:3000';
  const id    = await get(`${hostname}/id`);
  const group = await get(`${hostname}/group`);

  return get(`${hostname}/${group}/${id}`);
}

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

Дополнительно в курсе будет рассмотрен следующий набор тем:

  • Формат данных json
  • Итераторы
  • Атаки в сети (CSRF)
  • Модули nodejs : querystring , url , http

JS: Синхронная асинхронность URL

При работе с http возникает потребность в библиотеках, позволяющих манипулировать частями адресов, разбирать их, а так же собирать обратно. Nodejs предоставляет такие модули из коробки.

Url

Это модуль, основной задачей которого является парсинг строчки адреса для извлечения составных частей.

import url from 'url';

url.parse('http://user:pass@host.com:8080/p/a/t/h?query=string#hash')
Url {
  protocol: 'http:',
  slashes: true,
  auth: 'user:pass',
  host: 'host.com:8080',
  port: '8080',
  hostname: 'host.com',
  hash: '#hash',
  search: '?query=string',
  query: 'query=string',
  pathname: '/p/a/t/h',
  path: '/p/a/t/h?query=string',
  href: 'http://user:pass@host.com:8080/p/a/t/h?query=string#hash' }

Тоже самое можно визуализировать:

┌─────────────────────────────────────────────────────────────────────────────┐
│                                    href                                     │
├──────────┬┬───────────┬─────────────────┬───────────────────────────┬───────┤
│ protocol ││   auth    │      host       │           path            │ hash  │
│          ││           ├──────────┬──────┼──────────┬────────────────┤       │
│          ││           │ hostname │ port │ pathname │     search     │       │
│          ││           │          │      │          ├─┬──────────────┤       │
│          ││           │          │      │          │ │    query     │       │
"  http:   // user:pass @ host.com : 8080   /p/a/t/h  ?  query=string   #hash "
│          ││           │          │      │          │ │              │       │
└──────────┴┴───────────┴──────────┴──────┴──────────┴─┴──────────────┴───────┘

    (all spaces in the "" line should be ignored -- they're purely for formatting)

Как видно, этот модуль дал нам возможность извлечь query params из адреса, но в виде строки. Дальше, как вы уже догадались, нам понадобится еще один модуль - querystring.

Querystring

Он также входит в поставку nodejs и работает очень просто:

import querystring from 'querystring';

// w=%D1%8D%D1%80%D0%BB%D0%B0%D0%BD%D0%B3&foo=bar
querystring.parse(str);
// { w: 'эрланг', foo: 'bar' }

querystring.stringify({ w: 'эрланг', foo: 'bar' });
// w=%D1%8D%D1%80%D0%BB%D0%B0%D0%BD%D0%B3&foo=bar

Здесь необходимо сделать пояснение. Спецификация http разрешает использовать в адресах только те символы, которые входят в ASCII character-set . Возникает вопрос: как быть, если у нас есть другие символы? А для этого применяется специальное кодирование, называемое url encoding . Например, слово скрипт будет закодировано в строку: %D1%81%D0%BA%D1%80%D0%B8%D0%BF%D1%82 .

Модуль querystring делает кодирование/декодирование автоматически, это видно в примере выше. Но если вам вдруг понадобилось делать это руками, то js спешит на помощь:

encodeURI('эрланг');
// %D1%8D%D1%80%D0%BB%D0%B0%D0%BD%D0%B3

decodeURI('%D1%8D%D1%80%D0%BB%D0%B0%D0%BD%D0%B3');
// эрланг

А теперь собираем все вместе:

import url from 'url';
import querystring from 'querystring';

const uri = '/?q=%D1%8D%D1%80%D0%BB%D0%B0%D0%BD%D0%B3';
const { query } = url.parse(uri);
const { q } = querystring.parse(query);
console.log(q); // эрланг

По правде говоря, можно сделать проще: url.parse() принимает вторым параметром флаг, который как раз включает парсинг параметров запроса.

import url from 'url';
import querystring from 'querystring';

const uri = '/?q=%D1%8D%D1%80%D0%BB%D0%B0%D0%BD%D0%B3';
const { query } = url.parse(uri, true);
console.log(query); // { q: 'эрланг' }

Обратная задача – конструирование адреса по его частям – выполняется так же просто с помощью функции format того же модуля:

const data = {
  hostname: 'ru.hexlet.io',
  pathname: 'my',
  query: { page: 5 }
};
url.format(data);

В общем случае в url.format нужно передать объект такой же структуры как и urlObject , получаемый после url.parse . Для более полного понимания работы этой функции нужно смотреть в документацию. У нее есть хитрые кейсы в случае наличия или отсутствия определенных ключей и их значений в передаваемом объекте.

JS: Синхронная асинхронность GET-запрос

Предположим, что мы хотим программно выполнить get запрос к Хекслету. В nodejs сделать это довольно просто:

import http from 'http';

http.get('http://ru.hexlet.io/my', res => {
  console.log(res.statusCode);
});

Вторым параметром передается коллбек, который будет вызван после получения ответа. Он так же принимает на вход объект response , который содержит в себе параметры ответа.

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

// response
{
  headers: /* ... */,
  statusCode: 301,
  statusMessage: 'Moved Permanently',
};

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

import http from 'http';

// headers, method, port, ...
const options = {
  hostname: 'ru.hexlet.io',
  path: 'my',
};

http.get(options, res => {
  console.log(res.statusCode);
});

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

  • headers - объект, в котором ключ это название заголовка
  • method - например GET
  • port
  • hostname
  • path

Body

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

http.get(url, res => {
  const body = [];
  res.on('data', chunk => {
    body.push(chunk.toString());
  }).on('end', () => {
    const html = body.join();
    console.log(html);
  });
});

Как видно из примера выше, объект response представляет из себя eventEmitter с событиями data и end . Первое вызывается после получения очередного чанка с данными, второе вызывается после того, как все данные пришли и нужно обозначить конец обработки.

Buffer

Из кода сбора чанков в body можно сделать вывод, что chunk – это не строка. Это действительно так, chunk – это объект типа Buffer , который предназначен для хранения потока байтов в виде массива фиксированного размера. Нужно это по той простой причине, что данные, передаваемые по http , не обязательно имеют текстовое представление. Возможна передача также и бинарных данных, таких как картинки, архивы и тому подобное.

const str = 'string as bytes';
const buffer = new Buffer(str, 'utf-8');
console.log(buffer);
// <Buffer 73 74 72 69 6e 67 20 61 73 20 62 79 74 65 73>
buffer.toString();
// string as bytes

Errors

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

  • Проблемы с DNS
  • Ошибки уровня tcp
  • Ошибки парсинга http ответа

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

import http from 'http';

const uri = 'http://ru.hexlet.io/my';

const req = http.get(uri, res => {
  console.log(res.statusCode);
});

req.on('error', e => {
  console.log(`Got error: ${e.message}`);
});

JS: Синхронная асинхронность POST-запрос

Разобравшись с get запросом, пора переходить к post и передаче данных на сервер. Для начала познакомимся с методом request модуля http . Ниже пример обычного GET запроса, который мы делали с помощью http.get :

const options = {
  hostname: 'ru.hexlet.io',
  path: 'my',
  method: 'GET', // default
}
const req = http.request(options, res => {
  console.log(res.statusCode);
});
req.end();

На самом деле, http.get – это обертка над http.request , которая выполняет req.end автоматически. Ее добавили в модуль http только потому, что это очень частый вариант использования. Другими словами, http.request - универсальный способ делать http запросы. Для выполнения любого запроса, нужно лишь правильно подставить глагол в опцию method , будь то хоть POST хоть HEAD.

Следующий аспект это заголовок Content-Length . Как вы помните из курса http , этот заголовок обязателен при наличии тела запроса. Он содержит цифру - количество байт в теле запроса. Так как данные формы обычно отправляют, используя тип application/x-www-form-urlencoded , то перед подсчетом размера тела его нужно сначала сформировать, выполнив правильные преобразования.

// Content-Type: application/x-www-form-urlencoded

const postData = querystring.stringify({
  'msg': 'Hello World!',
  'key': 'value',
});
// msg=Hello%20World!&key=value

// Content-Length: ?

Buffer.byteLength(postData);
// 28

Рекомендуется всегда использовать такой способ определения размера тела запроса, потому что он считает количество байт, в отличие от length , который считает количество символов в текстовом представлении. Различия будут проявляться при использовании символов, не входящих в ASCII .

'Пошло поехало'.length; // 13
Buffer.byteLength('Пошло поехало'); // 25

Теперь можно собрать все вместе и выполнить post запрос с передачей данных формы:

const options = {
  hostname: 'www.google.com',
  path: '/upload',
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Content-Length': Buffer.byteLength(postData)
  }
};
const req = http.request(options, (res) => {
  console.log(`STATUS: ${res.statusCode}`);
});
req.write(postData);
req.end();

Данные отправляются с помощью метода write объекта request . Часто можно упростить отправку до вызова req.end(postData) . write больше полезен тогда, когда используется отправка с помощью чанков, например, в случае передачи больших бинарных данных (картинки, видео).

JS: Синхронная асинхронность Promises

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

Полное описание всех возможностей и аспектов поведения промисов является объемной задачей, которая может запутать на первых порах, поэтому в этом уроке мы остановимся на ключевых особенностях поведения. Все остальное можно почерпнуть из стандарта и/или документации.

Знакомству с промисами способствует понимание темы “конечные автоматы”.

Начнем по традиции с примера:

const file = '/tmp/hello1.txt';
import { writeFile, readFile } from 'fs-promise';

writeFile(file, 'hello world')
  .then(() => readFile(file, 'utf8'))
  .then(contents => console.log(contents))
  .catch(err => console.log(err));
// hello world

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

Абзац выше – это пример того, как выглядит типичная программа, построенная на промисах. Так что такое промис?

Объект, используемый для асинхронных операций. Промис содержит в себе результат выполнения и позволяет строить цепочки из вычислений, избегая проблемы callback hell

Интерфейс:

  • Promise.prototype.then(onFulfilled, onRejected)
  • Promise.prototype.catch(onRejected)

Отсутствие callback hell происходит благодаря тому, что мы всегда работаем на уровне последовательных вызовов then , а не уходим в глубину.

Разберем пример выше по косточкам. Первый вызов writeFile(file, 'hello world') возвращает тот самый промис, и пока не важно, как он строится внутри, сейчас мы пытаемся понять то, как с ним работать.

// Вызов ничем не отличается кроме того, что мы не передаем коллбек
writeFile(file, 'hello world')

После этого у нас есть два варианта:

  • Мы вызываем then и передаем функцию onFulfilled , которая будет вызвана в случае успешного выполнения асинхронной операции
  • Мы вызываем catch и передаем функцию onRejected , которая будет вызвана, в случае ошибок в результате выполнения асинхронной операции.

Функция onFulfilled принимает на вход данные, которые были получены в результате предыдущего выполнения. Таким образом идет передача данных по цепочке.

.then(() => readFile(file, 'utf8'))
.then(contents => console.log(contents))

Данные, возвращаемые из функции onFulfilled , переходят по цепочке в функцию onFulfilled следующего then . Но если вернуть promise , то в следующем then окажутся данные, полученные в результате выполнения этого промиса, а не сам промис. Что и происходит в примере выше: мы возвращаем readFile() , а ниже получаем contents . То есть, промисы хорошо комбинируются друг с другом.

Конечный автомат

Теперь попробуем посмотреть внутрь промиса. С концептуальной точки зрения промис – это конечный автомат, у которого три состояния: pending , fullfiled , rejected .

Изначально он находится в состоянии pending , а дальше может перейти в одно из двух: либо выполнен ( fullfiled ), либо отклонен ( rejected ). И все, больше никакие переходы невозможны. Придя один раз в одно из терминальных (конечных) состояний, промис больше не подвержен изменениям, как бы мы не старались снаружи заставить его перейти в другое состояние.

Реализация

const promiseReadFile = filename => {
  return new Promise((resolve, reject) => {
    fs.readFile(filename, (err, data) => {
      err ? reject(err) : resolve(data);
    });
  });
};

Любая функция возвращающая промис, внутри себя создает объект промиса привычным способом. Конструктор Promise принимает на вход функцию, внутри которой запускается выполнение асинхронной операции. Делается это, кстати, сразу, промисы не являются примером отложенного (lazy) выполнения кода. Но это еще не все. Промис требует от нас некоторых действий для своей работы. Во входную функцию передаются две другие: reject и resolve . reject должна быть вызвана в случае ошибки, с передачей внутрь объекта error , а resolve - в случае успешного завершения асинхронной операции с передачей внутрь данных, если они есть.

Ошибки

Ошибка обрабатывается ближайшим обработчиком onRejected в цепочке вызовов. При этом существует два варианта определения обработчика. Первый - через catch , второй - с помощью передачи в then второго параметра. Это продемонстрировано в примере ниже:

promiseReadFile('file1')
  .then(data => promiseWriteFile('file2', data))
  .then(() => promiseReadFile('file3'))
  .then(data => console.log(data))
  .catch(err => console.log(err));
  // .then(null, err => console.log(err));

Promise.all

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

const readJsonFiles = filenames => {
  // N.B. passing readJSON as a function,
  // not calling it with `()`
  return Promise.all(filenames.map(readJSON));
}

readJsonFiles(['a.json', 'b.json'])
  .then(results => {
    // results is an array of the values
    // stored in a.json and b.json
  });

JS: Синхронная асинхронность JSON

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

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

Одним из таких способов представления структурированных данных является json .

JSON

JSON (JavaScript Object Notation) - простой формат обмена данными, удобный для чтения и написания как человеком, так и компьютером. Он основан на подмножестве языка программирования JavaScript.

{
  "firstName": "John",
  "lastName": "Smith",
  "isAlive": true,
  "age": 25,
  "address": {
    "streetAddress": "21 2nd Street",
    "city": "New York",
    "state": "NY",
    "postalCode": "10021-3100"
  },
  "phoneNumbers": [
    {
      "type": "home",
      "number": "212 555-1234"
    },
    {
      "type": "office",
      "number": "646 555-4567"
    },
    {
      "type": "mobile",
      "number": "123 456-7890"
    }
  ],
  "children": [],
  "spouse": null
  }

Структура подозрительно смахивает на объекты, с которыми мы работаем в js. По большому счету это и есть текстовое представление наших объектов.

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

  • Языко-независимый
  • Текстовый
  • Человеко-ориентированный
  • Доступен везде

js так же имеет встроенные средства для трансляции в json и обратно, причем как в браузере, так и в nodejs :

const obj = { key: 'value', keys: ['v1', 'v2'] };

const str = JSON.stringify(obj);
// {"key":"value","keys":["v1","v2"]}

JSON.parse(str);
// { key: 'value', keys: [ 'v1', 'v2' ] }

Media Type

При передаче контента по интернету мы должны явно специфицировать, какого типа этот контент. Достигается это использованием заголовка content-type . А вот значение, которое можно туда подставить, не является произвольным. Существует такое понятие, как media type (раньше – MIME type ), которое представляет из себя строковой идентификатор. Для json он выглядит так: application/json .

JS: Синхронная асинхронность Итераторы и Генераторы

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

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

// Упадет с ошибкой
for (const v of { a: 1, b: 2 }) {
  console.log(v);
}

// obj это объект и for спокойно по нему пройдется
const obj = makeIterator(); // Устройство функции makeIterator будет раскрыто позже
for (const v of obj) {
  console.log(v);
}

Во втором примере, obj представляет из себя итерируемый объект ( iterable object ). Мы можем самостоятельно сделать его таким:

const obj = {
  collection: ['yo', 'ya'],
  [Symbol.iterator]: makeIterator,
};

for (const v of obj) {
  console.log(v);
}
// yo
// ya

Напомню, что Symbol – это специальный неизменяемый тип данных. В основном используется в свойствах объектов. js предоставляет несколько встроенных символов, одним из которых и является iterator .

Чтобы сделать любой объект итерируемым, нужно создать свойство со значением Symbol.iterator и записать туда специальную функцию, о структуре которой мы сейчас и поговорим.

Эта функция не является общей для всех итерируемых объектов, ее содержимое зависит от объектов, для которых она предназначена. Общее правило - функция должна реализовывать The iterable protocol .

const makeIterator = function () {
  let nextIndex = 0;

  const next = () => {
    if (nextIndex < this.collection.length) {
      const value = this.collection[nextIndex++];
      return { value, done: false };
    }
    return { done: true };
  };
  return { next };
};

Функция makeIterator не имеет параметров, потому что так она вызывается внутри js . Из этого следует, что доступ к текущему объекту, к которому она прикреплена, возможен только через this , а значит она должна быть объявлена как functionDeclaration , а не arrowFunction . Требование к возвращаемому значению этой функции следующее:

Необходимо вернуть объект с методом next . Каждый вызов next будет возвращать объект с двумя свойствами: value и done . value – это значение текущего элемента коллекции, а done – это флаг конца коллекции. Как только next завершает перебор, то возвращается { done: true } , и это является сигналом к тому, что итерирование завершено.

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

const makeIterator = coll => {
  let nextIndex = 0;

  const next = () => {
    if (nextIndex < coll.length) {
      const value = coll[nextIndex++];
      return { value, done: false };
    }
    return { done: true };
  };
  return { next };
}

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

const it = makeIterator(['yo', 'ya']);
it.next(); // { value: 'yo', done: false }
it.next(); // { value: 'ya', done: false }
it.next(); // { done: true }

Именно так будет вызываться next скрыто от наших глаз в момент итерации по объекту.

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

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

const makeIterator = function* (coll) {
  for (const value of coll) {
    yield value;
  }
};

const it = makeIterator(['yo', 'ya']);
it.next(); // { value: 'yo', done: false }
it.next(); // { value: 'ya', done: false }
it.next(); // { value: undefined, done: true }

Как видно из примера, генераторы вводят новый синтаксис в язык. Во-первых, это звездочка после слова function . Она просто указывает на то, что мы имеем дело с генератором. Во-вторых, выражение yield (подчеркиваю: это – не инструкция).

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

Еще один пример для осознания:

const gen = function* () {
  yield 1;
  yield 2;
  yield 3;
};

const it = gen();
it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { value: 3, done: false }
it.next(); // { value: undefined, done: true }

Или даже так:

const it = gen();
[...it]; // [1, 2, 3]

Кроме yield в генераторах можно использовать версию yield* , которая ожидает на вход коллекцию и делает yield для каждого элемента этой коллекции.

const makeIterator = function* () {
  yield* this.collection;
};

Теперь можно переписать наш первый пример вот таким образом:

const obj = {
  collection: ['yo', 'ya'],
  [Symbol.iterator]: function* () {
    yield* this.collection;
  },
};

for (const v of obj) {
  console.log(v);
}
// yo
// ya

JS: Синхронная асинхронность Корутины

В Computer Science под генераторами понимается data producer , то есть сущность в языке, которая только выдает наружу данные, используя yield . При этом существует более общая концепция, которая называется coroutine или сопрограмма. В отличие от генераторов, она может не только генерировать данные, но так же может и потреблять их ( data consumer ). Самым удивительным в этой истории является то, что генераторы в js , по сути, являются корутинами, а использование их в качестве генераторов – это всего лишь один из возможных вариантов.

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

Главное, на что нужно обратить внимание, это появление выражения yield справа от знака равно: const a = yield 10 .

Попробуем по шагам выполнить этот код:

  1. Создание корутины const coroutine = gen();
  2. Вызов next() . Первый вызов приводит к тому, что наружу возвращается { value: 10, done: false } , так как внутри мы оказываемся в точке yield 10 .
  3. Вызов next(result.value + 1) . Выражение result.value + 1 равно 11 , поэтому в итоге происходит вызов next(11) . Внутри корутины мы находимся в этой позиции const a = yield . Аргумент, переданный в next , оказывается записанным в константу a внутри корутины и код продолжает выполнятся до следующего вызова yield , на котором корутина останавливается, и управление возвращается наружу.
  4. Дальнейший вызов next(15) приводит к тому, что константа b становится равна 15 , а наружу возвращается { value: 26, done: true } .

Если обобщить, то yield <что-то> производит данные наружу, const a = yield потребляет данные, а const a = yield <что-то> производит и потребляет в два шага.

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

co(function* () {
  const a = yield Promise.resolve(1);
  const b = yield Promise.resolve(2);
  const c = yield Promise.resolve(3);

  console.log([a, b, c]); // [1, 2, 3]
});

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

co(function* () {
  const a = yield Promise.resolve(1);
  try {
    const b = yield Promise.reject(new Error('Boom'));
  } catch (e) {
    console.log(e.message); // Boom
  }
});

Чтобы такой код заработал, необходимо в функции co отслеживать состояние rejected и использовать метод throw , который есть у нашего генератора. Ниже пример того, как это можно было бы сделать (без промисов):

const gen = function* () {
  try {
    const a = yield;
    yield new Error('Boom');
  } catch (e) {
    console.log(e.message);
  }
  console.log('after Boom');
};

const coroutine = gen();
coroutine.next();
const result = coroutine.next();
coroutine.throw(result.value); // => { value: undefined, done: true }

// Boom
// After Boom

Метод throw() возобновляет выполнение тела генератора кидая внутри исключение и возвращает объект со свойствами done и value.

JS: Синхронная асинхронность async/await

Концепция async/await для работы с асинхронным кодом впервые появилась на платформе .net в 2011 году. С тех пор разработчики многих языков оценили удобство этой конструкции и начали ее внедрение. Например, такое произошло с python , в котором есть уже встроенная поддержка этого механизма, а также с языком kotlin . js тоже не остается в стороне, и не смотря на то, что этот механизм находится в стадии разработки (он не утвержден), многие его уже активно используют в продакшен среде. Хекслет начал использовать async/await еще в 2015 году, за что отдельное спасибо babel .

Использование async/await очень похоже на то, как мы работаем с генераторами:

const load = async () => {
  const a = await Promise.resolve(5);
  const b = await Promise.resolve(10);
  return a + b;
};
load().then(value => console.log(value)); // 15

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

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

Еще одной особенностью таких функций является то, что наружу всегда будет возвращен промис, даже если мы возвращаем что-то другое, как в примере выше. Внутри происходит автоматическое преобразование Promise.resolve(a + b) . Кстати, это означает, что код, вызывающий async функцию, может применять к ней await , и так далее по цепочке вверх.

Теперь можно посмотреть на более комплексный пример и сравнить генераторы с async/await :

import { get } from 'hexlet-http-request';

const getFriends = id => {
  return co(function* () {
    const link = `http://ru.hexlet.io/users/${id}.json`;
    const user = yield get(link);
    yield Promise.all(user.friends.map(f => get(f.link)));
  });
};

getFriends(5).then(friends => console.log(friends));

Версия с async/await :

import { get } from 'hexlet-http-request';

const getFriends = async id => {
  const link = `http://ru.hexlet.io/users/${id}.json`;
  const user = await get(link);
  return await Promise.all(user.friends.map(f => get(f.link)));
};

const load = async () => {
  const friends = await getFriends(5);
  console.log(friends);
};

load();

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