W3docs

JavaScript Web Workers

Изучите Web Workers в JavaScript: запускайте код в фоновом потоке, сохраняйте отзывчивость UI, общайтесь через postMessage и эффективно передавайте данные.

JavaScript выполняет ваш код в единственном главном потоке — том же потоке, который располагает элементы страницы, отрисовывает пиксели и обрабатывает клики и нажатия клавиш. Этот единственный поток является сердцем цикла событий: он берёт одну задачу, выполняет её до конца, затем переходит к следующей. Поэтому когда функция делает что-то по-настоящему тяжёлое — обрабатывает большой array, разбирает файл размером в несколько мегабайт, тысячи раз хеширует пароль — цикл оказывается заблокирован внутри этой функции. Ничто другое произойти не может: прокрутка подтормаживает, кнопки перестают реагировать, страница выглядит замёрзшей, пока работа не завершится.

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

Почему таймеров недостаточно

Первый инстинкт — обернуть медленную работу в setTimeout и надеяться, что она выполнится «в фоне». Но это не так. Таймеры лишь откладывают задачу на следующий виток того же цикла — когда коллбэк наконец срабатывает, он по-прежнему выполняется в главном потоке и по-прежнему блокирует всё во время выполнения. (Смотрите статью о планировании с setTimeout и setInterval — там объясняется, как эта очередь работает на самом деле.)

// This still freezes the page — it just freezes it 50ms later.
setTimeout(() => {
  let total = 0;
  for (let i = 0; i < 5_000_000_000; i++) total += i;
  console.log(total);
}, 50);

Web Worker — это другое: его код выполняется в совершенно другом потоке, поэтому главный поток свободен продолжать отрисовку и реагировать на события, пока worker трудится.

Создание Worker

Worker — это отдельный JavaScript-файл. Вы создаёте его, передав конструктору Worker URL этого файла:

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

Браузер запускает новый поток, загружает worker.js и начинает его выполнять. С этого момента два скрипта общаются только через сообщения — они не разделяют никаких переменных, функций и object.

Вот минимальная пара файлов.

main.js

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

worker.postMessage('Hello from the main thread');

worker.onmessage = (event) => {
  console.log('Main received:', event.data);
};

worker.js

self.onmessage = (event) => {
  console.log('Worker received:', event.data);
  self.postMessage('Hello back from the worker');
};

Обмен сообщениями через postMessage

Коммуникация двунаправленная и асинхронная. Главный поток вызывает worker.postMessage(data) и слушает через worker.onmessage; внутри worker self.postMessage(data) отправляет ответ, а self.onmessage получает входящие. Каждый обработчик получает MessageEvent, а полезная нагрузка находится в свойстве .data.

Передаваемые данные копируются, а не передаются по ссылке, с помощью алгоритма structured clone. Это означает, что можно передавать строки, числа, boolean, array, простые object, Map, Set, Date, ArrayBuffer и многое другое — но не функции, DOM-узлы и экземпляры классов с методами. Поскольку это копия, изменение object на одной стороне никогда не влияет на другую.

Вот полный цикл «запрос — ответ». Worker вычисляет n-е число Фибоначчи с помощью наивного рекурсивного алгоритма — намеренно медленного, именно того рода работы, которая вызвала бы подтормаживание UI, если бы выполнялась в главном потоке.

main.js

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

worker.onmessage = (event) => {
  console.log(`fib(${event.data.n}) = ${event.data.result}`);
};

// The page stays interactive while this runs on the worker thread.
worker.postMessage({ n: 42 });

worker.js

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

self.onmessage = (event) => {
  const { n } = event.data;
  const result = fib(n);
  self.postMessage({ n, result });
};

Главный поток отправляет запрос и немедленно возвращается к обработке кликов и отрисовке. Когда worker завершит работу, результат придёт в виде сообщения — без заморозки, без подтормаживания спиннера.

Глобальная область видимости Worker

Внутри worker нет window. Глобальным object является self (экземпляр DedicatedWorkerGlobalScope), и принципиально важно, что там нет document и нет DOM. Worker не может читать или изменять страницу; если ему нужно обновить UI, он отправляет сообщение и позволяет главному потоку сделать это.

Внимание

Код внутри Web Worker не может обращаться к DOM. Там нет document, нет window и нет доступа к элементам страницы. Всё визуальное должно передаваться обратно в главный поток через postMessage. Именно это ограничение делает workers безопасными для параллельного выполнения — нет разделяемого состояния UI, которое можно было бы испортить.

Тем не менее workers далеко не пусты. Область видимости worker предоставляет множество полезных API:

  • importScripts('a.js', 'b.js') для синхронной загрузки классических скриптов.
  • fetch и XMLHttpRequest для сетевых запросов.
  • Таймеры: setTimeout, setInterval.
  • console, crypto, TextEncoder / TextDecoder, WebSocket, IndexedDB и многое другое.

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

Обработка ошибок и завершение

