К списку

Асинхронный код для начинающих

21 апреля 2020

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

Прежде всего нужно разобраться с асинхронным кодом с точки зрения разработчика, столкнувшегося с ним. Для этого давайте представим, что у нас есть функция asyncFunction( param ), которая выполняется асинхронно, ее работа занимает какое-то время, например, 1 секунду, и в результате своей работы она выводит в консоль param. Т.е. такой код

asyncFunction(‘Hello’)

выведет в консоль сообщение Hello через 1 секунду после вызова. А теперь пример использования этой функции в чуть более сложном случае:

asyncFunction(‘Hello’);
	console.log(‘ world’);

Что мы увидим в консоли? Чаще всего начинающие ожидают увидеть сообщение Hello world, ведь сначала мы вызываем нашу функцию, а потом console.log. Но нет. Результатом работы этого кода будет сообщение в консоль world Hello. Так получается потому, что наша функция асинхронная, и хоть мы и привыкли, что код выполняется по строчкам по порядку, но выполнение первой строки — это только вызов функции, ее результат получится только через 1 секунду, и, поскольку, мы договорились, что функция асинхронная, браузер не ждет полного выполнения этой функции и переходит на вторую строку, вызывая console.log. Поэтому слово world выведется раньше, и только через одну секунду появится слово Hello.

На самом деле, возможность писать асинхронный код легко и просто в js — это очень хорошо. В некоторых языках бывает очень сложно запустить выполнение какой-то задачи параллельно. Но такая возможность вызывает одну большую проблему у людей, у которых нет привычки: что делать, если мы хотим вызывать асинхронные операции, и в результате их выполнения делать что-нибудь с их результатом? В приведенном выше коде, очевидно, что хотелось бы выполнить console.log(‘ world’) именно тогда, когда asyncFunction закончит свою работу. И относится к таким случаям нужно именно так: нужно подумать, что мы хотим выполнить наш код именно в момент прекращения работы асинхронного. Нужно ловить именно этот момент.

Сделаем небольшое отступление. Надо же уметь отличить асинхронный код от обычного. Чаще всего мы встречаемся с асинхронным кодом при работе с фронтендом, когда делаем Ajax запросы или работаем со временем, например, используем setTimeout. Для того, чтобы дальше было удобно, давайте напишем код нашей асинхронной функции asyncFunction. Там внутри может быть какой-нибудь POST запрос, или fetch(), но давайте для простоты сделаем ее с помощью setTimeout:

function asyncFunction( param ) {
		setTimeout(function() {
			console.log(param)
		}, 1000);
	}

Запустить ее можно, чтобы проверить работу: asyncFunction(‘hello world!!’). Сообщение в консоли появится примерно через одну секунду, и всё должно быть хорошо. Нам понадобится еще одна функция:

function myFunction( param ) {
		console.log(‘my faunction: ‘, param)
	}

Это обычная (не асинхронная) функция и мы постараемся запустить ее сразу после выполнения asyncFunction. Теперь вернемся к проблеме. Как запустить свой код после того, как асинхронная функция отработала? Само собой, такой вариант:

asyncFunction( ‘async’ ); 
	myFunction(‘done’);

не пойдет. Для этого существует 3 приема: callbacks, promises и async/await. Все эти приемы отличаются друг от друга чистотой и читаемостью кода, и все они требуют небольших изменений самой функции. Разберемся с ними в историческом порядке их появления.

Callbacks

Callback — это дословно «функция обратного вызова». Это значит, что callback — это функция, которую мы передаем в другую функцию в качестве параметра для последующего выполнения. В нашем случае, колбэком должна стать myFunction, чтобы функция asyncFunction могла ее вызвать тогда, когда нужно. Выглядеть это должно, например, так:

asyncFunction( ‘hello world’, myFunction);

Давайте добавим асинхронной функции такую возможность. Это достаточно просто и понятно:

function asyncFunction( param, callback ) {
		setTimeout(function() {
			console.log(param)
			callback();
		}, 1000);
	}

Мы добавили еще один параметр, и, поскольку мы знаем, что это будет функция, вызываем ее в нужный момент. Ну и попробуем, как это будет работать:

asyncFunction(‘hello callback’, myFunction)

Судя по тому, что в консоли мы увидим через 1 секунду два сообщения hello callback и my faunction: undefined, у нас две новости. Во-первых, у нас получилось решить задачу по времени, во-вторых, появилась проблема вызова myFunction с нужным нам параметром. Первое, что приходит на ум — добавить параметр при вызове callback внутри asyncFunction, но тогда нужно будет как-то уметь менять этот параметр, если мы захотим.

