Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

WebAssembly + Rust trong React App: Tăng Tốc Compute-Heavy Tasks Thực Tế

Ếch Trendy
Ếch Trendy

7 tháng trước · 9 phút đọc

WebAssembly đang trỗi dậy - và đây là lý do bạn cần quan tâm

Mình nhớ lần đầu thử xử lý ảnh trực tiếp trên browser bằng JavaScript thuần. Chụp ảnh căn cước công dân, crop, xoay, nén rồi upload. Kết quả? Browser đơ 3-4 giây, UI freeze cứng đờ, người dùng tưởng app lỗi. Vấn đề không phải code sai - JS đơn giản là không đủ nhanh cho compute-heavy tasks kiểu đó.

WebAssembly (WASM) sinh ra để giải quyết đúng bài toán này. Không phải thay thế JavaScript - mà làm đồng đội với nó. Bạn viết phần tính toán nặng bằng Rust, compile sang WASM, rồi gọi từ JS như gọi một hàm bình thường.

342199 WASM không thay JS, nó làm cái JS không giỏi

Trend này đang rise mạnh trong cộng đồng dev toàn cầu. Hàng nghìn dự án production đang dùng WebAssembly cho các tác vụ xử lý nặng như video encoding, PDF rendering, game engine chạy trên web. Với dev Việt, cơ hội rõ nhất nằm ở các app fintech, eKYC, và công cụ xử lý tài liệu - những bài toán image processing cực kỳ phổ biến.


Setup môi trường: Rust + wasm-pack + React

Trước khi benchmark hay viết code xử lý ảnh, cần setup đúng tool chain. Mình sẽ đi thẳng vào từng bước.

Bước 1: Cài Rust và wasm-pack

# Cài Rust (nếu chưa có)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Cài wasm-pack - công cụ build WASM từ Rust
cargo install wasm-pack

# Kiểm tra
wasm-pack --version

Bước 2: Tạo Rust WASM package

# Tạo thư mục project
mkdir my-wasm-lib && cd my-wasm-lib
cargo init --lib

Sửa Cargo.toml:

[package]
name = "my-wasm-lib"
version = "0.1.0"
edition = "2021"

[lib]
# Bắt buộc để compile sang WASM
crate-type = ["cdylib"]

[dependencies]
# Cầu nối giữa Rust và JavaScript
wasm-bindgen = "0.2"

Bước 3: Viết function Rust đầu tiên

// src/lib.rs
use wasm_bindgen::prelude::*;

// Export function này sang JS
#[wasm_bindgen]
pub fn grayscale(data: &[u8]) -> Vec<u8> {
    // Xử lý từng pixel theo định dạng RGBA
    let mut result = vec![0u8; data.len()];
    
    for i in (0..data.len()).step_by(4) {
        // Công thức tính độ sáng (luminance)
        let r = data[i] as f32;
        let g = data[i + 1] as f32;
        let b = data[i + 2] as f32;
        let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
        
        result[i] = gray;
        result[i + 1] = gray;
        result[i + 2] = gray;
        result[i + 3] = data[i + 3]; // Giữ nguyên alpha
    }
    
    result
}

Bước 4: Build và tích hợp vào React

# Build WASM package
wasm-pack build --target web --out-dir ../react-app/src/wasm

342200 Tool chain gọn: Rust code → wasm-pack build → JS module sẵn dùng

Sau khi build xong, bạn sẽ có folder wasm/ trong React app với 3 file chính: file .wasm (binary), file .js (glue code), và file .d.ts (TypeScript types). Không cần config webpack phức tạp - Vite và Create React App đều hỗ trợ import WASM trực tiếp.


Dùng WASM trong React: Xử lý ảnh thực tế

Mình chọn bài toán xử lý ảnh căn cước công dân - use case cực kỳ phổ biến trong fintech và eKYC Việt Nam. Người dùng chụp CCCD, app cần: chuyển grayscale, tăng contrast để OCR dễ hơn, rồi nén xuống trước khi upload.

Đây là cách gọi WASM module từ React:

// hooks/useImageProcessor.ts
import { useEffect, useRef, useState } from 'react';

// Lazy load WASM để không block initial render
const loadWasm = () => import('../wasm/my_wasm_lib');

