Оптимизируйте длительные задачи

Вы наверняка слышали, что не нужно блокировать основной поток и что длительные задачи нужно разбивать на части. Но что это значит?

Дата публикации: 30 сентября 2022 г. Последнее обновление: 19 декабря 2024 г.

Обычно для ускорения приложений JavaScript рекомендуют следующее:

  • "Не блокируйте основной поток".
  • "Разбивайте длинные задачи".

Это отличный совет, но какую работу он предполагает? Отправка меньшего количества JavaScript – это хорошо, но означает ли это автоматически более отзывчивый пользовательский интерфейс? Может, да, а может, и нет.

Чтобы понять, как оптимизировать задачи в JavaScript, сначала нужно разобраться, что это такое и как браузер их обрабатывает.

Что такое задача?

Задача – это любая отдельная операция, которую выполняет браузер. К таким задачам относятся рендеринг, синтаксический анализ HTML и CSS, выполнение JavaScript и другие процессы, которыми вы не можете управлять напрямую. Из всего этого JavaScript, который вы пишете, возможно, является самым большим источником задач.

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

Задачи, связанные с JavaScript, влияют на производительность двумя способами:

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

Все это, за исключением веб-воркеров и аналогичных API, происходит в основном потоке.

Что такое основной поток?

Основной поток – это поток, в котором браузер выполняет большинство задач и почти весь код JavaScript.

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

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

Длительная задача в профилировщике производительности инструментов разработчика в Chrome. Блокирующая часть задачи (более 50 миллисекунд) изображена в виде красных диагональных полос.
Длительная задача в профилировщике производительности Chrome. Длительные задачи помечаются красным треугольником в углу, а блокирующая часть задачи заштрихована красными диагональными полосами.

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

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

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

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

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

Теперь, когда вы знаете, почему важно разбивать задачи, вы можете узнать, как это сделать в JavaScript.

Стратегии управления задачами

Общий совет в архитектуре программного обеспечения — разбить вашу работу на более мелкие функции:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

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

Концептуально saveSettings() хорошо спроектирован. Если вам нужно отладить одну из этих функций, вы можете перемещаться по дереву проекта, чтобы выяснить, что делает каждая функция. Такое разделение работы упрощает навигацию по проектам и их поддержку.

Однако здесь есть потенциальная проблема: JavaScript не запускает каждую из этих функций как отдельную задачу, поскольку они выполняются в рамках функции saveSettings(). Это означает, что все пять функций будут выполняться как одна задача.

Функция saveSettings, показанная в профилировщике производительности Chrome. Хотя функция верхнего уровня вызывает пять других функций, вся работа выполняется в рамках одной длительной задачи, поэтому результат выполнения функции не виден пользователю, пока не будут выполнены все функции.
Одна функция saveSettings(), которая вызывает пять функций. Работа выполняется как часть одной длинной монолитной задачи, блокируя любой визуальный ответ до тех пор, пока не будут выполнены все пять функций.

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

В этом случае saveSettings() запускается кликом пользователя, и, поскольку браузер не может показать ответ, пока не завершится вся функция, результатом этой длительной задачи является медленный и не отвечающий пользовательский интерфейс, что будет измерено как низкий показатель Interaction to Next Paint (INP).

Как отложить выполнение кода вручную

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

Один из методов, который разработчики используют для разбиения задач на более мелкие, включает setTimeout(). При этом вы передаете функцию в setTimeout(). Это откладывает выполнение обратного вызова в отдельную задачу, даже если вы укажете тайм-аут 0.

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

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

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

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

Использовать setTimeout() в этом случае не рекомендуется, поскольку это неудобно для разработчиков. Кроме того, после пяти вложенных вызовов setTimeout() браузер начнет добавлять задержку не менее 5 миллисекунд для каждого последующего вызова setTimeout().

У setTimeout есть ещё один недостаток, связанный с уступкой: когда вы уступаете основному потоку, откладывая выполнение кода в последующей задаче с помощью setTimeout, эта задача добавляется в конец очереди. Если в очереди есть другие задачи, они будут выполнены до отложенного кода.

Специальный API для уступки: scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: 142.
  • Safari: not supported.

Source

scheduler.yield() – это API, специально разработанный для передачи управления основному потоку в браузере.

Это не синтаксис на уровне языка и не специальная конструкция. scheduler.yield() – это просто функция, которая возвращает Promise, который будет разрешен в будущей задаче. Любой код, который должен выполняться после разрешения Promise (в явной цепочке .then() или после await в асинхронной функции), будет выполняться в этой будущей задаче.

На практике: вставьте await scheduler.yield(), и функция приостановит выполнение в этой точке и уступит место основному потоку. Выполнение остальной части функции, называемой продолжением, будет запланировано для запуска в новой задаче цикла событий. Когда задача начнется, ожидаемое обещание будет выполнено, а функция продолжит выполняться с того места, где она остановилась.

