Символы, генераторы, async-await и асинхронные итераторы в JavaScript: их сущность, взаимосвязь и варианты использования

в 9:40, , рубрики: javascript, Блог компании RUVDS.com, разработка, Разработка веб-сайтов

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

image

Материал, перевод которого мы сегодня публикуем, направлен на разъяснение таких механизмов и конструкций JavaScript, как символы, известные символы, итераторы, итерируемые объекты, генераторы, механизм async/await, и асинхронные итераторы. В частности, речь здесь пойдёт о том, почему они появились в языке, и о том, как ими пользоваться. Надо отметить, что темы, которые будут здесь подняты, рассчитаны на тех, кто уже имеет некоторое представление о JavaScript.

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

В ES2015 появился новый, шестой тип данных — symbol. Зачем? У появления этого типа данных есть три основных причины.

▍Причина №1. Расширение базовых возможностей с учётом обратной совместимости

JavaScript-разработчики и комитет ECMAScript (TC39) нуждались в возможности добавлять новые свойства объектов, не нарушая работу существующих механизмов, вроде циклов for-in и методов наподобие Object.keys.

Предположим, у нас имеется такой объект:

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

Если выполнить команду Object.keys(myObject), она вернёт массив [firstName, lastName].

Теперь добавим к объекту myObject ещё одно свойство, например — newProperty. При этом нам надо, чтобы команда Object.keys(myObject) возвратила бы те же значения, что и прежде (другими словами, нечто должно сделать так, чтобы эта функция игнорировала новое свойство newProperty), то есть — [firstName, lastName], а не [firstName, lastName, newProperty]. Как это сделать?

На самом деле, до появления типа данных symbol сделать этого было нельзя. Теперь же, если добавить newProperty в виде символа, команда Object.keys(myObject) проигнорирует это свойство (так как она о нём просто не знает), и вернёт то, что нужно — [firstName, lastName].

▍Причина №2. Предотвращение коллизий имён

Тем, кто занимается развитием JavaScript, хотелось бы, чтобы новые свойства объектов были бы уникальными. Это позволило бы им продолжать добавлять в глобальные объекты новые свойства (то же самое могли бы делать и разработчики, использующие JS для решения практических задач), не беспокоясь о коллизиях имён.

Например, предположим, что работая над неким проектом, вы решили добавить собственный метод toUpperCase в Array.prototype.

Теперь представим, что вы подключили к проекту некую библиотеку (или вышел ES2019), где имеется собственная версия метода Array.prototype.toUpperCase. Подобное вполне может привести к тому, что ваш код перестанет правильно работать.

Рассмотрим пример.

Array.prototype.toUpperCase = function(){
    var i;
    for (i = 0; i<this.length; i++){
        this[i] = this[i].toUpperCase();
    }
    return this;
}

var myArray = ['raja', 'rao'];

myArray.toUpperCase(); //['RAJA', 'RAO']

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

▍Причина №3. Организация вызова стандартными механизмами языка методов, разработанных программистом самостоятельно

Предположим, вы хотите, чтобы некая стандартная функция, скажем, String.prototype.search, вызывала бы вашу собственную функцию, реализующую вашу логику для поиска чего-либо в строке. То есть, например, нужно, чтобы конструкция ‘somestring’.search(myObject); вызывала бы функцию search объекта myObject и передавала бы ей, в качестве аргумента, ‘somestring’. Как это сделать?

Именно в подобных ситуациях нам пригодятся возможности ES2015, в котором имеется множество глобальных символов, называемых «известными символами» («well-known symbols»). И если в вашем объекте есть свойство, представленное одним из таких символов, вы можете организовать вызов вашей функции стандартными функциями. Ниже мы рассмотрим этот механизм подробнее, а, прежде чем это сделать, поговорим о том, как работать с символами.

▍Создание символов

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

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

Обратите внимание на то, что символы можно принять за объекты, так как у них есть методы, но объектами они не являются. Это — примитивные значения. Их можно рассматривать как некие «особые» объекты, которые чем-то похожи на обычные объекты, но ведут себя не так, как они.

Например, у символов есть методы, что роднит их с объектами, но, в отличие от объектов, символы иммутабельны и уникальны.

▍Создание символов и ключевое слово new

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

var mySymbol = new Symbol(); //некорректная конструкция, тут будет выдана ошибка