export function useImageProcessor() {
  const wasmRef = useRef<any>(null);
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    loadWasm().then(async (wasm) => {
      // Bắt buộc init trước khi dùng
      await wasm.default();
      wasmRef.current = wasm;
      setIsReady(true);
    });
  }, []);

  const processImage = async (imageData: ImageData): Promise<ImageData> => {
    if (!wasmRef.current) throw new Error('WASM chưa sẵn sàng');
    
    // Lấy raw pixel data (Uint8ClampedArray)
    const pixels = imageData.data;
    
    // Gọi Rust function - nhanh như native
    const processed = wasmRef.current.grayscale(pixels);
    
    // Trả về ImageData mới
    return new ImageData(
      new Uint8ClampedArray(processed),
      imageData.width,
      imageData.height
    );
  };

  return { processImage, isReady };
}

Component sử dụng hook này:

// components/CCCDProcessor.tsx
import { useRef } from 'react';
import { useImageProcessor } from '../hooks/useImageProcessor';

export function CCCDProcessor() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const { processImage, isReady } = useImageProcessor();

  const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file || !isReady) return;

    const img = new Image();
    img.src = URL.createObjectURL(file);
    
    img.onload = async () => {
      const canvas = canvasRef.current!;
      const ctx = canvas.getContext('2d')!;
      
      canvas.width = img.width;
      canvas.height = img.height;
      ctx.drawImage(img, 0, 0);
      
      // Lấy pixel data từ canvas
      const imageData = ctx.getImageData(0, 0, img.width, img.height);
      
      // Xử lý bằng WASM - không block UI
      const processed = await processImage(imageData);
      ctx.putImageData(processed, 0, 0);
    };
  };

  return (
    <div>
      <input
        type="file"
        accept="image/*"
        onChange={handleImageUpload}
        disabled={!isReady}
      />
      <canvas ref={canvasRef} />
    </div>
  );
}

342201 Upload ảnh → WASM xử lý → kết quả hiển thị ngay, không freeze UI

Một lưu ý quan trọng: processImage ở trên vẫn chạy trên main thread. Với ảnh lớn (>5MB), bạn nên wrap nó vào Web Worker để UI hoàn toàn không bị block. Mình sẽ đề cập phần này ở section benchmark.


Benchmark thực tế: WASM vs JavaScript thuần

Số liệu đẹp nhất là số liệu bạn tự đo. Mình benchmark 3 tác vụ image processing trên ảnh CCCD thực tế (kích thước 2400×1600px, ~11.5MB pixel data):

Tác vụ JS thuần WASM (Rust) Chênh lệch
Grayscale ~180ms ~18ms 10x nhanh
Tăng contrast ~210ms ~22ms 9.5x nhanh
Box blur 5×5 ~850ms ~65ms 13x nhanh
Toàn bộ pipeline ~1240ms ~105ms ~12x nhanh

Khác biệt đến từ đâu? JavaScript là dynamically typed và phải qua JIT compiler. WASM chạy bytecode đã được compile sẵn, gần với native code hơn. Với pixel loop chạy hàng triệu lần (2400×1600 = 3.84 triệu pixels), overhead nhỏ nhân lên cực lớn.

342202 13x nhanh hơn ở blur - đây là loại tác vụ WASM sinh ra để làm

Để tự benchmark, bạn dùng performance.now():

// utils/benchmark.ts
export async function benchmarkProcessing(
  fn: () => Promise<void>,
  label: string,
  runs = 5
) {
  const times: number[] = [];
  
  for (let i = 0; i < runs; i++) {
    const start = performance.now();
    await fn();
    times.push(performance.now() - start);
  }
  
  const avg = times.reduce((a, b) => a + b) / runs;
  console.log(`${label}: trung bình ${avg.toFixed(1)}ms (${runs} lần chạy)`);
  return avg;
}

Một điểm mình muốn thành thật: WASM không phải lúc nào cũng nhanh hơn 10x. Với tác vụ đơn giản như string concatenation hay object sorting nhỏ, overhead của việc truyền data qua JS-WASM boundary có thể khiến WASM chậm hơn JS. Rule of thumb: dùng WASM khi xử lý array/buffer data lớn với nhiều phép tính lặp đi lặp lại.


Web Worker + WASM: UI không bao giờ freeze

Benchmark trên chạy trên main thread. Với ảnh 12MP từ camera điện thoại Việt Nam thường dùng, ngay cả 105ms WASM cũng đủ để người dùng thấy lag. Giải pháp: đẩy WASM vào Web Worker.

Tạo file imageWorker.ts:

