一、Module Federation 核心原理
1.1 什么是 Module Federation
Module Federation(模块联邦)是 Webpack 5 的革命性特性,允许多个独立的构建可以在运行时共享代码。
传统方式:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ App A │ │ App B │ │ App C │
│ Vue 3MB │ │ Vue 3MB │ │ Vue 3MB │ ← 重复打包
└─────────┘ └─────────┘ └─────────┘
Module Federation:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ App A │ │ App B │ │ App C │
│ expose │←─│ consume │←─│ consume │
└─────────┘ └─────────┘ └─────────┘
↓
Vue 3MB (共享一份)
1.2 核心概念
1. Host(宿主应用)
- 消费其他应用的模块
- 相当于主应用
2. Remote(远程应用)
- 暴露模块供其他应用使用
- 相当于子应用
3. Shared(共享依赖)
- 多个应用共享的依赖
- 如 Vue、Vue Router、Axios 等
4. Exposes(暴露)
- 声明哪些模块可以被其他应用使用
5. Remotes(远程引用)
- 声明要使用哪些远程应用的模块
1.3 与其他微前端方案对比
| 特性 | Module Federation | qiankun | Wujie | Micro-App |
|---|---|---|---|---|
| 技术栈 | Webpack5 原生 | Single-spa | iframe + WC | Web Component |
| 粒度 | 模块级(细粒度) | 应用级 | 应用级 | 应用级 |
| 共享依赖 | 原生支持(最优) | 手动配置 | 不支持 | 不支持 |
| 运行时 | 运行时加载 | 运行时加载 | 运行时加载 | 运行时加载 |
| 构建时优化 | 支持(最好) | 不支持 | 不支持 | 不支持 |
| 使用复杂度 | 高 | 中 | 低 | 低 |
| 适用场景 | 组件库共享 | 应用集成 | 强隔离 | 快速接入 |
二、Module Federation 实战配置
2.1 项目结构
module-federation-demo/
├── host-app/ # 宿主应用
│ ├── src/
│ │ ├── main.js
│ │ ├── App.vue
│ │ └── router/
│ ├── webpack.config.js
│ └── package.json
│
├── remote-app1/ # 远程应用1
│ ├── src/
│ │ ├── main.js
│ │ ├── App.vue
│ │ ├── components/
│ │ │ ├── Button.vue
│ │ │ └── Header.vue
│ │ └── bootstrap.js
│ ├── webpack.config.js
│ └── package.json
│
├── remote-app2/ # 远程应用2
│ └── ...
│
└── shared-lib/ # 共享组件库
└── ...
2.2 远程应用配置(暴露模块)
Webpack 配置
// remote-app1/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container
const { VueLoaderPlugin } = require('vue-loader')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: 'http://localhost:3001/',
clean: true
},
devServer: {
port: 3001,
hot: true,
headers: {
'Access-Control-Allow-Origin': '*'
}
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: './public/index.html'
}),
// Module Federation 配置
new ModuleFederationPlugin({
// 应用名称(唯一)
name: 'remoteApp1',
// 暴露的文件名
filename: 'remoteEntry.js',
// 暴露的模块
exposes: {
'./Button': './src/components/Button.vue',
'./Header': './src/components/Header.vue',
'./utils': './src/utils/index.js',
'./store': './src/store/index.js'
},
// 共享依赖
shared: {
vue: {
singleton: true, // 单例模式
requiredVersion: '^3.3.0', // 版本要求
eager: false // 是否立即加载
},
'vue-router': {
singleton: true,
requiredVersion: '^4.2.0'
},
axios: {
singleton: true,
requiredVersion: '^1.5.0'
}
}
})
],
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: {
'@': path.resolve(__dirname, 'src'),
'vue': '@vue/runtime-dom'
}
}
}
远程应用代码
<!-- remote-app1/src/components/Button.vue -->
<template>
<button
:class="['remote-button', `remote-button--${type}`]"
@click="handleClick"
>
<slot>{{ text }}</slot>
</button>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
text: {
type: String,
default: '按钮'
},
type: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'success', 'warning', 'danger'].includes(value)
}
})
const emit = defineEmits(['click'])
const handleClick = (e) => {
emit('click', e)
}
</script>
<style scoped>
.remote-button {
padding: 10px 24px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
}
.remote-button--primary {
background: #1890ff;
color: white;
}
.remote-button--primary:hover {
background: #40a9ff;
}
.remote-button--success {
background: #52c41a;
color: white;
}
.remote-button--warning {
background: #faad14;
color: white;
}
.remote-button--danger {
background: #ff4d4f;
color: white;
}
</style>
<!-- remote-app1/src/components/Header.vue -->
<template>
<header class="remote-header">
<div class="remote-header__logo">
<img :src="logo" alt="Logo" v-if="logo">
<span>{{ title }}</span>
</div>
<nav class="remote-header__nav">
<a
v-for="item in menus"
:key="item.path"
:href="item.path"
@click.prevent="handleMenuClick(item)"
>
{{ item.name }}
</a>
</nav>
<div class="remote-header__actions">
<slot name="actions"></slot>
</div>
</header>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
default: '远程应用'
},
logo: {
type: String,
default: ''
},
menus: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['menu-click'])
const handleMenuClick = (item) => {
emit('menu-click', item)
}
</script>
<style scoped>
.remote-header {
display: flex;
align-items: center;
height: 64px;
padding: 0 24px;
background: #001529;
color: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.remote-header__logo {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
font-weight: bold;
}
.remote-header__logo img {
height: 32px;
}
.remote-header__nav {
display: flex;
gap: 12px;
flex: 1;
margin-left: 50px;
}
.remote-header__nav a {
padding: 8px 20px;
color: rgba(255, 255, 255, 0.65);
text-decoration: none;
border-radius: 4px;
transition: all 0.3s;
}
.remote-header__nav a:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.remote-header__actions {
display: flex;
align-items: center;
gap: 12px;
}
</style>
// remote-app1/src/utils/index.js
export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const minute = String(d.getMinutes()).padStart(2, '0')
const second = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
.replace('HH', hour)
.replace('mm', minute)
.replace('ss', second)
}
export function debounce(fn, delay = 300) {
let timer = null
return function(...args) {
clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
export function throttle(fn, delay = 300) {
let lastTime = 0
return function(...args) {
const now = Date.now()
if (now - lastTime >= delay) {
fn.apply(this, args)
lastTime = now
}
}
}
Bootstrap 文件(重要)
// remote-app1/src/bootstrap.js
import { createApp } from 'vue'
import App from './App.vue'
// 暴露创建应用的方法
export function mount(container) {
const app = createApp(App)
app.mount(container || '#app')
return app
}
// 独立运行
if (!window.__POWERED_BY_MODULE_FEDERATION__) {
mount()
}
// remote-app1/src/main.js
// 异步导入 bootstrap,确保共享依赖先加载
import('./bootstrap')
2.3 宿主应用配置(消费模块)
Webpack 配置
// host-app/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container
const { VueLoaderPlugin } = require('vue-loader')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: 'http://localhost:3000/',
clean: true
},
devServer: {
port: 3000,
hot: true,
historyApiFallback: true
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: './public/index.html'
}),
// Module Federation 配置
new ModuleFederationPlugin({
name: 'hostApp',
// 引用的远程应用
remotes: {
remoteApp1: 'remoteApp1@http://localhost:3001/remoteEntry.js',
remoteApp2: 'remoteApp2@http://localhost:3002/remoteEntry.js'
},
// 共享依赖(与远程应用保持一致)
shared: {
vue: {
singleton: true,
requiredVersion: '^3.3.0',
eager: true // 宿主应用立即加载
},
'vue-router': {
singleton: true,
requiredVersion: '^4.2.0',
eager: true
},
axios: {
singleton: true,
requiredVersion: '^1.5.0',
eager: true
}
}
})
],
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: {
'@': path.resolve(__dirname, 'src'),
'vue': '@vue/runtime-dom'
}
}
}
宿主应用代码
<!-- host-app/src/App.vue -->
<template>
<div id="host-app">
<!-- 使用远程组件 Header -->
<RemoteHeader
:title="appTitle"
:menus="menus"
@menu-click="handleMenuClick"
>
<template #actions>
<span class="user-info">{{ username }}</span>
<RemoteButton type="danger" @click="logout">退出</RemoteButton>
</template>
</RemoteHeader>
<div class="host-content">
<aside class="host-sidebar">
<h3>本地功能</h3>
<ul>
<li @click="currentView = 'local'">本地组件</li>
<li @click="currentView = 'remote'">远程组件展示</li>
<li @click="currentView = 'utils'">远程工具函数</li>
<li @click="currentView = 'mixed'">混合使用</li>
</ul>
</aside>
<main class="host-main">
<!-- 本地组件 -->
<div v-if="currentView === 'local'" class="view-container">
<h2>本地组件</h2>
<p>这是宿主应用的本地内容</p>
<button @click="count++">本地按钮 ({{ count }})</button>
</div>
<!-- 远程组件展示 -->
<div v-if="currentView === 'remote'" class="view-container">
<h2>远程组件展示</h2>
<div class="demo-section">
<h3>远程按钮组件</h3>
<div class="button-group">
<RemoteButton type="primary" @click="handleClick('primary')">
主要按钮
</RemoteButton>
<RemoteButton type="success" @click="handleClick('success')">
成功按钮
</RemoteButton>
<RemoteButton type="warning" @click="handleClick('warning')">
警告按钮
</RemoteButton>
<RemoteButton type="danger" @click="handleClick('danger')">
危险按钮
</RemoteButton>
</div>
<p v-if="clickMessage" class="message">{{ clickMessage }}</p>
</div>
</div>
<!-- 远程工具函数 -->
<div v-if="currentView === 'utils'" class="view-container">
<h2>远程工具函数</h2>
<div class="demo-section">
<h3>日期格式化</h3>
<p>原始时间: {{ currentTime }}</p>
<p>格式化后: {{ formattedTime }}</p>
</div>
<div class="demo-section">
<h3>防抖函数测试</h3>
<input
v-model="searchText"
@input="debouncedSearch"
placeholder="输入搜索关键词(防抖 500ms)"
>
<p v-if="searchResult">搜索结果: {{ searchResult }}</p>
</div>
</div>
<!-- 混合使用 -->
<div v-if="currentView === 'mixed'" class="view-container">
<h2>混合使用示例</h2>
<div class="demo-section">
<h3>表单示例</h3>
<div class="form-group">
<label>用户名:</label>
<input v-model="formData.username" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>邮箱:</label>
<input v-model="formData.email" placeholder="请输入邮箱">
</div>
<div class="form-group">
<label>创建时间:</label>
<p>{{ formatDate(formData.createdAt) }}</p>
</div>
<div class="form-actions">
<RemoteButton type="primary" @click="submitForm">
提交
</RemoteButton>
<RemoteButton @click="resetForm">
重置
</RemoteButton>
</div>
</div>
</div>
</main>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { defineAsyncComponent } from 'vue'
// 动态导入远程组件
const RemoteButton = defineAsyncComponent(() =>
import('remoteApp1/Button')
)
const RemoteHeader = defineAsyncComponent(() =>
import('remoteApp1/Header')
)
// 导入远程工具函数
import('remoteApp1/utils').then(module => {
window.remoteUtils = module
})
const appTitle = ref('Module Federation 示例')
const username = ref('Admin')
const currentView = ref('local')
const count = ref(0)
const clickMessage = ref('')
const currentTime = ref(Date.now())
const searchText = ref('')
const searchResult = ref('')
const menus = ref([
{ name: '首页', path: '/' },
{ name: '应用1', path: '/app1' },
{ name: '应用2', path: '/app2' }
])
const formData = ref({
username: '',
email: '',
createdAt: Date.now()
})
const formattedTime = computed(() => {
if (!window.remoteUtils) return '加载中...'
return window.remoteUtils.formatDate(currentTime.value)
})
const handleMenuClick = (item) => {
console.log('点击菜单', item)
clickMessage.value = `点击了菜单: ${item.name}`
}
const handleClick = (type) => {
clickMessage.value = `点击了 ${type} 按钮`
setTimeout(() => {
clickMessage.value = ''
}, 3000)
}
const logout = () => {
if (confirm('确定要退出吗?')) {
username.value = ''
alert('已退出')
}
}
// 防抖搜索
let debouncedSearch = null
onMounted(() => {
// 等待工具函数加载
const checkUtils = setInterval(() => {
if (window.remoteUtils) {
debouncedSearch = window.remoteUtils.debounce((e) => {
const value = e.target.value
if (value) {
searchResult.value = `搜索 "${value}" 的结果...`
// 模拟搜索
setTimeout(() => {
searchResult.value = `找到 ${Math.floor(Math.random() * 100)} 条关于 "${value}" 的结果`
}, 500)
}
}, 500)
clearInterval(checkUtils)
}
}, 100)
// 更新时间
setInterval(() => {
currentTime.value = Date.now()
}, 1000)
})
const formatDate = (timestamp) => {
if (!window.remoteUtils) return '加载中...'
return window.remoteUtils.formatDate(timestamp)
}
const submitForm = () => {
if (!formData.value.username || !formData.value.email) {
alert('请填写完整信息')
return
}
alert(`提交成功!
用户名: ${formData.value.username}
邮箱: ${formData.value.email}
创建时间: ${formatDate(formData.value.createdAt)}`)
}
const resetForm = () => {
formData.value = {
username: '',
email: '',
createdAt: Date.now()
}
}
</script>
<style scoped>
#host-app {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.host-content {
display: flex;
flex: 1;
}
.host-sidebar {
width: 200px;
background: white;
padding: 20px;
box-shadow: 2px 0 8px rgba(0,0,0,0.08);
}
.host-sidebar h3 {
margin-bottom: 16px;
color: #333;
}
.host-sidebar ul {
list-style: none;
padding: 0;
}
.host-sidebar li {
padding: 12px;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
margin-bottom: 4px;
}
.host-sidebar li:hover {
background: #f0f2f5;
}
.host-main {
flex: 1;
padding: 24px;
background: #f0f2f5;
}
.view-container {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.view-container h2 {
margin-bottom: 24px;
color: #001529;
}
.demo-section {
margin-bottom: 32px;
padding-bottom: 32px;
border-bottom: 1px solid #e8e8e8;
}
.demo-section:last-child {
border-bottom: none;
}
.demo-section h3 {
margin-bottom: 16px;
color: #666;
}
.button-group {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.message {
padding: 12px;
background: #e6f7ff;
border: 1px solid #91d5ff;
border-radius: 4px;
color: #1890ff;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
}
.form-group input:focus {
outline: none;
border-color: #1890ff;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
.user-info {
margin-right: 16px;
color: rgba(255, 255, 255, 0.85);
}
</style>
// host-app/src/main.js
import('./bootstrap')
// host-app/src/bootstrap.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')
2.4 运行和构建
package.json 配置
{
"name": "host-app",
"version": "1.0.0",
"scripts": {
"dev": "webpack serve",
"build": "webpack --mode production"
},
"dependencies": {
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"axios": "^1.5.0"
},
"devDependencies": {
"webpack": "^5.88.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
"vue-loader": "^17.2.2",
"babel-loader": "^9.1.3",
"@babel/core": "^7.22.9",
"@babel/preset-env": "^7.22.9",
"css-loader": "^6.8.1",
"style-loader": "^3.3.3",
"html-webpack-plugin": "^5.5.3"
}
}
启动命令
# 启动远程应用1
cd remote-app1
npm install
npm run dev # http://localhost:3001
# 启动远程应用2(可选)
cd remote-app2
npm install
npm run dev # http://localhost:3002
# 启动宿主应用
cd host-app
npm install
npm run dev # http://localhost:3000
访问 http://localhost:3000 即可看到效果。
三、高级特性
3.1 动态远程加载
// 动态加载远程应用
function loadRemoteModule(url, scope, module) {
return async () => {
// 初始化共享作用域
await __webpack_init_sharing__('default')
// 创建 script 标签加载远程入口
const script = document.createElement('script')
script.src = url
await new Promise((resolve, reject) => {
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
// 获取容器
const container = window[scope]
// 初始化容器
await container.init(__webpack_share_scopes__.default)
// 获取模块
const factory = await container.get(module)
return factory()
}
}
// 使用示例
const RemoteButton = defineAsyncComponent(() =>
loadRemoteModule(
'http://localhost:3001/remoteEntry.js',
'remoteApp1',
'./Button'
)
)
3.2 版本管理
// webpack.config.js
new ModuleFederationPlugin({
shared: {
vue: {
singleton: true,
requiredVersion: '^3.3.0',
// 严格版本检查
strictVersion: true,
// 版本不匹配时的处理
// false: 不加载(默认)
// 'warn': 警告但继续加载
shareScope: 'default'
}
}
})
3.3 共享作用域
// 自定义共享作用域
new ModuleFederationPlugin({
name: 'app',
shared: {
vue: {
singleton: true,
shareScope: 'myScope' // 自定义作用域
}
}
})
// 不同作用域的模块不会共享
// 'default' 作用域和 'myScope' 作用域的 Vue 会分别加载