Frontend Interview: браузерный пайплайн, Promises, генераторы и производительность рендеринга

Курс системно разбирает путь от ввода URL до завершения рендеринга, а также ключевые механизмы JavaScript (event loop, промисы, генераторы). Отдельный фокус — рендеринг в браузере: reflow/layout, repaint, compositing и практические триггеры в JS/CSS, влияющие на производительность.

От URL до первого байта: DNS, TCP/TLS, HTTP, кеши и Service Worker

Подготовка к собеседованию в Яндекс: Browser Pipeline, Promises и Генераторы

На технических интервью в крупные компании (как Яндекс, Avito, Тинькофф) требуют глубокого понимания того, как работает платформа. Вас спросят не только о том, как написать код, но и о том, какой ценой он выполняется. В этой статье мы разберем полный путь работы браузера, механизмы рендеринга и внутреннее устройство асинхронности в JavaScript.

Часть 1. Browser Pipeline: От URL до пикселей

Этот процесс делится на два больших этапа: сетевой (доставка ресурсов) и рендеринг (превращение кода в картинку).

1. Сетевой этап (Network)

Когда пользователь вводит URL и нажимает Enter, браузер выполняет следующие шаги:

  1. Разбор URL и проверка кешей. Браузер проверяет HSTS (нужен ли HTTPS), Service Worker и HTTP-кеш (Disk/Memory). Если ресурс свежий, сеть не используется.
  2. DNS (Domain Name System). Если кеша нет, браузер должен узнать IP-адрес сервера. Запрос идет от локального кеша к DNS-серверу провайдера и далее рекурсивно.
  3. Установка соединения (TCP/TLS).
    • TCP Handshake: Обмен пакетами SYNSYN-ACKACK для создания надежного канала.
    • TLS Handshake: Обмен сертификатами и ключами шифрования. В TLS 1.3 это происходит быстрее (1 RTT), но это все равно затратная операция.
  4. HTTP-запрос и ответ. Отправляется запрос (GET). Сервер думает (Time To First Byte — TTFB) и начинает отдавать HTML.

2. Построение объектных моделей (Parsing)

Как только браузер получает первые байты HTML, он начинает парсинг, не дожидаясь загрузки всего документа.

  1. HTML → DOM (Document Object Model).
    • Байты → Символы → Токены (теги) → Узлы (Nodes) → DOM-дерево.
    • Если парсер встречает <script>, он блокирует построение DOM (если нет атрибутов async или defer), загружает и выполняет JS.
  2. CSS → CSSOM (CSS Object Model).
    • Браузер загружает стили и строит дерево правил. CSSOM блокирует рендеринг: браузер не покажет контент, пока не поймет, как его стилизовать.

3. Render Tree

Браузер объединяет DOM и CSSOM в Render Tree (дерево рендеринга). В него попадают только видимые элементы.

  • Элементы с display: none не попадают в Render Tree.
  • Элементы с visibility: hidden попадают (они занимают место, просто прозрачные).
  • Псевдоэлементы (::before, ::after) добавляются сюда.

4. Layout (Reflow) — Компоновка

На этом этапе браузер рассчитывает геометрию каждого элемента: точные координаты (x,yx, y) и размеры (width,heightwidth, height) в пикселях экрана. Процесс идет рекурсивно от <html> вглубь.

Понятие Reflow (перекомпоновка) означает повторный запуск этого процесса при изменениях.

5. Paint (Repaint) — Отрисовка

Браузер заполняет пиксели: цвета, фоны, тени, границы, текст. Отрисовка часто происходит на нескольких слоях.

Понятие Repaint (перерисовка) означает обновление внешнего вида без изменения геометрии.

6. Composite — Композиция

Финальный этап. Браузер собирает все слои в итоговое изображение на экране. Этот этап выполняется на GPU (видеокарте), что делает его очень быстрым.

Часть 2. Reflow, Repaint и Composite: Детальный разбор

Понимание этих терминов критично для оптимизации производительности (60 FPS).

Reflow (Layout)

Самая дорогая операция. Происходит, когда меняется геометрия страницы или структура дерева.

Что вызывает Reflow:

  • Добавление/удаление DOM-узлов.
  • Изменение размеров окна браузера (resize).
  • Изменение шрифта.
  • Изменение свойств: width, height, padding, margin, border, left, top.
  • Чтение геометрических свойств (Forced Synchronous Layout). Если вы меняете стиль, а потом сразу читаете offsetWidth, браузер обязан прервать JS и срочно пересчитать макет, чтобы дать актуальные данные.

Repaint

Средняя по стоимости операция. Происходит, когда меняется внешний вид, но не геометрия.

Что вызывает Repaint:

  • color, background-color.
  • visibility.
  • box-shadow, outline.

Composite

Самая дешевая операция. Если свойство можно обработать только на этапе композиции, Layout и Paint пропускаются.

Свойства для Composite (GPU):

  • transform (перемещение, масштабирование, вращение).
  • opacity (прозрачность).
  • filter (некоторые фильтры).

Для плавных анимаций всегда используйте transform и opacity. Изменение left/top вызывает Reflow каждый кадр, что нагружает CPU.

Часть 3. Promises (Промисы)

Промис — это объект, представляющий результат асинхронной операции, который будет получен в будущем. Это замена callback-hell.

Внутреннее устройство

У промиса есть три состояния:

  1. Pending (ожидание) — исходное состояние.
  2. Fulfilled (исполнено) — операция завершена успешно, есть результат (value).
  3. Rejected (отклонено) — произошла ошибка, есть причина (reason).

Смена состояния необратима. Из Fulfilled нельзя перейти в Rejected и наоборот.

Microtask Queue

