ES Modules و سیستم Module Resolution؛ زیر پوست import/export

اگه یه مدت با جاوااسکریپت مدرن کار کرده باشی، حتماً صدها بار import و export نوشتی، بدون این‌که واقعاً بدونی پشت صحنه چه اتفاقی می‌افته. این مقاله رو نوشتم که دقیقاً همین رو باز کنیم: ماژول‌ها چطور resolve می‌شن، چرا ESM با CommonJS فرق داره، Tree Shaking چطور ممکن می‌شه، Circular Dependency چرا گاهی کار می‌کنه و گاهی نه، و اون مفهوم عجیب "Live Binding" دقیقاً چیه.

یه‌کم تاریخچه، تا بدونیم از کجا شروع شد

جاوااسکریپت از اول اصلاً سیستم ماژول نداشت. برای همین صنعت مجبور شد خودش دست به کار بشه. اول با IIFE (توابعی که فوراً اجرا می‌شدن) سعی می‌کردن جلوی آلوده شدن global scope رو بگیرن؛ بعد CommonJS اومد که مخصوص Node.js بود و synchronous کار می‌کرد؛ بعد AMD و RequireJS برای مرورگر (که async بودن) اومدن؛ و بالاخره در سال ۲۰۱۵ خودِ استاندارد ECMAScript، ماژول رو به‌عنوان بخشی از خودِ زبان معرفی کرد؛ همون چیزی که الان بهش می‌گیم ES Modules یا ESM.

IIFE عصر درد CommonJS 2010 · سمت سرور AMD / RequireJS async، سمت مرورگر ES Modules 2015 · بخشی از خودِ زبان

هرکدوم از این‌ها اومدن یه مشکل مشخص رو حل کنن: IIFE جلوی global pollution رو گرفت، CommonJS یه syntax استاندارد برای export/import داد، AMD مشکل synchronous بودن رو تو مرورگر حل کرد. اما ESM اولین باری بود که خودِ زبان جاوااسکریپت، بدون نیاز به هیچ ابزار یا کتابخونه‌ای، از import/export پشتیبانی کرد.

import/export پشت صحنه چطور کار می‌کنن؟

نکته‌ی مهمی که خیلی‌ها ازش خبر ندارن اینه که وقتی موتور جاوااسکریپت به یه فایل ماژول می‌رسه، بلافاصله کدش رو اجرا نمی‌کنه. اجرای یک ماژول سه فاز مشخص داره:

  1. Construction (Parsing): فایل خونده و parse می‌شه، و یک "Module Record" ساخته می‌شه که لیست importها و exportهای اون فایل رو نگه می‌داره. تو همین فاز، موتور می‌فهمه هر ماژول به کدوم ماژول‌های دیگه وابسته‌ست.
  2. Instantiation (Linking): حالا موتور برای هر export یه "جایگاه حافظه" (memory slot) کنار می‌ذاره، و importهای هر ماژول رو به همون جایگاه‌های export ماژول مبدأ وصل می‌کنه. توجه کن: تو این فاز هنوز هیچ کدی اجرا نشده، فقط اتصال برقرار شده.
  3. Evaluation: حالا بدنه‌ی واقعی هر ماژول، به ترتیبِ گراف وابستگی (اول برگ‌های گراف، بعد ریشه)، اجرا می‌شه و مقادیر واقعی تو همون جایگاه‌های حافظه قرار می‌گیرن.
Parsing ساخت Module Record Linking اتصال import به export Evaluation اجرای واقعیِ کدِ هر ماژول

دقیقاً به همین دلیله که import declarationها "hoisted" هستن؛ یعنی حتی اگه یه import رو وسط فایل بنویسی، انگار از اول فایل نوشته شده، چون فاز Linking قبل از هر خط کد دیگه‌ای انجام می‌شه.

// math.js
export const PI = 3.14159;

export function square(x) {
  return x * x;
}

// main.js
import { PI, square } from './math.js';

console.log(square(PI)); // موتور اول Linking رو انجام داد، بعد این خط اجرا شد

تفاوت ESM و CommonJS

CommonJS همون سیستمیه که Node.js از اول باهاش شروع کرد؛ با require() و module.exports. ولی ESM از پایه با یه فلسفه‌ی متفاوت طراحی شده: static بودن. یعنی موتور، بدون اجرای هیچ کدی، فقط با خوندن متن فایل می‌تونه بفهمه هر ماژول چی export/import می‌کنه.

ویژگی CommonJS ES Modules
Syntax require() / module.exports import / export
نوع لود شدن Synchronous Asynchronous
قابلیت آنالیز Dynamic (فقط موقع اجرا معلوم می‌شه) Static (قبل از اجرا معلوم می‌شه)
Tree Shaking ✗ عملاً ممکن نیست ✓ ممکنه
مقدار import‌شده یه کپی از مقدار Live Binding (رفرنس زنده)
Top-level await ✗ ✓
Circular import ممکنه export ناقص/خالی بگیری به‌لطف Live Binding معمولاً درست کار می‌کنه
// ==== CommonJS ====
// math.js
function add(a, b) { return a + b; }
module.exports = { add };

// main.js
const { add } = require('./math.js'); // اجرا می‌شه و یه کپی از خروجی می‌گیری

// ==== ES Modules ====
// math.js
export function add(a, b) { return a + b; }

// main.js
import { add } from './math.js'; // یه رفرنس زنده به همون binding می‌گیری

Static Analysis و Tree Shaking