На самом деле проблему можно решить легко, если мы вспомним, что в js можно объявлять функции в любой момент. Можно модифицировать вызов asyncFunction, добавив в качестве callback новую функцию, вызывающую myFunction с нужным параметром:

asyncFunction(‘hello callback’, function () {
		myFunction(‘done’)
	});

Теперь все работает, как надо. Есть еще один момент. Асинхронная функция может быть не такой простой, и результатом ее выполнения может быть и ошибка. Что, если бы мы хотели выполнить разный код в зависимости от результата. Очевидно, что asyncFunction должна уметь принимать два callback-а: один для положительного результата, другой для ошибки. И в нашем примере вызов такой функции мог бы выглядеть так:

asyncFunctionWithErrors(‘hello callback’, function () {
		myFunction(‘done’)
	}, function () {
		myFunction(‘error’)
	});

Даже в этом простом примере читать код становится неудобно. Callback-подход всегда работает, и его достоинство в том, что он производит впечатление простого и понятного: мы передаем функцию в качестве параметра, и в нужный момент вызываем ее. Однако, такой код не очень приятно читается и может вызывать то, что обычно называется «callback hell». Тут можно посмотреть на примеры «ада» и на техники избегания его.

Promises

Начнем с того, что для решения проблемы чистоты кода существуют «промисы». Эту концепцию следует понимать так: асинхронная функция должна уметь возвращать promise (обещание) получить результат выполнения этой функции в будущем. Причем это обещание может находиться в одном из трех состояний («ожидание», «исполнено» и «отклонено»). И мы можем попросить выполнить наш код в тот момент, когда обещание станет исполненным или отклоненным. Работа с асинхронными функциями, возвращающими промис выглядит очень приятно:

asyncFunctionWithPromise(‘hello promise’)
		.then(onSuccess)
		.catch(onError)

В этом примере мы вызываем нашу асинхронную функцию только со своим, нужным ей, параметром ‘hello promise’. И, поскольку asyncFunctionWithPromise должна возвращать promise, мы можем как-будто подписаться на состояния обещания «исполнено» при помощи .then() и «отклонено» при помощи .catch(). Достаточно туда передать те функции, которые мы хотим вызвать (onSuccess и onError). Обычно код с промисами выглядит гораздо более понятным и лаконичным. И, если не использовать объявления безымянных функций внутри .then() и .catch(), а передавать туда имена уже объявленных, то код будет всегда оставаться легко читаемым и поддерживаемым. Более того, методы then и catch сами возвращают promise, а это значит, что мы можем использовать цепочки вызовов. Например:

promiseFunction()
		.then(onDone)
		.then(makeAjaxCall)
		.then(onAjaxSuccess)
		.catch(onError)

Все выглядит красиво, но мы так и не научили нашу asyncFunction возвращать «обещание». Добавим туда еще обработку ошибки отсутствия параметра, чтобы можно было посмотреть на работу .catch(). Чтобы заставить нашу функцию работать с promise, придется в ней создать объект класса Promise и вернуть его как результат работы функции. Сам конструктор класса Promise требует от нас параметр — функцию-исполнитель. Которая, в свою очередь принимает два параметра — resolve и reject — это колбэки, которые передает Promise в функцию-исполнитель, и которые мы должны вызвать в случаях успешного или неуспешного выполнения нашего кода соответственно. Вот так будет выглядеть asyncFunction, возвращающая promise:

function asyncFunction( param ) {
		var promise = new Promise(executor);
		return promise;
		
		function executor(resolve, reject) {
			setTimeout(function() {
				if (param) {
					console.log(param);
					resolve();
				} else {
					reject();
				}
			}, 1000);
		}
	}

Выглядит немного хуже, чем вариант с callback, но разобраться все же можно.

Давайте добавим две синхронные функции для обработки удачного результата выполнения асинхронной и ошибки:

function onSuccess() {
		console.log(‘Success!’)
	}
	function onError() {
		console.log(‘Error’)
	}

Теперь можно пробовать:

asyncFunction('hello callback').then(onSuccess).catch(onError)

или с ошибкой:

asyncFunction().then(onSuccess).catch(onError)

На самом деле промисы могут гораздо больше, например, можно передавать данные по цепочке .then/.catch, или запустить несколько асинхронных функций одновременно, имея одно «обещание» на всех. Подробнее об этом можно почитать на MDN .

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

async / await