Обработчики .then(), .catch(), .finally() выполняются асинхронно через Microtask Queue. Микрозадачи имеют приоритет перед макрозадачами (setTimeout, I/O) и выполняются сразу после текущего синхронного кода, но до рендеринга.

Пример порядка выполнения:

console.log('1'); // Синхронно

setTimeout(() => console.log('2'), 0); // Макрозадача

Promise.resolve().then(() => console.log('3')); // Микрозадача

console.log('4'); // Синхронно

// Результат: 1, 4, 3, 2

Ключевые методы

  • Promise.all([p1, p2]) — ждет выполнения всех. Если хоть один упал — падает весь all.
  • Promise.allSettled([p1, p2]) — ждет завершения всех (неважно, успех или ошибка). Возвращает массив статусов.
  • Promise.race([p1, p2]) — возвращает результат первого завершившегося (успех или ошибка).
  • Promise.any([p1, p2]) — возвращает первый успешный. Ошибка будет только если все упали.

Часть 4. Генераторы (Generators)

Генераторы — это функции, выполнение которых можно приостанавливать и возобновлять. Они лежат в основе async/await и библиотек вроде Redux-Saga.

Синтаксис и работа

Обозначаются звездочкой function*. При вызове код функции не выполняется сразу, а возвращается объект итератора.

function* numbers() {
  yield 1;
  yield 2;
  return 3;
}

const gen = numbers(); // Получили итератор
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: true }

Ключевое слово yield

yield делает две вещи:

  1. Возвращает значение наружу (как return).
  2. Ставит выполнение функции на паузу, сохраняя локальные переменные.

Двусторонний обмен данными

В генератор можно передавать данные через next(value). Значение попадет в переменную слева от yield, на котором стояла пауза.

function* listener() {
  const input = yield 'Waiting...';
  console.log('Got:', input);
}

const gen = listener();
console.log(gen.next()); // Запуск до первого yield. Вывод: { value: 'Waiting...', done: false }
gen.next('Hello!');      // Возобновление. В input попадет 'Hello!'. Console: Got: Hello!

Это позволяет писать асинхронный код в синхронном стиле, что и реализовано в синтаксическом сахаре async/await (где await — это автоматический yield промиса).

Итоги

  1. Пайплайн браузера: URL → DNS → TCP/TLS → HTTP → Parsing (DOM/CSSOM) → Render Tree → Layout → Paint → Composite.
  2. Оптимизация рендеринга: Избегайте Reflow (изменения геометрии). Для анимаций используйте transform и opacity, так как они вызывают только Composite и обрабатываются на GPU.
  3. Промисы: Управляют асинхронностью через Microtask Queue. Важно знать методы all, race, allSettled, any.
  4. Генераторы: Функции с возможностью паузы (yield). Позволяют создавать итераторы и управлять потоком выполнения, являясь фундаментом для async/await.

Парсинг и выполнение: HTML/CSS/JS, DOM/CSSOM, загрузка ресурсов

Парсинг и выполнение: HTML/CSS/JS, DOM/CSSOM, загрузка ресурсов

Как эта часть пайплайна связана с предыдущей статьёй

В прошлой статье мы дошли до TTFB: браузер получил первый байт HTML, пройдя URL → кеши/Service Worker → DNS → TCP/TLS → HTTP.

Дальше начинается критический путь рендеринга: браузер должен превратить поток байтов HTML/CSS/JS в структуры данных и понять, что и когда можно рисовать.

Эта статья отвечает на вопросы:

  • Как HTML превращается в DOM
  • Как CSS превращается в CSSOM
  • Почему CSS и некоторые скрипты блокируют рендер
  • Как браузер находит и приоритизирует загрузку ресурсов
  • Что означают DOMContentLoaded и load

Рекомендуемая база от платформы: How browsers work

Стриминг HTML и почему «первый байт» важен

HTML обычно приходит потоком, а не целиком. Это позволяет браузеру:

  • Начать парсить документ, не дожидаясь полной загрузки
  • Рано обнаружить критичные ресурсы (CSS, шрифты, JS) и запустить их загрузку

Практический вывод для интервью: улучшение TTFB и ранняя отдача верхней части HTML помогают ускорить не только сеть, но и старт парсинга и обнаружение подресурсов.

HTML-парсер и построение DOM

От байтов к токенам и узлам

Упрощённо браузер делает:

  • Декодирует байты в символы (учитывая кодировку)
  • Превращает поток символов в токены (теги, текст, комментарии)
  • По токенам строит дерево узлов — DOM (Document Object Model)

DOM — это структурированное представление документа, с которым работает JS: document.querySelector, события, изменение узлов.

Инкрементальность DOM

DOM строится по мере поступления HTML. Поэтому:

  • Скрипт, стоящий в середине документа, «видит» только уже распарсенную часть
  • Событие DOMContentLoaded зависит от того, когда HTML закончил парситься (подробнее ниже)

Справочно: Document Object Model (DOM)

CSS: от текста к CSSOM

Что такое CSSOM

CSSOM (CSS Object Model) — дерево/структура, которая представляет стили документа в виде объектов и правил.

Браузер получает CSS из:

  • Внешних файлов через link rel="stylesheet"
  • Встроенных стилей

Справочно: CSSOM

Почему CSS считается render-blocking

Чтобы нарисовать элементы корректно, браузеру нужны стили. Поэтому внешние таблицы стилей (по умолчанию) считаются render-blocking:

  • Браузер может продолжать строить DOM
  • Но первый рендер (и дальнейшие этапы вроде layout/paint) ограничены тем, что стили ещё не готовы

Ключевой нюанс: CSS не блокирует загрузку HTML, но может блокировать первый визуальный результат и выполнение некоторых скриптов, которым нужны вычисленные стили.

Материал по критическому пути: Critical rendering path

Как JavaScript вмешивается в парсинг

Почему обычный script блокирует HTML-парсер

