Вам говорили «не блокируйте основной поток» и «разбивайте длинные задачи», но что значит делать эти вещи?
Опубликовано: 30 сентября 2022 г., Последнее обновление: 19 декабря 2024 г.
Общие советы по обеспечению быстрой работы приложений JavaScript сводятся к следующему:
- «Не блокируйте основную ветку».
- «Разделяйте длительные задачи».
Это отличный совет, но сколько работы это потребует? Меньше JavaScript — это хорошо, но означает ли это автоматически более отзывчивые пользовательские интерфейсы? Возможно, а может и нет.
Чтобы понять, как оптимизировать задачи в JavaScript, сначала нужно узнать, что такое задачи и как браузер их обрабатывает.
Что такое задача?
Задача — это любая отдельная операция, выполняемая браузером. Эта работа включает в себя рендеринг, парсинг HTML и CSS, выполнение JavaScript и другие виды операций, которые вы можете не контролировать напрямую. Из всего этого написанный вами JavaScript, пожалуй, является самым крупным источником задач.

click
, показана в профилировщике производительности Chrome DevTools.Задачи, связанные с JavaScript, влияют на производительность несколькими способами:
- Когда браузер загружает файл JavaScript во время запуска, он ставит в очередь задачи по анализу и компиляции этого JavaScript, чтобы его можно было выполнить позже.
- В другие моменты времени в течение жизни страницы задачи ставятся в очередь, когда JavaScript работает, например, реагирование на взаимодействия через обработчики событий, анимация на основе JavaScript и фоновая активность, такая как сбор аналитических данных.
Все эти процессы, за исключением веб-воркеров и подобных API, происходят в основном потоке.
Какова основная тема?
Основной поток — это место, где в браузере выполняется большинство задач, и где выполняется почти весь написанный вами JavaScript.
Основной поток может обрабатывать только одну задачу одновременно. Любая задача, выполнение которой занимает более 50 миллисекунд, считается долгой . Для задач, выполнение которых занимает более 50 миллисекунд, общее время задачи за вычетом 50 миллисекунд называется периодом блокировки задачи.
Браузер блокирует взаимодействие во время выполнения задачи любой длительности, но это незаметно для пользователя, если задачи не выполняются слишком долго. Однако, когда пользователь пытается взаимодействовать со страницей, когда на ней находится много длительных задач, пользовательский интерфейс будет казаться неотзывчивым и, возможно, даже неработоспособным, если основной поток будет заблокирован на очень долгое время.

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

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

В верхней части предыдущего рисунка обработчик событий, поставленный в очередь в результате взаимодействия с пользователем, был вынужден ожидать выполнения одной длительной задачи, прежде чем начать выполнение. Это задерживает выполнение взаимодействия. В этом случае пользователь мог заметить задержку. В нижней части рисунка обработчик событий может начать выполняться раньше, и взаимодействие может ощущаться мгновенным .
Теперь, когда вы знаете, почему важно разбивать задачи, вы можете узнать, как это сделать в JavaScript.
Стратегии управления задачами
Распространенный совет по архитектуре программного обеспечения — разбить работу на более мелкие функции:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
В этом примере есть функция saveSettings()
, которая вызывает пять функций для проверки формы, отображения счетчика, отправки данных в бэкэнд приложения, обновления пользовательского интерфейса и отправки аналитики.
Концептуально функция saveSettings()
имеет продуманную архитектуру. Если вам нужно отладить одну из этих функций, вы можете просмотреть дерево проекта, чтобы понять, что делает каждая функция. Такое разделение задач упрощает навигацию по проектам и их поддержку.
Однако потенциальная проблема здесь заключается в том, что JavaScript не запускает каждую из этих функций как отдельную задачу, поскольку они выполняются внутри функции saveSettings()
. Это означает, что все пять функций будут выполняться как одна задача.

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()
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()
можно рассматривать как своего рода прогрессивное улучшение: yield в браузерах, где scheduler.yield()
доступен, в противном случае continue.
Это можно сделать как с помощью обнаружения функций, так и возврата к ожиданию одной микрозадачи в удобной однострочной команде:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Разбейте длительную работу с помощью scheduler.yield()
Преимущество использования любого из этих методов применения scheduler.yield()
заключается в том, что вы можете await
его в любой async
функции.
Например, если вам нужно выполнить ряд заданий, которые часто приводят к увеличению продолжительности задачи, вы можете вставить yields, чтобы разбить задачу.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
Продолжение runJobs()
будет иметь приоритет, но при этом будет разрешено выполнять более приоритетные задачи, например, визуальное реагирование на ввод пользователя, без необходимости ждать завершения потенциально длинного списка задач.
Однако это неэффективное использование yielding. scheduler.yield()
работает быстро и эффективно, но при этом возникают некоторые накладные расходы. Если некоторые задания в jobQueue
очень короткие, то накладные расходы могут быстро привести к тому, что время, затраченное на yielding и возобновление, превысит время выполнения самой работы.
Один из подходов — группировать задания, передавая их друг другу только в том случае, если с момента последней передачи прошло достаточно много времени. Обычно срок составляет 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 наше понимание сути yielding расширилось, особенно с появлением INP. Мы больше не рекомендуем использовать этот API и вместо этого рекомендуем yielding независимо от того, ожидается ли ввод данных или нет, по ряду причин:
-
isInputPending()
может ошибочно возвращатьfalse
несмотря на то, что в некоторых обстоятельствах пользователь взаимодействовал с ним. - Ввод данных — не единственный случай, когда задачи должны быть выполнены. Анимация и другие регулярные обновления пользовательского интерфейса могут быть столь же важны для создания адаптивной веб-страницы.
- С тех пор были введены более комплексные API-интерфейсы, которые решают проблемы, связанные с передачей данных, такие как
scheduler.postTask()
иscheduler.yield()
.
Заключение
Управление задачами — сложная задача, но благодаря этому ваша страница будет быстрее реагировать на действия пользователя. Единого совета по управлению задачами и их приоритизации не существует, но существует ряд различных методов. Повторюсь, вот основные моменты, которые следует учитывать при управлении задачами:
- Переключитесь на основной поток для критически важных задач, с которыми сталкивается пользователь.
- Используйте
scheduler.yield()
(с кроссбраузерным резервным копированием) для эргономичного выполнения и получения продолжений с приоритетом. - Наконец, старайтесь выполнять как можно меньше работы по своим функциям.
Дополнительную информацию о scheduler.yield()
, его явном относительном планировании задач scheduler.postTask()
и приоритизации задач см. в документации по API планирования приоритетных задач .
Используя один или несколько из этих инструментов, вы сможете структурировать работу приложения таким образом, чтобы оно отдавало приоритет потребностям пользователя, обеспечивая при этом выполнение менее важной работы. Это улучшит пользовательский опыт, сделает приложение более отзывчивым и приятным в использовании.
Особая благодарность Филиппу Уолтону за техническую проверку данного руководства.
Изображение миниатюры взято с Unsplash , любезно предоставлено Амирали Мирхашемяном .