awesome-everything EN
↑ Обратно к восхождению

Браузер и фронтенд-рантайм

Задачи, микрозадачи и scheduler.yield()

Суть Модель обработки HTML-спецификации — источники задач, 5-шаговая итерация, как ввод попадает в цикл и как правильно уступать без потери рендеринга.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 16 min

Вы пишете await Promise.resolve(), чтобы «уступить» внутри длинного цикла. Браузер всё равно зависает. Пробуете setTimeout(fn, 0). Теперь мигает странно. Проблема не в коде — вы просто не знаете, в какую очередь попадает каждый примитив.

Модель HTML-спецификации: источники задач и очереди задач

HTML-стандарт (§ event loops) определяет event loop как поток с набором очередей задач. Каждая задача связана с источником задач — типом породившей её работы (манипуляции с DOM, пользовательские взаимодействия, сеть, навигация по истории). Каждый источник имеет собственную очередь, и пользовательский агент выбирает одну очередь за итерацию, вытягивает старейшую задачу и исполняет её до конца без прерываний. Когда задача завершается, агент выполняет microtask checkpoint, затем опционально шаг рендеринга, затем возвращается в начало.

Микрозадачи vs задачи: решающее правило

Микрозадача ставится в очередь: Promise.then, Promise.catch, Promise.finally, queueMicrotask, MutationObserver и await (синтаксический сахар над Promise.then).

Задача ставится в очередь: setTimeout, setInterval, MessageChannel.postMessage, сетевые ответы, отправленные DOM-события, распарсенные куски HTML.

Решающее операционное правило: микрозадачи дренируются до пустой между каждой задачей и каждым кадром. Если микрозадача планирует ещё одну микрозадачу, обе выполняются до того, как цикл перейдёт дальше. Именно поэтому Promise.resolve().then(self) в тесной цепочке вешает страницу: очередь микрозадач никогда не пуста, цикл никогда не доходит до шага 4, и браузер никогда не рисует.

Что куда попадает
Promise.then / queueMicrotask
очередь микрозадач
setTimeout / setInterval
очередь задач (источник таймеров)
MessageChannel.postMessage
очередь задач (источник сообщений)
DOM-событие click
очередь задач (источник пользовательского ввода)
Колбэк MutationObserver
очередь микрозадач
scheduler.yield()
очередь задач (возобновляется как следующая задача)

queueMicrotask vs Promise.resolve().then vs MessageChannel

Три способа «запланировать что-то скоро», каждый с разной семантикой:

  • queueMicrotask(fn) — ставит микрозадачу напрямую. Выполняется после текущей задачи, до следующей. Рендеринга между ними нет.
  • Promise.resolve().then(fn) — идентична queueMicrotask по времени, но аллоцирует Promise, замыкание и элемент цепочки. Чуть медленнее, по сути то же самое. Рендереру не уступает.
  • new MessageChannel().port1.postMessage(...) — ставит задачу. Выполняется после текущей задачи, после дренажа микрозадач, возможно после кадра. Уступает рендереру.

Это важно, когда нужно уступить рендерингу: микрозадачи не уступают; MessageChannel — уступает. Современный код использует scheduler.yield() (Chrome 115+) для явных точек уступки.

Где находится requestAnimationFrame

Колбэки rAF выполняются на шаге 4 цикла, непосредственно перед стилями/компоновкой/отрисовкой. Браузер входит в шаг 4 только когда решает, что кадр пора показать — обычно каждые 16,67 мс при 60 Гц. Если длинная задача пропустила несколько дедлайнов кадра, браузер не ставит несколько rAF-колбэков в очередь; он запускает rAF-набор ровно один раз. Вот почему длинная задача всегда выглядит как один пропущенный кадр: rAF управляется бюджетом рендеринга, а не таймером.

Где находится ввод

События ввода (mousemove, click, keydown, touchstart) ставятся в очередь как задачи потоком ввода браузера (отдельный поток ОС). Главный поток вытягивает их на шаге 1 как любую задачу. Прерываний нет: если ваша JS-задача выполняется 80 мс, а клик пришёл на отметке 20 мс, клик ждёт 60 мс до запуска обработчика. INP измеряет именно эту задержку: когда пользователь кликнул, когда следующая отрисовка отразила результат. INP < 200 мс p75 считается «хорошим». Достичь этого значит не допускать задач длиннее примерно 50 мс.

Как DOM-событие становится задачей

Когда пользователь кликает, поток ввода браузера записывает цель попадания и ставит единственную задачу в очередь задач источника пользовательских взаимодействий главного потока. Когда главный поток вытягивает эту задачу, он выполняет полный диспатч события за один раз: фаза захвата вниз к цели, затем сама цель, затем фаза всплытия обратно вверх. Все совпадающие слушатели вдоль этого пути выполняются синхронно внутри этой одной задачи — между ними нет точек уступки. Именно поэтому медленный слушатель на высоком предке (обработчик mousemove на document) нагружает каждое взаимодействие с потомком. Опция passive: true важна для scroll, wheel и touch: она обещает, что слушатель не вызовет preventDefault(), позволяя браузеру начать прокрутку на композиторе без ожидания окончания задачи диспатча.