Когда HTML-парсер встречает script без специальных атрибутов, браузер обычно:

  • Останавливает парсинг HTML
  • Догружает скрипт (если он внешний)
  • Выполняет скрипт
  • Продолжает парсинг

Причина простая: скрипт может вызвать document.write, изменить DOM, добавить стили и так далее. Чтобы сохранить корректность, браузер делает выполнение синхронным относительно парсинга.

Справочно: Элемент script

async и defer: что меняется

Главная тема на собеседованиях — как атрибуты скрипта влияют на порядок загрузки/выполнения.

Атрибут Загрузка Выполнение Влияние на парсинг HTML Типичный кейс
без атрибутов во время парсинга, по встрече сразу после загрузки блокирует парсинг критичный код, который должен выполниться немедленно
async параллельно парсингу сразу после загрузки может прервать парсинг в момент готовности аналитика, виджеты, независимые скрипты
defer параллельно парсингу после завершения парсинга HTML парсинг не блокируется основной бандл приложения, работающий с готовым DOM

Важные детали:

  • defer сохраняет порядок выполнения скриптов относительно друг друга (как они идут в документе).
  • async не гарантирует порядок: кто раньше загрузится, тот раньше выполнится.

Module-скрипты

<script type="module"> по поведению ближе к defer: загружается параллельно и выполняется после парсинга (плюс имеет свою модель импортов и строгий режим).

Справочно: JavaScript modules

Взаимозависимость CSS и JS

На практике блокировки часто накладываются:

  • Если скрипт должен вычислить размеры/стили элементов, ему нужен актуальный стиль, а значит CSS должен быть загружен и распарсен.
  • В некоторых браузерных оптимизациях встречается задержка выполнения скриптов, если перед ними есть ещё не загруженные критичные стили, чтобы скрипт не «прочитал» промежуточное состояние.

Практический вывод: если скрипт стоит в head и зависит от стилей, вы легко получите длинный провал до первого контента.

Обнаружение и загрузка ресурсов

Откуда берутся подресурсы

Браузер узнаёт, что нужно загрузить, из нескольких источников:

  • HTML-теги: link, script, img, source, video, audio
  • CSS: @import, url(...) (фоны, шрифты)
  • JS: fetch, import(), создание Image, добавление link в DOM

Порядок обнаружения влияет на скорость: то, что объявлено раньше, обычно раньше попадает в очередь загрузки.

Приоритизация: что грузится раньше

Грубо (и упрощённо) браузер пытается как можно раньше добыть то, что нужно для первого рендера:

  • HTML документа
  • Render-blocking CSS
  • Шрифты (если они влияют на видимый текст)
  • Скрипты, которые блокируют парсер или нужны для ранней инициализации

Но реальный приоритет зависит от:

  • Типа ресурса и контекста (например, img в первом экране vs далеко ниже)
  • Текущей загрузки сети и количества соединений
  • Подсказок разработчика

Preload и другие подсказки

Когда вы знаете, что ресурс понадобится очень рано, можно помочь браузеру.

  • link rel="preload" — сказать: загрузи как можно раньше, это точно понадобится.

  • fetchpriority — подсказать относительную важность, например для изображений.

Важный нюанс для интервью: preload полезен только если вы правильно указали as (тип ресурса) и ресурс действительно используется, иначе вы создаёте конкуренцию за сеть и можете ухудшить загрузку.

DOMContentLoaded и load: что они означают

DOMContentLoaded

DOMContentLoaded срабатывает, когда:

  • HTML распарсен
  • DOM построен
  • Выполнены скрипты, которые должны выполниться до завершения парсинга (например, defer-скрипты)

При этом не обязательно:

  • Загружены картинки
  • Загружены стили фоновых изображений
  • Завершены все сетевые запросы

Справочно: DOMContentLoaded

load

Событие load на window — это более поздняя точка: когда загрузились все зависимые ресурсы страницы (в типичном понимании: стили, изображения, подфреймы и т.д.).

Справочно: Window load event

Практический вывод: измерять “страница загрузилась” по load часто слишком поздно для UX, а вот DOMContentLoaded ближе к моменту, когда приложение может начать работу с DOM.

Типовые ошибки и правильные ментальные модели для собеседования

Ошибка: «CSS блокирует парсинг HTML»

Правильнее:

  • HTML обычно продолжает парситься
  • Но CSS может блокировать первый визуальный рендер и влиять на момент, когда имеет смысл выполнять код, завязанный на стили

Ошибка: «async быстрее defer всегда»

Правильнее:

  • async быстрее доставляет выполнение, но ломает порядок
  • defer сохраняет порядок и обычно лучше подходит для основного бандла приложения

Ошибка: «DOMContentLoaded значит всё загрузилось»

Правильнее:

  • DOM готов, но изображения и другие ресурсы могут догружаться

Как это проверять в DevTools

Мини-набор действий, которые ожидают на интервью:

  • Network → включить Waterfall, посмотреть, какие ресурсы стартуют поздно
  • Performance → увидеть длительные задачи, оценить, чем занят main thread во время парсинга/выполнения
  • Elements/Styles → понять, когда применились стили

Справочно по Performance панели: Analyze runtime performance

Итог

На участке от первого байта HTML до готовности базовых структур браузер:

  • Потоково парсит HTML и строит DOM
  • Загружает и парсит CSS, строит CSSOM
  • Выполняет JavaScript, который может блокировать парсер (обычные script) или выполняться позже (defer) либо независимо (async)
  • Обнаруживает ресурсы из HTML/CSS/JS и приоритизирует их загрузку
  • Сигнализирует ключевыми событиями DOMContentLoaded и load