▍«Описание» символов

То, что называется «описанием» («description») символа, представлено в виде строки и используется для логирования.

//Переменная 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(), их можно создавать с помощью метода Symbol.for(<key>). Этот метод принимает ключ (строку <key>) и создаёт новые символы. При этом, если данному методу будет передан ключ, уже назначенный существующему символу, он просто вернёт этот существующий символ. Поэтому можно говорить о том, что поведение Symbol.for напоминает шаблон проектирования синглтон.

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

Метод Symbol.for существует для того, чтобы можно было создавать символы в одном месте, а работать с ними — в другом.

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

▍Ключи и описания символов

Тут стоит отметить, что, если не пользоваться конструкцией Symbol.for, символы, даже при использовании одинаковых ключей, будут уникальными. Однако если вы пользуетесь Symbol.for, то при указании неуникальных ключей и символы, возвращаемые Symbols.for, окажутся неуникальными. Рассмотрим пример.

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

//только следующее выражение возвращает true,
//так как при создании этих символов с помощью .for
//был использован один и тот же ключ
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'

Обратите внимание на то, что свойства объектов, являющиеся символами, известны как «Symbol-keyed properties», или «свойства с ключами Symbol».

▍Точка и квадратные скобки

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

let myCar = {name: 'BMW'};

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

let honk = Symbol('store honk function');
myCar[honk] = () => 'honk';
//использование

myCar.type; // ошибка
myCar[type]; // 'store car type'

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

▍Зачем использовать символы?

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

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

В следующем примере цикл for-in перебирает свойства объекта obj, но он не знает о свойствах 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, добавив к его прототипу собственный метод Array.prototype.includes. Этот метод будет конфликтовать со стандартным методом includes, присутствующим в JavaScript ES2018. Как снабдить прототип таким методом и избежать коллизии?

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

