JavaScript: методы асинхронного программирования +17


Синхронный код на JavaScript, автор которого не стремился сбить с толку тех, кто этот код будет читать, обычно выглядит просто и понятно. Команды, из которых он состоит, выполняются в том порядке, в котором они следуют в тексте программы. Немного путаницы может внести поднятие объявлений переменных и функций, но чтобы превратить эту особенность JS в проблему, надо очень постараться. У синхронного кода на JavaScript есть лишь один серьёзный недостаток: на нём одном далеко не уехать.



Практически каждая полезная JS-программа написана с привлечением асинхронных методов разработки. Здесь в дело вступают функции обратного вызова, в просторечии — «коллбэки». Здесь в ходу «обещания», или Promise-объекты, называемые обычно промисами. Тут можно столкнуться с генераторами и с конструкциями async/await. Асинхронный код, в сравнении с синхронным, обычно сложнее писать, читать и поддерживать. Иногда он превращается в совершенно жуткие структуры вроде ада коллбэков. Однако, без него не обойтись.

Сегодня предлагаем поговорить об особенностях коллбэков, промисов, генераторов и конструкций async/await, и подумать о том, как писать простой, понятный и эффективный асинхронный код.

О синхронном и асинхронном коде


Начнём с рассмотрения фрагментов синхронного и асинхронного JS-кода. Вот, например, обычный синхронный код:

console.log('1')
console.log('2')
console.log('3')

Он, без особых сложностей, выводит в консоль числа от 1 до 3.

Теперь — код асинхронный:

console.log('1')
setTimeout(function afterTwoSeconds() {
  console.log('2')
}, 2000)
console.log('3')

Тут уже будет выведена последовательность 1, 3, 2. Число 2 выводится из коллбэка, который обрабатывает событие срабатывания таймера, заданного при вызове функции setTimeout. Коллбэк будет вызвана, в данном примере, через 2 секунды. Приложение при этом не остановится, ожидая, пока истекут эти две секунды. Вместо этого его исполнение продолжится, а когда сработает таймер, будет вызвана функция afterTwoSeconds.

Возможно, если вы только начинаете путь JS-разработчика, вы зададитесь вопросами: «Зачем это всё? Может быть, можно переделать асинхронный код в синхронный?». Поищем ответы на эти вопросы.

Постановка задачи


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

В плане интерфейса ограничимся чем-нибудь простым.


Простой интерфейс поиска пользователей GitHub и соответствующих им репозиториев

В примерах выполнение запросов будет выполнено средствами XMLHttpRequest (XHR), но вы вполне можете использовать тут jQuery ($.ajax), или более современный стандартный подход, основанный на использовании функции fetch. И то и другое сводится к использованию промисов. Код, в зависимости от похода, будет меняться, но вот, для начала, такой пример:

// аргумент 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 помощью JSON.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 вы пользуетесь?




К сожалению, не доступен сервер mySQL