В следующей части курса логично перейти к тому, как DOM и CSSOM превращаются в пиксели, и где в этом пайплайне возникают reflow, repaint и composite, а также какие операции JS заставляют браузер перерасчитывать геометрию и перерисовывать страницу.

Рендеринг: style, layout(reflow), paint(repaint), compositing и GPU

Рендеринг: style, layout(reflow), paint(repaint), compositing и GPU

Как эта статья продолжает пайплайн из предыдущих частей

В прошлых статьях мы дошли до момента, когда браузер:

  • Получил HTML (TTFB и сетевой слой)
  • Распарсил HTML и построил DOM
  • Загрузил и распарсил CSS и построил CSSOM
  • Выполнил JavaScript и обнаружил ресурсы

Дальше начинается превращение структур данных (DOM и CSSOM) в пиксели на экране. На собеседованиях это обычно спрашивают через термины reflow/layout, repaint/paint, composite и через практику: какие свойства и методы в JS заставляют браузер пересчитывать геометрию и перерисовывать страницу.

Базовая модель: что именно браузер рендерит

Render tree: что попадает в отрисовку

Браузеру недостаточно иметь DOM и CSSOM отдельно. Он строит структуру, которая участвует в отрисовке (часто называют render tree):

  • Берутся DOM-узлы, которые должны отображаться
  • Применяются вычисленные стили из CSSOM
  • Узлы, которые не рисуются, исключаются

Классический пример:

  • display: none исключает элемент из рендера целиком (нет layout и paint для него)
  • visibility: hidden оставляет место в layout, но элемент не рисуется (layout есть, paint нет)

Invalidation: почему браузер не пересчитывает всё всегда

Если бы браузер пересчитывал стили, геометрию и рисование для всей страницы при любом изменении, интерфейсы были бы очень медленными.

Поэтому используется подход:

  • изменение в DOM или стилях создаёт инвалидации (что именно стало неактуальным)
  • браузер старается пересчитать только затронутую часть
  • пересчёты обычно батчатся и выполняются перед следующим кадром

Этапы рендеринга

Ниже — ментальная модель, которую удобно проговаривать на интервью. Реальные движки сложнее, но эти этапы сохраняются.

Style: вычисление стилей

Style calculation — это вычисление итоговых стилей для элементов.

Что происходит:

  • Каскад: выбор победивших правил (учёт специфичности, порядка, важности)
  • Наследование: применение наследуемых свойств от родителей
  • Вычисление значений: приведение em, rem, процентов и других единиц к вычисленному виду

Что важно:

  • Изменение class или инлайнового style почти всегда требует пересчёта стилей
  • Style сам по себе ещё не означает пересчёт геометрии: часть свойств влияет только на рисование или композицию

Справочно:

Layout (reflow): расчёт геометрии

Layout (часто на интервью говорят reflow) — это вычисление:

  • размеров боксов
  • их позиций
  • переносов строк, размеров текста, таблиц
  • зависимостей между элементами (поток документа)

Почему layout дорогой:

  • геометрия элементов часто зависит от соседей и родителей
  • изменение одного блока может «потянуть» пересчёт большого поддерева
  • чтение геометрии из JS может принудительно завершить незаконченные пересчёты (см. ниже про forced synchronous layout)

Терминология:

  • layout и reflow в разговорной практике почти синонимы
  • в документации движков чаще используется layout

Paint (repaint): рисование

Paint (часто на интервью говорят repaint) — это этап, на котором браузер превращает элементы в команды рисования (условно: нарисуй фон, нарисуй текст, нарисуй границу, нарисуй тень) и затем растеризует результат.

Paint обычно требуется, когда меняется внешний вид без изменения геометрии:

  • background-color
  • color
  • box-shadow
  • border-color
  • outline

Важно:

  • repaint не обязательно означает layout
  • но layout почти всегда влечёт paint для затронутых областей

Compositing: сборка слоёв

Compositing — это сборка финального кадра из отдельных поверхностей (слоёв) и применение трансформаций.

Простая модель:

  • часть элементов может быть поднята в отдельные слои
  • эти слои можно двигать/прозрачивать без перерисовки их содержимого
  • компоновщик (compositor) собирает кадр и отдаёт на вывод

Типичный выигрыш:

  • анимации transform и opacity часто можно выполнять на уровне compositing, без layout и paint

Справочно:

GPU и что именно ускоряется

Важно разделять:

  • GPU не делает магически всё быстрее
  • GPU особенно полезен там, где задача сводится к массовым параллельным операциям над пикселями и текстурами

В терминах браузера GPU часто участвует в:

  • растеризации (в зависимости от движка и настроек)
  • компоновке слоёв (compositing)
  • применении transform, opacity, клиппинга и некоторых эффектов на уровне текстур

Практический вывод:

  • свойства, которые можно обработать на этапе compositing (например, transform), обычно дают более плавные анимации
  • но лишние слои увеличивают потребление памяти и могут ухудшить ситуацию

Справочно:

Три категории изменений: layout, paint, composite-only

Для интервью удобно классифицировать изменения стилей на 3 группы по самой дорогой стадии, которую они вызывают.

Таблица: типовые свойства и какой этап они затрагивают

Категория Что происходит Примеры свойств Типичный риск
Layout (reflow) Пересчёт геометрии, затем обычно paint width, height, padding, margin, border-width, display, font-size, line-height, position, top/left (в потоке) Большие каскадные пересчёты, лаги при скролле/ресайзе
Paint (repaint) Перерисовка без изменения геометрии background, color, box-shadow, border-color, outline, text-decoration Частая перерисовка больших областей
Composite-only Без layout и часто без paint, меняется сборка слоёв transform, opacity Лишние слои и память, но обычно лучшая плавность анимаций

Оговорки, которые полезно проговорить на собеседовании:

  • одна и та же правка может приводить к разным последствиям в зависимости от контекста (например, top для position: absolute и transform: translate часто различаются по стоимости)
  • движки оптимизируют, но общая логика «layout дороже paint, paint дороже composite» обычно верна

