توسعه فرانت‌اند

Module Federation: میکروفرانت‌اند با Webpack

اشتراک ماژول هنگام اجرا، مرز اعتماد، خطا و نسخه، استقلال انتشار و نمونه کامل میزبان و remote با Webpack و تابع mount.

1404/12/10 5 دقیقه مطالعه 812 بازدید

مرز انتشار با مرز component فرق دارد

دو تیم ممکن است بخش‌های مختلف یک صفحه را بسازند، اما همچنان در یک برنامه و یک build مشترک کار کنند. این ساختار الزاماً مشکل نیست. وقتی لازم است بخشی از رابط جدا ساخته و منتشر شود و میزبان آن را در زمان اجرا دریافت کند، Module Federation یکی از گزینه‌هاست. تصمیم اصلی دربارهٔ مالکیت و قرارداد میان برنامه‌هاست. جدا کردن یک component به یک remote، به تنهایی استقلال تیم یا کاهش هزینهٔ هماهنگی ایجاد نمی‌کند؛ وابستگی‌های رابط همچنان باید تعریف شوند.

در Webpack، ModuleFederationPlugin امکان expose کردن ماژول از یک build و مصرف آن در build دیگر را فراهم می‌کند. دریافت remote در زمان اجرا، مسیر شکست تازه‌ای هم می‌آورد: میزبان ممکن است سالم باشد اما remote بارگذاری نشود. به همین دلیل loading، خطا و سازگاری نسخه بخشی از معماری‌اند. معرفی رسمی Module Federation مدل container و اشتراک ماژول را توضیح می‌دهد. Microfrontend یک تصمیم سازمانی و فنی گسترده‌تر از نصب این plugin است.

Expose یک API عمومی است

Remote باید بداند چه چیزی را عمومی می‌کند. Export کردن فایل داخلی بدون قرارداد، میزبان را به جزئیات پیاده‌سازی وابسته می‌کند. در نمونه، یک تابع mount می‌گیریم که container و عنوان را دریافت می‌کند و تابع cleanup برمی‌گرداند. میزبان دربارهٔ ساختار داخلی فهرست چیزی نمی‌داند. این API کوچک کمک می‌کند مسئولیت DOM روشن بماند. اگر remote بعداً timer یا subscription اضافه کرد، cleanup باید همان منابع را آزاد کند و قرارداد قبلی را حفظ کند.

برای رابط React می‌توان component به اشتراک گذاشت، اما آن انتخاب موضوع dependency مشترک و نسخهٔ React را هم وارد قرارداد می‌کند. نمونهٔ این مقاله عمداً با DOM معمولی نوشته شده و shared library ندارد تا اصل ارتباط دو build دیده شود. افزودن shared و singleton باید بر اساس نیاز واقعی انجام شود. Singleton به معنی سازگاری خودکار هر نسخه نیست؛ requiredVersion و سیاست انتخاب نسخه هنوز مهم‌اند. مستندات plugin این گزینه‌ها را مشخص می‌کند.

نمونهٔ کامل با میزبان و فهرست مستقل

Node 24 و npm لازم است. در پوشهٔ خالی package.json را در ریشه قرار دهید. دو پوشهٔ host و remote بسازید؛ هر کدام webpack.config.cjs، index.html و src دارند. در host/src فایل index.js و bootstrap.js، و در remote/src فایل index.js و mount.js قرار می‌گیرد. فایل‌های ارائه‌شده با پیشوند توضیحی نام‌گذاری شده‌اند؛ هنگام کپی آن‌ها را به نام مقصد گفته‌شده تغییر دهید. پورت 3000 برای میزبان و 3001 برای remote است و باید آزاد باشند.

