Написание асинхронных задач в современном JavaScript
Опубликовано: 2022-03-10JavaScript как язык программирования имеет две основные характеристики, и обе важны для понимания того, как будет работать наш код. Во-первых, это его синхронный характер, что означает, что код будет выполняться строка за строкой, почти так же, как вы его читаете, а во-вторых, он является однопоточным , в любой момент времени выполняется только одна команда.
По мере развития языка в сцене появлялись новые артефакты, обеспечивающие асинхронное выполнение; разработчики пробовали разные подходы при решении более сложных алгоритмов и потоков данных, что приводило к появлению вокруг них новых интерфейсов и паттернов.
Синхронное выполнение и шаблон Observer
Как упоминалось во введении, большую часть времени JavaScript запускает код, который вы пишете, построчно. Даже в первые годы у языка были исключения из этого правила, хотя их было несколько, и вы могли их уже знать: HTTP-запросы, DOM-события и временные интервалы.
const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })
Если мы добавим прослушиватель событий, например, щелчок по элементу, и пользователь инициирует это взаимодействие, механизм JavaScript поставит в очередь задачу для обратного вызова прослушивателя событий, но продолжит выполнение того, что присутствует в его текущем стеке. После того, как это будет сделано с присутствующими там вызовами, теперь будет выполняться обратный вызов слушателя.
Такое поведение похоже на то, что происходит с сетевыми запросами и таймерами, которые были первыми артефактами для доступа к асинхронному выполнению для веб-разработчиков.
Хотя это были исключения из общего синхронного выполнения в JavaScript, важно понимать, что язык по-прежнему однопоточный, и хотя он может ставить задачи в очередь, запускать их асинхронно и затем возвращаться к основному потоку, он может выполнять только один фрагмент кода. вовремя.
Например, давайте проверим сетевой запрос.
var request = new XMLHttpRequest(); request.open('GET', '//some.api.at/server', true); // observe for server response request.onreadystatechange = function() { if (request.readyState === 4 && request.status === 200) { console.log(request.responseText); } } request.send();
Когда сервер возвращается, задача для метода, назначенного onreadystatechange
, ставится в очередь (выполнение кода продолжается в основном потоке).
Примечание . Объяснение того, как движки JavaScript ставят задачи в очередь и обрабатывают потоки выполнения, является сложной темой для освещения и, вероятно, заслуживает отдельной статьи. Тем не менее, я рекомендую посмотреть «Что, черт возьми, такое цикл событий?» Филиппа Робертса, чтобы помочь вам лучше понять.
В каждом упомянутом случае мы реагируем на внешнее событие. Достигнут определенный интервал времени, действие пользователя или ответ сервера. Мы не могли создать асинхронную задачу как таковую, мы всегда наблюдали события, происходящие за пределами нашей досягаемости.
Вот почему код, оформленный таким образом, называется шаблоном наблюдателя , который в данном случае лучше представлен интерфейсом addEventListener
. Вскоре процветали библиотеки генераторов событий или фреймворки, раскрывающие этот шаблон.
Node.js и генераторы событий
Хорошим примером является Node.js, страница которого описывает себя как «асинхронную среду выполнения JavaScript, управляемую событиями», поэтому генераторы событий и обратный вызов были первоклассными гражданами. У него даже был уже реализован конструктор EventEmitter
.
const EventEmitter = require('events'); const emitter = new EventEmitter(); // respond to events emitter.on('greeting', (message) => console.log(message)); // send events emitter.emit('greeting', 'Hi there!');
Это был не только готовый подход к асинхронному выполнению, но и основной шаблон и соглашение экосистемы. Node.js открыл новую эру написания JavaScript в другой среде — даже за пределами Интернета. Как следствие, возможны другие асинхронные ситуации, такие как создание новых каталогов или запись файлов.
const { mkdir, writeFile } = require('fs'); const styles = 'body { background: #ffdead; }'; mkdir('./assets/', (error) => { if (!error) { writeFile('assets/main.css', styles, 'utf-8', (error) => { if (!error) console.log('stylesheet created'); }) } })
Вы могли заметить, что обратные вызовы получают error
в качестве первого аргумента, если ожидаются данные ответа, они идут в качестве второго аргумента. Это называлось Error-first Callback Pattern и стало соглашением, которое авторы и участники приняли для своих собственных пакетов и библиотек.
Обещания и бесконечная цепочка обратных вызовов
Поскольку веб-разработка столкнулась с более сложными проблемами, возникла потребность в улучшенных асинхронных артефактах. Если мы посмотрим на последний фрагмент кода, мы увидим повторяющуюся цепочку обратного вызова, которая плохо масштабируется по мере увеличения количества задач.
Для примера добавим всего два шага, чтение файла и предобработку стилей.
const { mkdir, writeFile, readFile } = require('fs'); const less = require('less') readFile('./main.less', 'utf-8', (error, data) => { if (error) throw error less.render(data, (lessError, output) => { if (lessError) throw lessError mkdir('./assets/', (dirError) => { if (dirError) throw dirError writeFile('assets/main.css', output.css, 'utf-8', (writeError) => { if (writeError) throw writeError console.log('stylesheet created'); }) }) }) })
Мы можем видеть, как по мере того, как программа, которую мы пишем, становится все более сложной, код становится все труднее отслеживать человеческому глазу из-за множественной цепочки обратных вызовов и повторяющейся обработки ошибок.
Промисы, обертки и паттерны цепочек
Promises
не уделялось особого внимания, когда они были впервые объявлены как новое дополнение к языку JavaScript, они не являются новой концепцией, поскольку другие языки имели аналогичные реализации десятилетия назад. Правда, оказалось, что они сильно изменили семантику и структуру большинства проектов, над которыми я работал с момента их появления.
Promises
не только предоставил разработчикам встроенное решение для написания асинхронного кода, но и открыл новый этап в веб-разработке, выступая в качестве основы для создания более поздних новых функций веб-спецификации, таких как fetch
.
Миграция метода с обратного вызова на метод, основанный на промисах, становилась все более и более привычной в проектах (таких как библиотеки и браузеры), и даже Node.js начал потихоньку мигрировать на них.
Давайте, например, readFile
метод readFile Node:
const { readFile } = require('fs'); const asyncReadFile = (path, options) => { return new Promise((resolve, reject) => { readFile(path, options, (error, data) => { if (error) reject(error); else resolve(data); }) }); }
Здесь мы скрываем обратный вызов, выполняя его внутри конструктора промиса, вызывая resolve
, когда результат метода успешен, и reject
, когда объект ошибки определен.
Когда метод возвращает объект Promise
, мы можем следить за его успешным разрешением, передав функцию then
, ее аргументом является значение, которое было разрешено промисом, в данном случае data
.

