Что первым приходит в голову при словах «оптимизация фронтенда»? Для большинства это сокращение количества сетевых запросов, уменьшение размера бандла или грамотное использование кэша. Иногда добавляется борьба с лишними ре-рендерами или настройка момента загрузки ресурсов. Главный поток браузера в этом списке обычно не фигурирует — и на большинстве экранов он действительно никогда не становится проблемой. Но на экранах с активным взаимодействием, где данные приходят в реальном времени, а скролл, анимация и ввод переплетаются друг с другом, картина меняется. Сколько бы ни было выиграно на сети и размере бандла, экран замирает в тот момент, когда блокируется главный поток.

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

Когда разработчик сталкивается с подобными подвисаниями, первая реакция — задаться вопросом «неужели мой код медленный?» и начать разбирать алгоритмы, искать лишние вычисления. Но в большинстве случаев дело не в скорости кода. Код не медленный. Просто именно он в этот момент занимает главный поток.

У браузера есть несколько потоков, но почти всё, к чему можно притронуться из кода, сосредоточено в одном — главном. Вычисления, отрисовка, обработка событий, обработка сетевых ответов и внутренняя логика фреймворка — всё это происходит там. Один ресурс — гора работы.

Главный поток браузера — дорогой ресурс. Большую часть времени он не создаёт проблем, но как только приходит замысел посложнее, работа с главным потоком становится ключевой частью задачи. Далее — о том, как обращаться с этим дорогим ресурсом.

Чем занимается главный поток

Стоит начать с того, что именно делает главный поток. Его работа делится на две большие категории.

Первая — выполнение JavaScript. Написанный код, обработчики событий, таймеры, коллбэки сетевых ответов и внутренняя логика фреймворка выполняются именно здесь. Эти задачи исполняются в порядке поступления в очередь, при первой возможности, без какой-либо связи с циклом обновления экрана.

Вторая — отрисовка экрана. Когда DOM или стили меняются и экран нужно обновить, браузер проходит примерно такие шаги, по порядку, чтобы получить кадр.

  • Выполнение коллбэков requestAnimationFrame — JavaScript, зарегистрированный для запуска непосредственно перед отрисовкой кадра
  • Расчёт стилей — вычисление итоговых значений CSS для каждого элемента
  • Layout — вычисление позиции и размера каждого элемента (также называется reflow)
  • Paint — формирование команд рисования, описывающих, что и каким цветом рисовать

Если ничего не изменилось, эти шаги полностью пропускаются, поэтому они не обязательно выполняются каждый кадр. Только финальный этап композиции, который собирает готовый результат на экране, передаётся потоку компоузера. Иными словами, большая часть конвейера отрисовки экрана — задача главного потока.

Чтобы экран выглядел плавно, кадры должны отрисовываться с частотой обновления дисплея. На самом распространённом 60-герцовом экране это означает 60 кадров в секунду, то есть около 16,6 миллисекунды на кадр. И использовать весь этот бюджет не получится. После вычета собственных издержек браузера практический бюджет обычно оценивается в районе 10 миллисекунд, а на устройстве с частотой 120 Гц сам бюджет сокращается вдвое.

Проблема в том, что оба вида работы стоят в одной очереди на одном потоке. JavaScript спроектирован вокруг модели однопоточного event loop. Главный поток обрабатывает по одной задаче за раз, и пока задача выполняется, ничего другого произойти не может. Если одна JavaScript-функция выполняется 200 миллисекунд, то на эти 200 миллисекунд браузер не может ни перерисовать экран, ни получить клик пользователя. При бюджете кадра порядка 10 миллисекунд это фатальное время. Задача, которая держит главный поток настолько долго, называется long task, и всё, что превышает 50 миллисекунд, обычно считается проблемой.

Главный поток занят выполнением JavaScript и отрисовкой экрана, и они делят одну очередь. Пока идёт длинная задача, экран замирает: ни перерисовки, ни отклика на клик.

При нажатии кнопки JS-анимация останавливается, а набор текста в поле ввода не даёт никакого эффекта. CSS-анимация при этом продолжает работать — к этому различию есть смысл вернуться позже. Пока важно запомнить, что долгое удержание главного потока — это то же самое, что замирание экрана.

