Vite 也可以模塊聯邦
前言
之前寫過一篇文章,《將 React 應用遷移至 Vite》[1] 介紹了 Vite 的優勢,並且和 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
等不同的加載方式
官方還提供了 2 點 warning
:
-
React 項目中不能使用異構組件(例如 vite 使用 webpack 的組件或者反之),因爲現在還無法保證
vite/rollup
和webpack
在打包commonjs
框架時轉換出export
一致的 chunk,這是使用shared
的先決條件 -
vite
使用webpack
組件相對容易,但是webpack
使用vite
組件時vite-plugin-federation
組件最好是esm
格式,因爲其他格式暫時缺少測試用例完成測試
模塊聯邦的原理
Host 端消費 Remote 模塊
整體邏輯
比如 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 端暴露模塊
比如 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
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/H8emiJzYxN06Syrmn5ShOg