Всем, привет, меня зовут Дмитрий, я React-разработчик, и я снова здесь и попробую сегодня рассказать про Web Workers.

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

Почему так происходит? Всё дело в том, что React, как и JS, в браузере работает на одном основном потоке. Точнее, чтобы меня не закидали помидорами, React — это библиотека JS, и она полностью работает в основном потоке браузера, который однопоточен. Когда вы запускаете тяжёлую синхронную задачу, она полностью занимает этот поток и блокирует его, мешая браузеру рендерить интерфейс и обрабатывать события пользователя.

В современном фронтенде отзывчивость интерфейса — требование по умолчанию. Пользователи ожидают, что ваше приложение будет мгновенно реагировать на действия, даже если оно выполняет тяжёлые вычисления или загружает большие объёмы данных.

В этой статье мы разберём, как можно решить эту проблему одним из вариантов — с помощью Web Workers.

Что такое Web Workers

Чтобы решить проблему замороженного интерфейса при тяжёлых вычислениях, браузеры предлагают встроенный механизм — Web Workers. Что это? Это, по сути, API, который позволяет запускать JS-код в отдельном потоке параллельно с основным. Такой поток не мешает браузеру выполнять рендеринг страницы и обрабатывать клики пользователя, пока воркер занят вычислениями.

Проще говоря, Web Worker — это своего рода мини-программа, которая живёт отдельно от вашей страницы (в нашем контексте – отдельно от Реакта) и общается с ней сообщениями.

Как работают Web Workers

Когда вы создаёте воркер, браузер запускает новый поток и загружает туда отдельный JS-файл. Основной поток и воркер не разделяют память. Они обмениваются сообщениями через механизм postMessage / onmessage. Основной поток отправляет воркеру данные, воркер их получает, что-то вычисляет и отправляет результат обратно.

Таким образом, воркер может спокойно крутить тяжёлый цикл, парсить гигабайтный JSON или шифровать данные, не блокируя интерфейс пользователя.

Есть ещё Shared Workers — что это?

Кроме обычных Web Workers, существует ещё один тип воркеров — Shared Workers. Стоит их упомянуть. Я сделаю это одним абзацем – для полноты картины. Итак, эти воркеры отличаются тем, что могут быть подключены сразу несколькими вкладками, фреймами или окнами одного и того же происхождения. В таких случаях вместо того, чтобы создавать отдельный воркер на каждую вкладку, можно использовать один общий, который управляет логикой и состоянием для всех подключённых клиентов.

Получается, Shared Worker живет отдельно от каждого окна: его «хата» с краю, но он на стрёме. Такие воркеры полезны, когда, например, нужно синхронизировать состояние между вкладками, кешировать данные, выполнять ресурсоёмкие операции централизованно, имея общий вычислительный процесс.

Ограничения: они не поддерживаются во всех браузерах, например, Safari ограничивает поддержку, и требуют более сложной логики для управления.

Это было лирическое отступление, вернёмся к обычным web-воркерам.

Ограничения Web Workers

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

1. Нет доступа к DOM

Воркер не может взаимодействовать с документом напрямую, т.е. нельзя вставить элемент, изменить класс, прочитать значение поля формы.
Это сделано намеренно, чтобы исключить гонки данных и конфликт при обновлении DOM с нескольких потоков. Если вам нужно обновить интерфейс по результатам работы воркера, нужно послать сообщение в основной поток, а он уже обновит DOM.

2. Общение с воркером только через postMessage

Единственный способ общения с воркером — это обмен сериализованными сообщениями. В воркер мы отправляем сообщения с помощью postMessage. А основной поток слушает входящие от форкера события.

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

// В основном потоке:
worker.postMessage({ task: 'compute', payload: data });
worker.onmessage = (event) => {
  console.log('Результат воркера:', event.data);
};

3. Нужно сериализовать данные