Уступка внутри длинной задачи

Если нужно выполнить 200 мс работы, разбейте её на куски ≤50 мс, разделённые точками уступки, чтобы ввод мог вклиниваться. Три техники:

  1. setTimeout(fn, 0) — ставит задачу примерно через 4 мс (зажато); грубо, но универсально.
  2. MessageChannel с postMessage — ставит задачу немедленно, без зажима. Именно это использует React 18 внутри для time-slicing.
  3. scheduler.yield() (Chrome 115+) — обещает точку уступки, которая позволяет запустить ввод и рендеринг, затем возобновляет ту же логическую задачу с приоритетом. Современная рекомендация: используйте scheduler.yield(), при недоступности — setTimeout(0). Не используйте await Promise.resolve() для уступки — это ставит микрозадачу, которая не позволяет запустить рендерер.
Викторина

`setTimeout(fn, 0)` и `queueMicrotask(fn)` оба «планируют что-то скоро». В чём операционная разница?

Викторина

Тело цикла: `await fetch(url)`. Почему страница всё равно рендерится между итерациями, хотя цикл никогда не возвращает управление?

Проследи
1/4

DevTools Performance показывает «длинную задачу» 320 мс на обработчике поля поиска. INP страницы — 410 мс p75. Обработчик делает: парсит ввод → fetch подсказок → JSON.parse ответа → ре-рендер дропдауна. Где время?

1
Step 1 of 4
JSON.parse большого ответа — наиболее вероятный виновник: синхронный на главном потоке, линейно растёт с объёмом байт
2
Locked
fetch — узкое место: задержка сети
3
Locked
React-ре-рендер дропдауна — узкое место
4
Locked
Таймаут сети с откатом
Викторина

Нужно разбить задачу 200 мс на куски по 50 мс, чтобы ввод мог вклиниваться. Какая техника реально позволяет браузеру обрабатывать ввод между кусками?

Викторина

Worker вызывает `postMessage(data)` в главный поток. data — Float32Array 5 МБ. Что является доминирующей стоимостью?

Расставь шаги по порядку

Click-обработчик выполняет синхронный код, затем `Promise.resolve().then(...)`, затем `setTimeout(..., 0)`, затем ещё синхронный код. Расставьте колбэки в порядке их реального выполнения.

  1. 1 Синхронный код в начале обработчика
  2. 2 Синхронный код в конце обработчика (перед return)
  3. 3 Колбэк Promise.then (микрозадача)
  4. 4 Колбэк setTimeout (задача)
Закончи аналогию

Когда два потока (главный и воркер) обмениваются данными, сообщения пересекаются через очередь задач, а не очередь микрозадач. Значит, нижняя граница задержки туда-обратно — одно переключение задачи в каждую сторону. Как это переключение обычно называют в обсуждениях производительности?

Выбери лучший вариант

Нужно запланировать код «после этого синхронного блока, как можно скорее». Какой примитив?

Посчитай

Колбэк `setTimeout(fn, 0)` после 5-го уровня вложенности зажимается до минимума. По HTML-спецификации этот минимум (в мс) равен:

мс
Вспомните перед уходом
  1. 01
    Объясните, почему `await Promise.resolve()` не «уступает браузеру», хотя и приостанавливает вызывающую функцию.
  2. 02
    Воркер отправляет 60 сообщений в секунду в главный поток, каждое несёт небольшой JSON. Почему это всё равно вызывает джанк главного потока, даже если каждое сообщение крошечное?
  3. 03
    Почему медленный слушатель событий на `document` (например, обёртка аналитики для mousemove) нагружает каждое взаимодействие с потомком?
Итог

HTML-спецификация определяет несколько очередей задач — по одной на источник задач — и браузер выбирает одну очередь за итерацию. Микрозадачи, напротив, живут в едином checkpoint, который дренируется до пустой после каждой задачи и до каждого кадра. Три примитива планирования имеют разную семантику: queueMicrotask работает до рендеринга, setTimeout(0)/MessageChannel — после рендеринга (уступка на уровне задач), а scheduler.yield() — структурированная уступка уровня задач с сохранением приоритета. События ввода попадают в очередь задач из потока ввода ОС; задача длиной 80 мс задерживает пришедший в середине клик на всю оставшуюся длительность. Опция passive на слушателях scroll и touch позволяет потоку композитора начать немедленно вместо ожидания задачи диспатча — конкретный выигрыш для отзывчивости скролла без изменения JS-логики.

Связанные уроки
встречается в267
Продолжить восхождение ↑Точность таймеров, троттлинг и фоновая работа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.