تخطّى إلى المحتوى الرئيسي
لماذا تنكسر الحزمة داخل تطبيق CommonJS: ملاحظات ميدانية حول require(esm) وخريطة exports وخطر الحزمة المزدوجة

لماذا تنكسر الحزمة داخل تطبيق CommonJS: ملاحظات ميدانية حول require(esm) وخريطة exports وخطر الحزمة المزدوجة

September 10, 2026
هندسة الويب المتكاملة
8 دقيقة قراءة

منذ الإصدار 22.12 صار بإمكان Node.js استدعاء وحدة ES متزامنة عبر require()، وهو ما يلغي السبب الأصلي الذي دفع معظم المكتبات إلى نشر بنائين اثنين، لكنه لا يلغي خطر الحزمة المزدوجة ولا يتسامح مع ترتيب خاطئ في خريطة exports. التغييرات الثلاثة التي أصلحت خط النشر لدينا هي: وضع types أولاً و default أخيراً في كل كتلة شروط، والإبقاء على تنفيذ واحد فقط لحالة الوحدة، وتشغيل publint و @arethetypeswrong/cli داخل CI قبل npm publish.

أهم النقاط

• فعّل الإصدار Node.js 22.12.0 خاصية require(esm) افتراضياً في مسار 22.x، وهي مفعّلة افتراضياً منذ Node.js 23.0.0. يستطيع ملف CommonJS الآن استدعاء وحدة ES عبر require() طالما أن شجرة الوحدة لا تحتوي على await على المستوى الأعلى.

• استدعاء require() لشجرة وحدات ES تستخدم await على المستوى الأعلى يرمي الخطأ ERR_REQUIRE_ASYNC_MODULE وليس ERR_REQUIRE_ESM. الخطآن يدلان على مشكلتين مختلفتين ولكل منهما إصلاح مختلف.

• يطابق حقل exports في package.json الشروط بترتيب كتابتها، وأول تطابق هو الفائز، لذلك يجب أن يأتي types أولاً و default أخيراً. إضافة exports تحجب أيضاً كل مسار فرعي لم تذكره، وهو ما يظهر لدى المستهلكين على شكل ERR_PACKAGE_PATH_NOT_EXPORTED.

• خطر الحزمة المزدوجة هو ما يحدث حين تحمّل عملية واحدة بناء CommonJS وبناء ES module للحزمة نفسها: تصبح حالة الوحدة موجودة مرتين، ولا تبقى الكائنات المفردة مفردة، ويعيد instanceof بين النسختين القيمة false.

• لا تعمل الاستيرادات المسمّاة من ملف CommonJS إلا إذا استطاع cjs-module-lexer اكتشاف أسماء التصدير بشكل ساكن. التصديرات المسندة داخل حلقة أو خلف شرط تكون غير مرئية له، وعلى المستهلك حينها استخدام استيراد افتراضي.

الحزمة التي دفعتنا لكتابة هذه الملاحظات كانت بضع مئات من الأسطر لدوال تحليل مشتركة، نُشرت بصيغة ESM فقط لأن كل تطبيقاتنا كانت أصلاً بصيغة ESM. أول فريق ثبّتها كان يشغّل خدمة CommonJS، وفشلت عند الإقلاع بالخطأ ERR_REQUIRE_ESM. كان رد فعلنا الأول إضافة بناء CommonJS، وهو الجواب المعتاد وهو أيضاً الجواب الذي يُدخل أسوأ خلل في هذا المجال.

هل يستطيع Node.js استدعاء وحدة ES عبر require() الآن؟

نعم، على Node.js 22.12.0 وما بعده، ودون أي راية تجريبية. تتيح require(esm) لملف CommonJS تحميل وحدة ES بشكل متزامن، فتعيد كائن فضاء أسماء الوحدة، ويكون التصدير الافتراضي متاحاً في الخاصية default. ظهرت الخاصية أولاً خلف الراية ‎--experimental-require-module في مسار 22.x، ثم فُعّلت افتراضياً في 22.12.0، وهي مفعّلة افتراضياً في Node.js 23 و24.

الحد الصارم الوحيد هو اللاتزامن. الدالة require() متزامنة بحكم تعريفها، فإذا احتوت شجرة وحدات ES المطلوبة على await على المستوى الأعلى في أي موضع، لن يستطيع Node.js إنهاء التقييم قبل العودة وسيرمي ERR_REQUIRE_ASYNC_MODULE. هذا التمييز مهم أثناء التصحيح: ERR_REQUIRE_ESM يعني أن بيئة التشغيل قديمة جداً أو أن آلية الحل اختارت ملفاً لن تحمّله، بينما ERR_REQUIRE_ASYNC_MODULE يعني أن بيئة التشغيل حاولت وأن الوحدة نفسها كانت المشكلة. المخرج في تلك الحالة هو استخدام import() الديناميكي داخل دالة غير متزامنة.