Так как данные пересылаются между потоками, они должны сериализоваться.
Это значит, что:

  • нельзя передавать функции или ссылки на DOM-элементы;

  • большие объёмы данных могут сериализоваться и десериализоваться, скажем так, - небыстро;

  • для передачи больших буферов данных лучше использовать Transferable Objects, например, ArrayBuffer, чтобы избежать копирования.

Когда нужны Web Workers

Теперь, разобравшись, что такое Web Workers и как они работают, остаётся понять, когда их действительно нужно использовать. Главный принцип здесь простой: воркеры нужны тогда, когда ваши вычисления занимают столько времени, что заметно тормозят интерфейс.

Длинные циклы / тяжёлые вычисления

Если ваш компонент запускает алгоритм с множеством итераций или сложной логикой, например, поиск пути, сортировка больших массивов, машинное обучение прямо в браузере — это почти всегда блокирует главный поток и портит UX. В таких случаях воркер сможет помочь и спокойно отрабатывать во благо UI.

Парсинг больших JSON

Когда API возвращает JSON размером в несколько мегабайт, например, отчёты, статистика или дампы данных, его парсинг (JSON.parse) на главном потоке может вызвать заметную паузу. Отправив сырой текст в воркер и распарсив его там, вы сохраните отзывчивость страницы.

Работа с файлами

Часто пользователи загружают файлы на клиент, и их нужно прочитать, проанализировать или преобразовать в другой формат. Например, чтение CSV с последующим преобразованием в массив объектов или генерация Excel-файла из данных таблицы. Вот как раз «последующее преобразование и генерация Excel» могут быть ресурсоёмкими и можно доверить их воркеру.

Когда не нужны воркеры

Ну и по классике, если можно использовать – это не значит, что нужно пихать во все места. Здесь правило звучит так: не оптимизируй преждевременно.

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

Кроме того, воркеры имеют накладные расходы: нужно сериализовать данные и управлять их жизненным циклом.

Используйте Web Workers только там, где это действительно нужно.

Как использовать Web Workers

Web Workers интегрируются в приложение через стандартное API браузера. Давайте посмотрим на базовый пример работы с воркером на чистом JS — это поможет понять общую идею, прежде чем переходить к интеграции с React.

1. Создаём файл воркера

Воркер — это отдельный JS-файл.
В нём вы пишете код, который будет выполняться в другом потоке.
Например, worker.js:

// worker.js

self.onmessage = (event) => {
  const data = event.data;
  // например, делаем тяжёлую работу
  const result = data.number * 2;

  // возвращаем результат в основной поток
  self.postMessage(result);
};

Где:
self — глобальный объект внутри воркера.
onmessage — обработчик сообщений от основного потока.
postMessage — отправка ответа обратно.

2. Создаём воркер в основном потоке

В коде страницы или приложения подключаем воркер:

const worker = new Worker('./worker.js');

3. Обмениваемся данными

Чтобы отправить данные воркеру, используем метод postMessage:

worker.postMessage({ number: 42 });

В основном потоке слушаем ответ от воркера:

worker.onmessage = (event) => {
  console.log('Результат воркера:', event.data);
};

Чтобы завершить воркер, когда он больше не нужен, вызываем:

worker.terminate()

Вот, собственно, и вся структура работы с воркерами. Единственное, нужно подключать воркер, как часть сборки в React, потому что, когда мы пишем:

const worker = new Worker('./worker.js');

браузер ожидает, что ./worker.js — это JS-файл где-то на сервере. Но Webpack пакует всё в один бандл — и worker.js оказывается внутри, а не рядом как отдельный файл.

Чтобы Webpack корректно вынес воркер в отдельный файл и отдал его браузеру — нужен loader. Для этого нужно будет установить worker-loader, и в файл webpack.config.js сделать следующее:

module.exports = {
  // …
  module: {
    rules: [
      {
        test: /\.worker\.js$/,
        use: { loader: 'worker-loader' }
      },
      // другие правила
    ]
  }
};

Теперь можно подключать воркер, как обычный импорт:

