Webpack 模塊聯(lián)邦(Module Federation)

2023-05-15 17:26 更新

動(dòng)機(jī)

多個(gè)獨(dú)立的構(gòu)建可以組成一個(gè)應(yīng)用程序,這些獨(dú)立的構(gòu)建之間不應(yīng)該存在依賴關(guān)系,因此可以單獨(dú)開發(fā)和部署它們。

這通常被稱作微前端,但并不僅限于此。

底層概念

我們區(qū)分本地模塊和遠(yuǎn)程模塊。本地模塊即為普通模塊,是當(dāng)前構(gòu)建的一部分。遠(yuǎn)程模塊不屬于當(dāng)前構(gòu)建,并在運(yùn)行時(shí)從所謂的容器加載。

加載遠(yuǎn)程模塊被認(rèn)為是異步操作。當(dāng)使用遠(yuǎn)程模塊時(shí),這些異步操作將被放置在遠(yuǎn)程模塊和入口之間的下一個(gè) chunk 的加載操作中。如果沒有 chunk 加載操作,就不能使用遠(yuǎn)程模塊。

chunk 的加載操作通常是通過調(diào)用 ?import()? 實(shí)現(xiàn)的,但也支持像 ?require.ensure? 或 ?require([...])? 之類的舊語法。

容器是由容器入口創(chuàng)建的,該入口暴露了對特定模塊的異步訪問。暴露的訪問分為兩個(gè)步驟:

  1. 加載模塊(異步的)
  2. 執(zhí)行模塊(同步的)

步驟 1 將在 chunk 加載期間完成。步驟 2 將在與其他(本地和遠(yuǎn)程)的模塊交錯(cuò)執(zhí)行期間完成。這樣一來,執(zhí)行順序不受模塊從本地轉(zhuǎn)換為遠(yuǎn)程或從遠(yuǎn)程轉(zhuǎn)為本地的影響。

容器可以嵌套使用,容器可以使用來自其他容器的模塊。容器之間也可以循環(huán)依賴。

高級概念

每個(gè)構(gòu)建都充當(dāng)一個(gè)容器,也可將其他構(gòu)建作為容器。通過這種方式,每個(gè)構(gòu)建都能夠通過從對應(yīng)容器中加載模塊來訪問其他容器暴露出來的模塊。

共享模塊是指既可重寫的又可作為向嵌套容器提供重寫的模塊。它們通常指向每個(gè)構(gòu)建中的相同模塊,例如相同的庫。

packageName 選項(xiàng)允許通過設(shè)置包名來查找所需的版本。默認(rèn)情況下,它會自動(dòng)推斷模塊請求,當(dāng)想禁用自動(dòng)推斷時(shí),請將 requiredVersion 設(shè)置為 false 。

構(gòu)建塊(Building blocks)

ContainerPlugin (low level)

該插件使用指定的公開模塊來創(chuàng)建一個(gè)額外的容器入口。

ContainerReferencePlugin (low level)

該插件將特定的引用添加到作為外部資源(externals)的容器中,并允許從這些容器中導(dǎo)入遠(yuǎn)程模塊。它還會調(diào)用這些容器的 ?override? API 來為它們提供重載。本地的重載(當(dāng)構(gòu)建也是一個(gè)容器時(shí),通過 ?__webpack_override__? 或 ?override? API)和指定的重載被提供給所有引用的容器。

ModuleFederationPlugin (high level)

?ModuleFederationPlugin? 組合了 ?ContainerPlugin? 和 ?ContainerReferencePlugin?。

概念目標(biāo)

  • 它既可以暴露,又可以使用 webpack 支持的任何模塊類型
  • 代碼塊加載應(yīng)該并行加載所需的所有內(nèi)容(web:到服務(wù)器的單次往返)
  • 從使用者到容器的控制
  1. 重寫模塊是一種單向操作
  2. 同級容器不能重寫彼此的模塊
  • 概念適用于獨(dú)立于環(huán)境、
  1. 可用于 web、Node.js 等
  • 共享中的相對和絕對請求
  1. 會一直提供,即使不使用
  2. 會將相對路徑解析到 ?config.context?
  3. 默認(rèn)不會使用 ?requiredVersion?
  • 共享中的模塊請求
  1. 只在使用時(shí)提供
  2. 會匹配構(gòu)建中所有使用的相等模塊請求
  3. 將提供所有匹配模塊
  4. 將從圖中這個(gè)位置的 package.json 提取 ?requiredVersion?
  5. 當(dāng)你有嵌套的 node_modules 時(shí),可以提供和使用多個(gè)不同的版本
  • 共享中尾部帶有 /  的模塊請求將匹配所有具有這個(gè)前綴的模塊請求

