Предлагаю вашему вниманию перевод статьи «Tasks, microtasks, queues and schedules» Джейка Арчибальда (Jake Achibald), занимающего должность Developer Advocate for Google Chrome.
Когда я сказал своему коллеге Мэту Ганту, что подумываю о написании статьи об очерёдности микрозадач и порядке их исполнения внутри событийного цикла браузера, он сказал «Джейк, буду честен, я об этом читать не стану». Что ж, я всё же написал, поэтому откиньтесь на спинку кресла и давайте вместе в этом разберёмся, ладно?
На самом деле, если вам будет проще посмотреть видео, есть замечательное выступление Филиппа Робертса на JSConf, которое рассказывает о событийном цикле – оно не покрывает микрозадачи, но в остальном является отличным вступлением в тему. В любом случае, погнали…
Давайте рассмотрим следующий код на JavaScript:
Как вы думаете, в каком порядке должны вывестись логи?
Верный ответ:
Microsoft Edge, Firefox 40, iOS Safari и настольный Safari 8.0.8 логируют
Для более точного понимания процесса нужно сначала представить как событийный цикл обрабатывает задачи и микрозадачи. На первый раз это может показаться слишком сложным. Глубокий вдох…
Каждый «поток» имеет собственный событийный цикл, а значит и каждый веб-воркер, так что они могут выполняться независимо, тогда как все окна с одного домена (по правилу same origin) делят между собой один и тот же событийный цикл, ведь они могут синхронно коммуницировать между собой. Событийный цикл работает постоянно, исполняя поставленные в очередь задачи. Задачи выполняются последовательно и не могут пересекаться. Ладно-ладно, не уходите…
Задачи планируются таким образом чтобы браузер мог из их дебрей ступить на землю JavaScript/DOM и быть уверенным что эти действия происходят поочерёдно. Обработка колбека события щелчка мыши требует планирования задачи, так же как и разбор HTML и
Микрозадачи обычно планируются для вещей, который должны исполняться моментально после текущего исполняемого сценария. Например, реагирование на пачку действий или для того, чтобы сделать что-то асинхронно без необходимости терять производительность на пустом месте из-за полностью новой задачи. Очередь микрозадач развёртывается в конце каждой полной задачи, а также после колбеков в случае если никакой другой JavaScript не находится на стадии исполнения. Любые дополнительные микрозадачи, поставленные в очередь во время развёртывания очереди микрозадач, добавляются в конец очереди и тоже обрабатываются. Микрозадачи включают в себя колбеки Mutation observer и промисов, как в примере выше.
Как только промис решается или если он уже был решён, он ставит в очередь микрозадачу на исполнение колбека. Это даёт уверенность, что колбеки промисов исполняются асинхронно даже если они уже решены. Итак, вызов
Прим. переводчика: в этом месте у автора в оригинале вставлена великолепная наглядная презентация работы планироващика JavaScript, однако повторить это на Хабре у меня едва ли имеется техническая возможность, за сим отправляю любознательного читателя на страницу оригинала.
Да, я и правда сделал пошаговую анимированную диаграмму. Как вы провели свою субботу, наверняка гуляли где-то на свежем воздухе с друзьями? Что ж, я – нет. На случай, если что-то не ясно в моём обалденном UI, попробуйте пощёлкать стрелочки вправо-влево.
Они выводят в журнал
Хотя таким образом мы и делаем предположение что реализация верна, единственный способ – тестировать. Смотреть порядок вывода журнала относительно промисов и
Точный способ – посмотреть спецификацию. Например,
В мире ECMAScript микрозадачи именуют заданиями («jobs»). На
Теперь давайте взглянем на более комплексный пример. В зале кто-то сконфужено вскрикнет «Нет, они не готовы!». Не обращайте внимания, вы готовы.
Следующая задачка могла бы показаться мне сложной до того как я написал этот пост. Вот небольшой кусок HTML:
Рассуждая логически, что выведет в журнал следующий JavaScript код если я щёлкну
Попробуйте подумать прежде чем перейдёте к ответу. Подсказка: логи могут выводиться больше раза.
Прим. переводчика: у автора в этом месте в блоге есть интерактивный DOM элемент (прямая ссылка) на котором можно воочию проверить поведение вашего браузера.
Вы думали будет иначе? Спешу вас успокоить, возможно вы были правы. К сожалению, у разных браузеров разная степень приятия этого мнения:
Обработка события «click» это задача. Колбеки Mutation observer и промиса ставятся в очередь как микрозадачи. Колбек
Так что правильно ведёт себя Chrome. Для меня в новость было узнать что микрозадачи развёртываются после колбеков (если только это не часть выполнения другого сценария JavaScript), я думал что их развёртывание ограничено лишь окончанием выполнения задачи. Это правило описано в спецификации HTML по вызову колбеков:
Firefox и Safari верно опустошают очередь микрозадач между обработчиками щелчков, как видно по колбекам мутации, но промисы ставятся в очередь иначе. Это можно было бы простить, особенно учитывая туманность связи между заданием («jobs») и микрозадачей, однако я ожидал что они выполнятся между обработчиками. Заявка на Firefox. Заявка на Safari.
Мы уже поняли, что Edge ставит промисы в очередь неверно, но он также не стал опустошать очередь микрозадач между обработчиками щелчков, вместо этого очередь развернулась лишь после вызова всех обработчиков, что объясняет единственный вывод
Блин! А что если к предыдущему примеру добавить:
Событие начнёт обрабатываться точно так же как и до этого, но посредством вызова из сценария, а не от реального взаимодействия пользователя.
Прим. переводчика: в оригинале тут ещё одна интерактивная площадка, где можно нажать кнопку и узнать правильный ответ для своего браузера (ссылка прямая).
И я не перестаю получать различные результаты в Chrome, я уже сто раз обновлял эту таблицу думая что до этого по ошибке проверял в Canary. Если у вас в Chrome другие результаты, скажите мне в комментариях на какой вы версии.
Прим. переводчика: в этом месте ещё один последний раз автор даёт нам возможность насладиться визуализацией чудес инженерной мысли браузеростроителей (ссылка, опять-таки, прямая).
Итак, правильный порядок следующий:
После того как каждый из обработчиков щелчка вызван…
Ещё бы, это будет съедать вас изнутри (уф). Я столкнулся с этим когда попытался создать лаконичную обёртку над IndexedDB, использующую промисы вместо ужасных объектов IDBRequest. С ней IDB почти стал мне приятен.
Когда в IDB срабатывает событие успешности, объект транзакции становится неактивным после передачи управления (шаг 4). Если я создам промис, который решается во время возбуждения этого события, обработчики должны бы исполниться до шага 4 пока транзакция ещё активна, однако этого не происходит ни в одном браузере кроме Chrome, из-за чего библиотека становится как бы бесполезной.
В Firefox с этим можно справиться, ведь полифилы промисов, такие как es6-promise, используют Mutation observers для колбеков, которые есть не что иное как микрозадачи. Safari при этом исправлении вступает в состояние гонки, но дело, скорее всего, в их поломанной реализации IDB. К сожалению IE/Edge на данный момент не подлежит исправлению, так как события мутаций не происходят после колбеков.
Остаётся лишь надеяться что в этом вопросе мы когда-то сможем наблюдать взаимозаменяемость.
В заключение:
Здесь кто-нибудь остался? Алё?! Алё?
Когда я сказал своему коллеге Мэту Ганту, что подумываю о написании статьи об очерёдности микрозадач и порядке их исполнения внутри событийного цикла браузера, он сказал «Джейк, буду честен, я об этом читать не стану». Что ж, я всё же написал, поэтому откиньтесь на спинку кресла и давайте вместе в этом разберёмся, ладно?
На самом деле, если вам будет проще посмотреть видео, есть замечательное выступление Филиппа Робертса на JSConf, которое рассказывает о событийном цикле – оно не покрывает микрозадачи, но в остальном является отличным вступлением в тему. В любом случае, погнали…
Давайте рассмотрим следующий код на JavaScript:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
Как вы думаете, в каком порядке должны вывестись логи?
Верный ответ:
script start
, script end
, promise1
, promise2
и setTimeout
, однако покамест порядок в разных браузерах довольно часто различен.Microsoft Edge, Firefox 40, iOS Safari и настольный Safari 8.0.8 логируют
setTimeout
перед promise1
и promise2
. Что действительно странно, ибо Firefox 39 и Safari 8.0.7 работали верно.Почему так происходит
Для более точного понимания процесса нужно сначала представить как событийный цикл обрабатывает задачи и микрозадачи. На первый раз это может показаться слишком сложным. Глубокий вдох…
Каждый «поток» имеет собственный событийный цикл, а значит и каждый веб-воркер, так что они могут выполняться независимо, тогда как все окна с одного домена (по правилу same origin) делят между собой один и тот же событийный цикл, ведь они могут синхронно коммуницировать между собой. Событийный цикл работает постоянно, исполняя поставленные в очередь задачи. Задачи выполняются последовательно и не могут пересекаться. Ладно-ладно, не уходите…
Задачи планируются таким образом чтобы браузер мог из их дебрей ступить на землю JavaScript/DOM и быть уверенным что эти действия происходят поочерёдно. Обработка колбека события щелчка мыши требует планирования задачи, так же как и разбор HTML и
setTimeout
из примера выше.setTimeout
ждёт заданной отсрочки и затем планирует новую задачу для своего колбека. Поэтому setTimeout
выводится в лог после script end
, так как логирование script end
является частью первой задачи, а вывод слова setTimeout
– второй. Наберитесь терпения, мы почти у цели, впереди самое интересное…Микрозадачи обычно планируются для вещей, который должны исполняться моментально после текущего исполняемого сценария. Например, реагирование на пачку действий или для того, чтобы сделать что-то асинхронно без необходимости терять производительность на пустом месте из-за полностью новой задачи. Очередь микрозадач развёртывается в конце каждой полной задачи, а также после колбеков в случае если никакой другой JavaScript не находится на стадии исполнения. Любые дополнительные микрозадачи, поставленные в очередь во время развёртывания очереди микрозадач, добавляются в конец очереди и тоже обрабатываются. Микрозадачи включают в себя колбеки Mutation observer и промисов, как в примере выше.
Как только промис решается или если он уже был решён, он ставит в очередь микрозадачу на исполнение колбека. Это даёт уверенность, что колбеки промисов исполняются асинхронно даже если они уже решены. Итак, вызов
.then(func)
у решённого промиса немедленно ставит в очередь микрозадачу. Вот почему promise1
и promise2
выводятся в журнал после script end
, ведь текущий исполняемый сценарий должен завершиться до того как начнут обрабатываться микрозадачи. promise1
и promise2
выводятся в журнал до setTimeout
ибо микрозадачи всегда развёртываются до следующей большой задачи.Прим. переводчика: в этом месте у автора в оригинале вставлена великолепная наглядная презентация работы планироващика JavaScript, однако повторить это на Хабре у меня едва ли имеется техническая возможность, за сим отправляю любознательного читателя на страницу оригинала.
Да, я и правда сделал пошаговую анимированную диаграмму. Как вы провели свою субботу, наверняка гуляли где-то на свежем воздухе с друзьями? Что ж, я – нет. На случай, если что-то не ясно в моём обалденном UI, попробуйте пощёлкать стрелочки вправо-влево.
Что неправильно в некоторых браузерах?
Они выводят в журнал
script start
, script end
, setTimeout
, promise1
и promise2
. Колбеки промисов исполняются после оных setTimeout
. Похоже, для колбеков промисов заводится целая отдельная задача вместо простой микрозадачки. Такое поведение может привезти к проблемам с производительностью при использовании промисов, ведь колбеки могут незаслуженно откладываться до выполнения рендеринга и прочих относящихся к большой задаче вещей. Вот заявки на исправление аномалии в Edge и Firefox (прим. переводчика: к моменту написания перевода в заявке для Firefox выяснилось, что от неожиданного поведения страдают только 40-я и 41-я версии, а начиная с 42-й аномалия не воспроизводится). Ночные сборки WebKit ведут себя как положено, поэтому я предполагаю что вскоре и Safari вновь вернётся на путь праведный.Как понять когда используются задачи, а когда – микрозадачи
Хотя таким образом мы и делаем предположение что реализация верна, единственный способ – тестировать. Смотреть порядок вывода журнала относительно промисов и
setTimeout
.Точный способ – посмотреть спецификацию. Например,
шаг 14 setTimeout
ставит в очередь задачу, тогда как в спецификации фиксирования мутации шаг 5 создаёт микрозадачу.В мире ECMAScript микрозадачи именуют заданиями («jobs»). На
шаге 8.a спецификации PerformPromiseThen
для постановки микрозадачи в очередь вызывается EnqueueJob
. К сожалению, покамест нет явного отношения между заданиями («jobs») и микрозадачами, однако в одной из рассылок es-discuss упоминалось что они должны использовать общую очередь.Теперь давайте взглянем на более комплексный пример. В зале кто-то сконфужено вскрикнет «Нет, они не готовы!». Не обращайте внимания, вы готовы.
Первый уровень: Схватка с Боссом
Следующая задачка могла бы показаться мне сложной до того как я написал этот пост. Вот небольшой кусок HTML:
<div class="outer">
<div class="inner"></div>
</div>
Рассуждая логически, что выведет в журнал следующий JavaScript код если я щёлкну
div.inner
?// Придержим ссылки на эти элементы
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');
// Послушаем изменения атрибутов внешнего
// элемента с классом outer
new MutationObserver(function() {
console.log('mutate');
}).observe(outer, {
attributes: true
});
// А вот и колбек…
function onClick() {
console.log('click');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise');
});
outer.setAttribute('data-random', Math.random());
}
// …который мы повесим на оба элемента
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
Попробуйте подумать прежде чем перейдёте к ответу. Подсказка: логи могут выводиться больше раза.
Испытание
Прим. переводчика: у автора в этом месте в блоге есть интерактивный DOM элемент (прямая ссылка) на котором можно воочию проверить поведение вашего браузера.
Вы думали будет иначе? Спешу вас успокоить, возможно вы были правы. К сожалению, у разных браузеров разная степень приятия этого мнения:
|
|
|
|
Кто прав?
Обработка события «click» это задача. Колбеки Mutation observer и промиса ставятся в очередь как микрозадачи. Колбек
setTimeout
это задача. (Прим. переводчика: тут снова интерактивная диаграмма, поясняющая пошагово принцип работы приведённого ранее кода, рекомендую взглянуть.)Так что правильно ведёт себя Chrome. Для меня в новость было узнать что микрозадачи развёртываются после колбеков (если только это не часть выполнения другого сценария JavaScript), я думал что их развёртывание ограничено лишь окончанием выполнения задачи. Это правило описано в спецификации HTML по вызову колбеков:
If the stack of script settings objects is now empty, perform a microtask checkpoint…а чекпойнт микрозадач означает не что иное кроме развёртывания очереди микрозадач, если только мы уже не развёртываем очередь микрозадач. А вот что нам говорит спецификация ECMAScript о заданиях («jobs»):
— HTML: Cleaning up after a callback, шаг 3
Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty……хотя «can be» в контексте HTML носит характер «must be», т.е. «обязан».
— ECMAScript: Jobs and Job Queues
Что недопоняли браузеры?
Firefox и Safari верно опустошают очередь микрозадач между обработчиками щелчков, как видно по колбекам мутации, но промисы ставятся в очередь иначе. Это можно было бы простить, особенно учитывая туманность связи между заданием («jobs») и микрозадачей, однако я ожидал что они выполнятся между обработчиками. Заявка на Firefox. Заявка на Safari.
Мы уже поняли, что Edge ставит промисы в очередь неверно, но он также не стал опустошать очередь микрозадач между обработчиками щелчков, вместо этого очередь развернулась лишь после вызова всех обработчиков, что объясняет единственный вывод
mutate
после обоих click
в журнале. Это ошибка.Злой брат Босса с Первого уровня
Блин! А что если к предыдущему примеру добавить:
inner.click();
Событие начнёт обрабатываться точно так же как и до этого, но посредством вызова из сценария, а не от реального взаимодействия пользователя.
Испытание
Прим. переводчика: в оригинале тут ещё одна интерактивная площадка, где можно нажать кнопку и узнать правильный ответ для своего браузера (ссылка прямая).
|
|
|
|
Почему теперь по-другому?
Прим. переводчика: в этом месте ещё один последний раз автор даёт нам возможность насладиться визуализацией чудес инженерной мысли браузеростроителей (ссылка, опять-таки, прямая).
Итак, правильный порядок следующий:
click
, click
, promise
, mutate
, promise
, timeout
и последний timeout
, что, похоже, означает что Chrome работает корректно.После того как каждый из обработчиков щелчка вызван…
If the stack of script settings objects is now empty, perform a microtask checkpointРанее это означало что микрозадачи будут выполнены между обработчиками щелчка, однако явный
— HTML: Cleaning up after a callback, шаг 3
.click()
происходит синхронно, так что сценарий, который вызвал .click()
между обработчиками щелчка всё ещё будет в стеке. Приведённое правило удостоверяет, что микрозадачи не прерывают выполнение JavaScript-кода. Это означает, что очередь микрозадач не будет развёрнута до тех пор, пока все обработчики не выполнятся; очередь до микрозадач дойдёт лишь после всех обработчиков событий.Разве это важно?
Ещё бы, это будет съедать вас изнутри (уф). Я столкнулся с этим когда попытался создать лаконичную обёртку над IndexedDB, использующую промисы вместо ужасных объектов IDBRequest. С ней IDB почти стал мне приятен.
Когда в IDB срабатывает событие успешности, объект транзакции становится неактивным после передачи управления (шаг 4). Если я создам промис, который решается во время возбуждения этого события, обработчики должны бы исполниться до шага 4 пока транзакция ещё активна, однако этого не происходит ни в одном браузере кроме Chrome, из-за чего библиотека становится как бы бесполезной.
В Firefox с этим можно справиться, ведь полифилы промисов, такие как es6-promise, используют Mutation observers для колбеков, которые есть не что иное как микрозадачи. Safari при этом исправлении вступает в состояние гонки, но дело, скорее всего, в их поломанной реализации IDB. К сожалению IE/Edge на данный момент не подлежит исправлению, так как события мутаций не происходят после колбеков.
Остаётся лишь надеяться что в этом вопросе мы когда-то сможем наблюдать взаимозаменяемость.
Мы сделали это!
В заключение:
- Задачи исполняются по порядку и браузер может рендерить в промежутках между ними
- Микрозадачи исполняются по порядку и исполняются:
- после каждого колбека, если только это не часть выполнения какого-то другого сценария
- в конце каждой задачи
Здесь кто-нибудь остался? Алё?! Алё?