import MyWorker from './myWorker.worker.js';

const worker = new MyWorker();

worker.postMessage({ number: 42 });

worker.onmessage = (event) => {
  //…
};

В последних версиях Webpack (v5+) использование worker-loader считается устаревшим (deprecated). Вместо него рекомендуется использовать встроенную поддержку воркеров через new URL()

const worker = new Worker(new URL('./worker.js', import.meta.url));

Выражение new URL(..., import.meta.url) уже поддерживается большинством современных сборщиков и браузеров. Этот способ позволяет корректно подключать воркеры как часть модульной системы без необходимости дополнительных загрузчиков.

Web Workers и React: подводные камни

Использовать Web Workers в React-проектах иногда полезно, но есть несколько важных особенностей, о которых нужно помнить.
React не знает о существовании воркеров и не управляет ими, поэтому при интеграции легко допустить ошибки в синхронизации состояния, в управлении жизненным циклом или в работе с памятью.

State и воркеры: как синхронизировать?

React управляет состоянием компонентов и их деревом, а воркеры работают вне этого дерева, в своём изолированном потоке. Это значит, что между React и воркером нет общей памяти. Чтобы обновить UI по результатам работы воркера, данные нужно передать обратно через событие onmessage, а затем обновить React state.

Схема выглядит так:

const [result, setResult] = useState(null);

useEffect(() => {
  const worker = new Worker(new URL('./worker.js', import.meta.url));

  worker.onmessage = (event) => {
    setResult(event.data); // обновляем React state
  };

  worker.postMessage('start');

  return () => {
    worker.terminate();
  };
}, []);

Никогда не пытайтесь напрямую изменить React state из воркера — он не имеет доступа к setState или хукам. Всегда используйте onmessage, чтобы получить данные, и уже в основном потоке вызывать setState.

Не храните воркер в state напрямую

На первый взгляд может показаться логичным положить воркер в React state, но это плохая практика. setState вызывает перерендер, а воркер сам по себе не зависит от рендера. Перерендер компонента не должен пересоздавать воркер или влиять на него. Если хранить воркер в state, это только усложнит логику и может привести к утечкам памяти или дублированию воркеров. Вместо этого лучше хранить воркер в useRef, который не участвует в рендерах:

const workerRef = useRef();

useEffect(() => {
  workerRef.current = new Worker(new URL('./worker.js', import.meta.url));

  workerRef.current.onmessage = (event) => {
    setResult(event.data);
  };

  workerRef.current.postMessage('start');

  return () => {
    workerRef.current?.terminate();
  };
}, []);

Также можно вынести воркер в кастомный хук

Если вы хотите переиспользовать воркеры и скрыть за простым API, вот эти вот ваши - postMessage, onmessage, terminate— вынесите код в кастомный хук, например, useWorker, то такой хук можно реализовать примерно так:

const useWorker = (workerFactory) => {
  const workerRef = useRef(null);

  const [data, setData] = useState(null);

  useEffect(() => {
    workerRef.current = workerFactory();

    workerRef.current.onmessage = (e) => {
      setData(e.data);
    };

    return () => {
      workerRef.current?.terminate();
    };
  }, [workerFactory]);

  const postMessage = (msg) => {
    workerRef.current?.postMessage(msg);
  };

  return [data, postMessage];
}

а использовать в нужном месте вот так:

const [result, send] = useWorker(
  () => new Worker(new URL('./worker.js', import.meta.url))
);

useEffect(() => {
  send({ number: 42 });
}, [send]);

Конечно же, можно использовать библиотеки

Я думаю, стоит упомянуть, что уже имеются библиотеки: они подходят для более сложных сценариев или если хочется избежать ручного обмена сообщениями.
Они скрывают низкоуровневую механику и позволяют работать с воркером как с обычной функцией. Не буду вдаваться в подробности и просто упомяну comlink, workerize и threads.js. Информацию можно найти по ним: переписывать документацию – нет смысла.

