Вам говорили «не блокируйте основной поток» и «разбивайте свои длинные задачи», но что значит делать эти вещи?
Общие советы по обеспечению быстроты JavaScript-приложений сводятся к следующим советам:
- «Не блокируйте основной поток».
- «Разбивайте свои длинные задачи».
Это отличный совет, но какую работу он предполагает? Меньшее количество JavaScript — это хорошо, но означает ли это автоматически более отзывчивый пользовательский интерфейс? Может быть, а может и нет.
Чтобы понять, как оптимизировать задачи в JavaScript, сначала нужно знать, что это за задачи и как с ними справляется браузер.
Что такое задача?
Задача — это любая отдельная часть работы, которую выполняет браузер. Эта работа включает в себя рендеринг, анализ HTML и CSS, запуск JavaScript и другие виды работы, которые вы не можете контролировать напрямую. Из всего этого написанный вами JavaScript является, пожалуй, крупнейшим источником задач.
Задачи, связанные с JavaScript, влияют на производительность несколькими способами:
- Когда браузер загружает файл JavaScript во время запуска, он ставит в очередь задачи для анализа и компиляции этого JavaScript, чтобы его можно было выполнить позже.
- В других случаях в течение жизни страницы задачи ставятся в очередь, когда JavaScript работает, например управление взаимодействием через обработчики событий, анимацию на основе JavaScript и фоновые действия, такие как сбор аналитики.
Все это — за исключением веб-воркеров и подобных API — происходит в основном потоке.
Какова основная нить?
Основной поток — это место, где в браузере выполняется большинство задач и где выполняется почти весь написанный вами код JavaScript.
Основной поток может обрабатывать только одну задачу одновременно. Любая задача, которая занимает более 50 миллисекунд, является долгой задачей . Для задач, длительность которых превышает 50 миллисекунд, общее время задачи минус 50 миллисекунд называется периодом блокировки задачи.
Браузер блокирует взаимодействие во время выполнения задачи любой длины, но это незаметно для пользователя, пока задачи не выполняются слишком долго. Однако когда пользователь пытается взаимодействовать со страницей, когда имеется много длительных задач, пользовательский интерфейс будет не отвечать на запросы и, возможно, даже сломается, если основной поток заблокирован на очень длительные периоды времени.
Чтобы основной поток не блокировался слишком надолго, можно разбить длинную задачу на несколько более мелких.
Это важно, потому что когда задачи разбиты на части, браузер может гораздо быстрее реагировать на более приоритетную работу, включая взаимодействие с пользователем. После этого оставшиеся задачи выполняются до завершения, гарантируя, что работа, которую вы изначально поставили в очередь, будет выполнена.
В верхней части предыдущего рисунка обработчик событий, поставленный в очередь в результате взаимодействия с пользователем, должен был дождаться одной длинной задачи, прежде чем она сможет начаться. Это задерживает взаимодействие. В этом сценарии пользователь мог заметить задержку. Внизу обработчик событий может начать выполняться раньше, и взаимодействие может ощущаться мгновенно .
Теперь, когда вы знаете, почему важно разбивать задачи, вы можете узнать, как это сделать с помощью JavaScript.
Стратегии управления задачами
Распространенный совет в архитектуре программного обеспечения — разбить вашу работу на более мелкие функции:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
В этом примере есть функция с именем saveSettings()
, которая вызывает пять функций для проверки формы, отображения счетчика, отправки данных в серверную часть приложения, обновления пользовательского интерфейса и отправки аналитики.
Концептуально saveSettings()
имеет хорошую архитектуру. Если вам нужно отладить одну из этих функций, вы можете просмотреть дерево проекта, чтобы выяснить, что делает каждая функция. Такое разделение работы упрощает навигацию и поддержку проектов.
Однако потенциальная проблема здесь заключается в том, что JavaScript не запускает каждую из этих функций как отдельные задачи, поскольку они выполняются внутри функции 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();
}
}
В результате некогда монолитная задача теперь разбита на отдельные задачи.
Специальный API планировщика
setTimeout
— это эффективный способ разбить задачи, но у него есть недостаток: когда вы уступаете основному потоку, откладывая выполнение кода в последующей задаче, эта задача добавляется в конец очереди.
Если вы контролируете весь код на своей странице, вы можете создать свой собственный планировщик с возможностью определения приоритета задач, но сторонние скрипты не будут использовать ваш планировщик. По сути, вы не можете расставлять приоритеты в работе в таких условиях. Вы можете только разбить его на части или явно подчиниться взаимодействиям с пользователем.
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'});
};
Здесь приоритет задач планируется таким образом, что задачи с приоритетом браузера, такие как взаимодействие с пользователем, могут выполняться между ними по мере необходимости.
Это упрощенный пример использования postTask()
. Можно создавать экземпляры различных объектов TaskController
, которые могут разделять приоритеты между задачами, включая возможность изменять приоритеты для разных экземпляров TaskController
по мере необходимости.
Встроенный выход с продолжением с использованием API scheduler.yield()
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()
заключается в продолжении. Это означает, что если вы уступите середину набора задач, другие запланированные задачи продолжатся в том же порядке после точки выхода. Это позволяет избежать прерывания кода из сторонних скриптов порядка выполнения вашего кода.
Не используйте isInputPending()
API isInputPending()
предоставляет способ проверки того, пытался ли пользователь взаимодействовать со страницей, и передает данные только в том случае, если ввод ожидает ответа.
Это позволяет JavaScript продолжать работу, если нет ожидающих входных данных, вместо того, чтобы уступить и оказаться в конце очереди задач. Это может привести к впечатляющему повышению производительности, как подробно описано в Intent to Ship , для сайтов, которые в противном случае не могли бы вернуться в основной поток.
Однако с момента запуска этого API наше понимание доходности возросло, особенно с появлением INP. Мы больше не рекомендуем использовать этот API , а вместо этого рекомендуем передавать данные независимо от того, ожидается ли ввод или нет, по ряду причин:
-
isInputPending()
может ошибочно возвращатьfalse
несмотря на то, что пользователь взаимодействовал в некоторых обстоятельствах. - Ввод — не единственный случай, когда задачи должны давать результаты. Анимации и другие регулярные обновления пользовательского интерфейса могут быть не менее важны для создания адаптивной веб-страницы.
- С тех пор были представлены более комплексные API-интерфейсы доходности, которые решают проблемы доходности, такие как
scheduler.postTask()
иscheduler.yield()
.
Заключение
Управление задачами является сложной задачей, но это гарантирует, что ваша страница будет быстрее реагировать на взаимодействия с пользователем. Не существует единого совета по управлению задачами и расстановке приоритетов, а есть несколько различных методов. Еще раз повторю: вот основные моменты, которые следует учитывать при управлении задачами:
- Перейдите в основной поток для решения критических задач, с которыми сталкивается пользователь.
- Расставьте приоритеты задач с помощью
postTask()
. - Рассмотрите возможность экспериментирования с
scheduler.yield()
. - Наконец, выполняйте как можно меньше работы в своих функциях.
С помощью одного или нескольких из этих инструментов вы сможете структурировать работу своего приложения так, чтобы оно отдавало приоритет потребностям пользователя, гарантируя при этом выполнение менее важной работы. Это улучшит пользовательский опыт, сделает его более отзывчивым и приятным в использовании.
Особая благодарность Филипу Уолтону за техническую проверку этого руководства.
Миниатюрное изображение взято с сайта Unsplash , любезно предоставлено Амирали Мирхашемяном .