عمّال الويب في موجّه التطبيقات بـ Next.js: ملاحظاتنا الميدانية حول import.meta.url وخطأ DataCloneError وما لا يستطيع العامل إصلاحه
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗عامل الويب هو خيط جافاسكربت منفصل يشغّله المتصفح بجانب الخيط الرئيسي، وله نطاق عام خاص به، ولا يملك وصولاً إلى DOM، ويتواصل عبر تبادل الرسائل فقط. نقل فريقنا ثلاث ميزات إلى عمّال ويب داخل تطبيق يعتمد على موجّه التطبيقات في Next.js 16: تحليل ملفات CSV، وتغيير حجم الصور في المتصفح، وفهرس بحث تقريبي.
كل خطأ واجهناه جاء من أحد ثلاثة مصادر: التسلسل عبر حدود الخيوط، أو طريقة حزم المسارات، أو تشغيل React StrictMode لعامل ثانٍ لم يُنهَ أبداً. هذه هي الملاحظات التي تمنينا لو امتلكناها من اليوم الأول.
أهم النقاط
عامل الويب يفيد فقط عندما تكون المهمة الطويلة شيفرة جافاسكربت مرتبطة بالمعالج تملكها أنت. إذا نسبت لوحة الأداء في المتصفح المهمة الطويلة إلى عرض React أو مرحلة الإسناد أو إعادة حساب الأنماط، فلن ينقل العامل شيئاً، لأن العرض يجب أن يبقى على الخيط الرئيسي.
أنشئ العامل دائماً بالصيغة new Worker(new URL('./parse.worker.ts', import.meta.url)). يتعرف كل من webpack 5 و Turbopack على هذا التعبير بالتحديد ويصدران العامل كحزمة مستقلة بتجزئة خاصة، بينما يؤدي المسار النصي العادي إلى خطأ 404 في بناء الإنتاج.
تستخدم postMessage خوارزمية الاستنساخ البنيوي، وهي تُسقط الدوال وأنماط الأصناف وعقد DOM والخصائص المحسوبة. إرسال أي منها يرمي DataCloneError، وإرسال كائن من صنف ينتج بهدوء كائناً عادياً بلا توابع على الخيط المستقبِل.
تحوّل مكتبة Comlink استدعاء postMessage إلى استدعاءات توابع مع await باستخدام Proxy، فيعرض العامل كائناً وتستدعي الصفحة await api.parse(file). حجمها نحو كيلوبايت واحد بعد الضغط، وتلغي الحاجة إلى إدارة معرّفات الطلبات يدوياً.
يشغّل React StrictMode التأثيرات مرتين في بيئة التطوير، لذا فإن عاملاً لم يُنهَ يسرّب خيطاً ثانياً. إرجاع () => worker.terminate() من التأثير هو الحل في التطوير وهو أيضاً السلوك الصحيح في الإنتاج عندما يغادر المستخدم الصفحة أثناء المعالجة.
ما الذي يصلحه عامل الويب فعلياً وما الذي لا يصلحه؟
يعالج عامل الويب تعطيل الخيط الرئيسي الناتج عن شيفرة جافاسكربت متزامنة طويلة لا تلمس DOM. التحليل والضغط والمقارنة والتجزئة والترميز وبناء فهرس بحث وفك ترميز حمولة كبيرة وتشغيل وحدة WebAssembly كلها تنتقل بسلاسة.
لا يعالج عامل الويب بطء العرض في React. يجب أن تعمل مرحلتا التوفيق والإسناد في React على الخيط الرئيسي لأنهما تكتبان إلى DOM. إذا جاء تراجع مقياس Interaction to Next Paint من عرض جدول بخمسة آلاف صف، فالحل هو الافتراضية أو تقليل المكوّنات، لا العامل.
يظهر الفرق بوضوح في تتبّع الأداء: وسّع المهمة الطويلة واقرأ مخطط اللهب. إذا كانت أعرض الإطارات تحمل أسماء دوالك، فسيفيد العامل. أما إن كانت داخليات React أو إعادة حساب الأنماط، فلن يفيد.
لا يملك العامل وصولاً إلى window أو document أو localStorage، لكنه يملك fetch و IndexedDB و WebAssembly و crypto.subtle و OffscreenCanvas والمؤقتات. القاعدة التي يستخدمها فريقنا: إذا كانت الشيفرة ستعمل كما هي في Node.js فستعمل داخل عامل.
كيف ننشئ عامل ويب في موجّه التطبيقات بـ Next.js؟
أنشئ العامل داخل useEffect في مكوّن عميل، باستخدام new URL(..., import.meta.url)، وأنهِه في دالة التنظيف. لا يوجد مُنشئ Worker في Node.js، لذا فإن إنشاءه أثناء العرض على الخادم أو في نطاق الوحدة يرمي ReferenceError: Worker is not defined أثناء البناء.
ثلاثة تفاصيل مهمة. يجب أن يظهر التعبير new URL('./x.worker.ts', import.meta.url) حرفياً داخل مُنشئ Worker، لأن نقله إلى متغير يعطّل التحليل الساكن للحازم فلا يُصدَّر الملف أبداً. والخيار { type: 'module' } هو ما يسمح باستخدام جمل import داخل العامل. وملف العامل يجب أن يكون خارج مجلد app — نحفظ ملفاتنا في src/workers — كي لا يفسّره الموجّه كمسار.
داخل ملف العامل، جملة export {} في نهايته ليست زخرفة. فهي تجعل الملف وحدة برمجية كي يحدد TypeScript نطاق self ضمن سياق العامل بدلاً من التعارض مع التعريفات العامة لمكتبة DOM.
لماذا يرمي العامل خطأ DataCloneError؟
تُسلسِل postMessage وسيطها بخوارزمية الاستنساخ البنيوي، التي تنسخ البيانات البسيطة لكنها ترفض الدوال والرموز وعقد DOM وأي شيء يحمل إغلاقاً. تمرير أي منها يرمي DataCloneError: Failed to execute 'postMessage'.
الفشل الأهدأ هو كائنات الأصناف. تنسخ الخوارزمية الخصائص الذاتية القابلة للتعداد وتتجاهل النمط الأولي، فيصل كائن Decimal أو محلل ما ككائن عادي بلا توابع، ثم يفشل أول استدعاء بعيداً عن سطر postMessage المسبب. صرنا نرسل بيانات بشكل JSON فقط عبر الحدود ونعيد بناء الكائنات على الطرف المستقبِل.
الكائنات القابلة للنقل هي المخرج للحمولات الكبيرة. يمكن نقل ArrayBuffer أو MessagePort أو ImageBitmap أو OffscreenCanvas أو ReadableStream بدلاً من نسخها، عبر تمريرها في الوسيط الثاني لـ postMessage، ما ينقل الملكية إلى الخيط الآخر بزمن ثابت ويترك الأصل منفصلاً بطول صفر.
نسيان هذا الوسيط الثاني هو الفرق بين نقل مؤشر ونسخ كل بايت. في عامل تغيير حجم الصور كان هذا التغيير وحده كافياً ليصبح التفاعل فورياً، لأن نسخة بحجم عدة ميغابايت كانت تحدث على الخيط الرئيسي قبل أن يبدأ العامل أصلاً.
متى نستخدم Comlink بدل postMessage المباشر؟
Comlink مكتبة بحجم كيلوبايت تقريباً من فريق Chrome تغلّف postMessage داخل Proxy فيبدو العامل ككائن قابل للانتظار. استخدمها فور امتلاك العامل أكثر من عملية واحدة، لأن توجيه الرسائل يدوياً يعني ابتكار معرّفات طلبات وخريطة وعود معلّقة واتحاداً مميّزاً لأنواع الرسائل، ثم صيانة الثلاثة معاً.
يستدعي العامل Comlink.expose(api) ويصدّر نوع ذلك الكائن، بينما تستدعي الصفحة Comlink.wrap بذلك النوع على نسخة Worker ثم تنتظر استدعاءات توابع عادية مثل api.query('next.js').
قاعدتان تعلمناهما بكسرهما. يجب تغليف دوال الاستدعاء الراجع بـ Comlink.proxy(cb) لأن الدالة المجردة غير قابلة للاستنساخ. والكائنات القابلة للنقل تحتاج Comlink.transfer(value, [buffer])، لأن Comlink لا تستنتج قائمة النقل، فيضيع مكسب النسخ الصفري بصمت إن أهملتها.
استخدم import type لنوع الواجهة المعروضة كي لا تُسحب وحدة العامل إلى الحزمة الرئيسية، مع بقاء الإكمال التلقائي كاملاً على الخيط الرئيسي وكل نوع إرجاع مغلّف بوعد.
هل نستخدم عامل ويب أم scheduler.yield()؟
استخدم scheduler.yield() عندما يجب أن تلمس المهمة DOM أو تكون طويلة بشكل متوسط، واستخدم عامل ويب عندما تكون المهمة حساباً خالصاً يُقاس بمئات الميلي ثانية. الدالة scheduler.yield() تابع من واجهة الجدولة يعيد وعداً ويتيح للمتصفح معالجة الإدخال المعلّق قبل متابعة الدالة نفسها، فيقسّم مهمة طويلة إلى مهام قصيرة على الخيط نفسه بدل نقل العمل.
الخيط: يعمل عامل الويب على خيط منفصل ويترك الخيط الرئيسي حراً، بينما تبقى scheduler.yield() على الخيط نفسه وتقسّم المهمة فقط.
الوصول إلى DOM: لا وصول لعامل الويب، ووصول كامل مع scheduler.yield().
كلفة الإقلاع: يُنشئ عامل الويب خيطاً ويحلّل حزمة ثانية، بينما تكلّف scheduler.yield() انتظاراً واحداً.
كلفة البيانات: يدفع عامل الويب ثمن الاستنساخ البنيوي ما لم تنقل الكائنات، ولا تدفع scheduler.yield() شيئاً لأن الذاكرة مشتركة.
الأنسب لـ: العمّال مناسبون للتحليل والتجزئة والفهرسة و WebAssembly، بينما تناسب scheduler.yield() الحلقات الطويلة التي تبني الواجهة أو تعدّلها.
كلفة الإقلاع حقيقية: إنشاء عامل يعني أن المتصفح ينشئ خيطاً ويحلّل حزمة ثانية. للعمل الذي يُقاس بميلي ثانيتين، الرحلة ذهاباً وإياباً كلفة صافية. لا ننقل شيئاً إلى عامل إلا بعد ظهوره كمهمة طويلة تتجاوز 50 ميلي ثانية في التتبّع.
ما الذي انكسر معنا في المرة الأولى؟
أنشأ StrictMode عاملين. يقوم React StrictMode بتركيب المكوّنات وإزالتها وإعادة تركيبها في بيئة التطوير. تأثير ينشئ عاملاً ولا يعيد شيئاً كان يترك خيطاً يتيماً يحتفظ بفهرسه في الذاكرة عند كل إعادة تحميل ساخنة. إرجاع () => worker.terminate() حلّ المشكلة.
مسار URL منقول خارج المُنشئ أنتج 404 في الإنتاج. كان التطوير يعمل، لكن البناء المنشور طلب مسار عامل غير موجود فتلقّى صفحة خطأ HTML. السبب هو نقل new URL(...) إلى ثابت مشترك، ما يعطّل الكشف الساكن للحازم.
اختفت الأخطاء. الاستثناء الذي يُرمى داخل عامل مباشر لا يرفض أي وعد على الخيط الرئيسي، بل يطلق حدث error على كائن العامل. صرنا نربط worker.onerror دائماً، ومع Comlink نغلّف الاستدعاءات بـ try/catch، مع تذكّر أن الرسالة والمكدس فقط يعبران الحدود ما لم يُسجَّل مُعالج في Comlink.transferHandlers.
لم يكن SharedArrayBuffer خياراً. مشاركة الذاكرة بين الخيوط بلا نسخ تتطلب عزلاً عبر المصادر عبر ترويستي Cross-Origin-Opener-Policy: same-origin و Cross-Origin-Embedder-Policy: require-corp. هاتان الترويستان كسرتا أدوات طرف ثالث مضمّنة في الصفحة نفسها، فبقينا مع الكائنات القابلة للنقل.
عامل واحد كان كافياً. بنينا تجمّع عمّال بحجم navigator.hardwareConcurrency قبل القياس، ثم اكتشفنا أن عاملاً واحداً أزال كل مهمة طويلة من التتبّع. التجمّع مفيد فقط عندما تكون القطع متوازية ومستقلة فعلاً.
الأسئلة الشائعة
س: هل تعمل عمّال الويب مع مكوّنات الخادم في موجّه التطبيقات بـ Next.js؟
ج: ليس مباشرة. Worker واجهة متصفح، لذا يحتاج الملف الذي ينشئها إلى توجيه 'use client'، ويجب أن يحدث الإنشاء داخل useEffect أو معالج حدث كي لا يعمل أثناء العرض على الخادم. يمكن لمكوّن خادم أن يعرض مكوّن العميل المالك للعامل.
س: هل يحسّن نقل العمل إلى عامل ويب مقياس INP؟
ج: فقط إذا كان التفاعل معطّلاً بشيفرة جافاسكربت مرتبطة بالمعالج تتحكم بها. يقيس Interaction to Next Paint التأخير من التفاعل حتى الإطار التالي، لذا فإن نقل تحليل يستغرق 400 ميلي ثانية خارج الخيط الرئيسي يفيد مباشرة، بينما لا يتأثر إسناد React البطيء لأن العرض لا يمكن أن يغادر الخيط الرئيسي.
س: هل يدعم Turbopack الصيغة new Worker(new URL(...))؟
ج: نعم. يتعرف Turbopack، وهو الحازم الافتراضي لأمري next dev و next build في Next.js 16، على النمط new URL('./file', import.meta.url) داخل مُنشئ Worker ويصدّر العامل كحزمة مستقلة، تماماً كما يفعل webpack 5.
س: ما الفرق بين عامل الويب وعامل الخدمة؟
ج: عامل الويب خيط حساب في الخلفية تملكه صفحة واحدة وينتهي معها. أما عامل الخدمة فهو وسيط شبكي بين الصفحة والشبكة يبقى عبر تحميلات الصفحات ويشغّل التخزين دون اتصال والإشعارات الفورية، وليس مكاناً لتشغيل حسابات ثقيلة للصفحة الحالية.
س: هل يمكن مشاركة الحالة بين الخيط الرئيسي والعامل بلا نسخ؟
ج: نعم عبر SharedArrayBuffer، لكن يجب أن تكون الصفحة معزولة عبر المصادر بترويستي COOP و COEP. بدون هاتين الترويستين، استخدم الكائنات القابلة للنقل التي تنقل ملكية ArrayBuffer بزمن ثابت بدل مشاركتها.
الخلاصة
النموذج الذهني الذي جعل العمّال سهلين على فريقنا: العامل خادم صغير يصادف أنه يعمل داخل التبويب نفسه. ترسل إليه طلباً فيجيب ببيانات، وكل ما يعبر الفجوة يجب أن ينجو من التسلسل.
الانضباط يبدأ بالقياس. افتح تتبّعاً، جد المهمة الطويلة، اقرأ مخطط اللهب، وبعدها فقط فكّر في خيط جديد. عمّال الويب يحلّون مشكلة واحدة محددة بامتياز، ويضيفون تعقيداً حقيقياً لكل ما عداها.
قراءات إضافية
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.
هندسة الواجهات
صور Open Graph الديناميكية في Next.js 16: كيف نبني opengraph-image.tsx ونتحقق من إصدار Satori الذي تضمّنه next/og ونحافظ على صحة معاينات الروابط
واجهت بطاقات المشاركة المولّدة لدينا ثلاث مفاجآت: خط WOFF2 لم يستطع المحرّك قراءته، وملف README لـ Satori يصف محرّكًا أحدث من المضمّن في Next.js، وصفحة ظلّت تعرض صورة الـ layout. هذه ملاحظاتنا الميدانية حول opengraph-image.tsx في Next.js 16 وأولوية البيانات الوصفية وفحص og:image قبل مشاركة أي رابط.