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

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

Джереми Вагнер
Джереми Вагнер

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

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

Что все это значит? Использование меньшего количества JavaScript — это хорошо, но означает ли это автоматически более быстрый пользовательский интерфейс на протяжении всего жизненного цикла страницы? Может быть, а может и нет.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Помимо setTimeout() , существует несколько других API, которые позволяют отложить выполнение кода до следующей задачи. Один из них предполагает использование postMessage() для более быстрого тайм-аута . Вы также можете разбить работу с помощью requestIdleCallback() — но будьте осторожны! — requestIdleCallback() планирует задачи с минимально возможным приоритетом и только во время простоя браузера. Когда основной поток перегружен, задачи, запланированные с помощью requestIdleCallback() , могут никогда не запуститься.

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

В оставшейся части этого руководства вы встретите фразу «уступить основной нити», но что это значит? Почему вам следует это сделать? Когда вам следует это сделать?

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

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

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

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

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

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

Уступайте только при необходимости

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

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

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

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

Во время работы saveSettings() он будет циклически перебирать задачи в очереди. Если isInputPending() возвращает true во время цикла, saveSettings() вызовет yieldToMain() , чтобы можно было обработать пользовательский ввод. В противном случае следующая задача будет перенесена из начала очереди и будет выполняться непрерывно. Он будет делать это до тех пор, пока не останется больше задач.

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

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

Другой способ использования isInputPending() — особенно если вы беспокоитесь о предоставлении запасного варианта для браузеров, которые его не поддерживают, — это использовать подход, основанный на времени, в сочетании с необязательным оператором цепочки :

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

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

Пробелы в текущих API

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

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

К счастью, в настоящее время находится в разработке специальный API-интерфейс планировщика, который решает эти проблемы.

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

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

API postTask() имеет три приоритета, которые вы можете использовать:

  • '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 по мере необходимости.

Встроенный выход с продолжением через 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() заключается в продолжении. Это означает, что если вы уступите середину набора задач, другие запланированные задачи продолжатся в том же порядке после точки выхода. Это не позволяет коду сторонних скриптов узурпировать порядок выполнения вашего кода.

Заключение

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

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

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

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

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