跪拜 Guibai
← Back to the summary

Keep React Responsive During Heavy Computation with Web Workers and useRef

Refuse Page Freezes! React Concurrent Programming in Practice: Web Worker + useRef Deep Dive and High-Performance Computing Architecture

Foreword: When React Encounters a "Computing Power Crisis"

In modern frontend development, we are accustomed to using useState to drive views and useEffect to handle side effects. However, when facing massive data rendering, complex mathematical calculations, image processing, or even on-device AI model inference, React's reactive mechanism often becomes a performance bottleneck.

Have you ever encountered this scenario: after clicking an "Export Report" or "Start Calculation" button, the entire page instantly freezes, button click effects disappear, the scrollbar cannot be dragged, and only after a few seconds does the result suddenly pop out? This is a typical main thread block.

JavaScript is a single-threaded language; its execution shares the same thread as UI rendering. Once the JS execution stack is occupied by a long task, the browser cannot perform the next frame's paint. To break this bottleneck, we need to introduce Web Workers to enable multi-threaded parallel computation. But Worker lifecycle management and synchronization with React component state often give developers headaches.

This article will combine the persistent nature of useRef with the message mechanism of Web Workers, taking you from scratch to build a production-grade high-performance computing architecture. We will dive deep into the V8 engine and browser rendering principles, supplemented with complete TypeScript practical code.


Chapter 1: Core Principles — Why Do We Need "Dual Threading"?

1.1 The Limitations of JS Single-Threading and the Event Loop

In the browser's multi-process architecture, the Renderer Process includes DOM tree construction, style calculation, layout, and JavaScript execution.

These two threads are mutually exclusive. When the JS engine is executing a time-consuming script (e.g., iterating through a loop 10 million times), the GUI rendering thread is suspended. This is why the page "drops frames" or even "freezes" during complex calculations.

Although JS provides asynchronous mechanisms (Promise, setTimeout), they are essentially still non-blocking single-threaded scheduling. Asynchronous tasks are merely deferred to the task queue for execution, without truly utilizing the parallel computing power of multi-core CPUs.

1.2 Web Worker: The Browser's "Background Computing Center"

The Web Worker standard introduced by HTML5 allows us to open a completely independent thread in the browser.


Chapter 2: Key Hook Analysis — The Essential Difference Between useRef and useState

The biggest pain point when integrating Workers in React is: How to hold a Worker instance that does not reset with component re-renders?

2.1 useState vs useRef

Many beginners try to use useState to store a Worker instance, which is wrong.

2.2 Why Must a Worker Be Placed in useRef?

If you directly write const worker = new Worker(...) inside the component function body, then every time the component re-renders due to any state change (e.g., the user types a character), a new Worker will be created, leading to memory leaks and logic chaos.

Using useRef with useEffect, we can ensure:

  1. The Worker is created only once when the component mounts.
  2. When the component unmounts, we can accurately locate that instance and destroy it (terminate), preventing memory leaks.

Chapter 3: Practical Exercise — Building a High-Performance Computing Component

Theory alone is not enough; let's go straight to the code. To demonstrate a real-world scenario, we will simulate a "Fibonacci sequence large number calculation" or "mass data encryption" task, which easily blocks the main thread.

3.1 Directory Structure Plan

src/
├── hooks/
│   └── useHeavyCalc.ts      # Custom Hook encapsulating Worker logic
├── workers/
│   └── calc.worker.ts       # Worker thread's specific execution logic
├── components/
│   └── Calculator.tsx       # UI display component
└── App.tsx                  # Entry point

3.2 Step 1: Write the Worker Thread Logic

The Worker file is an independently running script. We need to listen for messages from the main thread, execute calculations, and send results back.

// Define the message type received by the Worker
interface CalcMessage {
  type: 'CALCULATE_FIB';
  payload: number;
}

// Simulate an extremely time-consuming recursive calculation (intentionally blocking)
const heavyFibonacci = (n: number): number => {
  if (n <= 1) return n;
  return heavyFibonacci(n - 1) + heavyFibonacci(n - 2);
};

// self represents the Worker's global scope
self.onmessage = function (e: MessageEvent<CalcMessage>) {
  const { type, payload } = e.data;

  console.log(`[Worker] Received task: ${type}, parameter: ${payload}`);

  if (type === 'CALCULATE_FIB') {
    const startTime = performance.now();
    
    // Execute the time-consuming calculation
    const result = heavyFibonacci(payload);
    
    const endTime = performance.now();
    const duration = (endTime - startTime).toFixed(2);

    // Send the result back to the main thread
    self.postMessage({
      type: 'RESULT',
      data: { result, duration }
    });
  }
};

export {}; // Ensure this is a module

3.3 Step 2: Encapsulate a Custom Hook (Core)

To keep the component cleaner, we encapsulate the Worker creation, communication, and destruction logic into useHeavyCalc. Here we use useRef to save the instance and useState to manage UI state.

import { useState, useEffect, useRef, useCallback } from 'react';

// Define the state interface for returned results
interface CalcState {
  loading: boolean;
  result: number | null;
  duration: string | null;
  error: string | null;
}

