Головна / Статті

Збереження реактивності сторінки: коли переносити обчислення CPU у веб-робота

Дізнайтеся, чому великі цикли зупиняють інтерфейс браузера, як модель обміну повідомленнями Web Worker допомагає перекласти цю роботу на інший процес, та як вирішити, чи справді певне завдання потребує використання Worker.

Видавець: REACTAPP.TOP12 хв читання2703 слів

Джерела: JavaScript Workers: How to Run Code Without Freezing Your UI

Користувач натискає кнопку, підпис змінюється на „Обробка…“, а потім не відбувається жодної реакції: анімації зупиняються, скролл не працює, подальші натискання ігноруються. Вкладка не зламалась; просто якийсь фрагмент JavaScript монополізує основну схему виконання. У цьому посібнику пояснюється, чому це відбувається, показаний базовий патерн Web Worker для переміщення витратних обчислень в інше місце, а також надано практичний спосіб визначити, коли Worker є правильним рішенням, а коли він лише додає зайвих елементів.

Чому одне тривале завдання заморожує все

Браузер в цілому робить набагато більше, ніж просто виконує ваші скрипти, але JavaScript, який належить до сторінки, виконується на одному основному потоці. Якщо ви хочете краще зрозуміти, як цей потік вписується у багатопроцесну архітектуру браузера, перегляньте однопоточну модель JavaScript проти багатопроцесного двигуна браузера. Практичні наслідки прості: цей потік може стати вузьким місцем.

Візьмемо функцію, яка підсумовує всі цілі числа менше мільярда. У ній немає нічого поганого, і двигун з радістю виконає її до кінця:

function calculate() {
  let total = 0;
  for (let i = 0; i < 1_000_000_000; i++) {
      total += i;
    }
    return total;
  }
calculate();

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

Ланцюжок подій виглядає так:

User clicks button
       ↓
JavaScript starts heavy calculation
       ↓
Main thread stays busy
       ↓
UI work has to wait
       ↓
Page feels frozen

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

Надання складним завданням власного контексту виконання

Web Worker — це окремий контекст виконання JavaScript, який працює незалежно від основного скрипту сторінки. Замість того, щоб накопичувати все в одному потоці:

MAIN THREAD
     │
     ├── UI
     ├── Events
     ├── JavaScript
     └── Heavy calculation  ← problem

ви розділяєте обов’язки так, щоб сторінка зберігала інтерфейс, вхідні дані та логіку, тоді як Worker виконує ресурсомісткі обчислення:

MAIN THREAD                  WORKER
     │                         │
     ├── UI                    ├── Heavy calculation
     ├── User input            ├── Data processing
     └── Page logic            └── Result
             │                  │
             └── messages ─────┘

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

Перший Worker, крок за кроком

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

const worker = new Worker("worker.js");

worker.postMessage(1_000_000_000);

worker.onmessage = (event) => {
  console.log("Result:", event.data);
};

Файл Worker прослуховує надходження повідомлень, виконує цикл за номером, який йому був наданий, та повертає загальну суму:

self.onmessage = (event) => {
  let total = 0;
  for (let i = 0; i < event.data; i++) {
    total += i;
  }
  self.postMessage(total);
};

Для відстеження процесу обробки корисно аналізувати кожен виклик окремо. Сторінка передає дані Worker за допомогою postMessage:

worker.postMessage(...)

Усередині Worker self вказує на глобальний простір Worker, а обробник onmessage — це місце, куди надходять дані:

self.onmessage = (...)

Після завершення обчислень Worker відповідає за допомогою власного postMessage:

self.postMessage(...)

Нарешті, обробник сторінки для об’єкта Worker отримує цю відповідь у форматі event.data:

worker.onmessage = (...)

Зверніть увагу, що присвоєння значення onmessage після виклику postMessage у скрипті сторінки є допустимим: відповідь може надійти лише асинхронно, після того як поточний скрипт завершить виконання, тож обробник завжди буде готовий у потрібний час.

Що змінюється, а що — ні

Корисним способом для уявлення цього процесу є концепція делегування. Основний потік фактично каже "ось дані, розбирайтеся з ними", а працівник (Worker) виконує завдання та повідомляє про його завершення. Без працівника потік є суто послідовним, і інтерфейс користувача чекає:

Main thread
     ↓
Heavy calculation
     ↓
UI waits

З працівником основний потік передає завдання та продовжує обслуговувати сторінку, тоді як обчислення відбувається паралельно:

Main thread ──→ Worker
     │             │
     │             └── Heavy calculation
     │
     └── continues handling the page

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

Взаємодія з Worker: повідомлення, а не DOM

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

document.querySelector("#result");

Усе, що перетинає межі, передається через повідомлення. Дані надсилаються за допомогою:

worker.postMessage(data);

а повідомлення, які надходять у відповідь, отримуються за допомогою:

worker.onmessage = (event) => {
  console.log(event.data);
};

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

Main Thread
     │
     │ postMessage()
     ▼
  Worker
     │
     │ postMessage()
     ▼
Main Thread

Для більшості додатків вузький межовий рубіж у формі повідомлень між „частиною, яка відображає“ та „частиною, яка обробляє“ саме такий дизайн, який потрібен. Worker ніколи не торкається DOM, а сторінка вирішує, що робити з кожним результатом.

Рефакторинг блокуючого обробника кліків

Типовою відправною точкою є обробник кліків, який синхронно обробляє велику набір даних та негайно відображає результат:

button.addEventListener("click", () => {
  const result = processHugeDataset(data);
  renderResult(result);
});

Якщо processHugeDataset() виконується протягом значного часу, основна смуга завантаження залишається зайнятою протягом усього цього часу, і сторінка припиняє реагувати до моменту її завершення.

Версія, заснована на Worker, розділяє обробник на дві частини. Клацання лише надсилає дані; відображення відбувається пізніше, коли надходить повідомлення з результатом:

const worker = new Worker("worker.js");

button.addEventListener("click", () => {
  worker.postMessage(data);
});

worker.onmessage = (event) => {
  renderResult(event.data);
};

Витратна обробка тепер відбувається всередині Worker, а основний потік може продовжувати обробляти сторінку, поки чекає. Є дві речі, які варто пам’ятати щодо такої структури. По-перше, Worker створюється лише один раз, поза обробником, тож багаторазові клацання використовують його знову, замість того щоб створювати новий щоразу. По-друге, якщо користувач може швидко клацнути кілька разів, результати можуть надійти після того, як користувач вже перейшов далі; у реальному коді зазвичай кожне повідомлення позначається ідентифікатором або ігноруються застарілі відповіді, щоб старий результат не перезаписував новіший.

Вирішення, що має потрапити до Worker

Легко стати ентузіастом та почати переміщувати кожну функцію до Worker. Це рідко є гарною ідеєю. Worker знаходить своє застосування тоді, коли завдання є настільки ресурсоємним для CPU, що вимагає конкуренції за час із основним потоком. Типові приклади включають:

  • Обробку або об’єднання великих наборів даних
  • Складні числові чи алгоритмічні обчислення
  • Великомасштабні трансформації JSON
  • Парсинг великих файлів
  • Маніпуляцію зображеннями
  • Обробку аудіо чи відео
  • Стиснення та інші трансформації даних
  • Певні криптографічні чи математичні алгоритми
  • Підсвічування синтаксису та іншу ресурсоємну обробку тексту

Постійна схема залишається однаковою: багато обчислень та мінімальна потреба у прямому впливі на DOM.

Коли Worker є зайвим

Для настільки простої операції, як множення двох чисел, використання Worker є суто зайвим:

const total = price * quantity;

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

Архітектурний побічний ефект: чіткі межі

Worker — це не лише інструмент для покращення продуктивності. Вони також забезпечують розділення обов’язків. Ваш код користувацького інтерфейсу може зосередитися на перетворенні вхідних даних у стан та стану — у відображення:

Input
↓
State
↓
Display

Поки Worker зосереджується на перетворенні вхідних даних у вихідні:

Input data
↓
Processing
↓
Output data

Таке ізоляція ресурсомістких операцій полегшує їх тестування та розуміння, адже Worker поводиться як чиста функція за інтерфейсом повідомлень.

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

Спеціалізовані, спільні та сервісні Worker — це різні інструменти

„Web Worker“ часто використовується так, ніби це назва єдиного API, але існує кілька API, пов’язаних з Worker, які виконують різні функції:

  • Дедикований працівник: виконує JavaScript від імені певної сторінки чи контексту. Саме цей тип використовується в усьому цьому посібнику.
  • Спільний працівник: єдиний працівник, до якого можуть під’єднатися та з яким можна спілкуватися кілька браузерних контекстів (наприклад, кілька вкладок одного походження).
  • Службовий працівник: знаходиться між сторінкою та мережею, щоб забезпечити такі функції, як перехоплення запитів, кешування та підтримка роботи в офлайн-режимі.

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

Деталі, які мають значення у продакшні

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

Слухайте за помилками

Неконтрольована виняток усередині Worker відображається на сторінці як подія error. Приєднайте обробник, щоб збої фіксувалися, а не залишали інтерфейс у стані очікування результату без кінця:

worker.onerror = (error) => {
  console.error("Worker error:", error);
};

Завершуйте Workers, які вам більше не потрібні

Worker продовжує працювати, доки його не зупинять. Якщо Worker створений лише для одноразової роботи, завершіть його на сторінці після того, як ви закінчите роботу:

worker.terminate();

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

Передача даних — це не безкоштовно

Кожне повідомлення передбачає переміщення даних між контекстами. Для більшості значень JavaScript браузер використовує алгоритм структурованого клонування, який створює копію на отримувальній стороні. Для деяких великих бінарних навантажень, таких як екземпляри ArrayBuffer, їх можна передавати як об’єкти, що підлягають передачі — це передає право власності на іншу сторону замість копіювання; відправник більше не може використовувати переданий буфер. Вам не потрібні всі деталі цього прямо зараз, але пам’ятайте одне правило: відведення обчислень від основної нитки не робить передачу даних до неї безкоштовною. Клонування дуже великого об’єкта саме по собі може зайняти значний час на основній нитці.

Робочі процеси та React

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

function handleClick() {
  const result = expensiveCalculation();
  setResult(result);
}

Жодна фреймворк не може зробити так, щоб вимогливі до процесора скрипти JavaScript зникли. Те, що ви можете зробити, — це використовувати Worker як шар обробки даних під вашими компонентами: інтерфейс надсилає дані, Worker їх обробляє, а результат повертається для зберігання у стані та відображення:

React UI
   │
   │ send data
   ▼
Web Worker
   │
   │ process data
   ▼
React UI

Це особливо корисно в інтерфейсах, де користувачі працюють із великими наборами даних, редакторами, візуалізаціями чи іншими складними за обчислювальною потужністю елементами. Сама фреймворк-структура не є ключовою — важливо знати, яка потік виконує ресурсомістку роботу. У компоненті це зазвичай означає створення Worker у рамках ефекту, виклик функції setResult з його обробника повідомлень та завершення роботи цього Worker під час очищення ефекту, щоб компоненти, які більше не використовуються, не залишали Worker у робочому стані.

Workers не є загальним засобом прискорення

Найпоширеніша помилка — використання Worker як універсального рішення для покращення продуктивності. Якщо додаток працює повільно через зайве оновлення відображення, надмірну кількість мережевих запитів, великі розміри файлів, неефективні оновлення React чи заплутаний код, Worker не допоможе вирішити жодну з цих проблем. Спочатку потрібно визначити справжню причину уповільнення, зазвичай за допомогою інструменту аналізу продуктивності браузера, і лише потім вирішити, чи є Worker правильним рішенням.

Корисна коротка перелік перевірок:

  1. Чи справді ця задача вимагає значних ресурсів CPU?
  2. Чи триває її виконання достатньо довго, щоб це вплинуло на швидкість реакції додатку?
  3. Чи потрібен їй прямий доступ до DOM?
  4. Чи можуть вхідні та вихідні дані передаватися у вигляді повідомлень?
  5. Чи варта додаткова складність таких зусиль?

Якщо відповіді свідчать про те, що задача вимагає багато ресурсів, триває довго, не потребує доступу до DOM, підходить для передачі через повідомлення та варта таких зусиль, то Worker, ймовірно, буде хорошим вибором.

Це також допомагає розділити дві проблеми, які легко плутати. У першому випадку JavaScript виконує більше роботи, ніж потрібно, і рішенням є кращий алгоритм чи зменшення обсягу роботи. У другому випадку виконання завдань справді є ресурсоємним, але немає причин блокувати інтерфейс; саме тут стає в пригоді Worker. Worker не перетворює неефективний код на ефективний — він просто надає дорогому у виконанні коду інше місце для роботи.

Ключові висновки

Вам не потрібно запам’ятовувати всю API Worker. Все, що має значення, можна показати на одному малюнку:

MAIN THREAD
     │
     │ "Here is the work."
     ▼
  WORKER
     │
     │ "Here is the result."
     ▼
MAIN THREAD
  • Основний потік керує сторінкою; будь-яке тривале синхронне завдання у ньому затримує обробку введення та оновлення екрана.
  • Спеціалізований Worker виконує ізольований JavaScript та комунікує лише через postMessage та onmessage; він не має доступу до DOM.
  • Перенесення обчислень зберігає відгуковість інтерфейсу, але зазвичай не прискорює самі обчислення, а передача великих даних все одно має свою вартість.
  • Обробляйте ситуації, пов’язані з onerror, викликайте terminate() у тимчасових Worker-ах, а також розгляньте використання об’єктів Transferable для великих бінарних даних.
  • Спочатку проведіть аналіз. Оптимізуйте алгоритм, якщо його робота є марною; використовуйте Worker, коли робота необхідна, але не повинна виконуватися на основній смузі.
  • Наступного разу, коли сторінка зависне під час виконання складних обчислень, запитайте не лише про те, як прискорити функцію, а й чи взагалі вона потребує виконання на основній смузі.