Module Federation

الدافع

ينبغي أن تشكل عدة builds منفصلة تطبيقًا واحدًا. تعمل هذه builds المنفصلة مثل containers، ويمكنها كشف الكود واستهلاكه فيما بينها، مما ينشئ تطبيقًا واحدًا موحدًا.

يُعرف هذا غالبًا باسم Micro-Frontends، لكنه لا يقتصر عليها.

مفاهيم منخفضة المستوى

نميز بين الوحدات المحلية والوحدات البعيدة. الوحدات المحلية هي وحدات عادية تُعد جزءًا من build الحالي. أما الوحدات البعيدة فهي وحدات ليست جزءًا من build الحالي، لكنها تُحمّل وقت التشغيل من remote container.

يُعد تحميل الوحدات البعيدة عملية غير متزامنة. عند استخدام وحدة بعيدة، توضع هذه العمليات غير المتزامنة داخل عملية أو عمليات تحميل chunk التالية الواقعة بين الوحدة البعيدة ونقطة الدخول. لا يمكن استخدام وحدة بعيدة بدون عملية تحميل chunk.

عادةً تكون عملية تحميل chunk عبارة عن استدعاء import()، لكن التركيبات الأقدم مثل require.ensure أو require([...]) مدعومة أيضًا.

يُنشأ container عبر container entry، وهو يتيح وصولًا غير متزامن إلى الوحدات المحددة. ينقسم الوصول المكشوف إلى خطوتين:

  1. تحميل الوحدة (غير متزامن)
  2. تقييم الوحدة (متزامن).

تتم الخطوة الأولى أثناء تحميل chunk. وتتم الخطوة الثانية أثناء تقييم الوحدة بالتداخل مع الوحدات الأخرى، سواء كانت محلية أو بعيدة. بهذه الطريقة، لا يتأثر ترتيب التقييم عند تحويل وحدة من محلية إلى بعيدة أو العكس.

يمكن تداخل containers. يمكن لـ containers استخدام وحدات من containers أخرى. كما أن التبعيات الدائرية بين containers ممكنة أيضًا.

مفاهيم عالية المستوى

يعمل كل build كـ container ويستهلك أيضًا builds أخرى بوصفها containers. بهذه الطريقة، يستطيع كل build الوصول إلى أي وحدة مكشوفة أخرى عبر تحميلها من container الخاص بها.

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

يسمح خيار packageName بتعيين اسم حزمة للبحث عن requiredVersion. يُستنتج ذلك تلقائيًا افتراضيًا لطلبات الوحدات، ويمكن تعيين requiredVersion إلى false عندما تريد تعطيل الاستنتاج التلقائي.

اللبنات الأساسية

ContainerPlugin (منخفض المستوى)

تنشئ هذه الإضافة container entry إضافيًا مع الوحدات المكشوفة المحددة.

ContainerReferencePlugin (منخفض المستوى)

تضيف هذه الإضافة مراجع محددة إلى containers على هيئة externals، وتسمح باستيراد الوحدات البعيدة من هذه containers. كما تستدعي override API لهذه containers لتزويدها بالتجاوزات. تُقدم التجاوزات المحلية، عبر __webpack_override__ أو override API عندما يكون build نفسه container، وكذلك التجاوزات المحددة إلى كل containers المشار إليها.

ModuleFederationPlugin (عالي المستوى)

تجمع ModuleFederationPlugin بين ContainerPlugin و ContainerReferencePlugin.

أهداف المفهوم

  • يجب أن يكون من الممكن كشف واستهلاك أي نوع وحدة يدعمه webpack.
  • يجب أن يحمّل chunk loading كل ما يلزم بالتوازي؛ على الويب يعني ذلك round-trip واحدًا إلى الخادم.
  • التحكم من المستهلك إلى container
    • تجاوز الوحدات عملية باتجاه واحد.
    • لا تستطيع sibling containers تجاوز وحدات بعضها.
  • يجب أن يكون المفهوم مستقلًا عن البيئة.
    • قابل للاستخدام في الويب و Node.js وغيرها.
  • الطلبات النسبية والمطلقة في shared:
    • ستُقدم دائمًا، حتى لو لم تُستخدم.
    • ستُحل نسبيًا إلى config.context.
    • لا تستخدم requiredVersion افتراضيًا.
  • طلبات الوحدات في shared:
    • تُقدم فقط عندما تُستخدم.
    • تطابق كل طلبات الوحدات المتساوية المستخدمة في build لديك.
    • تقدم كل الوحدات المطابقة.
    • تستخرج requiredVersion من package.json في هذا الموضع من المخطط.
    • يمكنها تقديم واستهلاك عدة إصدارات مختلفة عندما تكون لديك node_modules متداخلة.
  • طلبات الوحدات التي تنتهي بـ / في shared ستطابق كل طلبات الوحدات التي تبدأ بهذه البادئة.

حالات الاستخدام

builds منفصلة لكل صفحة