ما الذي يتغير عملياً: صار خيار ESM فقط خياراً قابلاً للدفاع عنه لمكتبة جديدة تستهدف Node.js 22.12 وما فوق، وانخفض الضغط لنشر بناء CommonJS ثانٍ بشكل كبير. وما لا يتغير: أدوات التحزيم وبيئات التشغيل الأقدم والأدوات التي ما زالت تحل شرط require ستظل تطلب نقطة دخول CommonJS لوقت طويل.

لماذا تحل خريطة exports إلى الملف الخاطئ؟

لأن الشروط الشرطية تُطابق من الأعلى إلى الأسفل وأول مفتاح مطابق هو الفائز، أي أن الترتيب دلالة برمجية وليس مسألة تنسيق. يجب أن يأتي شرط types أولاً وإلا فقد تحل TypeScript ملف تشغيل قبل أن ترى ملف التصريحات أصلاً. ويجب أن يأتي شرط default أخيراً لأنه يطابق كل شيء ويحجب أي شرط يوضع بعده.

كتلة الدخول السليمة لحزمة باسم ‎@acme/toolkit تسرد بالترتيب: types يشير إلى ملف التصريحات، ثم react-server يشير إلى بناء آمن للخادم، ثم import يشير إلى بناء ES module، ثم require يشير إلى بناء CommonJS، ثم default يكرر بناء ES module. وهناك تفصيلان إضافيان يستحقان الذكر. تصدير ‎./package.json صراحةً يبقي الأدوات التي تقرأ الملف التعريفي أثناء التشغيل عاملة، لأن وجود حقل exports يجعله غير قابل للوصول. أما شرط react-server فهو ما تستخدمه آلية حل مكونات الخادم في React، وتقوم Next.js بحله لأي وحدة تُستورد من مكوّن خادم.

أكثر أنماط الفشل إيلاماً هو حجب المسارات الفرعية. قبل وجود exports كان أي ملف داخل الحزمة قابلاً للاستيراد. وفي اللحظة التي تضيف فيها exports يصبح كل ما لم تذكره خاصاً، ويحصل المستهلكون الذين كانوا يستوردون مساراً داخلياً عميقاً على ERR_PACKAGE_PATH_NOT_EXPORTED. هذا تغيير كاسر حتى لو لم يتحرك أي شيء آخر في الحزمة.

ما هو خطر الحزمة المزدوجة وكيف نتجنبه؟

خطر الحزمة المزدوجة هو الخلل الذي تحمّل فيه عملية Node.js واحدة بناءي الحزمة نفسها، فتوجد كل قيمة على مستوى الوحدة مرتين. يحتفظ Node.js بذاكرتي تخزين منفصلتين لـ CommonJS ولوحدات ES، لذا فإن dist/index.js و dist/index.cjs وحدتان مختلفتان بنسختين مختلفتين من كل صنف وسجل وعدّاد مُعرّف على مستوى الوحدة. الكائن الذي تنشئه نسخة ESM من صنف ما يعيد false عند مقارنته بـ instanceof مع نسخة CommonJS من الصنف ذاته.

وهو خلل خبيث تحديداً لأنه صامت. لا شيء يرمي استثناءً. يفشل فحص تحقق لقيمة من النوع الصحيح بوضوح، أو تبلغ ذاكرة تخزين يُفترض أنها مشتركة عن عدم وجود القيمة، ويشير أثر النداء إلى منطق التطبيق بدل أن يشير إلى التحزيم.

هناك ثلاث استراتيجيات. ESM فقط ينشر بناء وحدة ES واحداً بلا شرط require ولا يحمل أي خطر، وهو خيارنا الافتراضي للمكتبات الجديدة التي تستهدف Node.js 22.12 وما فوق. ESM مع غلاف CommonJS بلا حالة ينشر تنفيذ ES module إلى جانب ملف CommonJS صغير يعيد تصديره، ولا يحمل خطراً شرط ألا يحتفظ الغلاف بأي حالة خاصة به، ونستخدمه حين يجب إبقاء مستهلكي CommonJS الأقدم يعملون. البناءان الكاملان ينشران مخرجات CommonJS و ES module محزّمة كل على حدة، ويحملان خطراً عالياً، ولا يُقبلان إلا لدوال خالصة بلا أي حالة على مستوى الوحدة وبلا أصناف مصدَّرة.

