Phần 5: Phát triển ứng dụng Frontend tích hợp WebAssembly
Tác giả: tranmai93 — 21/03/2026
Thiết lập môi trường phát triển React với wasm-bindgen
Khởi tạo project React và cài đặt dependencies
Bước đầu tiên là tạo skeleton cho ứng dụng Frontend. Chúng ta sử dụng Vite thay vì Create React App để có tốc độ build nhanh hơn và hỗ trợ WebAssembly tốt hơn ngay từ đầu.
Thực thi lệnh tạo project mới với template React. Sau đó, di chuyển vào thư mục và cài đặt các thư viện cần thiết: wasm-bindgen để giao tiếp giữa JS và Wasm, và @tensorflow/tfjs nếu cần xử lý tensor bổ sung (dù inference chính nằm trong Wasm).
npm create vite@latest ai-frontend -- --template react
cd ai-frontend
npm install
npm install wasm-bindgen
Kết quả mong đợi: Project được tạo thành công, thư mục node_modules xuất hiện, và các dependencies được cài đặt mà không có lỗi.
Cấu hình Vite để hỗ trợ WebAssembly
Vite cần được cấu hình để xử lý file .wasm như là một module ES6, thay vì đọc như file binary thông thường. Điều này giúp trình duyệt tải và khởi tạo Wasm module đúng cách.
Sửa file vite.config.js tại đường dẫn /ai-frontend/vite.config.js với nội dung hoàn chỉnh sau:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['wasm-bindgen'],
},
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
})
Kết quả mong đợi: File config được lưu. Các header COOP và COEP được thêm vào để đảm bảo tính năng SharedArrayBuffer hoạt động (bắt buộc cho Wasm hiệu năng cao trên Chrome), đồng thời Vite sẽ tự động chuyển đổi .wasm thành import module.
Chuẩn bị file WebAssembly vào thư mục public
Giả định từ Phần 4, bạn đã compile file model.wasm và file JS wrapper model.js (do wasm-bindgen generate). Chúng ta cần đặt file .wasm vào thư mục public để nó được serve như tài nguyên tĩnh.
Tạo thư mục public/wasm và copy file model.wasm từ project C++ sang đây. Cấu trúc thư mục public sẽ trông như sau:
mkdir -p public/wasm
# Giả sử file model.wasm đã có sẵn trong thư mục gốc hoặc thư mục build của C++
cp path/to/your/compiled/model.wasm public/wasm/
Kết quả mong đợi: File model.wasm xuất hiện trong /ai-frontend/public/wasm/. Khi chạy server dev, file này sẽ truy cập được qua URL /wasm/model.wasm.
Verify cấu hình môi trường
Chạy server phát triển và mở trình duyệt. Mở Developer Tools (F12) -> tab Network. Refresh trang. Kiểm tra xem file model.wasm có được tải với status 200 OK và Content-Type là application/wasm hay không.
npm run dev
Kết quả mong đợi: Server chạy trên http://localhost:5173, không có lỗi khởi tạo, và file Wasm được tải thành công trong tab Network.
Triển khai logic Frontend: Camera và WebAssembly
Tạo component xử lý camera và Canvas
Chúng ta cần một component React để quản lý vòng đời của camera (Web API getUserMedia) và vẽ kết quả lên canvas. Logic này sẽ chạy liên tục trong một vòng lặp (requestAnimationFrame).
Tạo file mới /ai-frontend/src/components/AIInference.jsx với nội dung hoàn chỉnh dưới đây. Code này bao gồm logic lấy stream video, vẽ frame vào canvas, và chuẩn bị biến để gọi Wasm.
import { useEffect, useRef, useState } from 'react';
const AIInference = () => {
const videoRef = useRef(null);
const canvasRef = useRef(null);
const [inferenceResult, setInferenceResult] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480 },
audio: false
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play();
requestAnimationFrame(processFrame);
}
} catch (err) {
console.error('Lỗi truy cập camera:', err);
}
};
const processFrame = () => {
if (videoRef.current && videoRef.current.readyState === videoRef.current.HAVE_ENOUGH_DATA) {
if (canvasRef.current && videoRef.current) {
const ctx = canvasRef.current.getContext('2d');
ctx.drawImage(videoRef.current, 0, 0, 640, 480);
// Ở đây sẽ gọi hàm Wasm để inference
// Giả định hàm Wasm nhận input là ImageData và trả về kết quả
if (wasmModule && isProcessing) {
runInference(ctx, setInferenceResult);
}
}
}
requestAnimationFrame(processFrame);
};
startCamera();
return () => {
// Cleanup stream khi unmount
if (videoRef.current && videoRef.current.srcObject) {
const tracks = videoRef.current.srcObject.getTracks();
tracks.forEach(track => track.stop());
}
};
}, [isProcessing]);
return (
Trạng thái AI
Đang xử lý: {isProcessing ? 'Đang chạy' : 'Dừng'}
{inferenceResult && (
{JSON.stringify(inferenceResult, null, 2)}
)}
);
};
export default AIInference;
Kết quả mong đợi: Component AIInference được tạo. Nó sẽ hiển thị luồng video từ webcam và một canvas trống cùng nút điều khiển. Chưa có bounding box vì chưa tích hợp Wasm.
Tích hợp module WebAssembly vào React
Bước quan trọng nhất là tải và khởi tạo module Wasm. Chúng ta sử dụng hàm init được generate bởi wasm-bindgen. Hàm này trả về một Promise chứa các hàm C++ đã được export.
Tạo file /ai-frontend/src/wasm_loader.js để đóng gói logic khởi tạo. Điều này giúp tách biệt logic tải Wasm khỏi component UI, tránh việc tải lại module mỗi khi component render.
import init, { run_inference } from '../wasm/model.js';
let wasmInstance = null;
export const loadWasmModule = async () => {
if (wasmInstance) return wasmInstance;
try {
// wasm-bindgen tự động tìm file .wasm tương ứng với .js
// Nếu cần chỉ định đường dẫn thủ công, truyền vào tham số getWasm
wasmInstance = await init();
console.log('WebAssembly module loaded successfully');
return wasmInstance;
} catch (error) {
console.error('Lỗi tải WebAssembly:', error);
throw error;
}
};
export const getWasmInstance = () => wasmInstance;
Kết quả mong đợi: Module wasm_loader được tạo. Khi gọi loadWasmModule, nó sẽ tải file model.wasm và trả về đối tượng chứa hàm run_inference.
Liên kết Camera, Canvas và WebAssembly
Bây giờ, chúng ta kết nối AIInference.jsx với wasm_loader.js. Khi người dùng nhấn "Bắt đầu AI", chúng ta gọi hàm run_inference từ Wasm, truyền vào dữ liệu pixel của canvas (ImageData) và nhận lại tọa độ bounding box.
Sửa file /ai-frontend/src/components/AIInference.jsx. Thêm import loadWasmModule và cập nhật logic useEffect để gọi Wasm. Lưu ý: Trong thực tế, run_inference có thể cần dữ liệu đầu vào là Uint8Array hoặc ImageData tùy theo cách bạn write C++.
import { useEffect, useRef, useState } from 'react';
import { loadWasmModule } from '../wasm_loader';
const AIInference = () => {
const videoRef = useRef(null);
const canvasRef = useRef(null);
const [inferenceResult, setInferenceResult] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [wasmReady, setWasmReady] = useState(false);
useEffect(() => {
// Tải Wasm khi component mount
const initWasm = async () => {
try {
await loadWasmModule();
setWasmReady(true);
} catch (e) {
console.error('Không thể khởi tạo Wasm:', e);
}
};
initWasm();
}, []);
useEffect(() => {
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480 },
audio: false
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play();
requestAnimationFrame(processFrame);
}
} catch (err) {
console.error('Lỗi camera:', err);
}
};
const runInference = (ctx) => {
if (!wasmReady) return;
// Lấy dữ liệu pixel từ canvas
const imageData = ctx.getImageData(0, 0, 640, 480);
// Giả định hàm Wasm nhận Uint8Array và trả về object {x, y, w, h, label}
// Nếu C++ của bạn trả về struct, wasm-bindgen sẽ tự convert sang JS Object
const result = wasmModule.run_inference(imageData.data);
setInferenceResult(result);
};
const processFrame = () => {
if (videoRef.current && videoRef.current.readyState === videoRef.current.HAVE_ENOUGH_DATA) {
if (canvasRef.current && videoRef.current) {
const ctx = canvasRef.current.getContext('2d');
ctx.drawImage(videoRef.current, 0, 0, 640, 480);
if (wasmReady && isProcessing) {
runInference(ctx);
}
}
}
requestAnimationFrame(processFrame);
};
startCamera();
return () => {
if (videoRef.current && videoRef.current.srcObject) {
videoRef.current.srcObject.getTracks().forEach(t => t.stop());
}
};
}, [isProcessing, wasmReady]);
return (
Trạng thái AI
Wasm Loaded: {wasmReady ? 'Đã sẵn sàng' : 'Đang tải...'}
Đang xử lý: {isProcessing ? 'Đang chạy' : 'Dừng'}
{inferenceResult && (
{JSON.stringify(inferenceResult, null, 2)}
)}
);
};
// Giả lập object wasmModule để code compile được (trong thực tế sẽ import từ wasm_loader)
const wasmModule = { run_inference: () => ({ label: 'person', score: 0.95, x: 100, y: 100, w: 200, h: 300 }) };
export default AIInference;
Kết quả mong đợi: Code đã liên kết logic. Khi nhấn nút, nếu Wasm đã load, nó sẽ gọi hàm inference và hiển thị kết quả giả lập (hoặc thực tế nếu C++ đã compile đúng).
Verify tích hợp WebAssembly
Chạy lại server dev. Mở tab Console trong trình duyệt. Kiểm tra log "WebAssembly module loaded successfully". Nhấn nút "Bắt đầu AI". Quan sát xem console có in ra object kết quả inference hay không.
npm run dev
Kết quả mong đợi: Log xuất hiện, nút "Bắt đầu AI" kích hoạt, và JSON kết quả hiện ra trong khung bên phải.
Hiển thị kết quả AI trên Canvas
Vẽ Bounding Box và Label
Bây giờ chúng ta cần vẽ hình chữ nhật (bounding box) và văn bản (label) trực tiếp lên canvas dựa trên tọa độ nhận được từ Wasm. Điều này cần được thực hiện ngay trong vòng lặp processFrame sau khi nhận kết quả.
Sửa lại logic vẽ trong AIInference.jsx. Chúng ta sẽ dùng ctx.fillRect và ctx.fillText. Cần đảm bảo màu sắc nổi bật (ví dụ: xanh lá cho detection thành công).
import { useEffect, useRef, useState } from 'react';
import { loadWasmModule } from '../wasm_loader';
const AIInference = () => {
const videoRef = useRef(null);
const canvasRef = useRef(null);
const [isProcessing, setIsProcessing] = useState(false);
const [wasmReady, setWasmReady] = useState(false);
const [currentResult, setCurrentResult] = useState(null);
useEffect(() => {
const initWasm = async () => {
try {
await loadWasmModule();
setWasmReady(true);
} catch (e) {
console.error('Lỗi Wasm:', e);
}
};
initWasm();
}, []);
useEffect(() => {
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480 },
audio: false
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play();
requestAnimationFrame(processFrame);
}
} catch (err) {
console.error('Lỗi camera:', err);
}
};
const runInference = (ctx) => {
if (!wasmReady) return;
const imageData = ctx.getImageData(0, 0, 640, 480);
// Giả định hàm C++ trả về object kết quả
const result = wasmModule.run_inference(imageData.data);
setCurrentResult(result);
};
const processFrame = () => {
if (videoRef.current && videoRef.current.readyState === videoRef.current.HAVE_ENOUGH_DATA) {
if (canvasRef.current && videoRef.current) {
const ctx = canvasRef.current.getContext('2d');
// 1. Vẽ video frame
ctx.drawImage(videoRef.current, 0, 0, 640, 480);
// 2. Xử lý inference nếu đang chạy
if (wasmReady && isProcessing) {
runInference(ctx);
}
// 3. Vẽ kết quả (Bounding Box + Label)
if (currentResult) {
const { x, y, w, h, label, score } = currentResult;
// Vẽ khung hình chữ nhật
ctx.strokeStyle = '#00FF00'; // Màu xanh lá
ctx.lineWidth = 3;
ctx.strokeRect(x, y, w, h);
// Vẽ nền label
ctx.fillStyle = '#00FF00';
ctx.fillRect(x, y - 20, (label + ' ' + score).length * 10 + 20, 20);
// Vẽ text
ctx.fillStyle = '#000000';
ctx.font = '16px Arial';
ctx.fillText(`${label} ${score.toFixed(2)}`, x, y - 5);
// Reset kết quả để tránh vẽ lặp lại frame cũ (tùy chọn, hoặc dùng state để lưu)
// Ở đây ta giữ nguyên để thấy kết quả, nhưng trong real-time nên cập nhật mỗi frame
}
}
}
requestAnimationFrame(processFrame);
};
startCamera();
return () => {
if (videoRef.current && videoRef.current.srcObject) {
videoRef.current.srcObject.getTracks().forEach(t => t.stop());
}
};
}, [isProcessing, wasmReady, currentResult]);
return (
Điều khiển
Wasm Loaded: {wasmReady ? 'Đã sẵn sàng' : 'Đang tải...'}
setIsProcessing(!isProcessing)}
disabled={!wasmReady}
style={{ marginTop: '10px', padding: '10px 20px' }}
>
{isProcessing ? 'Dừng AI' : 'Bắt đầu AI'}
);
};
// Mock object cho mục đích demo
const wasmModule = { run_inference: () => ({ label: 'person', score: 0.98, x: 200, y: 150, w: 250, h: 350 }) };
export default AIInference;
Kết quả mong đợi: Khi camera bật và nhấn nút "Bắt đầu AI", trên canvas sẽ xuất hiện hình chữ nhật màu xanh bao quanh đối tượng (giả lập) và nhãn "person 0.98" ngay phía trên.
Verify hiển thị thực tế
Thay thế hàm wasmModule.run_inference mock bằng hàm thật từ wasm_loader. Chạy lại ứng dụng. Đảm bảo bounding box di chuyển theo đối tượng thực tế trong video webcam (nếu mô hình của bạn là object tracking/detection động).
npm run dev
Kết quả mong đợi: Bounding box xuất hiện chính xác trên người hoặc vật thể trong khung hình webcam, cập nhật theo thời gian thực.
Tối ưu hiệu năng và xử lý lỗi
Quản lý bộ nhớ và tránh Memory Leak
Khi chạy inference liên tục, việc tạo đối tượng mới (ImageData, Tensor) mỗi frame có thể gây đầy bộ nhớ (Memory Leak) hoặc GC (Garbage Collection) làm giật ứng dụng. Cần tái sử dụng buffer nếu có thể, hoặc đảm bảo giải phóng tài nguyên khi component unmount.
Trong code React, việc gọi track.stop() trong useEffect cleanup là bắt buộc. Ngoài ra, nếu mô hình Wasm của bạn yêu cầu buffer cố định, hãy khai báo useRef để giữ buffer đó xuyên suốt các lần render thay vì tạo mới.
// Ví dụ: Tái sử dụng buffer trong Wasm (nếu cần)
const bufferRef = useRef(null);
useEffect(() => {
if (!bufferRef.current) {
// Khởi tạo buffer một lần
bufferRef.current = new Uint8Array(640 * 480 * 4);
}
// Sử dụng bufferRef.current trong vòng lặp
}, []);
Kết quả mong đợi: Ứng dụng chạy ổn định trong thời gian dài mà không bị treo hoặc tăng RAM đột ngột.
Xử lý lỗi tương thích trình duyệt
Không phải trình duyệt nào cũng hỗ trợ đầy đủ SharedArrayBuffer hoặc WebAssembly SIMD. Cần có cơ chế fallback hoặc thông báo lỗi rõ ràng cho người dùng.
Thêm kiểm tra 'sharedArrayBuffer' in globalThis trước khi khởi tạo Wasm. Nếu không hỗ trợ, hiển thị thông báo và disable nút AI.
const isWasmSupported = 'sharedArrayBuffer' in globalThis;
if (!isWasmSupported) {
console.warn('Trình duyệt này không hỗ trợ SharedArrayBuffer, Wasm hiệu năng cao sẽ không hoạt động.');
}
Kết quả mong đợi: Ứng dụng không bị crash trên trình duyệt cũ, mà chỉ hiển thị cảnh báo và vô hiệu hóa tính năng AI.
Verify hiệu năng cuối cùng
Mở Chrome DevTools -> tab Performance. Ghi lại (Record) khi nhấn nút "Bắt đầu AI". Quan sát FPS của camera và thời gian của hàm run_inference. Mục tiêu là FPS > 30 và inference time < 30ms cho mỗi frame.
npm run dev
Kết quả mong đợi: Biểu đồ FPS ổn định, không có spike về CPU, và bounding box di chuyển mượt mà không bị trễ.
Điều hướng series:
Mục lục: Series: Xây dựng nền tảng Real-time AI với WebAssembly, TensorFlow Lite và Kubernetes
« Phần 4: Xây dựng engine suy luận WebAssembly từ C++
Phần 6: Bao gói ứng dụng Web thành Docker Container »