Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React

Web Workers w React

6 min czytania
W tej lekcji6

Wyobraź sobie że główny komputer pokładowy Twojego statku (main thread) musi jednocześnie renderować kokpit, obsługiwać systemy nawigacyjne i przeliczać trajektorię lotu. Jeśli obliczenia trajektorii są zbyt ciężkie, kokpit zamarza i kapitan nie może kliknąć żadnego przycisku. Rozwiązanie? Dedykowany komputer obliczeniowy, czyli Web Worker, który pracuje równolegle i nie blokuje głównego systemu.

Czym są Web Workers?

Web Workers to mechanizm przeglądarki, który uruchamia kod JavaScript w osobnym wątku. Główny wątek odpowiada za renderowanie UI, obsługę zdarzeń i interakcję z użytkownikiem, więc ciężkie obliczenia na nim zamrażają interfejs. Tak wygląda problem:

1// BEZ Web Worker - blokuje main thread
2function HeavyCalculation() {
3  const [result, setResult] = useState(null);
4
5  const calculate = () => {
6    // To zablokuje UI na kilka sekund!
7    let sum = 0;
8    for (let i = 0; i < 1_000_000_000; i++) {
9      sum += Math.sqrt(i);
10    }
11    setResult(sum);
12  };
13
14  return (
15    <div>
16      <button onClick={calculate}>Oblicz</button>
17      <p>Wynik: {result}</p>
18    </div>
19  );
20}

Pętla z miliardem iteracji zajmie główny wątek na kilka sekund: przycisk nie zareaguje, a React nie wyrenderuje nawet stanu ładowania. useState niczego tu nie ratuje, bo problemem jest zajęty wątek, a nie sposób przechowywania wyniku.

Tworzenie Web Workera

Worker to osobny plik JavaScript z własnym zakresem globalnym self. Z głównym wątkiem rozmawia wyłącznie przez wiadomości: postMessage wysyła, a onmessage odbiera:

1// worker.js - plik Workera (oddzielny wątek)
2self.onmessage = function(event) {
3  const { type, data } = event.data;
4
5  if (type === 'CALCULATE_TRAJECTORY') {
6    const result = calculateTrajectory(data);
7    self.postMessage({ type: 'RESULT', result });
8  }
9};
10
11function calculateTrajectory(params) {
12  let trajectory = [];
13  for (let t = 0; t < params.steps; t++) {
14    const x = params.speed * t * Math.cos(params.angle);
15    const y = params.speed * t * Math.sin(params.angle) - 0.5 * 9.81 * t * t;
16    trajectory.push({ x, y, t });
17  }
18  return trajectory;
19}

Worker czeka na wiadomość typu CALCULATE_TRAJECTORY, liczy i odsyła wynik przez self.postMessage. Nie ma dostępu do DOM ani do stanu Reacta, zna tylko to, co przyszło w event.data.

Po stronie Reacta tworzysz workera raz, w efekcie, i trzymasz go w useRef:

1// App.jsx - komponent React (główny wątek)
2import { useState, useRef, useEffect } from 'react';
3
4function TrajectoryCalculator() {
5  const [result, setResult] = useState(null);
6  const [isCalculating, setIsCalculating] = useState(false);
7  const workerRef = useRef(null);
8
9  useEffect(() => {
10    workerRef.current = new Worker(new URL('./worker.js', import.meta.url));
11
12    workerRef.current.onmessage = (event) => {
13      if (event.data.type === 'RESULT') {
14        setResult(event.data.result);
15        setIsCalculating(false);
16      }
17    };
18
19    return () => workerRef.current?.terminate();
20  }, []);
21
22  const handleCalculate = () => {
23    setIsCalculating(true);
24    workerRef.current.postMessage({
25      type: 'CALCULATE_TRAJECTORY',
26      data: { speed: 1000, angle: 0.785, steps: 100000 }
27    });
28  };
29
30  return (
31    <div>
32      <button onClick={handleCalculate} disabled={isCalculating}>
33        {isCalculating ? 'Obliczam...' : 'Oblicz trajektorię'}
34      </button>
35      {result && <p>Punktów trajektorii: {result.length}</p>}
36    </div>
37  );
38}

