Web Workers ו-Comlink ב-2026: איך להעביר עבודה כבדה מה-Main Thread ולשפר INP

מדריך מעשי להעברת חישובים כבדים מה-Main Thread באמצעות Web Workers ו-Comlink — כולל דוגמאות קוד, דפוסי תקשורת, מדידת ההשפעה על INP ושגיאות נפוצות שכדאי להימנע מהן.

אז עברתם על המדריך שלנו על Long Animation Frames API, גיליתם שמשימות JavaScript ארוכות הן בדיוק "האשם" העיקרי במדד ה-INP (Interaction to Next Paint) שלכם, ועכשיו אתם עומדים בצומת המוכרת של ביצועי ווב: צריך להוריד עבודה מה-Main Thread. ב-2026 התשובה הסטנדרטית והבוגרת היא Web Workers — ובמיוחד שילוב שלהם עם הספרייה Comlink, שגומרת אחת ולתמיד עם כאב הראש של postMessage ידני.

במדריך הזה נסקור מתי באמת שווה להעביר עבודה ל-Worker, איך מקימים אחד מודרני עם Vite/Webpack/esbuild, איך משתמשים ב-Comlink כדי להפוך פונקציות מרוחקות לקריאות async רגילות, ואיך מודדים את ההשפעה האמיתית על INP ו-TBT. בלי שיווק, רק קוד שעובד.

מתי באמת צריך Web Worker?

בואו נוריד את הפיל מהחדר מיד: Web Workers הם לא כדור כסף. הם מוסיפים מורכבות, סדרליזציה (structured clone), ועוד טיפה דיליי בתקשורת. כלל אצבע פשוט שאני משתמש בו: שקלו Worker רק כשיש לכם משימה סינכרונית שלוקחת יותר מ-50ms ב-CPU "מציאותי" (כלומר CPU throttling x4 ב-DevTools), והיא רצה בתגובה לאינטראקציית משתמש.

תרחישים קלאסיים שכדאי להעביר ל-Worker:

  • פרסור JSON גדול (מעל ~200KB) שמגיע מ-API
  • עיבוד תמונות בצד הלקוח (resize, blur, crop) באמצעות OffscreenCanvas
  • חישובים מתמטיים כבדים — רגרסיות, סטטיסטיקה, ניתוח נתונים
  • חיפוש fuzzy/full-text מקומי על datasets גדולים
  • קריפטוגרפיה מותאמת אישית (hashing, encryption)
  • פירוק קבצים: PDF, CSV, XLSX בצד הלקוח
  • קימפול/transpile — למשל הרצת Babel/PostCSS בדפדפן עבור פלייגראונד

מצד שני: אל תעבירו ל-Worker פעולות שדורשות קריאות תכופות ל-DOM, פעולות שלוקחות פחות מ-10ms, או קריאות API פשוטות (כי הן כבר async, וזה מספיק).

הבעיה: postMessage גולמי הוא פשוט כאב

הדרך ה"רגילה" לדבר עם Worker היא postMessage + onmessage + ניהול ידני של request IDs. ככה זה נראה בפועל:

// main.js — הדרך הישנה
const worker = new Worker('./heavy.worker.js', { type: 'module' });
let nextId = 0;
const pending = new Map();

worker.onmessage = (e) => {
  const { id, result, error } = e.data;
  const { resolve, reject } = pending.get(id);
  pending.delete(id);
  error ? reject(error) : resolve(result);
};

function callWorker(method, args) {
  return new Promise((resolve, reject) => {
    const id = nextId++;
    pending.set(id, { resolve, reject });
    worker.postMessage({ id, method, args });
  });
}

// heavy.worker.js
self.onmessage = async (e) => {
  const { id, method, args } = e.data;
  try {
    const result = await methods[method](...args);
    self.postMessage({ id, result });
  } catch (error) {
    self.postMessage({ id, error: String(error) });
  }
};

זה עובד, כן — אבל מסורבל, פתוח לבאגים, ולא ידידותי במיוחד ל-TypeScript. בכנות, ב-2026 כבר אף אחד לא כותב את זה ידנית. נכנס Comlink.

Comlink: RPC שקוף ל-Web Workers

Comlink היא ספרייה זעירה (~1.1KB minified) מבית Google Chrome Labs שעוטפת את postMessage ב-Proxy מבוסס MessageChannel. התוצאה? אתם קוראים לפונקציות ב-Worker בדיוק כאילו היו פונקציות async רגילות. וגם types של TypeScript עוברים נכון, בלי לעקם את האפים.

