Отказываемся от коллбэков: Генераторы в ECMAScript 6

в 10:59, , рубрики: callbacks, ecmascript 6, harmony, javascript

Я постоянно слышу людей, ноющих об асинхронных коллбэках в JavaScript. Держать в голове порядок исполнения в этом языке немного трудно (это тот случай, который называют «Callback Hell» или «The Pyramid of Doom»), если до этого ты имел дело с синхронным программированием. Моим обычным ответом было «тебе придется как-то с этим обходиться». В конце концов, ожидаем ли мы, что все языки программирования будут выглядеть и ощущаться одинаково? Конечно нет.

Все поменял недавний обзор черновика ECMAScript 6, в котором описываются генераторы — возможность языка, которая целиком изменит наш способ написания и серверного, и клиентского JavaScript. С помощью генераторов мы можем превратить вложенные коллбэки в похожий на синхронный код без блокирования нашей единственной event loop.
Например, этот код:

    setTimeout(function(){
        _get("/something.ajax?greeting", function(err, greeting) {
            if (err) { console.log(err); throw err; }
            _get("/else.ajax?who&greeting="+greeting, function(err, who) {
                if (err) { console.log(err); throw err; }
                console.log(greeting+" "+who);
            });
        });
    }, 1000);

может быть написан так:

    sync(function* (resume) {
        try (e) {
            yield setTimeout(resume, 1000);
            var greeting = yield _get('/something.ajax?greeting', resume)
            var who = yield _get('/else.ajax?who&greeting=' + greeting, resume)
            console.log(greeting + ' ' + who)
        }
        catch (e) {
            console.log(e);
            throw e;  
        } 
    });

Интересно, не правда ли? Централизованная обработка исключений и понятный порядок исполнения.

Э-э, ECMAScript 6?

Примеры в этой статье будут работать в Chrome Canary 33.0.1716.0. Примеры, за исключением тех, где есть XHR, должны работать в Node.js с флагом --harmony (с версии 0.11, прим. перев.). Реализация генераторов, предлагаемая в JavaScript 1.7+, не придерживается черновика ECMAScript 6 — так что вам придется внести некоторые изменения, чтобы заставить примеры работать в Firefox. Если вы хотите запустить эти примеры в Canary, вы можете запускать их в таком же виде, как здесь.

ES6 генераторы

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

В соответствии с черновиком ECMAScript 6, генераторы — это «сопрограммы первого класса, представляющие из себя объекты, инкапсулирующие отложенные контексты исполнения». Проще говоря, генераторы — это функции, которые могут останавливать свое исполнение (с помощью ключевого слова yield) и продолжать свое исполнение с того же места после вызова их метода next. JavaScript все так же выполняет только одну задачу в одно и то же время, но он теперь в состоянии приостанавливать выполнение в середине тела функции-генератора и переключать контекст на исполнение чего-то другого. Генераторы не дают возможности параллельного исполнения кода и они не умеют обращаться с потоками.

Скромный итератор

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

    function* fibonacci() {
        var a = 0, b = 1, c = 0;

        while (true) {
            yield a;
            c = a;
            a = b;
            b = c + b;
        }
    }
     
    function run() {
        var seq = fibonacci();
        console.log(seq.next().value); // 0
        console.log(seq.next().value); // 1
        console.log(seq.next().value); // 1
        console.log(seq.next().value); // 2
        console.log(seq.next().value); // 3
        console.log(seq.next().value); // 5
    }
     
    run();

Что здесь происходит:

  1. Функция run инициализирует генератор чисел Фибоначчи (он описан специальным синтаксисом funсtion*). В отличие от обычной функции этот вызов не начинает исполнение ее тела, а возвращает новый объект — генератор.
  2. Когда функция run вызывает метод генератора next (синхронная операция), код выполняется до того момента, пока не встретит оператор yield.
  3. Выполнение оператора yield останавливает генератор и возвращает результат наружу. Операции, следующие за yield в этот момент не были выполнены. Значение (операнд a за yield) будет доступно снаружи через свойство value у результата исполнения.
    При следующем вызове метода next у генератора, выполнение кода продолжается с того места, где оно остановилось на предыдущем yield.

Вам, наверно, интересно, выйдет ли генератор когда-либо из цикла while. Нет, он будет исполняться внутри цикла до тех пор, пока кто-то вызывает его метод next.

Следим за исполнением кода

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

    function* powGenerator() {
        var result = Math.pow(yield "a", yield "b");
        return result;
    }
 
    var g = powGenerator();
    console.log(g.next().value);   // "a", from the first yield
    console.log(g.next(10).value); // "b", from the second
    console.log(g.next(2).value);  // 100, the result

