- PVSM.RU - https://www.pvsm.ru -
Синхронный код на JavaScript, автор которого не стремился сбить с толку тех, кто этот код будет читать, обычно выглядит просто и понятно. Команды, из которых он состоит, выполняются в том порядке, в котором они следуют в тексте программы. Немного путаницы может внести поднятие [1] объявлений переменных и функций, но чтобы превратить эту особенность 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
;
SON.parse
, преобразовываем его, для удобства, в объект;
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
, здесь три, заключается в том, что такой код тяжело читать и поддерживать. Подобная проблема существует, пожалуй, со дня появления функций обратного вызова, она широко известна как ад коллбэков.
Ад коллбэков во всей красе. Изображение взято отсюда [3].
В данном случае под «состоянием гонки» мы понимаем ситуацию, когда мы не контролируем порядок получения данных о репозиториях пользователей. Мы запрашиваем данные по всем пользователям, и вполне может оказаться так, что ответы на эти запросы окажутся перемешанными. Скажем, ответ по десятому пользователю придёт первым, а по второму — последним. Ниже мы поговорим о возможном решении этой проблемы.
Используя промисы можно улучшить читабельность кода. В результате, например, если в ваш проект придёт новый разработчик, он быстро поймёт, как там всё устроено.
Для того, чтобы создать промис, можно воспользоваться такой конструкцией:
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
;
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
. С кодом легко работать, его легко читать.
На самом деле, это лишь вершина айсберга того, что называется промисами. Вот материал [4], который я рекомендую почитать тем, кто хочет более основательно погрузиться в эту тему.
Ещё один подход к решению нашей задачи, который, однако, нечасто встретишь — это генераторы. Тема это немного более сложная, чем остальные, поэтому, если вы чувствуете, что вам это изучать пока рано, можете сразу переходить к следующему разделу этого материала.
Для того, чтобы определить функцию-генератор, можно воспользоваться знаком звёздочки, «*», после ключевого слова 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)
}
Здесь мы можем индивидуально обрабатывать список репозиториев каждого пользователя. Для того, чтобы улучшить этот код, можно было бы выделить функции обратного вызова, как мы уже делали выше.
Я неоднозначно отношусь к генераторам. С одной стороны, можно быстро понять, чего ожидать от кода, взглянув на генератор, с другой, выполнение генераторов приводит к проблемам, похожим не те, что возникают в аду коллбэков.
Надо отметить, что генераторы — возможность сравнительно новая, как результат, если вы рассчитываете на использование вашего кода в старых версиях браузеров, код надо обработать транспилятором. Кроме того, генераторы в написании асинхронного кода используют нечасто, поэтому, если вы занимаетесь командной разработкой, учтите, что некоторые программисты могут быть с ними незнакомы.
На тот случай, если вы решили лучше вникнуть в эту тему, вот [5] и вот [6] — отличные материалы о внутреннем устройстве генераторов.
Этот метод похож на смесь генераторов и промисов. Вам нужно лишь указать, с помощью ключевого слова 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
можно почитать здесь [7].
Минус async/await
, как и минус генераторов, заключается в том, что эту конструкцию не поддерживают старые браузеры, а для её использования в серверной разработке нужно пользоваться Node 8. В подобной ситуации, опять же, поможет транспилятор, например — babel [8].
Здесь [9] можно посмотреть код проекта, который решает поставленную в начале материала задачу с использованием async/await
. Если вы хотите как следует разобраться с тем, о чём мы говорили — поэкспериментируйте с этим кодом и со всеми рассмотренными технологиями.
Обратите внимание на то, что наши примеры можно улучшить, сделать лаконичнее, если переписать их с использованием альтернативных способов выполнения запросов, вроде $.ajax
и fetch
. Если у вас есть идеи о том, как улучшить качество кода при использовании вышеописанных методик — буду благодарен, если расскажете об этом мне [10].
В зависимости от особенностей поставленной перед вами задачи, может оказаться так, что вы будете пользоваться async/await, коллбэками, или некоей смесью из разных технологий. На самом деле, ответ на вопрос о том, какую именно методику асинхронной разработки выбрать, зависит от особенностей проекта. Если некий подход позволяет решить задачу с помощью читабельного кода, который легко поддерживать, который понятен (и будет понятен через некоторое время) вам и другим членам команды, значит этот подход — то, что вам нужно.
Уважаемые читатели! Какими методиками написания асинхронного кода на JavaScript вы пользуетесь?
Автор: ru_vds
Источник [11]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/263642
Ссылки в тексте:
[1] поднятие: https://scotch.io/tutorials/understanding-hoisting-in-javascript
[2] Image: https://habrahabr.ru/company/ruvds/blog/337662/
[3] отсюда: https://medium.com/@sagish/node-with-benefits-using-coffeescript-in-your-stack-e9754bf58668
[4] материал: https://pouchdb.com/2015/05/18/we-have-a-problem-with-promises.html
[5] вот: https://codeburst.io/generators-in-javascript-1a7f9f884439
[6] вот: http://chrisbuttery.com/articles/synchronous-asynchronous-javascript-with-es6-generators/
[7] здесь: https://davidwalsh.name/async-await
[8] babel: https://babeljs.io/
[9] Здесь: https://codepen.io/daspinola/pen/EvOEKB
[10] мне: https://medium.com/@daspinola
[11] Источник: https://habrahabr.ru/post/337662/
Нажмите здесь для печати.