Статьи

Управление потоком данных в современном JS: обратные вызовы для обещаний асинхронного / ожидающего

JavaScript регулярно утверждается, что он асинхронный . Что это обозначает? Как это влияет на развитие? Как изменился подход в последние годы?

Рассмотрим следующий код:

result1 = doSomething1(); result2 = doSomething2(result1); 

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

Однопоточная обработка

JavaScript работает в одном потоке обработки. При выполнении на вкладке браузера все остальное останавливается. Это необходимо, потому что изменения DOM страницы не могут происходить в параллельных потоках; было бы опасно, чтобы один поток перенаправлял на другой URL, а другой пытался добавить дочерние узлы.

Это редко очевидно для пользователя, потому что обработка происходит быстро небольшими порциями. Например, JavaScript обнаруживает нажатие кнопки, выполняет вычисления и обновляет DOM. После завершения браузер может свободно обрабатывать следующий элемент в очереди.

(Примечание: другие языки, такие как PHP, также используют один поток, но могут управляться многопоточным сервером, таким как Apache. Два запроса на одну и ту же страницу PHP одновременно могут инициировать два потока, выполняющие изолированные экземпляры PHP во время выполнения.)

Идти асинхронно с обратными вызовами

Одиночные темы создают проблему. Что происходит, когда JavaScript вызывает «медленный» процесс, такой как запрос Ajax в браузере или операция с базой данных на сервере? Эта операция может занять несколько секунд — даже минут . Браузер заблокировался, ожидая ответа. На сервере приложение Node.js не сможет обрабатывать дальнейшие запросы пользователей.

Решение асинхронной обработки. Вместо того, чтобы ждать завершения, процессу говорят вызвать другую функцию, когда результат готов. Это называется обратным вызовом и передается в качестве аргумента любой асинхронной функции. Например:

 doSomethingAsync(callback1); console.log('finished'); // call when doSomethingAsync completes function callback1(error) { if (!error) console.log('doSomethingAsync complete'); } 

doSomethingAsync() принимает функцию обратного вызова в качестве параметра (передается только ссылка на эту функцию, поэтому накладных расходов немного). Неважно, сколько времени doSomethingAsync() ; все, что мы знаем, это то, что callback1() будет выполнен в какой-то момент в будущем. Консоль покажет:

 finished doSomethingAsync complete 

Callback Hell

Часто обратный вызов вызывается только одной асинхронной функцией. Поэтому возможно использовать краткие анонимные встроенные функции:

 doSomethingAsync(error => { if (!error) console.log('doSomethingAsync complete'); }); 

Последовательность из двух или более асинхронных вызовов может быть выполнена последовательно путем вложения функций обратного вызова. Например:

 async1((err, res) => { if (!err) async2(res, (err, res) => { if (!err) async3(res, (err, res) => { console.log('async1, async2, async3 complete.'); }); }); }); 

К сожалению, это вводит ад обратного вызова — печально известная концепция, которая даже имеет свою собственную веб-страницу ! Код трудно читать, и он будет ухудшаться при добавлении логики обработки ошибок.

Ад обратного вызова относительно редок в кодировании на стороне клиента. Он может пройти два или три уровня глубиной, если вы делаете Ajax-вызов, обновляете DOM и ждете завершения анимации, но обычно он остается управляемым.

Ситуация отличается в ОС или серверных процессах. Вызов API Node.js может принимать закачки файлов, обновлять несколько таблиц базы данных, записывать в журналы и совершать дополнительные вызовы API перед отправкой ответа.

обещания

ES2015 (ES6) представил обещания . Обратные вызовы все еще используются под поверхностью, но Promises обеспечивает более четкий синтаксис, который объединяет асинхронные команды, поэтому они выполняются последовательно (подробнее об этом в следующем разделе ).

Чтобы включить выполнение на основе Promise, необходимо изменить асинхронные функции на основе обратного вызова, чтобы они немедленно возвращали объект Promise. Этот объект обещает запустить одну из двух функций (переданных в качестве аргументов) в будущем:

  • resolve : функция обратного вызова запускается, когда обработка успешно завершается, и
  • reject : дополнительная функция обратного вызова, запускаемая при возникновении ошибки.

В приведенном ниже примере API базы данных предоставляет метод connect() который принимает функцию обратного вызова. Внешняя asyncDBconnect() немедленно возвращает новое Promise и запускает asyncDBconnect() или reject() после установления соединения или сбоя:

 const db = require('database'); // connect to database function asyncDBconnect(param) { return new Promise((resolve, reject) => { db.connect(param, (err, connection) => { if (err) reject(err); else resolve(connection); }); }); } 

