Символы, итераторы и другие концепции JavaScript простым языком

Символы, итераторы, генераторы, async/await и асинхронные итераторы в JavaScript неотделимы друг от друга. Разбираем все концепции разом.

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

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

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

Символы и «известные» символы

Символы были введены в язык стандартом ES2015. Для чего понадобились эти странные конструкции? Оказывается, у них сразу три важных задачи.

Задача #1 – Безопасное добавление функциональности

Чтобы язык развивался, нужна возможность вводить в объекты дополнительные служебные методы, не ломая при этом циклы for ... in и функции, подобные Object.keys().

Для примера возьмем следующую структуру:

var myObject = {
  firstName:'raja', 
  lastName:'rao'
};

Если передать этот объект функции Object.keys(myObject), мы получим на выходе массив с ключами ["firstName", "lastName"]. Все ожидаемо и правильно.

Теперь определим еще одно поле, скажем, newProperty. Задача заключается в том, чтобы Object.keys() по-прежнему возвращал только старые свойства, пропуская новое. Другими словами, мы хотим снова получить ["firstName", "lastName"], а не ["firstName", "lastName", "newProperty"].

Раньше JavaScript такой логики не предусматривал, поэтому пришлось придумать Symbol.

Секрет в том, чтобы определить newProperty как символ. В этом случае Object.keys() не узнает о его существовании. Мы получим ["firstName", "lastName"], как и хотели.

Задача #2 – Защита от совпадения имен

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

К примеру, разработчик добавил в объект Array.prototype полезный метод toUpperCase().

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

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

Задача #3 – Хуки во встроенных методах

Сразу перейдем к примеру. В JavaScript есть встроенный метод String.prototype.search. Задача состоит в том, чтобы этот метод при вызове передавал управление некоторой пользовательской функции, которая реализует собственную поисковую логику.

Иначе говоря, требуется, чтобы команда

"somestring".search(myObject);

отрабатывала следующим образом:

myObject.search("somestring");

Ух ты! Встроенный метод объекта String должен вызвать метод search у своего аргумента, передать ему саму строку, да еще, видимо, каким-то образом обработать результат. Неужели это возможно в JavaScript?

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

Основы работы

Создание

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

//Переменная mySymbol имеет тип symbol
var mySymbol = Symbol();

Значения типа symbol нетрудно спутать с объектами, ведь у них тоже есть методы. Однако это неизменяемые примитивы, больше похожие на строки.

А где «new»?

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

var mySymbol = new Symbol(); // => TypeError: Symbol is not a constructor

Дескриптор символа

Для символа можно определить дескриптор. Он передается параметром в функцию Symbol() и используется только для логирования.

// символ в переменной mySymbol имеет некоторое уникальное значение и описание "some text"

const mySymbol = Symbol('some text');

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

const mySymbol1 = Symbol('some text');
const mySymbol2 = Symbol('some text');
mySymbol1 == mySymbol2 // => false

Метод Symbol.for()

Symbol() не единственный способ создать символ. Есть еще метод Symbol.for(). Он принимает в качестве параметра ключ, под которым символ будет занесен в глобальный реестр.

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

var mySymbol1 = Symbol.for('some key'); // создает новый символ
var mySymbol2 = Symbol.for('some key'); // возвращает уже существующий символ
mySymbol1 == mySymbol2 // => true, так как это один и тот же символ

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

Помните, что нельзя переопределить символ, ассоциированный с существующим ключом.

Описание vs. ключ

Как говорилось раньше, символьные дескрипторы не влияют на уникальность. И наоборот, Symbol.for(key) всегда сопоставляет одинаковым ключам один символ.

// создаёт уникальный символ с описанием "some text"
var mySymbol1 = Symbol('some text'); 
// создаёт еще один уникальный символ с описанием "some text"
var mySymbol2 = Symbol('some text'); 
// создаёт уникальный символ с ключом "some text"
var mySymbol3 = Symbol.for('some text'); 
// возвращает символ из mySymbol3
var mySymbol4 = Symbol.for('some text'); 

