Недавно я нашел библиотеку 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, чтобы понять, что происходит внутри. Это немного сложно, но не так сложно, если идти шаг за шагом.