قاعدتنا بسيطة: إذا كانت الحزمة تصدّر صنفاً أو تحتفظ بسجل أو تخزّن نتيجة على مستوى الوحدة، فإنها تنشر تنفيذاً واحداً. راحة مستهلكي CommonJS تستحق غلافاً، ولا تستحق أبداً نسخة ثانية من المنطق.

لماذا لا يمكن تفكيك التصديرات المسمّاة من وحدة CommonJS؟

لأن Node.js يكتشف التصديرات المسمّاة لملف CommonJS عبر تحليل ساكن باستخدام cjs-module-lexer، وهذا التحليل لا يرى إلا الإسنادات التي يستطيع قراءتها حرفياً في الشيفرة المصدرية. التصديرات المنتجة أثناء التشغيل، مثل أسماء تُسند داخل حلقة for، تكون غير مرئية له، فيفشل الاستيراد عند الربط بخطأ صياغي بدلاً من الفشل عند الاستدعاء.

إن كنت تملك ملف CommonJS فالإصلاح هو كتابة الإسنادات حرفياً، سطر exports.parse = ... لكل اسم، حتى يجدها المحلل. وإن كنت لا تملكه فاستورد كائن الوحدة استيراداً افتراضياً ثم فكّكه بعد ذلك، وهو ما ينجح دائماً لأنه يؤجل البحث إلى وقت التشغيل.

كيف تُضبط TypeScript ليطابق الناتج صيغة الحزمة؟

اضبط module على nodenext و moduleResolution على nodenext، ودع أقرب حقل type في package.json يقرر كيف يُخرَج كل ملف. تحت nodenext تحاكي TypeScript آلية حل Node.js بدقة: ملف ‎.ts داخل حزمة ‎type: module هو وحدة ES، و‎.mts دائماً ESM، و‎.cts دائماً CommonJS، والاستيرادات النسبية تحتاج امتدادات ملفات.

رايتان تستحقان جملة لكل منهما. الراية verbatimModuleSyntax تطلب من TypeScript إخراج جمل الاستيراد والتصدير كما كُتبت تماماً، مع حذف ما وُسم بـ import type فقط، وهو ما يزيل صنفاً قديماً من الأخطاء كان المترجم فيه يحذف استيراداً موجوداً لأثر جانبي. والإصدار TypeScript 5.8 علّم ‎--module nodenext كيفية التعامل مع require() لوحدة ES، فلم يعد ملف ‎.cts الذي يستدعي اعتمادية ESM خطأ في الترجمة على بيئات التشغيل الداعمة.

وجانب التصريحات له فخّه الخاص. إذا نشرت حزمة مزدوجة فلن يستطيع ملف ‎.d.ts واحد وصف الصيغتين بشكل صحيح، لأن نوع الوحدة لملف التصريحات يُستنتج بالطريقة نفسها المتبعة مع ملف المصدر. الحزم المزدوجة تحتاج ‎.d.mts و‎.d.cts إلى جانب نقاط الدخول المقابلة لكل منهما.

كيف نتحقق من الحزمة قبل نشرها؟

شغّل publint و @arethetypeswrong/cli على الأرشيف المحزّم، ثم اختبر أسلوبي الاستيراد في عملية Node.js حقيقية. تفحص publint الملف التعريفي بحثاً عن أخطاء التحزيم مثل ترتيب الشروط والملفات المفقودة وعدم تطابق الصيغة بين امتداد الملف ومحتواه. أما @arethetypeswrong/cli، التي تُستدعى باسم attw، فتتحقق من أن ملفات التصريحات تُحل بشكل صحيح لكل إعدادات المستهلكين، وهي التي تكشف التصريحات المتنكرة حين تُوصَف نقطة دخول CommonJS بملف تصريحات بصيغة ESM.

نربط الأداتين بمهمة CI ذاتها التي تنفّذ البناء، حتى تفشل خريطة exports السيئة في خط الإنتاج بدل أن تفشل عند تثبيت شخص آخر. هذا الفحص يكلف ثوانٍ قليلة وهو الجزء الوحيد غير القابل للتفاوض في هذا المسار، لأن أخطاء التحزيم غير مرئية داخل مستودعك ولا تظهر إلا عند المستهلك.

