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, чтобы пользователи не застряли на старой версии.