Если во время выполнения метода возникла ошибка, будет вызвана функция catch
, если она присутствует.
Примечание . Если вам нужно более глубоко понять, как работают Promises, я рекомендую статью Джейка Арчибальда «JavaScript Promises: An Introduction», которую он написал в блоге веб-разработки Google.
Теперь мы можем использовать эти новые методы и избегать цепочек обратного вызова.
asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))
Наличие собственного способа создания асинхронных задач и понятного интерфейса для отслеживания возможных результатов позволило отрасли отказаться от шаблона наблюдателя. Основанные на промисах, казалось, решили нечитаемый и подверженный ошибкам код.
Поскольку лучшая подсветка синтаксиса или более четкие сообщения об ошибках помогают при написании кода, код, который легче понять, становится более предсказуемым для читающего его разработчика, с лучшей картиной пути выполнения, тем легче поймать возможную ловушку.
Принятие Promises
было настолько глобальным в сообществе, что Node.js быстро выпускает встроенные версии своих методов ввода-вывода для возврата объектов промисов, таких как импорт их файловых операций из fs.promises
.
Он даже предоставил promisify
для переноса любой функции, которая следует шаблону обратного вызова с ошибкой, и преобразования ее в функцию, основанную на обещании.
Но во всех ли случаях помогают промисы?
Давайте заново представим нашу задачу предварительной обработки стиля, написанную с помощью Promises.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => writeFile('assets/main.css', result.css, 'utf-8')) ) .catch(error => console.error(error))
В коде наблюдается явное сокращение избыточности, особенно в отношении обработки ошибок, поскольку теперь мы полагаемся на catch
, но промисы каким-то образом не обеспечивают четкого отступа кода, который напрямую связан с конкатенацией действий.
На самом деле это достигается в первом операторе then
после readFile
. Что происходит после этих строк, так это необходимость создать новую область видимости, где мы можем сначала создать каталог, чтобы позже записать результат в файл. Это вызывает нарушение ритма вдавливания, что затрудняет определение последовательности инструкций с первого взгляда.
Способ решить эту проблему состоит в том, чтобы предварительно запечь собственный метод, который обрабатывает это и позволяет правильно конкатенировать метод, но мы бы добавили еще одну глубину сложности в код, который, кажется, уже имеет то, что ему нужно для выполнения задачи. мы хотим.
Примечание . Примите во внимание, что это пример программы, и мы контролируем некоторые методы, и все они следуют отраслевым соглашениям, но это не всегда так. С более сложными конкатенациями или введением библиотеки с другой формой наш стиль кода может легко сломаться.
К счастью, сообщество JavaScript снова изучило синтаксис других языков и добавило нотацию, которая очень помогает в тех случаях, когда конкатенация асинхронных задач не так приятна или проста для чтения, как синхронный код.
Асинхронно и ждите
Promise
определяется как неразрешенное значение во время выполнения, и создание экземпляра Promise
является явным вызовом этого артефакта.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => { writeFile('assets/main.css', result.css, 'utf-8') })) .catch(error => console.error(error))
Внутри асинхронного метода мы можем использовать зарезервированное слово await
для определения разрешения Promise
перед продолжением его выполнения.
Давайте вернемся к фрагменту кода, используя этот синтаксис.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') async function processLess() { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } processLess()
Примечание . Обратите внимание, что нам нужно было переместить весь наш код в метод, потому что сегодня мы не можем использовать await
вне области действия асинхронной функции.
Каждый раз, когда асинхронный метод находит оператор await
, он прекращает выполнение до тех пор, пока не будет разрешено исходное значение или обещание.
Существует явное последствие использования нотации async/await, несмотря на ее асинхронное выполнение, код выглядит так, как если бы он был синхронным , что мы, разработчики, больше привыкли видеть и рассуждать.
Что с обработкой ошибок? Для этого мы используем операторы, давно присутствующие в языке, try
and catch
.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less'); async function processLess() { try { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } catch(e) { console.error(e) } } processLess()
Мы уверены, что любая ошибка, возникшая в процессе, будет обработана кодом внутри оператора catch
. У нас есть основное место, которое занимается обработкой ошибок, но теперь у нас есть код, который легче читать и выполнять.
Имея последовательные действия, которые возвращают значение, не нужно хранить в переменных, таких как mkdir
, которые не нарушают ритм кода; также нет необходимости создавать новую область для доступа к значению result
на более позднем этапе.
Можно с уверенностью сказать, что обещания были фундаментальным артефактом, представленным в языке, необходимым для включения нотации async/await в JavaScript, которую вы можете использовать как в современных браузерах, так и в последних версиях Node.js.
Примечание . Недавно на JSConf Райан Даль, создатель и первый участник Node, пожалел, что не придерживался Promises на ранних стадиях разработки, главным образом потому, что целью Node было создание управляемых событиями серверов и управления файлами, для которых шаблон Observer подходил лучше.
Заключение
Внедрение промисов в мир веб-разработки изменило то, как мы ставим в очередь действия в нашем коде, и то, как мы рассуждаем о выполнении нашего кода и как мы создаем библиотеки и пакеты.
Но уйти от цепочек обратных вызовов сложнее, я думаю, что необходимость передать метод then
не помогла нам отойти от хода мыслей после многих лет привыкания к паттерну Observer и подходам, принятым основными поставщиками. в сообществе, таком как Node.js.
Как говорит Нолан Лоусон в своей превосходной статье о неправильном использовании конкатенаций промисов, старые привычки обратного вызова умирают с трудом ! Позже он объясняет, как избежать некоторых из этих ловушек.
Я считаю, что промисы были необходимы в качестве промежуточного шага, позволяющего естественным образом генерировать асинхронные задачи, но они не сильно помогли нам продвинуться вперед в улучшении шаблонов кода, иногда вам действительно нужен более адаптируемый и улучшенный синтаксис языка.
Когда мы пытаемся решать более сложные головоломки с помощью JavaScript, мы видим потребность в более зрелом языке и экспериментируем с архитектурами и шаблонами, которые раньше не встречали в сети.
“
Мы до сих пор не знаем, как будет выглядеть спецификация ECMAScript через несколько лет, поскольку мы всегда расширяем возможности управления JavaScript за пределы Интернета и пытаемся решать более сложные головоломки.
Сейчас трудно сказать, что именно нам понадобится от языка, чтобы некоторые из этих головоломок превратились в более простые программы, но я доволен тем, как Интернет и сам JavaScript двигают вещи, пытаясь адаптироваться к вызовам и новым средам. Я чувствую, что сейчас JavaScript более дружелюбен к асинхронности , чем когда я начал писать код в браузере более десяти лет назад.
Дальнейшее чтение
- «Обещания JavaScript: введение», Джейк Арчибальд
- «Promise Anti-Patterns», документация по библиотеке Bluebird.
- «У нас проблема с обещаниями», Нолан Лоусон