webroad.online
  1. 1Веб
  2. 2HTML
  3. 3CSS
  4. 4JavaScript
  5. 5TypeScript
  6. 6Git
  7. 7Инструменты
  8. 8React
  9. 9Стейт-менеджмент
  10. 10Next.js
  11. 11Формы
  12. 12Данные и бэкенд
  13. 13SEO
  14. 14Tailwind CSS
  15. 15Анимации
  16. 16Тестирование
  17. 17Архитектура
JavaScript · Урок 18 из 18

Web Workers, Service Workers и другие воркеры

Главный поток, Web Workers и postMessage, Service Workers для кэша и офлайна, и что значит «воркер» на edge.

Обновлено

Главный поток и почему страница зависает

JS страницы выполняется в главном потоке (main thread): в том же потоке, который рисует страницу, отвечает на клики и запускает React. Event loop берёт следующую задачу, только когда текущая закончилась. Цикл на 2 секунды (фильтр для картинки, разбор большого CSV, нечёткий поиск по 50 000 строк) означает 2 секунды без кликов, без плавного скролла и без анимаций на JS.

async/await здесь не помогает. Promise только откладывает код, а выполняется он всё равно в главном потоке. Чтобы действительно работать параллельно, нужен другой поток, то есть воркер.

Попробуйте: одни и те же 2 секунды вычислений, сначала в главном потоке, потом в воркере.

Показать код
<style>
  body { align-content: center; }
  .spinner { width: 56px; height: 56px; border-radius: 50%; border: 6px solid rgb(255 252 225 / 0.15); border-top-color: #0ae448; }
  .buttons { display: flex; gap: 8px; }
  button { font: 600 14px system-ui, sans-serif; padding: 8px 14px; border: 0; border-radius: 8px; cursor: pointer; background: #fffce1; color: #0e100f; }
  button.off { background: #0ae448; }
  output { font: 13px ui-monospace, monospace; color: #abff84; min-height: 1.4em; text-align: center; }
</style>
<div class="spinner"></div>
<div class="buttons">
  <button class="main">Main thread</button>
  <button class="off">Web Worker</button>
</div>
<output>2 seconds of math. Watch the spinner.</output>
<script>
  const spinner = document.querySelector('.spinner')
  const output = document.querySelector('output')

  // the spinner is driven by JS, like any interactive UI
  let angle = 0
  const spin = () => {
    angle += 6
    spinner.style.transform = `rotate(${angle}deg)`
    requestAnimationFrame(spin)
  }
  requestAnimationFrame(spin)

  // the same heavy function, used by both buttons
  function heavy(ms) {
    const end = performance.now() + ms
    let ops = 0
    while (performance.now() < end) ops++
    return ops
  }

  // the worker gets the same function as text, through a Blob URL
  const source = heavy.toString() + '\nonmessage = e => postMessage(heavy(e.data))'
  const worker = new Worker(URL.createObjectURL(new Blob([source], { type: 'text/javascript' })))
  worker.onmessage = e => {
    output.textContent = `Worker: ${e.data.toLocaleString('en')} ops, the spinner kept spinning`
  }

  document.querySelector('.main').onclick = () => {
    output.textContent = 'Main thread busy...'
    setTimeout(() => {
      const ops = heavy(2000)
      output.textContent = `Main thread: ${ops.toLocaleString('en')} ops, the spinner froze`
    }, 50)
  }
  document.querySelector('.off').onclick = () => {
    output.textContent = 'Worker busy...'
    worker.postMessage(2000)
  }
</script>

Web Workers

Web Worker — это скрипт, который работает в отдельном потоке со своим event loop. Общих переменных со страницей у него нет: общаетесь только сообщениями.

// main.js: страница
const worker = new Worker(new URL('./primes.worker.js', import.meta.url), { type: 'module' })

worker.postMessage(2_000_000)                     // отправить данные
worker.onmessage = event => {                     // получить результат
  document.querySelector('.result').textContent = event.data
}
worker.onerror = event => console.error(event.message)
// primes.worker.js: воркер
import { countPrimes } from './math.js'          // { type: 'module' } разрешает import

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

worker.terminate() сразу останавливает воркер со стороны страницы (изнутри — self.close()). Делайте это, когда компонент исчезает или результат больше не нужен, иначе поток так и останется в памяти.

Что получает воркер: structured clone и transferables

postMessage копирует данные алгоритмом structured clone. Подходят объекты, массивы, Map, Set, Date, Blob, ArrayBuffer. Функции и DOM-узлы не подходят (будет DataCloneError), а экземпляры классов приходят как обычные объекты, без методов.

Копировать 100 МБ долго. ArrayBuffer можно передать (transfer): память переходит к воркеру без копирования, а на странице остаётся пустой буфер.

const pixels = new Uint8ClampedArray(width * height * 4)
worker.postMessage(pixels.buffer, [pixels.buffer]) // второй аргумент: что передать
pixels.byteLength                                    // 0, буфер теперь принадлежит воркеру

Что воркер может и чего не может

Нет Есть
document, DOM, window fetch, WebSocket
localStorage, alert setTimeout, setInterval
доступа к переменным страницы IndexedDB, Cache API
importScripts (классический) или import (модульный)

Правило: воркер считает, страница показывает.

Виды воркеров

Вид Что делает Сколько живёт Когда использовать
Dedicated Worker (new Worker) поток для одной страницы пока открыта страница или до terminate() тяжёлые вычисления: картинки, парсинг, криптография, поиск
Shared Worker (new SharedWorker) один общий воркер для всех вкладок одного сайта пока открыта хотя бы одна вкладка одно WebSocket-соединение на все вкладки; проверьте поддержку на мобильных
Service Worker прокси между страницей и сетью браузер запускает и останавливает его по необходимости кэш, офлайн, push, PWA
Worklets (Audio, Paint) маленькие скрипты внутри конвейера звука или отрисовки управляет браузер обработка звука в реальном времени, своя отрисовка в CSS через paint() (только Chromium)

Service Workers

Service Worker стоит между страницей и сетью: каждый запрос страницы (HTML, CSS, картинки, fetch) проходит через его событие fetch, и он решает, ответить из кэша, из сети или чем-то ещё. Работает только по HTTPS (и на localhost для разработки), потому что прокси, внедрённый по HTTP, был бы подарком для атакующих.

register() со страницы→install -> кладёт в кэш базовые файлы→activate -> удаляет старые кэши→fetch -> перехватывает запросы
// на странице
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js')
// sw.js
const CACHE = 'static-v2'

self.addEventListener('install', event => {
  event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(['/offline.html', '/styles.css'])))
})

self.addEventListener('activate', event => {
  event.waitUntil(caches.keys().then(keys =>
    Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))))
})

