什麼是 module Federation
module Federation
(下面簡稱 MF
) 是 webpack5
推出的最新的概念
有用過 webpack
的小夥伴都知道, 在我們打包時, 都會對資源進行分包, 或者使用異步加載路由的方案,
這樣打出來的包(也叫 chunk
), 在我們使用時, 就是一個單獨的加載
在過去, chunk 只是在一個項目中使用, webpack5
中, chunk
通過 路徑/fetch 等方式也可以提供給其他應用使用
這便是 MF
的發展由來, 不得不說這是一個很有想象力的API
MF
的粒度, 最小可以是一個組件/組件庫, 最大可以是一個頁面, 取決於你怎樣使用
基礎使用
首先我們來看 ModuleFederationPlugin
的一些基礎配置參數:
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
// options
}),
],
};
參數
file filename
name
是容器的名稱, filename
是具體的文件名入口
如果沒有 filename
則以 name
爲文件名, 需要注意的是 name
需要是唯一值
new ModuleFederationPlugin({
name: 'app2', // 名稱
filename: 'remoteEntry.js', // 入口文件
// 打包後, 就會自動打出 remoteEntry.js
// 他的內容就是 exposes 參數中的映射
})
exposes
在這個配置裏你可以暴露你想要的所有內容
new ModuleFederationPlugin({
name: 'app2', // 名稱
filename: 'remoteEntry.js', // 入口文件
exposes: {
'./Widget': './src/Widget',
},
})
exposes
的 key
不能是一個直接的名稱, 如 'Widget': './src/Widget',
這樣會報錯
shared
使用 shared
可以最大限度地減少依賴關係的重複,因爲遠程依賴於主機的依賴關係。如果主機缺少一個依賴項,遠程只在必要時下載其依賴項
使用 dependencies
是爲了共享模塊的版本和 package.json
中的版本保持一致。如果不一致則會打印警告
const deps = require('./package.json').dependencies;
new ModuleFederationPlugin({
name: 'app2',
filename: 'remoteEntry.js',
exposes: {
'./Widget': './src/Widget',
},
shared: {
moment: deps.moment,
react: {
requiredVersion: deps.react,
import: 'react', // 所提供的模塊應該被放置在共享範圍內。如果在共享範圍內沒有找到共享模塊或版本無效,這個提供的模塊也作爲後備模塊。
shareKey: 'react', // 所請求的共享模塊在這個鍵下從共享範圍中被查找出來。
shareScope: 'default', // 共享範圍的名稱。
singleton: true,
},
'react-dom': {
requiredVersion: deps['react-dom'],
singleton: true,
},
},
})
關於 singleton
這個參數只允許在共享範圍內有一個單一版本的共享模塊(默認情況下禁用)。一些庫使用全局的內部狀態(例如 react, react-dom)。
因此,在同一時間只有一個庫的實例在運行是很關鍵的。
remotes
一般來說,remote
是使用 URL
配置的,示例如下
new ModuleFederationPlugin({
name: "app1",
remotes: {
app2: 'app2@http://localhost:3002/remoteEntry.js',
}
})
當然 remotes
還可以有其他的擴展, 在後面會詳細說明
引用 MF
MF
插件組合了 ContainerPlugin
和 ContainerReferencePlugin
所以它既是一個入口, 也是一個出口
所以我們再使用 MF
時, 也是需要添加對應插件:
new ModuleFederationPlugin({
name: 'mainApp',
remotes: {
app2: 'app2@http://localhost:3002/remoteEntry.js',
},
// 省略 shared
})
運行時截圖:
之後我們就可以直接使用組件了:
import App2Widget from 'app2/Widget';
function App() {
// 當成正常組件一樣使用
return (
<div>
<h1>Dynamic System Host</h1>
<h2>main App</h2>
<App2Widget/>
</div>
);
}
remotes 的方案
環境變量
在不同的環境中使用不同的鏈接, 可以解決 pro
和 dev
的不同環境問題
但是在大型應用中, 環境較多, 配置(添加/修改 url
)就比較麻煩了
new ModuleFederationPlugin({
name: "Host",
remotes: {
RemoteA: `RemoteA@${env.A_URL}/remoteEntry.js`,
RemoteB: `RemoteB@${env.B_URL}/remoteEntry.js`,
},
})
Webpack External Remotes Plugin
有一個方便的 Webpack
插件,由 MF
的創建者之一 Zack Jackson 開發,稱爲 external-remotes-plugin。它允許我們使用模板在運行時解析 URL
plugins: [
new ModuleFederationPlugin({
name: "Host",
remotes: {
RemoteA: "RemoteA@[window.appAUrl]/remoteEntry.js",
RemoteB: "RemoteB@[window.appBUrl]/remoteEntry.js",
},
}),
new ExternalTemplateRemotesPlugin(),
]
在從遠程應用程序加載任何代碼之前, 我們加可以定義 window
的屬性來靈活地定義我們的 URL
這種方法是完全動態的,可以解決我們的用例,但這種方法仍有一點限制:
我們不能完全控制加載的生命週期。
Promise
基於 promise
的獲取方案, 此方案在官網也有所提及
但是你也可以向 remote 傳遞一個 promise,其會在運行時被調用。你應該用任何符合上面描述的 get/init 接口的模塊來調用這個 promise。例如,如果你想傳遞你應該使用哪個版本的聯邦模塊,你可以通過一個查詢參數做以下事情:
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')
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)
}
document.head.appendChild(script);
})
`,
},
// ...
}),
],
};
請注意當使用該 API
時,你 必須 resolve
一個包含 get/init
API
的對象。
在 promise
中我們創建一個 script
標籤, 同時添加動態 URL
, 不過此方案是比較死板的, 因爲 url
仍舊是寫在配置中
Dynamic Remote Containers
在 webpack
官網中有一種方案, 即動態遠程容器
這種方案就是像加載 react
子應用一樣加載 MF
應用
我們的插件可以不用設置 remotes
:
plugins: [
new ModuleFederationPlugin({
name: "Host",
remotes: {},
}),
]
核心加載程序:
function loadComponent(scope, module) {
return async () => {
// 初始化共享作用域(shared scope)用提供的已知此構建和所有遠程的模塊填充它
await __webpack_init_sharing__('default');
const container = window[scope]; // or get the container somewhere else
// 初始化容器 它可能提供共享模塊
await container.init(__webpack_share_scopes__.default);
const factory = await window[scope].get(module);
const Module = factory();
return Module;
};
}
webpack_init_sharing 是一些 webpack 編譯的變量, 最後運行時都是會轉換成 webpack_require
webpack_require 是 webpack 運行時引用文件內容的方法
container
指的是我們在 webpack
配置的 remotes
中配置的一個應用。
module
指的是另一個 exposes
字段中的定義。
最後封裝獲取到 hooks
:
// 這裏的 url, scope, module 都是可以通過接口什麼的異步獲取, 做到完全動態
// 原理還是通過 script 標籤加載js 代碼
const { Component: FederatedComponent, errorLoading } = useFederatedComponent('http://localhost:3002/remoteEntry.js', 'app2', './Widget');
<Suspense fallback={'loading...'}>
{errorLoading
? `Error loading module "${module}"`
: FederatedComponent && <FederatedComponent />}
</Suspense>
其中 remoteEntry
就是我們的入口了, 762 和 700, 這兩個是 moment
的主題和多語言文件
而 630 則是 Widget
文件的內容
子組件單獨加載 moment
, 而沒有 react
, react-dom
, 就是因爲我們的 shared
配置
整個 Dynamic Remote Containers
的加載流:
具體例子可點此查看
基於 MF 的框架
這裏也有一個框架是基於 MF
的: EMP
該框架通過靈活的共享配置, 可自定義選擇 MF
/ CDN
/ ES import
/ Dll
多種方式來共享庫
擴展的可能性
MF
目前的 定位在於公共組件庫/業務庫的複用、統一, 但是他能作爲應用級別的載體嗎
在 qiankun
中, 子應用的獲取, 是通過 fetch
來獲取實例, 並在沙盒中解析的
但是 MF
的組件就不能實現這種場景, 因爲我們一開始引用的是入口文件, 後續具體文件不能通過接口獲取
只能通過 script
來拿, 但是這樣就沒有了 js
環境的隔離
所以在應用級別上, 目前的結論是: 可以當做一個乞丐版微前端的框架
但是也需要注意各個應用直接的重複關係, JS/CSS
的隔離問題, 同時他也缺少對應生命週期來管理
在這方面, qiankun
是一個成熟的案例實現, MF
值得嘗試的場景還是在業務公共組件這一塊
總結
MF
已經引來了許多的嚐鮮者, 他是一種可擴展的解決方案,在獨立的應用程序之間共享代碼,對開發者來說非常方便。
但是也存在以下的問題點:
- 需要使用
webpack5
, 現在很多老項目是在用webpack4
, 而又有一些新項目使用了vite
shared
依賴不能tree sharking
- 代碼執行不能使用沙箱隔離, 不太推薦做到應用級別