چون تو ESM باید import و export همیشه در سطح بالای فایل نوشته بشن (نه داخل if یا تابع)، و اسم چیزی که export/import می‌شه باید یه شناسه‌ی مشخص باشه (نه یه متغیر یا رشته‌ی داینامیک)، ابزارهایی مثل Webpack، Rollup یا esbuild می‌تونن قبل از اجرای کد، کل گراف وابستگی رو بسازن و ببینن دقیقاً کدوم export از کدوم فایل واقعاً جایی استفاده می‌شه. هر چیزی که استفاده نشه، از باندل نهایی حذف می‌شه؛ به این می‌گیم Tree Shaking.

تو CommonJS این کار عملاً غیرممکنه، چون require() رو می‌شه هرجا (حتی شرطی و داینامیک) صدا زد، و module.exports می‌تونه یه آبجکتِ کاملاً داینامیک باشه که فقط با اجرای واقعیِ کد معلوم می‌شه چی توشه.

utils.js یک فایل با سه export export sum export average export median import { sum } from utils باندل نهایی average و median هیچ‌جا import نشدن ⇒ در Tree Shaking حذف می‌شن

Circular Dependencyها (وابستگیِ حلقوی)

وقتی ماژول A چیزی از B وارد می‌کنه، و B هم چیزی از A وارد می‌کنه، گراف وابستگی یک حلقه (cycle) پیدا می‌کنه. تو دنیای ایده‌آل این گراف باید acyclic باشه، ولی تو پروژه‌های واقعی گاهی اجتناب‌ناپذیره.

a.js b.js import b import a

نکته‌ی جالب اینه که Circular Import همیشه خطا نمی‌ده. مقدار یه import فقط وقتی واقعاً خونده می‌شه که "استفاده" بشه، نه لحظه‌ی import شدن. اگه استفاده‌ی سینکرون از یه مقدار، قبل از اینه که مقدارش initialize شده باشه، اون‌وقت خطا می‌گیری:

// a.js (نقطه‌ی ورود)
import { b } from './b.js';
console.log(b); // ❌ ReferenceError: Cannot access 'b' before initialization
export const a = 2;

// b.js
import { a } from './a.js';
console.log(a); // این خط اصلاً به این نقطه نمی‌رسه
export const b = 1;

ولی اگه همون استفاده رو به تعویق بندازیم (مثلاً داخل یه تابع یا setTimeout)، تا زمانی که همه‌ی ماژول‌ها evaluate شده باشن، مشکلی پیش نمی‌آد:

// a.js
import { b } from './b.js';

setTimeout(() => console.log(b), 0); // ✅ الان دیگه b مقدار داره
export const a = 2;

// b.js
import { a } from './a.js';

setTimeout(() => console.log(a), 0); // ✅
export const b = 1;

راه‌حل‌های رایج برای فرار از Circular Dependency: ادغام دو ماژول تو یکی، انتقال کد مشترک به یه ماژول سوم، یا تأخیر انداختن استفاده از importِ حلقوی به داخل یه تابع.

Live Binding دقیقاً یعنی چه؟

اینجا شاید مهم‌ترین تفاوت مفهومیِ ESM با CommonJS باشه. تو CommonJS، وقتی require() می‌کنی، یه کپی از مقدارِ اون لحظه‌ی module.exports می‌گیری. اما تو ESM، وقتی import می‌کنی، یه رفرنسِ زنده به همون جایگاه حافظه‌ای می‌گیری که ماژولِ export‌کننده داره؛ یعنی اگه ماژول export‌کننده مقدار متغیرش رو عوض کنه، تو import‌کننده هم بلافاصله همون تغییر رو می‌بینی، بدون این‌که دوباره چیزی import کنی.

// counter.js
export let count = 0;

export function increment() {
  count++; // این تغییر مستقیماً روی همون binding اعمال می‌شه
}

// main.js
import { count, increment } from './counter.js';

console.log(count); // 0
increment();
console.log(count); // 1  — چون count یک رفرنس زنده‌ست، نه یک کپی!
CommonJS: کپیِ مقدار count = 0 کپی count = 0 حتی اگه اصل عوض بشه، این ثابت می‌مونه ESM: رفرنسِ زنده count همون slot هر دو طرف به یک جایگاه حافظه اشاره می‌کنن

یه نکته‌ی مهم: خودِ import‌کننده اجازه ندارد مقدار متغیر import‌شده را دستی بازنویسی کند (شبیه یک const رفتار می‌کند)؛ فقط ماژولِ export‌کننده حق تغییر دادنش را دارد. دقیقاً همین مکانیزم است که باعث می‌شود خیلی از سناریوهای Circular Import در ESM بدون مشکل کار کنند؛ چون حتی اگر مقدار یک export موقع Linking هنوز آماده نباشد، خودِ binding از قبل وجود دارد و فقط بعداً پر می‌شود.

جمع‌بندی

ESM فقط یه syntax جدید برای import/export نیست؛ یه مدل اجرایی کاملاً متفاوته که به‌خاطر Static بودنش، هم Tree Shaking رو ممکن می‌کنه، هم رفتار قابل‌پیش‌بینی‌تری تو Circular Dependencyها می‌ده، و هم به‌لطف Live Binding، ماژول‌ها می‌تونن state زنده باهم به اشتراک بذارن. شناختِ این سه فاز (Parsing → Linking → Evaluation) کلید فهمیدنِ خیلی از رفتارهای عجیبِ ماژول‌هاست که تو کار روزمره باهاشون برخورد می‌کنی.

منابع