Node.js 8.0+ предоставляет утилиту util.promisify () для преобразования функции на основе обратного вызова в альтернативу на основе Promise. Есть пара условий:

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

Пример:

 // Node.js: promisify fs.readFile const util = require('util'), fs = require('fs'), readFileAsync = util.promisify(fs.readFile); readFileAsync('file.txt'); 

Различные клиентские библиотеки также предоставляют опции Promisify, но вы можете создать их самостоятельно в несколько строк:

 // promisify a callback function passed as the last parameter // the callback function must accept (err, data) parameters function promisify(fn) { return function() { return new Promise( (resolve, reject) => fn( ...Array.from(arguments), (err, data) => err ? reject(err) : resolve(data) ) ); } } // example function wait(time, callback) { setTimeout(() => { callback(null, 'done'); }, time); } const asyncWait = promisify(wait); ayscWait(1000); 

Асинхронное сцепление

Все, что возвращает Promise, может запустить серию асинхронных вызовов функций, определенных в методах .then() . Каждому передается результат из предыдущего resolve :

 asyncDBconnect('http://localhost:1234') .then(asyncGetSession) // passed result of asyncDBconnect .then(asyncGetUser) // passed result of asyncGetSession .then(asyncLogAccess) // passed result of asyncGetUser .then(result => { // non-asynchronous function console.log('complete'); // (passed result of asyncLogAccess) return result; // (result passed to next .then()) }) .catch(err => { // called on any reject console.log('error', err); }); 

Синхронные функции также могут выполняться в блоках .then() . Возвращаемое значение передается следующему .then() (если есть).

Метод .catch() определяет функцию, которая вызывается при срабатывании любого предыдущего reject . В этот момент дальнейшие методы .then() запускаться не будут. Вы можете иметь несколько .catch() всей цепочке, чтобы фиксировать различные ошибки.