// возвращает true, так как это один и тот же символ
mySymbol3 == mySymbol4 // => true

// все эти символы уникальны
mySymbol1 == mySymbol2 // => false
mySymbol1 == mySymbol3 // => false
mySymbol1 == mySymbol4 // => false

Символьные ключи объектов

Символы, как и строки, могут быть ключами объектов. Фактически это основной способ их использования.

const mySymbol = Symbol("Some car description");
const myObject = {name: 'bmw'};

//для добавления символов в качестве идентификаторов
//свойств объекта пользуются скобками
myObject[mySymbol] = 'This is a car';

console.log(myObject[mySymbol]); // => "This is a car"

Свойства объектов, ключами которых являются символы, для удобства будем называть символьными свойствами.

Скобки или точка?

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

let myCar = {name: 'BMW'};

let type = Symbol('store car type');
myCar[type] = 'A_luxury_Sedan';

let honk = Symbol('store honk function');
myCar[honk] = () => 'honk';

//использование
myCar.type; // => ошибка
myCar[type]; // => 'store car type'

myCar.honk(); // => ошибка
myCar[honk](); // => 'honk'

3 задачи символов

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

Задача #1 – Символы не обрабатываются циклами

Цикл for ... in игнорирует символьные свойства prop3 и prop4:

var obj = {};
obj['prop1'] = 1;
obj['prop2'] = 2;

//Добавим в объект свойства с идентификаторами-символами,
//используя квадратные скобки 
var prop3 = Symbol('prop3');
var prop4 = Symbol('prop4');
obj[prop3] = 3;
obj[prop4] = 4;

for(var key in obj){
    console.log(key, '=', obj[key]);
}
//цикл не знает о свойствах prop3 и prop4
// => prop1 = 1
// => prop2 = 2

//Однако к свойствам prop3 и prop4 можно обращаться
console.log(obj[prop3]); // => 3
console.log(obj[prop4]); // => 4

Методы Object.keys() и Object.getOwnPropertyNames() также их пропускают:

const obj = {
    name: 'raja'
};

//Добавим в объект свойства с идентификаторами-символами
obj[Symbol('store string')] = 'some string';
obj[Symbol('store func')] = () => console.log('function');

//Они игнорируются многими методами
console.log(Object.keys(obj)); // => [name]
console.log(Object.getOwnPropertyNames(obj)); // => [name]

Задача #2 – Символы уникальны

Предположим, требуется включить в объект Array.prototype новый метод includes. Однако в стандарте ES2015 уже есть встроенный метод с тем же названием.

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

var includes = Symbol('will store custom includes method');

//Добавим новый метод в Array.prototype
Array.prototype[includes] = () => console.log('inside includes func');

var arr = [1,2,3];

//вызов стандартного метода includes
console.log(arr.includes(1)); // => true
console.log(arr['includes'](1)); // => true

//вызов пользовательского метода includes
arr[includes](); // => 'inside includes func'

Задача #3 – «Известные» символы

Глобальный объект Symbol (тот самый, который создает уникальные символы) имеет ряд свойств, например, Symbol.search или Symbol.iterator. Они содержат символьные значения и называются «известными» символами. Их можно использовать, чтобы перехватывать управление у некоторых встроенных методов JavaScript-объектов.

Использование «известных» символов

Мы уже вспоминали о встроенном методе String.prototype.search. Он возвращает индекс вхождения в исходную строку подстроки или регулярного выражения.

Новый стандарт установил в эту функцию хук, который проверяет, не определен ли метод Symbol.search у аргумента (например, у регулярного выражения). Если этот метод находится, то управление передается ему.