self.addEventListener('fetch', event => {
  event.respondWith(fetch(event.request).catch(() => caches.match('/offline.html')))
})

Scope (область): SW управляет только URL-ами в своей папке и ниже. /sw.js управляет всем сайтом, /blog/sw.js — только /blog/.... Поэтому файл обычно лежит в корне.

Стратегии кэширования

Стратегия Как Подходит для
cache-first кэш, а если там нет — сеть файлы с хешем в имени (app.3f9a.js), шрифты, картинки
network-first сеть, а если она упала — кэш HTML, данные, которые должны быть свежими
stale-while-revalidate отвечает из кэша и обновляет кэш из сети на следующий раз аватары, ленты, API, где немного устаревшие данные — это нормально
async function staleWhileRevalidate(request) {
  const cache = await caches.open('api-v1')
  const cached = await cache.match(request)
  const fresh = fetch(request).then(response => {
    if (response.ok) cache.put(request, response.clone())
    return response
  })
  return cached ?? fresh
}

Офлайн, PWA и push

SW, который отдаёт страницы из кэша, плюс web app manifest (manifest.json с названием и иконками) превращают сайт в PWA: его можно установить на экран, и он запускается даже без интернета. SW же принимает push-уведомления: страница подписывается через registration.pushManager.subscribe(...), сервер отправляет сообщение через Web Push, а SW показывает его в событии push с помощью self.registration.showNotification(...), даже если сайт закрыт. На iPhone push работает только для PWA, добавленного на главный экран.

Опасность: устаревший кэш

Самая частая проблема с SW: вы выкатили новую версию, а пользователи всё ещё видят старую. Почему:

  • старый SW отдаёт HTML по схеме cache-first, и страница даже не узнаёт о новой версии;
  • браузер скачивает новый sw.js, но тот висит в состоянии waiting, пока не закроются все вкладки под управлением старого; простого обновления страницы мало.

Как этого избежать:

  • network-first для HTML, cache-first только для файлов с хешем в имени;
  • новое имя кэша для каждой версии (static-v3), а старые удаляйте в activate;
  • self.skipWaiting() в install и self.clients.claim() в activate, чтобы новый SW сразу взял управление; или, вежливее, баннер «Новая версия, обновите страницу», который отправляет skipWaiting только по клику;
  • отдавайте sw.js с Cache-Control: no-cache, чтобы браузер всегда проверял, не изменился ли он;
  • как аварийный выключатель (kill switch) — новый sw.js, который вызывает self.registration.unregister().

В DevTools → Application → Service Workers видно состояние, можно принудительно обновить SW и включить «Update on reload» на время разработки.

Воркеры на практике

RPC вместо сообщений. Когда у воркера несколько функций, postMessage с { type, payload } быстро превращается в большой switch. Comlink (от Google Chrome Labs) превращает их в обычные async-вызовы:

// primes.worker.js
import * as Comlink from 'comlink'
Comlink.expose({ countPrimes })

// main.js
const api = Comlink.wrap(new Worker(new URL('./primes.worker.js', import.meta.url), { type: 'module' }))
const total = await api.countPrimes(2_000_000)   // выполняется в воркере

Service Workers без ручного кода. Workbox даёт готовые стратегии (new CacheFirst(), new StaleWhileRevalidate(), precache при сборке). Для Next.js официальный гайд по PWA упоминает Serwist — современный форк Workbox.

Бандлеры и new URL(..., import.meta.url). Vite, webpack 5 и Turbopack (в Next.js) распознают именно шаблон new Worker(new URL('./file.js', import.meta.url)) и собирают воркер в отдельный файл со всеми его импортами. Пишите URL прямо в вызове, а не в переменной, иначе бандлер его не увидит. В Next.js воркер создаётся только в Client Component, внутри useEffect (на сервере Worker нет), а в функции очистки вызывается worker.terminate().

«Воркеры» на сервере: другое значение слова

Cloudflare Workers и Vercel Functions не имеют отношения к потокам в браузере. Это функции, которые работают на сервере или на edge, рядом с пользователем: принимают Request и возвращают Response. API Cloudflare Workers как раз вырос из API Service Worker, отсюда и название.

export default {
  async fetch(request) {
    return new Response('Hello from the edge')
  },
}

Коротко

  • Главный поток отвечает и за JS, и за интерфейс; тяжёлые вычисления его блокируют, и async не спасает. Переносите их в Web Worker: postMessage и передача ArrayBuffer.
  • У воркера нет DOM и window, зато есть fetch, таймеры и IndexedDB. Он считает, страница показывает.
  • Service Worker — сетевой прокси для кэша, офлайна и push; версионируйте кэш и используйте network-first для HTML, чтобы пользователи не застряли на старой версии.

Официальные источники

Упражнения

Была ли страница полезной?

Один клик — без регистрации.