Что в JS вызывает layout: чтение и запись

Forced synchronous layout: почему чтение размеров может быть дорогим

Браузер старается откладывать пересчёты до конца текущей задачи и выполнять их пачкой перед кадром. Но некоторые операции в JS требуют точного ответа прямо сейчас. Тогда браузер вынужден:

  • применить все накопленные изменения стилей
  • выполнить layout
  • вернуть актуальное значение

Это называют forced synchronous layout (в быту часто говорят forced reflow).

Справочно:

Типичные чтения, которые могут форсировать layout

Важно: сами по себе эти операции не всегда вызывают layout, но если есть грязные изменения, они часто заставят браузер «досчитать» геометрию.

  • element.getBoundingClientRect()
  • element.offsetWidth, element.offsetHeight, element.offsetTop, element.offsetLeft
  • element.clientWidth, element.clientHeight
  • element.scrollWidth, element.scrollHeight, element.scrollTop (чтение/запись зависит от ситуации)
  • window.getComputedStyle(element) (особенно если затем читаются вычисленные геометрические значения)

Справочно:

Записи, которые часто приводят к layout

  • изменения, влияющие на геометрию: width, height, padding, margin, display
  • вставка/удаление элементов в DOM в местах, где меняется поток
  • изменение текста (может менять размеры строк и контейнеров)

Layout thrashing: самый частый анти-паттерн

Layout thrashing — это ситуация, когда код чередует:

  1. запись, которая пачкает layout (например, меняет ширину)
  2. чтение геометрии (например, offsetWidth)
  3. снова запись
  4. снова чтение

В результате браузер вынужден выполнять layout много раз в рамках одного кадра, вместо одного батча.

Типичная стратегия исправления:

  • сначала собрать все чтения (measure)
  • затем выполнить все записи (mutate)

Этот паттерн часто всплывает на интервью в виде вопроса: почему анимация лагает, хотя вы меняете всего пару свойств.

Как браузер решает, что станет отдельным слоем

Движок создаёт слои по внутренним правилам. В среднем вам важно понимать:

  • некоторые эффекты и типы позиционирования повышают шанс, что элемент окажется в отдельном слое
  • отдельный слой помогает, когда элемент часто меняется на этапе compositing
  • слишком много слоёв создают накладные расходы

Инструменты:

  • в Chrome DevTools можно включать визуализацию слоёв и перерисовок

Справочно:

will-change: подсказка браузеру

will-change позволяет заранее сообщить браузеру, что свойство скоро начнёт часто меняться.

  • плюс: браузер может подготовить оптимизации (например, слой)
  • минус: злоупотребление приводит к лишним слоям и расходу памяти

Справочно:

Repaint, reflow, composite: как это спрашивают на собеседовании

Полезный ответ в 20–30 секунд:

  1. Style: пересчитываем итоговые стили.
  2. Layout (reflow): пересчитываем размеры и позиции.
  3. Paint (repaint): рисуем пиксели для элементов.
  4. Composite: собираем слои и выводим кадр; transform и opacity часто могут обновляться здесь.

И дальше сразу практический пример:

  • изменение width почти всегда вызывает layout
  • изменение background-color обычно вызывает paint
  • изменение transform: translateX(...) часто можно сделать на composite-only и получить более плавную анимацию

Как это проверять в DevTools

Минимальный набор, который стоит уметь делать руками:

  • Performance запись: увидеть, где время ушло в Recalculate Style, Layout, Paint, Composite
  • Rendering инструменты: включить подсветку перерисовок (paint flashing) и увидеть, какие зоны реально перерисовываются

Справочно:

Итог

Рендеринг в браузере можно объяснить как последовательность стадий:

  • Style: вычислить итоговые стили
  • Layout (reflow): вычислить геометрию
  • Paint (repaint): нарисовать пиксели
  • Compositing: собрать слои и вывести кадр (часто с участием GPU)

Для производительности фронтендера ключевое:

  • минимизировать частые layout-операции и особенно forced layout
  • избегать layout thrashing (чередования read/write)
  • по возможности анимировать через transform и opacity, понимая компромиссы со слоями и памятью

Event Loop и асинхронность: задачи, микрозадачи, рендер-тик

Event Loop и асинхронность: задачи, микрозадачи, рендер-тик

Зачем это нужно в контексте браузерного пайплайна

В предыдущих статьях мы прошли путь:

  • от URL и кешей до получения HTML
  • через парсинг HTML/CSS/JS и построение DOM/CSSOM
  • до рендеринга: style → layout (reflow) → paint (repaint) → compositing

Но на практике главный вопрос на собеседованиях звучит так: когда именно выполняется JavaScript относительно рендера и пользовательского ввода.

Ответ на него — Event Loop: модель, по которой браузер планирует выполнение JS, обработку событий, сетевых колбэков и вставляет между ними рендер-тики.

Основные источники для подготовки:

Базовые понятия: стек, задачи и микрозадачи

Стек вызовов и почему он должен опустеть

JavaScript на главном потоке браузера выполняется в call stack (стеке вызовов). Пока стек не пуст:

  • браузер не может начать выполнять следующую задачу
  • браузер обычно не может начать следующий рендер-тик на главном потоке

Поэтому длительный синхронный код создаёт long task и «замораживает» интерфейс: события не обрабатываются, кадры не рисуются.

Что такое задача (task)

Task (часто в разговоре говорят macrotask) — это единица работы, которую event loop берёт из очереди задач.

Типичные источники задач в браузере:

  • setTimeout, setInterval
  • обработчики событий DOM (например, click, keydown)
  • завершение некоторых операций Web API (упрощённо: «колбэк вернулся в JS»)
  • сообщение postMessage