Стандартная реализация

    1. Интерпретатор встречает команду "rajarao".search("rao") и анализирует ее.
    2. Строка "rajarao" конвертируется в объект String, а аргумент "rao" в объект RegExp.
      new String("rajarao");
      new Regexp("rao");
    3. Запускается метод search, внутри него происходит проверка существования метода Symbol.search у объекта регулярного выражения. Если таковой найден, то ему делегируется управление:
      // это выглядит примерно так
      "rao"[Symbol.search]("rajarao")
    4. Метод объекта Regexp получает результат 4 и передает его методу объекта String, который в свою очередь возвращает значение в код программы.

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

// класс String
class String {
    constructor(value){
        this.value = value;
    }

    search(obj){
        // передача управления методу Symbol.search объекта obj 
        obj[Symbol.search](this.value);
    }
}

// класс RegExp
class RegExp {
    constructor(value){
        this.value = value;
    }

    // реализация операции поиска
    [Symbol.search](string){
        return string.indexOf(this.value);
    }
}

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

Пользовательская реализация

Давайте заставим встроенный метод String.prototype.search делегировать управление методу пользовательского класса Product.

class Product {
    constructor(type){
        this.type = type;
    }

    // реализация поиска
    [Symbol.search](string){
        return string.indexOf(this.type) >=0 ? 'FOUND' : "NOT_FOUND";
    }
}

var soapObj = new Product('soap');

'barsoap'.search(soapObj); // => FOUND
'shampoo'.search(soapObj); // => NOT_FOUND

Действия JavaScript-интерпретатора аналогичные: он встречает команду и начинает ее разбор. Сначала строка "barsoap" конвертируется в объект String. soapObj уже является объектом, поэтому не нуждается в конвертации. Метод search после запуска первым делом проверяет, нет ли у аргумента свойства Symbol.search. Оно находится, поэтому управление передается объекту soapObj.

soapObj[Symbol.search]("barsoap");

Метод получает значение FOUND, которое по цепочке возвращается в программу.

Вот мы и разобрались с символами. Теперь можно двигаться дальше.

Итераторы и итерируемые объекты

В JavaScript существуют удобные методы перебора объектов, например, цикл for ... of или spread-оператор. Однако их можно использовать не для всякой структуры. Зачастую приходится писать собственные get-методы для получения данных.

Например, объект класса Users просто так не перебрать:

// нельзя использовать стандартный цикл for-of или
// оператор расширения для извлечения сведений об отдельных
// пользователях из объекта Users

class Users {
    constructor(users){
        this.users = users;
    }

    //это нестандартный метод
    get() {
        return this.users;
    }
}

const allUsers = new Users([
    { name: 'raja' },
    { name: 'john' },
    { name: 'matt' },
]);

//Команда allUsers.get() работает, но так сделать нельзя
for (const user of allUsers){
    console.log(user);
}
// => TypeError: allUsers is not iterable

// Так тоже нельзя
const users = [...allUsers];
// => TypeError: allUsers is not iterable

Было бы очень удобно использовать встроенную функциональность языка для пользовательских объектов. Теперь это возможно!

Для того чтобы стандартные инструменты JavaScript могли обрабатывать объект, он должен быть итерируемым (iterable). Итерируемый объект соответствует ряду условий:

  1. Хранит некоторый набор данных.
  2. Имеет метод Symbol.iterator, результатом работы которого является объект «итератор», имеющий доступ к данным.
  3. У итератора определен метод next, результатом работы которого является объект с полями value и done.
  4. Поле done имеет булево значение. Если итератор закончил работу и данных больше нет, оно равно true.

Давайте сделаем объекты класса Users итерируемыми:

//объект Users итерируемый, 
// так как он реализует метод Symbol.iterator

class Users{
    constructor(users){
        this.users = users;
    }

    //символьное свойство Symbol.iterator 
    // хранит соответствующий метод

    [Symbol.iterator](){
        let i = 0;
        let users = this.users;

        //этот возвращаемый объект называется итератором

        return {
            next(){
                if (i<users.length) {
                    return { done: false, value: users[i++] };
                }

                return { done: true };
            },
        };
    }
}

// allUsers называют итерируемым объектом
const allUsers = new Users([
    { name: 'raja' },
    { name: 'john' },
    { name: 'matt' },
]);