В случае выбора между callbacks и promises приходится чем-то жертвовать: либо читаемостью кода самой асинхронной функции, либо читаемостью кода работы с ней. Более того, часто бэкенд-разработчикам, когда приходится немного поработать с js кодом, бывает сложно переключиться на ту легкость, с которой в JavaScript появляются асинхронные операции.

Недавно появился еще один инструмент работы с асинхронностью — asynс / await. На самом деле это расширенный интерфейс работы с промисами. Но код с использованием этого подхода иногда получается более приятным.

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

async function myFunction( param ) {
		console.log(‘my faunction: ‘, param)
	}

Теперь ее можно вызвать и даже выполнить что-нибудь как реакцию на состояние обещания «исполнено»:

myFunction(‘hello’).then(onSuccess)

Как видите, мы не создавали никаких сложных объектов внутри функции, простое добавление ключевого слова async при ее объявлении автоматически сделало просим за нас.

Можно ли так легко и просто поступить с нашей асинхронной функцией asyncFunction? Попробуем:

async function asyncFunction( param ) {
		setTimeout(function() {
			console.log(param);
		}, 1000)
	}

А теперь вызовем ее так как в последнем примере с promises:

asyncFunction(‘hello’).then(onSuccess).catch(onError)

Как ни странно, в консоли через одну секунду мы увидим только сообщение hello, а функции onSuccess и onError, судя по всему так и не вызвались. Чтобы все заработало, придется все же создать объект Promise:

async function asyncFunction( param ) {
		var promise = new Promise(executor)
 
		return promise;
        
		function executor(resolve, reject) {
			setTimeout(function() {
				if (param) {
					console.log(param);
					resolve();
				} else {
					reject();
				}
			}, 1000)
		}
	}

Теперь можно вызывать так, как мы и собирались. Получается, что пока для асинхронной функции ключевое слово async не добавило нам простоты. Код функции остался точно таким же, как и был в примере с promises.

Самое время обратиться к await. Await — это ключевое слово, которое можно использовать только внутри функции, объявленных с помощью await. Await заставляет браузер ждать выполнения promise. Вот как это выглядит:

var result = await promise;

С помощью async и await можно писать асинхронный код так, будто он синхронный. В нашем случае реализация asyncFunction все же не изменится, но мы можем работать с обработкой результата ее выполнения по-другому. Не забудем, что await можно использовать только внутри функций, объявленных с помощью async. Создадим такую функцию и выполним в ней необходимые операции:

async function main() {
		await asyncFunction(‘sdsd’);
		onSuccess();
	}
 
	main();

Как видим, внутри функции main все выполняется в порядке объявления и браузер ждет выполнения asyncFunction для того, чтобы вызвать onSuccess;

Но все же сам код функции asyncFunction не стал лучше. Дело в том, что await умеет работать со всеми объектами, возвращающими промис, а setTimeout его не возвращает и приходится делать это самим. Совсем другое дело, когда асинхронным является, например Ajax запрос, возвращающий promise, например fetch():

async function asyncFunction( param ) {
		await fetch(‘some url’)
	}

И все становится просто. Остается один вопрос: как обработать ошибку при таком подходе? Получается, что пара async await позволяет нам запустить асинхронный код и ждать его выполнения, прежде, чем двигаться дальше. Никакого .catch у нас нет. Если вернуться к последней реализации asyncFunction и вызвать в main ее без параметра, то мы увидим, что в консоли появилась ошибка Uncaught (in promise) undefined. Это выглядит так, как будто вместо вызова reject произошел throw new Error(). Этим можно воспользоваться и оборачивать await-вызовы в стандартный блок обработки ошибок try catch. Вот как будет выглядеть функция main в таком случае:

async function main(param) {
  		try {
			await asyncFunction(param);
			onSuccess()
		} catch {
			onError()
		}
	}

Завершение

Как видим, все три способа работы с асинхронным кодом достаточно просты, но имеют свои особенности. И, как мне кажется, выбор между ними — больше дело вкуса, чем каких-то архитектурных решений. Конечно, проблема «callback hell» легко решается цепочками промисов, а цепочки промисов можно превратить в последовательный вызов await. Но, для промисов может понадобиться реализовывать их самим, что для новичков может быть не очень легко, а для await необходимо следить за тем, чтобы он был объявлен внутри async функции, и иногда от promise не получится отказаться совсем.

Лично мой выбор — использование promises и это, скорее, дело привычки.

Автор материала – Игорь Коршук, преподаватель Тренинг центра ISsoft.

Образование: окончил физический факультет Белорусского Государственного Университета.
Опыт работы: front-end разработчик с 2010 года.