Синхронный код на JavaScript, автор которого не стремился сбить с толку тех, кто этот код будет читать, обычно выглядит просто и понятно. Команды, из которых он состоит, выполняются в том порядке, в котором они следуют в тексте программы. Немного путаницы может внести поднятие объявлений переменных и функций, но чтобы превратить эту особенность JS в проблему, надо очень постараться. У синхронного кода на JavaScript есть лишь один серьёзный недостаток: на нём одном далеко не уехать.
Практически каждая полезная JS-программа написана с привлечением асинхронных методов разработки. Здесь в дело вступают функции обратного вызова, в просторечии — «коллбэки». Здесь в ходу «обещания», или Promise-объекты, называемые обычно промисами. Тут можно столкнуться с генераторами и с конструкциями async/await. Асинхронный код, в сравнении с синхронным, обычно сложнее писать, читать и поддерживать. Иногда он превращается в совершенно жуткие структуры вроде ада коллбэков. Однако, без него не обойтись.
Сегодня предлагаем поговорить об особенностях коллбэков, промисов, генераторов и конструкций async/await, и подумать о том, как писать простой, понятный и эффективный асинхронный код.
О синхронном и асинхронном коде
Начнём с рассмотрения фрагментов синхронного и асинхронного JS-кода. Вот, например, обычный синхронный код:
console.log('1')
console.log('2')
console.log('3')</code>
Он, без особых сложностей, выводит в консоль числа от 1 до 3.
Теперь — код асинхронный:
<code>console.log('1')
setTimeout(function afterTwoSeconds() {
console.log('2')
}, 2000)
console.log('3')</code>
Тут уже будет выведена последовательность 1, 3, 2. Число 2 выводится из коллбэка, который обрабатывает событие срабатывания таймера, заданного при вызове функции <code>setTimeout</code>. Коллбэк будет вызвана, в данном примере, через 2 секунды. Приложение при этом не остановится, ожидая, пока истекут эти две секунды. Вместо этого его исполнение продолжится, а когда сработает таймер, будет вызвана функция <code>afterTwoSeconds</code>.
Возможно, если вы только начинаете путь JS-разработчика, вы зададитесь вопросами: «Зачем это всё? Может быть, можно переделать асинхронный код в синхронный?». Поищем ответы на эти вопросы.
<h2><font color="#000">Постановка задачи</font></h2>
Предположим, перед нами стоит задача поиска пользователя GitHub и загрузки данных о его репозиториях. Главная проблема тут в том, что мы не знаем точного имени пользователя, поэтому нам нужно вывести всех пользователей с именами, похожими на то, что мы ищем, и их репозитории.
В плане интерфейса ограничимся чем-нибудь <a href="https://jsfiddle.net/fp9pk8pq/">простым</a>.
<img src="https://lh6.googleusercontent.com/1lP6pvH8VTM_OHHTM7rcjBLqnI_4eevKzheo2ikR2x166io8RP7w-HluwQ3C-VjmoDNWXdtGT9iF_OTYHljs11NeMOyLn0HfeO8Pmt_4Crow5-BGNP1wuziqYatePRMUx1XTcI0c0mnfx_yi6Q">
<i><font color="#999999">Простой интерфейс поиска пользователей GitHub и соответствующих им репозиториев</font></i>
В примерах выполнение запросов будет выполнено средствами <code>XMLHttpRequest</code> (XHR), но вы вполне можете использовать тут jQuery (<code>$.ajax</code>), или более современный стандартный подход, основанный на использовании функции <code>fetch</code>. И то и другое сводится к использованию промисов. Код, в зависимости от похода, будет меняться, но вот, для начала, такой пример:
<source>// аргумент url может быть чем-то вроде 'https://api.github.com/users/daspinola/repos'
function request(url) {
const xhr = new XMLHttpRequest();
xhr.timeout = 2000;
xhr.onreadystatechange = function(e) {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// Код обработки успешного завершения запроса
} else {
// Обрабатываем ответ с сообщением об ошибке
}
}
}
xhr.ontimeout = function () {
// Ожидание ответа заняло слишком много времени, тут будет код, который обрабатывает подобную ситуацию
}
xhr.open('get', url, true)
xhr.send();
}
Обратите внимание на то, что в этих примерах важно не то, что в итоге придёт с сервера, и как это будет обработано, а сама организация кода при использовании разных подходов, которые вы сможете использовать в своих асинхронных разработках.
Функции обратного вызова
С функциями в JS можно делать очень много всего, в том числе — передавать в качестве аргументов другим функциям. Обычно так делают для того, чтобы вызвать переданную функцию после завершения какого-то процесса, который может занять некоторое время. Речь идёт о функциях обратного вызова. Вот простой пример:
// Вызовем функцию "doThis" с другой функцией в качестве параметра, в данном случае - это функция "andThenThis". Функция "doThis" исполнит код, находящийся в ней, после чего, в нужный момент, вызовет функцию "andThenThis".
doThis(andThenThis)
// Внутри "doThis" обращение к переданной ей функции осуществляется через параметр "callback" , фактически, это просто переменная, которая хранит ссылку на функцию
function andThenThis() {
console.log('and then this')
}
// Назвать параметр, в котором окажется функция обратного вызова, можно как угодно, "callback" - это просто распространённый вариант
function doThis(callback) {
console.log('this first')
// Для того, чтобы функция, ссылка на которую хранится в переменной, была вызвана, нужно поместить после имени переменной скобки, '()', иначе ничего не получится
callback()
}
Используя этот подход для решения нашей задачи, мы можем написать такую функцию request
:
function request(url, callback) {
const xhr = new XMLHttpRequest();
xhr.timeout = 2000;
xhr.onreadystatechange = function(e) {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
callback(null, xhr.response)
} else {
callback(xhr.status, null)
}
}
}
xhr.ontimeout = function () {
console.log('Timeout')
}
xhr.open('get', url, true)
xhr.send();
}
Теперь функция для выполнения запроса принимает параметр callback
, поэтому, после выполнения запроса и получения ответа сервера, коллбэк будет вызван и в случае ошибки, и в случае успешного завершения операции.
const userGet = `https://api.github.com/search/users?page=1&q=daspinola&type=Users`
request(userGet, function handleUsersList(error, users) {
if (error) throw error
const list = JSON.parse(users).items
list.forEach(function(user) {
request(user.repos_url, function handleReposList(err, repos) {
if (err) throw err
//Здесь обработаем список репозиториев
})
})
})
Разберём то, что здесь происходит:
- Выполняется запрос для получения репозиториев пользователя (в данном случае я загружаю собственные репозитории);
- После завершения запроса вызывается коллбэк
handleUsersList
; - Если не было ошибок, разбираем ответ сервера c помощью J
SON.parse
, преобразовываем его, для удобства, в объект; - После этого перебираем список пользователей, так как в нём может быть больше одного элемента, и для каждого из них запрашиваем список репозиториев, используя URL, возвращённый для каждого пользователя после выполнения первого запроса. Подразумевается, что
repos_url
— это URL для наших следующих запросов, и получили мы его из первого запроса. - Когда запрос, направленный на загрузку данных о репозиториях, завершён, вызывается коллбэк, теперь это
handleReposList
. Здесь, так же как и при загрузке списка пользователей, можно обработать ошибки или полезные данные, в которых содержится список репозиториев пользователя.
Обратите внимание на то, что использование в качестве первого параметра объекта ошибки — это широко распространённая практика, в частности, для разработки с использованием Node.js.
Если придать нашему коду более завершённый вид, снабдить его средствами обработки ошибок и отделить определение функций обратного вызова от кода выполнения запроса, что улучшит читабельность программы, получится следующее:
try {
request(userGet, handleUsersList)
} catch (e) {
console.error('Request boom! ', e)
}
function handleUsersList(error, users) {
if (error) throw error
const list = JSON.parse(users).items
list.forEach(function(user) {
request(user.repos_url, handleReposList)
})
}
function handleReposList(err, repos) {
if (err) throw err
// Здесь обрабатываем список репозиториев
console.log('My very few repos', repos)
}
Этот подход работает, но используя его, мы рискуем столкнуться с проблемами вроде состояния гонки запросов и сложностей с обработкой ошибок. Однако, основная неприятность, связанная с коллбэками, которых, считая то, что происходит в цикле forEach
, здесь три, заключается в том, что такой код тяжело читать и поддерживать. Подобная проблема существует, пожалуй, со дня появления функций обратного вызова, она широко известна как ад коллбэков.
Ад коллбэков во всей красе. Изображение взято отсюда.
В данном случае под «состоянием гонки» мы понимаем ситуацию, когда мы не контролируем порядок получения данных о репозиториях пользователей. Мы запрашиваем данные по всем пользователям, и вполне может оказаться так, что ответы на эти запросы окажутся перемешанными. Скажем, ответ по десятому пользователю придёт первым, а по второму — последним. Ниже мы поговорим о возможном решении этой проблемы.
Промисы
Используя промисы можно улучшить читабельность кода. В результате, например, если в ваш проект придёт новый разработчик, он быстро поймёт, как там всё устроено.
Для того, чтобы создать промис, можно воспользоваться такой конструкцией:
const myPromise = new Promise(function(resolve, reject) {
// Здесь будет код
if (codeIsFine) {
resolve('fine')
} else {
reject('error')
}
})
myPromise
.then(function whenOk(response) {
console.log(response)
return response
})
.catch(function notOk(err) {
console.error(err)
})
Разберём этот пример:
- Промис инициализируется с помощью функции, в которой есть вызовы методов
resolve
иreject
; - Асинхронный код помещают внутри функции, созданной с помощью конструктора
Promise
. Если код будет выполнен успешно, вызывают методresolve
, если нет —reject
; - Если функция вызовет
resolve
, будет исполнен метод.then
для объектаPromise
, аналогично, если будет вызванreject
, будет исполнен метод.catch
.
Вот что стоит помнить, работая с промисами:
- Методы
resolve
иreject
принимают только один параметр, в результате, например, при выполнении команды видаresolve('yey', 'works')
, коллбэку.then
будет передано лишь'yey'
; - Если объединить в цепочку несколько вызовов
.then
, в конце соответствующих коллбэков следует всегда использоватьreturn
, иначе все они будут выполнены одновременно, а это, очевидно, не то, чего вы хотите достичь; - При выполнении команды
reject
, если следующим в цепочке идёт.then
, он будет выполнен (вы можете считать.then
выражением, которое выполняется в любом случае); - Если в цепочке из вызовов
.then
в каком-то из них возникнет ошибка, следующие за ним будут пропущены до тех пор, пока не будет найдено выражение.catch
; - У промисов есть три состояния: «pending» — состояние ожидания вызова
resolve
илиreject
, а также состояния «resolved» и «rejected», которые соответствуют успешному, с вызовомresolve
, и неуспешному, с вызовомreject
, завершению работы промиса. Когда промис оказывается в состоянии «resolved» или «rejected», оно уже не может быть изменено.
Обратите внимание на то, что промисы можно создавать без использования отдельно определённых функций, описывая функции в момент создания промисов. То, что показано в нашем примере — лишь распространённый способ инициализации промисов.
Для того, чтобы не погрязнуть в теории, вернёмся к нашему примеру. Перепишем его с использованием промисов.
function request(url) {
return new Promise(function (resolve, reject) {
const xhr = new XMLHttpRequest();
xhr.timeout = 2000;
xhr.onreadystatechange = function(e) {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
resolve(xhr.response)
} else {
reject(xhr.status)
}
}
}
xhr.ontimeout = function () {
reject('timeout')
}
xhr.open('get', url, true)
xhr.send();
})
}
При таком подходе, когда вы вызываете request
, возвращено будет примерно следующее.
Это — промис в состоянии ожидания. Он может быть либо успешно разрешён, либо отклонён
Теперь, воспользовавшись новой функцией request
, перепишем остальной код.
const userGet = `https://api.github.com/search/users?page=1&q=daspinola&type=Users`
const myPromise = request(userGet)
console.log('will be pending when logged', myPromise)
myPromise
.then(function handleUsersList(users) {
console.log('when resolve is found it comes here with the response, in this case users ', users)
const list = JSON.parse(users).items
return Promise.all(list.map(function(user) {
return request(user.repos_url)
}))
})
.then(function handleReposList(repos) {
console.log('All users repos in an array', repos)
})
.catch(function handleErrors(error) {
console.log('when a reject is executed it will come here ignoring the then statement ', error)
})
Здесь мы оказываемся в первом выражении .then
при успешном разрешении промиса. У нас имеется список пользователей. Во второе выражение .then
мы передаём массив с репозиториями. Если что-то пошло не так, мы окажемся в выражении .catch
.
Благодаря такому подходу мы разобрались с состоянием гонки и с некоторыми возникающими при этом проблемами. Ада коллбэков тут не наблюдается, но код пока ещё читать не так-то легко. На самом деле, наш пример поддаётся дальнейшему улучшению за счёт выделения из него объявлений функций обратного вызова:
const userGet = `https://api.github.com/search/users?page=1&q=daspinola&type=Users`
const userRequest = request(userGet)
// Если просто прочитать эту часть программы вслух, можно сразу понять что именно делает код
userRequest
.then(handleUsersList)
.then(repoRequest)
.then(handleReposList)
.catch(handleErrors)
function handleUsersList(users) {
return JSON.parse(users).items
}
function repoRequest(users) {
return Promise.all(users.map(function(user) {
return request(user.repos_url)
}))
}
function handleReposList(repos) {
console.log('All users repos in an array', repos)
}
function handleErrors(error) {
console.error('Something went wrong ', error)
}
При таком подходе один взгляд на имена коллбэков в выражениях .then
раскрывает смысл вызова userRequest
. С кодом легко работать, его легко читать.
На самом деле, это лишь вершина айсберга того, что называется промисами. Вот материал, который я рекомендую почитать тем, кто хочет более основательно погрузиться в эту тему.
Генераторы
Ещё один подход к решению нашей задачи, который, однако, нечасто встретишь — это генераторы. Тема это немного более сложная, чем остальные, поэтому, если вы чувствуете, что вам это изучать пока рано, можете сразу переходить к следующему разделу этого материала.
Для того, чтобы определить функцию-генератор, можно воспользоваться знаком звёздочки, «*», после ключевого слова function
. С помощью генераторов асинхронный код можно сделать очень похожим на синхронный. Например, выглядеть это может так:
function* foo() {
yield 1
const args = yield 2
console.log(args)
}
var fooIterator = foo()
console.log(fooIterator.next().value) // выведет 1
console.log(fooIterator.next().value) // выведет 2
fooIterator.next('aParam') // приведёт к вызову console.log внутри генератора и к выводу 'aParam'
Дело тут в том, что генераторы, вместо return
, используют выражение yield
, которое останавливает выполнение функции до следующего вызова .next
итератора. Это похоже на выражение .then
в промисах, которое выполняется при разрешении промиса.
Посмотрим теперь, как это всё применить к нашей задаче. Итак, вот функция request
:
function request(url) {
return function(callback) {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(e) {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
callback(null, xhr.response)
} else {
callback(xhr.status, null)
}
}
}
xhr.ontimeout = function () {
console.log('timeout')
}
xhr.open('get', url, true)
xhr.send()
}
}
Тут, как обычно, мы используем аргумент url
, но вместо того, чтобы сразу выполнить запрос, мы хотим его выполнить только тогда, когда у нас будет функция обратного вызова для обработки ответа.
Генератор будет выглядеть так:
function* list() {
const userGet = `https://api.github.com/search/users?page=1&q=daspinola&type=Users`
const users = yield request(userGet)
yield
for (let i = 0; i<=users.length; i++) {
yield request(users[i].repos_url)
}
}
Вот что здесь происходит:
- Мы ожидаем подготовки первого запроса, возвращая ссылку на функцию и ожидая коллбэка для этого первого запроса (вспомните функцию
request
, которая она принимаетurl
и возвращает функцию, которая ожидает коллбэк); - Ожидаем готовности списка пользователей,
users
, для отправки в следующий.next
; - Проходимся по полученному массиву
users
и ожидаем, для каждого из них,.next
, возвращая, для каждого, соответствующий коллбэк.
Использование этого всего будет выглядеть так:
try {
const iterator = list()
iterator.next().value(function handleUsersList(err, users) {
if (err) throw err
const list = JSON.parse(users).items
// Отправляем список пользователей итератору
iterator.next(list)
list.forEach(function(user) {
iterator.next().value(function userRepos(error, repos) {
if (error) throw repos
// Здесь обрабатываем информацию о репозиториях каждого пользователя
console.log(user, JSON.parse(repos))
})
})
})
} catch (e) {
console.error(e)
}
Здесь мы можем индивидуально обрабатывать список репозиториев каждого пользователя. Для того, чтобы улучшить этот код, можно было бы выделить функции обратного вызова, как мы уже делали выше.
Я неоднозначно отношусь к генераторам. С одной стороны, можно быстро понять, чего ожидать от кода, взглянув на генератор, с другой, выполнение генераторов приводит к проблемам, похожим не те, что возникают в аду коллбэков.
Надо отметить, что генераторы — возможность сравнительно новая, как результат, если вы рассчитываете на использование вашего кода в старых версиях браузеров, код надо обработать транспилятором. Кроме того, генераторы в написании асинхронного кода используют нечасто, поэтому, если вы занимаетесь командной разработкой, учтите, что некоторые программисты могут быть с ними незнакомы.
На тот случай, если вы решили лучше вникнуть в эту тему, вот и вот — отличные материалы о внутреннем устройстве генераторов.
Async/await
Этот метод похож на смесь генераторов и промисов. Вам нужно лишь указать, с помощью ключевого слова async
, какую функцию предполагается выполнять асинхронно, и, используя await
, сообщить системе о том, какая часть кода должна ждать разрешения соответствующего промиса.
Как обычно, сначала — простой пример.
sumTwentyAfterTwoSeconds(10)
.then(result => console.log('after 2 seconds', result))
async function sumTwentyAfterTwoSeconds(value) {
const remainder = afterTwoSeconds(20)
return value + await remainder
}
function afterTwoSeconds(value) {
return new Promise(resolve => {
setTimeout(() => { resolve(value) }, 2000);
});
}
Здесь происходит следующее:
- Имеется асинхронная функция
sumTwentyAfterTwoSeconds
; - Мы предлагаем коду подождать разрешения промиса
afterTwoSeconds
, который может завершиться вызовомresolve
илиreject
; - Выполнение кода заканчивается в
.then
, где завершается операция, отмеченная ключевым словомawait
, в данном случае — это всего одна операция.
Подготовим функцию request
к использовании в конструкции async/await
:
function request(url) {
return new Promise(function(resolve, reject) {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(e) {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
resolve(xhr.response)
} else {
reject(xhr.status)
}
}
}
xhr.ontimeout = function () {
reject('timeout')
}
xhr.open('get', url, true)
xhr.send()
})
}
Теперь создаём функцию с ключевым словом async
, в которой используем ключевое слово await
:
async function list() {
const userGet = `https://api.github.com/search/users?page=1&q=daspinola&type=Users`
const users = await request(userGet)
const usersList = JSON.parse(users).items
usersList.forEach(async function (user) {
const repos = await request(user.repos_url)
handleRepoList(user, repos)
})
}
function handleRepoList(user, repos) {
const userRepos = JSON.parse(repos)
// Обрабатываем тут репозитории для каждого пользователя
console.log(user, userRepos)
}
Итак, у нас имеется асинхронная функция list
, которая обработает запрос. Ещё конструкция async/await
нам понадобится в цикле forEach
, чтобы сформировать список репозиториев. Вызвать всё это очень просто:
list()
.catch(e => console.error(e))
Этот подход и использование промисов — мои любимые методы асинхронного программирования. Код, написанный с их использованием, удобно и читать и править. Подробности об async/await
можно почитать здесь.
Минус async/await
, как и минус генераторов, заключается в том, что эту конструкцию не поддерживают старые браузеры, а для её использования в серверной разработке нужно пользоваться Node 8. В подобной ситуации, опять же, поможет транспилятор, например — babel.
Итоги
Здесь можно посмотреть код проекта, который решает поставленную в начале материала задачу с использованием async/await
. Если вы хотите как следует разобраться с тем, о чём мы говорили — поэкспериментируйте с этим кодом и со всеми рассмотренными технологиями.
Обратите внимание на то, что наши примеры можно улучшить, сделать лаконичнее, если переписать их с использованием альтернативных способов выполнения запросов, вроде $.ajax
и fetch
. Если у вас есть идеи о том, как улучшить качество кода при использовании вышеописанных методик — буду благодарен, если расскажете об этом мне.
В зависимости от особенностей поставленной перед вами задачи, может оказаться так, что вы будете пользоваться async/await, коллбэками, или некоей смесью из разных технологий. На самом деле, ответ на вопрос о том, какую именно методику асинхронной разработки выбрать, зависит от особенностей проекта. Если некий подход позволяет решить задачу с помощью читабельного кода, который легко поддерживать, который понятен (и будет понятен через некоторое время) вам и другим членам команды, значит этот подход — то, что вам нужно.
Уважаемые читатели! Какими методиками написания асинхронного кода на JavaScript вы пользуетесь?
Автор: ru_vds