Это напрямую связано с метриками веб-производительности. INP (Interaction to Next Paint), измеряющая, сколько времени требуется экрану на отклик после действия пользователя, и TBT (Total Blocking Time), измеряющая суммарное время блокировки главного потока во время загрузки страницы, — обе метрики по сути выражают, насколько долго был заблокирован главный поток. Значительная часть оптимизации производительности сводится к тому, насколько аккуратно расходуется этот единственный поток.

Способы аккуратного расхода делятся на две большие группы. Первая — грамотно распределять время главного потока изнутри. Вторая — выносить работу за пределы главного потока вовсе. Рассмотрим их по порядку.

Разумное использование дорогого ресурса

Первая группа приёмов подразумевает, что работа остаётся на главном потоке, но его время тратится разумно. Есть четыре базовых приёма.

  • Как разбить работу, которая выполняется слишком долго?
  • Как сгруппировать работу, которая выполняется слишком часто?
  • Из нескольких задач — какая идёт первой?
  • Как отложить работу, которую не нужно делать прямо сейчас?

Эти приёмы называются разбиением (splitting), группировкой (batching), приоритизацией (prioritizing) и отложенным выполнением (deferring). Первые два определяют размер задач, последние два — их время выполнения. Из четырёх приёмов разбиение — основа для остальных: у задач должны быть границы, прежде чем решать, что вставить между ними и что отложить. Поэтому начать стоит именно с разбиения.

Разбиение

Представим панель чата на прямом эфире. На популярном стриме чат может выдавать всплески до сотен сообщений в секунду. В такой среде сообщения не приходят вежливо, по одному. При скачке трафика сервер отправляет их пачками по несколько десятков штук, а в момент входа в комнату сразу приходят сотни накопившихся сообщений. Что происходит, если отрисовать всю эту пачку разом, в момент её получения? Каждое отрисованное сообщение тащит за собой создание DOM, расчёт стилей, layout и paint, и сотни таких итераций выполняются подряд внутри одной задачи. При этом пользователь, пытающийся набрать собственное сообщение, получает подтормаживающее поле ввода, а любая другая анимация на экране начинает дёргаться. Чужой чат монополизирует главный поток и мешает своему собственному.

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

При демонстрации моделируется панель чата на стриме. Нажатие «Flood the chat» запускает лавину сообщений. Пока сообщения поступают, можно набирать текст в поле ввода, следя за индикатором плавности и fps сверху, и сравнивать режимы «Immediate render» и «Yielding render».

В режиме «Immediate render» DOM изменяется при получении каждого сообщения, поэтому во время наплыва чата fps резко падает, индикатор дёргается, а поле ввода отстаёт. При внимательном наблюдении заметно, что и сами сообщения чата начинают появляться заметно медленнее — потому что коллбэк, который их принимает и обрабатывает, тоже стоит в очереди задач главного потока и задерживается наравне со всем остальным. При переключении на «Yielding render» сообщения по-прежнему отрисовываются по одному, как и раньше, но ввод снова оживает, а экран снова двигается. Единственное изменение — после каждых 20 сообщений главный поток на мгновение освобождается.

Важно не спутать одну вещь: освобождение потока не делает работу быстрее. Общий объём работы не меняется, а несколько миллисекунд ожидания при каждой передаче управления — это дополнительные издержки, так что по фактическому времени процесс занимает даже дольше. Так почему же отрисовка восстановилась вместе с вводом?

Как было показано ранее, главный поток не способен ничего делать, пока выполняется задача. Конвейер отрисовки, производящий кадры, тоже не может вклиниться в середину задачи — он способен работать только между задачами. Освобождение потока — это и есть создание таких промежутков. Накопившийся ввод и производство кадров получают свой шанс именно в этих промежутках, и пользователю кажется, будто производительность выросла.

На уровне кода классический способ освободить поток — setTimeout, который переносит продолжение работы в следующую задачу. Вот пример кода.

// Пачка сообщений чата приходит сразу
socket.on('messages', (chats) => {
  renderChats(chats);
});