تُكشف كل صفحة من تطبيق Single Page Application من container build في build منفصل. كما يكون application shell build منفصلًا يشير إلى كل الصفحات كوحدات بعيدة. بهذه الطريقة يمكن نشر كل صفحة بشكل منفصل. يُنشر application shell عندما تُحدّث المسارات أو تُضاف مسارات جديدة. يعرّف application shell المكتبات المستخدمة عمومًا كوحدات مشتركة لتجنب تكرارها في builds الصفحات.

مكتبة مكونات كـ container

تشارك كثير من التطبيقات مكتبة مكونات مشتركة يمكن بناؤها كـ container مع كشف كل مكوّن. يستهلك كل تطبيق المكونات من container مكتبة المكونات. يمكن نشر تغييرات مكتبة المكونات بشكل منفصل دون الحاجة إلى إعادة نشر كل التطبيقات. وسيستخدم التطبيق تلقائيًا النسخة الأحدث من مكتبة المكونات.

Dynamic Remote Containers

تدعم واجهة container طريقتي get و init. طريقة init متوافقة مع async وتُستدعى بمعامل واحد: كائن shared scope. يُستخدم هذا الكائن كـ shared scope في remote container ويُملأ بالوحدات المقدمة من host. يمكن الاستفادة من ذلك لربط remote containers بـ host container ديناميكيًا وقت التشغيل.

init.js

(async () => {
  // Initializes the shared scope. Fills it with known provided modules from this build and all remotes
  await __webpack_init_sharing__("default");
  const container = globalThis.someContainer; // or get the container somewhere else
  // Initialize the container, it may provide shared modules
  await container.init(__webpack_share_scopes__.default);
  const module = await container.get("./module");
})();

يحاول container تقديم الوحدات المشتركة، لكن إذا كانت الوحدة المشتركة قد استُخدمت بالفعل، فسيظهر تحذير وسيتم تجاهل الوحدة المشتركة المقدمة. قد يظل container يستخدمها كخيار احتياطي.

بهذه الطريقة يمكنك تحميل اختبار A/B ديناميكيًا يقدم إصدارًا مختلفًا من وحدة مشتركة.

مثال:

init.js

function loadComponent(scope, module) {
  return async () => {
    // Initializes the shared scope. Fills it with known provided modules from this build and all remotes
    await __webpack_init_sharing__("default");
    const container = window[scope]; // the remote container exposed by the loaded remoteEntry.js script
    // Initialize the container, it may provide shared modules
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

loadComponent("abtests", "test123");

راجع التنفيذ الكامل

Dynamic Remotes المعتمدة على Promise

عادةً تُكوّن remotes باستخدام عناوين URL كما في هذا المثال:

export default {
  plugins: [
    new ModuleFederationPlugin({
      name: "host",
      remotes: {
        app1: "app1@http://localhost:3001/remoteEntry.js",
      },
    }),
  ],
};

لكن يمكنك أيضًا تمرير promise لهذا remote، وسيُحل وقت التشغيل. يجب أن تحل هذا promise بكائن يناسب واجهة get/init الموضحة أعلاه. على سبيل المثال، إذا أردت تمرير إصدار الوحدة الاتحادية الذي يجب استخدامه عبر query parameter، فيمكنك فعل شيء مثل الآتي:

export default {
  plugins: [
    new ModuleFederationPlugin({
      name: "host",
      remotes: {
        app1: `promise new Promise(resolve => {
      const urlParams = new URLSearchParams(window.location.search)
      const version = urlParams.get('app1VersionParam')
      // This part depends on how you plan on hosting and versioning your federated modules
      const remoteUrlWithVersion = 'http://localhost:3001/' + version + '/remoteEntry.js'
      const script = document.createElement('script')
      script.src = remoteUrlWithVersion
      script.onload = () => {
        // the injected script has loaded and is available on window
        // we can now resolve this Promise
        const proxy = {
          get: (request) => window.app1.get(request),
          init: (...arg) => {
            try {
              return window.app1.init(...arg)
            } catch(e) {
              console.log('remote container already initialized')
            }
          }
        }
        resolve(proxy)
      }
      // inject this script with the src set to the versioned remoteEntry.js
      document.head.appendChild(script);
    })
    `,
      },
      // ...
    }),
  ],
};

لاحظ أنك عند استخدام هذه API يجب أن تحل كائنًا يحتوي على API الخاصة بـ get/init.

Dynamic Public Path

تقديم host API لتعيين publicPath

يمكن السماح للـ host بتعيين publicPath لوحدة بعيدة وقت التشغيل عبر كشف طريقة من تلك الوحدة البعيدة.

هذا الأسلوب مفيد خصوصًا عندما تركّب تطبيقات فرعية منشورة بشكل مستقل على مسار فرعي من نطاق host.

السيناريو:

لديك تطبيق host مستضاف على https://my-host.com/app/* وتطبيق فرعي مستضاف على https://foo-app.com. التطبيق الفرعي مركب أيضًا على نطاق host، ولذلك يُتوقع أن يكون https://foo-app.com قابلًا للوصول عبر https://my-host.com/app/foo-app وأن تُعاد توجيه طلبات https://my-host.com/app/foo-app/* إلى https://foo-app.com/* عبر proxy.

مثال:

webpack.config.js (remote)

export default {
  entry: {
    remote: "./public-path",
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "remote", // this name needs to match with the entry name
      exposes: ["./public-path"],
      // ...
    }),
  ],
};

