На этой странице

Материал этой статьи во многом вдохновлён книгой Mixu про Node.js.

По своей сути JavaScript спроектирован так, чтобы не блокировать «главный» поток — тот, в котором рендерятся представления (views). Можно представить, насколько это важно в браузере. Когда главный поток блокируется, возникает пресловутое «зависание», которого так боятся конечные пользователи, и никакие другие события не могут быть обработаны, что приводит, например, к потере получаемых данных.

Это создаёт ряд уникальных ограничений, которые лечит только функциональный стиль программирования. Вот тут-то на сцену и выходят колбэки.

Однако колбэки могут стать трудноуправляемыми в более сложных процедурах. Часто это выливается в «callback hell» (ад колбэков), когда множество вложенных функций с колбэками делают код труднее для чтения, отладки, организации и т. д.

async1(function (input, result1) {
  async2(function (result2) {
    async3(function (result3) {
      async4(function (result4) {
        async5(function (output) {
          // делаем что-то с output
        });
      });
    });
  });
});

Разумеется, в реальной жизни, скорее всего, были бы дополнительные строки кода для обработки result1, result2 и т. д., поэтому из-за длины и сложности этой проблемы код обычно выглядит гораздо более запутанным, чем в примере выше.

Вот где функции оказываются очень кстати. Более сложные операции складываются из множества функций:

  1. инициатор / вход (initiator style / input)
  2. middleware (промежуточное звено)
  3. терминатор (terminator)

«Инициатор / вход» — это первая функция в последовательности. Эта функция принимает исходный вход, если он есть, для операции. Операция — это исполняемая серия функций, а исходным входом в основном будут:

  1. переменные в глобальном окружении
  2. прямой вызов с аргументами или без них
  3. значения, полученные из файловой системы или сетевых запросов

Сетевые запросы могут быть входящими запросами, инициированными внешней сетью, другим приложением в той же сети или самим приложением в той же или внешней сети.

Функция middleware возвращает другую функцию, а функция-терминатор вызывает колбэк. Ниже показан поток для сетевых запросов или запросов к файловой системе. Здесь задержка равна 0, потому что все эти значения доступны в памяти.

function final(someInput, callback) {
  callback(`${someInput} and terminated by executing callback `);
}

function middleware(someInput, callback) {
  return final(`${someInput} touched by middleware `, callback);
}

function initiate() {
  const someInput = 'hello this is a function ';
  middleware(someInput, function (result) {
    console.log(result);
    // требует, чтобы колбэк `вернул` результат
  });
}

initiate();

Функции могут зависеть или не зависеть от состояния. Зависимость от состояния возникает, когда вход или другая переменная функции опирается на внешнюю функцию.

Таким образом, есть две основные стратегии управления состоянием:

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

Обратите внимание, я не упомянул глобальные переменные. Управление состоянием через глобальные переменные — часто неряшливый антипаттерн, который делает трудным или невозможным гарантировать состояние. Глобальных переменных в сложных программах по возможности следует избегать.

Если объект доступен в памяти, итерация возможна, и изменения в управлении потоком не будет:

function getSong() {
  let _song = '';
  let i = 100;
  for (i; i > 0; i -= 1) {
    _song += `${i} beers on the wall, you take one down and pass it around, ${
      i - 1
    } bottles of beer on the wall\n`;
    if (i === 1) {
      _song += "Hey let's get some more beer";
    }
  }

  return _song;
}

function singSong(_song) {
  if (!_song) {
    throw new Error("song is '' empty, FEED ME A SONG!");
  }

  console.log(_song);
}

const song = getSong();
// это сработает
singSong(song);

Однако если данные существуют вне памяти, итерация больше не сработает:

function getSong() {
  let _song = '';
  let i = 100;
  for (i; i > 0; i -= 1) {
    setTimeout(function () {
      _song += `${i} beers on the wall, you take one down and pass it around, ${
        i - 1
      } bottles of beer on the wall\n`;
      if (i === 1) {
        _song += "Hey let's get some more beer";
      }
    }, 0);
  }

  return _song;
}

function singSong(_song) {
  if (!_song) {
    throw new Error("song is '' empty, FEED ME A SONG!");
  }

  console.log(_song);
}