Справочно:

Что такое микрозадача (microtask)

Microtask — это задача более высокого приоритета, которая выполняется после текущей задачи, но до того, как event loop возьмёт следующую задачу.

Основные источники микрозадач:

  • реакции промисов: .then, .catch, .finally
  • queueMicrotask
  • MutationObserver

Справочно:

Главный цикл: что происходит «между кадрами»

Для интервью очень полезна короткая формулировка:

  1. Event loop берёт одну задачу из очереди задач и выполняет её до конца.
  2. Затем выполняет все микрозадачи, которые накопились.
  3. Затем браузер получает шанс выполнить render step: пересчитать стили, сделать layout, paint и compositing, если есть изменения.
  4. Затем берётся следующая задача.

Критичный нюанс: микрозадачи выполняются до тех пор, пока очередь микрозадач не станет пустой. Это означает, что микрозадачами можно случайно вытеснить рендер, если бесконечно планировать новые микрозадачи.

Порядок выполнения: разбор на примере

Код ниже проверяет базовую дисциплину очередей:

console.log('A');

setTimeout(() => console.log('timeout'), 0);

Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'));

console.log('B');

Ожидаемый порядок:

  • A
  • B
  • promise 1
  • promise 2
  • timeout

Почему так:

  1. Синхронный код выполняется прямо сейчас (стек).
  2. setTimeout ставит колбэк в очередь задач (не «выполнить сразу», даже при 0).
  3. then ставит реакцию промиса в очередь микрозадач.
  4. После завершения текущей задачи (в данном случае — выполнение всего скрипта) движок вычищает микрозадачи.
  5. Только затем берётся следующая задача — setTimeout.

Справочно:

async/await как синтаксис над промисами

async/await на интервью важно объяснять не «магией», а как удобный синтаксис над промисами.

async function f() {
  console.log('1');
  await Promise.resolve();
  console.log('2');
}

console.log('A');
f();
console.log('B');

Порядок будет:

  • A
  • 1
  • B
  • 2

Почему:

  • до await выполнение синхронное
  • await «разрывает» функцию: продолжение после await планируется как микрозадача (через механизм промисов)

Это напрямую связано с практикой: код после await не выполнится в текущем стеке, даже если вы await-ите уже готовый промис.

Рендер-тик: где в этом месте появляется style/layout/paint/composite

Что такое «render step» и почему он не обязан происходить после каждой задачи

Рендеринг — дорогой процесс. Поэтому браузер обычно не рендерит «после каждой строчки JS», а старается:

  • батчить изменения DOM/CSS
  • выполнить пересчёты ближе к кадру

Ментальная модель:

  • JS меняет DOM/CSS → браузер помечает части как «грязные»
  • в конце задачи и микрозадач браузер решает, нужен ли рендер
  • если нужен, выполняет этапы из прошлой статьи: style → layout → paint → compositing

Важное следствие: DOM меняется сразу, а пиксели обычно обновляются на следующем рендер-тике.

requestAnimationFrame: код «прямо перед кадром»

requestAnimationFrame (сокращённо rAF) — API для планирования функции на ближайший кадр, перед отрисовкой.

Типичные применения:

  • плавные анимации, синхронизированные с кадрами
  • паттерн measure-then-mutate для борьбы с layout thrashing

Справочно:

Практическая ментальная модель для интервью:

  • setTimeout(..., 0) попадёт в очередь задач и выполнится «когда дойдёт очередь»
  • requestAnimationFrame — это «вставка около рендера», чтобы работать в ритме кадров

Forced synchronous layout и связь с event loop

Из прошлой статьи про рендеринг у нас был ключевой баг-паттерн: forced synchronous layout.

Он особенно хорошо объясняется через event loop:

  • в рамках одной задачи вы сделали записи в DOM/CSS (браузер отложил layout)
  • затем вы читаете геометрию (offsetWidth, getBoundingClientRect())
  • браузеру нужно вернуть точный ответ прямо сейчас, поэтому он форсирует пересчёты (style/layout)

Пример опасного чередования:

box.style.width = '200px';
const w = box.offsetWidth; // может форсировать layout
box.style.width = (w + 10) + 'px';

Один из типовых способов снизить риск — группировать чтения и записи, иногда в связке с rAF:

requestAnimationFrame(() => {
  const w = box.getBoundingClientRect().width; // measure
  box.style.width = (w + 10) + 'px'; // mutate
});

Это не «магическое лечение», но полезная дисциплина: измерения ближе к кадру, изменения — тоже батчом.

Частые ловушки, которые любят на собеседованиях

Ловушка: «микрозадачи — это просто маленькие задачи»

Важно проговорить отличие:

  • задачи выполняются по одной, между ними браузер может вставлять рендер
  • микрозадачи вычищаются полностью перед тем, как перейти к следующей задаче и перед тем, как дать шанс рендеру

Следствие: бесконечная цепочка микрозадач способна «съесть» время и ухудшить отзывчивость.

Ловушка: «setTimeout(0) — выполнится сразу после текущей строки»

Нет: он выполнится не раньше, чем завершится текущая задача и микрозадачи, и когда event loop дойдёт до следующей задачи.

Ловушка: «Promise.then выполнится синхронно, если промис уже resolve»

Нет: реакции промиса всегда планируются как микрозадачи.

Как это проверять в DevTools

Что стоит уметь сделать руками:

  • В Performance записи увидеть:
    • длинные задачи (scripting)
    • участки Recalculate Style, Layout, Paint, Composite между задачами
  • По логам/markers понять, что выполняется в задаче, что в микрозадаче, и где происходят кадры

Справочно:

Итог

