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

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

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

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

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

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

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

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

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

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

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

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

Какова основная нить?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Один из методов, который разработчики использовали для разбиения задач на более мелкие, — это 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() не является подходящим инструментом для этой работы — по крайней мере, при таком использовании.

Используйте async / await для создания точек доходности

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

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

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

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

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

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

В результате некогда монолитная задача теперь разбита на отдельные задачи.

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

Специальный API планировщика

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

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

Поддержка браузера

  • 94
  • 94
  • Икс

Источник

API-интерфейс планировщика предлагает функцию postTask() , которая позволяет более детально планировать задачи и является одним из способов помочь браузеру расставить приоритеты в работе, чтобы задачи с низким приоритетом уступали основному потоку. postTask() использует промисы и принимает одну из трех настроек priority :

  • 'background' для задач с самым низким приоритетом.
  • 'user-visible' для задач со средним приоритетом. Это значение по умолчанию, если priority не установлен.
  • 'user-blocking' для критически важных задач, которые необходимо выполнять с высоким приоритетом.

В качестве примера возьмем следующий код, где API postTask() используется для запуска трех задач с максимально возможным приоритетом, а оставшихся двух задач — с минимально возможным приоритетом.

function saveSettings () {
  // Validate the form at high priority
  scheduler.postTask(validateForm, {priority: 'user-blocking'});

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, {priority: 'user-blocking'});

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, {priority: 'background'});

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, {priority: 'user-blocking'});

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, {priority: 'background'});
};

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

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

Это упрощенный пример использования postTask() . Можно создавать экземпляры различных объектов TaskController , которые могут разделять приоритеты между задачами, включая возможность изменять приоритеты для разных экземпляров TaskController по мере необходимости.

Встроенный выход с продолжением с использованием будущего API scheduler.yield()

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

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

Этот код во многом знаком, но вместо использования yieldToMain() он использует await scheduler.yield() .

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

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

Использование scheduler.postTask() с priority: 'user-blocking' также имеет высокую вероятность продолжения из-за высокого приоритета user-blocking , поэтому на данный момент этот подход можно использовать в качестве альтернативы.

Использование setTimeout() (или scheduler.postTask() с priority: 'user-visibile' или без явного priority ) планирует задачу в конце очереди и, таким образом, позволяет другим ожидающим задачам выполняться до продолжения.

Не используйте isInputPending()

Поддержка браузера

  • 87
  • 87
  • Икс
  • Икс

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

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

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

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

Заключение

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

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

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

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

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