Главная » Статьи » Написание асинхронных задач в современном JavaScript

Написание асинхронных задач в современном JavaScript

Написание асинхронных задач в современном JavaScript

От автора: в этой статье мы исследуем эволюцию JavaScript в аспекте асинхронного выполнения от прошлой эпохи и до того, как он изменил способ написания и чтения кода. Мы начнем с начала веб-разработки и пройдем весь путь до современных примеров асинхронных шаблонов.

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

По мере развития языка в сцене появлялись новые артефакты, позволяющие реализовать асинхронное выполнение; разработчики пробовали разные подходы при решении более сложных алгоритмов и потоков данных, что привело к появлению новых интерфейсов и шаблонов, связанных с ними.

Синхронное выполнение и Observer Pattern

Как упоминалось во введении, 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 && xhr.status === 200) { console.log(request.responseText); }
} request.send();

Когда сервер возвращает ответ, задача для назначенного метода onreadystatechange ставится в очередь (выполнение кода продолжается в основном потоке).

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

Вот почему код, сформированный таким образом, называется Observer Pattern, который в этом случае лучше представлен интерфейсом 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, если ожидаются данные ответа, которые указываются в качестве второго аргумента. Это называлось Call-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'); }) }) })
})

Мы можем видеть, как по мере усложнения программы, которую мы пишем, человеческому глазу становится все труднее следовать коду из-за множественных цепочек обратных вызовов и повторяющейся обработки ошибок.

Промисы, оболочки и цепочки

Промисы не получили должного внимания, когда их впервые объявили, как новое дополнение к языку JavaScript. Они не являются новой концепцией, поскольку другие языки имели схожие реализации десятилетия до того. Однако, по правде говоря, они сильно изменили семантику и структуру большинства проектов, над которыми я работал с момента их появления.

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

Миграция методов от подхода обратных вызовов на методы, основанные на промисах, становилась все более обычным делом в проектах (таких как библиотеки и браузеры), и даже Node.js начал медленно переходить к ним. Давайте, например, обернем метод Node readFile:

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, если она есть.

Теперь мы можем использовать эти новые методы и избегать цепочек обратных вызовов.

asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))

Наличие нативного способа создания асинхронных задач и понятный интерфейс для отслеживания возможных результатов позволили отрасли отойти от Observer Pattern. Основанный на промисах подход, казалось, решал проблемы нечитаемого и подверженного ошибкам кода.

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

Принятие Promise было настолько глобальным в сообществе, что Node.js быстро выпустил встроенные версии своих методов ввода-вывода, чтобы возвращать объекты Promise, например импортировать из них файловые операции fs.promises.

Он даже предоставил утилиту promisify, предназначенную для оборачивания любой функции, которая следовала шаблону обратного вызова с ошибкой, и преобразовать ее в промис.

Но помогают ли промисы во всех случаях?

Давайте заново представим нашу задачу предварительной обработки стиля, написанную с помощью 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))

Существует явное сокращение кода, особенно в том, что касается обработки ошибок, так как мы теперь полагаемся на catch, но промис почему-то не смог обеспечить четкий отступ кода, который напрямую связан с объединением действий.

Это на самом деле достигается в первом операторе then после вызова readFile. После этих строк возникает необходимость создать новую область видимости, в которой мы можем сначала создать каталог, чтобы затем записать результат в файл. Это приводит к нарушению ритма отступов, что затрудняет определение последовательности инструкций с первого взгляда.

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

Примечание. Примите во внимание, что это пример программы, и мы контролируем некоторые методы, и все они следуют отраслевым соглашениям, но это не всегда так. С более сложными объединениями или введением библиотеки с другой формой этот стиль кода может легко сломаться.

Отрадно, что сообщество JavaScript снова извлекло уроки из синтаксиса других языков и добавило нотацию, которая очень помогает в тех случаях, когда объединение асинхронных задач не так просто читать, как синхронный код.

Async и Await

Промис определяется как неразрешенное значение во время выполнения, и создание экземпляра 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 вне области действия функции async.

Каждый раз, когда асинхронный метод находит оператор await, он прекращает выполнение до тех пор, пока не будет разрешено исходное значение или промис.

Существует явное следствие использования нотации async / await, несмотря на асинхронное выполнение, код выглядит так, как если бы он был синхронным, что мы, разработчики, более привыкли видеть. Как насчет обработки ошибок? Для этого мы используем операторы, которые уже давно присутствуют в языке, try и 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.

Заключение

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

Но отойти от цепочек обратных вызовов непросто, я думаю, что необходимость передавать метод в then мешает нам после многих лет отойти от хода мыслей, связанных с Observer Pattern.

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

Мы до сих пор не знаем, как будет выглядеть спецификация ECMAScript через годы, поскольку мы постоянно расширяем возможности управления JavaScript за пределами Веб и пытаемся решать более сложные головоломки.

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

Автор: Jeremias Menichelli

Источник: https://www.smashingmagazine.com

Редакция: Команда webformyself.