Zapis new Worker(new URL('./worker.js', import.meta.url)) rozpoznają bundlery, jak webpack 5 i Vite, i same dołączają plik workera. Cleanup wywołuje terminate(), więc po odmontowaniu komponentu wątek nie pracuje dalej. Dane z postMessage są kopiowane algorytmem structured clone, dlatego funkcji ani elementów DOM nie wyślesz.

Własny hook: useWorker

Żeby nie powtarzać tej logiki w każdym komponencie, zamknij ją w hooku wielokrotnego użytku:

1import { useState, useRef, useEffect, useCallback } from 'react';
2
3function useWorker(workerFactory) {
4  const [result, setResult] = useState(null);
5  const [error, setError] = useState(null);
6  const [isRunning, setIsRunning] = useState(false);
7  const workerRef = useRef(null);
8
9  useEffect(() => {
10    workerRef.current = workerFactory();
11
12    workerRef.current.onmessage = (event) => {
13      setResult(event.data);
14      setIsRunning(false);
15    };
16
17    workerRef.current.onerror = (err) => {
18      setError(err.message);
19      setIsRunning(false);
20    };
21
22    return () => workerRef.current?.terminate();
23  }, []);
24
25  const run = useCallback((data) => {
26    setIsRunning(true);
27    setError(null);
28    workerRef.current?.postMessage(data);
29  }, []);
30
31  return { result, error, isRunning, run };
32}

run wysyła dane, a hook sam zarządza stanami result, error i isRunning. Przekazujesz fabrykę, np. () => new Worker(url), bo worker ma powstać w efekcie, a nie przy każdym renderze.

Ręczne rozpakowywanie wiadomości szybko męczy. Biblioteka Comlink od Google Chrome Labs pozwala traktować workera jak obiekt z asynchronicznymi metodami. W pliku workera wystawiasz obiekt przez Comlink.expose:

1// worker.js z Comlink
2import * as Comlink from 'comlink';
3
4const navigationComputer = {
5  async calculateRoute(origin, destination) {
6    const route = computeOptimalPath(origin, destination);
7    return route;
8  },
9  async analyzeStarField(stars) {
10    return stars.map(star => ({
11      ...star,
12      habitableZone: calculateHabitableZone(star.mass, star.luminosity)
13    }));
14  }
15};
16
17Comlink.expose(navigationComputer);

Metody calculateRoute i analyzeStarField nadal działają w osobnym wątku, zmienia się tylko sposób ich wywołania.

W komponencie owijasz workera przez Comlink.wrap i wołasz metody jak zwykłe funkcje asynchroniczne:

1// App.jsx z Comlink
2import * as Comlink from 'comlink';
3
4function NavigationPanel() {
5  const [route, setRoute] = useState(null);
6  const navComputer = useRef(null);
7
8  useEffect(() => {
9    const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
10    navComputer.current = Comlink.wrap(worker);
11    return () => worker.terminate();
12  }, []);
13
14  const planRoute = async () => {
15    // Wywołujemy metodę jak zwykłą funkcję async!
16    const result = await navComputer.current.calculateRoute(
17      { x: 0, y: 0, z: 0 },
18      { x: 4.2, y: 1.3, z: 0.7 }
19    );
20    setRoute(result);
21  };
22
23  return (
24    <div>
25      <button onClick={planRoute}>Planuj trasę</button>
26      {route && <RouteVisualization data={route} />}
27    </div>
28  );
29}