// allUsersIterator называют итератором
const allUsersIterator = allUsers[Symbol.iterator]();

// Метод next возвращает следующее значение из набора данных
console.log(allUsersIterator.next()); 
// => { done: false, value: { name: 'raja' } }
console.log(allUsersIterator.next()); 
// => { done: false, value: { name: 'john' } }
console.log(allUsersIterator.next()); 
// => { done: false, value: { name: 'matt' } }

//Использование цикла for-of
for(const u of allUsers){
    console.log(u.name);
}
// здесь выводятся имена пользователей: raja, john, matt

//Использование оператора расширения
console.log([...allUsers]);
// здесь выводится массив объектов пользователей

Цикл for ... of и spread-оператор вызывают метод Symbol.iterator автоматически, «под капотом».

Функции-генераторы

Основное предназначение генераторов:

  1. создать удобную обертку для итераторов;
  2. упорядочить поток выполнения кода.

Функция #1 – Обертка для итераторов

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

В чем особенности этой новой конструкции?

  1. Для обозначения генераторов введен оператор * (звездочка), который ставится после слова function или непосредственно перед названием метода.
  2. Возвращаемый функцией объект реализует интерфейс итератора. Для удобства его тоже называют генератором.
  3. Возврат данных из функции-генератора осуществляется командой yield.
  4. Вызов оператора yield приостанавливает работу функции. Место прерывания при этом запоминается.
  5. Если yield находится внутри цикла, то он будет выполняться однократно при каждом вызове next().

Генератор вместо итератора

//Вместо того чтобы делать объект итерируемым, можно
//просто создать метод-генератор (*getIterator())
//и возвратить итератор для извлечения данных

class Users{
    constructor(users) {
        this.users = users;
        this.len = users.length;
    }

    //это генератор, который возвращает итератор
    *getIterator(){
        for (let i in this.users){
            yield this.users[i];
            //хотя эта команда вызывается внутри цикла,
            //yield выполняется лишь один раз за вызов
        }
    }
}

const allUsers = new Users([
    { name: 'raja' },
    { name: 'john' },
    { name: 'matt' },
]);

//allUsersIterator называют итератором
const allUsersIterator = allUsers.getIterator();

// Метод next возвращает следующее значение из набора данных,
console.log(allUsersIterator.next()); 
// => { done: false, value: { name: 'raja' } }
console.log(allUsersIterator.next()); 
// => { done: false, value: { name: 'john' } }
console.log(allUsersIterator.next()); 
// => { done: false, value: { name: 'matt' } }
console.log(allUsersIterator.next()); 
// => { done: true, value: undefined }

//Использование цикла for-of
for(const u of allUsers.getIterator()){
    console.log(u.name);
}
// здесь выводятся имена пользователей: raja, john, matt

//Использование оператора расширения
console.log([...allUsers.getIterator()]);
// здесь выводится массив объектов пользователей

Генератор вместо класса

//Функция Users - это генератор, она возвращает итератор
function* Users(users){
    for (let i in users){
        yield users[i++];
        //хотя эта команда вызывается внутри цикла,
        //yield выполняется лишь один раз за вызов
    }
}

const allUsers = Users([
    { name: 'raja' },
    { name: 'john' },
    { name: 'matt' },
]);

//Метод next возвращает следующее значение из набора данных
console.log(allUsers.next()); 
// => { done: false, value: { name: 'raja' } }
console.log(allUsers.next());
// => { done: false, value: { name: 'john' } }
console.log(allUsers.next());
// => { done: false, value: { name: 'matt' } }
console.log(allUsers.next());
// => { done: true, value: undefined }

const allUsers1 = Users([
    { name: 'raja' },
    { name: 'john' },
    { name: 'matt' },
]);

//Использование цикла for-of
for(const u of allUsers1){
    console.log(u.name);
}
//Здесь выводятся имена пользователей: raja, john, matt

