Асинхронная композиция функций

compose и pipe для асинхронных функций существуют в трёх контрактах: callback (error-first), Promise (then-цепочка), async/await. Все три совместимы и могут смешиваться, но имеют разный overhead.

Callback-вариант (error-first)

const compose = (...fns) => (x, cb) => {
  const fn = fns.shift();
  if (!fns.length) return fn(x, cb);
  fn(x, (err, res) => {
    if (err) return cb(err);
    compose(...fns)(res, cb);
  });
};

Что если случилась ошибка, то мы сразу должны вернуть её в callback и не вызывать вторую функцию. Нужен return, чтобы дальше управление не пошло.

Принцип: контракт callback-last error-first сохраняется при компоновке. Скомпонованная функция имеет ту же сигнатуру (x, cb) => void.

Async/await-вариант

const compose = (...fns) => async (x) => {
  let result = x;
  for (const fn of fns) result = await fn(result);
  return result;
};

Простой for-of, await на каждой итерации. Скомпонованная функция возвращает Promise.

Promise-вариант через then

const compose = (...fns) => (x) =>
  fns.reduce((acc, fn) => acc.then(fn), Promise.resolve(x));

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

Какой быстрее

Promise.resolve сразу создаёт зарезолвленный промис, а async-функция откладывает свое действие на микротаск. На промисах это будет выглядеть короче и проще, чем на async/await.

  • async/await — самый читаемый, но создаёт лишние промисы
  • pipe через then — короче, без обёртки в async IIFE
  • callback — самый быстрый, но боль с error handling

Совместимость

Оба способа композиции совместимы, у них одинаковый контракт. При помощи такого compose мы можем композировать функции из предыдущего примера.

Async-функция → promise → thenable → совместимо с await. Можно смешивать.

Параллельная композиция

const parallel = (...fns) => async (x) =>
  Promise.all(fns.map(fn => fn(x)));

// Использование: один вход — массив результатов
const enrichments = await parallel(getProfile, getOrders, getStats)(userId);

Как только мы переходим к асинхронщине, нам нужно реализовать параллельную композицию и последовательную. Два вида композиции.

Pipe для последовательности действий

const main = pipe(getDataset, calcProportion, renderTable);
await main('data.csv');

Я по сути pipe-ом задаю последовательность действий. Альтернатива лесенке async/await с локальными переменными.

Тонкости

  • void cb(...) иногда нужен, чтобы случайный return cb(...) из стрелочной функции не возвращал результат callback
  • callback сам по себе не делает код асинхронным — это контракт, а не механизм. Функция может звать callback и синхронно
  • При смешивании error-first callback и Promise — используй promisify из node:util

Простота > скорости

Лучше всего это сравнивать по простоте описания и понятности кода. Если нравится compose через рекурсию — используй через рекурсию.

Связанные темы

Ресурсы