const song = getSong('beer');
// это не сработает
singSong(song);
// Uncaught Error: song is '' empty, FEED ME A SONG!

Почему так произошло? setTimeout инструктирует CPU сохранить инструкции где-то ещё на шине и указывает, что данные запланированы к «забору» позже. Проходят тысячи тактов процессора, прежде чем функция снова срабатывает на отметке 0 миллисекунд, CPU достаёт инструкции с шины и выполняет их. Единственная проблема в том, что song ('') был возвращён тысячами тактов ранее.

Та же ситуация возникает при работе с файловыми системами и сетевыми запросами. Главный поток попросту нельзя блокировать на неопределённый период времени — поэтому мы используем колбэки, чтобы контролируемо планировать выполнение кода во времени.

Почти все свои операции вы сможете выполнить с помощью следующих 3 паттернов:

  1. Последовательно (in series): функции выполняются в строгом последовательном порядке; этот паттерн больше всего похож на циклы for.
// операции определены где-то ещё и готовы к выполнению
const operations = [
  { func: function1, args: args1 },
  { func: function2, args: args2 },
  { func: function3, args: args3 },
];

function executeFunctionWithArgs(operation, callback) {
  // выполняет функцию
  const { args, func } = operation;
  func(args, callback);
}

function serialProcedure(operation) {
  if (!operation) {
    process.exit(0); // завершено
  }

  executeFunctionWithArgs(operation, function (result) {
    // продолжаем ПОСЛЕ колбэка
    serialProcedure(operations.shift());
  });
}

serialProcedure(operations.shift());
  1. Последовательно с ограничением (limited in series): функции выполняются в строгом последовательном порядке, но с ограничением на число выполнений. Полезно, когда нужно обработать большой список, но с потолком на число успешно обработанных элементов.
let successCount = 0;

function final() {
  console.log(`dispatched ${successCount} emails`);
  console.log('finished');
}

function dispatch(recipient, callback) {
  // `sendMail` — гипотетический SMTP-клиент
  sendMail(
    {
      subject: 'Dinner tonight',
      message: 'We have lots of cabbage on the plate. You coming?',
      smtp: recipient.email,
    },
    callback
  );
}

function sendOneMillionEmailsOnly() {
  getListOfTenMillionGreatEmails(function (err, bigList) {
    if (err) {
      throw err;
    }

    function serial(recipient) {
      if (!recipient || successCount >= 1000000) {
        return final();
      }

      dispatch(recipient, function (_err) {
        if (!_err) {
          successCount += 1;
        }

        serial(bigList.pop());
      });
    }

    serial(bigList.pop());
  });
}

sendOneMillionEmailsOnly();
  1. Полностью параллельно (full parallel): когда порядок не важен, например при рассылке письма списку из 1 000 000 получателей.
let count = 0;
let success = 0;
const failed = [];
const recipients = [
  { name: 'Bart', email: 'bart@tld' },
  { name: 'Marge', email: 'marge@tld' },
  { name: 'Homer', email: 'homer@tld' },
  { name: 'Lisa', email: 'lisa@tld' },
  { name: 'Maggie', email: 'maggie@tld' },
];

function dispatch(recipient, callback) {
  // `sendMail` — гипотетический SMTP-клиент
  sendMail(
    {
      subject: 'Dinner tonight',
      message: 'We have lots of cabbage on the plate. You coming?',
      smtp: recipient.email,
    },
    callback
  );
}

function final(result) {
  console.log(`Result: ${result.count} attempts \
      & ${result.success} succeeded emails`);
  if (result.failed.length) {
    console.log(`Failed to send to: \
        \n${result.failed.join('\n')}\n`);
  }
}

recipients.forEach(function (recipient) {
  dispatch(recipient, function (err) {
    if (!err) {
      success += 1;
    } else {
      failed.push(recipient.name);
    }
    count += 1;

    if (count === recipients.length) {
      final({
        count,
        success,
        failed,
      });
    }
  });
});

У каждого паттерна свои сценарии использования, преимущества и проблемы, которые вы можете поэкспериментировать и изучить подробнее. Самое главное — не забывайте модуляризировать свои операции и использовать колбэки! Если сомневаетесь, относитесь ко всему так, будто это middleware!