أنماط تصميم تطبيقات الويب › أنماط JavaScript 1 / 3 السابق التالي

نمط الوحدة (module)

الوحدة (module) ملف يملك جزءًا من السلوك، ويحدد ما ينبغي مشاركته، ويحتفظ بما عداه لنفسه. اليوم، تعني «وحدة» في JavaScript وحدات ES — مواصفة حقيقية مدمجة في اللغة، ويدعمها كل متصفح حديث (browser) وNode.js بصورة أصلية. أما الأنماط والمقايضات التي كانت تحدد أنظمة الوحدات قبل 2015، مثل الإغلاقات التي تحاكي الخصوصية ومحملات AMD وغلاف CommonJS، فبقيت فضولًا تاريخيًا في معظمها. البديل الذي حل محلها أكثر إثارة للاهتمام، وهو محور هذا المقال.

النموذج الذهني مختصر:

  • الوحدة ملف، والملف نطاق الوحدة (module scope) الخاص به.
  • كل ما لا يتم تصديره عبر export خاص بالملف.
  • تُقوَّم الوحدة مرة واحدة لكل نطاق واقع (realm). يرى كل مستورد الارتباطات نفسها.
  • عبارات import (الاستيراد) ثابتة ومرفوعة؛ أما import() فديناميكية وتعيد وعدًا (promise).

تمسك بهذه النقاط الأربع؛ فكل ما عداها مجرد تنويعات عليها.

الصادرات والاستيرادات، الصيغة الحديثة

// inventory.js

const cache = new Map();

export function get(sku) {

return cache.get(sku);

}

export async function refresh() {

const response = await fetch("/api/inventory");

const items = await response.json();

cache.clear();

for (const item of items) cache.set(item.sku, item);

}

export const ready = refresh(); // top-level await also works in modules

هناك أمران لاحظتهما. أولًا، لا يمكن بلوغ cache من خارج هذا الملف. لا حاجة إلى Object.freeze ولا حيلة إغلاق ولا اصطلاح تسمية؛ فاللغة تمنحك الخصوصية مجانًا. ثانيًا، ready هي نتيجة تعبير await على المستوى الأعلى، وهي ميزة من ES2022: إذا أراد المستورد انتظار الجلب الأولي، فيمكنه تنفيذ await على التصدير.

على جانب المستهلك:

// app.js

import { get, refresh, ready } from "./inventory.js";

await ready;

console.log(get("ABC-123"));

تعد الصادرات المسماة الأسلوب الافتراضي منذ عام 2024 فصاعدًا. ولا تزال الصادرات الافتراضية موجودة (export default Foo) ولها مكانها، وهي أكثر نفعًا عندما يمثل الملف شيئًا واحدًا، مثل مكون React أو صنف أو نقطة دخول مكتبة. أما ملفات الأدوات متعددة الدوال، فصادراتها المسماة أسهل في إعادة الهيكلة وإزالة الشيفرة الميتة (tree shaking).

وحدات ESM في Node.js

يقرر Node ما إذا كان الملف وحدة بالنظر إلى ثلاثة أمور، بالترتيب:

  • امتداد الملف. تُعد .mjs وحدة دائمًا، بينما يُعد .cjs بصيغة CommonJS دائمًا.
  • أقرب ملف package.json. إذا كان يحتوي على "type": "module"، تصبح ملفات .js وحدات. وإذا كان يحتوي على "type": "commonjs" أو أهمل الحقل، تصبح ملفات .js بصيغة CommonJS.
  • الخيار --input-type لـ stdin.

تبدو حزمة Node حديثة هكذا:

{

"name": "@example/inventory",

"version": "1.0.0",

"type": "module",

"exports": {

".": "./dist/index.js",

"./schema": "./dist/schema.js",

"./package.json": "./package.json"

},

"imports": {

"#config": "./src/config.js",

"#test/*": "./test/helpers/*.js"

}

}

