返回笔记首页

Module Federation 联邦模块-上

主题配置

一、Module Federation 核心原理

1.1 什么是 Module Federation

Module Federation(模块联邦)是 Webpack 5 的革命性特性,允许多个独立的构建可以在运行时共享代码。

plain
传统方式:
┌─────────┐  ┌─────────┐  ┌─────────┐
│  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 项目结构

plain
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 配置

javascript
// 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'
    }
  }
}

远程应用代码

vue
<!-- 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>
vue
<!-- 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>
javascript
// 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 文件(重要)

javascript
// 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()
}
javascript
// remote-app1/src/main.js
// 异步导入 bootstrap,确保共享依赖先加载
import('./bootstrap')

2.3 宿主应用配置(消费模块)

Webpack 配置

javascript
// 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'
    }
  }
}

宿主应用代码

vue
<!-- 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>
javascript
// host-app/src/main.js
import('./bootstrap')
javascript
// 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 配置

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"
  }
}

启动命令

bash
# 启动远程应用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 动态远程加载

javascript
// 动态加载远程应用
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 版本管理

javascript
// webpack.config.js
new ModuleFederationPlugin({
  shared: {
    vue: {
      singleton: true,
      requiredVersion: '^3.3.0',
      // 严格版本检查
      strictVersion: true,
      // 版本不匹配时的处理
      // false: 不加载(默认)
      // 'warn': 警告但继续加载
      shareScope: 'default'
    }
  }
})

3.3 共享作用域

javascript
// 自定义共享作用域
new ModuleFederationPlugin({
  name: 'app',
  shared: {
    vue: {
      singleton: true,
      shareScope: 'myScope' // 自定义作用域
    }
  }
})

// 不同作用域的模块不会共享
// 'default' 作用域和 'myScope' 作用域的 Vue 会分别加载