Первое выполнение генератора возвращает значение "a" в качестве свойства value результата исполнения. Затем мы продолжаем исполнение, передав в генератор значение 10. Воспользуемся подстановкой, чтобы продемонстрировать, что происходит:

    function* powGenerator() {
        var result = Math.pow(----10----, yield "b");
        return result;
    }

Затем генератор доходит до второго yield и снова приостанавливает свое исполнение. Значение "b" будет доступно в возвращенном объекте. Наконец, мы снова продолжаем исполнение, передавая в качестве аргумента 2. Снова подстановка:

    function* powGenerator() {
        var result = Math.pow(----10----, ----2----);
        return result;
    }

После этого вызывается метод pow, и генератор возвращает значение, хранимое в переменной result.

Ненастоящая синхронность: блокирующий Ajax

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

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

Обратно к коду:

    // **************
    // framework code

    function sync(gen) {
        var iterable, resume;
 
        resume = function(err, retVal) {
            if (err) iterable.raise(err);
            iterable.next(retVal); // resume!  
        };
 
        iterable = gen(resume);
        iterable.next();
    }
 
    function _get(url, callback) {
        var x = new XMLHttpRequest();
 
            x.onreadystatechange = function() {
                if (x.readyState == 4) {
                callback(null, x.responseText);
            }
        };
 
        x.open("GET", url);
        x.send();
    }
 
    // ****************
    // application code
 
    sync(function* (resume) {
        log('foo');
        var resp = yield _get("blix.txt", resume); // suspend!
        log(resp);
    });
 
    log('bar'); // not part of our generator function’s body

Можете ли вы догадаться, что вы увидете в консоли? Правильный ответ: «foo», «bar» и «то, что находится в blix.txt». Располагая код внутри генератора, мы делаем его похожим на обычный синхронный код. Мы не блокируем поток event loop; мы останавливаем генератор и продолжаем выполнять код, расположенный дальше после вызова next. Будущий коллбэк, который будет вызван на другом тике, продолжит наш генератор, передав ему нужное значение.

Централизованная обработка ошибок

Централизованная обработка ошибок внутри нескольких асинхронных коллбэках — это боль. Вот пример:

    try {
        firstAsync(function(err, a) {
            if (err) { console.log(err); throw err; }
            secondAsync(function(err, b) {
                if (err) { console.log(err); throw err; }
                thirdAsync(function(err, c) {
                    if (err) { console.log(err); throw err; }
                    callback(a, b, c);
                });
            });
        });
    }
    catch (e) {
        console.log(e);
    }

Блок catch никогда не будет исполнен из-зи того, что выполнение коллбэка — это часть совершенно другого коллстека, в другом тике event loop. Обработка исключений должна быть расположена внутри самой функции-коллбэка. Можно реализовать функцию высшего порядка, чтобы избавиться от некоторых повторяющихся проверок на наличие ошибок и убрать некоторые вложения с помощью библиотеки навроде async. Если же следовать соглашению Node.js об ошибке, как о первом аргументе, можно написать общий обрабочик, который будет возвращать все ошибки назад в генератор:

    function sync(gen) {
        var iterable, resume;

        resume = function(err, retVal) {
            if (err) iterable.raise(err); // raise!
            iterable.next(retVal);
        };

        iterable = gen(resume);
        iterable.next();
    }

    sync(function* (resume) {
        try {
            var x = firstAsync(resume);
            var y = secondAsync(resume);
            var z = thirdAsync(resume);
            // … do something with your data
        }
        catch (e) {
            console.log(e); // will catch errors from any of the three calls
        }
    });

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

Одновременные операции.

То, что код генератора исполняется сверху вниз, не значит, что вы не можете работать с несколькими асинхронными операциями одновременно. Бибилиотеки навроде genny и gen-run дают такой API: они просто выполняют некоторое количество асинхронных операций перед тем, как продолжить исполнение генератора. Пример, с использование genny:

    genny.run(function* (resume) {
        _get("test1.txt", resume());
        _get("test2.txt", resume());
        var res1 = yield resume, res2 = yield resume; // step 1
        var res3 = yield _get("test3.txt", resume()); // step 2
        console.log(res1 + res2);
    });

Итого

Асинхронные коллбэки де-факто были основным паттерном в JavaScript на протяжении долго времени. Но теперь вместе с генераторами в браузере (Firefox с JavaScript 1.7 и Chrome Canary несколько месяцев назад) все меняется. Новые конструкции управления исполнением дают возможность использовать совершенно новый стиль программирования, такой, который сможет соперничать с традиционным стилем вложенных коллбэков. Осталось дождаться, когда стандарт ECMAScript 6 будет реализован в завтрашних движках JavaScript.

Автор: Verkholantsev

Источник

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


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