Для браузерной асинхронности полезна простая, но точная модель:

  • Задачи (task): события, таймеры и другие «внешние» поводы выполнить JS
  • Микрозадачи (microtask): в первую очередь промисы; выполняются сразу после текущей задачи и до следующей
  • Рендер-тик: шанс браузеру применить изменения и пройти этапы style/layout/paint/composite между задачами

Эта модель связывает воедино темы курса:

  • почему промисы влияют на порядок выполнения
  • почему длительные задачи и цепочки микрозадач вредят отзывчивости
  • почему чтение геометрии в неправильный момент вызывает forced layout
  • почему requestAnimationFrame помогает синхронизироваться с кадром

Promises и генераторы: устройство, протоколы, ошибки и типовые вопросы

Promises и генераторы: устройство, протоколы, ошибки и типовые вопросы

Как эта тема связана с браузерным пайплайном

В предыдущей статье про Event Loop мы зафиксировали ключевую модель:

  • JavaScript выполняется задачами (task)
  • реакции промисов выполняются микрозадачами (microtask)
  • между задачами браузер получает шанс на рендер-тик (style → layout → paint → composite)

Промисы и генераторы на собеседовании проверяют не как синтаксис, а как механизмы управления исполнением: что выполняется немедленно, что откладывается, как распространяются ошибки, и как это влияет на отзывчивость интерфейса.

Promises: что это такое на уровне механики

Promise — объект, который представляет результат асинхронной операции: либо успешный, либо с ошибкой, либо ещё не готов.

Состояния промиса

У промиса есть три состояния:

  • pending — ещё не завершён
  • fulfilled — успешно завершён, есть значение
  • rejected — завершён с ошибкой

Переход возможен только один раз: из pending в fulfilled или rejected.

Справочно: MDN: Promise

Executor и синхронность создания

Важный факт для интервью: функция-executor в new Promise((resolve, reject) => { ... }) запускается синхронно.

console.log('A');

new Promise((resolve) => {
  console.log('B');
  resolve(1);
});

console.log('C');

Логи: A, B, C.

Но обработчики .then/.catch/.finally всегда выполняются асинхронно через микрозадачи.

console.log('A');

Promise.resolve(1).then(() => console.log('then'));

console.log('B');

Логи: A, B, then.

Как промисы попадают в микрозадачи

Когда промис переходит в fulfilled или rejected, движок:

  • ставит в очередь микрозадач выполнение обработчиков, которые были подписаны через .then/.catch/.finally
  • гарантирует, что эти обработчики не выполнятся “внутри текущего стека”

Это напрямую связывает промисы с темой рендера: длинная цепочка микрозадач способна отодвинуть момент, когда браузер сможет выполнить рендер-тик.

Справочно: MDN: microtask

Цепочки .then: почему они работают как конвейер

Ключ: .then возвращает новый Promise.

const p = Promise.resolve(2)
  .then(x => x * 2)      // возвращаем значение
  .then(x => Promise.resolve(x + 1)); // возвращаем промис

p.then(console.log); // 5

Правила:

  • если из .then вернуть обычное значение, новый промис станет fulfilled с этим значением
  • если вернуть промис, новый промис “подождёт” его и перейдёт в то же состояние
  • если бросить исключение (throw), новый промис станет rejected

Thenable assimilation

На интервью часто спрашивают: “что будет, если вернуть объект с .then?”.

Thenable — объект, похожий на промис: у него есть метод then. По правилам промисов такой объект может быть “усвоен” как асинхронный результат.

const thenable = {
  then(resolve) {
    setTimeout(() => resolve('ok'), 0);
  }
};

Promise.resolve(thenable).then(console.log); // ok

Практический вывод:

  • промисы доверяют контракту thenable
  • некорректные thenable могут приводить к странным зависаниям или двойным вызовам, поэтому в продакшене лучше полагаться на реальные Promise

Ошибки в промисах: где они теряются и как их ловить

throw внутри .then и reject

Эти два варианта эквивалентны по эффекту для цепочки:

Promise.resolve()
  .then(() => { throw new Error('boom'); })
  .catch(e => console.log('caught', e.message));

Promise.resolve()
  .then(() => Promise.reject(new Error('boom')))
  .catch(e => console.log('caught', e.message));

Почему try/catch не ловит асинхронные ошибки

Типовая ловушка:

try {
  Promise.reject(new Error('x'));
} catch (e) {
  console.log('не поймается');
}

try/catch ловит только то, что происходит в текущем синхронном стеке. Отказ промиса будет обработан позже как микрозадача.

Правильно:

Promise.reject(new Error('x')).catch(() => {});

finally: что он делает и чего не делает

finally:

  • выполняется и при успехе, и при ошибке
  • не получает значение “как аргумент” (в отличие от then/catch)
  • пропускает исходный результат дальше, если вы явно не возвращаете другое
Promise.resolve(1)
  .finally(() => {
    // сайд-эффекты
  })
  .then(v => console.log(v)); // 1

Если в finally бросить ошибку или вернуть rejected-промис, цепочка станет rejected.

Справочно: MDN: Promise.prototype.finally

Unhandled rejections в браузере

Если промис отклонён и к нему не присоединили обработчик ошибки, в браузере возникают события:

  • unhandledrejection
  • rejectionhandled

Это важно на собеседованиях как признак зрелости: ошибки промисов нужно завершать обработчиком, иначе вы получите шум в консоли и потенциально пропущенные сбои.

Справочно: MDN: unhandledrejection

Комбинаторы: типовые вопросы и тонкости

Promise.all

  • резолвится, когда все промисы успешно завершились
  • реджектится при первой же ошибке
  • порядок результатов соответствует порядку входного массива
Promise.all([p1, p2, p3]).then(([a, b, c]) => {});

Справочно: MDN: Promise.all

Promise.allSettled

  • ждёт завершения всех
  • никогда не реджектится из-за одной ошибки
  • возвращает массив объектов со статусами

