Вы наверняка слышали, что не нужно блокировать основной поток и что длительные задачи нужно разбивать на части. Но что это значит?
Дата публикации: 30 сентября 2022 г. Последнее обновление: 19 декабря 2024 г.
Обычно для ускорения приложений JavaScript рекомендуют следующее:
- "Не блокируйте основной поток".
- "Разбивайте длинные задачи".
Это отличный совет, но какую работу он предполагает? Отправка меньшего количества JavaScript – это хорошо, но означает ли это автоматически более отзывчивый пользовательский интерфейс? Может, да, а может, и нет.
Чтобы понять, как оптимизировать задачи в JavaScript, сначала нужно разобраться, что это такое и как браузер их обрабатывает.
Что такое задача?
Задача – это любая отдельная операция, которую выполняет браузер. К таким задачам относятся рендеринг, синтаксический анализ HTML и CSS, выполнение JavaScript и другие процессы, которыми вы не можете управлять напрямую. Из всего этого JavaScript, который вы пишете, возможно, является самым большим источником задач.
click, показана в профилировщике производительности инструментов разработчика Chrome.
Задачи, связанные с JavaScript, влияют на производительность двумя способами:
- Когда браузер скачивает файл JavaScript во время запуска, он ставит в очередь задачи по синтаксическому анализу и компиляции этого кода, чтобы выполнить его позже.
- В других случаях задачи ставятся в очередь, когда JavaScript выполняет работу, например реагирует на взаимодействия через обработчики событий, анимацию на основе JavaScript и фоновую активность, такую как сбор аналитики.
Все это, за исключением веб-воркеров и аналогичных API, происходит в основном потоке.
Что такое основной поток?
Основной поток – это поток, в котором браузер выполняет большинство задач и почти весь код JavaScript.
Основной поток может обрабатывать только одну задачу за раз. Любая задача, выполнение которой занимает более 50 миллисекунд, считается длительной задачей. Если задача выполняется дольше 50 миллисекунд, то время, превышающее этот порог, называется периодом блокировки.
Браузер блокирует взаимодействия, пока выполняется задача любой длительности, но это незаметно для пользователя, если задачи не выполняются слишком долго. Однако если пользователь пытается взаимодействовать со страницей, на которой выполняется много длительных задач, пользовательский интерфейс будет казаться не отвечающим на запросы и, возможно, даже неработающим, если основной поток заблокирован на очень длительные периоды времени.
Чтобы основной поток не блокировался надолго, можно разбить длительную задачу на несколько более мелких.
Это важно, потому что, когда задачи разбиты, браузер может гораздо быстрее реагировать на более приоритетную работу, включая взаимодействие с пользователем. После этого оставшиеся задачи выполняются до конца, чтобы обеспечить выполнение работы, которую вы изначально поставили в очередь.
В верхней части предыдущего рисунка обработчик событий, поставленный в очередь в результате взаимодействия с пользователем, должен был дождаться завершения одной длительной задачи, прежде чем начать работу. Это задерживает взаимодействие. В этом случае пользователь мог заметить задержку. Внизу обработчик событий может начать работать раньше, и взаимодействие может показаться мгновенным.
Теперь, когда вы знаете, почему важно разбивать задачи, вы можете узнать, как это сделать в JavaScript.
Стратегии управления задачами
Общий совет в архитектуре программного обеспечения — разбить вашу работу на более мелкие функции:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
В этом примере есть функция saveSettings(), которая вызывает пять функций для проверки формы, показа индикатора загрузки, отправки данных в серверную часть приложения, обновления пользовательского интерфейса и отправки аналитики.
Концептуально saveSettings() хорошо спроектирован. Если вам нужно отладить одну из этих функций, вы можете перемещаться по дереву проекта, чтобы выяснить, что делает каждая функция. Такое разделение работы упрощает навигацию по проектам и их поддержку.
Однако здесь есть потенциальная проблема: JavaScript не запускает каждую из этих функций как отдельную задачу, поскольку они выполняются в рамках функции saveSettings(). Это означает, что все пять функций будут выполняться как одна задача.
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()
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() теперь разделено на две задачи. В результате макет и отрисовка могут выполняться между задачами, обеспечивая пользователю более быстрый визуальный отклик, измеряемый теперь гораздо более коротким взаимодействием с указателем.
Однако реальное преимущество 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 миллисекунд.
Не использовать isInputPending()
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) за техническую проверку этого руководства.