public-path.js (remote)

export function set(value) {
  __webpack_public_path__ = value;
}

src/index.js (host)

const publicPath = await import("remote/public-path");
publicPath.set("/your-public-path");

//bootstrap app  e.g. import('./bootstrap.js')

استنتاج publicPath من السكربت

يمكن استنتاج publicPath من وسم script عبر document.currentScript.src وتعيينه باستخدام متغير __webpack_public_path__ الخاص بالوحدة وقت التشغيل.

مثال:

webpack.config.js (remote)

export default {
  entry: {
    remote: "./setup-public-path",
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "remote", // this name needs to match with the entry name
      // ...
    }),
  ],
};

setup-public-path.js (remote)

// derive the publicPath with your own logic and set it with the __webpack_public_path__ API
__webpack_public_path__ = `${document.currentScript.src}/../`;

استكشاف الأخطاء وإصلاحها

Uncaught Error: Shared module is not available for eager consumption

ينفذ التطبيق بشكل eager تطبيقًا يعمل كـ host متعدد الاتجاهات. توجد عدة خيارات:

يمكنك تعيين التبعية كـ eager داخل API المتقدمة لـ Module Federation، وهذا لا يضع الوحدات داخل chunk غير متزامن، بل يقدمها بشكل متزامن. يسمح لنا ذلك باستخدام هذه الوحدات المشتركة في chunk الابتدائي. لكن انتبه لأن كل الوحدات المقدمة والاحتياطية ستُحمّل دائمًا. يُوصى بتقديمها في نقطة واحدة فقط من تطبيقك، مثل shell.

نوصي بشدة باستخدام حد غير متزامن. سيؤدي ذلك إلى فصل كود التهيئة الخاص بـ chunk أكبر لتجنب أي round trips إضافية وتحسين الأداء عمومًا.

على سبيل المثال، كانت نقطة الدخول لديك تبدو هكذا:

index.js

import { createRoot } from "react-dom/client";
import App from "./App";

const root = createRoot(document.getElementById("root"));
root.render(<App />);

لننشئ ملف bootstrap.js وننقل محتويات نقطة الدخول إليه، ثم نستورد bootstrap في نقطة الدخول:

index.js

+ import('./bootstrap');
- import { createRoot } from 'react-dom/client';
- import App from './App';

- const root = createRoot(document.getElementById('root'));
- root.render(<App />);

bootstrap.js

+ import { createRoot } from 'react-dom/client';
+ import App from './App';
+ const root = createRoot(document.getElementById('root'));
+ root.render(<App />);

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

تعيين eager: true للتبعية عبر ModuleFederationPlugin:

webpack.config.js

// ...
new ModuleFederationPlugin({
  shared: {
    ...deps,
    react: {
      eager: true,
    },
  },
});

Uncaught Error: Module "./Button" does not exist in container.

غالبًا لن تقول الرسالة "./Button" بالضبط، لكنها ستكون مشابهة. تظهر هذه المشكلة عادة عند الترقية من webpack beta.16 إلى webpack beta.17.

داخل ModuleFederationPlugin، غيّر exposes من:

new ModuleFederationPlugin({
  exposes: {
-   'Button': './src/Button'
+   './Button':'./src/Button'
  }
});

Uncaught TypeError: fn is not a function

غالبًا ينقصك remote container، تأكد من إضافته. إذا كان container محملًا للـ remote الذي تحاول استهلاكه وما زلت ترى هذا الخطأ، فأضف ملف remote container الخاص بالـ host إلى HTML أيضًا.

تعيين output.uniqueName

في إعداد Module Federation، يجب أن يكون لكل من host وكل remote قيمة output.uniqueName فريدة عالميًا. يشتق webpack هذه القيمة افتراضيًا من حقل name في package.json. هذا يعني أن buildين يشتركان في قيمة name نفسها داخل package.json، وهو نمط شائع عند فصل remote من مشروع قائم، يمكن أن يتصادما بصمت وقت التشغيل.

أحد الحلول هو استخدام ملف package.json منفصل باسم مميز لكل تكوين.

بدلًا من ذلك، يمكنك تعيين output.uniqueName صراحة في كل تكوين webpack:

webpack.config.js (host)

export default {
  output: {
    uniqueName: "my-host-app",
  },
  plugins: [
    new ModuleFederationPlugin({
      // ...
    }),
  ],
};

webpack.config.js (remote)

export default {
  output: {
    uniqueName: "my-remote-app", // must differ from host and all other remotes
  },
  plugins: [
    new ModuleFederationPlugin({
      // ...
    }),
  ],
};

يمكن أن تكون القيمة أي سلسلة، ما دامت فريدة بين كل builds الاتحادية المحملة على الصفحة نفسها.

Edit this page·

1 Contributor

RlxChap2