{"name":"federation-demo","version":"1.0.0","private":true,"scripts":{"dev:host":"webpack serve --config host/webpack.config.cjs","dev:remote":"webpack serve --config remote/webpack.config.cjs","build:host":"webpack --config host/webpack.config.cjs --mode production","build:remote":"webpack --config remote/webpack.config.cjs --mode production"},"devDependencies":{"webpack":"^5.0.0","webpack-cli":"^6.0.0","webpack-dev-server":"^5.0.0","html-webpack-plugin":"^5.0.0"}}
const path = require('node:path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
  mode: 'development',
  entry: path.resolve(__dirname, 'src/index.js'),
  output: { path: path.resolve(__dirname, 'dist'), publicPath: 'auto', uniqueName: 'demoHost', clean: true },
  devServer: { port: 3000 },
  plugins: [
    new ModuleFederationPlugin({ name: 'host', remotes: { catalog: 'catalog@http://localhost:3001/remoteEntry.js' } }),
    new HtmlWebpackPlugin({ template: path.resolve(__dirname, 'index.html') }),
  ],
};
const path = require('node:path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
  mode: 'development',
  entry: path.resolve(__dirname, 'src/index.js'),
  output: { path: path.resolve(__dirname, 'dist'), publicPath: 'auto', uniqueName: 'demoCatalog', clean: true },
  devServer: { port: 3001 },
  plugins: [
    new ModuleFederationPlugin({ name: 'catalog', filename: 'remoteEntry.js', exposes: { './mount': path.resolve(__dirname, 'src/mount.js') } }),
    new HtmlWebpackPlugin({ template: path.resolve(__dirname, 'index.html') }),
  ],
};
<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>میزبان</title><style>body{font-family:system-ui,sans-serif;background:#edf3f4;color:#19303a;padding:2rem}main{max-width:38rem;margin:auto;background:white;padding:1.5rem;border-radius:1rem}button{font:inherit;padding:.7rem;background:#075c55;color:white;border:0;border-radius:.5rem}:focus-visible{outline:3px solid #b96b00;outline-offset:3px}#slot{margin-block-start:1rem;padding:1rem;border:1px solid #a2b6bf}</style></head><body><main><h1>صفحهٔ میزبان</h1><button type="button" id="load">دریافت فهرست</button><p id="status" role="status">فهرست هنوز دریافت نشده است.</p><section id="slot" aria-label="فهرست محصولات"></section></main></body></html>
<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>فهرست مستقل</title><style>body{font-family:system-ui,sans-serif;padding:2rem;background:#edf3f4;color:#19303a}main{max-width:38rem;margin:auto;padding:1.5rem;background:white;border-radius:1rem}</style></head><body><main><h1>فهرست مستقل</h1><section id="slot" aria-label="فهرست محصولات"></section><p id="status" role="status"></p></main></body></html>
import('./bootstrap.js').catch(() => {
  document.getElementById('status').textContent = 'رابط میزبان آماده نشد.';
});
const button = document.getElementById('load');
const status = document.getElementById('status');
const slot = document.getElementById('slot');
let dispose;
button.addEventListener('click', async () => {
  button.disabled = true;
  slot.setAttribute('aria-busy', 'true');
  status.textContent = 'در حال دریافت فهرست…';
  try {
    const module = await import('catalog/mount');
    dispose?.();
    dispose = module.mount(slot, { heading: 'محصولات نمونه' });
    status.textContent = 'فهرست دریافت شد.';
  } catch {
    status.textContent = 'فهرست در دسترس نیست. پس از آماده شدن سرویس، صفحه را دوباره بارگذاری کنید.';
  } finally {
    slot.removeAttribute('aria-busy');
  }
});
window.addEventListener('pagehide', () => dispose?.(), { once: true });
import('./mount.js').then(({ mount }) => {
  const dispose = mount(document.getElementById('slot'), { heading: 'محصولات نمونه' });
  window.addEventListener('pagehide', dispose, { once: true });
}).catch(() => { document.getElementById('status').textContent = 'فهرست آماده نشد.'; });
export function mount(container, { heading }) {
  const section = document.createElement('div');
  const title = document.createElement('h2');
  title.textContent = heading;
  const list = document.createElement('ul');
  for (const name of ['دفتر نمونه', 'مداد نمونه']) {
    const item = document.createElement('li');
    item.textContent = name;
    list.append(item);
  }
  section.append(title, list);
  container.append(section);
  return () => section.remove();
}

npm install را در ریشه اجرا کنید تا dependency و lock واقعی تولید شود. در دو terminal جدا، npm run dev:remote و npm run dev:host را اجرا کنید. صفحهٔ میزبان در localhost:3000 و فهرست مستقل در localhost:3001 است. با دکمهٔ میزبان، ماژول catalog/mount دریافت می‌شود. npm run build:remote و npm run build:host خروجی‌های جدا در پوشهٔ dist هر برنامه می‌سازند. نشانی remote در config این مثال محلی است؛ خروجی production بدون تغییر نشانی و روش میزبانی، نمونهٔ آمادهٔ استقرار عمومی نیست.

در تنظیم expose مسیر فایل با resolve تعیین شده تا به پوشهٔ جاری terminal وابسته نباشد. HtmlWebpackPlugin اسکریپت‌های لازم را به HTML خروجی اضافه می‌کند. نام uniqueName دو build متفاوت است تا runtimeها هویت یکسان نگیرند. publicPath برابر auto کمک می‌کند مسیر chunk بر اساس محل script تعیین شود، اما مقصد واقعی انتشار و تنظیم server همچنان باید بررسی شوند. این فایل‌ها روش اجرا را کامل می‌کنند و این مقاله گزارشی از اجرای مرورگر یا build ارائه نمی‌دهد.

مرز غیرهم‌زمان را حفظ کنید

فایل index میزبان، bootstrap را با import غیرهم‌زمان دریافت می‌کند. در الگوهای federation این مرز فرصت آماده شدن runtime و وابستگی‌های لازم را فراهم می‌کند. مصرف eager و هم‌زمان shared dependencyها می‌تواند خطاهای متفاوتی بسازد؛ تغییر آن باید با شناخت graph انجام شود. در این نمونه import remote پس از کلیک رخ می‌دهد، بنابراین صفحهٔ اصلی پیش از دریافت فهرست قابل خواندن است. دکمه دوباره فعال نمی‌شود و در خطا از کاربر می‌خواهد پس از آماده شدن سرویس reload کند؛ ادعای retry کامل نداریم.

Promise مربوط به import می‌تواند برای مشکل دانلود یا اجرا reject شود. catch در میزبان پیام خوانا نشان می‌دهد و aria-busy در finally پاک می‌شود. این مدیریت خطای کوتاه جای گزارش‌گیری عملیاتی، timeout یا retry با سیاست مناسب را نمی‌گیرد. نتیجهٔ شکست import ممکن است در runtime حفظ شود، بنابراین کلیک دوباره همیشه بازیابی را تضمین نمی‌کند. مقالهٔ Promise و کنترل خطا برای طراحی دقیق‌تر مسیر غیرهم‌زمان مفید است.

Remote کد قابل اعتماد در صفحهٔ میزبان است

کدی که از remote در همان صفحه اجرا می‌شود، sandbox مستقل محسوب نمی‌شود. به DOM و امکانات مجاز همان context دسترسی دارد. بنابراین انتخاب نشانی remote، زنجیرهٔ انتشار و مجوز تغییر آن بخشی از اعتماد محصول هستند. دادن امکان انتخاب URL آزاد به دادهٔ کاربر، مرز خطرناکی می‌سازد. اگر واقعاً جداسازی امنیتی لازم است، باید معماری مناسب دیگری بررسی شود. Module Federation برای اشتراک کد است و به خودی خود isolation امنیتی ایجاد نمی‌کند.

سیاست CSP، HTTPS و دسترسی شبکه باید با روش دریافت script سازگار باشند. باز کردن بی‌قید همهٔ originها برای رفع خطای محلی، سیاست production نیست. در مثال localhost نشانی صریح دارد و چیزی از ورودی کاربر ساخته نمی‌شود. همچنین remote با textContent متن را وارد DOM می‌کند و HTML دریافتی را اجرا نمی‌کند. این انتخاب خطر تزریق متن را محدود می‌کند، ولی اعتماد به خود JavaScript دریافت‌شده همچنان لازم است. Secret هم نباید در bundle هیچ‌یک از برنامه‌های مرورگر باشد.

CSS و طراحی مشترک به قرارداد احتیاج دارند

دو برنامه ممکن است stylesheetهای global متفاوت داشته باشند. یک selector عمومی برای button یا h2 می‌تواند ظاهر remote را در میزبان عوض کند. نمونه فقط CSS میزبان و فهرست ساده دارد تا این رابطه دیده شود؛ برای محصول واقعی باید scope، token و قرارداد component تعیین شوند. Shadow DOM یا نام‌گذاری محدود، بسته به معماری گزینه‌اند، اما هر کدام اثر خود را روی style و دسترس‌پذیری دارند. مقالهٔ Custom Properties و Scope دربارهٔ مرز تنظیم ظاهر توضیح می‌دهد.

کاربر رابط را یک محصول می‌بیند، حتی اگر پشت آن چند تیم باشد. Focus، عنوان‌ها، زبان متن و پیام خطا باید هماهنگ باشند. دریافت remote نباید focus را بدون دلیل جابه‌جا کند یا صفحه را هنگام خواندن بپراند. در مثال، فهرست پس از دکمه ظاهر می‌شود و پیام status نتیجه را اعلام می‌کند. برای رابط پیچیده‌تر، بخش دسترس‌پذیری باید بخشی از قرارداد میان میزبان و remote باشد، نه مسئولیتی که هر تیم به دیگری واگذار کند.

استقلال انتشار به معنی نبود هماهنگی نیست

اگر remote تابع mount را حذف کند یا شکل ورودی را تغییر دهد، میزبان قدیمی می‌شکند؛ حتی اگر دو build جدا منتشر شوند. باید دورهٔ سازگاری، نسخهٔ قرارداد یا نشانی نسخه‌دار تعریف شود. نگه داشتن فایل‌های chunk قدیمی هنگام انتشار می‌تواند برای sessionهای باز مهم باشد. پاک کردن فوری همهٔ خروجی‌های قبلی ممکن است صفحه‌ای را که هنوز remoteEntry قبلی دارد به chunk گم‌شده برساند. روش cache و retention باید همراه مسیر انتشار طراحی شود.

برای همین، pipeline هر برنامه باید خروجی مشخص و قرارداد مصرف‌کننده را بررسی کند. نوشتهٔ CI/CD فرانت‌اند دربارهٔ artifact و کنترل انتشار توضیح می‌دهد. Monorepo هم گزینهٔ متفاوتی برای هماهنگی تغییرهاست و در راهنمای Turborepo بررسی شده؛ داشتن مخزن مشترک و federation الزاماً متضاد نیستند. انتخاب ابزار باید با مرز تیم، نیاز انتشار و هزینهٔ بارگذاری کد مرتبط باشد.

از یک مرز قابل نگهداری شروع کنید

اگر چند تیم هنوز قرارداد UI و مالکیت داده ندارند، جدا کردن build ممکن است اختلاف‌ها را بیشتر آشکار کند. ابتدا یک بخش محدود با API کوچک، fallback روشن و مسئول انتشار مشخص انتخاب کنید. هزینهٔ شبکه، نسخه و گزارش خطا را در همان بخش بشناسید. اگر نیاز واقعی فقط اشتراک چند تابع است، package مشترک ساده‌تر هم می‌تواند کافی باشد. Federation زمانی مفید است که دریافت مستقل ماژول در زمان اجرا، مسئلهٔ مشخصی از محصول و سازمان را پاسخ دهد.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!