Pod spodem Comlink dalej wysyła postMessage, a każde wywołanie zwraca obietnicę, dlatego potrzebny jest await. Ten plik workera używa instrukcji import, więc new Worker dostaje opcję { type: 'module' } i przeglądarka uruchamia go jako worker modułowy. Na tym opiera się też tryb deweloperski Vite, a przy budowaniu bundler dołącza zależności do pliku workera.

Kiedy używać Web Workers w React?

Workery są idealne do ciężkich obliczeń matematycznych, jak symulacje i analiza danych, oraz do parsowania dużych zbiorów, na przykład pliku CSV z 500 000 wierszy albo JSON-a z tysiącami rekordów. Sprawdzą się też przy przetwarzaniu obrazów (filtry, kompresja) i wyszukiwaniu rozmytym w dużych kolekcjach.

Nie potrzebujesz ich do prostych operacji na stanie ani do zapytań API przez fetch czy axios, bo oczekiwanie na sieć nie blokuje wątku. Nie pomogą też przy manipulacji DOM, bo workery nie mają do niego dostępu. Polecam najpierw zmierzyć w zakładce Performance, czy zadanie trwa dłużej niż 50 ms, bo od tej granicy przeglądarka uznaje je za długie, i dopiero wtedy przenieść je do workera, najlepiej przez Comlink.

Podsumowanie

  1. Web Workers - osobny wątek dla ciężkich obliczeń
  2. Komunikacja - postMessage / onmessage
  3. Własny hook useWorker - enkapsulacja logiki workera
  4. Comlink - uproszczone API (metody async zamiast wiadomości)
  5. Ograniczenia - brak dostępu do DOM, osobny zakres globalny

W kolejnej lekcji React Compiler zajmie się wydajnością od drugiej strony: zamiast przenosić pracę do innego wątku, pominie zbędne rendery.

Pamiętaj: Web Worker to drugi komputer pokładowy - liczy trajektorię w tle, a kokpit przez cały czas słucha kapitana.

