Оптимізуйте довгі завдання у JavaScript

Вам казали «не блокуйте основний потік» та «розбивайте свої довгі завдання», але що означає робити ці речі?

Якщо ви читаєте багато матеріалів про веб-продуктивності, то поради щодо забезпечення швидкості ваших додатків JavaScript, як правило, включають деякі з цих цікавих фактів:

  • “Не блокуйте основний потік”.
  • “Розбивайте свої довгі завдання”.

Що це все означає? Використання  меншої кількості  JavaScript — це добре, але чи означає це автоматично швидший інтерфейс користувача протягом усього життєвого циклу сторінки? Можливо, а може й ні.

Щоб зрозуміти, чому так важливо оптимізувати завдання JavaScript, вам необхідно зрозуміти роль завдань і те, як браузер їх обробляє — і це починається з розуміння того, що таке завдання.

Що таке завдання?

Завдання  це будь-яка окрема частина роботи, яку виконує браузер. Завдання включають такі роботи, як рендеринг, аналіз HTML і CSS, виконання написаного вами коду JavaScript та інші речі, над якими ви не можете безпосередньо контролювати. З усього цього JavaScript, який ви пишете та розгортаєте в Інтернеті, є основним джерелом завдань.

Оптимізація JavaScript
Зображення завдання,  click обробником події клацання у профільнику продуктивності в Chrome DevTools.

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

Яка основна нитка?

Основний потік  це місце, де в браузері виконується більшість завдань. Він недаремно називається основним потоком: це єдиний потік, в якому майже весь написаний вами JavaScript виконує свою роботу.

Основний потік може обробляти лише одне завдання одночасно. Коли завдання перевищують певну точку – точніше, 50 мілісекунд – вони класифікуються як  довгі задачі  . Якщо користувач намагається взаємодіяти зі сторінкою під час виконання тривалого завдання або якщо необхідно виконати важливе оновлення рендерингу, браузер затримуватиметься під час виконання цієї роботи. Це призводить до затримки взаємодії чи рендерингу.

Оптимізація JavaScript
Довге завдання, як показано у профільнику продуктивності Chrome. Довгі завдання позначаються червоним трикутником у кутку задачі, а блокуюча частина завдання заповнена візерунком з червоних діагональних смуг.

Вам потрібно  розбити  завдання. Це означає, що потрібно взяти одну довгу задачу та розділити її на дрібніші завдання, виконання яких окремо потребує менше часу.

Оптимізація JavaScript
Візуалізація одного довгого завдання порівняно з тим самим завданням, розбитим на п’ять більш коротких завдань.

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

Оптимізація JavaScript
Візуалізація того, що відбувається з взаємодіями, коли завдання занадто довгі і браузер не може реагувати на взаємодії досить швидко, а також коли більш довгі завдання розбиваються більш дрібні завдання.

У верхній частині попереднього малюнка обробник подій, поставлений у чергу в результаті взаємодії з користувачем, повинен був дочекатися одного довгого завдання, перш ніж він зможе запуститися. Це затримує взаємодію. Внизу обробник подій може запуститися раніше. Оскільки обробник подій мав можливість запускатись між меншими завданнями, він запускається раніше, ніж якби йому доводилося чекати завершення довгого завдання. У верхньому прикладі користувач міг помітити затримку; внизу взаємодія могла здатися  миттєвою  .

Проблема, однак, у тому, що поради «розбивати свої довгі завдання» та «не блокувати основний потік» є недостатньо конкретними, якщо ви вже не знаєте,  як  це робити. Це те, що пояснить це керівництво.

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

Поширена порада в архітектурі програмного забезпечення – розбити вашу роботу на дрібніші функції. Це дає вам переваги кращої читання коду та зручності супроводу проекту. Це також полегшує написання тестів.

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

У цьому прикладі є функція з ім’ям  saveSettings() , яка викликає п’ять функцій у собі виконання певної роботи, наприклад перевірки форми, відображення лічильника, відправки даних тощо. буд. Концептуально це добре спроектовано. Якщо вам потрібно налагодити одну з цих функцій, можна переглянути дерево проекту, щоб з’ясувати, що робить кожна функція.

Проблема, однак, полягає в тому, що JavaScript не запускає кожну з цих функцій як окремі завдання, оскільки вони виконуються всередині функції  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() не є відповідним інструментом для цієї роботи, принаймні при такому використанні.

Крім  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();  }}

В результаті колись монолітне завдання тепер розбито на окремі завдання.

Оптимізація JavaScript
Функція  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() , щоб можна було обробити введення користувача. В іншому випадку наступне завдання буде перенесено з початку черги і виконуватиметься безперервно. Він робитиме це доти, доки не залишиться більше завдань.

Оптимізація JavaScript
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'});};

Тут пріоритет завдань планується таким чином, щоб завдання з пріоритетом браузера, такі як взаємодія з користувачем могли виконуватися.

Оптимізація JavaScript
Під час запуску  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() .

Оптимізація JavaScript
Візуалізація виконання завдання без поступки, зі поступкою, а також зі поступкою та продовженням. При використанні  scheduler.yield() виконання завдання поновлюється з того місця, де його було зупинено, навіть після точки виходу.

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

Висновок

Керувати завданнями складно, але це допомагає вашій сторінці швидше реагувати на взаємодію користувача. Не існує єдиної ради з управління завданнями та розміщення пріоритетів. Скоріше це кілька різних технік. Ще раз повторю: ось основні моменти, які слід враховувати під час управління завданнями:

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

За допомогою одного або кількох цих інструментів ви зможете структурувати роботу своєї програми так, щоб вона віддавала пріоритет потребам користувача, гарантуючи при цьому виконання менш важливої ​​роботи. Це покращить досвід користувача, зробить його більш чуйним і приємним у використанні.

Джерело