Почему одна бесконечная рекурсия вешает вкладку, а другая нет: event loop, микро- и макрозадачи, rAF
Бесконечный
Все три колбэка бесконечные, все три через очередь. Убивает вкладку только первая
Event loop сам по себе ничего не выполняет. Он диспетчер: смотрит, освободился ли стек вызовов, и если да, то берёт из очереди следующий колбэк и кладёт туда. Очередей у него несколько, и разбирает он их по-разному. Вот в этом «по-разному» весь ответ
Порядок одного прохода
Микрозадачи исполняются до конца. Условие «очередь пуста» диспетчер проверяет перед каждым шагом, поэтому микрозадача, которая по ходу дела положила в очередь ещё одну, успевает в тот же самый проход.
У этой ненасытности есть причина: Промисы и
Macrotask берётся ровно одна. Одна - и диспетчер свободен.
Честного нуля в
Кадр браузер собирает заранее. Список колбэков копируется до того, как браузер начнёт их выполнять. Всё, что зарегистрируешь через
К кадру привязаны и наблюдатели. Сразу после колбэков rAF браузер пересчитывает стили и layout и тут же раздаёт колбэки
Как дробить тяжёлую работу
Если функция считает что-то долго и вкладка на это время подвисает, работу надо разрезать на куски и между кусками отдавать управление браузеру - чтобы он успел отрисовать кадр и обработать клики.
Рекурсия на
Инструментов два, и выбор между ними - это вопрос «когда доделать»:
-
-
Красиво тут то, что получается симметрия с rAF. Все три - способ сказать «разбуди меня в нужный момент», просто моменты разные: rAF ловит «пора рисовать кадр», idle callback - «дел больше нет», таймер - «прошло столько-то миллисекунд».
Мои заметки по теме;
HTML Standard: event loops
#js
Бесконечный
.then() замораживает вкладку. Бесконечный requestAnimationFrame крутится сутками, и браузеру норм:function a() { Promise.resolve().then(a) }
function b() { requestAnimationFrame(b) }
function c() { setTimeout(c, 0) }Все три колбэка бесконечные, все три через очередь. Убивает вкладку только первая
Event loop сам по себе ничего не выполняет. Он диспетчер: смотрит, освободился ли стек вызовов, и если да, то берёт из очереди следующий колбэк и кладёт туда. Очередей у него несколько, и разбирает он их по-разному. Вот в этом «по-разному» весь ответ
Порядок одного прохода
синхронный код
→ микрозадачи (.then, await, queueMicrotask, MutationObserver) - все до единой
→ кадр: rAF → стили и layout → ResizeObserver → IntersectionObserver → отрисовка
→ ровно одна задача (setTimeout, клик, сеть)
→ и заново
Микрозадачи исполняются до конца. Условие «очередь пуста» диспетчер проверяет перед каждым шагом, поэтому микрозадача, которая по ходу дела положила в очередь ещё одну, успевает в тот же самый проход.
a кладёт следующую a, та - следующую, и поэтому очередь никогда не опустошится. До кадра и до клика дело просто не доходит: вкладка заморозиласьУ этой ненасытности есть причина: Промисы и
await должны исполняться как единый непрерывный блок относительно остального кода - иначе между шагами await во вкладку просачивались бы чужие события и рендер с промежуточным, ещё не готовым состоянием. Атомарность микрозадач - то, на чём держится вся семантика async/awaitqueueMicrotask(fn) кладёт колбэк в ту же самую очередь, что и .then(). Разница в том, что Promise.resolve().then(fn) создаёт лишний промис ради побочного эффекта, а queueMicrotask говорит то же самое напрямую
Macrotask берётся ровно одна. Одна - и диспетчер свободен.
c плодит новые точно так же бесконечно, но между двумя c браузер успевает все свои дела, поэтому вкладка живаяЧестного нуля в
setTimeout(c, 0) при этом нет. Глубже пятого уровня вложенности браузер начинает подставлять минимальную задержку около 4 мс - защита ровно от такого рекурсивного злоупотребления таймером. c крутится с этим поломКадр браузер собирает заранее. Список колбэков копируется до того, как браузер начнёт их выполнять. Всё, что зарегистрируешь через
requestAnimationFrame уже внутри этого прохода, физически в него не попадёт - уедет в следующий кадр. Поэтому рекурсия через rAF и есть штатный способ анимировать: она сама себя режет по кадрам. Мне эта часть нравится больше всегоК кадру привязаны и наблюдатели. Сразу после колбэков rAF браузер пересчитывает стили и layout и тут же раздаёт колбэки
ResizeObserver, а перед самой отрисовкой обновляет наблюдения IntersectionObserver. Колбэки intersection не лежат в общей очереди задач рядом с кликом и таймером - их ставит сама фаза кадра, отдельным шагом после ResizeObserverКак дробить тяжёлую работу
Если функция считает что-то долго и вкладка на это время подвисает, работу надо разрезать на куски и между кусками отдавать управление браузеру - чтобы он успел отрисовать кадр и обработать клики.
Рекурсия на
.then() для этого не подходит вообще: она управление не отдаёт, а забивает микроочередь и держит браузер до последнего. Резать нужно через очередь задач - там между кусками у браузера есть окно.Инструментов два, и выбор между ними - это вопрос «когда доделать»:
-
setTimeout(fn, 0) - если остаток работы нужен скоро и ждать нельзя-
requestIdleCallback(fn) - если он терпит и может подождать момента, когда браузеру нечем занятьсяКрасиво тут то, что получается симметрия с rAF. Все три - способ сказать «разбуди меня в нужный момент», просто моменты разные: rAF ловит «пора рисовать кадр», idle callback - «дел больше нет», таймер - «прошло столько-то миллисекунд».
п.с. Цикл на микрозадачах держится только на условии остановки внутри самого колбэка, снаружи его не подстрахует никто. У задач и у rAF страховка вшита в саму очередь - это граница прохода.
Мои заметки по теме;
HTML Standard: event loops
#js
FAUSTZE
Event Loop, Microtasks, Macrotasks
⏱ Event Loop, микротаски, макротаски, requestAnimationFrame 💡 Основная идея Event Loop не выполняет код.