حقل exports هو البديل الحديث للحقل main. وهو يؤدي أمرين مهمين: يتحكم في المسارات الفرعية التي يمكن للمستهلكين استيرادها (كل ما لم يُدرج خاص بالحزمة)، ويمكنه تعيين المسار الفرعي نفسه إلى ملفات مختلفة حسب البيئة (import مقابل require، والمتصفح مقابل node، والتطوير مقابل الإنتاج).

"exports": {

".": {

"types":  "./dist/index.d.ts",

"browser": "./dist/index.browser.js",

"node":    "./dist/index.node.js",

"default": "./dist/index.js"

}

}

هذه صادرات مشروطة. يختار وقت التشغيل أول شرط مطابق من الأعلى إلى الأسفل — لذا يهم الترتيب، وينبغي أن يأتي "types" أولًا لأن TypeScript يقرأه.

يوفّر حقل imports ميزة للاستيراد بمسارات فرعية داخل حزمتك. في أي مكان داخل الحزمة، يحل import config from "#config" إلى كل ما تعين إليه imports["#config"]. وهو بديل أنظف من مسارات مثل ../../../../config.js، ويمنحك مكانًا واحدًا لتبديل التنفيذ أثناء الاختبار.

التوافق بين ESM وCJS، باختصار

ستصادف هذا الأمر. القواعد هي:

  • استيراد ESM لـ CJS: مسموح. يصبح module.exports الخاص بـ CJS هو التصدير default في ESM. تعمل الاستيرادات المسماة مع الصادرات المسماة التي يمكن تحليلها ثابتًا، لكن أي عنصر ديناميكي في module.exports لا يمكن الوصول إليه إلا عبر الافتراضي.
  • CJS الذي يتطلب ESM: كان محظورًا تاريخيًا، لكن Node 22+ يسمح بـrequire() لـ ESM عندما لا تحوي الوحدة الهدف await على المستوى الأعلى. وإلا، فاستخدم await import() داخل دالة غير متزامنة.
  • حزم ثنائية: انشر الصيغتين من حزمة واحدة باستخدام الصادرات المشروطة. أبقِ الحالة خارج الحزمة، وإلا فستحصل في النهاية ستحصل على نسختين منها في وقت التشغيل — وهي «خطر الحزمة الثنائية».

يبدو التصدير الثنائي العامل هكذا:

"exports": {

".": {

"import": "./dist/index.mjs",

"require": "./dist/index.cjs"

}

}

تؤتمت أدوات البناء (tsup وunbuild وrollup) إنشاء الحزمتين تلقائيًا.

وحدات ESM في المتصفح

يخبر `` المتصفح بأن يحلل الملف كوحدة. تُؤجل الوحدات افتراضيًا، وتُنفذ بالترتيب، وتُجلب باستخدام CORS:

<script type="module" src="/app.js"></script>

<script nomodule src="/legacy-bundle.js"></script>

تُعد nomodule خطة بديلة للمتصفحات التي لا تدعم الوحدات، وهي أقل عددًا يومًا بعد يوم؛ ويمكن لمعظم المشاريع الاستغناء عن الحزمة (bundle) القديمة تمامًا.

خرائط الاستيراد (import maps)

أهم تحسين في جانب المتصفح هو خرائط الاستيراد. تتيح لك استخدام المواصفات المجردة (import { x } from "lodash-es") في المتصفح من دون أداة تجميع، إذ تعطي المتصفح تعيين JSON من الاسم إلى عنوان URL.

<script type="importmap">

{

"imports": {

"lit": "https://cdn.jsdelivr.net/npm/lit@3/index.js",

"@app/": "/src/app/"

},

"scopes": {

"/legacy/": { "lit": "https://cdn.jsdelivr.net/npm/lit@2/index.js" }

}

}

</script>

<script type="module">

import { LitElement } from "lit";

import { Router } from "@app/router.js";

</script>

تتيح لك scopes تجاوز التعيينات تحت بادئات URL محددة، وهو أمر مفيد عندما يحتاج جزء من تطبيقك إلى إصدار أقدم من تبعية. تتوفر خرائط الاستيراد في كل متصفح حديث منذ عام 2023.

التحميل المسبق للوحدات