// Отрисовка сообщений с освобождением главного потока после каждых 20
async function renderChats(chats) {
  let count = 0;
  for (const chat of chats) {
    appendChatNode(chat); // отрисовать одно сообщение

    if (++count % 20 === 0) {
      await new Promise((resolve) => setTimeout(resolve, 0)); // освобождение потока здесь
    }
  }
}

С таким подходом, как бы сильно ни бушевал чат, работа с DOM никогда не занимает главный поток целиком, а между кусками остаётся место для обработки пользовательского ввода и анимаций.

Главная деталь здесь — setTimeout. Когда он планирует продолжение оставшейся работы как новую задачу, текущая задача сразу завершается, и в этом промежутке обрабатываются накопившийся ввод и отрисовка. await приостанавливает функцию до наступления запланированной задачи, после чего работа продолжается с того места, где остановилась.

В примере выше входящая работа делилась по количеству. Но если уже идёт анимация или пользователь занят скроллом, деление по времени безопаснее деления по количеству. Анимация использует немного главного потока каждый кадр, поэтому тяжёлая задача должна постоянно проверять время и обрывать себя, прежде чем поглотит остаток бюджета кадра.

async function processDuringAnimation(items) {
  let i = 0;
  let frameStart = performance.now();
  while (i < items.length) {
    // Работать только пока не прошло 5мс с начала кадра
    while (i < items.length && performance.now() - frameStart < 5) {
      doWork(items[i++]);
    }
    frameStart = await new Promise(requestAnimationFrame); // продолжить с началом следующего кадра
  }
}

performance.now() здесь работает как секундомер, проверяющий превышение бюджета, а requestAnimationFrame — как будильник, говорящий «разбуди меня прямо перед отрисовкой следующего кадра». Именно поэтому при делении по времени поток освобождают через rAF, а не через setTimeout — продолжение работы попадает в ритм кадрового цикла.

Стоит отметить, что rAF передаёт своему коллбэку временную метку начала кадра, и код выше использует её как точку отсчёта для бюджета. Причина в том, что функция не единолично владеет кадром. Если в том же кадре ранее выполнялись другие коллбэки анимации, доля этой функции должна сократиться на потраченное ими время — иначе бюджет кадра будет нарушен. Привязка к моменту начала кадра превращает «использовать 5мс» в «использовать до 5мс после начала кадра», что заставляет код естественно сотрудничать, когда несколько анимаций делят один кадр.

Почему именно 5 миллисекунд? В самом числе нет ничего особенного. Раз практический бюджет составляет около 10 миллисекунд, разумной эвристикой будет отдать примерно половину фоновой работе, оставив остальное коллбэкам анимации, стилям, layout и paint. Если анимации тяжёлые — стоит уменьшить это значение.

С таким подходом, даже пока идёт тяжёлая работа, остаётся место для отрисовки каждого кадра, и работа с анимацией идут плавно рядом друг с другом. При движении мыши 4000 частиц разлетаются от курсора, а близкие частицы также отталкивают друг друга, поэтому для определения направления одной частицы нужно проверить её расстояние до всех остальных. Это порядка 16 миллионов вычислений расстояния за проход, и пересчёт всего этого каждый кадр сам по себе взрывает бюджет кадра.

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

Наконец, несколько моментов, требующих внимания. Во-первых, слишком мелкое разбиение работает в обратную сторону. Освобождение потока и возврат к работе имеют собственную цену, и если куски слишком малы, эти издержки могут превысить саму работу, которую пытаются сделать быстрее.

Во-вторых, освобождение потока через setTimeout сопряжено с минимальной задержкой, так что каждый кусок может ждать несколько миллисекунд впустую. Обычно это не имеет значения, но в ситуациях, требующих очень высокого уровня отзывчивости, задержка становится проблемой.

Поэтому некоторый код планирует следующую задачу, отправляя сообщение через MessageChannel вместо setTimeout. Именно так работает планировщик React. Более того, недавно появился стандартный API scheduler.yield(), решающий ту же проблему. Его преимущество — после освобождения потока исходная работа возобновляется раньше других задач в очереди, а не отправляется в конец. Впрочем, поддержка в браузерах пока неравномерна.

