В Javascript есть два основных способа решения асинхронных задач - обратные вызовы и обещания. В целом считается, что обещания проще в использовании и обслуживании, чем обратные вызовы. Но на самом деле даже обещания сами по себе не сделают вас счастливыми. Асинхронный код все еще может быть довольно трудным для чтения и понимания. Поэтому сторонние библиотеки, например, co », предоставляет средства для написания синхронного асинхронного кода.

Лично я предпочитаю, чтобы все на свете было таким же ясным и красивым, как redux-saga. Но не всем повезло работать с React и Redux, чтобы иметь возможность использовать саги. В этой статье я покажу, что в современном Javascript нетрудно написать хорошо структурированный и простой для понимания асинхронный код без использования каких-либо сторонних библиотек.

Обратный вызов ад

Начнем с примера. Скажем, у нас есть объект, который может читать некоторые данные из потока, и этот объект использует эмиттер событий для уведомления всех, кто интересуется событиями. Событиями являются «start», «data», «stop» и, чтобы немного усложнить задачу, «pause».

Итак, мы хотим поймать событие «start», при котором мы хотели бы начать получение и сохранение данных, одновременно прослушивая событие «data». И при событии «стоп» нам нужно выполнить некоторую обработку данных. При событии «пауза» мы перестаем ждать следующего события «данные» и вместо этого ждем «старт», чтобы продолжить получение и сохранение данных.

Вот код:

let data = '';
 
const handleStart = () => {
   streamReader.removeAllListeners('pause', handlePause);
 
   streamReader.on('data', (chunk, err) => {
       if (err) {
           console.error(err);
           streamReader.removeAllListeners('data');
           streamReader.removeAllListeners('pause');
           return;
       }
 
       data += chunk;
   })
}
 
const handleStop = () => {
   streamReader.removeAllListeners('data');
   streamReader.removeAllListeners('pause');
   streamReader.removeAllListeners('stop');
 
   processData(data, (err, result) => {
       if (err) {
           console.error(err);
           return;
       }
 
       storeResult(result, () => {
           console.log('Stored')
       })
   });
}
 
const handlePause = () => {
   streamReader.removeAllListeners('data');
   streamReader.on('start', handleStart);
}
 
streamReader.once('start', handleStart);
streamReader.on('stop', handleStop)
streamReader.on('pause', handlePause);

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

Что не так с этим кодом? Ну… я думаю, это полный кошмар. Прежде всего, существует глобальная переменная data, от которой невозможно избавиться. Также я упомянул поток выше, но в коде нет потока. Очень сложно понять последовательность действий и поэтому очень сложно отлаживать. Люди не зря называют это «адом обратного вызова».

Выход из положения

Асинхронные обратные вызовы в Javascript хороши тем, что вам не нужно их использовать, если вы этого не хотите. Любой обратный вызов можно превратить в обещание. Самый простой пример будет выглядеть так:

const processDataPromise = new Promise((resolve, reject) => {
   processData(data, (err, result) => {
       if (err) reject(err);
       resolve(result);
   });
})

Или более общее решение:

function promisify(f, context, isEvent) {
   const ctx = context || this;
   return function () {
       return new Promise((resolve, reject) => {
           f.call(ctx, ...arguments, (...args) => {
               const err = arguments ? args.find((a) => a instanceof Error) : null;
               if (err) {
                   reject(err);
               } else {
                   if (isEvent) {
                       resolve({
                           type: arguments[0],
                           cbArgs: [...args],
                       });
                   } else {
                       resolve([...args]);
                   }
               }
           })
       });
   }
}

Общее решение не сразу становится очевидным, поэтому позвольте мне объяснить.

Функция promisify принимает асинхронную функцию в качестве первого аргумента и возвращает функцию, которая принимает все те же параметры, что и исходная, за исключением обратного вызова. Когда эта возвращенная функция вызывается, она возвращает обещание. Исходная функция вызывается внутри обещания, которое разрешается, когда исходная функция вызывает обратный вызов. Если исходная функция имеет контекст (аргумент context для promisify), она привязывается к нему при вызове внутри обещания. Если исходная функция - просто обычная асинхронная функция, мы разрешаем Promise с аргументами обратного вызова. Если это прослушиватель событий (isEvent = true), мы возвращаем и тип события, и аргументы обратного вызова. И если обратный вызов вызывается с ошибкой, обещание отклоняется.

Приложение promisify выглядит так:

const processDataPromise = promisify(processData);
const storeResultPromise = promisify(storeResult);
const onEventPromise = promisify(emitter.once, emitter, true);

И Promise можно использовать так:

processDataPromise(data).then(([err, processedData]) => {
/* do something with the data*/
})

Но есть способ получше.

Как сага рай

И здесь нам нужен лучший способ, потому что практически невозможно сжать поток, подобный описанному выше, в цепочку Promise.

Лучше всего использовать асинхронную функцию Javascript, и вот еще одна реализация того же потока:

async function readStream(streamReader, initialData) {
 const processDataPromise = promisify(processData);
const storeResultPromise = promisify(storeResult);
const onEventPromise = promisify(streamReader.once, streamReader, true);
 
   await onEventPromise('start');
   let data = initialData || '';
 
   while (true) {
       try {
           const event = await Promise.race([
               onEventPromise('data'),
               onEventPromise('stop'),
               onEventPromise('pause'),
           ]);
 
           const {type} = event;
 
           if (type === 'data') {
               const [chunk] = event.cbArgs;
               data += chunk;
           }
 
           if (type === 'pause') {
               readStream(streamReader, data);
               break;
           }
 
           if (type === 'stop') {
               const [err, processedData] = await processDataPromise(data);
               await storeResultPromise(processedData);
               return processedData;
           }
       } catch (err) {
           handleError(err);
           return;
       }
   }
}

Ну чем хороша эта реализация? Во-первых, это выглядит красивее. Во-вторых, что более важно, в этом коде есть поток. Это почти похоже на блок-схему, где вы можете шаг за шагом отслеживать всю последовательность с каждым циклом и каждой ветвью. Это действительно похоже на сагу, о которой я упоминал в начале, но не нужно ничего знать о redux-saga, чтобы написать такой код. Что еще более важно, это код, с которым вы можете жить.









По сценарию Ильи Богаслаучика