Главная / Статьи

Поддержание адаптивности страницы: когда переносить вычисления 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 в скрипте страницы не приводит к проблемам: ответ может поступить только асинхронно, после завершения выполнения текущего скрипта, поэтому обработчик всегда готов в нужное время.

Что меняется, а что нет

Полезным способом понимания этого процесса является концепция делегирования. Основной поток фактически говорит: «Вот данные, разбирайтесь с ними», а виртуальный рабочий процесс выполняет задачу и сообщает о завершении работы. Без виртуального рабочего процесса поток выполнения строго последовательный, и интерфейс ожидает:

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, передаётся через сообщения. Данные отправляются с помощью:

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. Однако это редко бывает хорошей идеей. Worker находит свое применение тогда, когда задача настолько ресурсоемкая с точки зрения CPU, что требует конкуренции за время с основным потоком. К типичным примерам относятся:

  • Обработка или агрегация больших наборов данных
  • Сложные числовые или алгоритмические вычисления
  • Большие преобразования JSON
  • Распарсивание крупных файлов
  • Манипуляция изображениями
  • Обработка аудио или видео
  • Компрессия и другие преобразования данных
  • Определенные криптографические или математические алгоритмы
  • Выделение синтаксиса и другая ресурсоемкая обработка текста

Основная закономерность всегда одна: много вычислений и небольшая необходимость в прямом взаимодействии с DOM.

Когда Worker — избыточность

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

const total = price * quantity;

Запуск Worker сопряжен с определенными затратами на старт, каждое сообщение влечет за собой дополнительные расходы на обмен данными, к тому же появляется еще один файл с собственным циклом жизни. Для небольших операций лучше использовать обычный JavaScript на основном потоке. Вместо того чтобы спрашивать, можно ли использовать Worker, стоит задуматься, настолько ли сложна операция, что ее выполнение в основном потоке действительно ухудшит пользовательский опыт. Этот вопрос позволяет исключить большинство случаев, когда использование Worker лишь увеличит сложность.

Архитектурный побочный эффект: четкое разделение

Worker — это не только инструмент для улучшения производительности. Они также способствуют разделению ответственностей. Код интерфейса может сосредоточиться на преобразовании входных данных в состояние и состояния в отображаемый контент:

Input
↓
State
↓
Display

Пока воркер сосредотачивается на преобразовании входных данных в выходные:

Input data
↓
Processing
↓
Output data

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

Однако существует определенный компромисс: теперь необходимо управлять асинхронным потоком на основе сообщений, процессом запуска и остановки воркеров, способами выявления сбоев и передачей данных через границы. Это разумная цена, когда объем работы велик. Хорошее инжиниринговое решение заключается не в устранении сложности повсюду, а в ее размещении там, где она приносит пользу.

Специализированные, общедоступные и сервис-воркеры — это разные инструменты

Термин «Web Worker» часто используется так, будто он обозначает единственный API, но существует несколько связанных с воркерами API, каждый из которых выполняет свои функции:

  • Дедикированный рабочий процесс: выполняет JavaScript от имени конкретной страницы или контекста. Именно этот тип используется во всем данном руководстве.
  • Общий рабочий процесс: единственный рабочий процесс, к которому могут подключаться и взаимодействовать несколько браузерных контекстов (например, несколько вкладок одного и того же источника).
  • Рабочий процесс обслуживания: находится между страницей и сетью, обеспечивая такие функции, как перехват запросов, кэширование и работа в офлайн-режиме.

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

Важные детали в производственной среде

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

Наблюдение за ошибками

Необработанная исключение внутри Worker отображается на странице как событие error. Необходимо добавить обработчик, чтобы ошибки фиксировались, а не оставляли интерфейс в состоянии ожидания результата бесконечно:

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

Прекращение работы Worker, которые больше не нужны

Worker продолжает работать до тех пор, пока его не остановят. Если Worker создан только для однократной задачи, его следует остановить с страницы после завершения работы:

worker.terminate();

Это особенно важно для временных Worker; долговечный 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 в активном состоянии.

Worker не являются универсальным средством для ускорения

Самая распространённая ошибка — использование Worker как универсального решения для повышения производительности. Если приложение работает медленно из-за ненужных перерисовок, чрезмерного количества сетевых запросов, слишком больших ресурсов, неэффективных обновлений React или запутанного кода, Worker не поможет с любым из этих проблем. Сначала необходимо выявить настоящий узкое место, обычно с помощью инструмента анализа производительности браузера, и только затем решить, является ли Worker подходящим решением.

В помощь может прийти краткий чек-лист:

  1. Действительно ли задача требует больших вычислений процессором?
  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() у временных рабочих процессов и рассмотрите использование объектов типа Transferable для передачи крупных двоичных данных.
  • Сначала проведите анализ производительности. Оптимизируйте алгоритм, если происходит лишняя трата ресурсов; обращайтесь к рабочим процессам, когда выполнение задачи необходимо, но она не должна выполняться на основном потоке.
  • В следующий раз, когда страница зависнет во время выполнения сложных вычислений, задайте себе вопрос не только о том, как сделать функцию быстрее, но и о том, действительно ли она должна выполняться на основном потоке.