Если worker выбрасывает необработанную ошибку, она появляется в главном потоке через worker.onerror:

worker.onerror = (event) => {
  console.error(`Worker error: ${event.message} (${event.filename}:${event.lineno})`);
};

Worker продолжает работать до тех пор, пока его не остановят. Вы можете остановить его снаружи с помощью worker.terminate(), что немедленно завершает поток — любая незавершённая работа теряется:

worker.terminate();

Или worker может завершить себя изнутри, когда он закончил:

// inside worker.js
self.close();

Завершение простаивающих workers освобождает память; долго живущие workers, обрабатывающие много сообщений, вполне можно оставлять активными.

Transferable Objects: перемещение вместо копирования

Structured clone удобен, но копирует данные. Для большой бинарной нагрузки — скажем, буфера изображения размером 50 МБ — копирование и тратит память, и отнимает время. Transferable objects позволяют передавать право собственности вместо копирования: данные перемещаются в другой поток без каких-либо копий, и отправитель теряет к ним доступ.

Для этого передайте второй аргумент в postMessage — список объектов для передачи:

const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64 MB

// Transfer ownership of the buffer to the worker (no copy).
worker.postMessage({ buffer }, [buffer]);

console.log(buffer.byteLength); // 0 — this thread can no longer use it

После передачи buffer.byteLength на отправляющей стороне равен 0: память теперь принадлежит worker. Передача идеальна для ArrayBuffer и типизированных arrays, построенных на их основе — смотрите статью об ArrayBuffer и бинарных arrays, где описана структура этих бинарных данных. Другие transferable-объекты: MessagePort, ImageBitmap и OffscreenCanvas.

Примечание

Содержимое второго аргумента должно также присутствовать в сообщении. В worker.postMessage({ buffer }, [buffer]) буфер упоминается в полезной нагрузке и перечислен как transferable. Если указать что-то, недостижимое из сообщения, браузер выбросит DataCloneError.

Module Workers

По умолчанию worker является классическим скриптом и использует importScripts() вместо синтаксиса ES-модулей. Передайте { type: 'module' } — и worker станет модульным, способным использовать статический и динамический import:

const worker = new Worker('worker.js', { type: 'module' });

worker.js

import { compress } from './compression.js';

self.onmessage = (event) => {
  self.postMessage(compress(event.data));
};

Module workers — это современный стандарт для нового кода: они дают правильный импорт, строгий режим и более чистый граф зависимостей.

Другие виды Workers

Простой new Worker(...) создаёт dedicated worker: он принадлежит единственной странице, которая его создала. Есть два связанных — но отличных — типа workers, которые нужно уметь различать:

  • SharedWorker — единственный экземпляр worker, разделяемый несколькими вкладками, окнами или iframe одного origin. Страницы подключаются к нему через MessagePort, что делает его полезным для координации состояния или единственного сетевого соединения между вкладками. Это не более быстрый dedicated worker — это разделяемый worker.
  • Service Worker — специальный worker, выступающий сетевым прокси между страницей и сетью, обеспечивающий кеширование, офлайн-поддержку и push-уведомления. Он основан на событиях и существует дольше жизни страницы. Это совсем другая задача, нежели у dedicated worker («выполни вычисление вне основного потока»); подробнее об этом читайте в статье о Service Workers.
Информация

Практическое правило: используйте dedicated Web Worker для переноса CPU-интенсивной работы с главного потока, SharedWorker — для совместного использования одного worker несколькими вкладками одного сайта, и Service Worker — для управления сетевыми запросами и создания офлайн-приложений.

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

Web Workers оправдывают себя, когда задача ограничена CPU и длится достаточно долго, чтобы ощущаться как подтормаживание:

  • Тяжёлые вычисления — физика, анализ данных, большие сортировки и агрегации.
  • Обработка изображений и видео, включая манипуляции с пикселями вне экрана.
  • Разбор и сжатие больших файлов (CSV, JSON, архивы).
  • Криптография — хеширование и шифрование без заморозки ввода.
  • Обработка больших наборов данных перед передачей компактного результата на отрисовку.

Если узкое место — ожидание сети, а не вычисления, worker обычно не нужен — fetch уже асинхронен и не блокирует поток. Workers блистают тогда, когда именно CPU не даёт главному потоку работать.

Проверьте свои знания

Практика
Может ли код, выполняющийся внутри Web Worker, напрямую обращаться к DOM?
Может ли код, выполняющийся внутри Web Worker, напрямую обращаться к DOM?
Практика
Как данные, переданные в worker.postMessage(data), попадают в worker по умолчанию?
Как данные, переданные в worker.postMessage(data), попадают в worker по умолчанию?
Практика
В чём главное преимущество передачи ArrayBuffer через worker.postMessage(buffer, [buffer])?
В чём главное преимущество передачи ArrayBuffer через worker.postMessage(buffer, [buffer])?
Was this page helpful?