// workers/imageWorker.ts
import init, { grayscale, enhance_contrast } from '../wasm/my_wasm_lib';

// Khởi tạo WASM một lần khi worker load
let wasmReady = false;

async function ensureWasm() {
  if (!wasmReady) {
    await init();
    wasmReady = true;
  }
}

self.onmessage = async (e: MessageEvent) => {
  const { type, pixels, width, height } = e.data;
  
  await ensureWasm();
  
  let result: Uint8Array;
  
  switch (type) {
    case 'grayscale':
      result = grayscale(pixels);
      break;
    case 'enhance':
      result = enhance_contrast(pixels, 1.5); // factor 1.5
      break;
    default:
      self.postMessage({ error: 'Unknown type' });
      return;
  }
  
  // Dùng transferable objects để tránh copy data
  self.postMessage(
    { result, width, height },
    [result.buffer] // Transfer ownership, không copy
  );
};

Dùng trong React hook:

// hooks/useWorkerProcessor.ts
import { useEffect, useRef } from 'react';

export function useWorkerProcessor() {
  const workerRef = useRef<Worker | null>(null);

  useEffect(() => {
    // Vite/webpack tạo worker URL tự động
    workerRef.current = new Worker(
      new URL('../workers/imageWorker.ts', import.meta.url),
      { type: 'module' }
    );
    
    return () => workerRef.current?.terminate();
  }, []);

  const processAsync = (imageData: ImageData, type: string) => {
    return new Promise<ImageData>((resolve, reject) => {
      const worker = workerRef.current!;
      
      worker.onmessage = (e) => {
        if (e.data.error) { reject(e.data.error); return; }
        resolve(new ImageData(
          new Uint8ClampedArray(e.data.result),
          e.data.width,
          e.data.height
        ));
      };
      
      // Transfer pixels để zero-copy
      const pixels = new Uint8ClampedArray(imageData.data);
      worker.postMessage(
        { type, pixels, width: imageData.width, height: imageData.height },
        [pixels.buffer]
      );
    });
  };

  return { processAsync };
}

342203 Worker + WASM: UI thread hoàn toàn tự do trong khi xử lý ảnh

Kỹ thuật transferable objects ([pixels.buffer]) là chi tiết quan trọng. Thay vì copy toàn bộ buffer sang worker (tốn thêm 11.5MB memory và thời gian copy), bạn transfer ownership - gần như zero-cost. Sau khi transfer, pixels.buffer ở main thread sẽ bị detach và không dùng được nữa.


Khi nào NÊN và KHÔNG NÊN dùng WASM

Mình thấy nhiều bài viết chỉ nói về ưu điểm của WASM mà quên trade-off. Thực tế không đẹp như benchmark.

Dùng WASM khi:

  • Xử lý pixel/audio/video data ở dạng raw buffer
  • Thuật toán lặp nhiều triệu lần (matrix multiplication, FFT)
  • Port thư viện C/C++ có sẵn (OpenCV, FFmpeg) sang web
  • Game engine hoặc physics simulation

Không nên dùng WASM khi:

  • Logic CRUD đơn giản (đọc/ghi data, validate form)
  • String processing thông thường
  • DOM manipulation (WASM không access DOM trực tiếp)
  • Tác vụ chạy < 5ms - JS đã đủ nhanh, overhead WASM không đáng

342204 Chọn đúng tool: WASM không phải silver bullet

Còn một thực tế khác: bundle size. File .wasm cho image processing của mình là ~85KB sau optimization. Với user Việt Nam dùng mạng 4G, đây là cost cần tính đến. Dùng lazy loading (dynamic import()) để chỉ load WASM khi người dùng thật sự cần - như mình đã làm ở hook useImageProcessor trên.

Cuối cùng, Rust learning curve là thật. Nếu team bạn không có ai biết Rust, có thể dùng AssemblyScript (TypeScript-like syntax compile sang WASM) hoặc Emscripten để port thư viện C. Performance thấp hơn Rust một chút nhưng onboarding dễ hơn nhiều. Tài nguyên để bắt đầu: AssemblyScript docs.

Giờ bạn đã biết đủ để bắt đầu. Bước tiếp:

  1. Clone template project Rust + Vite + React: Rust + Vite + React WASM starter
  2. Đo performance điểm đau thực tế trong app của bạn trước
  3. Chỉ port sang WASM nếu tác vụ đó chiếm > 100ms

Gặp vấn đề gì khi setup, drop comment - mình sẽ support.