用例

每個(gè)頁面單獨(dú)構(gòu)建

單頁應(yīng)用的每個(gè)頁面都是在單獨(dú)的構(gòu)建中從容器暴露出來的。主體應(yīng)用程序(application shell)也是獨(dú)立構(gòu)建,會將所有頁面作為遠(yuǎn)程模塊來引用。通過這種方式,可以單獨(dú)部署每個(gè)頁面。在更新路由或添加新路由時(shí)部署主體應(yīng)用程序。主體應(yīng)用程序?qū)⒊S脦於x為共享模塊,以避免在頁面構(gòu)建中出現(xiàn)重復(fù)。

將組件庫作為容器

許多應(yīng)用程序共享一個(gè)通用的組件庫,可以將其構(gòu)建成暴露所有組件的容器。每個(gè)應(yīng)用程序使用來自組件庫容器的組件??梢詥为?dú)部署對組件庫的更改,而不需要重新部署所有應(yīng)用程序。應(yīng)用程序自動(dòng)使用組件庫的最新版本。

動(dòng)態(tài)遠(yuǎn)程容器

該容器接口支持 ?get?  和 ?init? 方法。 ?init? 是一個(gè)兼容 ?async? 的方法,調(diào)用時(shí),只含有一個(gè)參數(shù):共享作用域?qū)ο?shared scope object)。此對象在遠(yuǎn)程容器中用作共享作用域,并由 host 提供的模塊填充。 可以利用它在運(yùn)行時(shí)動(dòng)態(tài)地將遠(yuǎn)程容器連接到 host 容器。

init.js

(async () => {
  // 初始化共享作用域(shared scope)用提供的已知此構(gòu)建和所有遠(yuǎn)程的模塊填充它
  await __webpack_init_sharing__('default');
  const container = window.someContainer; // 或從其他地方獲取容器
  // 初始化容器 它可能提供共享模塊
  await container.init(__webpack_share_scopes__.default);
  const module = await container.get('./module');
})();

容器嘗試提供共享模塊,但是如果共享模塊已經(jīng)被使用,則會發(fā)出警告,并忽略所提供的共享模塊。容器仍能將其作為降級模塊。

你可以通過動(dòng)態(tài)加載的方式,提供一個(gè)共享模塊的不同版本,從而實(shí)現(xiàn) A/B 測試。

例子:

init.js

function loadComponent(scope, module) {
  return async () => {
    // 初始化共享作用域(shared scope)用提供的已知此構(gòu)建和所有遠(yuǎn)程的模塊填充它
    await __webpack_init_sharing__('default');
    const container = window[scope]; // 或從其他地方獲取容器
    // 初始化容器 它可能提供共享模塊
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

loadComponent('abtests', 'test123');

查看完整實(shí)現(xiàn)

基于 Promise 的動(dòng)態(tài) Remote

一般來說,remote 是使用 URL 配置的,示例如下:

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: 'app1@http://localhost:3001/remoteEntry.js',
      },
    }),
  ],
};

但是你也可以向 remote 傳遞一個(gè) promise,其會在運(yùn)行時(shí)被調(diào)用。你應(yīng)該用任何符合上面描述的 get/init 接口的模塊來調(diào)用這個(gè) promise。例如,如果你想傳遞你應(yīng)該使用哪個(gè)版本的聯(lián)邦模塊,你可以通過一個(gè)查詢參數(shù)做以下事情:

module.exports = {
  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);
    })
    `,
      },
      // ...
    }),
  ],
};

請注意當(dāng)使用該 API 時(shí),你 必須 resolve 一個(gè)包含 get/init API 的對象。

動(dòng)態(tài) Public Path

提供一個(gè) host api 以設(shè)置 publicPath

可以允許 host 在運(yùn)行時(shí)通過公開遠(yuǎn)程模塊的方法來設(shè)置遠(yuǎn)程模塊的 publicPath。

當(dāng)你在 host 域的子路徑上掛載獨(dú)立部署的子應(yīng)用程序時(shí),這種方法特別有用。

場景:

你在 ?https://my-host.com/app/* ?上有一個(gè) host 應(yīng)用,并且在 ?https://foo-app.com? 上有一個(gè)子應(yīng)用。子應(yīng)用程序也掛載在 host 域上, 因此, ?https://foo-app.com? 可以通過 ?https://my-host.com/app/foo-app? 訪問,并且 ?https://my-host.com/app/foo-app/*? 可以通過代理重定向到 ?https://foo-app.com/*?。

示例:

webpack.config.js (remote)

module.exports = {
  entry: {
    remote: './public-path',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'remote', // 該名稱必須與入口名稱相匹配
      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

在運(yùn)行時(shí),可以從 document.currentScript.src 的腳本標(biāo)簽中推斷出 publicPath,并使用 __webpack_public_path__ 模塊變量進(jìn)行設(shè)置。

示例:

webpack.config.js (remote)

module.exports = {
  entry: {
    remote: './setup-public-path',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'remote', // 該名稱必須與入口名稱相匹配
      // ...
    }),
  ],
};

setup-public-path.js (remote)

// 使用你自己的邏輯派生 publicPath,并使用 __webpack_public_path__ API 設(shè)置它
__webpack_public_path__ = document.currentScript.src + '/../';

故障排除

Uncaught Error: Shared module is not available for eager consumption

應(yīng)用程序正急切地執(zhí)行一個(gè)作為全局主機(jī)運(yùn)行的應(yīng)用程序。有如下選項(xiàng)可供選擇:

你可以在模塊聯(lián)邦的高級 API 中將依賴設(shè)置為即時(shí)依賴,此 API 不會將模塊放在異步 chunk 中,而是同步地提供它們。這使得我們在初始塊中可以直接使用這些共享模塊。但是要注意,由于所有提供的和降級模塊是要異步下載的,因此,建議只在應(yīng)用程序的某個(gè)地方提供它,例如 shell。

我們強(qiáng)烈建議使用異步邊界(asynchronous boundary)。它將把初始化代碼分割成更大的塊,以避免任何額外的開銷,以提高總體性能。

例如,你的入口看起來是這樣的:

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));

讓我們創(chuàng)建 bootstrap.js 文件,并將入口文件的內(nèi)容放到里面,然后將 bootstrap 引入到入口文件中:

index.js

+ import('./bootstrap');
- import React from 'react';
- import ReactDOM from 'react-dom';
- import App from './App';
- ReactDOM.render(<App />, document.getElementById('root'));

bootstrap.js

+ import React from 'react';
+ import ReactDOM from 'react-dom';
+ import App from './App';
+ ReactDOM.render(<App />, document.getElementById('root'));

這種方法有效,但存在局限性或缺點(diǎn)。

通過 ?ModuleFederationPlugin?  將依賴的 ?eager? 屬性設(shè)置為 ?true?

webpack.config.js

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

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

錯(cuò)誤提示中可能不會顯示 ?"./Button"?,但是信息看起來差不多。這個(gè)問題通常會出現(xiàn)在將 webpack beta.16 升級到 webpack beta.17 中。

在 ModuleFederationPlugin 里,更改 exposes:

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

Uncaught TypeError: fn is not a function

此處錯(cuò)誤可能是丟失了遠(yuǎn)程容器,請確保在使用前添加它。 如果已為試圖使用遠(yuǎn)程服務(wù)器的容器加載了容器,但仍然看到此錯(cuò)誤,則需將主機(jī)容器的遠(yuǎn)程容器文件也添加到 HTML 中。

來自多個(gè) remote 的模塊之間的沖突

如果你想從不同的 remote 中加載多個(gè)模塊,建議為你的遠(yuǎn)程構(gòu)建設(shè)置 ?output.uniqueName? 以避免多個(gè) webpack 運(yùn)行時(shí)之間的沖突。

更多閱讀


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號