آموزش OffscreenCanvas در JavaScript — از پایه تا پیادهسازی
بحران فریز شدن UI: چرا Canvas معمولی برای انیمیشنهای سنگین مناسب نیست؟
تا حالا شده روی یک وبسایت کلیک کنی و احساس کنی مرورگر چند ثانیه کاملاً قفل شده؟ این جادوی سیاه که بهش UI Freezing یا Jank میگیم، زمانی رخ میده که پردازشهای سنگین، نخ اصلی مرورگر (Main Thread) رو به اشغال خودشون درمیارن. اگر در حال توسعه یک بازی تحت وب، ابزار ادیت تصویر، یا داشبورد پردازش دادههای حجیم باشی، احتمالاً با این بحران دستوپنجه نرم کردی.
مشکل اصلی از معماری Single-Threaded مرورگر سرچشمه میگیره. مرورگر مجبوره تمام کارهای زیر رو روی تنها یک نخ (Main Thread) و به صورت ترتیبی انجام بده:
- اجرای کدهای کلاینت (JavaScript)
- محاسبه کدهای CSS و ساختار صفحه (Style & Layout)
- رسم پیکسلها روی صفحه (Paint)
- پاسخ به رویدادهای کاربر (Event Handling مثل کلیک و اسکرول)
وقتی یک انیمیشن سنگین Canvas رو روی Main Thread اجرا میکنی، مرورگر فرصتی برای پردازش ورودیهای کاربر پیدا نمیکنه. جریان دادهها و بلاک شدن نخ اصلی رو میشه به این صورت تصویر کرد:
[ Main Thread ] ──► [ پردازش ریاضی سنگین Canvas ] ──► [ رندر پیکسلها ] ──► [ کلیک کاربر ] (بلاک شده!)
└────────── 45ms ──────────┘ (بودجه مجاز: 16.6ms)
یک توسعهدهنده جونیور در مواجهه با این مشکل، معمولاً سراغ راهحلهای ناپایدار میره؛ مثلاً استفاده از setTimeout برای خرد کردن پردازشها یا تکیه بر requestAnimationFrame. اما این روشها مسئله رو ریشهای حل نمیکنن، چون تمام محاسبات هنوز دارن روی همون Main Thread شلوغ اجرا میشن.
بیا یک نمونه کد معمولی رو بررسی کنیم که چطور نخ اصلی رو خفه میکنه:
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
function renderHeavyScene() {
// پاکسازی فریم قبلی
ctx.clearRect(0, 0, canvas.width, canvas.height);
// شبیهسازی پردازش ریاضی ۵۰,۰۰۰ پارتیکل روی نخ اصلی
for (let i = 0; i < 50000; i++) {
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
ctx.fillRect(x, y, 2, 2);
}
requestAnimationFrame(renderHeavyScene);
}
renderHeavyScene();
در این کد، حلقه 50000 تکراری در هر فریم اجرا میشه. برای داشتن یک انیمیشن روان با نرخ ۶۰ فریم بر ثانیه (60 FPS)، مرورگر فقط ۱۶.۶ میلیثانیه زمان در اختیار داره تا کل تابع را تمام کند و صفحه را redraw کند. وقتی اجرای این حلقه ۴۵ میلیثانیه طول بکشد، مرورگر مجبور میشود فریمهای بعدی را دور بریزد (Frame Drop) و تا زمان پایان پردازش، هیچ کلیکی از سمت کاربر ثبت نخواهد شد.
اگر مقدار پارتیکلها را به 5000 کاهش دهیم، مشکل ظاهراً برطرف میشود؛ اما این یعنی کم کردن کیفیت پروژه به خاطر محدودیتهای فنی!
| نوع رویکرد | محل اجرای پردازش | وضعیت پاسخگویی UI | حداکثر توان پردازشی |
|---|---|---|---|
| Canvas معمولی | Main Thread | ضعیف (احتمال فریز بالا در بار سنگین) | محدود به بودجه ۱۶.۶ میلیثانیهای UI |
| OffscreenCanvas | Web Worker | عالی (کاملاً مستقل از UI) | وابسته به حداکثر توان CPU/GPU کاربر |
اشتباه رایج: خیلی از برنامهنویسها تصور میکنند تابع
requestAnimationFrameکد را در یک Thread جداگانه اجرا میکند! اما این تابع فقط اجرای کد شما را با زمان بازسازی صفحه (Refresh Rate) هماهنگ میکند و همچنان روی همان Main Thread قفلکننده قرار دارد.
آیا این یعنی باید Canvas معمولی را کلاً کنار بگذاریم؟ مطلقاً خیر. اگر قصد داری یک نمودار ساده کشیده، فرمهای ساده متحرک بسازی یا انیمیشنهای خلوت اجرا کنی، Canvas معمولی به دلیل سادگی و عدم نیاز به پیچیدگیهای Worker، همچنان بهترین و کمهزینهترین انتخاب است.
در این بخش متوجه شدیم که وابستگی Canvas به DOM و Main Thread چطور باعث افت فریم و فریز شدن صفحه میشود. در بخش بعدی یاد میگیریم که OffscreenCanvas چطور با قطع وابستگی Canvas از DOM، رندرینگ را به یک Web Worker منتقل میکند تا UI برنامه همیشه مثل ابریشم روان بماند.
مفهوم OffscreenCanvas مستقل: ساخت بوم نقاشی بدون نیاز به DOM
فرض کن میخوای توی اپلیکیشنت برای کاربر یک کلاژ تصویری، کارت دعوت یا واترمارک سفارشی با رزولوشن بالا بسازی تا بتونه دانلودش کنه. اگر بدون شناخت سیستم رندرینگ جاوااسکریپت سراغ این کار بری، احتمالاً اولین کاری که میکنی ساخت یک عنصر <canvas> مخفی در DOM با document.createElement و دادن استایل display: none به اونه.
این روش ناشیانه، مرورگر رو مجبور میکنه یک عنصر گرافیکی کامل رو وارد درخت DOM کنه و الگوریتمهای محاسباتی Layout و Style Calculation رو بیدلیل درگیر کنه. از اون بدتر، این کار وابستگی شدید به ساختار DOM ایجاد میکنه و تمام این پردازشهای سنگین گرافیکی رو روی Main Thread نگه میداره که نتیجهاش افت فریم و لگ زدن انیمیشنهای صفحه است.
راهکار مهندسی، قطع کامل وابستگی بوم گرافیکی از درخت DOM است. کلاس OffscreenCanvas درست مثل یک بوم نقاشی در حافظه رم (In-Memory Buffer) عمل میکنه؛ بدون اینکه کوچکترین اثری در صفحه وب داشته باشه یا مرورگر رو درگیر محاسبه استایلهای CSS کنه.
نگاهی به مسیر پردازش داده در یک بوم مستقل بندازیم:
[ورودی: دادههای گرافیکی]
│
▼
┌──────────────────────────┐
│ OffscreenCanvas (In-RAM) │ ◄── بدون وابستگی به DOM و CSS
└──────────┬───────────────┘
│
▼
[خروجی: Blob / ImageData] ──► [ذخیره یا دانلود فایل]
حالا بیا اولین بوم مستقل خودمون رو بسازیم. ساختار اصلی بسیار ساده است اما تواناییهای پردازشی فوقالعادهای بهت میده:
// ساخت یک بوم مستقل در حافظه با ابعاد دقیق ۸۰۰ در ۶۰۰ پیکسل
const offscreen = new OffscreenCanvas(800, 600);
const ctx = offscreen.getContext('2d');
// رسم یک مستطیل آبی روی بوم موجود در حافظه
ctx.fillStyle = '#1e40af';
ctx.fillRect(0, 0, 800, 600);
در این کد، ابعاد 800 در 600 بر حسب پیکسل واقعی تعیین شدهاند تا خروجی نهایی بدون افت کیفیت یا کشیدگی پیکسلها (Pixelation) تولید بشه. اگه این عددها رو روی 1920 در 1080 بگذاری، بوم دقیقاً با کیفیت Full HD در حافظه ساخته میشه؛ بدون اینکه ابعاد نمایشگر کاربر یا اندازه پنجره مرورگر محدودیت ایجاد کنه.
حالا چطور خروجی این بوم رو دریافت کنیم بدون اینکه نیازی به قرار دادن اون روی صفحه باشه؟ با استفاده از متد convertToBlob() میتونیم تصویر رندر شده رو مستقیماً به یک فایل در حافظه تبدیل کنیم:
// استخراج خروجی به صورت یک فایل تصویری PNG
const blob = await offscreen.convertToBlob({
type: 'image/png',
quality: 0.92
});
const downloadUrl = URL.createObjectURL(blob);
تنظیم quality: 0.92 به مرورگر میگه که تعادل بهینهای بین حجم فایل و کیفیت گرافیکی برقرار کنه. مقادیر کمتر از 0.7 حجم رو شدیداً کم میکنه اما خطاهای فشردهسازی (Artifacts) روی تصویر باقی میذاره، و عدد 1.0 خروجی بدون افت کیفیت اما با حجم بالا میده.
تفاوت در نحوه مقداردهی اولیه برای پردازشهای ۲ بعدی و ۳ بعدی رو در دو واریانت زیر میبینی:
// واریانت ۱: بوم ۲ بعدی برای پردازش متون و بنرها
const canvas2d = new OffscreenCanvas(400, 400);
const ctx2d = canvas2d.getContext('2d');
ctx2d.fillText('Hello System!', 50, 50);
// واریانت ۲: بوم ۳ بعدی برای پردازش سنگین گرافیک WebGL
const canvas3d = new OffscreenCanvas(1024, 1024);
const gl = canvas3d.getContext('webgl');
// آمادهسازی شدرها و ساخت بافت بدون اشغال حتی یک پیکسل از صفحه
نکته کاربردی: تغییر دادن خواص
widthوheightرویOffscreenCanvasدرست مثل بوم استاندارد، تمام محتوای رسمشده و وضعیت Context (مثل فونتها، رنگها و ماتریسهای تبدیل) رو کاملاً ریست و پاک میکنه. اگر نیاز به تغییر سایز پویا داری، همیشه ابعاد جدید رو قبل از شروع رسم مجدد تنظیم کن.
چه زمانی نباید از بوم مستقل در حافظه استفاده کنیم؟
اگر هدفت نمایش زنده یک بوم تعاملی به کاربر باشه (مثلاً کاربر روی بوم کلیک میکنه یا نشانگر ماوس رو حرکت میده)، ساخت بوم مستقل به تنهایی کافی نیست. در این شرایط، بوم مستقل چون توی DOM نیست نمیتونه رویدادهای UI رو مستقیماً دریافت کنه. برای این سناریوها باید کنترل یک Canvas واقعی در صفحه رو به یک پردازش پسزمینه بسپاری.
در این بخش یاد گرفتیم چطور بدون اشغال درخت DOM و بدون ریسک افت فریم، بومهای گرافیکی مستقل در حافظه بسازیم و خروجی بگیریم. در بخش بعدی میبینیم که چطور میتونیم کنترل یک عنصر <canvas> موجود در صفحه رو به یک Web Worker سپرده و پردازش گرافیکی زنده رو کلاً از Main Thread خارج کنیم.
انتقال مالکیت بوم: متد transferControlToOffscreen و نحوه کارکرد آن
فرض کن یک داشبورد تحلیل داده زنده (Real time) داری که باید هزاران نقطه داده و نمودار پیچیده رو روی یک عنصر <canvas> در صفحه نمایش بده. اگه تمام محاسبات ریاضی و عملیات رسم پیکسلها رو روی Main Thread انجام بدی، به محض اینکه کاربر روی یک تب کلیک کنه یا صفحه رو اسکرول کنه، مرورگر لک میزنه و فریمها میافتن (Jank).
برنامهنویسهای تازهکار معمولاً برای حل این مشکل، محاسبات سنگین ریاضی رو به Web Worker منتقل میکنند، اما در هر فریم خروجی پیکسلها یا تصاویر (ImageData) رو با postMessage به Main Thread میفرستند تا اونجا روی بوم رسم بشه. این روش اگرچه Main Thread رو کمی سبک میکنه، اما حجم عظیمی از داده در هر ۱۶ میلیثانیه بین Threadها کپی میشه. این کپیبرداری مداوم، حافظه RAM رو پر از Garbage میکنه و پردازش کپی دادهها دوباره Main Thread رو قفل میکنه!
راهکار مهندسی و اصولی، قطع کامل وابستگی رندرینگ از Main Thread است. متد transferControlToOffscreen() دقیقاً برای همین هدف طراحی شده: این متد کنترل لایه رندر بوم موجود در DOM رو تحویل میگیره و یک نمونه OffscreenCanvas میسازه. حالا میتونی مالکیت این بوم رو بدون هیچگونه کپیبرداری سنگین در حافظه (Zero-Copy)، به یک Web Worker بسپاری تا تمام عملیات کشیدن و پردازش گرافیکی مستقیماً از داخل پسزمینه روی تصویر اصلی صفحه اعمال بشه.
[ Main Thread ]
<canvas id="myCanvas"> --- transferControlToOffscreen() ---> [ OffscreenCanvas ]
|
postMessage(..., [offscreen])
v
[ Web Worker ]
OffscreenCanvas.getContext('2d') <--- رندر مستقیم و بدون واسطه روی پیکسلهای DOM
بیا ابتدا نحوه جداکردن کنترل بوم از DOM رو در سادهترین شکل ممکن ببینی:
const htmlCanvas = document.getElementById('analyticsChart');
// انتقال کنترل رندر بوم به یک شیء OffscreenCanvas
const offscreen = htmlCanvas.transferControlToOffscreen();
در این تکهکد، فراخوانی transferControlToOffscreen() باعث میشه عنصر <canvas> موجود در صفحه، لایه گرافیکی خودش رو در قالب یک شیء OffscreenCanvas آزاد کنه. متد بازگشتی، یک نمونه آماده برای انتقال به Threadهای دیگه است.
حالا چطور این مالکیت رو بدون افت سرعت به Worker بفرستیم؟ به ساختار آرگومان دوم متد postMessage دقت کن:
// main.js - Thread اصلی
const canvas = document.getElementById('realtimeGraph');
const offscreenCanvas = canvas.transferControlToOffscreen();
const worker = new Worker('renderWorker.js');
// انتقال مالکیت کامل بوم به Worker با آرایه Transferables
worker.postMessage({ type: 'INIT', canvas: offscreenCanvas }, [offscreenCanvas]);
چرا [offscreenCanvas] رو به عنوان آرگومان دوم (Transfer Array) پاس دادیم؟ اگه این پارامتر رو حذف کنی، مرورگر سعی میکنه شیء رو کلون کنه که بلافاصله خطای DataCloneError رخ میده. با قرار دادن اون در لیست Transferable Objects، مالکیت این بخش از حافظه کلاً از Main Thread سلب و مستقیم به Worker تزریق میشه. این یعنی زمان کپی داده دقیقاً صفر میلیثانیه است.
حالا کد سمت Worker برای دریافت مالکیت و شروع رندرینگ به این صورت خواهد بود:
// renderWorker.js - Thread پسزمینه
self.onmessage = (event) => {
if (event.data.type === 'INIT') {
const canvas = event.data.canvas;
const ctx = canvas.getContext('2d');
// رندر مستقیم روی بومِ موجود در DOM اصلی
ctx.fillStyle = '#10B981'; // سبز زمردی برای تست رندر
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
};
توی این کد، Worker بدون اینکه به DOM دسترسی داشته باشه (که اصلاً در Worker دسترسی بهش وجود نداره)، مستقیماً خروجی گرافیکی رو روی عنصر HTML موجود در صفحه نمایش میده.
اما این معماری دو محدودیت فنی مهم ایجاد میکنه که باید موقع طراحی حواست بهشون باشه:
- انتقال یکطرفه و غیرقابل بازگشت: به محض فراخوانی این متد، دیگه نمیتونی روی Main Thread متد
canvas.getContext()رو صدا بزنی. اجرای اون خطایInvalidStateErrorمیده. - عدم دسترسی Worker به تغییر ابعاد DOM: چون Worker به رویدادهای مرورگر مثل
resizeدسترسی نداره، هرگونه تغییر ابعاد بوم باید از Main Thread محاسبه و به Worker ارسال بشه.
برای مدیریت تغییر ابعاد پنجره (Resize)، از الگوی ارسال پیام به Worker استفاده میکنیم:
// main.js - ارسال ابعاد جدید به Worker هنگام تغییر سایز پنجره
window.addEventListener('resize', () => {
worker.postMessage({
type: 'RESIZE',
width: window.innerWidth,
height: window.innerHeight
});
});
مقادیر window.innerWidth و innerHeight ابعاد واقعی پیکسلهای نمایشگر رو میگیرند تا بوم تار نشه. در سمت Worker ابعاد جدید روی شیء تنظیم میشه:
// renderWorker.js - بهروزرسانی ابعاد بوم در Worker
if (event.data.type === 'RESIZE') {
canvas.width = event.data.width;
canvas.height = event.data.height;
// فراخوانی مجدد تابع رندر پس از تغییر ابعاد
}
نکته کاربردی: متد
transferControlToOffscreen()رو فقط و فقط یکبار میتونی روی یک عنصر<canvas>فراخوانی کنی. فراخوانی دوم روی همون بوم، استثنایInvalidStateErrorپرتاب میکنه. بنابراین حتماً فراخوانی این متد رو در بخش کدهای راهاندازی اولیه (Initialization) قرار بده.
در این بخش یاد گرفتیم چطور با متد transferControlToOffscreen() مالکیت لایه گرافیکی بوم رو به Web Worker منتقل کنیم تا Main Thread کاملاً آزاد بمونه. در بخش بعدی بررسی میکنیم که چطور حلقه انیمیشن (requestAnimationFrame) رو در داخل خود Worker پیادهسازی کنیم تا رندر روان ۶۰ فریم بر ثانیه داشته باشیم.
پایهریزی بستر موازی: راهاندازی Web Worker و مکانیزم ارسال پیام
وقتی تصمیم میگیری پردازش گرافیکی رو از Main Thread جدا کنی، با اولین چالش بزرگ مهندسی مواجه میشی: چطور بوم گرافیکی و دادههای سنگین رو بدون کند شدن رابط کاربری به یک نخ پردازشی (Thread) دیگه منتقل کنیم؟ اگر ارتباط بین نخ اصلی و ورکر به درستی طراحی نشه، انتقال دادهها خودش تبدیل به گلویی (Bottleneck) سیستم میشه.
در این بخش بستر موازیسازی برنامه رو پیادهسازی میکنیم و مکانیزم تبادل داده رو با بالاترین بازدهی ممکن میسازیم.
چالش کپی دادهها و راهکار Transferable Objects
در حالت عادی، وقتی دادهای رو از طریق postMessage به Web Worker میفرستی، مرورگر از یک الگوریتم به نام Structured Clone استفاده میکنه. این الگوریتم یک کپی عمیق و کامل از تمام دادهها در حافظه میسازه.
تصور کن یک بوم گرافیکی با حجم زیادی از پیکسلها یا یک ArrayBuffer چند مگابایتی داری. اگه مرورگر بخواد این داده رو فریمبهفریم کپی کنه، Main Thread دچار وقفههای چند میلیثانیهای (Jank) میشه و تمام زحمات ما برای آزادکردن نخ اصلی به باد میره!
راهکار مهندسی و حرفهای، استفاده از مفهوم Transferable Objects یا «انتقال مالکیت حافظه» است. در این روش، مرورگر داده رو کپی نمیکنه؛ بلکه آدرس مستقیم اشارهگر حافظه (Pointer) رو از نخ اصلی قطع میکنه و اون رو به Worker تحویل میده. زمان این عملیات صفر میلیثانیهست!
[Main Thread]
│
├─ 1. ایجاد OffscreenCanvas از بوم اصلی
│
└─ 2. worker.postMessage({ canvas }, [canvas]) ──► [Web Worker Thread]
│
(انتقال مالکیت آنی بدون کپی حافظه)
اما این روش یک هزینه دارد: به محض انتقال، نخ اصلی دسترسی خودش رو به آن شیء از دست میده و هرگونه فراخوانی مجدد روی اون باعث خطای TypeError میشه.
پیادهسازی راهاندازی Worker و ارسال بوم
ابتدا یک فایل ورکر مجزا میسازیم و در برنامه اصلی اون رو نمونهسازی (Instantiate) میکنیم. سپس بوم گرافیکی رو به صورت یک شیء Transferable به ورکر ارسال میکنیم.
کد زیر نحوه ساخت ورکر و ارسال بوم در نخ اصلی را نشان میدهد:
// main.js - Thread اصلی
const worker = new Worker('render-worker.js', { type: 'module' });
const htmlCanvas = document.getElementById('stage');
const offscreen = htmlCanvas.transferControlToOffscreen();
// ارسال بوم به ورکر همراه با لیست Transferable
worker.postMessage({ type: 'INIT', canvas: offscreen }, [offscreen]);
در این کد، پارامتر دوم متد postMessage یک آرایه است: [offscreen]. این آرایه به مرورگر دستور میده که مالکیت شیء offscreen رو کاملاً به Worker واگذار کنه. اگر این آرایه رو فراموش کنی، مرورگر سعی میکنه بوم رو کپی کنه و با خطای DataCloneError متوقف میشه.
حالا در سمت Worker، پیام ورودی رو دریافت کرده و محیط رسم دو بعدی (Context) رو آماده میکنیم:
// render-worker.js - Thread پردازشی
let canvasContext = null;
self.onmessage = (event) => {
const { type, canvas } = event.data;
if (type === 'INIT') {
canvasContext = canvas.getContext('2d');
// آمادهسازی اولیه پسزمینه
canvasContext.fillStyle = '#0f172a';
canvasContext.fillRect(0, 0, canvas.width, canvas.height);
}
};
در فایل Worker، شیء سراسری self شنونده پیامهای ورودی است. به محض دریافت اکشن INIT، محیط ۲D بوم استخراج شده و نخستین دستور رسم بدون اشغال حتی یک میلیثانیه از نخ اصلی اجرا میشود.
اشتباه رایج: بعد از اجرای
worker.postMessage(..., [offscreen])هرگونه تلاش برای خواندن ابعاد یا تغییر خصوصیاتoffscreenدر فایلmain.jsخطای متوقفکننده ایجاد میکند. کنترل این عنصر اکنون ۱۰۰٪ در اختیار Worker است.
مدیریت تغییر ابعاد و رویدادهای زنده
در برنامههای واقعی، کاربر صفحه را ریسایز میکند یا روی بوم کلیک میکند. چون Worker دسترسی مستقیم به DOM ندارد، باید یک معماری پیامرسانی اکشنمحور (Action-based) برای ارسال این تغییرات بسازیم.
برای دادههای سبک مثل اعداد ابعاد صفحه، نیازی به Transferable نیست و کپی معمولی بدون افت فریم انجام میشود:
// main.js - ارسال تغییر ابعاد صفحه
window.addEventListener('resize', () => {
worker.postMessage({
type: 'RESIZE',
width: window.innerWidth,
height: window.innerHeight
});
});
در ورکر، اکشن RESIZE را دریافت و ابعاد جدید را روی بوم اعمال میکنیم:
// render-worker.js - پردازش دستور تغییر ابعاد
self.onmessage = (event) => {
const { type, canvas, width, height } = event.data;
if (type === 'INIT') {
canvasContext = canvas.getContext('2d');
} else if (type === 'RESIZE') {
canvasContext.canvas.width = width;
canvasContext.canvas.height = height;
}
};
مقداردهی مستقیم width و height روی بوم داخل ورکر، محاسبات ماتریس رندر را فوراً بهروزرسانی میکند بدون اینکه فریمهای انیمیشن دچار پرش شوند.
مقایسه دو روش انتقال داده به Worker
| معیار مقایسه | کپی استاندارد (Structured Clone) | انتقال مالکیت (Transferable) |
|---|---|---|
| زمان پردازش | متناسب با حجم داده (میلیثانیه) | آنی و مستقل از حجم (نانوثانیه) |
| مصرف حافظه RAM | دو برابر (یک کپی در هر Thread) | ثابت (فقط جابهجایی آدرس) |
| دسترسی نخ اصلی | نخ اصلی همچنان به داده دسترسی دارد | دسترسی نخ اصلی فوراً قطع میشود |
| انواع داده پشتیبانیشده | تمام اشیاء و متغیرهای ساده | OffscreenCanvas, ArrayBuffer, ImageBitmap |
با پیادهسازی این معماری، بستر موازی و کانال ارتباطی کممصرف بین Thread اصلی و Web Worker کاملاً آماده است. در بخش بعدی یاد میگیریم چطور حلقه انیمیشن اختصاصی را در دل خود Worker روشن کنیم تا رندر ۶۰ فریم بر ثانیه پایدار داشته باشیم.
انتقال بدون کپی (Zero-Copy Transfer): ارسال بوم به Worker با متد postMessage
وقتی صحبت از پردازشهای سنگین گرافیکی در مرورگر میشود، بزرگترین کابوس هر توسعهدهندهای افت فریم و قفل شدن رابط کاربری است. اگر بخواهیم پیکسلهای یک تصویر با رزولوشن 4K را فریمبهفریم از Thread اصلی به Web Worker کپی کنیم، مرورگر مجبور میشود مگابایتها داده را در حافظه تکثیر کند. این کپیبرداری مداوم، CPU را درگیر کرده و دقیقا همان انیمیشنهای کندی را میسازد که سعی داشتیم از شرشان خلاص شویم.
روش جونیور برای حل این مشکل، معمولاً ارسال دادههای تصویر به صورت رشته یا آرایههای معمولی با متد standard postMessage است. اما مرورگر در این حالت الگوریتم Structured Clone را اجرا میکند؛ یعنی تمام بایتها را در مقصد دوباره میسازد. این کار نه تنها حافظه RAM را بلعیده و Garbage Collection را به شدت درگیر میکند، بلکه زمان پاسخی در حد چند ده میلیثانیه میسازد که برای رندر ۶۰ فریم بر ثانیه ناامیدکننده است.
راهکار حرفهای مهندسی، استفاده از Zero-Copy Transfer یا «انتقال مالکیت بدون کپی» است. در این روش، مرورگر به جای کپی کردن دادههای بوم گرافیکی، تنها اشارهگر حافظه (Memory Pointer) آن را به Web Worker منتقل میکند. زمان اجرای این عملیات بدون توجه به ابعاد بوم، همواره نزدیک به صفر میلیثانیه ($O(1)$) است.
[ Thread اصلی ] [ Web Worker ]
---------------- --------------
1. ایجاد OffscreenCanvas
(مالک اشارهگر حافظه)
2. worker.postMessage(
{ canvas },
[ canvas ] ==== (انتقال اشارهگر) ====> 3. دریافت بوم
) (مالک جدید اشارهگر)
4. غیرفعال شدن بوم در Thread
اصلی (Detached State)
الگوی پایه: ارسال بوم به Worker
برای اینکه مرورگر متوجه شود قصد کپی کردن نداریم و میخواهیم مالکیت شیء را کاملاً واگذار کنیم، باید شیء OffscreenCanvas را علاوه بر بدنه پیام، در آرایه دوم (Transferable List) متد postMessage نیز قرار دهیم.
const htmlCanvas = document.getElementById('renderTarget');
const offscreen = htmlCanvas.transferControlToOffscreen();
// ارسال اشارهگر بوم به Worker
worker.postMessage({ type: 'INIT_CANVAS', canvas: offscreen }, [offscreen]);
در این کد، قرار دادن offscreen در آرایه دوم ([offscreen]) به مرورگر دستور میدهد که دسترسی نخ اصلی را بلافاصله قطع کند و کنترل کامل حافظه آن را به نخ Worker بسپارد. اگر آرایه دوم را حذف کنید، مرورگر خطای DataCloneError صادر میکند، چون شیء OffscreenCanvas قابل کپی شدن نیست و فقط باید منتقل شود.
تنوع در پیادهسازی و سناریوهای مختلف
بسته به اینکه بوم گرافیکی چگونه ساخته شده یا چه دادههای دیگری همراه آن ارسال میشود، شیوه فراخوانی متغیر است:
حالت اول: بوم کاملاً در حافظه (بدون عنصر DOM)
گاهی اوقات بوم نیازی به نمایش مستقیم روی صفحه ندارد و صرفاً برای پردازشهای پسزمینه (مانند رندر تصویر در یک دکمه یا پردازش فایل) استفاده میشود:
// ساخت بوم در حافظه با ابعاد مشخص
const pureOffscreen = new OffscreenCanvas(1920, 1080);
// انتقال مستقیم به Worker
worker.postMessage({ type: 'PROCESS_IMAGE', canvas: pureOffscreen }, [pureOffscreen]);
حالت دوم: انتقال همزمان چندین منبع داده
اگر همراه با بوم، قصد دارید یک دیتای حجیم دیگر (مثل فایل صوتی یا مدل ۳ بعدی) را هم بدون کپی منتقل کنید، همه آنها را در آرایه دوم لیست میکنید:
const offscreen = htmlCanvas.transferControlToOffscreen();
const rawBuffer = new ArrayBuffer(1024 * 1024 * 16); // 16MB داده خام
// انتقال همزمان بوم و ArrayBuffer در یک دستور
worker.postMessage(
{ type: 'LOAD_SCENE', canvas: offscreen, geometry: rawBuffer },
[offscreen, rawBuffer] // هر دو شیء منتقل میشوند
);
پیادهسازی کامل: ارتباط دوطرفه بین Thread اصلی و Worker
حالا بیا ببینیم کد کامل سمت نخ اصلی و Worker چطور در کنار هم کار میکنند.
کد سمت Thread اصلی (main.js):
const worker = new Worker('renderer.js');
const canvasElement = document.getElementById('viewfinder');
// گرفتن کنترل بوم برای انتقال
const offscreenCanvas = canvasElement.transferControlToOffscreen();
// ارسال پیام به همراه لیست Transferable
worker.postMessage(
{
cmd: 'START_RENDER',
canvas: offscreenCanvas,
pixelRatio: window.devicePixelRatio || 1
},
[offscreenCanvas]
);
// بوم در نخ اصلی قفل شد؛ دسترسی مجدد خطاساز است
کد سمت Web Worker (renderer.js):
self.onmessage = function (event) {
const { cmd, canvas, pixelRatio } = event.data;
if (cmd === 'START_RENDER') {
// دریافت شیء منتقلشده و گرفتن بافت دو بعدی
const ctx = canvas.getContext('2d');
// تنظیم ابعاد با توجه به تراکم پیکسل نمایشگر
ctx.scale(pixelRatio, pixelRatio);
// رسم یک مستطیل تست در Worker
ctx.fillStyle = '#00f0ff';
ctx.fillRect(10, 10, 150, 100);
}
};
مقدار window.devicePixelRatio به Worker ارسال میشود تا کیفیت رندر در نمایشگرهای Retina خراب نشود. از آنجا که Worker به متغیر window دسترسی ندارد، این تنظیمات اولیه باید در قالب داده همراه بوم فرستاده شوند.
نکته کاربردی: پس از اجرای
postMessageبه همراه لیست Transferable، شیءOffscreenCanvasدر Thread اصلی اصطلاحاً Detached میشود. اگر بعد از این خط سعی کنید به ویژگیهایی مانندoffscreenCanvas.widthدسترسی پیدا کنید یا متدی روی آن فراخوانی کنید، مرورگر خطایInvalidStateErrorرخ میدهد. تمام کنترلها باید از این لحظه به بعد فقط و فقط از داخل Worker انجام شوند.
هزینهها و محدودیتها (Trade-offs)
انتقال مالکیت به شدت سریع است، اما یک محدودیت بزرگ دارد: ارتباط یکطرفه مالکیت.
شما نمیتوانید یک OffscreenCanvas منتقلشده به Worker را دوباره به Thread اصلی پس بفرستید! این واگذاری یکباره است. بنابراین اگر معماری برنامه شما بهگونهای است که باید گاهی در نخ اصلی و گاهی در Worker رندر بگیرید، Zero-Copy مناسب شما نیست و باید از ابتدا تمام منطق رندر را به Worker منتقل کنید.
در این بخش یاد گرفتیم چگونه بوم گرافیکی را بدون ایجاد بار اضافه روی RAM و CPU به Worker منتقل کنیم. در بخش بعدی، حلقه انیمیشن (requestAnimationFrame) را درون خود Worker پیاده میکنیم تا رندر بیوقفه داشته باشیم.
حلقه رندرینگ در سایه: پیادهسازی انیمیشن با requestAnimationFrame در Worker
تصور کن یک داشبورد معاملاتی آنلاین ساختهای که روی بوم گرافیکی (Canvas)، نمودار قیمت را بهصورت ۶۰ فریم بر ثانیه و نرم رندر میکند. ناگهان کاربر یک فیلتر سنگین روی جدول دادهها اعمال میکند یا یک کامپوننت سنگین React شروع به Re-render شدن میکند. نتیجه؟ فریمریت نمودار بهشدت افت میکند، انیمیشن پِک میزند (Jank) و کاربر احساس میکند برنامهات کند و غیرقابل اعتمادی است.
این افت فریم زمانی رخ میدهد که منطق پردازشی و حلقه انیمیشن (Animation Loop) هر دو در نخ اصلی (Main Thread) قفل شده باشند.
[Main Thread] ---> [DOM Updates / Heavy JS] ---> (Blocks Animation Frame!)
|
v
[Worker Thread] ---> [rAF Loop] ---> [OffscreenCanvas Render] (60 FPS Smooth!)
چرا روشهای سادهلوحانه شکست میخورند؟
وقتی توسعهدهنده تازهکار با افت فریم روی نخ اصلی مواجه میشود، معمولاً یکی از این دو مسیر اشتباه را انتخاب میکند:
۱. استفاده از setInterval یا setTimeout در Worker: توسعهدهنده میداند که Worker نخ جداگانه دارد، پس یک setInterval با زمان ۱۶ میلیثانیه (برای ۶۰ فریم) میسازد. این روش یک فاجعه سیستماتیک است؛ زیرا setInterval با نرخ نوسازی تصویر (V-Sync) مانیتور همگام نیست. نتیجه آن بریدگی تصویر (Screen Tearing)، انیمیشنهای ناصاف و مصرف بیرویه باتری است.
۲. ارسال سیگنال رندر از نخ اصلی با postMessage: نخ اصلی روی هر فریم requestAnimationFrame میزند و به Worker پیام میدهد «الان رندر کن». این کار تمام فلسفه انتقال بوم به Worker را زیر سوال میبرد! اگر نخ اصلی به هر دلیلی زیر بار برود، پیام ارسال نمیشود و انیمیشن باز هم قفل میکند.
راهکار مهندسی: اجرای مستقیم rAF در Worker
خوشبختانه محیط اجرا در Dedicated Worker به متد self.requestAnimationFrame دسترسی دارد. وقتی این متد را داخل Worker اجرا میکنی، مرورگر حلقه انیمیشن را مستقیماً با مرورگر و سختافزار کارت گرافیک همگام (Sync) میکند، بدون اینکه ۱ میلیثانیه از وقت نخ اصلی گرفته شود.
اما این تصمیم یک تاوان مهندسی (Trade-off) هم دارد: در Worker هیچ دسترسی مستقیمی به DOM، ابعاد پنجره یا رویدادهای کیبورد و ماوس نداری. بنابراین هرگونه داده ورودی باید بهصورت غیرهمگام (Async) از نخ اصلی به Worker ارسال شود.
پیادهسازی گامبهگام حلقه رندر
در اولین قدم، سادهترین حلقه انیمیشن ممکن را داخل فایل worker.js تعریف میکنیم تا ساختار متد requestAnimationFrame را در فضای Worker ببینیم.
// worker.js - ساختار پایه حلقه رندرینگ
let ctx = null;
self.onmessage = (event) => {
if (event.data.type === 'INIT') {
// دریافت بوم از نخ اصلی
const canvas = event.data.canvas;
ctx = canvas.getContext('2d');
// شروع حلقه انیمیشن در پسزمینه
self.requestAnimationFrame(renderLoop);
}
};
function renderLoop(timestamp) {
// پاکسازی فریم قبلی
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// رسم یک مربع ساده
ctx.fillStyle = '#3b82f6';
ctx.fillRect(50, 50, 100, 100);
// درخواست فریم بعدی از سیستمعامل/مرورگر
self.requestAnimationFrame(renderLoop);
}
در کد بالا، پارامتر timestamp بهصورت خودکار توسط مرورگر پاس داده میشود و زمان دقیق اجرای فریم را با دقت میلیثانیه (High Resolution Time) مشخص میکند.
مدیریت حرکت نرم با محاسبه Delta Time
اگر انیمیشن خود را فقط بر اساس نرخ فریم ثابت پیش ببری، روی مانیتور ۶۰ هرتز با یک سرعت و روی مانیتور ۱۴۴ هرتز با سرعتی بیش از دو برابر حرکت خواهد کرد! برای حل این مشکل، باید فریمها را بر اساس Delta Time (فاصله زمانی بین دو فریم متوالی) محاسبه کنیم.
// worker.js - پیادهسازی انیمیشن با نرخ فریم مستقل
let ctx = null;
let lastTime = 0;
let posX = 0;
const speedPerSecond = 150; // حرکت ۱۵۰ پیکسل در هر ثانیه
function renderLoop(currentTime) {
if (!lastTime) lastTime = currentTime;
// محاسبه فاصله زمانی بین فریم قبلی و فعلی (به ثانیه)
const deltaTime = (currentTime - lastTime) / 1000;
lastTime = currentTime;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// بهروزرسانی موقعیت بر اساس زمان واقعی سپریشده
posX += speedPerSecond * deltaTime;
if (posX > ctx.canvas.width) posX = 0;
ctx.fillStyle = '#10b981';
ctx.beginPath();
ctx.arc(posX, 100, 20, 0, Math.PI * 2);
ctx.fill();
self.requestAnimationFrame(renderLoop);
}
مقدار speedPerSecond = 150 تضمین میکند که توپ بدون توجه به اینکه مانیتور کاربر ۶۰ هرتز است یا ۲۴۰ هرتز، دقیقاً ۱۵۰ پیکسل در هر ثانیه طی میکند. اگر سیستم دچار افت فریم ناگهانی شود، deltaTime افزایش یافته و توپ به موقعیت واقعی خود جهش میکند تا زمانبندی زنده بههم نخورد.
تعامل و تغییر وضعیت از نخ اصلی
حالا چطور وضعیت انیمیشن (مثلاً تغییر سرعت یا رنگ) را از طریق رابط کاربری به Worker منتقل کنیم بدون اینکه حلقه رندر متوقف شود؟
// worker.js - دریافت پارامترهای کنترل انیمیشن
let config = { color: '#ef4444', speed: 200 };
self.addEventListener('message', (event) => {
if (event.data.type === 'UPDATE_CONFIG') {
// بهروزرسانی وضعیت در حین اجرای انیمیشن
config = { ...config, ...event.data.payload };
}
});
جدول زیر تفاوت رویکردها را در مدیریت انیمیشن نشان میدهد:
| ویژگی | رندر در Main Thread | رندر با setInterval در Worker |
رندر با rAF در Worker |
|---|---|---|---|
| استقلال از بار DOM | ❌ خیر | ✅ بله | ✅ بله |
| همگامی با V-Sync | ✅ بله | ❌ خیر | ✅ بله |
| مصرف بهینه انرژی | ❌ متوسط | ❌ بسیار بد | ✅ عالی |
| دسترسی مستقیم به DOM | ✅ بله | ❌ خیر | ❌ خیر |
نکته کاربردی:
وقتی زبانه مرورگر (Tab) توسط کاربر مخفی (Background) میشود، مرورگر برای کاهش مصرف باتری، فراخوانیrequestAnimationFrameرا در Worker هم مثل نخ اصلی متوقف یا بسیار کند میکند (تا حدود ۱ فریم در ثانیه). اگر انیمیشن شما منطق بازی یا محاسبات فیزیک متصل به زمان دارد، حتماً محاسبات منطقی (Physics State) را از رندر گرافیکی جدا کنید تا هنگام بازگشت کاربر به زبانه، اشیاء به جاهای عجیب پرتاب نشوند!
در این بخش توانستیم یک حلقه انیمیشن کاملاً مستقل، همگام با V-Sync و مقاوم در برابر پردازشهای سنگین UI بسازیم. اکنون که بوم گرافیکی در پسزمینه با حداکثر سرعت رندر میشود، در بخش بعدی یاد میگیریم چگونه رویدادهای کاربر مانند کلیک و حرکت ماوس را بدون ایجاد گلوگاه به Worker پاس بدهیم.
مدیریت ابعاد پویا: همگامسازی ابعاد بوم از DOM به Worker
تصور کن کاربری اندازه پنجره مرورگرش را تغییر میدهد یا پنل کناری سایتت را میبندد تا فضای بیشتری به بوم گرافیکی داده شود. اگر بوم گرافیکی متناسب با این تغییرات تغییر سایز ندهد، با دو مشکل فاجعهبار مواجه میشوی: یا تصویر به طرز زشتی کشیده و تار میشود، یا نسبتهای هندسی شکلها کاملاً به هم میریزند. در بومهای معمولی DOM، ابعاد را مستقیماً از طریق عنصر Canvas بهروزرسانی میکردیم؛ اما وقتی از OffscreenCanvas استفاده میکنیم، Worker هیچ دسترسی مستقیمی به CSS، عناصر DOM یا متغیر window ندارد و اصلاً نمیداند ابعاد واقعی عنصر در مرورگر چقدر است!
رویکرد جونیوری این است که رویداد window.onresize را در ترد اصلی گوش بگیریم و ابعاد جدید را با postMessage به Worker بفرستیم. این روش در محیطهای واقعی شکست میخورد. اول اینکه اگر ابعاد عنصر داخل صفحه تغییر کند ولی خود پنجره مرورگر تغییر سایز ندهد (مثلاً با تغییر استایل Flexbox یا Grid)، رویداد resize پنجره اصلاً اجرا نمیشود. دوم اینکه این روش نمایشگرهای با کیفیت بالا (Retina) را نادیده میگیرد؛ در نتیجه خروجی گرافیکی روی گوشیهای هوشمند یا مکبوکها مات و تار به نظر میرسد.
راهکار حرفهای، استفاده از ResizeObserver در ترد اصلی برای پایش دقیق تغییرات ابعاد عنصر Canvas و محاسبه تراکم پیکسلی (devicePixelRatio) است. سپس این ابعاد دقیق محاسبهشده را به Worker پاس میدهیم تا رزولوشن داخلی OffscreenCanvas بازتخصیص داده شود.
نحوه جریان داده میان ترد اصلی و Worker برای همگامسازی ابعاد:
[تغییر اندازه عنصر Canvas در DOM]
│
▼
(ترد اصلی: ResizeObserver)
│ (محاسبه: ابعاد CSS × تراکم پیکسلی DPR)
▼
postMessage({ type: 'RESIZE', width, height, dpr })
│
▼
(Worker Thread: onmessage)
│ (مقداردهی: offscreenCanvas.width & height)
▼
[رندر مجدد با پیکسلهای شفاف و دقیق]
در مرحله اول، یک ResizeObserver در ترد اصلی میسازیم تا تغییرات ابعاد فیزیکی عنصر را در لحظه دریافت کنیم.
// main.js - ترد اصلی
const canvasElement = document.getElementById('renderCanvas');
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
// استخراج ابعاد واقعی عنصر در صفحه CSS
const cssWidth = Math.floor(entry.contentRect.width);
const cssHeight = Math.floor(entry.contentRect.height);
// عدد 2 در نمایشگرهای Retina یعنی هر 1 پیکسل CSS برابر 2 پیکسل فیزیکی است
const dpr = window.devicePixelRatio || 1;
// ارسال پیام همگامسازی به Worker
graphicsWorker.postMessage({
type: 'RESIZE',
width: cssWidth * dpr,
height: cssHeight * dpr,
dpr: dpr
});
}
});
resizeObserver.observe(canvasElement);
کد بالا ابعاد CSS را در مقدار devicePixelRatio ضرب میکند. اگر این ضریب را ضرب نکنیم، مرورگر پیکسلهای بوم را دفرمه میکند تا با فضای CSS مطابقت پیدا کند که علت اصلی تار شدن بوم است.
حالا در سمت Worker، پیام دریافت شده را پردازش کرده و ابعاد داخلی OffscreenCanvas را تغییر میدهیم.
// worker.js - ترد پسزمینه
let offscreenCanvas;
let ctx;
self.onmessage = (event) => {
const { type, width, height, dpr } = event.data;
if (type === 'INIT') {
offscreenCanvas = event.data.canvas;
ctx = offscreenCanvas.getContext('2d');
} else if (type === 'RESIZE') {
// تغییر رزولوشن داخلی بوم گرافیکی
offscreenCanvas.width = width;
offscreenCanvas.height = height;
// مقیاسگذاری ماتریس رندر بر اساس تراکم پیکسلی
ctx.scale(dpr, dpr);
// اجرا مجدد حلقه رندر برای تصویر جدید
drawScene();
}
};
جدول زیر تفاوت ساختاری این دو رویکرد را در سطح سیستم نشان میدهد:
| پارامتر / معیار | روش ساده (window.onresize) |
روش مهندسی (ResizeObserver + DPR) |
رفتار سیستم |
|---|---|---|---|
| محرک اجرا (Trigger) | فقط تغییر اندازه کلی پنجره | هرگونه تغییر اندازه دقیق عنصر بوم | پاسخگویی به تغییرات layout و CSS |
| وضوح تصویر (Crispness) | تار در صفحات Retina | شفاف (Pixel-Perfect) | تطابق ۱:۱ پیکسلهای رندر با سختافزار |
| بار پردازشی (Overhead) | ایجاد Eventهای غیرضروری | اجرا فقط در صورت تغییر ابعاد بوم | بهینهسازی مصرف حافظه و GPU |
نکته کاربردی: هر بار که ویژگیهای
widthیاheightیک Canvas (چه معمولی و چه Offscreen) تغییر میکنند، تمام استاتوسهای context (مانندfillStyle،fontیا مقیاسscale) به حالت اولیه (Default) ریست میشوند! همیشه مقیاسگذاری (ctx.scale) و تنظیمات استایل گرافیکی را بلافاصله بعد از مقداردهی مجدد ابعاد انجام دهید.
بررسی هزینه و هزینههای پنهان (Trade-offs)
تغییر ابعاد OffscreenCanvas یک عملیات مجانی نیست؛ هر بار تغییر width و height باعث میشود کارت گرافیک (GPU) حافظه Framebuffer قبلی را آزاد کند و یک حافظه جدید تخصیص دهد. اگر کاربر بهصورت مداوم پنجره مرورگر را درگ کند (Drag)، صدها پیام RESIZE در ثانیه تولید میشود که میتواند باعث لکنت گرافیکی (Jank) شود.
برای بنچمارکها یا صحنههای بسیار سنگین، بهتر است فرستادن پیامهای تغییر ابعاد از ترد اصلی به Worker را با یک الگوریتم ساده Debounce به مقدار ۱۵ الی ۳۰ میلیثانیه محدود کنید تا از فشار بیمورد بر روی GPU جلوگیری شود.
در این بخش توانستیم ابعاد بوم گرافیکی را بهصورت پویا، هوشمند و با تراکم پیکسلی دقیق میان DOM و Worker همگامسازی کنیم. در بخش بعدی به سراغ مدیریت رویدادهای کاربر مانند کلیک و حرکت ماوس میرویم و یاد میگیریم چگونه تعاملات کاربر را بدون افت فریم به Worker منتقل کنیم.
چالش فونت و متن در محیط مستقل: شبیهسازی ترسیم متون بدون دسترسی به DOM
وقتی کار با OffscreenCanvas را در Web Worker شروع میکنید، اولین غافلگیری بزرگ زمانی رخ میدهد که قصد دارید یک متن ساده با فونت سفارشی (مثلاً وزیرمتن) روی بوم رسم کنید. ناگهان متوجه میشوید متن با فونت پیشفرض مرورگر رندر میشود یا ابعاد آن کاملاً اشتباه محاسبه میگردد. دلیل این اتفاق روشن است: در محیط اختصاصی Worker، هیچ خبری از document، المانهای HTML یا فایلهای CSS لینکشده در صفحه اصلی نیست!
مشکل کجاست و چرا روشهای معمولی شکست میخورند؟
در توسعه وب سنتی، مرورگر فایلهای فونت را از طریق DOM و دستورات CSS مدیریت کرده و آنها را در اختیار تمام عناصر صفحه، از جمله <canvas> معمولی، قرار میدهد.
یک توسعهدهنده تازهکار ممکن است تلاش کند فونت را در صفحه اصلی بارگذاری کند و سپس برای رسم هر فریم متن، ابعاد و مشخصات آن را از طریق postMessage به Worker بفرستد. این راهکار شاید در نگاه اول کار کند، اما یک "تله کارایی" است! ارسال مداوم پیام بین Thread اصلی و Worker، پهنای باند ارتباطی میان آنها را اشغال کرده و تمام مزیت اصلی OffscreenCanvas که همان آزادکنندگی Thread اصلی است را از بین میبرد.
راهکار مهندسی و درست، ایزولهسازی کامل مدیریت فونت است. ما فایل فونت را بهصورت مستقیم درون Worker دانلود کرده و با استفاده از FontFace API آن را مستقیماً به حافظه گرافیکی همان Worker تزریق میکنیم.
[صفحه اصلی (DOM)] -- (بدون ارسال داده فونت) --> [Web Worker]
|
1. fetch('vazir.woff2')
|
2. new FontFace()
|
3. self.fonts.add()
|
4. ctx.fillText()
بارگذاری ایزوله فونت درون Web Worker
برای اینکه Worker بتواند از یک فونت سفارشی استفاده کند، باید بایتهای فایل فونت را بهصورت مستقیم دریافت و به عنوان یک تایپفیس جدید به Worker معرفی کنیم:
async function loadWorkerFont(fontName, fontUrl) {
const response = await fetch(fontUrl);
const fontBuffer = await response.arrayBuffer();
const customFont = new FontFace(fontName, fontBuffer);
await customFont.load();
self.fonts.add(customFont);
}
چرا فایل را بهصورت arrayBuffer دریافت کردیم؟ چون در Worker امکان ارجاع مستقیم به URLهای CSS وجود ندارد. دریافت آرایه بایتی و تحویل آن به FontFace تضمین میکند فونت بدون نیاز به DOM آماده استفاده است. مقدار fontName (مثلاً 'Vazirmatn') شناسه یکتایی است که بعداً دقیقاً با همین نام در ctx.font ارجاع داده خواهد شد.
محاسبه ابعاد و ترسیم دقیق متن
پس از بارگذاری فونت، نوبت به ترسیم متن میرسد. یکی از حساسترین بخشهای کار با متون، محاسبه دقیق عرض و ارتفاع متن برای ایجاد باکسهای تعاملی یا متون چندخطی است:
function renderBadgeText(ctx, text, x, y) {
ctx.font = '20px Vazirmatn';
ctx.fillStyle = '#0f172a';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const metrics = ctx.measureText(text);
const textWidth = metrics.width;
ctx.fillText(text, x, y);
return textWidth;
}
مقدار '20px Vazirmatn' مشخص میکند که اندازه فونت ۲۰ پیکسل باشد و از تایپفیسی که در مرحله قبل ثبت کردیم استفاده شود. اگر متد measureText را قبل از اتمام کامل فرایند customFont.load() اجرا کنید، مرورگر ابعاد را بر اساس فونت Fallback (مثل Arial) محاسبه میکند و پس از بارگذاری فونت اصلی، تمام لایهبندی شما به هم خواهد ریخت!
برای کنترل دقیقتر جزییات گرافیکی، میتوانید از خروجیهای پیشرفته measureText نیز استفاده کنید:
// محاسبه ارتفاع واقعی متن جهت ساخت پسزمینه متناسب (Bounding Box)
const metrics = ctx.measureText("داشبورد پردازش");
const actualHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
مقادیر actualBoundingBoxAscent و actualBoundingBoxDescent ارتفاع واقعی پیکسلهای رسمشده متن را از خط کرسی (Baseline) بالا و پایین مشخص میکنند. این مقادیر بسیار دقیقتر از ارتفاع تقریبی فونت هستند و برای ساخت دکمهها و کارتهای پویا کاربرد دارند.
نکته کاربردی: تا زمانی که اجرای
await customFont.load()پایان نیافته، از فراخوانی دستورات ترسیم متن در Worker خودداری کنید. ترسیم متن با فونت پیشفرض و سپس تغییر ناگهانی آن به فونت جدید، باعث پدیده ناخوشایند FOUT (Flash of Unstyled Text) روی بوم گرافیکی میشود.
مقایسه مدیریت متن در Thread اصلی و Worker
| ویژگی | روش سنتی (Main Thread) | روش حرفهای (OffscreenCanvas / Worker) |
|---|---|---|
| منبع بارگذاری فونت | فایلهای CSS و document.fonts |
بارگذاری مستقیم بایتها با FontFace API |
محاسبه ابعاد (measureText) |
بلاککننده Thread اصلی در متون سنگین | پردازش کاملاً غیرهمگام بدون تأثیر روی UI |
| وابستگی به DOM | شدید (نیازمند عناصر صفحه) | صفر (کاملاً ایزوله در حافظه) |
در این بخش توانستیم بوم گرافیکی خود را در محیط Worker به فونتهای سفارشی مجهز کنیم و متنها را با دقت پیکسلی و بدون درگیر کردن Thread اصلی رسم کنیم. در بخش بعدی به سراغ مدیریت رویدادهای کاربر مثل کلیک و حرکت ماوس میرویم و یاد میگیریم چگونه تعاملات کاربر را بدون افت فریم به Worker منتقل کنیم.
پیادهسازی نهایی: شبیهساز ذرات فیزیکی ۶۰ فریم بر ثانیه در پسزمینه
تصور کنید قصد دارید یک شبیهساز ذرات تعاملی با بیش از ۵۰۰۰ ذره بسازید که به حرکت ماوس کاربر واکنش نشون میده. اگر پردازش سنگین ریاضی (محاسبه فاصله، زاویه و جاذبه) و رسم ذرات روی Thread اصلی انجام بشه، کوچکترین اسکرول کاربر یا باز شدن یک منو باعث افت شدید فریم (Lag) و قفل شدن کاملا مشهود UI میشود.
برای حل این چالش، باید موتور فیزیک و رندرینگ رو کاملاً به محیط Worker منتقل کنیم. اما چطور تعاملات سریع کاربر مثل حرکت ماوس رو بدون خفه کردن کانال ارتباطی (PostMessage) به سیستم ذرات در پسزمینه متصل کنیم؟
چالش همگامسازی ورودیها: معماری انتقال رویداد
وقتی کاربر ماوس رو حرکت میده، مرورگر دهها رویداد mousemove در ثانیه تولید میکنه. ارسال مستقیم کل شیء Event به Worker غیرممکنه چون اشیاء DOM قابل Serialization نیستن.
+-----------------------------------------------------------------------+
| MAIN THREAD |
| [ Mouse Move ] ---> Extract (x, y) ---> postMessage({x, y}) |
+-----------------------------------------------------------------------+
|
v (Zero-copy / Light Payload)
+-----------------------------------------------------------------------+
| WORKER THREAD |
| Receive State ---> Update Mouse Vector ---> Physics Loop (60 FPS) |
| | |
| v |
| OffscreenCanvas Render |
+-----------------------------------------------------------------------+
راهکار مبتدیانه اینه که رویداد خام رو جابهجا کنیم یا تمام ذرات رو در Thread اصلی محاسبه کنیم. راهکار حرفهای، الگوی Input State Synchronization هست: فقط مختصات خامی که تغییر کردن را به صورت پیامهای فوقالعاده سبک برای Worker بفرستیم و متغیر حالت ماوس در Worker رو بهروز بکنیم.
هزینه و مرز مهندسی (Trade-off): همگامسازی تعاملات از طریق
postMessageیک تاخیر فریم ناچیز (حداکثر ۱ فریم یا حدود ۱۶ میلیثانیه) ایجاد میکنه. با این حال، در بازدهی کلی سیستم و حفظ روانی UI صفحه تا ۱۰۰٪ بهبود ایجاد میکنه که کاملاً ارزش این تبادل رو داره.
گام اول: مدیریت ورودی در Thread اصلی
در این کد، Canvas رو مقداردهی اولیه میکنیم، کنترل گرافیکی رو به OffscreenCanvas میسپاریم و تنها دادههای لازم (مختصات ماوس) رو پاس میدیم:
// main.js
const canvas = document.querySelector('#particle-canvas');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('physics-worker.js');
// ارسال بوم به پسزمینه
worker.postMessage({ type: 'INIT', canvas: offscreen }, [offscreen]);
// ارسال هوشمند موقعیت ماوس بدون انتقال اشیاء سنگین
window.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
worker.postMessage({
type: 'MOUSE_MOVE',
x: e.clientX - rect.left,
y: e.clientY - rect.top
});
});
- دلیل انتخاب
getBoundingClientRect: محاسبه موقعیت نسبی ماوس نسبت به بوم باعث میشه حتی اگر Canvas در صفحه اسکرول بشه، مختصات دقیق پیکسلی داخل Worker محاسبه بشه. - ساختار پیام سبک: ارسال فقط دو متغیر عددی
xوyکمترین بار حافظه (Memory Overhead) رو روی مرورگر میذاره.
گام دوم: موتور فیزیک ذرات در Worker
حالا درون Worker، کلاس ذرات و حلقه اصلی ۶0 فریم بر ثانیهای رو طراحی میکنیم. ذرات در هر فریم موقعیت جدیدشون رو بر اساس فاصله تا ماوس بهروزرسانی میکنن:
// physics-worker.js
let ctx;
let mouse = { x: -1000, y: -1000 };
const particles = [];
const PARTICLE_COUNT = 5000; // ۵۰۰۰ ذره بدون فشار به UI
class Particle {
constructor(width, height) {
this.x = Math.random() * width;
this.y = Math.random() * height;
this.vx = (Math.random() - 0.5) * 2; // سرعت افقی اولیه (-1 تا 1)
this.vy = (Math.random() - 0.5) * 2; // سرعت عمودی اولیه (-1 تا 1)
this.radius = 2;
}
update(width, height) {
// محاسبه فاصله تا ماوس
const dx = mouse.x - this.x;
const dy = mouse.y - this.y;
const dist = Math.hypot(dx, dy);
const maxDist = 120; // شعاع اثرگذاری نیروی ربایش ماوس
if (dist < maxDist) {
const force = (maxDist - dist) / maxDist;
const angle = Math.atan2(dy, dx);
// اعمال شتاب در جهت ماوس
this.vx += Math.cos(angle) * force * 0.6;
this.vy += Math.sin(angle) * force * 0.6;
}
// اصطکاک برای جلوگیری از سرعت بینهایت
this.vx *= 0.95;
this.vy *= 0.95;
this.x += this.vx;
this.y += this.vy;
// بازخورد برخورد به دیوارهها
if (this.x < 0 || this.x > width) this.vx *= -1;
if (this.y < 0 || this.y > height) this.vy *= -1;
}
draw(ctx) {
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = '#00f3ff';
ctx.fill();
}
}
تحلیل اعداد و مقادیر تنظیمات:
- مقدار
PARTICLE_COUNT = 5000: رندر ۵۰۰۰ ذره روی Thread اصلی پردازنده رو به مرز ۱۰۰٪ میرسونه، اما در Worker با رندر بدون توقف ۶۰ فریم اجرا میشه. - ضریب اصطکاک
0.95: باعث میشه ذرات بعد از شتاب گرفتن از سمت ماوس، به مرور کند بشن و رفتار فیزیکی طبیعی از خودشون نشان بدن (کمتر از این مقدار حرکت رو خشک و بیشتر از آن حالت بیوزنی مطلق ایجاد میکنه). - شعاع اثر
maxDist = 120: تعیینکننده میدان مغناطیسی فرضی دور ماوس است.
گام سوم: حلقه رندرینگ و مدیریت پیامها
حالا حلقه انیمیشن مستقل رو با requestAnimationFrame اختصاصی سیستم-عامل در Worker فعال میکنیم:
// physics-worker.js (ادامه)
let width, height;
self.onmessage = (e) => {
if (e.data.type === 'INIT') {
const canvas = e.data.canvas;
ctx = canvas.getContext('2d');
width = canvas.width;
height = canvas.height;
// ساخت ساختار اولیه ذرات
for (let i = 0; i < PARTICLE_COUNT; i++) {
particles.push(new Particle(width, height));
}
// شروع حلقه رندر ۶۰ فریم
loop();
}
if (e.data.type === 'MOUSE_MOVE') {
mouse.x = e.data.x;
mouse.y = e.data.y;
}
};
function loop() {
// پاکسازی بوم با اثر مسیر (Trail Effect)
ctx.fillStyle = 'rgba(10, 10, 15, 0.2)';
ctx.fillRect(0, 0, width, height);
for (let i = 0; i < particles.length; i++) {
particles[i].update(width, height);
particles[i].draw(ctx);
}
requestAnimationFrame(loop);
}
- تکنیک
rgba(10, 10, 15, 0.2): به جای پاک کردن کامل صفحه باclearRect، استفاده از یک مستطیل نیمهشفاف باعث ایجاد افکت انیمیشنی دنبالهدار (Motion Blur) برای ذرات سریع میشه.
مقایسه عملکرد: پردازش در Thread اصلی در برابر Worker
| شاخص ارزیابی | پردازش مستقیم روی DOM (Main Thread) | پردازش غیرهمگام (OffscreenCanvas + Worker) |
|---|---|---|
| نرخ فریم (FPS) با ۵۰۰۰ ذره | افت شدید به ۱۵ - ۲۵ فریم | ثابت روی ۶۰ فریم بر ثانیه |
| تاخیر اسکرول صفحه (Input Lag) | بالا (موجب پریدن صفحه هنگام اسکرول) | صفر (کاملاً مستقل از UI) |
| استفاده از تمام هستههای CPU | محدود به ۱ هسته (تک نخ) | توزیع بار روی سایر هستهها |
نکته کاربردی: هنگام استفاده از
requestAnimationFrameداخل Web Worker، اگر تب مرورگر غیرفعال (In-active) بشه، مرورگر فرکانس اجرای رندر رو جهت بهینهسازی مصرف باتری دستگاه کاهش میده. برای بازیها یا شبیهسازهایی که نیازمند محاسبات مداوم زمانواقعی هستند، میتونید در زمان غیرفعال بودن تب، حلقه رو به یکsetIntervalپشتیبان متصل کنین.
تنوع در نحوه رفتار فیزیکی ذرات
میتونید با تغییر فرمول شتاب در متد update رفتار ذرات رو به حالت دفعکننده (Repulsion) تغییر بدید:
// تغییر جهت نیرو از ربایش به دفع
const force = (maxDist - dist) / maxDist;
const angle = Math.atan2(dy, dx);
// ضریب منفی یعنی ذرات از ماوس فرار میکنند!
this.vx -= Math.cos(angle) * force * 1.2;
this.vy -= Math.sin(angle) * force * 1.2;
با تغییر ضریب شتاب از 0.6 به -1.2 ذرات به محض نزدیک شدن ماوس با سرعت مضاعف فرار میکنند که یک افکت تعاملی جذاب برای بخشهای Landing Page ایجاد میکنه.
با پیادهسازی این شبیهساز ذرات، توانستیم سنگینترین محاسبات فیزیکی و عملیات رندرینگ پیکسلها رو به پسزمینه مرورگر منتقل کنیم و یک نرخ فریم کاملاً استوار و روان رو در صفحه وب تجربه کنیم. در بخش بعدی به جمعبندی قابلیتها، بهینهسازیهای پیشرفته حافظه و بررسی پشتیبانی مرورگرها میپردازیم.
بهینهسازیهای پیشرفته: متد transferToImageBitmap و مدیریت حافظه
وقتی در حال ساخت اپلیکیشنهای سنگین گرافیکی یا انیمیشنهای ۶۰ فریم بر ثانیه هستیم، انتقال فریمهای رندرشده از Worker به Thread اصلی بدون ایجاد شوک به حافظه RAM و GPU بزرگترین چالش ماست. اگر فرآیند انتقال فریمها یا مدیریت حافظه تصویری بهدرستی انجام نشه، مرورگر روی دستگاههای موبایل خیلی سریع با کمبود حافظه مواجه شده و تب برنامه کلاً کرش (Crash) میکنه.
در حالت عادی، وقتی میخوایم تصویر روی بوم رو ذخیره کنیم یا به Thread دیگری بفرستیم، مرورگر یک کپی کامل از تمام پیکسلها در حافظه ایجاد میکنه. این کار یعنی کپی کردن مگابایتها داده در هر فریم! راهکار جونیورها معمولاً اینه که از متدهایی مثل toDataURL یا getImageData استفاده کنند، اما این ابزارها با درگیر کردن شدید CPU و تخصیص مداوم حافظه، نرخ فریم رو به شدت کاهش میدن.
راهکار حرفهای و مهندسیشده در OffscreenCanvas استفاده از متد transferToImageBitmap است. این متد به جای کپی کردن پیکسلها، مالکیت (Ownership) حافظه تصویر رندرشده رو مستقیماً منتقل میکنه. یعنی انتقال با سرعت نزدیک به صفر ثانیه (Zero-copy transfer) انجام میشه!
Worker Thread Context
┌──────────────────────────┐
│ OffscreenCanvas (Draw) │
└────────────┬─────────────┘
│
▼ transferToImageBitmap() (تخلیه آنی بوم اصلی)
┌──────────────────────────┐
│ ImageBitmap Object │
└────────────┬─────────────┘
│
▼ postMessage(..., [bitmap]) (انتقال مالکیت بدون کپی)
Main Thread Context
┌──────────────────────────┐
│ mainContext.drawImage() │ ───► نمایش به کاربر
└────────────┬─────────────┘
│
▼ bitmap.close()
┌──────────────────────────┐
│ GPU Memory Immediately │ (آزادسازی آنی حافظه VRAM)
│ Freed │
└──────────────────────────┘
اما این سرعت بالاتری یک تاوان هم داره: متد transferToImageBitmap به محض فراخوانی، بوم OffscreenCanvas رو کاملاً پاک و ریست میکنه! اگر قصد دارید رندر قبلی رو حفظ کنید و فریم بعدی رو روی اون اضافه کنید، این متد گزینه مناسبی نیست و باید همان ساختار استاندارد Worker Canvas رو نگه دارید.
در ادامه نگاهی به پیادهسازی این متد در سمت Worker میاندازیم:
// Worker Thread: رندر و استخراج بیتمپ
const offscreen = new OffscreenCanvas(1920, 1080);
const ctx = offscreen.getContext('2d');
// اجرای محاسبات و رسم گرافیک
ctx.fillStyle = '#2ed573';
ctx.fillRect(100, 100, 400, 400);
// استخراج آنی تصویر و پاکسازی بوم اولیه
const imageBitmap = offscreen.transferToImageBitmap();
// ارسال تصویر با انتقال مالکیت (Transferable Objects)
self.postMessage({ type: 'RENDER_COMPLETE', bitmap: imageBitmap }, [imageBitmap]);
عدد 1920 و 1080 ابعاد بوم رو بر حسب پیکسل مشخص میکنند که با رزولوشن Full HD برابری میکنه. آرگومان دوم در postMessage که آرایهای شامل [imageBitmap] است، نقش کلیدی در انتقال zero-copy داره؛ این آرگومان به مرورگر دستور میده که شیء بیتمپ رو در Worker غیرفعال کنه و مالکیت حافظه GPU رو مستقیم به Main Thread بسپاره.
حالا در سمت Main Thread چطور باید این فریم رو دریافت کنیم و حافظه رو سالم نگه داریم؟
// Main Thread: دریافت تصویر و مدیریت حافظه
const displayCanvas = document.getElementById('viewCanvas');
const displayCtx = displayCanvas.getContext('2d');
worker.onmessage = (event) => {
if (event.data.type === 'RENDER_COMPLETE') {
const { bitmap } = event.data;
// رسم بیتمپ روی بوم اصلی صفحه
displayCtx.drawImage(bitmap, 0, 0);
// آزادسازی بلافاصله حافظه سختافزاری
bitmap.close();
}
};
متد bitmap.close() حیاتیترین بخش این کد است. Garbage Collector مرورگر عملکرد غیرقابلپیشبینی داره و ممکنه اشیاء ImageBitmap غیرقابل استفاده رو چند ثانیه دیرتر پاک کنه. اگر در انیمیشنهای سریع این آزادسازی صریح توسط متد close() انجام نشه، حافظه VRAM کارت گرافیک در چند ثانیه پر میشه.
نکته کاربردی: فراخوانی
bitmap.close()مانند بستن اتصال دیتابیس یا فایل است. بعد از اجرای این متد، شیءbitmapکاملاً غیرقابل استفاده و منقضی (Detached) میشه. پس حتماً دقت کنید که این کار رو بعد از متدdrawImageانجام بدید.
برای اینکه درک بهتری از تفاوت رفتار مدیریت حافظه داشته باشید، مقایسه زیر روند اجرای دو رویکرد رو نشان میده:
| ویژگی | رهاسازی خودکار توسط Garbage Collector | آزادسازی صریح با متد .close() |
|---|---|---|
| زمان آزادسازی حافظه | غیرقابل پیشبینی (هر زمان GC تصمیم بگیرد) | همان لحظه و بلافاصله پس از فراخوانی |
| مصرف حافظه گرافیکی (VRAM) | دارای نوسانات شدید و پدید آمدن Spikes | بسیار یکنواخت و کاملاً تحت کنترل |
| خطر Memory Leak | بالا (بهویژه در فریمریت ۶۰fps) | نزدیک به صفر |
| مناسب برای | اپلیکیشنهای ایستا و کمحجم | انیمیشنهای سنگین و بازیهای تحت وب |
حالا اگر با مرورگرهای قدیمیتر یا ساختارهایی مواجه شدیم که پشتیبانی کاملی از OffscreenCanvas ندارند، چطور کد خودمون رو ایمن کنیم؟ باید از الگوهای Feature Detection و Fallback استفاده کنیم تا برنامه با خطا مواجه نشه.
// بررسی پشتیبانی مرورگر و انتخاب استراتژی مناسب
function initGraphicsEngine() {
const containerCanvas = document.getElementById('mainAppCanvas');
if ('OffscreenCanvas' in window) {
// رویکرد بهینه: استفاده از Worker
const offscreen = containerCanvas.transferControlToOffscreen();
const worker = new Worker('renderWorker.js');
worker.postMessage({ type: 'INIT', canvas: offscreen }, [offscreen]);
} else {
// رویکرد پشتیبان: اجرای مستقیم در Main Thread
runFallbackEngine(containerCanvas);
}
}
در این بخش، به جای قفل کردن کد روی یک قابلیت خاص، بررسی میکنیم که آیا OffscreenCanvas در شیء window وجود داره یا خیر. متد transferControlToOffscreen بوم موجود در صفحه رو به یک نسخه Offscreen تبدیل میکنه تا کدهای Worker بتونند مستقیماً روی اون رندر بگیرند، بدون اینکه کنترل خروجی از دست Main Thread خارج بشه.
با ترکیب متد transferToImageBitmap و مدیریت دقیق چرخه حیات ImageBitmap از طریق .close()، ما پروژهای ساختیم که نه تنها سنگینترین محاسبات ریاضی و پردازش پیکسلها رو از Main Thread جدا کرده، بلکه کوچکترین بار اضافی یا نشتی حافظه به مرورگر کاربر تحمیل نمیکنه.
ما در این مقاله یاد گرفتیم که چگونه محاسبات سنگین گرافیکی را با OffscreenCanvas به پسزمینه منتقل کنیم، با انتقال مالکیت بیتمپ بالاترین سرعت رندر را تجربه کنیم و جلوی نشتی حافظه GPU را بگیریم. در بخش بعد، به جمعبندی کلی این مبحث و بررسی سناریوهای واقعی معماری پروژههای تجاری میپردازیم.