Bối cảnh & Lý do
Trình duyệt thực thi mã JavaScript trên một luồng đơn (single thread) cùng với việc tính toán lại layout, áp dụng style, gom rác bộ nhớ (garbage collection) và vẽ lại màn hình (repaints). Để đạt được tốc độ mượt mà 60 khung hình/giây (FPS), bạn chỉ có một khoảng thời gian vô cùng khắt khe: đúng 16,67 mili-giây cho mỗi frame. Nếu bỏ lỡ mốc thời gian đó, hiện tượng giật khung hình (frame drop) sẽ xảy ra ngay lập tức.
Các tác vụ nặng sẽ nhanh chóng làm cạn kiệt ngân sách thời gian này. Việc phân tích cú pháp một file CSV 45 MB, xử lý dữ liệu điểm ảnh (pixel), hoặc mô phỏng thuật toán chuyển động bầy đàn (flocking) có thể dễ dàng chiếm dụng CPU từ 150 đến 500 mili-giây. Trong suốt khoảng thời gian đó, trang web sẽ bị đơ hoàn toàn. Các sự kiện người dùng bị dồn ứ trong hàng đợi, hiệu ứng CSS bị giật cục và người dùng sẽ gặp phải trải nghiệm gián đoạn khó chịu do giật lag (visual jank).
Web Workers đã giải quyết một phần vấn đề này bằng cách chuyển các tác vụ tính toán thuần túy sang các luồng nền (background threads). Tuy nhiên, một rào cản lớn vẫn tồn tại: workers hoàn toàn không thể truy cập trực tiếp vào DOM. Trước đây, worker phải gửi buffer ảnh thô ngược lại main thread thông qua postMessage, buộc UI thread phải thực hiện các lệnh gọi ctx.drawImage() sau cùng. Quá trình tuần tự hóa và trao đổi dữ liệu hai chiều đó đã tự tạo ra độ trễ (latency overhead) không hề nhỏ.
API OffscreenCanvas ra đời để xóa bỏ nút thắt cổ chai đó. Nó tách biệt context render của canvas khỏi cây DOM, cho phép các background worker vẽ trực tiếp vào visual buffer. Đối với các dashboard hiển thị dữ liệu viễn trắc theo thời gian thực (telemetry), khung cảnh WebGL hoặc web game trên trình duyệt, sự tách biệt này giúp mọi tương tác của người dùng luôn nhạy bén bất kể khối lượng tính toán nặng đến mức nào.
Cài đặt
Chúng ta sẽ xây dựng một dự án tối giản sử dụng Vite. Vite hỗ trợ sẵn ES-module web workers mà không cần phải cấu hình thủ công các plugin worker cho Rollup hay Webpack.
Khởi tạo một dự án vanilla từ terminal của bạn:
# Khởi tạo template vanilla JavaScript gọn nhẹ
npm create vite@latest worker-canvas-demo -- --template vanilla
# Di chuyển vào thư mục dự án
cd worker-canvas-demo
# Cài đặt các dependencies của dự án
npm install
Thiết lập cấu trúc thư mục dự án của bạn theo sơ đồ sau:
worker-canvas-demo/
├── index.html
├── src/
│ ├── main.js
│ └── worker.js
└── package.json
Khởi chạy local dev server với Vite:
npm run dev
Cấu hình
Chúng ta sẽ xây dựng một bài kiểm tra chịu tải (stress test) cụ thể: một canvas tương tác render 3.000 hạt chuyển động (particles) trong một vòng lặp liên tục. Bên cạnh đó, một nút đếm HTML sẽ theo dõi các lượt click của người dùng. Trên kiến trúc đơn luồng thông thường, việc cập nhật tọa độ của 3.000 hạt sẽ tạo ra độ trễ thao tác từ 40–80 ms. Bằng cách chuyển toàn bộ thao tác canvas sang background worker, mỗi lần click nút sẽ được phản hồi trong vòng chưa đầy 1 mili-giây.
1. Chuẩn bị file HTML
Thay thế nội dung của index.html bằng một khung chứa canvas và nút kích hoạt kiểm tra độ phản hồi:
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo Web Worker & OffscreenCanvas</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
background: #0d1117;
color: #f0f6fc;
margin: 0;
padding: 24px;
}
canvas {
border: 1px solid #30363d;
border-radius: 8px;
background: #010409;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.controls {
margin-top: 20px;
display: flex;
gap: 16px;
align-items: center;
}
button {
padding: 10px 20px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
background: #238636;
border: 1px solid rgba(240, 246, 252, 0.1);
border-radius: 6px;
color: #ffffff;
transition: background-color 0.1s ease;
}
button:hover {
background: #2ea043;
}
button:active {
background: #1f702e;
}
span {
color: #8b949e;
font-size: 14px;
}
</style>
</head>
<body>
<h1>Mô phỏng hạt trên Background Thread</h1>
<canvas id="viewport" width="800" height="500"></canvas>
<div class="controls">
<button id="counterBtn">Lượt click: 0</button>
<span>Click liên tục: Giao diện hoàn toàn không bị gián đoạn hay đơ lag.</span>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
2. Cấu hình Controller trên Main Thread
Trong file src/main.js, hãy tách quyền điều khiển canvas bằng cách sử dụng transferControlToOffscreen(). Sau đó, chúng ta truyền trực tiếp đối tượng này vào worker thread.
Điều quan trọng là phải truyền tham chiếu offscreen vào đối số thứ hai của worker.postMessage() dưới dạng một Transferable item. Cách này sẽ chuyển quyền sở hữu vùng nhớ bên dưới thay vì phải thực hiện sao chép cấu trúc (structured clone) tốn nhiều tài nguyên.
// src/main.js
const canvas = document.getElementById('viewport');
const counterBtn = document.getElementById('counterBtn');
let count = 0;
// Bộ đếm nhanh: kiểm tra xem UI thread có xử lý các sự kiện kịp thời hay không
counterBtn.addEventListener('click', () => {
count += 1;
counterBtn.textContent = `Lượt click: ${count}`;
});
// Kiểm tra điều kiện: đảm bảo trình duyệt hỗ trợ chuyển quyền điều khiển canvas
if (!('transferControlToOffscreen' in canvas)) {
counterBtn.disabled = true;
alert('Trình duyệt hiện tại của bạn không hỗ trợ OffscreenCanvas.');
} else {
// Tách quyền điều khiển khỏi DOM. Mọi lệnh gọi getContext() sau đó trên main thread sẽ phát sinh lỗi.
const offscreen = canvas.transferControlToOffscreen();
// Khởi tạo background worker thông qua cơ chế phân giải ES module của Vite
const renderWorker = new Worker(
new URL('./worker.js', import.meta.url),
{ type: 'module' }
);
// Chuyển quyền sở hữu canvas sang worker thread thông qua mảng Transferable
renderWorker.postMessage(
{
type: 'INIT',
canvas: offscreen,
width: canvas.width,
height: canvas.height,
},
[offscreen]
);
}
3. Triển khai Background Render Worker
Trong file src/worker.js, hãy lắng nghe message, trích xuất offscreen canvas, lấy context 2D của nó và bắt đầu một vòng lặp requestAnimationFrame. Các trình duyệt hiện đại đều hỗ trợ đầy đủ requestAnimationFrame ngay bên trong ngữ cảnh của dedicated worker (Chrome 69+, Firefox 105+ và Safari 16.4+).
// src/worker.js
let ctx = null;
let width = 0;
let height = 0;
const PARTICLE_COUNT = 3000;
const particles = [];
class Particle {
constructor(w, h) {
this.x = Math.random() * w;
this.y = Math.random() * h;
this.vx = (Math.random() - 0.5) * 3;
this.vy = (Math.random() - 0.5) * 3;
this.radius = Math.random() * 2 + 1;
}
update(w, h) {
this.x += this.vx;
this.y += this.vy;
if (this.x < 0 || this.x > w) this.vx = -this.vx;
if (this.y < 0 || this.y > h) this.vy = -this.vy;
}
draw(context) {
context.beginPath();
context.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
context.fillStyle = '#38bdf8';
context.fill();
}
}
function render() {
// Lớp phủ bán trong suốt tạo hiệu ứng vệt chuyển động (motion trail) mượt mà
ctx.fillStyle = 'rgba(1, 4, 9, 0.25)';
ctx.fillRect(0, 0, width, height);
for (let i = 0; i < particles.length; i++) {
particles[i].update(width, height);
particles[i].draw(ctx);
}
// Vòng lặp render được lập lịch trực tiếp bên trong worker thread
requestAnimationFrame(render);
}
self.onmessage = (event) => {
const { type, canvas, width: w, height: h } = event.data;
if (type === 'INIT') {
width = w;
height = h;
ctx = canvas.getContext('2d');
for (let i = 0; i < PARTICLE_COUNT; i++) {
particles.push(new Particle(width, height));
}
requestAnimationFrame(render);
}
};
Kiểm tra & Giám sát
Mở bản demo Vite đang chạy trên Chrome, Firefox hoặc Edge để đo lường mức độ cô lập luồng và tính ổn định khi render.
1. Kiểm tra độ trễ sự kiện khi chịu tải
Quan sát 3.000 hạt chuyển động trên canvas. Hãy click liên tục vào nút đếm “Lượt click: 0”. Ngay cả khi worker phải tính toán 6.000 thay đổi tọa độ và thực thi 3.000 lệnh vẽ đường dẫn mỗi 16 mili-giây, các trình xử lý sự kiện click vẫn được kích hoạt trong chưa đầy 2 ms. Hoàn toàn không xuất hiện độ trễ thao tác.
2. Đo đạc tải công việc (Profile) trong Chrome DevTools
Xác thực sự tách biệt luồng bằng công cụ profiler của Chrome:
- Nhấn
F12để mở DevTools, sau đó chọn tab Performance. - Nhấn Record, click liên tục vào nút đếm trong khoảng ba giây, rồi nhấn Stop.
- Kiểm tra dòng thời gian của các luồng (thread timeline):
- Main: Cho thấy thời lượng thực thi script gần như bằng 0. Các tác vụ hoàn thành dưới 1 ms với khoảng 97% thời gian nhàn rỗi (idle time), để lại dư dả tài nguyên cho các sự kiện DOM và xử lý giao diện.
- Dedicated Worker: Duy trì liên tục các khối tính toán hạt và vẽ
arc()mà không kích hoạt bất kỳ cảnh báo Long Task (> 50 ms) nào trên luồng UI.
3. Kiểm tra độ ổn định của khung hình
Mở Command Menu bằng tổ hợp phím Ctrl + Shift + P (hoặc Cmd + Shift + P trên macOS), nhập Show Rendering và nhấn Enter. Kích hoạt mục Frame Rendering Stats ở bảng điều khiển bên dưới. Một bảng thông số GPU HUD theo thời gian thực sẽ xuất hiện, hiển thị chỉ số 60 FPS ổn định mà không hề có các đột biến sụt giảm khung hình.

