Недавно я нашел библиотеку Co.js. Проект описывается как Улучшение потока управления на основе генератора для nodejs и браузера с использованием промисов, позволяющее писать неблокирующий код красивым способом. Это помогает создавать асинхронный код, который считывается синхронно, используя генераторы и промисы.
В этом посте я собираюсь просмотреть исходный код Co. Прежде чем углубляться, было бы неплохо увидеть его в действии:
co(function* () { const x = yield Promise.resolve(1) const y = yield Promise.resolve(2) return x + y }).then(console.log) .catch(console.error) // 3
Co — это функция, которая принимает генератор в качестве входных данных и возвращает обещание. В генераторе есть 2 промиса, которые выдаются, и сразу после этого возвращается сложение результатов. По сути, то, что делает Co, — это прогон всех выходных данных внутри генератора. Он инициализирует генератор и вызывает метод next столько раз, сколько необходимо, пока все не будет получено и done не станет истинным.
Таким образом, ключевое слово yield дает нам возможность писать асинхронный код как синхронный и генерирует API для запуска любого их количества, которое Co абстрагирует для нас.
Метафорически я вижу Ко как черный ящик, в котором останавливается время. Внутри этого поля (на самом деле генератора ввода) вы можете получить любое количество асинхронных функций и рассматривать их как обычные функции (без .then(…), без обратных вызовов). Снаружи время течет как обычно, и Ко это просто обычное обещание.
Возможно, сейчас вы думаете, что если Co вернет промис, вы можете заставить Co запускать совместно сгенерированные промисы. Это мощная идея, и, к счастью, Co предлагает метод wrap, который делает ее очень простой:
var fn = co.wrap(function* (val) { return yield Promise.resolve(val); }); fn(true).then(function (val) { ... });
В этом примере из README репозитория метод wrap получает генератор и возвращает обычную функцию, которая, в свою очередь, возвращает промис. Обратите внимание, как вы можете передать значение генератору с помощью возвращаемой функции (val = true). Результат такой же, как и выше, но теперь очень легко запустить Co поверх совместно сгенерированных промисов:
const fn1 = co.wrap(function* (val) { const x = yield Promise.resolve(5) const y = yield Promise.resolve(7) return x + y }) const fn2 = co.wrap(function* (val) { const x = yield Promise.resolve(4) const y = yield Promise.resolve(1) return x * y }) co(function* () { const x = yield fn1() const y = yield fn2() return x/y // 3 }).then(console.log) .catch(console.error)
Такой подход дает вам большую гибкость в организации кода, а общая читабельность увеличивается в геометрической прогрессии. В общем, я только что обнаружил библиотеку, оставьте комментарий, если вы знаете лучшие способы или другие приемы!
Время погрузиться
Библиотека Co состоит из 239 LOC, найденных в ее index.js. Первое, с чем мы сталкиваемся, это только что рассмотренный метод wrap:
co.wrap = function (fn) { createPromise.__generatorFunction__ = fn; return createPromise; function createPromise() { return co.call(this, fn.apply(this, arguments)); } };
Функция createPromise вызывает совместную передачу в контексте и генератор ввода (fn), который сразу же инициализируется с помощью применения. Затем свойство __generatorFunction__ устанавливается на генератор входных данных (на данный момент я не уверен, есть ли для этого какая-либо конкретная цель, кроме возможности восстановить исходный генератор — пожалуйста, оставьте комментарий, если вы это сделаете ). Таким образом, когда мы оборачиваем генератор, мы получаем обычную функцию, которая при вызове вызывает co, передавая входной генератор инициализированному.
Далее мы сталкиваемся с функцией co; давайте рассмотрим его шаг за шагом (комментарии к коду удалены для удобства чтения):
function co(gen) { var ctx = this; var args = slice.call(arguments, 1); return new Promise(function(resolve, reject) { if (typeof gen === 'function') gen = gen.apply(ctx, args); if (!gen || typeof gen.next !== 'function') return resolve(gen); onFulfilled();
Прежде всего, он сохраняет контекст и любые дополнительные аргументы, которые вы передаете (кроме функции-генератора) в переменных ctx и args соответственно, для последующего повторного использования. Затем он возвращает новое обещание (если вы помните, вы можете рассматривать Co как обещание — вот почему). Пока все хорошо. Теперь становится интересно; он проверяет, является ли gen функцией (примечание: тип генератора — «функция»), и обновляет gen своим возвращаемым значением. Таким образом, у нас есть 2 различных возможности: если вы передаете генератор, gen теперь будет содержать генератор, инициализированный (т. е. с помощью метода next); если вы передаете обычную функцию, gen будет содержать возвращаемое значение (например, строку, если gen равно ()=› «Hello world» ).
Сразу после этого он проверяет, является ли gen правдивым и имеет ли метод next. Если какое-либо из этих условий не выполняется, обещание разрешается самим gen . Если вы передали генератор, он пройдет обе проверки; он имеет тип «функция» и после инициализации у него будет метод next. Поэтому он продолжает выполнять функцию onFulfilled. В противном случае будет разрешено любое значение gen. Итак, теперь вы настолько уверены, что входное значение является генератором.
function onFulfilled(res) { var ret; try { ret = gen.next(res); } catch (e) { return reject(e); } next(ret); return null; }
onFulfilled принимает один параметр (res), который передается методу next. Это механизм, используемый для передачи значений генератору на каждой итерации. В этом случае значение resolved каждый раз передается методу next, чтобы мы могли хранить и использовать эти значения в генераторе, например:
co(function* () { const a = yield p // promise console.log("P's resolved value", a) }).then(...).catch(...)
В этом примере a содержит значение, разрешенное промисом p, готовое к использованию. Это возможно, потому что указанное выше значение res (входные данные для onFulfilled) передается методу next. В противном случае a будет не определено.
Назад к onFulfilled; переменная ret получает выходные данные метода next ( объект со свойствами value и done), а затем передается функции next. Таким образом, onFulfilled отвечает за перебор значений генератора. В первый раз он вызывается без параметров, так как в первой итерации нет значения для передачи.
function next(ret) { if (ret.done) return resolve(ret.value); var value = toPromise.call(ctx, ret.value); if (value && isPromise(value)) return value.then(onFulfilled, onRejected); return onRejected(new TypeError(...)); }
В функции next свойство done проверяется, чтобы убедиться, что у генератора закончились значения, и в этом случае обещание разрешается с последним.
Если нужно сгенерировать больше значений, Co преобразует текущее значение в обещание с помощью функции toPromise. Мы рассмотрим его позже, по сути, он преобразует любой тип (объект, массив и т. д.) в обещание. Наконец, если value соответствует действительности и является обещанием (догадаетесь, как функция isPromise выполняет свою работу?), то onFullfilled или onRejected вызываются в зависимости от того, было ли обещание разрешено или отклонено.
Почему все превращается в обещание? Для меня это удобный способ использовать уникальный API (.then(…)) для обработки всех случаев. Представьте, если значение представляет собой массив, затем литерал объекта, а затем строку... вам нужно будет обрабатывать все эти случаи по-разному. Таким образом, у вас всегда есть промис, и вы передаете разрешенное значение в onFullfilled (который, в свою очередь, передает его генератору). Поэтому вам не нужно заботиться о точной логике преобразования всего в промис здесь, он просто решает эту проблему позже.
Давайте вместе рассмотрим onFullfilled и next, чтобы понять весь процесс:
function onFulfilled(res) { var ret; try { ret = gen.next(res); } catch (e) { return reject(e); } next(ret); return null; } function next(ret) { if (ret.done) return resolve(ret.value); var value = toPromise.call(ctx, ret.value); if (value && isPromise(value)) return value.then(onFulfilled, onRejected); return onRejected(new TypeError(...)); }
В первой итерации res равно undefined, а gen — инициализированный генератор (у него есть метод next). Он выполняет первую итерацию, вызывая next, и ret становится объектом со свойством value, которое будет первым доходным, и >done, которое будет false. Затем ret передается функции next.
ret.done изначально имеет значение false, поэтому value содержит обещание, которое является результатом преобразования текущего ret.value в обещание. Это может иметь разные результаты (см. ниже). Когда это обещание разрешено, значение передается в onFullfilled, и процесс начинается снова, пока ret.done не станет истинным. Вот и все.
function toPromise(obj) { if (!obj) return obj; if (isPromise(obj)) return obj; if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj); if ('function' == typeof obj) return thunkToPromise.call(this, obj); if (Array.isArray(obj)) return arrayToPromise.call(this, obj); if (isObject(obj)) return objectToPromise.call(this, obj); return obj; }
Функция toPromise возвращает обещание или значение. Если obj является ложным, он просто возвращает его, нет необходимости оборачивать ложное значение в промис. Если obj является обещанием, просто верните его. Интересно следующее: если obj является генератором, вызывает Co и передает obj. Помните, что Co возвращает обещание? Красивый! Если obj является функцией, массивом или простым объектом, у него есть специальные функции для их преобразования (вы можете прочитать их сами, комментировать особо нечего). Наконец, если obj является значением, просто возвращает его (опять же, нет необходимости оборачивать его в промис).
Мы просмотрели исходный код Co, чтобы понять, что происходит внутри. Это немного сложно, но не так сложно, если идти шаг за шагом.