npm install comlink

1. צד ה-Worker

// math.worker.ts
import * as Comlink from 'comlink';

const api = {
  async heavyCompute(data: number[]): Promise<number> {
    // משימה כבדה מדומה
    let sum = 0;
    for (let i = 0; i < data.length; i++) {
      sum += Math.sqrt(data[i]) * Math.log(data[i] + 1);
    }
    return sum;
  },

  async parseLargeJson(text: string) {
    const obj = JSON.parse(text);
    // עיבוד נוסף, נרמול שדות וכו'
    return obj;
  },
};

export type MathApi = typeof api;
Comlink.expose(api);

2. צד ה-Main Thread

// main.ts
import * as Comlink from 'comlink';
import type { MathApi } from './math.worker';

const worker = new Worker(
  new URL('./math.worker.ts', import.meta.url),
  { type: 'module' }
);
const math = Comlink.wrap<MathApi>(worker);

// שימוש — נראה כמו קריאת פונקציה רגילה
button.addEventListener('click', async () => {
  const data = Array.from({ length: 5_000_000 }, () => Math.random());
  const result = await math.heavyCompute(data);
  console.log('sum =', result);
});

שימו לב לקסם: math.heavyCompute(data) מחזיר Promise, ה-types נשמרים מ-MathApi, ואתם לא רואים שום postMessage בקוד שלכם. הראשונה שראיתי את זה עובד, חשבתי שזה איזה טריק. זה לא — זה פשוט Proxy טוב.

הגדרה עם Vite, Webpack ו-esbuild

הקסם של בנדלרים מודרניים: new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' }) הוא התחביר הסטנדרטי שכל הבנדלרים הגדולים מזהים. הם מפצלים אוטומטית את הקוד לקובץ נפרד עם hash בשם, ולכם לא צריך לעשות כלום מיוחד.

Vite

עובד מהקופסה. ה-pattern של new URL(...) מזוהה אוטומטית. עבור Workers שמיובאים במקומות רבים, אפשר להשתמש בתחביר המקוצר: import MyWorker from './my.worker.ts?worker'.

Webpack 5

גם כאן — עובד מהקופסה עם תחביר new URL. אין יותר צורך ב-worker-loader הישן (תודה לאל).

esbuild / Bun

תומכים באותו pattern, רק תוודאו ש-format: 'esm' ושה-target תומך ב-import.meta.url.

העברת Transferables: לא להעתיק — להעביר

כברירת מחדל, כל מה שעובר ל-Worker עובר structured clone, כלומר עותק עמוק. עבור ArrayBuffer בגודל 50MB, זו אופרציה שיכולה לקחת מעל 100ms ולשבור INP — בדיוק את הבעיה שבאנו לפתור! (אירוני, נכון?)

הפתרון נקרא Transferables. במקום להעתיק, אנחנו "מעבירים בעלות" על buffer ל-Worker (ה-buffer במקור הופך ל-neutered, באורך 0). זו פעולה ב-O(1).

import * as Comlink from 'comlink';

const buffer = new ArrayBuffer(50_000_000);
// העברת בעלות במקום העתקה
const result = await math.processBuffer(
  Comlink.transfer(buffer, [buffer])
);

// בצד ה-Worker
const api = {
  async processBuffer(buf: ArrayBuffer) {
    const view = new Uint8Array(buf);
    // ...עבודה על הbuffer...
    return Comlink.transfer(buf, [buf]); // החזרה ב-O(1)
  },
};

אובייקטים שניתן להעביר: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream, WritableStream, TransformStream.

OffscreenCanvas: רינדור מחוץ ל-Main Thread

אחד השימושים החזקים ביותר ב-Workers ב-2026 הוא OffscreenCanvas — קנבס שאפשר לרנדר אליו מתוך Worker. שימושי מאוד לויזואליזציות נתונים, עיבוד תמונות, או משחקי WebGL בלי להחזיק את ה-Main Thread כבן ערובה.

// main.ts
const canvas = document.querySelector('canvas')!;
const offscreen = canvas.transferControlToOffscreen();

const worker = new Worker(new URL('./render.worker.ts', import.meta.url), {
  type: 'module',
});

worker.postMessage({ canvas: offscreen }, [offscreen]);