ES2018 представляет метод .finally() , который запускает любую конечную логику независимо от результата — например, для очистки, закрытия подключения к базе данных и т. Д. В настоящее время он поддерживается только в Chrome и Firefox, но Технический комитет 39 выпустил .finally () полифилл .

 function doSomething() { doSomething1() .then(doSomething2) .then(doSomething3) .catch(err => { console.log(err); }) .finally(() => { // tidy-up here! }); } 

Несколько асинхронных вызовов с Promise.all ()

Методы Promise .then() запускают асинхронные функции одну за другой. Если порядок не имеет значения — например, при инициализации несвязанных компонентов — быстрее запустить все асинхронные функции одновременно и завершить, когда resolve последняя (самая медленная) функция.

Это может быть достигнуто с Promise.all() . Он принимает массив функций и возвращает другое обещание. Например:

 Promise.all([ async1, async2, async3 ]) .then(values => { // array of resolved values console.log(values); // (in same order as function array) return values; }) .catch(err => { // called on any reject console.log('error', err); }); 

Promise.all() немедленно завершается, если какая-либо из вызовов асинхронных функций reject .

Несколько асинхронных вызовов с Promise.race ()

Promise.race() похож на Promise.all() , за исключением того, что он будет разрешен или отклонен, как только первое Promise разрешит или отклонит. Только самая быстрая асинхронная функция, основанная на Promise, будет выполнена

 Promise.race([ async1, async2, async3 ]) .then(value => { // single value console.log(value); return value; }) .catch(err => { // called on any reject console.log('error', err); }); 

Многообещающее будущее?

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

В руководствах часто не упоминается, что вся цепочка Promise является асинхронной . Любая функция, использующая серию обещаний, должна либо возвращать свои собственные Promise, либо запускать функции обратного вызова в окончательных .catch() .then() , .catch() или .finally() .

У меня также есть признание: обещания смущали меня долгое время . Синтаксис часто кажется более сложным, чем обратные вызовы, есть много ошибок, и отладка может быть проблематичной. Тем не менее, важно изучить основы.

Дополнительные ресурсы Promise:

Асинхронный / Await

Обещания могут быть пугающими, поэтому в ES2017 введены async и ожидание . Хотя это может быть только синтаксический сахар, он делает Promises намного слаще, и вы можете полностью избежать цепочек .then() . Рассмотрим пример на основе обещаний ниже:

 function connect() { return new Promise((resolve, reject) => { asyncDBconnect('http://localhost:1234') .then(asyncGetSession) .then(asyncGetUser) .then(asyncLogAccess) .then(result => resolve(result)) .catch(err => reject(err)) }); } // run connect (self-executing function) (() => { connect(); .then(result => console.log(result)) .catch(err => console.log(err)) })(); 

Чтобы переписать это с помощью async / await :

  1. внешней функции должен предшествовать async оператор, и
  2. вызовам асинхронных функций, основанных на Promise, должно предшествовать await чтобы гарантировать выполнение обработки до выполнения следующей команды.
 async function connect() { try { const connection = await asyncDBconnect('http://localhost:1234'), session = await asyncGetSession(connection), user = await asyncGetUser(session), log = await asyncLogAccess(user); return log; } catch (e) { console.log('error', err); return null; } } // run connect (self-executing async function) (async () => { await connect(); })(); 

await эффективно заставляет каждый вызов выглядеть так, как если бы он был синхронным, не задерживая единый поток обработки JavaScript. Кроме того, async функции всегда возвращают Promise, поэтому они, в свою очередь, могут вызываться другими async функциями.

Код async / await не может быть короче, но есть значительные преимущества:

  1. Синтаксис чище. Меньше скобок и меньше ошибаться.
  2. Отладка проще. Точки останова могут быть установлены в любом await заявлении.
  3. Обработка ошибок лучше. Блоки try / catch могут использоваться так же, как и синхронный код.
  4. Поддержка это хорошо. Он реализован во всех браузерах (кроме IE и Opera Mini) и Node 7.6+.

Тем не менее, не все идеально …

Обещания, обещания

async / await прежнему полагается на Promises, которые в конечном итоге полагаются на обратные вызовы. Вам нужно понять, как работают Promises, и не существует прямого эквивалента Promise.all() и Promise.race() . Легко забыть о Promise.all() , который более эффективен, чем использование серии несвязанных команд await .

Асинхронное ожидание в синхронных циклах

В какой-то момент вы попытаетесь вызвать асинхронную функцию внутри синхронного цикла. Например:

 async function process(array) { for (let i of array) { await doSomething(i); } } 

Это не сработает. Ни один не будет это:

 async function process(array) { array.forEach(async i => { await doSomething(i); }); } 

Сами циклы остаются синхронными и всегда завершаются перед своими внутренними асинхронными операциями.

ES2018 представляет асинхронные итераторы, которые похожи на обычные итераторы, за исключением того, что метод next() возвращает Promise. Поэтому ключевое слово await может использоваться с циклами forof для последовательного запуска асинхронных операций. например:

 async function process(array) { for await (let i of array) { doSomething(i); } } 

Однако до тех пор, пока не будут реализованы асинхронные итераторы, возможно, лучше всего map элементы массива в async функцию и запустить их с помощью Promise.all() . Например:

 const todo = ['a', 'b', 'c'], alltodo = todo.map(async (v, i) => { console.log('iteration', i); await processSomething(v); }); await Promise.all(alltodo); 

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

попробуй / поймай уродство

async функции будут молча завершаться, если вы пропустите try / catch любой await сбой. Если у вас длинный набор асинхронных команд await , вам может потребоваться несколько блоков try / catch .

Одной из альтернатив является функция более высокого порядка, которая отлавливает ошибки, поэтому блоки try / catch становятся ненужными (спасибо @wesbos за предложение):

 async function connect() { const connection = await asyncDBconnect('http://localhost:1234'), session = await asyncGetSession(connection), user = await asyncGetUser(session), log = await asyncLogAccess(user); return true; } // higher-order function to catch errors function catchErrors(fn) { return function (...args) { return fn(...args).catch(err => { console.log('ERROR', err); }); } } (async () => { await catchErrors(connect)(); })(); 

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

Несмотря на некоторые подводные камни, async / await — элегантное дополнение к JavaScript. Другие ресурсы:

JavaScript Путешествие

Асинхронное программирование — это проблема, которую невозможно избежать в JavaScript. Обратные вызовы необходимы в большинстве приложений, но легко запутаться в глубоко вложенных функциях.

Обещает абстрактные обратные вызовы, но есть много синтаксических ловушек. Преобразование существующих функций может быть непростой .then() цепочки .then() все еще выглядят беспорядочно.

К счастью, async / await обеспечивает ясность. Код выглядит синхронно, но он не может монополизировать отдельный поток обработки. Это изменит то, как вы пишете JavaScript, и даже может заставить вас ценить обещания — если вы не делали этого раньше