← назад к разделу

Статья Асинхронность и event loop в TypeScript объясняет основу: один поток, неблокирующий ввод-вывод, очередь задач. Здесь — браузерная специфика: макрозадачи против микрозадач, рендер между тиками и что происходит, когда задача слишком длинная.

Call stack, task queue и event loop

Браузер выполняет JavaScript в одном потоке. Всё, что запущено прямо сейчас, лежит в call stack (стеке вызовов). Когда стек пуст, event loop берёт следующую задачу из task queue (очереди задач).

console.log("1"); // синхронно — сразу в стек

setTimeout(() => console.log("3"), 0); // в очередь — после "2"

console.log("2"); // синхронно — в стек

// Вывод: 1, 2, 3

setTimeout с задержкой 0 не значит «немедленно» — значит «не раньше, чем освободится стек и отработает текущий тик».

Макрозадачи и микрозадачи

Event loop поддерживает две очереди, и порядок их обработки разный.

Макрозадачи (task queue): setTimeout, setInterval, события DOM, fetch-колбэки, postMessage. После каждой макрозадачи браузер может сделать рендер.

Микрозадачи (microtask queue): Promise.then/catch/finally, queueMicrotask, MutationObserver. Микрозадачи выполняются сразу после текущей задачи, до следующей макрозадачи и до рендера.

console.log("1");

setTimeout(() => console.log("4"), 0); // макрозадача

Promise.resolve()
  .then(() => console.log("2"))  // микрозадача
  .then(() => console.log("3")); // ещё микрозадача

// Вывод: 1, 2, 3, 4

Разбор: "1" — синхронный код. Затем стек пуст: браузер сливает все микрозадачи ("2", "3"), и только потом берёт макрозадачу ("4").

Голодание: когда микрозадачи не дают рендеру дышать

Если из микрозадачи бесконечно порождать новые — рендер никогда не наступит. Браузер не может вставить кадр до опустошения microtask queue.

function flood() {
  Promise.resolve().then(flood); // новая микрозадача из микрозадачи
}
flood(); // страница зависнет — рендер заблокирован

queueMicrotask — это не инструмент для «отложить на чуть позже»; он быстрее setTimeout, но без уступки рендеру.

Где рисует браузер

Цикл выглядит так:

  1. Выполнить одну макрозадачу.
  2. Слить все микрозадачи до дна.
  3. (Опционально) Если пришло время кадра (~16 мс) — запустить requestAnimationFrame-колбэки и перерисовать страницу.
  4. Вернуться к шагу 1.

requestAnimationFrame стоит перед рендером, но после микрозадач текущего тика — это правильное место для анимаций, потому что код выполнится непосредственно перед отрисовкой кадра.

requestAnimationFrame(() => {
  // здесь изменять DOM для анимации — без промежуточного кадра
  element.style.transform = `translateX(${x}px)`;
});

Длинные задачи блокируют UI

Если макрозадача выполняется дольше ~50 мс — браузер не успевает рисовать кадры (60 fps = 16 мс на кадр), страница «лагает», клики перестают отзываться.

// Плохо: блокирует поток на сотни миллисекунд
function processHuge(items) {
  items.forEach(heavyWork);
}

Разбивка на чанки через setTimeout — стандартный приём. После каждого чанка браузер получает шанс отрисовать кадр и обработать события.

function processInChunks(items, chunkSize = 100) {
  let i = 0;
  function step() {
    const end = Math.min(i + chunkSize, items.length);
    for (; i < end; i++) heavyWork(items[i]);
    if (i < items.length) setTimeout(step, 0); // уступаем loop
  }
  step();
}

Современный вариант — scheduler.yield() (Chrome 115+) или scheduler.postTask(): планировщик сам выбирает, когда уступить, с учётом приоритетов.

Практические следствия для React

Батчинг обновлений. React 18 автоматически объединяет несколько setState в один рендер, даже внутри setTimeout или Promise.then. До React 18 батчинг работал только внутри синхронных обработчиков.

// React 18 — оба setState дадут один ре-рендер
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
}, 0);

Почему setState «асинхронный». setState не обновляет state мгновенно. Обновление ставится в очередь и применяется после завершения текущего обработчика события — это и есть батчинг. После коммита React обновляет DOM, и браузер рисует следующий кадр.

useEffect и микрозадачи. Колбэки useEffect запускаются после рендера, асинхронно (как макрозадача). useLayoutEffect — синхронно после DOM-мутации, до рендера кадра. Именно поэтому useLayoutEffect нужен для измерения DOM: в момент его выполнения браузер ещё не нарисовал кадр.

Ловушка: что выведет код

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve().then(() => {
  console.log("C");
  setTimeout(() => console.log("D"), 0);
});

console.log("E");

// Вывод: A, E, C, B, D

Разбор: A, E — синхронно. Стек пуст → микрозадачи: C + регистрация второго setTimeout. Затем макрозадачи по порядку: B, D.

Коротко

  • Браузер выполняет JavaScript в одном потоке: пока стек занят — ни рендера, ни событий.
  • Event loop: одна макрозадача → все микрозадачи → (опционально) рендер кадра → следующая макрозадача.
  • Микрозадачи (Promise.then, queueMicrotask) выполняются до следующей макрозадачи и до рендера.
  • Бесконечная цепочка микрозадач голодит рендер — страница зависает.
  • requestAnimationFrame запускается перед рендером кадра — правильное место для анимаций.
  • Задачи дольше ~50 мс блокируют UI; разбивают через setTimeout(step, 0) или scheduler.yield().
  • React 18 батчит setState даже внутри setTimeout; useLayoutEffect работает до рендера кадра.

Что почитать дальше

  • Асинхронность и event loop в TypeScript — вводная: однопоточность, Promise, async/await.
  • this: контекст вызова — почему this теряется в колбэках setTimeout и addEventListener.
  • События DOM: всплытие, погружение, делегирование — как события попадают в task queue.
  • Производительность рендеринга — как длинные задачи влияют на Core Web Vitals.