-
Цикл событий
Задачи, тики и Web API
Очередь задач
16,6 миллисекунды на задачу
Обработка больших задач
Микрозадачи
requestAnimationFrame
requestIdleCallback
Сравнение очередей
Цикл событий в Node.js -
Функции обратного вызова
Ад обратных вызовов
Не выпускайте Залго
Жёсткая сцепленность
Проблема доверия -
Обещания
Цепочки обещаний и проброс отказа
Неявное поведение
Возвращение нового обещания
Спрятанный try/catch
Thenable-объекты
Статические методы
Promise.all
Promise.race
Promise.any
Promise.allSettled
Промисификация
Обещания или функции обратного вызова?
Корутины -
Async/await
Верхнеуровневый await и асинхронные модули
Обработка ошибок
Не все await одинаково полезны - Заключение
Цикл событий
Для работы сайта браузер выделяет один единственный поток, который должен успевать одновременно делать две важные задачи: выполнять код и обновлять интерфейс. Но один поток в один момент времени может совершать только одно действие. Поэтому поток выполняет эти задачи по очереди, чтобы создать иллюзию параллельного выполнения. Это и есть цикл событий (event loop).
Цикл событий — выполнение кода и обновление интерфейса
Выполнение кода происходит в стеке вызовов. Если функция внутри себя вызывает другую функцию, то вызов её самой приостанавливается до тех пор, пока выполняется другая функция, — таким образом получается эдакий стек вызовов. Как только все операции будут выполнены и стек опустеет, цикл событий может поместить в стек ещё какой-нибудь код для выполнения или обновить интерфейс пользователя.
Стек вызовов
Обновление интерфейса пользователя выполняет движок браузера. Этот этап, как правило, состоит из четырёх шагов: style, layout (reflow), paint, composite. На этапе style браузер пересчитывает изменение стилей, вызванное операциями JavaScript, и рассчитывает медиа-выражения. Layout выполняет перерасчёт геометрии страницы, где происходит вычисление слоёв, расчёт взаимного расположения элементов и их влияния друг на друга. Во время шага paint движок отрисовывает элементы и применяет к ним стили, которые влияют только на внешний вид, например, на цвет, фон и т. п. Composite применяет оставшиеся специфические стили. Как правило, это трансформации, которые происходят в отдельном слое.
Браузер может пропускать некоторые операции, если они не нужны. Понимать, когда браузер выполняет или пропускает тот или иной шаг, может быть полезным для оптимизации веб-страницы. Более подробно о каждом этапе и его связи с производительностью можно прочитать во второй части хабрастатьи «Оптимизация производительности фронтенда».
Обновление интерфейса: style, layout (reflow), paint, composite
Первой операцией в цикле событий может быть как обновление интерфейса, так и выполнение кода. Если сайт использует синхронный тег script, то с большой вероятностью движок выполнит его перед тем, как отрисовать первый кадр интерфейса пользователя. Если же мы грузим скрипты асинхронно через async или defer, то велика вероятность, что до загрузки JavaScript браузер успеет отрисовать интерфейс пользователя.
Вариант с асинхронной загрузкой скриптов более предпочтительный, потому что начальный бандл, как правило, достаточно объёмный. Пока он полностью не выполнится, пользователь будет видеть белый экран, потому что цикл событий не сможет отрисовать интерфейс пользователя. При этом даже при асинхронной загрузке рекомендуется разбивать JavaScript-код на отдельные бандлы и сначала грузить только самое необходимое, потому что цикл событий очень последовательный: он полностью выполняет весь код в стеке вызовов и только потом переходит к обновлению интерфейса. Если в стеке вызовов будет слишком много кода, интерфейс будет обновляться с большой задержкой. У пользователя создастся впечатление, что сайт лагает. Если написать бесконечный цикл, то браузер так и будет выполнять код снова и снова, а обновление интерфейса не наступит никогда, поэтому страница просто зависнет и перестанет реагировать на действия пользователя.
Внутри стека вызовов будет выполняться как код, написанный разработчиком, так и код, встроенный по умолчанию и отвечающий за взаимодействие со страницей. Благодаря встроенному коду работают прокрутка, выделение, анимации и другие штуки, для которых, казалось бы, JavaScript не нужен. Стек вызовов будет выполнять встроенные скрипты даже тогда, когда мы запретим в браузере JavaScript. Для примера можно открыть пустую страницу about:blank без JavaScript, выполнить несколько кликов и увидеть, что стек вызовов выполнил код, отвечающий за обработку событий.
У цикла событий всегда есть работа, даже когда сайт написан без JavaScript
Задачи, тики и Web API
Задача — это JavaScript-код, который выполняется в стеке вызовов. Тик — выполнение задачи в стеке вызовов. Web API — свойства и методы в глобальном объекте Window.
Методы Web API работают либо синхронно, либо асинхронно: первые выполнятся в текущем тике, а вторые в одном из следующих тиков.
Хороший пример синхронных вызовов — это работа DOM:
const button = document.createElement('button')
document.body.appendChild(button)
button.textContent = 'OK'
Создание элемента, вставка в DOM и выставление свойств — это синхронные операции, которые выполняются в текущем тике. Поэтому совершенно нет разницы, когда мы выставим текст для кнопки — до вставки в DOM или после неё. Браузер обновит интерфейс только после полного завершения синхронных операций, так что пользователь увидит сразу актуальное состояние интерфейса.
Когда мы пишем асинхронный код, то гарантируем, что задача будет выполняться в следующем тике. При этом он может начаться как до обновления интерфейса, так и после. Например, когда циклу событий требуется выполнить очередную задачу, он может либо выполнить её сразу после предыдущей, либо сначала обновить интерфейс, а только потом выполнить следующую задачу. Для разработчиков это не играет особой роли. Важно просто понимать, что асинхронная задача выполнится когда-то в будущем.
Хороший пример асинхронного вызова — это запрос данных с сервера. Через функцию обратного вызова описывается задача, которая будет выполнена когда-то в будущем после получения данных:
fetch('/url')
.then((response) => {
// выполнится когда-то в будущем
})
Подсистема браузера, которая отвечает за работу с сетью, выполнит запрос в отдельном потоке, который будет работать независимо. Пока запрос будет выполняться фоном, цикл событий может спокойно обновлять интерфейс и выполнять код. После того как данные успешно загрузятся, задание, которое мы описали через функцию обратного вызова, станет готовым для выполнения в одном из следующих тиков нашего основного цикла заданий.
Задач, готовых к выполнению после асинхронных вызовов, может быть несколько. Поэтому для передачи их в стек вызовов на выполнение существует специальная очередь.
Очередь задач
Задачи попадают в очередь через асинхронное браузерное API. Сперва где-то в отдельном потоке выполняется асинхронная операция, а после её завершения в очередь добавляется задача, готовая к выполнению в стеке вызовов.
Очередь задач
Понимая эту концепцию, можно разобрать одну особенность таймеров в JavaScript, которые тоже являются асинхронным API.
setTimeout(() => {
// поставить задачу в очередь через 1000 мс
}, 1000)
Когда мы запускаем таймер, движок начинает вести обратный отсчёт в отдельном потоке и по готовности задача добавляется в очередь. Можно было бы подумать, что таймер выполнится через одну секунду, но на самом деле это не так. В лучшем случае через одну секунду он добавится в очередь заданий, а код будет выполнен только после того, как до него дойдёт очередь.
Точно такая же история с обработчиками событий. Каждый раз, когда мы регистрируем обработчик событий, мы привязываем к нему задачу, которая будет добавлена в очередь после наступления события:
document.body.addEventListener('click', () => {
// поставить задачу в очередь, когда наступит событие
})
16,6 миллисекунды на задачу
Чтобы сайты были быстрыми и «отзывчивыми», браузеру надо создать иллюзию, что он одновременно выполняет код пользователя и обновляет интерфейс. Но поскольку цикл событий работает строго последовательно, браузеру приходится быстро переключаться между задачами, чтобы пользователь ни о чём не догадался.
Как правило, мониторы обновляют картинку с частотой 60 кадров в секунду, поэтому цикл событий старается с такой же скоростью выполнять код и обновлять интерфейс, то есть на выполнение задачи уходит 16,6 миллисекунды. Если наш код будет выполняться быстрее, то браузер будет просто часто обновлять дисплей. Но если код будет выполняться медленно, то частота кадров начнёт снижаться, и у пользователя появится ощущение, что сайт лагает.
Для большинства сценариев 16,6 миллисекунды вполне достаточно. Но иногда на клиентской стороне требуется выполнять тяжёлые вычисления, которые могут потребовать гораздо больше времени. Для этого есть специальные методики.
Обработка больших задач
Оптимизировать большие задачи можно двумя путями: либо через разбиение их на подзадачи и выполнение последних в разных тиках, либо через вынесение вычисления в отдельный поток.
Выполнить что-то в следующем тике можно, например, через setTimeout с минимальной задержкой.
function longTask () {
toDoSomethingFirst()
setTimeout(() => {
toDoSomethingLater()
}, 0)
}
Кроме того есть экспериментальная функция postTask, которая является частью Scheduling API и в данный момент доступна только в Chrome. Через неё можно не только выполнять задачи асинхронно для разгрузки основного потока, но и устанавливать для них приоритет. Более подробно об этом можно почитать в статье Джереми Вагнера «Optimize long tasks».
Для запуска отдельного потока подойдёт Web Worker API:
const worker = new Worker('worker.js')
worker.addEventListener('message', () => {
// получить данные
})
// отправить данные
worker.postMessage('any data')
Для веб-воркера создаётся отдельный поток, где будут происходить вычисления независимо от основного цикла событий. Как только вычисления закончатся, воркер через postMessage сможет отправить данные в основной цикл событий и задача, связанная с их обработкой, будет добавлена в очередь и выполнена в одном из следующих тиков. Но у веб-воркера есть ограничения. Например, внутри нельзя работать с DOM, однако вычислительные задачи будут работать.
Если данные вычислений нужны внутри других вкладок в рамках одного источника (same origin), то вместо обычного воркера можно использовать SharedWorker. Кроме того, для некоторых задач может быть полезен ServiceWorker, но это уже другая история. Подробнее про воркеры можно прочитать, например, вот тут.
Кроме веб-воркеров есть другой, менее очевидный способ создать отдельный поток — открыть окно или фрейм на другом домене, чтобы нарушить политику same origin. Тогда у окна или фрейма будет свой собственный независимый цикл событий, который сможет выполнять какую-то работу и взаимодействовать с основным окном так же, как и веб-воркер через механизм postMessage. Это достаточно специфическое поведение, которое может по-разному выглядеть в разных браузерах, его можно проверить, например, через демку со Stack Overflow.
Микрозадачи
Микрозадачи — это те задачи, которые хранятся в специальной отдельной очереди.
Очередь микрозадач
Задачи попадают в эту очередь при использовании обещаний, асинхронных функций, встроенном вызове queueMicrotask или для Observer API.
Promise.resolve.then(() => {
// микрозадача
})
async function () {
// микрозадача
}
queueMicrotask(() => {
// микрозадача
})
new MutationObserver(() => {
// микрозадача
}).observe(document.body, { childList: true, subtree: true })
Очередь микрозадач более приоритетная, задачи из неё выполняются раньше обычных. Кроме того, у неё есть важная особенность — цикл событий будет выполнять микрозадачи до тех пор, пока очередь не опустеет. Благодаря этому, движок гарантирует, что все задачи из очереди имеют доступ к одинаковому состоянию DOM.
Это поведение можно наглядно увидеть на примере с обещаниями, где каждый последующий обработчик получает доступ к одному и тому же состоянию DOM (на момент установки обещания):
const div = document.createElement('div')
document.body.appendChild(div)
let height = 0
function changeDOM () {
height += 1
div.style.height = `${height}px`
requestAnimationFrame(changeDOM)
}
requestAnimationFrame(changeDOM)
setTimeout(() => {
const promise = Promise.resolve()
.then(() => {
console.log(div.style.height)
})
.then(() => {
console.log(div.style.height)
})
promise
.then(() => {
console.log(div.style.height)
})
}, 1000)
// все console.log выведут одинаковое значение
Есть замечательный наглядный сайт JavaScript Visualizer 9000, где можно более подробно изучить, как работают очереди задач и микрозадач. Ещё советую хорошую хабрастатью, где разбираются обещания.
requestAnimationFrame
requestAnimationFrame (или сокращённо rAF) позволяет выполнить JavaScript-код прямо перед обновлением интерфейса. Каким-то другим способом, например через таймеры, эмулировать такое поведение практически невозможно.
Вверху без rAF, внизу через rAF
Основная задача requestAnimationFrame — это плавное выполнение JavaScript-анимаций, но используют его нечасто, так как анимации проще и эффективнее задать средствами CSS. Тем не менее он занимает своё полноценное место в цикле событий.
Задач, которые нужно выполнить перед обновлением следующего кадра, может быть несколько, поэтому у requestAnimationFrame есть своя отдельная очередь.
requestAnimationFrame
Задачи из очереди выполняются один раз перед обновлением интерфейса в порядке их добавления:
requestAnimationFrame(() => {
console.log('one')
})
requestAnimationFrame(() => {
console.log('two')
})
requestAnimationFrame(() => {
console.log('three')
})
// one two three
Написать повторяющуюся задачу, которая будет выполняться снова и снова, можно через рекурсивную функцию. Причём, если по какой-то причине потребуется отменить выполнение, это можно сделать через cancelAnimationFrame. Но надо убедиться, что в него передаётся актуальный идентификатор, потому что каждый вызов rAF создаёт новый requestId.
let requestId
function animate () {
requestId = requestAnimationFrame(animate)
}
requestId = requestAnimationFrame(animate)
setTimeout(() => {
// отменить анимации через какое-то время
cancelAnimationFrame(requestId)
}, 3000)
Небольшая, но дельная статья по теме requestAnimationFrame есть в блоге Флавио Коупса.
requestIdleCallback
requestIdleCallback (или сокращённо rIC) добавляет задачи в ещё одну (четвёртую) очередь, которая будет выполняться в период простоя браузера, когда нет более приоритетных задач из других очередей.
function sendAnalytics () {
// отправить данные для аналитики
}
requestIdleCallback(sendAnalytics, { timeout: 2000 })
В качестве второго аргумента можно указать timeout и, если задача не будет выполнена в течение указанного числа миллисекунд, то она добавится в обычную очередь, после чего выполнится в порядке общей очереди.
По аналогии с requestAnimationFrame, чтобы регулярно добавлять задачу в очередь, потребуется написать рекурсивную функцию, а для остановки — передать актуальный идентификатор в cancelIdleCallback.
requestIdleCallback
В отличие от других очередей, рассмотренных ранее, requestIdleCallback — это всё ещё отчасти экспериментальное API, поддержка которого отсутствует в Safari. Кроме того, эта функция имеет ряд ограничений, из-за которых её удобно использовать только для небольших неприоритетных задач без взаимодействия с DOM, например для отправки аналитических данных. Более подробно о requestIdleCallback можно почитать в материале «Using requestIdleCallback» Пола Льюиса.
Сравнение очередей
Очередь микрозадач — это самая приоритетная очередь, с неё начинается выполнение кода. Работа браузера с этой очередью продолжается до тех пор, пока в ней есть задачи, сколько бы времени это ни заняло.
Из очереди задач движок выполняет, как правило, одно или несколько заданий, стараясь уложиться в 16,6 миллисекунды. Как только пройдёт отведённое время, движок пойдёт обновлять интерфейс, даже если в очереди остались задачи. К ним он вернётся на следующем витке цикла событий.
requestAnimation выполнит все задачи из своей очереди, потому что он гарантирует выполнение кода перед обновлением интерфейса. Но если в ходе выполнения кто-то добавит новые задания в очередь, то они выполнятся уже на следующем витке.
Когда наступит время простоя и в других очередях не будет более приоритетных задач, выполнится одно или несколько заданий requestIdleCallback. Таким образом, эта очередь чем-то похожа на очередь задач, но с более низким приоритетом.
Взаимодействие с очередями происходит через:
- задачи — таймеры, события (включая обработку postMessage);
- микрозадачи — обещания, асинхронные функции, Observer API, queueMicrotask;
- requestAnimationFrame, requestIdleCallback — соответствующие вызовы API.
Цикл событий в Node.js
В Node.js цикл событий работает схожим образом: сначала выполняется задание, затем нужно заглянуть в очередь за следующим. Но набор очередей отличается, а также отсутствуют этапы, связанные с обновлением интерфейса, потому что код работает на сервере. Подробнее о циклах событий в Node.js можно почитать в серии статей, которые написал Дипал Джаясекара (Deepal Jayasekara). Для быстрого понимания setImmediate и process.nextTick есть хорошее объяснение на Stack Overflow.
Функции обратного вызова
Это удобный и простой способ взаимодействовать с асинхронным API, но если быть с ним неаккуратным, возникает много проблем.
Ад обратных вызовов
Ад обратных вызовов (callback hell) — самая частая проблема, которую вспоминают, когда говорят про недостатки функций обратного вызова.
Последовательность асинхронных вызовов через функции обратного вызова становится похожа на пирамиду судьбы (pyramid of doom).
fetchToken(url, (token) => {
fetchUser(token, (user) => {
fetchRole(user, (role) => {
fetchAccess(role, (access) => {
fetchReport(access, (report) => {
fetchContent(report, (content) => {
// Welcome to Callback Hell
})
})
})
})
})
})
Можно подумать, что это самый главный недостаток функций обратного вызова, но на этом проблемы с ними только начинаются.
Не выпускайте Залго
Дело в том, что сходу нельзя определить, как именно будет вызвана функция обратного вызова — синхронно или асинхронно, а ведь от этого может сильно зависеть логика нашего кода. Чтобы разобраться наверняка, придётся прочитать реализацию функции. А это требует дополнительных действий и усложняет отладку.
syncOrAsync(() => {
// как именно выполняется код?
})
// синхронная реализация
function syncOrAsync (callback) {
callback()
}
// асинхронная реализация
function syncOrAsync (callback) {
queueMicrotask(callback)
}
В узких кругах эта проблема широко известна как проблема монстра Залго, которого лучше не выпускать на свободу.
Жёсткая сцепленность
Жёсткая сцепленность — это проблема зависимости одной части кода от другой при обработке последовательных асинхронных операций:
firstStep((error, data) => {
if (error) {
// отменить этап №1
}
secondStep((error, data) => {
if (error) {
// отменить этап №2, а затем №1
}
})
}))
Если ошибка наступит на этапе №1, надо будет обработать только её. Но если ошибка произойдёт уже на этапе №2, то придётся отменить этап №2, а затем и этап №1 тоже. И чем больше этапов, тем больше проблем при обработке ошибок.
Проблема доверия
Инверсия управления — это передача нашего кода в библиотеку, которую писали другие разработчики:
import { thirdPartyCode } from 'third-party-package'
thirdPartyCode(() => {
// инверсия управления
})
Мы полагаемся на то, что наша задача будет вызвана так, как надо, но всё может быть далеко не так. Другая библиотека может вызвать функцию слишком рано или слишком поздно, делать это слишком часто или редко, поглотить ошибки и исключения, передать неправильные аргументы или вовсе не вызвать нашу функцию.
Исходя из сказанного, можно подумать, что работа с асинхронностью через функции обратного вызова — это сплошные сложности. Отчасти так и есть, но, к счастью, со всеми этими проблемами помогают справиться обещания.
Обещания
Обещания напоминают талончики с номером заказа в кафе. Когда мы делаем заказ, вместо еды мы получаем талончик с номером. Дальше возможны два сценария. Первый: заказ успешно приготовят и через некоторое время выдадут на стойке получения заказов. Второй: что-то может пойти не так, например, закончатся нужные ингредиенты, тогда сотрудник кафе сообщит нам, что не может приготовить наш заказ, и предложит вернуть деньги или заказать что-то другое.
Обещания создаются через конструктор Promise, который обязательно вызывается через new. Конструктор принимает только один аргумент — функцию обратного вызова с двумя параметрами: resolve и reject. Внутри обещания вызывается асинхронная операция с функцией обратного вызова. В свою очередь внутри этой функции вызывается либо resolve, либо reject, которые устанавливают обещание либо на выполнение, либо на отказ соответственно.
Вот так вот можно выставить обещание либо на выполнение, либо на отказ:
// выставить обещание на выполнение
const resolvedPromise = new Promise((resolve, reject) => {
setTimeout(() => { resolve('^_^') }, 1000)
})
// выставить обещание на отказ
const rejectedPromise = new Promise((resolve, reject) => {
setTimeout(() => { reject('O_o') }, 1000)
})
После того как обещание установлено, результат можно получить через метод then:
resolvedPromise.then((value) => {
console.log(value) // ^_^
})
Обработать отказ можно либо через второй параметр в then, либо через catch:
rejectedPromise.then(
(value) => {
// ... проигнорировано
},
(error) => {
console.log(error) // O_o
}
)
rejectedPromise.catch((error) => {
console.log(error) // O_o
})
Значение обещания устанавливается один раз, после чего его уже не изменить:
const promise = new Promise((resolve, reject) => {
resolve('^_^')
reject('O_o') // уже никак не повлияет на состояние Promise
})
promise.then((value) => {
console.log(value) // ^_^
})
promise.then((value) => {
console.log(value) // ^_^
})
Для удобства можно использовать статические методы — функции-конструкторы Promise.resolve и Promise.reject, которые создают сразу установленное обещание:
Promise.resolve('^_^').then((value) => {
console.log(value) // ^_^
})
Promise.reject('O_o').catch((error) => {
console.log(error) // O_o
})
У обещаний ещё есть метод finally, который что-то сделает независимо от успеха или неудачи. Это чем-то похоже на приготовление блюда в духовке: неважно, пригорело наше блюдо или нет — духовку всё равно надо будет выключить.
Promise.resolve('^_^').finally(() => {
// какая-то работа
}).then((value) => {
console.log(value) // ^_^
})
Promise.reject('O_o').finally(() => {
// какая-то работа
}).catch((error) => {
console.log(error) // O_o
})
Цепочки обещаний и проброс отказа
Самая главная польза обещаний в том, что мы можем выстраивать из них цепочки асинхронных операций:
Promise.resolve('^')
.then((value) => {
return value + '_'
})
.then((value) => {
return value + '^'
})
.then((value) => {
console.log(value) // ^_^
})
Если где-то произойдёт ошибка, то отказ пропустит обработчики на выполнение и долетит до ближайшего обработчика отказа, после чего цепочка продолжит работать в штатном режиме:
Promise.resolve()
.then(() => {
return Promise.reject('O_o')
})
.then(() => {
// все обработчики на выполнение будут пропущены
})
.catch((error) => {
console.log(error) // O_o
})
.then(() => {
// продолжаем выполнять цепочку в штатном режиме
})
Возвращать значение можно и внутри catch, оно будет точно так же обработано в цепочке:
Promise.reject('O_o')
.catch((error) => {
console.log(error) // O_o
return '^_^'
})
.then((value) => {
console.log(value) // ^_^
})
Если цепочка завершится, а ошибка останется необработанной, то сработает событие unhandleledPromiseRejection, на которое можно подписаться, чтобы отслеживать необработанные ошибки внутри обещаний:
window.addEventListener('unhandledrejection', (event) => {
console.log('Необработанная ошибка Promise. Позор вам!')
console.log(event) // PromiseRejectionEvent
console.log(event.reason) // O_o
})
// Promise.reject('O_o') // UnhandledPromiseRejection
Важно понимать, что обработка ошибок работает только тогда, когда цепочка непрерывна. Если опустить return и создать обещание, установленное на отказ, то последующий catch не сможет его обработать:
Promise.resolve()
.then(() => {
Promise.reject('O_o')
})
.catch(() => {
// будет пропущено, потому что не указан return
// будет выброшено UnhandledPromiseRejection
})
Неявное поведение
У обещаний есть две неявные особенности. Во-первых, методы then и catch всегда возвращают новое обещание. Во-вторых, внутри себя они перехватывают любые ошибки и, если что-то пошло не так, возвращают обещание, установленное на отказ, с причиной ошибки.
Возвращение нового обещания
Каждый вызов then или catch создаёт новое обещание, значение которого либо undefined, либо явно выставляется через return.
Благодаря этому, вместо создания временных переменных можно сразу сделать удобную цепочку вызовов:
// можно и так:
const one = Promise.resolve('^')
const two = one.then((value) => {
return value + '_'
})
const three = two.then((value) => {
return value + '^'
})
three.then((value) => {
console.log(value) // ^_^
})
// но так гораздо лучше:
Promise.resolve('^')
.then((value) => {
return value + '_'
})
.then((value) => {
return value + '^'
})
.then((value) => {
console.log(value) // ^_^
})
При этом, если вернуть обещание, его значение распакуется и всё будет работать точно так же.
Promise.resolve()
.then(() => {
return Promise.resolve('^_^')
})
.then((value) => {
console.log(value) // ^_^
})
За счёт этого можно избегать вложенных обещаний и всегда писать код с одним уровнем вложенности:
// можно и так:
Promise.resolve('^')
.then((value) => {
return Promise.resolve(value + '_')
.then((value) => {
return Promise.resolve(value + '^')
.then((value) => {
console.log(value) // ^_^
})
})
})
// но так гораздо лучше:
Promise.resolve('^')
.then((value) => {
return Promise.resolve(value + '_')
})
.then((value) => {
return Promise.resolve(value + '^')
})
.then((value) => {
console.log(value) // ^_^
})
Спрятанный try/catch
Другая особенность обещаний связана с обработкой ошибок. Функции обратных вызовов, которые передаются в методы обещаний, оборачиваются в try/catch. Если что-то пойдёт не так, то try/catch перехватит ошибку и установит её в качестве причины отказа обещания:
Promise.resolve()
.then(() => {
undefined.toString()
})
.catch((error) => {
console.log(error) // TypeError: Cannot read property 'toString' of undefined
})
Это всё равно что самостоятельно написать вот такой код:
Promise.resolve()
.then(() => {
try {
undefined.toString()
} catch (error) {
return Promise.reject(error)
}
})
.catch((error) => {
console.log(error) // TypeError: Cannot read property 'toString' of undefined
})
Таким образом, внутри обещаний можно обойтись без try/catch, потому что обещания сделают это за нас. Главное — потом правильно обработать причину отказа в catch.
Thenable-объекты
Это объекты, в которых есть
then
:const thenable = {
then (fulfill) {
fulfill('@_@')
}
}
Скорее всего, это полифилы обещаний до ES6. Обещания распакуют такие объекты, а затем завернут их в полноценные ES6-обещания. По такому принципу работают resolve, Promise.resolve, then и catch.
Promise.resolve(thenable)
.then((value) => {
console.log(value) // @_@
})
new Promise((resolve) => {
resolve(thenable)
}).then((value) => {
console.log(value) // @_@
})
Promise.resolve()
.then(() => {
return thenable
})
.then((value) => {
console.log(value) // @_@
})
Благодаря этому обеспечивается совместимость с кодом, написанным до ES6:
const awesomeES6Promise = Promise.resolve(thenable)
awesomeES6Promise.then((value) => {
console.log(value) // @_@
})
Есть одна особенность: если в Promise.resolve передать обычное обещание, то оно не распакуется и вернётся без изменений. При этом resolve, then, catch распакуют значение и создадут новое обещание.
const thenable = {
then (fulfill) {
fulfill('@_@')
}
}
const promise = Promise.resolve('@_@')
const resolvedThenable = Promise.resolve(thenable)
const resolvedPromise = Promise.resolve(promise)
console.log(thenable === resolvedThenable) // false
console.log(promise === resolvedPromise) // true
Но самое интересное — это поведение reject и Promise.reject, которые работают совершенно иначе. Если туда передать любой объект, в том числе и обещание, они просто вернут его как причину отказа:
const promise = Promise.resolve('@_@')
Promise.reject(promise)
.catch((value) => {
console.log(value) // Promise {<fulfilled>: "@_@"}
})
Статические методы
У обещаний есть шесть полезных статических методов. Два из них мы уже разобрали — это Promise.resolve и Promise.reject. Давайте посмотрим на другие четыре.
Для наглядности напишем функцию, которая поможет получать установленное обещание через какое-то время:
const setPromise = (value, ms, isRejected = false) =>
new Promise((resolve, reject) =>
setTimeout(() => isRejected ? reject(value) : resolve(value), ms))
Все четыре метода, которые мы разберём ниже, принимают массив значений. Но каждый из этих методов работает по своему, и результаты они возвращают разные.
Promise.all
Этот вызов возвращает массив значений или первый отказ:
Promise.all([
setPromise('^_^', 400),
setPromise('^_^', 200),
]).then((result) => {
console.log(result) // [ "^_^", "^_^" ]
})
Если хотя бы одно обещание завершится неудачей, то вместо массива значений в catch прилетит причина отказа:
Promise.all([
setPromise('^_^', 400),
setPromise('O_o', 200, true),
]).catch((error) => {
console.log(error) // O_o
})
Для пустого массива сразу вернётся пустой результат:
Promise.all([])
.then((result) => {
console.log(result) // []
})
Promise.race
Этот метод возвращает первое значение или первый отказ:
Promise.race([
setPromise('^_^', 100),
setPromise('O_o', 200, true),
]).then((result) => {
console.log(result) // ^_^
})
Если отказ произойдёт первым, то race установится на отказ:
Promise.race([
setPromise('^_^', 400),
setPromise('O_o', 200, true),
]).catch((error) => {
console.log(error) // O_o
})
Если передать в Promise.race пустой массив, то обращение зависнет в состоянии выполнения и не будет установлено ни на выполнение, ни на отказ:
Promise.race([])
.then(() => {
console.log('resolve не выполнится никогда')
}).catch(() => {
console.log('reject тоже')
})
Promise.any
Вызов возвращает первое значение или массив с причинами отказа, если ни одно обещание не завершилось успешно:
Promise.any([
setPromise('^_^', 400),
setPromise('O_o', 200, true),
]).then((result) => {
console.log(result) // ^_^
})
Когда все обещания установятся на отказ, то any вернёт объект ошибки, в котором информацию об отказах можно будет достать из поля errors:
Promise.any([
setPromise('O_o', 400, true),
setPromise('O_o', 200, true),
]).catch((result) => {
console.log(result.message) // All promises were rejected
console.log(result.errors) // [ "O_o", "O_o" ]
})
Для пустого массива вернётся ошибка:
Promise.any([])
.catch((error) => {
console.log(error.message) // All promises were rejected
})
Promise.allSettled
Метод дождётся выполнения всех обещаний и вернёт массив специальных объектов:
Promise.allSettled([
setPromise('^_^', 400),
setPromise('O_o', 200, true),
]).then(([resolved, rejected]) => {
console.log(resolved) // { status: "fulfilled", value: "^_^" }
console.log(rejected) // { status: "rejected", reason: "O_o" }
})
Для пустого массива сразу вернётся пустой результат:
Promise.allSettled([])
.then((result) => {
console.log(result) // []
})
Промисификация
Когда требуется перейти от функций обратного вызова к обещаниям, на помощь приходит промисификация — специальная функция-помощник, которая превращает функцию, работающую с callback, в функцию, которая возвращает обещание:
function promisify (fn) {
return function (...args) {
return new Promise((resolve, reject) => {
function callback(error, result) {
return error ? reject(error) : resolve(result)
}
fn(...args, callback)
})
}
}
function asyncApi (url, callback) {
// ... выполнить асинхронную операцию
callback(null, '^_^')
}
promisify(asyncApi)('/url')
.then((result) => {
console.log(result) // ^_^
})
Работа промисификации зависит от сигнатуры функций в коде, потому что требуется учитывать порядок аргументов, а также параметры callback. В примере выше предполагается, что функция обратного вызова передаётся последним аргументом, а в качестве параметров сначала принимает ошибку, а затем результат.
Обещания или функции обратного вызова?
Обещания устраняют недостатки функций обратного вызова. Они всегда асинхронные и одноразовые, код линейный и не обладает жётской сцепленностью, можно не переживать за ад обратных вызовов.
Но как быть, если обещание всё-таки зависло и не устанавливается ни на выполнение, ни на отказ? В этом случае можно использовать Promise.race, чтобы прервать выполнение зависшего или очень длинного запроса по таймауту:
Promise.race([
fetchLongRequest(),
new Promise((_, reject) => setTimeout(reject, 3000)),
]).then((result) => {
// получили данные
}).catch((error) => {
// или отказ по таймеру
})
В любом случае важно понимать: несмотря на многие преимущества обещаний, всё равно кое-где придётся использовать функции обратного вызова, и это нормально. Через них работают обработчики событий и многие асинхронные методы API, например setTimeout, поэтому в таких случаях нет смысла промисифицировать и удобнее использовать функции обратного вызова. В конце концов, они нам также понадобятся, чтобы создать обещание. Главное запомнить, что, если где-то есть цепочка последовательных вызовов, там обязательно надо использовать обещания.
Корутины
Обещания — это фундамент для работы с асинхронностью, но над этим фундаментом есть очень удобная надстройка async/await, которая реализуется благодаря корутинам.
Корутина (coroutine, cooperative concurrently executed routine) — это сопрограмма или, проще говоря, особая функция, которая может приостанавливать свою работу и запоминать состояние, а также имеет несколько точек входа и выхода.
В JavaScript в качестве корутин выступают функции-генераторы, которые возвращают итератор. Итератор может приостанавливать свою работу, запоминать текущее состояние и взаимодействовать с внешним кодом через .next и .throw.
Благодаря таким возможностям корутин, можно написать специальную функцию следующего вида:
function async (generator) {
const iterator = generator()
function handle({ done, value }) {
return done ? value : Promise.resolve(value)
.then((x) => handle(iterator.next(x)))
.catch((e) => handle(iterator.throw(e)))
}
return handle(iterator.next())
}
И затем через неё последовательно вызывать асинхронные операции:
async(function* () {
const response = yield fetch('example.com')
const json = yield response.json()
// обработать json
})
Это оказалось настолько удобно, что позже в JavaScript добавили конструкции async/await.
Async/await
Это синтаксический сахар, реализованный через обещания и корутины, который делает работу с асинхронными операциями более удобной.
Модификатор async ставится перед функцией и делает её асинхронной:
async function asyncFunction () {
return '^_^'
}
Результат асинхронной функции — это всегда обещание. Для удобства можно представлять асинхронную функцию как обычную, которая заворачивает свой результат в обещание:
function asyncFunction () {
return Promise.resovle('^_^')
}
Результат асинхронной функции извлекается через then или через await:
asyncFunction().then((value) => {
console.log(value) // ^_^
})
(async () => {
const value = await asyncFunction()
console.log(value) // ^_^
})()
Await немного удобнее обещаний, но у него есть серьёзное ограничение — его можно вызывать только внутри асинхронной функции. При этом асинхронность становится «прилипчивой» — стоит один раз написать асинхронный вызов, и дороги назад, в синхронный мир, уже нет. Какое-то время с этим ничего нельзя было сделать, но потом появился верхнеуровневый await.
Верхнеуровневый await и асинхронные модули
Верхнеуровневый await (top level await) позволяет использовать этот оператор за пределами асинхронных функций:
const connection = await dbConnector()
const jQuery = await import('http://cdn.com/jquery')
Но его можно использовать только либо внутри ES6-модулей, либо в DevTools. Такое ограничение связано с тем, что await — это синтаксический сахар, который работает через модули.
Например, модуль с верхнеуровневым await для разработчика выглядит так:
// module.mjs
const value =
await Promise.resolve('^_^')
export { value }
// main.mjs
import {
value
} from './module.mjs'
console.log(value) // ^_^
Ни внутри модуля, ни внутри основной программы нет асинхронных функций, но при этом они необходимы для работы await. Как же тогда работает этот код?
Дело в том, что движок сам возьмёт на себя задачу обернуть await в асинхронную функцию, поэтому где-то «под капотом», без синтаксического сахара, верхнеуровневый await будет выглядеть примерно так:
// module.mjs
export let value
export const promise = (async () => {
value = await Promise.resolve('^_^')
})()
export { value, promise }
// main.mjs
import {
value,
promise
} from './module.mjs'
(async () => {
await promise
console.log(value) // ^_^
})()
Никакой магии. Просто синтаксический сахар.
Обработка ошибок
Есть два способа обработать ошибку внутри асинхронных функций. Первый — поставить catch после вызова функции, второй — обернуть вызов await в конструкцию try/catch.
Поскольку асинхронные функции — это обещания, после вызова можно поставить catch и обработать ошибку.
async function asyncFunction () {
await Promise.reject('O_o')
}
asyncFunction()
.then((value) => {
// вдруг ошибка?
})
.catch((error) => {
// тогда ошибку поймает catch
})
Такой способ будет работать, но, скорее всего, try/catch подойдёт лучше, потому что даст возможность обработать исключение сразу в теле функции:
async function asyncFunction () {
try {
await Promise.reject('O_o')
} catch (error) {
// перехватить ошибку
}
}
asyncFunction().then((value) => {
// мы в безопасности
})
Другое важное преимущество: в отличие от catch, конструкция try/catch сможет обработать верхнеуровневый await.
Не все await одинаково полезны
Оператор await приостанавливает выполнение задачи до завершения асинхронной операции. Если перед каждой асинхронной операцией бездумно ставить await, это может привести к неоптимальной работе кода из-за последовательной загрузки несвязанных данных.
Например, список статей и картинок, которые никак друг с другом не связаны, через await можно было бы загрузить вот так:
const articles = await fetchArticles()
const pictures = await fetchPictures()
// ... какие-то действия с articles и pictures
Тогда данные, которые можно было бы получить параллельно, будут запрашиваться последовательно. До тех пор пока первая часть данных не загрузится полностью, работа со второй частью не начнётся. Из-за этого задача будет выполняться дольше, чем могла бы. Допустим, каждый запрос выполняется за две секунды, тогда на полную загрузку данных уйдёт четыре секунды. Но если данные грузить параллельно, через Promise.all, то вся информация загрузится в два раза быстрее:
const [articles, pictures] = await Promise.all([
fetchArticles(),
fetchPictures(),
])
Заключение
Вот и всё, что вы, вероятно, хотели знать про асинхронность в браузере. Надеюсь, теперь вы хорошо понимаете, как действует цикл событий, сможете выбраться из ада обратных вызовов, легко работаете с обещаниями и грамотно используете async/await. Если я что-то забыл, напоминайте в комментариях.
Комментарии (21)
fransua
00.00.0000 00:00+3Мне кажется, для полного понимания не хватает освещения некоторых тем: какие способы обновления UI не зависят от загрузки основного потока и будут работать даже при запуске бесконечного цикла
while(true) { }
, transition, animation, video?
Или, например, какой цикл событий в WebWorker и зачем там requestAnimationFrame и почему нет requestIdleCallback?devenji
00.00.0000 00:00Есть статья про это?
fransua
00.00.0000 00:00Не знаю, не видел. Мне кажется при бесконечном цикле не будет работать css или video, но можно обновлять UI через
OffscreenCanvas
из воркера. Для него видимо и нуженrequestAnimationFrame
, но как он там работает точно не могу сказать. И проrequestIdleCallback
не знаю.
vasyakolobok77
00.00.0000 00:00-1Это скорее всего вкусовщина, но в современном коде async / await наше все, т.к. цепочки вызовов promise.then недалеко ушли от callback ада. Тут и обработка ошибок проще, и код короче и понятней.
demimurych
00.00.0000 00:00-1callback hell существует только в головах тех юных JavaScript-еров, которые пока еще не познакомились с композицией функций или не поняли к чему она может привести.
С того момента, как JS получил возможность оперировать функциями как объектами первого класса (то есть с момента своего рождения), ответственность за появление в коде call back hell лежит исключительно на совести программиста, но не языка.
Под спойлером простой пример того, о чем сказано выше:
Однажды заявленные два типа:
_typeThing
и_typeDo
выполняют все необходимые задачи для решения любых вопросов как с асинхронным так и с синхронным кодом, без необходимости использования async await.Более того, этот пример кода абсолютно работоспособен как на JS спецификации 1997 года, так и современной. И будет ровно таким же рабочим и в 2050 году.
При этом модификацией всего двух типов, мы можем добавить любые новые возможности языка, без изменения бизнес логики.
(()=>{ /* ******************************************* * Define types * *******************************************/ var _typeThing = ( ( theThing ) => ( ( doThen ) => ( ( theThing ?. then ) && theThing.then( doThen ) || doThen( theThing ) ) ) ); var _typeDo = ( ( doThing ) => ( ( _theThing ) => ( _typeThing( _theThing( doThing ) ) ) ) ); /* ******************************************* * Define helpers * *******************************************/ var doLog = ( ( ...theParamList ) => ( ( console.log( ...theParamList ) ) , theParamList[ theParamList.length - 1 ] ) ); var doJsonParse = ( ( theJsonStr ) => ( JSON.parse( theJsonStr ) ) ); var doDelayedJsonParse = ( ( theJsonStr ) => ( new Promise( (doRes, doRej) =>( setTimeout( ()=>doRes(JSON.parse( theJsonStr ) ),2000 ) ) ) ) ); var doGetTitle = ( ( theObj ) => ( theObj["title"] ) ) var _typeDoTitleLog = ( ( theLogTitle ) => _typeDo( ( theThing )=>doLog(theLogTitle, theThing) ) ); var _doJsonParse = _typeDo( doJsonParse ); var _doDelayedJsonParse = _typeDo( doDelayedJsonParse ); var _doGetTitle = _typeDo( doGetTitle ); /* ******************************************* * init data * *******************************************/ var _theJsonStr = _typeThing(`{"title":"Simple json title"}`); var _theFetchedJsonStr = _typeThing( fetch('https://dummyjson.com/products/1') .then(res => res.text()) ); /* ******************************************* * Main thread * *******************************************/ var _theDelayedTitle = _doGetTitle(_doDelayedJsonParse( _theJsonStr )); var _theTitle = _doGetTitle(_doJsonParse( _theFetchedJsonStr )); var _theFetchedTitleDelayedJson = _doGetTitle(_doDelayedJsonParse( _theFetchedJsonStr )); _typeDoTitleLog("_theDelayedTitle: ")( _theDelayedTitle ); _typeDoTitleLog("_theTitle: ")( _theTitle ); _typeDoTitleLog("_theFetchedTitleDelayedJson")(_theFetchedTitleDelayedJson ); })()
Использование async /await показано только в тех случаях, когда программист не умеет использовать все преимущества асинхронности или в случае, когда асинхронность не может принести никаких преимуществ. Но не как абсолютная его замена в любых случаях.
demimurych
00.00.0000 00:00+5На мой взгляд, всем подобным материалам не хватает обязательного обозначения того, на какую часть официальной спецификации (и какой именно) опирается автор декларируя те или иные части своего материала. Отдельно важно обозначать тот материал, который представляет из себя авторские вставки или интерпретации известного ему материала.
В силу того, что в опубликованном материале ничего подобного нет, становится совершенно невозможно разобраться - где данные которые отвечают современной (какой-либо) спецификации, а где рыбу заворачивали.Под спойлером я дал себе труд, обозначить, самые важные части.
Цикл событий
Описываемый цикл событий, относится к спецификации HTML5 Event Loops. Описание процесса выполнения кода, относится к спецификации ECMA части Execution Context
таким образом получается эдакий стек вызовов.
Таким образом получается не эдакий стек вызовов, а Execution context stack описанный в официально спецификации ECMA
Если же мы грузим скрипты асинхронно через async или defer, то велика вероятность, что до загрузки JavaScript браузер успеет отрисовать интерфейс пользователя.
Атрибут defer оказывает влияние только на classic script, при этом выполнение этих скриптов произойдет после окончания парсинга страницы и строго в той последовательности в которой они объявлены (хотя загрузка их может происходить в любом порядке). Очевидно, что в этот момент никаких гарантий отрисованного интерфейса пользователя быть не может.
Атрибут defer не оказывает никакого влияния на загружаемые модули -module script.
Classic script и module script с атрибутом async ведут себя одинаково и будут выполнены ровно в тот момент, когда они будут загружены - вне привязки к порядку объявления или событиям формирования контента.
При этом парсинг, что для скриптов с defer, что для скриптов с async будет выполнен в отдельном треде.
Внутри стека вызовов будет выполняться как код, написанный разработчиком, так и код, встроенный по умолчанию и отвечающий за взаимодействие со страницей.
Правильнее сказать, что это будет зависит строго от host implementation
Стек вызовов будет выполнять встроенные скрипты даже тогда, когда мы запретим в браузере JavaScript. Для примера можно открыть пустую страницу about:blank без JavaScript, выполнить несколько кликов и увидеть, что стек вызовов выполнил код, отвечающий за обработку событий.
Открыв about:blank , Вы не запретили выполнение JavaScript. Вы инициализировали Realm, к которому ни один script не был подключен. Соответственно host среда работает с JavaScript ровно точно так же, как если бы хотя бы один скрипт был загружен. Чтобы отследить поведение с отключенным JS вам нужно нажать ctrl (cmd для apple) +p, ввести команду >disable javascript нажать enter. После чего проводить свои эксперименты. Включить - соответственно ctrl(cmd) +p >enable javascript.
Задачи, тики и Web API
В этом разделе вы смешали в кучу Jobs - как часть спецификации Ecma, tasks - как часть Event loop стандарта HTML5 и тиков - как часть спецификации Node
Все это совершенно разные - лишь косвенно пересекающиеся области. Никаких Тиков или Task-ов из NodeJs или Tasko-ов из HTML5 в JavaScript НЕТ.
Поэтому ваше утверждение: "Задача — это JavaScript-код, который выполняется в стеке вызовов" - cправедливо для спецификации ECMA и только в случае если Вы имеете ввиду Job.
Ваше утверждение: "Тик — выполнение задачи в стеке вызовов" справедливо только для выполнения кода в среде NodeJs.Потому ваши заявления про
Методы Web API работают либо синхронно, либо асинхронно: первые выполнятся в текущем тике, а вторые в одном из следующих тиков.
Являются чушью поскольку в стандарте HTML5 для HOSTинга JS runtime как и для описания EventLoop никаких тиков не существует.
В результате ваши описания тиков это - то Job в рамках спецификации Ecma, то Task в рамках спецификации HTML5, то на самом деле tick но только в рамках описания работы NodeJs. Как следствие, Ваше описание имеет больше всего общего с описанием цикла событий в NodeJS. Чуть хуже в браузере. И вообще ничего общего с непосредственной средой выполнения JavaScript.
Очередь задач
Вследствие того, что как спецификация Ecma, так и спецификация HTML5 вместе с NodeJs имеют свою собственную интерпретацию того что такое Задача - вы обязаны указать о чем именно Вы говорите. Особенно с учетом того, что задачи спецификации HTML5 и NodeJs это производные от спецификации ECMA.
Понимая эту концепцию, можно разобрать одну особенность таймеров в JavaScript, которые тоже являются асинхронным API.
Нельзя. Потому, что никаких таймеров в JavaScript нет. SetTimeout - это API предоставляемое в соответствии со спецификацией HTML5, и это API никакого отношения к JavaScript не имеет. В JavaScript вообще не может существовать никаких таймеров или событий, кроме того, что описан в рамках концепции Jobs. Любые реализации EventLoop это host implemented API, которые в зависимости от HOST могут быть совершенно разными.
Но у веб-воркера есть ограничения. Например, внутри нельзя работать с DOM, однако вычислительные задачи будут работать.
То, что Вы назвали ограничением - является следствием спецификации Memory Model, которая прямо связано с работой JS кода в много-поточной среде.
RequestIdelCallBack - эта функция имеет ряд ограничений, из-за которых её удобно использовать только для небольших неприоритетных задач без взаимодействия с DOM, например для отправки аналитических данных. Более подробно о requestIdleCallback можно почитать в материале «Using requestIdleCallback» Пола Льюиса.
Никаких ограничений API RequestIdelCallBack не имеет. Ваша ссылка ведет на mdn где написаны рекомендации по эффективному использованию этого API, а статья Пола только подтверждает это. Совершенно нет никакой разницы где вы будете блокировать свой main thread - в основной ветке кода, или при вызове RequestIdelCallBack. С той лишь разницей, что может быть столкнувшись с RequestIdelCallBack придете наконец к очереди задач и эффективному распределению ресурсов для выполнения ее элементов.
Ад обратных вызовов (callback hell) — самая частая проблема, которую вспоминают, когда говорят про недостатки функций обратного вызова.
Ад обратных вызовов существует только в голове программистов, которые не знают что такое композиция функций и как можно писать последовательно код вне зависимости от того асинхронный он или синхронный.
Простые наглядные примеры этого комментарием Выше.
Не выпускайте Залго
Эта проблема того же порядка что и call back hell, кода программист еще не научился дифференцировать системный код с прикладным.
В результате путается между логикой, которая формирует системный порядок выполнения его кода, и бизнес логикой, которая не должна от него зависеть.Promise
Обещания создаются через конструктор Promise, который обязательно вызывается через new.
Ничего подобного. Допускаются все возможные для JS варианты вызова конструктора: new, super, reflect и т.д.
Reflect.construct( Promise , [ ( (doRes, doRej)=> doRes(10) ) ] )
Внутри обещания вызывается асинхронная операция с функцией обратного вызова.
Ничего подобного. Внутри конструктора вызывается executor синхронно. Причем в случае возврата из Executor-а значения при помощи throw автоматически приводит к вызову Reject связанных методов.
Если где-то произойдёт ошибка, то отказ пропустит обработчики на выполнение и долетит до ближайшего обработчика отказа, после чего цепочка продолжит работать в штатном режиме:
Никаких пробросов и прочих фантазий на эту тему не произойдет. Каждый последующий then catch или finally будет создавать новый Promise с флагом, о том, что Reject ситуация не обработана, что приведет к генерации сообщений в рамках host implementation. Chrome даст сообщение на каждую новую цепочку.
var theRejectedPromise = Promise.reject("Err"); var theRejectedPromise2 = theRejectedPromise.then( ()=>{}); var theRejectedPromise3 = theRejectedPromise2.then( ()=>{} ); setTimeout( ()=> { theRejectedPromise3 .catch( (theRes)=> { console.log("catch", theRes) } ) }, 2000)
Важно понимать, что обработка ошибок работает только тогда, когда цепочка непрерывна. Если опустить return и создать обещание, установленное на отказ, то последующий catch не сможет его обработать:
Вы пишите чушь. И пишите ее по двум причинам:
1 - Вы никогда не читали спецификацию
2 - В своем примере Вы забыли указать return перед Promise.rejectPromise.resolve() .then(() => { Promise.reject('O_o') }) .catch(() => { })
В результате чего вернули undefined, но не Rejected Promise. Как следствие -catch у Вас и не отработал.
У обещаний есть две неявные особенности. Во-первых, методы then и catch всегда возвращают новое обещание.
Все методы: then catch finally всегда возвращают новый Promise. Вне зависимости от наличия или отсутствия обработчиков. Вне зависимости от наличия return или throw.
Каждый вызов then или catch создаёт новое обещание, значение которого либо undefined, либо явно выставляется через return.
Не до конца верно. Значение нового Promise в случае handler catch, можно изменить посредством throw.
Promise.reject("Err").catch( ()=>{ throw "new Err"} ); // Uncaught (in promise) new Err
Другая особенность обещаний связана с обработкой ошибок. Функции обратных вызовов, которые передаются в методы обещаний, оборачиваются в try/catch.
Опять нет. Особенность Promise в том, что они всегда возвращают Promise, вне зависимости от того, каким образом завершился Executor. Разница только в том, что если Executor завершился через throw, а не через return ( вы же знаете что throw делает обычный возврат из функции?) то Promise будет отклонен (Rejected) со значением из throw
Thenable-объекты [...] Скорее всего, это полифилы обещаний до ES6.
Нет. Дело все в том, что в JS нет ни единой возможности точно установить - объект который перед нами действительно тот, который был создан при помощи конструктора Promise или нет. По этой причине любой thenable object ( объект который имеет метод then) дефакто приравнивается к Promise и принимает полноправное участие во всех алгоритмах связанных с разрешением Promis -ов. От методов Promise.all до асинхронных итераторов.
У обещаний есть шесть полезных статических методов. Два из них мы уже разобрали — это Promise.resolve и Promise.reject. Давайте посмотрим на другие четыре.
Все они принимают либо thenable Object либо Primitive value, которое автоматически оборачивается в Promise.resolve
Все четыре метода, которые мы разберём ниже, принимают массив значений. Но каждый из этих методов работает по своему, и результаты они возвращают разные.
Нет не массив значений. Они принимают любой itarable object.
Вместо ИГОГО
Характер материала сильно меняется, как только мы, нашим словам, пытаемся найти подтверждение в официальной спецификации. Это дает возможность как систематизировать то, что отвечает реальности (например стандарт EventLoop из спецификации HTML5), так и узнать для себя много нового (например как в рамках спецификации организована работа с промисами)
mayorovp
00.00.0000 00:00-1Никаких пробросов и прочих фантазий на эту тему не произойдет. Каждый последующий then catch или finally будет создавать [...]
Причём тут пробросы и фантазии?
Утверждается простой факт, что в цепочке
A.then(B).then(C).catch(D).then(E)
при возникновении ошибки в B вызов C будет пропущен, D вызван, после чего управление перейдёт к E в обычном режиме.Что там происходит внутри или в случае параллельных цепочек — это уже детали.
Вы пишите чушь. И пишите ее по двум причинам: [...] В своем примере Вы забыли указать return перед Promise.reject
Нет, это вы читать не умеете. Вы же сами привели цитату — "если опустить return и создать обещание, установленное на отказ, то последующий catch не сможет его обработать". Автор намеренно его опустил и показал что будет в таком случае.
Все методы: then catch finally всегда возвращают новый Promise. Вне зависимости от наличия или отсутствия обработчиков. Вне зависимости от наличия return или throw.
А автор что написал?
вы же знаете что throw делает обычный возврат из функции
Ну уж нет, throw делает throw completion, а обычный возврат — это return completion.
Нет. Дело все в том, что в JS нет ни единой возможности точно установить — объект который перед нами действительно тот, который был создан при помощи конструктора Promise или нет. [...]
А вот тут уже вы чушь пишете. Для движка установить как объект был создан вообще не проблема, к примеру с массивами он как-то справляется (см. array exotic object и метод Array.isArray).
Протокол Thenable был добавлен для совместимости со сторонними реализациями промисов, он пришёл в стандарт языка из стандарта Promises/A+.
rqdkmndh
00.00.0000 00:00+2Молоток Мурыч! Дави их спецификациями, дави! А то скоро совсем только нейросетью думать будут. Спасибо за ссылки на нужные разделы.
Opaspap
00.00.0000 00:00Навскидку не нашел, тут написано где нибудь явно, что обработчики событий запускаются синхронно? Как то долго пришлось в гитхабе доказывать, что это так и есть.
demimurych
00.00.0000 00:00События кого и где? Если речь идет о браузере, который реализует стандарт html5, то процесс обработки события описан в официальной спецификации стандарта HTML5
Opaspap
00.00.0000 00:00Я имел ввиду неочевидный для многих момент, что код который триггерит события, выполнит листенеры немедленно и синхронно
Hidden text
const element = document.querySelector('.pingMe'); let syncTest = 'async'; element.addEventListener('pingEvent', (e) => { console.log("I'm sync!", e.detail.a); syncTest = 'sync'; e.preventDefault(); }); setTimeout(()=>{ const event = new CustomEvent('pingEvent', {cancelable: true, detail: {a: 1}}); console.log('before dispatch', syncTest, event.defaultPrevented); element.dispatchEvent(event); console.log('after dispatch', syncTest, event.defaultPrevented); }, 1000);
mayorovp
00.00.0000 00:00+1А, вот вы про что… Тут только спецификацию HTML смотреть. Вот вам нужный раздел: 2.7. Interface EventTarget
Для полного понимания что происходит можно уйти вглубь в раздел 2.9. Dispatching events и не уивдеть никаких намёков на асинхронность.
Ну а чтобы просто убедиться что всё синхронно, достаточно увидеть в разделе ненормативного описания что метод dispatchEvent "returns true if either event’s cancelable attribute value is false or its preventDefault() method was not invoked; otherwise false". Разумеется. вернуть булево значение метод может только при синхронном выполнении.
Opaspap
00.00.0000 00:00Да, я понимаю как это все работает :) этож вроде статья про подводные камни при работе с асинхронными процедурами в js. Вот и спросил есть там ремарка про это или нет, . вскользь посмотре Я допер просто от понимания, что preventDefault() по другому и не сделать.
mayorovp
00.00.0000 00:00Пункт 9.5 спецификации языка Jobs and Host Operations to Enqueue Jobs явно требует от хоста следующего:
- Only one Job may be actively undergoing evaluation at any point in time.
- Once evaluation of a Job starts, it must run to completion before evaluation of any other Job starts.
Hemml
00.00.0000 00:00Отличная статья, но в ней не описан один момент, связанный с синхронными XHR. Я знаю, что они obsleted, но иногда без них нельзя обойтись. Так вот, в процессе ожидания данных от xhr, браузер вполне может выполнять другой код. Например, если в это время придет сообщение через вебсокет, то обработчик будет вызван, не дожидаясь, пока отработает задача, висящая в ожидании xhr. По крайней мере, в FF. Когда пишешь на JS, внутренне полагаешься на то, что код строго однопоточный, но, как миниумум функции, где есть синхронные xhr, надо рассчитывать на реентерабельность.
Когда-то давно я видел даже библиотечку, которая реализовывала кооперативную многозадачность через внутренние xhr :)
yuriyg_ua
00.00.0000 00:00Я конечно жутко извиняюсь, но статья называется "Полное понимание асинхронности в браузере". Зачем добавлять "в браузере", когда статья про асихронность в ДжаваСкрипте? Еще и есть раздел про Нод.жс.
А что касается браузера - то автор смешно пишет про очередь событий в браузере и сайте, ничего говоря про окна, табы, и ифреймы. Мы точно прочитали статью о программировании, где точность важна, как бы? Или это перевод?
Хотя это так, мелочные придирки, но мне кажется, если уж пытаться написать такой гайд, который вполне хорош, то надо уж его точнее называть и точнее рассказывать об именно внутренней специфике браузера, если уж это заявлено в заголовке.
Opaspap
00.00.0000 00:00+1А да, есть ещё смешное поведение, если один инстанс сайта открыл local storage (просто записав туда значение) и ушел в длинный синхронный цикл, то второй инстанс (соседний, независимый таб) при обращении к localStorage повиснет ожидая lock сторэджа.
shsv382
00.00.0000 00:00Спасибо автору за статью, было интересно и познавательно, некоторые вещи узнал впервые, некоторые освежил в памяти!
З.ы. ну почему на Хабре даже под полезными статьями всегда куча г@&на в комментариях??? ????
ShashkovS
Отличная статья, спасибо! Из нового узнал только про requestIdleCallback, но читалось легко (только «обещание» вместо «промиса» немного резало глаз).
Я бы ещё добавил про то, когда начинает исполняться код из промиса. В JS это совсем не так, как, скажем, в Python. В JS функция, переданная промису, начинает исполняться немедленно, без попадания в список таксов или микротасков. В отличие от кода в
then
, который выполнится в микротаксе даже в том случае, если код внутри промиса зарезолвится сразу, синхронно.alexprozoroff
Присоединяюсь к вышесказанному про терминологию. Силами Веб-стандартов собран классный словарик терминов фронтенда: https://github.com/web-standards-ru/dictionary/blob/main/dictionary.md#promises. Промисы они и есть промисы. Статья отличная!