Для вызова новой функции надо пользоваться квадратными скобками. Причём, обратите внимание на то, что в скобках нужно использовать имя соответствующей переменной, то есть, нечто вроде arr[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', так как includes - это символ

Причина №3. Известные символы (глобальные символы)

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

В ECMAScript 2015 эти символы затем используются для работы с базовыми методами, такими, как String.prototype.search и String.prototype.replace стандартных объектов наподобие String или Array.

Вот несколько примеров таких символов: Symbol.match, Symbol.replace, Symbol.search, Symbol.iterator и Symbol.split.

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

Пример №1. Использование Symbol.search

Общедоступный метод String.prototype.search объекта String выполняет поиск по строке с использованием регулярного выражения или строки-образца и, если ему удалось найти то, что нужно, возвращает индекс искомого в анализируемой строке.

'rajarao'.search(/rao/);
'rajarao'.search('rao');

В ES2015 этот метод сначала проверяет, реализован ли метод Symbol.search в объекте RegExp. Если это так, тогда вызывается именно метод объекта RegExp, средствами которого выполняется поиск. Таким образом, базовые объекты, вроде RegExp, реализуют методы, соответствующие Symbol.search, которые и решают задачи поиска.

▍Внутренние механизмы Symbol.search (стандартное поведение)

Процесс поиска подстроки в строке состоит из следующих шагов.

  1. Выполняется разбор команды 'rajarao'.search('rao');
  2. Последовательность символов "rajarao" преобразуется в объект String (new String("rajarao"))
  3. Последовательность символов "rao" преобразуется в объект RegExp (new Regexp("rao"))
  4. Вызывается метод search объекта String, основанного на строке "rajarao"
  5. Внутри метода search объекта "rajarao" производится вызов метода Symbol.search объекта "rao" (то есть, выполнение операции поиска делегируется объекту "rao"). Этому методу передаётся строка "rajarao". Если схематично представить этот вызов, то выглядеть он может примерно так: "rao"[Symbol.search]("rajarao")
  6. Конструкция "rao"[Symbol.search]("rajarao") возвращает, в качестве результата поиска, число 4, представляющее индекс искомой подстроки в строке, функции search объекта "rajarao", а эта функция, в свою очередь, возвращает 4 нашему коду.

Ниже показан фрагмент, написанный на псевдокоде, демонстрирующий устройство описанных выше внутренних механизмов стандартных объектов JavaScript.

//Псевдокод класса String
class String {
    constructor(value){
        this.value = value;
    }

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

//Псевдокод класса RegExp
class RegExp {
    constructor(value){
        this.value = value;
    }

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

Самое интересное здесь то, что в подобной ситуации теперь необязательно пользоваться объектом RegExp. Стандартная функция String.prototype.search теперь может правильно воспринять любой объект, реализующий метод Symbol.search, который возвращает то, что нужно разработчику, и это не нарушит работу кода. Остановимся на этом подробнее.

Пример №2. Использование Symbol.search для организации вызова функции собственной разработки из стандартных функций

Следующий пример показывает, как мы можем сделать так, чтобы функция String.prototype.search вызывала функцию поиска нашего собственного класса Product. Это возможно благодаря глобальному символу Symbol.search.

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

▍Внутренние механизмы Symbol.search (настраиваемое поведение)

При «поиске» нашего объекта в строке выполняются следующие действия.

  1. Выполняется разбор команды 'barsoap'.search(soapObj);
    Последовательность символов "barsoap" преобразуется в объект String (new String("barsoap"))
  2. Так как soapObj уже является объектом, его преобразования не производится
  3. Вызывается метод search объекта String, основанного на строке "barsoap"
  4. Внутри этого метода производится вызов метода Symbol.search объекта soapObj (операция поиска делегируется этому объекту), ему передаётся строка "barsoap". Фактически, речь идёт о такой команде: soapObj[Symbol.search]("barsoap")

    Команда soapObj[Symbol.search]("barsoap") возвращает функции search результат, который, в соответствии с внутренней логикой объекта soapObj, может принимать значения FOUND и NOT_FOUND. Функция search возвращает этот результат нашему коду.

Теперь, когда мы разобрались с символами, займёмся итераторами.

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

Для начала зададимся вопросом о том, зачем это нужно. Тут дело в том, что практически во всех приложениях необходимо работать со списками данных. Например, эти списки нужно выводить на обычных веб-страницах или в мобильных приложениях. Обычно для хранения и извлечения данных из таких списков разработчики пишут собственные методы.

Однако в нашем распоряжении уже есть стандартные механизмы языка, вроде цикла for-of и оператора расширения (), предназначенные для извлечения наборов данных из стандартных объектов наподобие массивов, строк, объектов типа Map. Почему бы нам не воспользоваться этими стандартными методами для работы с нашими собственными объектами, хранящими наборы данных?

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

//До применения нового подхода
//мы не можем использовать стандартный цикл 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

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

На самом деле, такие правила существуют. Они описывают особенности извлечения данных из объектов. Объекты, построенные в соответствии с этими правилами, называют итерируемыми объектами (iterables).

Вот эти правила:

  1. Основной объект/класс должен хранить некие данные.
  2. У него должно быть свойство, являющееся известным символом Symbol.iterator, реализующее метод, описанный в пунктах 3-6.
  3. Метод Symbol.iterator должен возвращать другой объект — итератор.
  4. Итератор должен иметь метод next.
  5. У метода next должен быть доступ к данным, описанным в пункте 1.
  6. При вызове метода next должны возвращаться данные из пункта 1, либо в формате {value:<stored data>, done: false}, в том случае, если итератор может возвратить ещё что-нибудь, или в виде {done: true}, если итератору больше нечего возвращать.

Если соблюдены все эти требования, тогда основной объект называют итерируемым (iterable), а объект, который он возвращает, называется итератором (iterator).

Поговорим теперь о том, как сделать объект Users итерируемым.

//После внесения изменений
//объект Users становится итерируемым объектом, так как он реализует
//метод Symbol.iterator, который возвращает объект с методом next,
//возвращающим значения в соответствии с вышеописанными правилами

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());
console.log(allUsersIterator.next());
console.log(allUsersIterator.next());
//Эти команды возвращают объекты следующего вида:
//{ done: false, value: { name: 'raja' } }
//{ done: false, value: { name: 'john' } }
//{ done: false, value: { name: 'matt' } }

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

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

Обратите внимание на то, что если мы передаём итерируемый объект (allUsers) циклу for-of или оператору расширения, внутри этих средств производится вызов вида <iterable>[Symbol.iterator]() для того, чтобы получить итератор (наподобие allUsersIterator), а затем использовать этот итератор для извлечения данных.

В результате вышеописанные правила дают нам стандартный способ возврата итераторов из собственных объектов.

Генераторы

Генераторы существуют по двум основным причинам:

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

Рассмотрим эти причины существования генераторов подробнее.

Причина №1. Генераторы в роли обёрток для итерируемых объектов

Вместо того, чтобы делать собственный класс или объект итерируемым, следуя вышеописанным правилам, можно просто создать функцию-генератор (generator), использование которой позволяет упростить работу с итерируемыми объектами.

Вот основные сведения о генераторах.

  1. Если метод-генератор используется в классе, то его имя строится по шаблону *<myGenerator>. Объявление функций-генераторов выглядит как function * myGenerator(){}
  2. Вызов генератора myGenerator() возвращает объект generator, который реализует правила (протокол) итератора, что позволяет, без дополнительных усилий, использовать его в качестве возвращаемого значения iterator.
  3. Генераторы используют специальное выражение yield для возврата данных.
  4. Выражение yield отслеживает предыдущие вызовы и продолжает работу с того места, где она остановилась в прошлый раз.
  5. Если выражение yield используется внутри цикла, оно будет выполнено по одному разу каждый раз, когда мы вызываем метод next итератора.

Пример №1. Метод-генератор вместо Symbol.iterator

В следующем фрагменте кода показано использование метода-генератора (*getIterator()) вместо использования метода Symbol.iterator и собственной реализации метода 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());
console.log(allUsersIterator.next());
console.log(allUsersIterator.next());
console.log(allUsersIterator.next());
//Эти команды возвращают объекты следующего вида:
//{ done: false, value: { name: 'raja' } }
//{ done: false, value: { name: 'john' } }
//{ done: false, value: { name: 'matt' } }
//{ done: true, value: undefined }

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

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

Пример №2. Работа с функциями-генераторам

Тот же пример можно реализовать и с помощью функции-генератора. Выглядеть то, что получится, будет даже проще, чем раньше. Для этого создадим функцию-генератор (в её объявлении используется символ *) и используем ключевое слово yield для возврата значений по одному.

//Функция 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());
console.log(allUsers.next());
console.log(allUsers.next());
console.log(allUsers.next());
//Эти команды возвращают объекты следующего вида:
//{ done: false, value: { name: 'raja' } }
//{ done: false, value: { name: 'john' } }
//{ done: false, value: { name: 'matt' } }
//{ 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]);
//здесь выводится массив объектов пользователей

Обратите внимание на то, что хотя в этом примере используется слово «iterator» для представления allUsers, это, на самом деле, объект Generator.

У объекта Generator есть методы вроде throw и return, в дополнение к методу next. Однако, в практических целях, мы можем использовать возвращённый объект как обычный итератор.

▍Причина №2. Новые средства управления потоком выполнения программы

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

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

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

Символы, генераторы, async-await и асинхронные итераторы в JavaScript: их сущность, взаимосвязь и варианты использования - 2
Сравнение обычной функции и функции-генератора

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

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. Однако, код, который находится после 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() второй раз
//это приводит к выводу в консоли 
//строки 'you can do more stuff after yield' и к возврату
//{value: undefined, done: true}
console.log(myIterator.next());

▍Использование ключевого слова yield несколько раз

В генераторе может встретиться несколько ключевых слов yield, и, хотя в обычных функциях тоже бывает по несколько ключевых слов return, выполняется лишь одна подобная инструкция, в то время как в случае с несколькими 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

С помощью метода .next() можно передавать данные в генератор.

На самом деле, именно эта возможность позволяет, с помощью генераторов, избежать ада коллбэков (это мы рассмотрим чуть ниже). Кроме того, надо отметить, что это интенсивно используется в библиотеках наподобие redux-saga.

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

function* profileGenerator() {
    //первый вызов .next() приведёт к тому, что функция
    //возвратит, с помощью первого yield, вопрос о возрасте.
    //Тут же мы указываем на то, что значение, переданное при
    //втором вызове .next(), нужно сохранить в переменной 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}

▍Генераторы и ад коллбэков

Адом коллбэков называют несколько вложенных асинхронных вызовов, с кодом которых неудобно работать.

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

Обратите внимание на то, как функция co передаёт результат из промиса в генератор с помощью вызова .next(result) на шагах №5 и 10, описание которых приведено ниже.

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(10)
  3. Метод Post.findByID(10) возвращает промис
  4. Организуется ожидание разрешения промиса
  5. После того, как промис вернёт результат, вызывается .next(result)
  6. Результат сохраняется в переменной post
  7. Выполняется асинхронный вызов post.getComments()
  8. Метод post.getComments() возвращает промис
  9. Организуется ожидание разрешения промиса
  10. После того, как промис вернёт результат, вызывается .next(result)
  11. Результат сохраняется в переменной comments
  12. Выполняется команда console.log(post, comments);

Теперь поговорим о конструкции async/await.

Конструкция async/await

Как вы уже знаете, генераторы помогают избежать ада коллбэков, но для того, чтобы воспользоваться ими в погоне за подобной целью, потребуется некая дополнительная библиотека, вроде co. Однако, ад коллбэков — проблема настолько серьёзная, что комитет ECMAScript решил создать вспомогательную конструкцию, предназначенную для использования именно этого аспекта генераторов. Так и появились новые ключевые слова async и await.
Сравним генераторы и конструкцию async/await.

  1. В конструкции async/await используется ключевое слово await вместо yield.
  2. Ключевое слово await умеет работать лишь с промисами.
  3. Для объявления асинхронных функций используется конструкция async function, а не function*.

В целом, конструкция async/await — это подмножество генераторов, снабжённое новым «синтаксическим сахаром».

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

В следующем примере функция getAmount вызывает две асинхронных функции — getUser и getBankBalance. Сделать это можно и в промисе, но использование 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);
    });
}

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