// render.worker.ts
self.onmessage = (e) => {
  const { canvas } = e.data;
  const ctx = canvas.getContext('2d')!;
  let t = 0;
  function frame() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = `hsl(${t % 360}, 70%, 50%)`;
    ctx.beginPath();
    ctx.arc(200 + 100 * Math.sin(t/30), 200, 50, 0, Math.PI * 2);
    ctx.fill();
    t++;
    requestAnimationFrame(frame);
  }
  frame();
};

נקודה חשובה: requestAnimationFrame בתוך Worker מתסנכרן עם ה-vsync של הדפדפן בדיוק כמו ב-Main Thread — אבל הוא לא נחסם על-ידי עבודה אחרת. וזה בדיוק הקסם.

מדידה: לפני ואחרי

איך באמת מוודאים שההעברה ל-Worker עזרה? יש שלוש מדידות חיוניות:

1. INP בפרודקשן באמצעות web-vitals

import { onINP } from 'web-vitals/attribution';

onINP((metric) => {
  console.log('INP:', metric.value, 'ms');
  console.log('Slow target:', metric.attribution.interactionTarget);
  console.log('Input delay:', metric.attribution.inputDelay);
  console.log('Processing duration:', metric.attribution.processingDuration);
  console.log('Presentation delay:', metric.attribution.presentationDelay);
  // שלחו ל-analytics
});

אחרי המעבר ל-Worker, processingDuration אמור לרדת דרמטית עבור האינטראקציות הרלוונטיות. זה המספר שאתם רוצים לראות יורד.

2. PerformanceObserver עבור Long Tasks

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn('Long task:', entry.duration, 'ms');
    }
  }
}).observe({ type: 'longtask', buffered: true });

3. Chrome DevTools Performance Panel

פתחו את ה-Performance Panel, הקליטו אינטראקציה לפני ואחרי. בטאב Threads תראו את ה-Worker כ-thread נפרד. ה-Main Thread אמור להיות שקט הרבה יותר בזמן הקלקה (זה הסיפוק האמיתי).

שגיאות נפוצות שכדאי להימנע מהן

1. יצירת Worker חדש בכל אינטראקציה

יצירת Worker עולה ~10-50ms (טעינת קוד, אתחול V8). צרו אותו פעם אחת בטעינת האפליקציה — או בעצלתיים (lazy) בפעם הראשונה שצריך אותו. שמרו singleton ותחסכו לעצמכם כאב ראש.

2. שכחה לקרוא ל-terminate()

אם Worker לא נחוץ יותר (למשל בעמוד שעוזבים אותו ב-SPA), קראו ל-worker.terminate() כדי לשחרר זיכרון. אחרת תסתבכו עם memory leaks שקשה לאתר.

3. העברת אובייקטים שלא ניתנים ל-clone

פונקציות, DOM nodes, ו-class instances עם methods — כל אלה לא עוברים structured clone. או שתסדרו את הנתונים, או שתשתמשו ב-Comlink.proxy עבור callbacks:

// callback עובר כ-proxy, לא כעותק
await math.heavyTask(input, Comlink.proxy((progress) => {
  console.log('progress:', progress);
}));

4. שימוש ב-SharedArrayBuffer בלי headers מתאימים

אם אתם רוצים לשתף זיכרון אמיתי בין threads (במקום להעביר בעלות), תזדקקו ל-SharedArrayBuffer. וזה דורש headers של cross-origin isolation:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

בלי זה, SharedArrayBuffer זמין רק בסביבות מאובטחות מסוימות — משהו שכמעט תמיד שוכחים ואז מבזבזים שעה לדבג.

תבנית מתקדמת: Worker Pool

עבור עבודה parallelizable (נניח, עיבוד 100 תמונות), Worker יחיד הוא צוואר בקבוק. תקימו pool בגודל navigator.hardwareConcurrency ותראו פלא:

import * as Comlink from 'comlink';
import type { MathApi } from './math.worker';

class WorkerPool {
  private workers: { wrap: Comlink.Remote<MathApi>; busy: boolean }[] = [];
  private queue: Array<(w: Comlink.Remote<MathApi>) => void> = [];

  constructor(size = navigator.hardwareConcurrency || 4) {
    for (let i = 0; i < size; i++) {
      const w = new Worker(new URL('./math.worker.ts', import.meta.url), {
        type: 'module',
      });
      this.workers.push({ wrap: Comlink.wrap<MathApi>(w), busy: false });
    }
  }

  acquire(): Promise<Comlink.Remote<MathApi>> {
    const free = this.workers.find((w) => !w.busy);
    if (free) {
      free.busy = true;
      return Promise.resolve(free.wrap);
    }
    return new Promise((resolve) => this.queue.push(resolve));
  }