export const useHeavyCalc = () => {
  // 1. Use useRef to persistently save the Worker instance
  // Initial value is null to avoid errors in SSR or non-browser environments
  const workerRef = useRef<Worker | null>(null);
  
  // 2. Use useState to manage reactive UI state
  const [state, setState] = useState<CalcState>({
    loading: false,
    result: null,
    duration: null,
    error: null,
  });

  // 3. Initialize the Worker
  useEffect(() => {
    // Use Vite's specific new URL syntax to import the Worker for easy bundling
    // In a CRA environment, typically use: new Worker(new URL('./workers/calc.worker.ts', import.meta.url))
    workerRef.current = new Worker(
      new URL('../workers/calc.worker.ts', import.meta.url)
    );

    // 4. Listen for the Worker's reply
    workerRef.current.onmessage = (e) => {
      const { type, data } = e.data;
      
      if (type === 'RESULT') {
        setState({
          loading: false,
          result: data.result,
          duration: data.duration,
          error: null,
        });
      }
    };

    // Error handling
    workerRef.current.onerror = (err) => {
      setState(prev => ({ ...prev, loading: false, error: err.message }));
    };

    // 5. Cleanup function: Terminate the Worker when the component unmounts to prevent memory leaks
    return () => {
      if (workerRef.current) {
        workerRef.current.terminate();
        workerRef.current = null;
      }
    };
  }, []); // Empty dependency array ensures execution only once on mount

  // 6. Expose the method for the component to call
  const startCalculation = useCallback((num: number) => {
    if (!workerRef.current) return;
    
    setState(prev => ({ ...prev, loading: true, error: null }));
    
    // Send a message to the Worker
    workerRef.current.postMessage({
      type: 'CALCULATE_FIB',
      payload: num
    });
  }, []);

  return {
    ...state,
    startCalculation
  };
};

3.4 Step 3: UI Component Implementation

Now, our UI component becomes very pure, only responsible for display and interaction, completely unaware of the underlying thread communication details.

import React, { useState } from 'react';
import { useHeavyCalc } from '../hooks/useHeavyCalc';

const Calculator: React.FC = () => {
  const [inputNum, setInputNum] = useState<number>(40);
  const { loading, result, duration, error, startCalculation } = useHeavyCalc();

  const handleStart = () => {
    startCalculation(inputNum);
  };

  return (
    <div style={{ padding: '20px', border: '1px solid #ddd', borderRadius: '8px' }}>
      <h2>🚀 High-Performance Calculator (Web Worker)</h2>
      <p>Enter a number to calculate the Fibonacci sequence (test main thread blocking):</p>
      
      <div style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
        <input 
          type="number" 
          value={inputNum} 
          onChange={(e) => setInputNum(Number(e.target.value))}
          disabled={loading}
          style={{ padding: '8px' }}
        />
        <button 
          onClick={handleStart} 
          disabled={loading}
          style={{ 
            padding: '8px 16px', 
            backgroundColor: loading ? '#ccc' : '#007bff', 
            color: '#fff', 
            border: 'none', 
            borderRadius: '4px',
            cursor: loading ? 'not-allowed' : 'pointer'
          }}
        >
          {loading ? '⏳ Calculating...' : 'Start Calculation'}
        </button>
      </div>

      {/* Result display area */}
      {error && <p style={{ color: 'red' }}>❌ Error: {error}</p>}
      
      {result !== null && !loading && (
        <div style={{ marginTop: '20px', padding: '15px', background: '#f9f9f9' }}>
          <h3>✅ Calculation Complete</h3>
          <p><strong>Result:</strong> {result}</p>
          <p><strong>Duration:</strong> {duration} ms</p>
          <p style={{ fontSize: '12px', color: '#666' }}>
            * Note: You can now freely click other buttons on the page or scroll without any lag.
          </p>
        </div>
      )}
    </div>
  );
};

export default Calculator;

Chapter 4: Advanced Thoughts and Extended Scenarios

4.1 Why Go Through All This Trouble? Can't We Just Calculate Directly?

If you are calculating 1+1 or simple list filtering, of course you don't need a Worker. But when your task complexity reaches O(2^n) or needs to process MB-level JSON data, the difference is between "a smooth user experience" and "the user thinking the webpage crashed."

4.2 More Application Scenarios

Mastering this useRef + Worker pattern, you can apply it to the following scenarios:

  1. Frontend Image Processing: Use Workers for image compression and filter processing (in conjunction with the Canvas API's ImageBitmap).
  2. Large File Parsing: Parse huge CSV or Excel files before uploading.
  3. On-Device AI Inference: Run TensorFlow.js or ONNX Runtime Web models; these model inferences are very time-consuming and must be placed in a Worker.
  4. Instant Search Highlighting: Perform regex matching and highlighting across tens of thousands of data entries.

4.3 Precautions

  1. Data Transfer Overhead: postMessage copies data. If transferring several megabytes of binary data, it is recommended to use ArrayBuffer and pass it as a Transferable Object (worker.postMessage(data, [data.buffer])), achieving zero-copy by directly transferring memory ownership to the Worker.
  2. Compatibility: Modern browsers have good support for Web Workers, but a Polyfill might be needed for very old browser versions.
  3. Debugging: Chrome DevTools' Sources panel has a dedicated "Threads" area where you can switch to view the execution stacks of the Main Thread and Worker Thread, making debugging very convenient.

Conclusion

The core of React lies in declarative UI, but this does not mean we should give up control over underlying performance. Through the seemingly simple useRef Hook, we cleverly bridge React's rendering world with the browser's multi-threaded world.

I hope this article helps you thoroughly understand the best practices for Web Workers in React. Next time you encounter lag, don't just optimize useMemo; try handing the heavy lifting over to a Worker!