模块联邦

很多人觉得他不是微前端,也有人定义它也是微前端 可以理解他是一个去中心化技术,它可以让多个独立构建的应用之间,动态的调用彼此的模块。这种运行机制,可以让我们轻松的拆分应用,真正做到跨应用的模块共享。

emp 框架:https://emp2.netlify.app/

核心原理

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

1
2
3
4
5
6
7
8
9
10
11
12
13
传统方式:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 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 实战配置

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// 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'
}
}
}

远程应用代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// <!-- 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<!-- 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 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 文件(重要)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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()
}
1
2
3
// remote-app1/src/main.js
// 异步导入 bootstrap,确保共享依赖先加载
import('./bootstrap')

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

Webpack 配置1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// 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'
}
}
}

宿主应用代码1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
<!-- 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>
1
2
// host-app/src/main.js
import('./bootstrap')
1
2
3
4
5
6
// 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 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"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
2
3
4
5
6
7
8
9
10
11
12
13
14
# 启动远程应用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 动态远程加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 动态加载远程应用
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 版本管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// webpack.config.js
new ModuleFederationPlugin({
shared: {
vue: {
singleton: true,
requiredVersion: '^3.3.0',
// 严格版本检查
strictVersion: true,
// 版本不匹配时的处理
// false: 不加载(默认)
// 'warn': 警告但继续加载
shareScope: 'default'
}
}
})

3.3 共享作用域

1
2
3
4
5
6
7
8
9
10
11
12
13
// 自定义共享作用域
new ModuleFederationPlugin({
name: 'app',
shared: {
vue: {
singleton: true,
shareScope: 'myScope' // 自定义作用域
}
}
})

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