В-третьих, разные инструменты освобождения потока возвращаются к работе в разное время. setTimeout и scheduler.yield() возобновляют работу без привязки к циклу рендеринга, тогда как requestAnimationFrame возобновляет работу непосредственно перед отрисовкой кадра, так что для работы, которая должна идти в ритме с обновлениями экрана, requestAnimationFrame подходит лучше. Если нужен более тонкий контроль приоритетов, можно построить собственную очередь на MessageChannel и самостоятельно управлять освобождением и возобновлением потока.

Наконец, разбиение не всегда возможно. Разбор многомегабайтного ответа с помощью JSON.parse, например, — это единый атомарный синхронный вызов, и остановиться посреди пути, освободив поток, не получится. До завершения разбора главный поток застрянет. Тяжёлая работа, которую нельзя разбить таким образом, — явная граница «разумного использования» ресурса. В этом случае нужно поменять сам подход и вовсе не выполнять работу на главном потоке. Об этом — в разделе «Отказ от использования дорогого ресурса».

Группировка

Разбиение само по себе решает не все проблемы. Вспомним пример со стриминговым чатом. Освобождение потока спасло ввод и отрисовку, но не сделало отрисовку чата быстрее. Более того, throughput — количество сообщений, отрисованных за единицу времени, — снизился на величину издержек от освобождения потока. Что происходит, если чат льётся быстрее, чем позволяет throughput? Поступление опережает обработку, очередь непрерывно растёт, а сообщения, добирающиеся до экрана, становятся всё более устаревшими. Это явление называется backpressure.

Для повышения throughput нужен другой инструмент, отличный от разбиения. Например, вместо отрисовки сообщений одно за одним можно отрисовать накопившуюся пачку разом. Фиксированные издержки на одно сообщение складываются вместе, и за то же время отрисовывается больше чата. Совет «разбивать», а следом совет «группировать» может звучать как противоречие, но суть обоих приёмов — привести задачи к подходящему размеру. Разбиение решает проблему задач, слишком длинных для того, чтобы между ними успевала вклиниться отрисовка, а группировка — проблему задач, слишком частых, чтобы раз за разом оплачивать фиксированную стоимость конвейера.

Лучшие цели для группировки — события. Scroll, resize и события ввода могут срабатывать десятки и сотни раз за короткий промежуток. Если запускать тяжёлый обработчик на каждое из них, от главного потока ничего не останется. Поэтому множество событий сворачивают в одно выполнение — либо «выполнить один раз после того, как всё успокоилось», либо «выполнять не чаще раза за интервал». Это называется debounce и throttle соответственно.

Демонстрация показывает markdown-редактор с открытым длинным CHANGELOG. Построение предпросмотра означает разбор всего документа (около 2000 строк) и полную перестройку его DOM с нуля, что слишком дорого выполнять на каждое нажатие клавиши. При быстром наборе текста в левом редакторе с включённым режимом «No debounce» предпросмотр перестраивается на каждый символ, и ввод отстаёт. При переключении на «Debounce 300ms» отрисовка происходит лишь однократно, после остановки набора, и печать становится плавной.

Debounce и throttle — базовые инструменты группировки, но не единственные. Иногда группировка происходит естественным образом на уровне самого браузера: множество изменений DOM в рамках одной синхронной задачи автоматически сливаются в один цикл отрисовки. Осознанное же применение debounce/throttle нужно там, где именно код инициирует повторные тяжёлые вычисления в ответ на частые события.

Заключение

Главный поток браузера — единственный ресурс, через который проходит почти вся содержательная работа: JavaScript-код и большая часть конвейера отрисовки экрана. Он не бесконечен, а его перегрузка ощущается пользователем напрямую — как замирание экрана, задержка ввода и рваная анимация. Разбиение, группировка, приоритизация и отложенное выполнение — способы бережно распорядиться этим ресурсом, оставаясь внутри него. Когда работа принципиально не делится или слишком тяжела для главного потока в любом виде, единственный выход — вынести её за пределы главного потока целиком, на compositor или в отдельный воркер.