Лучшие практики при работе с Web Workers

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

  1. Создавайте новый экземпляр воркера только тогда, когда он действительно нужен, например, при монтировании компонента.

  2. Не пересоздавайте воркер при каждом ререндере, иначе это приведёт к ненужным потокам и снижению производительности.

  3. Завершайте его при размонтировании. Воркеры продолжают работать в фоне, пока не будут завершены явно с помощью метода .terminate().
    Если не завершать их, можно накапливать ненужные потоки, которые будут потреблять память и процессор. Всегда вызывайте .terminate() в cleanup-функции useEffect или после завершения задачи.

Минимизируйте объём передаваемых данных

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

Если нужно передать большой массив или бинарные данные (например, ArrayBuffer), используйте механизм Transferable Objects. Вместо копирования памяти между потоками, вы можете передать буфер:

worker.postMessage(buffer, [buffer]);

Это позволяет избежать затрат на сериализацию и копирование, передав объект по ссылке. Но важно помнить, что после передачи основной поток теряет доступ к буферу — его владение переходит воркеру.

Сериализация/десериализация —  узкое место

Даже если данные небольшие, каждый вызов postMessage требует сериализации. Поэтому:

  • избегайте частых сообщений с данными, особенно больших;

  • если задача может быть разбита на несколько частей, попробуйте сгруппировать данные в одно сообщение;

  • подумайте о формате передачи: JSON может быть медленным, используйте ArrayBuffer или TypedArray, если они подходят.

Вывод

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

Если вы когда-нибудь парсили CSV на миллион строк прямо в useEffect и смотрели, как браузер превращается в тыкву — теперь вы знаете, как этого избежать.

Поэтому не бойтесь выходить за пределы главного потока — теперь вы знаете, как оттуда возвращаться.

Комментарии (8)


  1. saibaken
    30.07.2025 16:55

    Про парсинг JSON в воркере, чтобы не блокировать главный поток - какая-то глупость выходит. Ведь общение с воркером тоже сериализует/десериализует передаваемые объекты.

    Это имеет смысл только если нужно как-то агрегировать распаршенный JSON и в главный поток вернуть небольшой результат


    1. 4Nun4ku Автор
      30.07.2025 16:55

      Вы правы, сериализация/десериализация имеет свои издержки, особенно если передавать большие объекты целиком. Но всё зависит от контекста и объёма данных.

      Если JSON огромный и его нужно сначала спарсить, а затем обработать как-то, то всё это вместе в главном потоке может остановить время, как в бабушкиной квартире. В этом случае перенос парсинга и обработки в воркер имеет смысл, если на выходе мы получаем небольшой результат, как вы справедливо заметили.

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


  1. adminNiochen
    30.07.2025 16:55

    Такие вредные советы ещё и от имени компании писать не стыдно? Пример с хуком приведёт к бесконечному циклу перерендеров компонента.

    А ещё в блоке 3 "нужно сериализовать данные" пункты по два раза повторяются.


    1. 4Nun4ku Автор
      30.07.2025 16:55

      По поводу бесконечного цикла — если вы имеете в виду хук useWorker, то в нём workerFactory передаётся как зависимость useEffect, что и правда вызвать пересоздание воркера, если передавать не мемоизированную функцию.

      В статье я упростил пример, чтобы сфокусироваться на сути паттерна. В продакшн-коде ваше замечание стоит учесть, обернув workerFactory в useCallback, например.
      В целом - верно подметили.

      По поводу "стыдно". Стыдно писать категорично, а анализировать и обсуждать - нет.


  1. izibrizi2
    30.07.2025 16:55

    Ай, сколько воды...


    1. 4Nun4ku Автор
      30.07.2025 16:55

      Это живительная влага)


  1. asyncbtd
    30.07.2025 16:55

    У вас в статье дублируются строки в третьем абзаце


    1. 4Nun4ku Автор
      30.07.2025 16:55

      Благодарю, все мы люди, поправил.