和 webpack 做對比,webpack5 有個很重要的功能,就是模塊聯邦,那麼什麼是模塊聯邦?Vite 中也可以實現嗎?我們一起來探究下。
什麼是模塊聯邦?Module Federation 中文直譯為「模塊聯邦」,而在 webpack 官方文檔中,其實並未給出其真正含義,但給出了使用該功能的 motivation, 即動機,翻譯成中文
多個獨立的構建可以形成一個應用程序。這些獨立的構建不會相互依賴,因此可以單獨開發和部署它們。這通常被稱為微前端,但並不僅限於此。
原文在這裡:module-federation[2], 並且給出了stackblitz 在線運行鏈接[3]

這個是一個基於 lerna 的 monorepo 倉庫, app1 和 app2 是並行啟動的, 分別運行在 3001 和 3002 端口上。

但在 app1 中卻可以直接引用 app2 的組件

現在,直接修改 app2 中組件的代碼,在 app1 中就可以同步更新。

此處需要點擊下刷新按鈕,因為 2 個應用啟動在 2 個端口上,所以不會熱更新。
結合以上,不難看出,MF 實際想要做的事,便是把多個無相互依賴、單獨部署的應用合併為一個。通俗點講,即 MF 提供了能在當前應用中加載遠程服務器上應用模塊的能力,這就是模塊聯邦(Module Federation)。
模塊聯邦解決了什麼問題我們要在多個應用直接實現模塊共享,我們原來是怎麼做的?
發布 npm 組件
npm 是前端的優勢,也是前端之痛,一個項目只依賴了 1 個 npm 包,而在 node_modules 卻有無數個包,若是純粹的基礎組件發布 npm 包還可以,因為不常改動,若一個模塊涉及業務,發布 npm 包就會變得很麻煩,比如一個常見的需求,需要給每個應用加上客服聊天窗口。這個聊天窗口會隨着 chat services的改動而變化,當 chat 這個組件改變時,我們就會陷入 npm 發布 ——> app 升級 npm 包 -> app 上線 這樣的輪迴之中,而在現實場景中,我們會採用另一種方式。
Iframe
Iframe 是另一種方案,可以將 chat 做一個 iframe 嵌入到各個應用中,這樣只需要升級 chat 一個應用,其他應用都不用改動。但 iframe 也有缺點,首先使用 iframe 每次打開組件,DOM 樹都會重建,所以打開速度較慢。其次 iframe 跨應用通信使用 window.postMessage 的方式,若應用部署在不同的域名下,使用 postMessage 需要控制好 origin 和 source 屬性驗證發件人的身份,不然可能會存在跨站點腳本漏洞。
而 MF 很好地解決了多應用模塊復用的問題,相比上面的這 2 中解決方案,它的解決方式更加優雅和靈活。
如何配置模塊聯邦MF 引出下面兩個概念:
Host:引用了其他應用模塊的應用, 即當前應用
Remote:被其他應用使用模塊的應用, 即遠程應用
在 webpack 中配置無論是當前應用還是遠程應用都依賴 webpack5 中的 ModuleFederationPlugin plugin
作為組件提供方,需要在 plugins 中配置如下代碼
const { ModuleFederationPlugin } = require('webpack').container;const path = require('path');module.exports = { entry: './src/index', mode: 'development', devServer: { static: path.join(__dirname, 'dist'), port: 3002, }, output: { publicPath: 'auto', }, plugins: [ new ModuleFederationPlugin({ // 遠程組件的應用名稱 name: 'app2', // 遠程組件的入口文件 filename: 'remoteEntry.js', // 定義需要導出的組件列表 exposes: { './App': './src/App', './Component': './src/component', }, // 可以被共享的模塊 shared: { react: { singleton: true }, 'react-dom': { singleton: true } }, }), ],};
shared 本地模塊和遠程模塊共享的依賴。
singleton 表示共享作用域中共享模塊使用當前的版本(默認情況下禁用)。一些庫使用全局內部狀態(例如 react、react-dom)。因此,對一次只能運行一個庫實例是至關重要的。
在當前應用中,也就是作為組件的使用方,需要在 webpack.config.js 中配置如下代碼:
const HtmlWebpackPlugin = require("html-webpack-plugin");const { ModuleFederationPlugin } = require("webpack").container;const path = require("path");module.exports = { entry: "./src/index", mode: "development", devServer: { static: path.join(__dirname, "dist"), port: 3001, }, output: { publicPath: "auto", }, plugins: [ new ModuleFederationPlugin({ // 當前應用名稱 name: "app1", // 遠程應用加載js入口列表 remotes: { app2: "app2@http://localhost:3002/remoteEntry.js", }, //共享的模塊 shared: {react: {singleton: true}, "react-dom": {singleton: true}}, }) ],};
本地模塊需配置所有使用到的遠端模塊的依賴;遠端模塊需要配置對外提供的組件的依賴。
最後一步需要將入口文件改為異步加載。
比如原先的入口文件 index.js
import React from 'react';import ReactDOM from 'react-dom';import App from './App';ReactDOM.render(<App />, document.getElementById('root'));
將index.js要修改為異步加載
+ import('./bootstrap');- import React from 'react';- import ReactDOM from 'react-dom';- import App from './App';- ReactDOM.render(<App />, document.getElementById('root'));
重命名原先的 index.js 為 bootstrap.js
import React from 'react';import ReactDOM from 'react-dom';import App from './App';ReactDOM.render(<App />, document.getElementById('root'));
const RemoteApp = React.lazy(() => import("app2/App"));
這樣在 app1 中就可以只有引用 app2 中的組件了。
在 vite 中配置MF 提供的是一種加載方式,並不是 webpack 獨有的,所以社區中已經提供了一個的 Vite 模塊聯邦方案: vite-plugin-federation[4],這個方案基於 Vite(Rollup) 也實現了完整的模塊聯邦能力。
Vite 模塊聯邦 stackblitz 在線運行鏈接[5]打開這個示例,請按 readme 命令依次運行,由於 Vite 是按需編譯,所以 app2 必須先打包啟動, 2 個 App 無法同時是開發模式。
配置步驟
首先需要安裝 @originjs/vite-plugin-federation
app1 當前應用:vite.config.js配置
import { defineConfig } from "vite";import react from "@vitejs/plugin-react";import federation from "@originjs/vite-plugin-federation";// https://vitejs.dev/config/export default defineConfig({ plugins: [ react(), federation({ name: "app1", remotes: { app2: "http://localhost:3002/assets/remoteEntry.js", }, shared: ["react"], }), ],});
app2 遠程應用:vite.config.js配置
import { defineConfig } from "vite";import react from "@vitejs/plugin-react";import federation from "@originjs/vite-plugin-federation";// https://vitejs.dev/config/export default defineConfig({ plugins: [ react(), federation({ name: "app2", filename: "remoteEntry.js", library: { type: "module" }, exposes: { "./App": "./src/App.jsx", }, shared: ["react"], }), ],});
我們看到這些配置同 webpack 如出一轍,稍微不同的是
remotes 對象中不需要寫, app2@這個全局變量名稱,並且 vite 打包的 remoteEntry.js 默認在 assets 文件夾下
remotes: { app2: "http://localhost:3002/assets/remoteEntry.js",},
vite-plugin-federation 還可以和 webpack 配合使用
remotes: { app2: { external: 'http://localhost:5011/remoteEntry.js', format: 'var', from: 'webpack' }}
官方還提供了 systemjs\ esm 和 var 等不同的加載方式
hostremotedemorollup/vite+esmrollup/vite+esmsimple-react-esmrollup/vite+systemjsrollup/vite+systemjsvue3-demo-esmrollup/vite+systemjswebpack+systemjsvue3-demo-systemjsrollup/vite+esmwebpack+varvue3-demo-webpack-esm-varrollup/vite+esmwebpack+esmvue3-demo-webpack-esm-esm官方還提供了 2 點 warning:
React 項目中不能使用異構組件(例如 vite 使用 webpack 的組件或者反之),因為現在還無法保證 vite/rollup 和 webpack 在打包 commonjs 框架時轉換出 export 一致的 chunk,這是使用 shared 的先決條件
vite 使用 webpack 組件相對容易,但是 webpack 使用 vite 組件時 vite-plugin-federation 組件最好是 esm 格式,因為其他格式暫時缺少測試用例完成測試
模塊聯邦的原理
比如 Host 端有如下代碼
import Section from './components/Section.vue'const script = { name: 'App', components: { Section, Button: () => import('./components/Button.vue'), RemoteButton: () => import('remote-simple/remote-simple-button'), }}
編譯後悔轉換為如下代碼
import __federation__ from '__federation__';import Section from './components/Section.vue'const script = { name: 'App', components: { Section, Button: () => import('./components/Button.vue'), RemoteButton: () => __federation__.ensure("remote-simple").then((remote) => remote.get("./remote-simple-button")), }}
而 __federation__ 是一個虛擬文件,用於維護 remotesMap對象
const remotesMap = { 'remote-simple': () => import('http://localhost:5011/remoteEntry.js')}const shareScope = { vue: { get: () => import('__rf_shareScope__${vue}') }}const initMap = {}export default { ensure: async (remoteId) => { const remote = await remotesMap[remoteId]() if (!initMap[remoteId]) { remote.init(shareScope) initMap[remoteId] = true } return remote }}
比如 Remote 暴露了 Button 模塊
exposes: { './Button': './src/components/Button.js',},
則會生成如下 remoteEntry.js
let moduleMap = {"./Button":()=>{return import('./button.js')},};const get =(module, getScope) => { return moduleMap[module]();};const init =(shareScope, initScope) => { let global = window || node; global.__rf_var__shared= shareScope;};export { get, init };
moduleMap 維護了所有導出的 remote 模塊對象, init()做一些shareScope初始化的工作。get()會根據傳入的模塊名動態加載模塊。
此時 remote 端 ./button.js 是不存在的,需要根據 exposes 配置信息將模塊單獨打包為 chunk,供 Host 端調用時加載。所以需要將 remote 端改成多入口的打包方式,Rollup 插件在 options()鈎子,根據 exposes 改寫 Rollup 的 input 配置,例如示例的 exposes 會生成:
input: { Button: './src/components/Button.js'}
以上便是模塊聯邦的基本邏輯。
模塊聯邦存在問題CSS 樣式污染問題,建議避免在 component 中使用全局樣式。
模塊聯邦並未提供沙箱能力,可能會導致 JS 變量污染
在 vite 中, React 項目還無法將 webpack 打包的模塊公用模塊
小結鑑於 MF 的能力,我們可以完全實現一個去中心化的應用:每個應用是單獨部署在各自的服務器,每個應用都可以引用其他應用,也能被其他應用所引用,即每個應用可以充當 Host 的角色,亦可以作為 Remote 出現,無中心應用的概念。
本文介紹了什麼是模塊聯邦,在模塊聯邦之前,前端模塊共享存在着各種痛點,並且通過在線例子演示了模塊聯邦的配置,也介紹了vite-plugin-federation 插件的使用及原理,它讓我們可以在 Vite 項目中也可以實現模塊共享。總體而言模塊聯邦配置相對簡單,但模塊聯邦想要真正落地可能需要全員推動,因為在現實開發中,存在着跨部門協作,開發人員不可能了解每個項目的 vite.config.js 配置,這就需要我們將所有的 remote 模塊維護成文檔,供跨團隊調用。
以上就是本文全部內容,希望這篇文章對大家有所幫助,也可以參考我往期的文章或者在評論區交流你的想法和心得,歡迎一起探索前端。
參考[1]將 react 應用遷移至 Vite: https://juejin.cn/post/7110535158863757319
[2]module-federation: https://webpack.js.org/concepts/module-federation/
[3]stackblitz 在線運行鏈接: https://stackblitz.com/github/webpack/webpack.js.org/tree/master/examples/module-federation?file=README.md&terminal=start&terminal=
[4]vite-plugin-federation: https://github.com/originjs/vite-plugin-federation
[5]vite 模塊聯邦 stackblitz 在線運行鏈接: https://stackblitz.com/edit/github-kyokdx?file=readme.md