const allUsers2 = Users([
    { name: 'raja' },
    { name: 'john' },
    { name: 'matt' },
]);

//Использование оператора расширения
console.log([...allUsers2]);
//здесь выводится массив объектов пользователей

Объект генератора также имеет методы throw() и return().

Функция #2 — управление потоком

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

На картинке изображена работа генератора. Каждая команда yield прерывает выполнение функции и возвращает значение. А с помощью вызова извне generator.next("some value") можно передать внутрь промежуточные данные.

Более детальный пример управления потоком кода:

function* generator(a, b){
    //возвращает результат a + b
    //в k сохраняются новые входные данные, а не
    //результат операции a + b
    let k = yield a + b;
    //то же самое справедливо и для m
    let m = yield a + b + k;
 
    yield a + b + k + m;
}

var gen = generator(10, 20);

//Нас интересует значение a + b
//done имеет значение false, так как
//в коде имеются необработанные ключевые слова yield
console.log(gen.next()); 
// => {value: 30, done: false}

//В данный момент функция находится в памяти, 
//у неё есть значения a и b,
// если вызвать .next() снова, передав этому методу какое-то
//значение, выполнение функции начнётся с места остановки

//Запишем в k 50 и возвратим результат вычисления выражения a + b + k
console.log(gen.next(50));
// => {value: 80, done: false}

//Функция всё ещё остаётся в памяти, у неё есть значения a, b и k.
//Очередной вызов .next() снова ее запустит

//Запишем в m 100 и вернём результат вычисления выражения a + b + k + m
console.log(gen.next(100));
// => {value: 180, done: false}

//Если снова вызвать .next(), функция вернёт undefined, так как в ней
//больше нет строк с yield
console.log(gen.next());
// => {value: undefined, done: true}

Синтаксис

Несколько способов объявить генератор:

//Обычное объявление функции-генератора
function *myGenerator() {}
//или
function * myGenerator() {}
//или
function* myGenerator() {}

// функциональное выражение
const myGenerator = function*() {}

//стрелочные функции не могут быть генераторами
let generator = *() => {}
// => SyntaxError: Unexpected token *

// классы ES2015
class MyClass {
    *myGenerator() {}
}

// литерал объекта
const myObject = {
    *myGenerator() {}
}

yield и return

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

function* myGenerator() {
    let name = 'raja';
    yield name;
    console.log('you can do more stuff after yield');
}

//генератор возвращает итератор
const myIterator = myGenerator();

//вызываем .next() в первый раз
console.log(myIterator.next());
// => {value: "raja", done: false}

//вызываем .next() второй раз
console.log(myIterator.next());
//в консоли: 'you can do more stuff after yield'
//возвращенное значение: {value: undefined, done: true}

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

function* myGenerator() {
    let name = 'raja';
    yield name;
   
    let lastName = 'rao';
    yield lastName;
}

//генератор возвращает итератор
const myIterator = myGenerator();

//вызываем .next() в первый раз
console.log(myIterator.next());
// => {value: "raja", done: false}

//вызываем .next() второй раз
console.log(myIterator.next());
// => {value: "rao", done: false}

Передача данных в функцию

Методу next() можно передать параметры. Они будут отправлены генератору, и он сможет использовать их в своей работе.

Этот механизм спасает разработчиков от пирамиды коллбэков. Он активно используется различными JavaScript-библиотеками, например, redux-saga.

function* profileGenerator() {
    //при первом вызове функция спросит о возрасте.
    //значение, переданное при втором вызове
    // нужно сохранить в переменной answer
    let answer = yield 'How old are you?';

    //'adult' или 'child' в зависимости от answer
    if (answer > 18){
        yield 'adult';
    } else {
        yield 'child';
    }
}

//генератор возвращает итератор
const myIterator = profileGenerator();

console.log(myIterator.next());
// => {value: "How old are you?", done: false}

console.log(myIterator.next(23));
// => {value: "adult", done: false}