Асинхронные функции нередко нужно вызывать в цикле. Поэтому в ES2018 (согласование этого предложения завершено) TC39 решил включить новый символ Symbol.asyncIterator, и новую конструкцию — for-await-of для того, чтобы облегчить задачу вызова асинхронных функций в циклах.

Сравним обычные и асинхронные итераторы.

▍Обычные итераторы

  1. Метод объекта итератора .next() возвращает значение наподобие {value: ‘some val’, done: false}
  2. Этот метод используется так: iterator.next() //{value: ‘some val’, done: false}

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

Метод .next() объекта асинхронного итератора возвращает промис, который, через некоторое время, разрешается в нечто вроде {value: ‘some val’, done: false}.

Этот метод используется так:

iterator.next().then(({ value, done })=> {//{value: ‘some val’, done: false}}

В следующем фрагменте кода показана работа с циклом for-await-of.

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

Итоги

В этом материале мы рассмотрели некоторые технологии современного JavaScript, рассказали об их основных особенностях, о взаимосвязи друг с другом, о вариантах их практического применения. Подведём итоги.

  • Символы дают в распоряжение программиста тип данных, позволяющий создавать глобальные уникальные значения. Их используют, в основном, как свойства объектов, для добавления к объектам новых возможностей, которые не нарушают работу существующих механизмов наподобие метода Object.keys и цикла for-in.
  • Известные символы — это стандартные символы JavaScript, которые можно использовать для реализации базовых методов в наших собственных объектах.
  • Итерируемые объекты — это объекты, хранящие коллекции данных и следующие специфическим правилам. Это позволяет работать с ними, используя цикл for-of и оператор расширения для извлечения из них хранимых в них данных.
  • Итераторы — это объекты, возвращаемые итерируемыми объектами, и имеющие метод .next(). Именно они ответственны за извлечение данных из итерируемых объектов.
  • Генераторы — это объекты, дающие более высокий уровень абстракции для итерируемых объектов. Кроме того, они предоставляют в распоряжение программиста новые способы управления потоком выполнения программы, которые способствуют решению таких проблем, как ад коллбэков и являются строительными блоками для других механизмов, например — для конструкции async/await.
  • Конструкция async/await представляет собой дополнительный уровень абстракции для генераторов, она предназначена для решения проблемы ада коллбэков.
  • Асинхронные итераторы — это новая возможность ES2018, которая призвана помочь в работе с массивами асинхронных функций в циклах. В частности, такие итераторы позволяют получать значения, возвращаемые каждой из таких функций так же, как это происходит при работе с обычными циклами.

Уважаемые читатели! Какие технологии, освещённые в этом материале, вы используете на практике?

Символы, генераторы, async-await и асинхронные итераторы в JavaScript: их сущность, взаимосвязь и варианты использования - 3

Автор: ru_vds

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js