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

Вам говорили: «Не блокируйте основной поток» и «Разбивайте длительные задачи на части», но что значит это делать?

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

Как правило, советы по обеспечению быстрой работы JavaScript-приложений сводятся к следующему:

  • «Не блокируйте основную ветку обсуждения».
  • «Разбивайте свои длинные задачи на части».

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

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

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

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

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

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

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

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

В чём заключается основная идея?

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

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

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

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

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

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

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

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

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

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

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

В архитектуре программного обеспечения часто советуют разбивать работу на более мелкие функции:

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

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

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

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

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

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

В данном случае saveSettings() запускается щелчком пользователя, и поскольку браузер не может отобразить ответ, пока вся функция не завершится, результатом этой длительной задачи является медленный и неотзывчивый пользовательский интерфейс, что будет оценено как низкий показатель взаимодействия с последующим отображением (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);
}

Это называется уступкой (yielding ) и лучше всего подходит для последовательности выполнения нескольких функций.

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

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: не поддерживается.

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 функции.

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

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.
  • Край: 87.
  • Firefox: не поддерживается.
  • Safari: не поддерживается.

Source

Функция isInputPending() предоставляет способ проверки того, пытался ли пользователь взаимодействовать со страницей, и возвращает управление только в том случае, если ввод данных находится в состоянии ожидания.

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

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

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

Заключение

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

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

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

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

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

Изображение для миниатюры взято с Unsplash , любезно предоставлено Амирали Мирхашемиан .