  release(wrap: Comlink.Remote<MathApi>) {
    const entry = this.workers.find((w) => w.wrap === wrap)!;
    const next = this.queue.shift();
    if (next) next(wrap);
    else entry.busy = false;
  }
}

const pool = new WorkerPool();

async function processInParallel(items: number[][]) {
  return Promise.all(items.map(async (batch) => {
    const w = await pool.acquire();
    try {
      return await w.heavyCompute(batch);
    } finally {
      pool.release(w);
    }
  }));
}

חלופות ל-Workers: scheduler.yield()

ב-2026, ל-Chrome ולשאר הדפדפנים מבוססי-Chromium יש את scheduler.yield() ו-scheduler.postTask(). עבור משימות שאי-אפשר להעביר ל-Worker (כי הן צריכות גישה ל-DOM), אפשר לפצל אותן באמצעות yields:

async function processItems(items) {
  for (const item of items) {
    processItem(item); // עבודה קצרה
    if (navigator.scheduling?.isInputPending() || performance.now() % 50 > 45) {
      await scheduler.yield(); // החזרת שליטה לדפדפן
    }
  }
}

זה לא תחליף ל-Worker, אבל משלים אותו טוב מאוד לתרחישים שמשלבים DOM ועבודה כבדה.

שאלות נפוצות (FAQ)

האם Web Workers משפרים את ה-INP אוטומטית?

לא אוטומטית — רק אם המשימה שגרמה ל-INP גבוה הייתה JavaScript סינכרוני שמעכב את ה-Main Thread. אם ה-INP גבוה נגרם מ-style/layout כבדים, מ-DOM updates ענקיים או מ-input delay (שאלה אחרת לגמרי ב-rendering pipeline), Worker לא יעזור. השתמשו ב-web-vitals/attribution כדי לזהות את הסיבה האמיתית לפני שמשקיעים בריפקטור.

מה ההבדל בין Web Worker, Service Worker ו-Worklet?

Web Worker — thread כללי לעבודת JavaScript. Service Worker — proxy רשת לאינטרספציה של fetch, caching ו-offline. Worklets (Audio, Paint, Animation) — threads קצרים וייעודיים שרצים בתוך pipelines מסוימים של הדפדפן. כל אחד נועד למטרה אחרת לגמרי.

האם אפשר לגשת ל-DOM מ-Web Worker?

לא ישירות. אין document או window בתוך Worker. ניתן לבצע fetch, להשתמש ב-IndexedDB, ב-OffscreenCanvas, ב-WebSocket וב-crypto. אם צריך DOM — פשוט החזירו נתונים ל-Main Thread ועדכנו שם.

מה גודל ה-overhead של Comlink?

הספרייה עצמה ~1.1KB minified+gzipped. ה-overhead של קריאה הוא ~0.05–0.2ms במחשב מודרני (הזמן של structured clone + MessageChannel). עבור משימות שאורכות שניות זה זניח לחלוטין; עבור 1000 קריאות בשנייה — כבר משמעותי, אז באצרו או החזירו batches.

האם React/Vue/Svelte תומכים ב-Workers טוב?

כן — Workers הם פיצ'ר של הדפדפן ובלתי-תלויים ב-framework. עם זאת, ספריות עזר כמו react-use, workerize-loader או vite-plugin-comlink מקלות לכתוב את הקוד יותר נקי. כלל אצבע שלי: שמרו state ב-Worker ככל האפשר, והעבירו ל-UI רק את הפלט הסופי לרינדור.

סיכום

Web Workers בשילוב עם Comlink הם אחת מהדרכים הכי אפקטיביות לשמור על Main Thread רגיש ב-2026. הם פותרים בעיות אמיתיות של INP, מאפשרים שימוש בכוח עיבוד מקבילי, ועם הכלים המודרניים — Vite, TypeScript, Comlink — כתיבת הקוד הופכת לטבעית כמו async/await רגיל.

הצעד הבא? זהו אינטראקציה אחת באפליקציה שלכם עם INP מעל 200ms, הריצו פרופיל ב-DevTools, ובדקו אם המשימה הסינכרונית שמופיעה שם היא מועמדת ל-Worker. אני יכול להבטיח לכם: במקרים רבים, ההפרש בין UI חלק לקוטע הוא 50 שורות קוד והעברה ל-thread נפרד. שווה את ההשקעה.

אודות הכותב Editorial Team

Our team of expert writers and editors.