JavaScript: Управление потоком

Источник: «Flow Control in JavaScript: Callbacks, Promises, async/await»
В этой статье мы подробно рассмотрим, как работать с асинхронным кодом в JavaScript. Мы начнём с обратных вызовов, перейдём к промисам и закончим более современными async/await. В каждом разделе будет предложен пример кода, изложены основные моменты, о которых следует знать.

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

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

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

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

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

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

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

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

Переход на асинхронность с обратными вызовами/callbacks

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

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

Например:

doSomethingAsync(callback1);
console.log('finished');

// вызывается после завершения doSomethingAsync
function callback1(error) {
if (!error) console.log('doSomethingAsync complete');
}

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

finished
doSomethingAsync complete

Больше об обратных вызовах можно узнать в JavaScript: Что такое функции обратного вызова/Callback

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

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

Более подробно об аде обратных вызовов можно почитать в статье JavaScript: Спасение из ада обратных вызовов.

Промисы

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

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

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

const db = require('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() для преобразования функции на основе обратного вызова в альтернативу на основе промиса. Есть пара условий:

Пример:

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

readFileAsync('file.txt');

Асинхронные цепочки

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

asyncDBconnect('http://localhost:1234')
.then(asyncGetSession) // результат переданный из asyncDBconnect
.then(asyncGetUser) // результат переданный из asyncGetSession
.then(asyncLogAccess) // результат переданный из asyncGetUser
.then(result => { // не асинхронная функция
console.log('complete'); // (результат передачи asyncLogAccess)
return result; // (результат передан в следующий .then())
})
.catch(err => { // вызывается при любой ошибке/отклонении
console.log('error', err);

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

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

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

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

Будущее Промисов

Промисы уменьшают ад обратных вызовов, но привносят свои собственные проблемы.

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

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

async/await

Промисы могут быть обескураживающими, поэтому в ES2017 были введены async и await. Хотя это только синтаксический сахар, он делает промисы намного слаще, и вы можете избежать цепочек .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:

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/await не может быть короче, но имеет значительные преимущества:

Тем не менее не всё так идеально…

Промисы, Промисы

async/await опираются на промисы, которые в конечном итоге полагаются на обратные вызовы. Это означает, что вам всё равно нужно понимать, как работают промисы.

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

Уродство try/catch

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

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

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

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

Вы можете узнать больше об использовании async/await в статье JavaScript: Руководство по async/await, с примерами

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

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

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

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

Дополнительные материалы

Дополнительные материалы

Предыдущая Статья

JavaScript: Введение в Fetch API

Следующая Статья

JavaScript: Delay, Sleep, Pause, & Wait