Справочно: MDN: Promise.allSettled

Promise.race и Promise.any

  • race завершится первым завершившимся (и успехом, и ошибкой)
  • any вернёт первый успешный; если все упали — ошибка AggregateError

Справочно:

Типовая ловушка: “all отменяет остальные?”

Нет. Если один промис в Promise.all упал, итоговый промис станет rejected, но остальные операции продолжат выполняться сами по себе.

Практический ответ на интервью:

  • Promises не имеют встроенной отмены
  • для отмены обычно используют AbortController в тех API, которые его поддерживают (например, fetch)

Справочно: MDN: AbortController

async/await: как объяснять без магии

async/await — синтаксис над промисами.

Факты, которые часто проверяют:

  • async function всегда возвращает Promise
  • await приостанавливает функцию, а продолжение планируется как микрозадача
  • try/catch вокруг await ловит rejected-промис
async function f() {
  try {
    const data = await Promise.resolve(1);
    return data + 1;
  } catch (e) {
    return 0;
  }
}

f().then(console.log); // 2

Справочно: MDN: async function

Генераторы: что это и зачем они на интервью

Генератор — функция, которая может приостанавливать выполнение на yield и продолжать позже. На интервью генераторы — это проверка понимания:

  • итераторов и итерируемых объектов
  • протокола next(value) / { value, done }
  • управления потоком и передачи значений внутрь генератора

Справочно: MDN: function* (generator)

Протоколы Iterable и Iterator

Iterable protocol

Объект итерируемый, если у него есть метод Symbol.iterator, который возвращает итератор.

Справочно: MDN: Iteration protocols

Iterator protocol

Итератор — объект с методом next(), который возвращает объект вида:

  • value — очередное значение
  • done — признак завершения

Пример ручного итератора:

const counter = {
  from: 1,
  to: 3,
  [Symbol.iterator]() {
    let cur = this.from;
    const end = this.to;
    return {
      next() {
        if (cur <= end) return { value: cur++, done: false };
        return { value: undefined, done: true };
      }
    };
  }
};

for (const x of counter) {
  console.log(x); // 1, 2, 3
}

Генератор как итератор “из коробки”

Генераторная функция создаёт объект, который:

  • одновременно является итератором (есть next)
  • и итерируемым (есть Symbol.iterator, возвращающий самого себя)
function* gen() {
  yield 1;
  yield 2;
  return 3;
}

const it = gen();
console.log(it.next()); // { value: 1, done: false }
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: 3, done: true }

Нюанс, который любят: return генератора не попадает в for...of, потому что цикл останавливается на done: true.

Управление генератором: next, throw, return

Передача значения внутрь через next(value)

Значение, переданное в next(value), становится результатом предыдущего yield.

function* g() {
  const x = yield 'need x';
  yield x * 2;
}

const it = g();
console.log(it.next().value);    // need x
console.log(it.next(10).value);  // 20

Ошибки: iterator.throw(error)

Можно “вбросить” ошибку внутрь генератора:

function* g() {
  try {
    yield 1;
  } catch (e) {
    yield 'caught';
  }
}

const it = g();
it.next();
console.log(it.throw(new Error('x')).value); // caught

Принудительное завершение: iterator.return(value)

return завершает итерацию сразу.

yield*: делегирование итерации

yield* “прокидывает” элементы другого итерируемого объекта.

function* a() {
  yield 1;
  yield 2;
}

function* b() {
  yield 0;
  yield* a();
  yield 3;
}

console.log([...b()]); // [0, 1, 2, 3]

Генераторы и асинхронность: где здесь промисы

Обычные генераторы сами по себе не делают код асинхронным. Но исторически их использовали для “похожего на async/await” стиля: генератор yield-ит промис, а внешний раннер ждёт его и делает next.

Сегодня на интервью важно:

  • понимать, что async/await проще и стандартнее
  • но понимать идею “пауз” и “возобновления” полезно для объяснения протоколов

Async generators и for await...of

Есть асинхронные генераторы: async function*. Они возвращают async iterator, у которого next() возвращает промис.

async function* stream() {
  yield 1;
  yield 2;
}

(async () => {
  for await (const x of stream()) {
    console.log(x);
  }
})();

Справочно:

Типовые вопросы на собеседовании и как отвечать

Про промисы

  • Чем Promise.resolve(value) отличается от new Promise(resolve => resolve(value))?
    • Promise.resolve не создаёт лишний executor и умеет усваивать thenable.
  • Почему .then всегда асинхронный?
    • Реакции промиса выполняются как микрозадачи.
  • Почему try/catch “не ловит промисы”?
    • Потому что ошибка/отказ происходит не в текущем синхронном стеке.
  • Что вернёт Promise.all, если один упал?
    • Итоговый промис отклонится, но остальные операции не отменятся.

Про генераторы и протоколы

  • Что такое iterable и iterator?
    • Symbol.iterator создаёт итератор; итератор имеет next(){ value, done }.
  • Чем генератор удобнее ручного итератора?
    • Он автоматически реализует протокол итератора и управляет состоянием пауз.
  • Что делает yield*?
    • Делегирует выдачу значений другому итерируемому.

Итог

Промисы и генераторы в контексте браузера полезно держать в связке с Event Loop:

  • промисы планируют продолжения через микрозадачи, что влияет на порядок выполнения и момент рендера
  • цепочки .then работают как конвейер благодаря тому, что каждый .then возвращает новый промис и может усваивать thenable
  • ошибки в промисах требуют явной обработки (catch), иначе возникают unhandledrejection
  • генераторы проверяют знание итерационных протоколов и умение управлять потоком (next/throw/return, yield*)
  • асинхронные генераторы связывают генераторную модель с промисами через for await...of