Здесь первый вызов метода next() без параметров возвращает вопрос. А во второй передается значение 23, которое будет использовано функцией.

Ад коллбэков

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

co(function *() {
    let post = yield Post.findByID(10);
    let comments = yield post.getComments();
    console.log(post, comments);
}).catch(function(err){
    console.error(err);
});

Разберемся, как работает этот код.

  1. Генератор передается функции co.
  2. Вызывается метод Post.findByID(), который возвращает Promise.
  3. Функция временно приостанавливает работу.
  4. После получения результата его нужно вернуть в генератор через next().
  5. Значение записывается в post.
  6. Вызывается еще один асинхронный метод post.getComments(), который вновь возвращает обещание.
  7. Генератор ожидает, затем записывает полученное значение всomments.
  8. Данные выводятся на консоль.

Следующая связанная концепция, которую необходимо изучить, – async/await.

Async/await

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

  • Оператор * заменяется на оператор async.
  • Вместо команды yield используется await.

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

//Вместо промисов ES2015...
function getAmount(userId){
    getUser(userId)
        .then(getBankBalance)
        .then(amount => {
            console.log(amount);
        });
}

//конструкция async/await ES2017
async function getAmount2(userId){
    var user = await getUser(userId);
    var amount = await getBankBalance(user);
    console.log(amount);
}

getAmount('1'); // => $1,000
getAmount2('1'); // => $1,000

function getUser(userId){
    return new Promise(resolve => {
        setTimeout(() => {
            resolve('john');
        }, 1000);
    });
}

function getBankBalance(user){
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (user == 'john'){
                resolve('$1,000');
            } else {
                reject('unknown user');
            }
        }, 1000);
    });
}

Здесь внутри getAmount последовательно выполняются две асинхронные функции getUser() и getBankBalance().

То же самое можно сделать с помощью обещаний, но решение с async/await более изящно.

Асинхронные итераторы

Иногда возникает необходимость вызвать асинхронно выполняющуюся функцию внутри цикла. Это довольно сложная задача, поэтому ES-комитет решил добавить в стандарт еще один «известный» символ Symbol.asyncIterator и цикл for ... await ... of.

В чем разница между обычным итератором и асинхронным?

  1. Метод next() изначально возвращает обещание, которое разрешается в стандартный формат
    { 
      value: "some value", 
      done: false 
    }
  2. Вместо простого вызова iterator.next() необходимо использовать следующую конструкцию:
    iterator.next()
      .then(({ value, done }) => { ... });

     

Вот пример работы с асинхронным циклом:

const promises = [
    new Promise(resolve => resolve(1)),
    new Promise(resolve => resolve(2)),
    new Promise(resolve => resolve(3)),
];

//Можно просто пройтись в цикле по массиву функций,
//которые возвращают промисы
async function test(){
    for await (const p of promises){
        console.log(p);
    }
}

test(); // => 1, 2, 3

 

Подведем итоги

Символы – особый уникальный тип данных. Используются в основном как свойства объектов, невидимые для цикла for ... in.

Известные символы – встроенные символы языка, использующиеся для перехвата управления в стандартных методах.

Итерируемые объекты – объекты, удовлетворяющие ряду правил, с которыми может работать цикл for ... of.

Итераторы – имеют метод next() и обеспечивают извлечение данных из итерируемых объектов.

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

Async/await – абстракция над генераторами для удобной работы с промисами.

Асинхронные итераторы – новая функциональность языка, предназначенная для запуска асинхронных функций внутри циклов.

Перевод статьи rajaraodv: JavaScript Symbols, Iterators, Generators, Async/Await, and Async Iterators — All Explained Simply

Другие материалы по теме:

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

eFusion
01 марта 2020

ТОП-15 книг по JavaScript: от новичка до профессионала

В этом посте мы собрали переведённые на русский язык книги по JavaScript – ...
admin
10 июня 2018

Лайфхак: в какой последовательности изучать JavaScript

Огромный инструментарий JS и тонны материалов по нему. С чего начать? Расск...