Kod do tej lekcji: App.jsx
1import React, { useState, useRef, useEffect, useCallback } from 'react';
2
3// === WEB WORKERS W REACT ===
4// Symulacja ciężkich obliczeń w oddzielnym wątku
5
6// Własny hook useWorker - logika Workera w jednym miejscu
7function useWorker() {
8  const [result, setResult] = useState(null);
9  const [isRunning, setIsRunning] = useState(false);
10  const [progress, setProgress] = useState(0);
11
12  // Symulacja Workera (ten podgląd nie ma osobnego pliku workera)
13  const run = useCallback((params) => {
14    setIsRunning(true);
15    setProgress(0);
16    setResult(null);
17
18    // Obliczenia w porcjach z setTimeout udają "oddzielny wątek"
19    const totalSteps = params.steps || 10;
20    let currentStep = 0;
21    const trajectory = [];
22
23    const step = () => {
24      for (let i = 0; i < 1000; i++) {
25        const t = currentStep * 0.01;
26        const x = params.speed * t * Math.cos(params.angle);
27        const y = params.speed * t * Math.sin(params.angle) - 0.5 * 9.81 * t * t;
28        trajectory.push({ x: +x.toFixed(2), y: +y.toFixed(2), t: +t.toFixed(3) });
29        currentStep++;
30      }
31      setProgress(Math.min(100, Math.round((currentStep / (totalSteps * 1000)) * 100)));
32
33      if (currentStep < totalSteps * 1000) {
34        setTimeout(step, 0); // Oddaj sterowanie głównemu wątkowi
35      } else {
36        setResult(trajectory);
37        setIsRunning(false);
38      }
39    };
40    setTimeout(step, 0);
41  }, []);
42
43  return { result, isRunning, progress, run };
44}
45
46// Panel trajektorii - wizualizacja
47function TrajectoryPanel({ trajectory }) {
48  if (!trajectory || trajectory.length === 0) return null;
49
50  const maxX = Math.max(...trajectory.map(p => Math.abs(p.x)));
51  const maxY = Math.max(...trajectory.map(p => Math.abs(p.y)));
52  const scale = Math.max(maxX, maxY) || 1;
53
54  const validPoints = trajectory.filter(p => p.y >= 0).slice(0, 200);
55
56  return (
57    <div style={styles.trajectoryPanel}>
58      <h3 style={styles.sectionTitle}>Trajektoria lotu</h3>
59      <div style={styles.canvas}>
60        {validPoints.map((p, i) => (
61          <div
62            key={i}
63            style={{
64              position: 'absolute',
65              left: (p.x / scale * 45 + 50) + '%',
66              bottom: (p.y / scale * 80 + 5) + '%',
67              width: '3px', height: '3px',
68              borderRadius: '50%',
69              background: `hsl(${180 + (i / validPoints.length) * 60}, 100%, 60%)`,
70            }}
71          />
72        ))}
73      </div>
74      <p style={styles.stats}>
75        Punktów: {trajectory.length} | Max X: {maxX.toFixed(0)}m | Max Y: {Math.max(...trajectory.map(p => p.y)).toFixed(0)}m
76      </p>
77    </div>
78  );
79}
80
81// Panel kontrolny - interaktywny (UI reaguje podczas obliczeń)
82function ControlPanel({ isRunning }) {
83  const [clicks, setClicks] = useState(0);
84
85  return (
86    <div style={styles.controlPanel}>
87      <h3 style={styles.sectionTitle}>Panel kontrolny (główny wątek)</h3>
88      <p style={styles.hint}>
89        {isRunning
90          ? 'Worker oblicza... ale UI nadal działa!'
91          : 'Klikaj przycisk podczas obliczeń - UI nie zamarza'}
92      </p>
93      <button onClick={() => setClicks(c => c + 1)} style={styles.clickBtn}>
94        Kliknięcia: {clicks}
95      </button>
96      <div style={{ ...styles.indicator, background: isRunning ? '#ffaa00' : '#00ff88' }}>
97        {isRunning ? 'WORKER AKTYWNY' : 'WORKER WOLNY'}
98      </div>
99    </div>
100  );
101}
102
103function App() {
104  const { result, isRunning, progress, run } = useWorker();
105  const [speed, setSpeed] = useState(500);
106  const [angle, setAngle] = useState(45);
107  const [steps, setSteps] = useState(5);
108
109  const handleLaunch = () => {
110    run({
111      speed,
112      angle: (angle * Math.PI) / 180,
113      steps,
114    });
115  };
116
117  return (
118    <div style={styles.container}>
119      <header style={styles.header}>
120        <h1 style={styles.title}>Web Workers - Obliczenia trajektorii</h1>
121        <p style={styles.subtitle}>Ciężkie obliczenia bez blokowania UI</p>
122      </header>
123
124      <div style={styles.grid}>
125        <div style={styles.inputPanel}>
126          <h3 style={styles.sectionTitle}>Parametry lotu</h3>
127          <label style={styles.label}>
128            Prędkość: {speed} m/s
129            <input type="range" min="100" max="2000" value={speed}
130              onChange={e => setSpeed(+e.target.value)} style={styles.slider} />
131          </label>
132          <label style={styles.label}>
133            Kąt: {angle}°
134            <input type="range" min="10" max="80" value={angle}
135              onChange={e => setAngle(+e.target.value)} style={styles.slider} />
136          </label>
137          <label style={styles.label}>
138            Złożoność: {steps} tys. kroków
139            <input type="range" min="1" max="20" value={steps}
140              onChange={e => setSteps(+e.target.value)} style={styles.slider} />
141          </label>
142
143          <button onClick={handleLaunch} disabled={isRunning} style={styles.launchBtn}>
144            {isRunning ? 'Obliczam...' : 'Oblicz trajektorię'}
145          </button>
146
147          {isRunning && (
148            <div style={styles.progressBar}>
149              <div style={{ ...styles.progressFill, width: progress + '%' }} />
150              <span style={styles.progressText}>{progress}%</span>
151            </div>
152          )}
153        </div>
154
155        <ControlPanel isRunning={isRunning} />
156      </div>
157
158      <TrajectoryPanel trajectory={result} />
159
160      <div style={styles.info}>
161        <h3 style={{ color: '#ffd700', fontSize: '13px', margin: '0 0 8px' }}>Jak to działa:</h3>
162        <ul style={{ margin: 0, paddingLeft: '20px', fontSize: '12px', color: '#8892b0' }}>
163          <li>Obliczenia trajektorii odbywają się w "tle" (symulacja Web Workera)</li>
164          <li>Panel kontrolny reaguje na kliknięcia PODCZAS obliczeń</li>
165          <li>W prawdziwej aplikacji new Worker() tworzy oddzielny wątek</li>
166          <li>Komunikacja: postMessage() / onmessage</li>
167        </ul>
168      </div>
169    </div>
170  );
171}
172
173const styles = {
174  container: { fontFamily: 'system-ui', background: '#0a0e17', color: '#e0e1dd', minHeight: '100vh', padding: '16px' },
175  header: { textAlign: 'center', marginBottom: '16px' },
176  title: { fontSize: '20px', color: '#00d4ff', margin: '0 0 4px' },
177  subtitle: { fontSize: '12px', color: '#8892b0', margin: 0 },
178  grid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' },
179  inputPanel: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '14px', border: '1px solid rgba(0,212,255,0.15)' },
180  controlPanel: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '14px', border: '1px solid rgba(0,212,255,0.15)' },
181  sectionTitle: { fontSize: '14px', color: '#00d4ff', margin: '0 0 10px' },
182  label: { display: 'block', fontSize: '12px', color: '#8892b0', marginBottom: '10px' },
183  slider: { width: '100%', marginTop: '4px' },
184  launchBtn: { width: '100%', padding: '10px', background: 'linear-gradient(135deg, #00d4ff, #0088cc)', color: '#0a0e17', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold', fontSize: '13px' },
185  progressBar: { marginTop: '8px', height: '20px', background: '#1a2a3c', borderRadius: '10px', position: 'relative', overflow: 'hidden' },
186  progressFill: { height: '100%', background: 'linear-gradient(90deg, #00d4ff, #00ff88)', borderRadius: '10px', transition: 'width 0.3s' },
187  progressText: { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: '11px', fontWeight: 'bold', color: '#0a0e17' },
188  hint: { fontSize: '12px', color: '#8892b0', marginBottom: '10px' },
189  clickBtn: { width: '100%', padding: '12px', background: '#1a3a5c', color: '#00d4ff', border: '1px solid rgba(0,212,255,0.3)', borderRadius: '6px', cursor: 'pointer', fontSize: '16px', fontWeight: 'bold', marginBottom: '10px' },
190  indicator: { textAlign: 'center', padding: '6px', borderRadius: '4px', fontSize: '11px', fontWeight: 'bold', color: '#0a0e17' },
191  trajectoryPanel: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '14px', border: '1px solid rgba(0,212,255,0.15)', marginBottom: '12px' },
192  canvas: { position: 'relative', height: '200px', background: 'rgba(0,0,0,0.4)', borderRadius: '6px', overflow: 'hidden' },
193  stats: { fontSize: '12px', color: '#8892b0', marginTop: '8px', textAlign: 'center' },
194  info: { padding: '12px', background: 'rgba(255,215,0,0.05)', border: '1px solid rgba(255,215,0,0.15)', borderRadius: '8px' },
195};
196
197export default App;

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jaki mechanizm służy do komunikacji między głównym wątkiem a Web Workerem?

  2. 2. Które zadanie jest IDEALNYM kandydatem do przeniesienia do Web Workera?

Przydatne artykuły