يوقف import الثابت التنفيذ بينما تُجلب التبعية. وللوحدات الحرجة، تُستخدم `` لتسخين ذاكرة المتصفح المؤقتة (cache) حتى يُحل الاستيراد فورًا:

<link rel="modulepreload" href="/app.js">

<link rel="modulepreload" href="/router.js">

<link rel="modulepreload" href="/inventory.js">

يجلب المتصفح الوحدات المدرجة ويحللها ويصرّفها بالتوازي مع بقية الصفحة. وحين تعمل app.js، تكون تبعياتها موجودة بالفعل في الذاكرة.

الاستيرادات الديناميكية

تعيد import()، التي تشبه الدوال، وعدًا باسم مساحة الوحدة. وهي الأداة الأساسية لتقسيم الشيفرة (code splitting) حسب المسارات وتحميل الميزات عند الحاجة.

// Route-based: load the editor only when the user navigates to /edit

router.on("/edit/:id", async ({ id }) => {

const { mount } = await import("./editor.js");

mount(document.querySelector("#root"), { id });

});

// Interaction-based: load a heavy library only when the user clicks

button.addEventListener("click", async () => {

const { default: confetti } = await import("canvas-confetti");

confetti();

});

تعرف أدوات التجميع استدعاءات import() وتقسم الهدف تلقائيًا إلى حزمة (chunk) خاصة به. فكل أداة تجميع حديثة، من Vite وesbuild وRollup وwebpack وRspack، تفعل ذلك من دون إعداد.

يمكن دمج import() مع Promise.all للتوازي:

const [{ default: heavy }, { utils }] = await Promise.all([

import("./heavy.js"),

import("./utils.js"),

]);

كما تقبل متغيرات، ما يتيح لك حساب المسار في وقت التشغيل:

const lang = navigator.language.split("-")[0];

const { messages } = await import(`./i18n/${lang}.js`);

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

سمات الاستيراد

ميزة من عام 2024 (في المرحلة 3، وشائعة في V8 وJavaScriptCore) هي سمات الاستيراد (import attributes)، التي تتيح لك استيراد موارد غير مكتوبة بلغة JavaScript عبر تحديد نوعها:

import config from "./config.json" with { type: "json" };

import sheet  from "./styles.css"   with { type: "css" };

الصيغة الديناميكية تأخذ السمات كوسيط ثانٍ:

const data = await import("./data.json", { with: { type: "json" } });

تستبدل صيغة with كلمة assert الأسبق من المقترح الأصلي. إذا كانت لديك شيفرة أقدم تستخدم assert { type: "json" }، فحدّثها؛ إذ تُزال assert.

تمثل وحدات JSON حالة الاستخدام الأبرز، لكن الصيغة نفسها تتوسع نحو CSS Module Scripts، وفي المقترحات نحو وحدات WebAssembly.

أدوات التجميع وإزالة الشيفرة الميتة ومخرجات البناء

توفر ESM الأصلية تجربة تطوير رائعة لكنها تشحن ملفات صغيرة كثيرة. في الإنتاج، تدمج أدوات التجميع الوحدات وتحذف ما لا يستخدم. تتمثل خطوة التقليم — إزالة الشيفرة الميتة (tree shaking) — في بعض ثوابت مستوى الوحدة:

  • تكون الاستيرادات والصادرات ثابتة، ما يتيح لأداة التجميع تحديد الصادرات المشار إليها ثابتًا.
  • تكون الوحدة خالية من الآثار الجانبية، أو تضع الحزمة "sideEffects": false (أو قائمة بالملفات ذات الآثار الجانبية) في package.json.
  • تُتابع عمليات إعادة التصدير (export { foo } from "./bar.js") عبر المستويات.

لقد تقارب النظام البيئي:

أداة التجميع المحرك ملاحظات
Vite Rollup للبناء + esbuild للتطوير الافتراضية لتطبيقات SPA الجديدة؛ ESM أصلية في التطوير
esbuild Go الأصلية سريعة للغاية؛ تُستخدم غالبًا كمكتبة من أدوات أخرى
Rollup JavaScript مخرجات المكتبات هي مجال قوتها
Rspack منفذ webpack إلى Rust بديل مباشر لـwebpack وأسرع بكثير
Turbopack Rust أداة تجميع Next.js؛ تزايدة وذاكرة مؤقتة دائمة
Parcel JavaScript / Rust بلا إعداد

لا يلزمك اختيار «الأسرع»؛ فاختر ما يناسب إطارك. المخرجات عبر أدوات التجميع الحديثة متشابهة بما يكفي، ما يعني أن فروق الأداء (performance) تقع غالبًا وقت البناء لا وقت التشغيل.

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

HMR (استبدال الوحدات الساخنة)

تلائم ESM HMR بصورة تكاد تكون مثالية لأن رسم الوحدات صريح. عند تغيير ملف، يمكن لخادم التطوير استبدال تلك الوحدة من دون إعادة تحميل الصفحة، ويمكن للإطار أن يقرر كيف يعيد تشغيل أي شيفرة تعتمد عليها.

الراية القياسية في Vite ومعظم أدوات التجميع الأصلية في ESM هي undefined:

// some-feature.js

export function mount(root) { /* ... */ }

if (import.meta.hot) {

import.meta.hot.accept((newModule) => {

// Re-run with the updated implementation

newModule?.mount(document.querySelector("#root"));

});

import.meta.hot.dispose(() => {

// Tear down state before the new module takes over

document.querySelector("#root").innerHTML = "";

});

}

import.meta هو كائن البيانات الوصفية لكل وحدة — وهو أيضًا المكان الذي تجد فيه import.meta.url (عنوان URL للوحدة الحالية)، وفي Node تجد import.meta.dirname / import.meta.filename (أُضيفت في Node 21+).

المزالق الشائعة

الارتباطات الحية تفاجئ الناس

صادرات ESM هي ارتباطات حية (live bindings)، لا نسخ قيم. إذا صدّرت وحدة قيمة let، فسيرى المستوردون القيمة الحالية، لا قيمة وقت الاستيراد. ويعمل CommonJS بصورة معاكسة:

// counter.js

export let count = 0;

export function inc() { count++; }

// app.js

import { count, inc } from "./counter.js";

inc();

console.log(count); // 1, not 0

عادةً ما يكون هذا هو السلوك الذي تريده، لكنه قد يفاجئ القادمين من CommonJS.

الدورات الدائرية تعمل، مع تحذيرات

تستورد A الوحدة B، وتستورد B الوحدة A. تتعامل ESM مع هذا — فكلتا الوحدتين تُقوَّمان جزئيًا قبل أن تكتمل أي منهما — لكن إذا أشرت إلى تصدير من الوحدة الشريكة أثناء التهيئة (لا داخل دالة)، فقد ترى undefined لارتباطات لم تُسنَد بعد. أعد الهيكلة لتجنب الوصول عبر دورة دائرية وقت التهيئة.

اختلاف حل المسارات عن CommonJS

حل ESM في Node أكثر صرامة: الامتدادات مطلوبة (./foo.js لا ./foo)، وفهارس المجلدات ليست ضمنية، وقواعد مواصفة الوحدة تتبع مواصفة WHATWG URL. يضبط خيار TypeScript moduleResolution: "bundler" أو "node16" المُصرّف ليطابق ما سيفعله حل وقت التشغيل فعلًا.

انتظار المستوى الأعلى يعيق المستوردين

يوقف await في أعلى الوحدة اكتمالها، ما يعني أن كل مستورد ينتظر أيضًا. ويكون أنيقًا عند استخدامه بحكمة (تحميل الإعدادات، وكشف القدرات)، لكنه يسرّل بدء التطبيق بعناية (استدعاء شبكة في أعلى كل وحدة طرفية).

متى تقسم إلى وحدة جديدة

الوحدة هي الوحدة المناسبة عندما تملك جزءًا من الحالة أو السلوك له واجهة برمجية نظيفة. وفيما يلي بعض القواعد العملية:

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

المراجع