النسخة التي نشرناها كانت ESM فقط مع غلاف CommonJS رفيع، وتنفيذ واحد لكل صنف، وكتلة شروط تبدأ بـ types، والأداتين في CI. الخدمة التي بدأت كل هذا تثبّت الحزمة الآن دون خطوة بناء، وعلى Node.js 22.12 وما فوق لا تحتاج حتى إلى الغلاف.

أسئلة شائعة

س: هل تعني require(esm) أننا نستطيع إسقاط بناء CommonJS بالكامل؟

ج: فقط إذا كان كل مستهلك يشغّل Node.js 22.12 أو أحدث ولا توجد أداة تحزيم في سلسلته تصر على شرط require. أما لمكتبة ذات مستهلكين غير معروفين فإن غلاف CommonJS رفيعاً وبلا حالة يمثل تأميناً رخيصاً ولا يحمل خطر الحزمة المزدوجة.

س: ما الفرق بين ERR_REQUIRE_ESM و ERR_REQUIRE_ASYNC_MODULE؟

ج: يعني ERR_REQUIRE_ESM أن بيئة التشغيل لن تحمّل وحدة ES عبر require() إطلاقاً، وهو ما يشير عادةً على Node.js الحديث إلى أن آلية الحل اختارت ملفاً غير متوقع. ويعني ERR_REQUIRE_ASYNC_MODULE أن بيئة التشغيل حاولت وتوقفت لأن شجرة الوحدات تستخدم await على المستوى الأعلى، وهو ما لا تستطيع require() انتظاره.

س: لماذا كسرت إضافة حقل exports مستهلكينا؟

ج: يجعل حقل exports كل مسار لم تذكره غير قابل للوصول، فيحصل أي مستهلك يستورد ملفاً داخلياً بعمق على ERR_PACKAGE_PATH_NOT_EXPORTED. انشره كإصدار رئيسي، أو اذكر المسارات الفرعية التي كانت متاحة سابقاً بشكل صريح خلال فترة إهمال معلنة.

س: هل ما زال حقل main في package.json ضرورياً؟

ج: أبقِ main كخيار احتياطي للأدوات التي تسبق التصديرات الشرطية. يتجاهل Node.js حقل main كلما وُجد حقل exports، فهو لا يكلف شيئاً ويشتري توافقاً مع أدوات التحزيم وآليات الحل الأقدم.

س: كيف نحصل على __dirname داخل وحدة ES؟

ج: استخدم import.meta.dirname و import.meta.filename، وهما متاحان منذ Node.js 20.11 و21.2. ولبيئات التشغيل الأقدم استنتجه عبر fileURLToPath(import.meta.url) من node:url.

قراءات إضافية

لماذا يستمر استهلاك الذاكرة في عمليات Node بالارتفاع: ملاحظات ميدانية عن لقطات الكومة والكاش في نطاق الوحدة والتسريبات التي تظهر عند إعادة استخدام النسخ
اقرأ المقال

هندسة الويب المتكاملة

لماذا يستمر استهلاك الذاكرة في عمليات Node بالارتفاع: ملاحظات ميدانية عن لقطات الكومة والكاش في نطاق الوحدة والتسريبات التي تظهر عند إعادة استخدام النسخ

خدمة Node تبقى بصحة جيدة لساعة بعد كل نشر ثم يبدأ استهلاكها بالارتفاع تعني تسريباً فوق الطلب لا داخله. هذه ملاحظاتنا الميدانية حول التفريق بين تسريب حقيقي وتذبذب جمع المهملات، والتقاط لقطات الكومة عبر ‎--heapsnapshot-signal‎، وقراءة لوحة Retainers وصولاً إلى Map في نطاق الوحدة، ولماذا تجعل إعادة استخدام النسخ في Fluid Compute الكود بلا خادم يتسرب كخادم طويل العمر.

لماذا لا تنجح ذاكرة CDN أبدًا: ملاحظاتنا الميدانية حول Cache-Control وs-maxage وstale-while-revalidate
اقرأ المقال

هندسة الويب المتكاملة

لماذا لا تنجح ذاكرة CDN أبدًا: ملاحظاتنا الميدانية حول Cache-Control وs-maxage وstale-while-revalidate

كانت نقطة نهاية JSON عامة تصل إلى الخادم الأصلي في كل طلب بينما بدت إعدادات CDN صحيحة. هذه ملاحظاتنا الميدانية حول قراءة ترويسات x-vercel-cache وAge بدلًا من لوحات التحكم، والفرق بين max-age وs-maxage وstale-while-revalidate، ومتى تتفوق CDN-Cache-Control على ترويسة Cache-Control وحدها، والحالات الخمس التي تُعطّل التخزين على الحافة بصمت.