async function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Yield to the main thread:
  await scheduler.yield()

  // Work that isn't user-visible, continued in a separate task:
  saveToDatabase();
  sendAnalytics();
}
Функция saveSettings, показанная в профилировщике производительности Chrome, теперь разбита на две задачи. Первая задача вызывает две функции, а затем уступает место, позволяя выполнить макет и отрисовку и дать пользователю видимый ответ. В результате событие клика завершается гораздо быстрее – за 64 миллисекунды. Вторая задача вызывает последние три функции.
Выполнение функции saveSettings() теперь разделено на две задачи. В результате макет и отрисовка могут выполняться между задачами, обеспечивая пользователю более быстрый визуальный отклик, измеряемый теперь гораздо более коротким взаимодействием с указателем.

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

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

Три диаграммы, на которых показаны задачи без уступки, с уступкой и с уступкой и продолжением. Без уступки возникают длительные задачи. При уступке задач становится больше, они короче, но могут прерываться другими, не связанными с ними задачами. При уступке и продолжении задач становится больше, но они короче, а порядок их выполнения сохраняется.
Когда вы используете scheduler.yield(), продолжение начинается с того места, где оно остановилось, прежде чем перейти к другим задачам.

Поддержка разных браузеров

scheduler.yield() пока поддерживается не во всех браузерах, поэтому требуется резервный вариант.

Одно из решений – добавить в сборку scheduler-polyfill, а затем использовать scheduler.yield() напрямую. Полифил будет использовать другие функции планирования задач, чтобы обеспечить одинаковую работу в разных браузерах.

В качестве альтернативы можно написать менее сложную версию в несколько строк, используя только setTimeout, обернутый в Promise, в качестве резервного варианта, если scheduler.yield() недоступен.

function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  // Fall back to yielding with setTimeout.
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

Браузеры, не поддерживающие scheduler.yield(), не смогут использовать приоритетное продолжение, но будут уступать управление, чтобы не зависать.

Наконец, могут быть случаи, когда ваш код не может позволить себе уступить основному потоку, если его продолжение не является приоритетным (например, известная занятая страница, где уступка рискует не завершить работу в течение некоторого времени). В этом случае scheduler.yield() можно рассматривать как своего рода прогрессивное улучшение: уступать в браузерах, где доступен scheduler.yield(), в противном случае продолжать.

Это можно сделать, обнаружив функцию и вернувшись к ожиданию одной микрозадачи в удобной однострочной строке:

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

Разбивайте длительные задачи с помощью scheduler.yield()

Преимущество использования любого из этих методов использования scheduler.yield() заключается в том, что вы можете await его в любой функции async.

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

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

    // Yield to the main thread:
    await yieldToMain();
  }
}

Продолжение runJobs() будет иметь приоритет, но по-прежнему позволит выполнять более приоритетную работу, например визуально реагировать на действия пользователя, не дожидаясь завершения потенциально длинного списка заданий.

Однако это неэффективное использование уступки. scheduler.yield() – это быстрый и эффективный метод, но он требует дополнительных ресурсов. Если некоторые задания в jobQueue очень короткие, то накладные расходы могут быстро превысить время, затрачиваемое на выполнение фактической работы.

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

async function runJobs(jobQueue, deadline=50) {
  let lastYield = performance.now();

  for (const job of jobQueue) {
    // Run the job:
    job();

    // If it's been longer than the deadline, yield to the main thread:
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

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

Серия функций, показанная на панели производительности инструментов разработчика Chrome, выполнение которых разбито на несколько задач.
Задания, разделенные на несколько задач.

Не использовать isInputPending()

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

API isInputPending() позволяет проверить, пытался ли пользователь взаимодействовать со страницей, и уступить, только если ожидается ввод.

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

Однако с момента запуска этого API наше понимание доходности возросло, особенно с введением INP. Мы больше не рекомендуем использовать этот API. Вместо этого советуем уступать управление независимо от того, ожидается ли ввод, по следующим причинам:

  • В некоторых случаях isInputPending() может возвращать false, даже если пользователь взаимодействовал с контентом.
  • Ввод – не единственный случай, когда задачи должны уступать. Анимация и другие регулярные обновления пользовательского интерфейса могут быть не менее важны для обеспечения адаптивности веб-страницы.
  • С тех пор были представлены более комплексные API, которые решают проблемы с уступкой, такие как scheduler.postTask() и scheduler.yield().

Заключение

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

  • Передавайте управление основному потоку для выполнения критически важных пользовательских задач.
  • Используйте scheduler.yield() (с резервным вариантом для разных браузеров), чтобы эргономично уступать и получать приоритетные продолжения.
  • Наконец, сведите к минимуму количество операций в функциях.

Чтобы узнать больше о scheduler.yield(), его родственнике для явного планирования задач scheduler.postTask() и приоритизации задач, ознакомьтесь с документацией по Prioritized Task Scheduling API.

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

Особая благодарность Филипу Уолтону (Philip Walton) за техническую проверку этого руководства.