Vue 基础体系 · 第 62/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 微前端:路由、状态、样式、依赖隔离和迁移取舍
微前端不是“把一个 Vue 项目拆成多个目录”,而是把一个前端系统拆成多个能够独立开发、独立构建、独立发布,且在运行时共同组成用户体验的应用单元。
这里的“独立”至少涉及四个边界:
- 构建边界:子应用可以单独执行类型检查、测试和构建。
- 发布边界:子应用发布不必重新构建主应用。
- 运行时边界:主应用和子应用之间通过明确协议通信。
- 故障边界:一个子应用失败时,系统是否仍能显示其他区域或提供降级页面。
微前端的难点不在“如何加载另一个 JavaScript 文件”,而在于加载之后谁负责路由、状态、样式、依赖和错误恢复。若这些问题没有明确边界,最终通常只是一个更难维护的单体前端。
一、先确定拆分模型:微前端不是唯一实现
Vue 3 应用可以采用多种组合方式。它们都能实现“一个页面由多个前端单元构成”,但隔离强度、通信成本和迁移成本不同。
1. iframe:隔离最强,协作成本最高
iframe 创建独立的浏览上下文。子应用拥有自己的:
window和document- JavaScript 全局对象
- CSS 作用域
- 路由实例
- 依赖加载环境
- 错误边界
父页面和 iframe 之间不能直接访问对方 DOM,通常通过 postMessage 通信。
flowchart LR
B[浏览器] --> H[主应用]
H --> I[iframe]
I --> C[子应用]
H <-->|postMessage| I
H --> HR[主应用路由]
I --> CR[子应用路由]
iframe 适合以下场景:
- 子应用技术栈不同;
- 子应用由不同团队或组织维护;
- 历史系统难以改造;
- 安全边界比交互性能更重要;
- 允许子应用有独立地址栏、滚动容器或登录上下文。
它的代价也很明确:布局高度同步、返回按钮协同、跨应用状态共享、统一弹窗和无障碍体验都需要额外协议。iframe 不是“没有隔离成本”,而是把很多成本转换成跨窗口通信成本。
2. 同页面运行时组合:体验更自然,隔离依赖实现
主应用和子应用运行在同一个页面中,可以通过以下方式组合:
- 子应用暴露一个挂载函数;
- 子应用构建为 Web Component;
- 使用运行时加载器或微前端框架;
- 使用 Module Federation 等远程模块机制。
这种方式可以共享布局、弹窗、路由上下文和浏览器窗口,但也意味着默认共享:
windowdocument- JavaScript 全局副作用
- CSS 文档环境
- 网络和存储
- 可能重复加载的依赖
因此,同页面组合必须显式解决卸载、样式、依赖版本和全局副作用问题。
3. 构建时组合:最简单,但不是真正的独立发布
例如把多个 Vue 包作为 monorepo 中的 workspace,最后由一个主应用统一构建。它适合代码组织和团队拆分,但子模块的发布仍然依赖主应用构建。
packages/
design-system/
user-domain/
order-domain/
apps/
admin/
这种方式不具备完整的运行时独立发布能力,却常常是最合理的第一步。若团队尚未遇到独立发布、独立技术栈或故障隔离问题,直接引入运行时微前端通常是在提前支付复杂度。
二、运行时组合的基本协议
无论采用哪种加载方案,子应用都应暴露相对稳定的生命周期接口。一个最小协议可以定义为:
export interface MicroAppContext {
basePath: string
getToken?: () => string | undefined
onEvent?: (event: {
type: string
payload?: unknown
}) => void
}
export interface MicroAppHandle {
unmount(): void | Promise<void>
}
export interface MicroAppModule {
mount(
container: HTMLElement,
context: MicroAppContext,
): Promise<MicroAppHandle> | MicroAppHandle
}
这里有三个重要约束:
mount必须接收明确的容器,而不是默认接管整个document.body。mount必须返回unmount,否则路由切换后旧应用可能继续监听事件、定时器和网络回调。- 主应用通过
context注入能力,而不是让子应用随意读取主应用内部变量。
子应用的生命周期通常是:
未加载
-> 加载模块
-> 创建 Vue 应用
-> mount(container)
-> 运行中
-> unmount()
-> 销毁监听器、定时器、组件和副作用
如果使用 Vue 3,createApp() 返回的应用实例必须保存下来,并在卸载时调用 app.unmount():
// child-entry.ts
import { createApp } from 'vue'
import App from './App.vue'
import type { MicroAppContext, MicroAppHandle } from './contract'
export async function mount(
container: HTMLElement,
context: MicroAppContext,
): Promise<MicroAppHandle> {
const root = document.createElement('div')
root.className = 'orders-root'
container.replaceChildren(root)
const app = createApp(App, {
basePath: context.basePath,
})
const onResize = () => {
// 示例:子应用自己的副作用
}
window.addEventListener('resize', onResize)
app.mount(root)
return {
unmount() {
window.removeEventListener('resize', onResize)
app.unmount()
container.replaceChildren()
},
}
}
这段代码的关键不是 Vue API 本身,而是副作用的成对管理:
addEventListener -> removeEventListener
setInterval -> clearInterval
subscribe -> unsubscribe
createApp -> app.unmount
appendChild -> removeChild
如果漏掉任意一项,切换路由多次后就可能出现重复请求、重复事件处理、旧组件修改新页面状态等问题。
三、路由:只能有一个“浏览器地址解释者”
路由问题通常不是“Vue Router 怎么配置”,而是谁拥有 URL 的哪一部分。
设浏览器当前地址为:
https://example.com/portal/orders/detail/42?from=home
可以定义:
- 主应用路径:
/portal - 子应用路径:
/orders - 子应用内部路径:
/detail/42 - 查询参数:
from=home
一个清晰的路由分层如下:
主应用:/portal
├── /dashboard
├── /users/**
└── /orders/** -> 交给 orders 子应用
子应用内部解析 /detail/42
主应用不应该同时把 /orders/detail/42 和子应用都当成最终路由所有者。否则会出现两个路由器争抢同一个 URL 的问题:
- 主应用先匹配并渲染空壳;
- 子应用再根据同一 URL 匹配;
- 前进、后退时双方监听顺序不确定;
- 子应用刷新后无法恢复自身路由;
- 访问未知路径时可能重复执行 404 逻辑。
1. 主应用路由配置
主应用可以把某个前缀作为子应用挂载点:
// host/src/router.ts
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from './views/Dashboard.vue'
import MicroAppView from './views/MicroAppView.vue'
export const router = createRouter({
history: createWebHistory('/portal/'),
routes: [
{
path: '/dashboard',
component: Dashboard,
},
{
path: '/orders/:pathMatch(.*)*',
component: MicroAppView,
},
],
})
这里的 /orders/:pathMatch(.*)* 只表示:
主应用把
/orders及其后续路径交给一个挂载容器。
它不应该再解析 detail/42 的业务含义。
2. 子应用路由配置
子应用使用自己的基础路径:
// orders/src/router.ts
import { createRouter, createWebHistory } from 'vue-router'
import ListPage from './pages/ListPage.vue'
import DetailPage from './pages/DetailPage.vue'
export function createOrdersRouter(basePath: string) {
return createRouter({
history: createWebHistory(basePath),
routes: [
{
path: '/',
component: ListPage,
},
{
path: '/detail/:id',
component: DetailPage,
},
],
})
}
在 /portal/orders/detail/42 下,子应用的 basePath 应为:
/portal/orders
于是子应用看到的内部路径是:
/detail/42
这是路由分层的核心变换:
浏览器完整路径 /portal/orders/detail/42
去掉主应用和子应用前缀 /portal/orders
子应用内部路径 /detail/42
3. 将路径交给子应用
主应用视图可以根据当前路径加载并挂载子应用:
<!-- host/src/views/MicroAppView.vue -->
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { useRoute } from 'vue-router'
import type { MicroAppHandle } from '../micro/contract'
const route = useRoute()
const container = ref<HTMLElement | null>(null)
let handle: MicroAppHandle | undefined
onMounted(async () => {
if (!container.value) return
const module = await import('../micro/loadOrders')
handle = await module.mount(container.value, {
basePath: '/portal/orders',
onEvent(event) {
console.log('orders event', event)
},
})
})
onBeforeUnmount(async () => {
await handle?.unmount()
handle = undefined
})
</script>
<template>
<section ref="container" aria-label="订单应用" />
</template>
示例中的 import('../micro/loadOrders') 是本地演示形式。生产环境可以把它替换为远程模块加载器,但生命周期和路由边界不应因此改变。
4. Hash 路由和 History 路由的取舍
createWebHistory() 使用真实 URL,例如:
/portal/orders/detail/42
优点是地址自然、SEO 和复制链接更直观;缺点是服务器必须把未知前端路径回退到入口 HTML。若服务器没有配置回退,用户刷新该地址可能得到 404。
createWebHashHistory() 使用:
/portal/orders#/detail/42
哈希之后的内容不会作为服务器路径发送,部署简单,但主应用和子应用之间容易产生双重 hash 规则,地址也不如 History 直观。
一个常见错误是:
// 子应用错误示例
createWebHistory('/')
当子应用部署在 /portal/orders 下时,它会认为自己拥有域名根路径,生成 /detail/42,从而覆盖主应用路径。正确的基础路径必须与部署位置一致,或者由主应用通过上下文注入。
5. iframe 的路由边界
iframe 中的子应用可以独立使用自己的 History 路由,但浏览器地址栏默认只反映父页面地址。若希望父页面记录子应用内部路径,需要定义协议:
// 子应用 iframe
window.parent.postMessage(
{
source: 'orders',
type: 'route-change',
path: '/detail/42',
},
'https://example.com',
)
// 主应用
window.addEventListener('message', (event) => {
if (event.origin !== 'https://orders.example.com') return
if (event.data?.source !== 'orders') return
if (event.data?.type !== 'route-change') return
// 根据协议更新父级 URL
})
不能只检查 event.data.type。postMessage 的安全判断至少要验证 origin 和消息来源标识,否则任意页面都可能向主应用伪造路由或业务事件。
四、状态:先区分“所有权”,再讨论共享
状态是微前端中最容易被错误共享的部分。一个状态的“可见范围”不等于它的“所有权”。
可以把状态分成四类:
| 状态类型 | 示例 | 默认所有者 |
|---|---|---|
| 视图局部状态 | 弹窗开关、表单输入 | 当前组件 |
| 子域业务状态 | 订单列表、筛选条件、订单详情缓存 | 订单子应用 |
| 会话状态 | 用户身份、权限、租户 | 主应用或统一会话层 |
| 跨域业务状态 | 当前组织、主题、语言 | 明确定义的共享协议 |
1. 子应用内部状态应保持私有
订单列表只被订单子应用使用,就没有理由放进主应用 Pinia:
// orders/src/stores/order.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useOrderStore = defineStore('orders/order', () => {
const items = ref<{ id: string; title: string }[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
async function load() {
loading.value = true
error.value = null
try {
const response = await fetch('/api/orders')
if (!response.ok) {
throw new Error(`订单请求失败:${response.status}`)
}
items.value = await response.json()
} catch (err) {
error.value = err instanceof Error ? err : new Error('未知错误')
} finally {
loading.value = false
}
}
return { items, loading, error, load }
})
这样做的因果关系是:
订单状态仅由订单子应用使用
-> 状态所有权属于订单子应用
-> 主应用无需知道其内部结构
-> 子应用可以单独重构 store、缓存和请求策略
如果把所有业务状态集中到主应用,表面上通信简单,实际上会形成“隐式单体”:子应用无法独立演进,主应用也必须理解所有业务字段。
2. 共享状态应通过最小协议传输
例如主应用提供当前用户信息,但不把整个 Pinia store 暴露给子应用:
export interface SessionSnapshot {
userId: string
tenantId: string
roles: string[]
}
export interface HostCapabilities {
getSession(): SessionSnapshot | null
getAccessToken(): string | undefined
navigate(path: string): void
}
子应用拿到的是能力和快照:
const session = context.getSession?.()
if (!session) {
// 显示未登录或等待会话恢复
}
不要暴露:
context.hostPiniaStore
context.router
context.internalHttpClient
context.currentUserRef
因为这些对象会把实现细节变成跨应用 ABI。主应用一旦更换状态管理库、路由实例或请求封装,所有子应用都可能被迫修改。
3. 事件适合通知,不适合承载完整状态
事件应表达“发生了什么”,而不是让接收方依赖发送方的内部对象:
type HostEvent =
| {
type: 'session-changed'
payload: { userId: string; tenantId: string }
}
| {
type: 'order-created'
payload: { orderId: string }
}
比较稳定的流程是:
子应用创建订单
-> 发出 order-created(orderId)
-> 主应用或其他应用收到通知
-> 接收方按自己的数据源重新读取订单
而不是:
子应用创建订单
-> 把整个订单 store、响应式对象和缓存传给主应用
前者传输的是事实,后者传输的是实现。
4. 跨应用状态必须处理并发和过期
假设主应用提供异步会话恢复,子应用同时启动。可能出现:
t0:子应用开始挂载
t1:子应用读取 session,结果为 null
t2:主应用完成刷新 token
t3:子应用已经显示“未登录”
因此,“没有会话”和“会话尚未恢复”必须区分:
type SessionState =
| { status: 'loading' }
| { status: 'authenticated'; user: SessionSnapshot }
| { status: 'anonymous' }
| { status: 'error'; error: Error }
子应用只有在 status === 'anonymous' 时才能确认用户未登录。loading 不能被当成未登录,否则启动时会发生闪烁或错误跳转。
异步请求还需要防止卸载后的旧请求修改状态:
import { onBeforeUnmount } from 'vue'
const controller = new AbortController()
fetch('/api/orders', {
signal: controller.signal,
}).catch((error) => {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
// 处理真实错误
})
onBeforeUnmount(() => {
controller.abort()
})
微前端切换速度更快、并发入口更多,因此“请求完成后组件还存在吗”是必须考虑的生命周期问题。
5. URL 是一种共享状态,但不应重复拥有
筛选条件、分页、选中对象等状态可以放在 URL 中:
/portal/orders?status=paid&page=2
这样刷新和复制链接都能恢复状态。但 URL 仍然必须有单一写入规则:
- 主应用负责
/portal和子应用前缀; - 订单子应用负责
status、page; - 其他应用不能随意删除或重写订单参数。
否则两个应用都监听 popstate 并修改查询参数时,可能发生互相覆盖甚至导航循环。
五、样式:作用域不是完整隔离
Vue 的 <style scoped> 会通过属性选择器限制组件样式,例如:
<template>
<button class="submit">提交</button>
</template>
<style scoped>
.submit {
color: white;
}
</style>
编译后通常会生成类似:
.submit[data-v-abc123] {
color: white;
}
这能降低组件样式泄漏,但不能提供完整的微前端隔离,原因包括:
body、html、*等全局选择器仍可能影响页面;- CSS 自定义属性会通过继承传播;
- 弹窗、下拉菜单、浮层可能挂载到
body; - 第三方组件库可能注入全局样式;
- 子应用的字体、动画名、层叠顺序仍可能冲突;
scoped不会自动隔离 JavaScript 动态插入的样式。
1. CSS 命名空间
同页面子应用至少应有根命名空间:
<template>
<div class="orders-app">
<header class="orders-app__header">订单</header>
<button class="orders-app__button">刷新</button>
</div>
</template>
<style>
.orders-app {
--orders-primary: #1677ff;
}
.orders-app .orders-app__button {
color: white;
background: var(--orders-primary);
}
</style>
选择器从根元素开始,可以把影响范围限制在子应用内部。
不应在子应用中随意写:
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.modal {
z-index: 9999;
}
这些规则可能修改主应用或其他子应用的行为。
2. CSS 变量是共享协议,不是任意继承
主应用可以提供设计令牌:
:root {
--color-primary: #1677ff;
--space-2: 8px;
}
子应用使用这些变量:
.orders-app {
color: var(--color-text, #1f2329);
padding: var(--space-2, 8px);
}
这里的默认值很重要:如果子应用被单独运行,仍然应该有可用外观。
但如果两个应用都定义同名变量:
:root {
--color-primary: red;
}
后加载的样式可能覆盖先加载的样式。因此共享变量必须由设计系统或主应用统一命名,子应用不应重新定义公共变量。
3. Shadow DOM:更强的样式边界
Web Component 可以使用 Shadow DOM:
class OrdersElement extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' })
root.innerHTML = `
<style>
.button { color: white; background: #1677ff; }
</style>
<button class="button">订单</button>
`
}
}
customElements.define('orders-app', OrdersElement)
Shadow DOM 中的普通样式不会直接泄漏到外部,外部普通选择器也不能直接匹配内部元素。但它仍有边界:
- CSS 变量可以从宿主元素继承;
::part、::slotted提供了有意的穿透方式;- 第三方库若依赖
document.body挂载浮层,仍需适配; - Vue 组件使用 Shadow DOM 时,样式注入和组件库兼容性要验证;
- Shadow DOM 解决的是 DOM/CSS 边界,不是状态、路由或依赖边界。
4. 浮层是样式隔离的高风险点
很多组件库会把 Dialog、Select、Tooltip 挂载到 document.body。这会绕过子应用根节点的命名空间。
可以优先让浮层挂载在子应用容器内:
const container = document.querySelector('.orders-app')
具体配置名称取决于组件库,不能假设所有 Vue UI 库都支持相同参数。验证时应检查:
- 浮层 DOM 是否位于子应用容器内;
- 主应用的全局样式是否改变浮层;
- 子应用卸载后浮层是否被移除;
- 多个子应用同时打开浮层时
z-index是否有统一规则。
六、依赖隔离:版本、实例和副作用是三个不同问题
“依赖隔离”不是简单地说“每个项目有自己的 package.json”。至少要区分:
- 版本隔离:子应用可使用不同版本的库。
- 实例隔离:不同版本或不同应用的运行时对象互不污染。
- 副作用隔离:依赖注入的全局 CSS、事件、插件和单例不互相破坏。
1. iframe 的依赖隔离
iframe 的脚本环境天然分离。子应用可以使用自己的 Vue、Vue Router 和 Pinia,不会与父页面的同名全局变量合并。
代价是重复下载和重复初始化。如果主应用和子应用都加载 Vue,浏览器可能缓存文件,但仍可能存在两份运行时实例;缓存命中不等于运行时实例共享。
2. 同页面组合的依赖策略
同页面组合通常有三种策略。
策略 A:完全自带依赖
每个子应用把 Vue 等依赖打包进自己的产物。
优点:
- 版本互不影响;
- 子应用独立运行最简单;
- 升级某个应用不要求其他应用同步。
缺点:
- 资源可能重复;
- 同页面存在多个框架运行时;
- 全局副作用类库仍可能冲突。
适用于隔离优先或版本差异较大的场景。
策略 B:共享依赖
主应用和子应用约定共享 Vue、Vue Router 等依赖。
优点:
- 减少重复资源;
- 运行时对象可以统一;
- 设计系统和插件更容易统一。
缺点:
- 版本必须满足兼容范围;
- 主应用升级可能影响所有子应用;
- 子应用独立运行时要有另一套依赖处理;
- “共享 Vue”并不自动共享 Pinia store 或插件配置。
共享依赖必须有可验证的版本约束。例如:
Vue 主版本必须为 3
Vue Router 只能使用 4.x
设计系统包必须与 Vue 3 兼容
不能只写“大家尽量用同版本”。发布系统应在构建或集成测试阶段检查实际版本。
策略 C:按依赖类型分别处理
可以将依赖分为:
- 运行时基础依赖:Vue;
- 领域依赖:订单表格、图表库;
- 设计系统:组件和 CSS 变量;
- 基础设施:请求、监控、日志。
其中 Vue 是否共享,要根据组合机制和版本治理能力决定;领域依赖通常由子应用自己管理;设计系统则需要更强的契约测试。
3. Vite 中的外部化不是自动微前端共享
Vite 提供开发服务器、构建和依赖处理能力,但“远程模块运行时共享”通常需要额外机制。Vite 官方工具链并没有因为使用 Vite 就自动拥有微前端运行时。
例如,下面的配置会让某个依赖不进入产物:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
external: ['vue'],
},
},
})
但 external 只表示:
构建时不要把
vue打包进去。
它并没有告诉浏览器运行时从哪里获得 vue。如果页面没有通过其他方式提供该模块,最终会出现导入失败。要完成共享,还需要 import map、全局变量映射、远程模块协议或构建插件等完整方案。
因此,判断依赖共享是否成立,至少要验证:
构建产物是否包含依赖
-> 浏览器能否解析依赖地址
-> 主子应用是否获得兼容版本
-> 是否共享了同一个运行时实例
-> 插件和全局副作用是否兼容
4. Vue 插件实例不能随意跨应用复用
Vue 插件可能注册:
- 全局组件;
- 全局指令;
app.config.globalProperties;- provide/inject;
- 全局事件监听;
- CSS;
- 单例缓存。
即使两个应用使用同一个 Vue 版本,也不能假设一个应用的 app.use(plugin) 会自动适用于另一个应用。每个 createApp() 都是独立应用实例,插件安装需要明确发生在哪个实例上。
反过来,如果某个库直接修改 window 或 document,它也可能跨越 Vue 应用实例边界。因此依赖审查不能只看 npm 包名,还要看其副作用模型。
七、一个可运行的最小示例:主应用加载独立子应用
下面用两个 Vite 项目表达运行时契约。它不是完整的 Module Federation 配置,而是一个可理解、可测试的 ESM 组合示例。
1. 子应用构建为库
子应用入口:
// orders/src/micro-entry.ts
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import type { MicroAppContext, MicroAppHandle } from './contract'
export async function mount(
container: HTMLElement,
context: MicroAppContext,
): Promise<MicroAppHandle> {
const root = document.createElement('div')
root.className = 'orders-app'
container.replaceChildren(root)
const router = createRouter({
history: createWebHistory(context.basePath),
routes: [
{ path: '/', component: App },
],
})
const app = createApp(App, {
getToken: context.getToken,
})
app.use(router)
await router.isReady()
app.mount(root)
return {
unmount() {
app.unmount()
container.replaceChildren()
},
}
}
Vite 配置可以把入口构建为 ESM 库:
// orders/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: 'src/micro-entry.ts',
formats: ['es'],
fileName: 'orders-micro',
},
},
})
构建后会生成 JavaScript 产物。若主应用直接通过 URL 动态导入,服务器必须正确提供:
- JavaScript MIME 类型;
- CORS 响应头;
- 与子应用产物匹配的静态资源路径;
- 稳定的版本化文件名或 manifest。
2. 使用 manifest 解析版本化资源
不要把不可变资源写成永远覆盖的 remote.js,否则浏览器缓存和 CDN 可能导致主应用读取旧版本。可以使用一个 manifest:
{
"name": "orders",
"entry": "https://cdn.example.com/orders/2025.03.08/orders-micro.js",
"integrity": "sha384-..."
}
加载器:
// host/src/micro/loadOrders.ts
import type { MicroAppModule } from './contract'
interface Manifest {
name: string
entry: string
}
export async function loadOrders(): Promise<MicroAppModule> {
const response = await fetch('/micro-manifest/orders.json', {
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`无法读取订单子应用清单:${response.status}`)
}
const manifest = (await response.json()) as Manifest
if (manifest.name !== 'orders') {
throw new Error('订单子应用名称校验失败')
}
return import(/* @vite-ignore */ manifest.entry)
}
这里的 @vite-ignore 表示让 Vite 不把动态 URL 当成构建时静态依赖分析。它不是安全校验,也不会自动解决 CORS、签名或版本兼容问题。
主应用挂载时应捕获加载失败:
async function loadAndMount(container: HTMLElement) {
try {
const orders = await loadOrders()
const handle = await orders.mount(container, {
basePath: '/portal/orders',
getToken: () => sessionStorage.getItem('access_token') ?? undefined,
})
return handle
} catch (error) {
console.error('订单应用加载失败', error)
container.innerHTML = `
<div role="alert">
订单模块暂时不可用,请稍后重试。
</div>
`
return undefined
}
}
生产中还应考虑:
- manifest 不可用;
- entry 下载超时;
- JavaScript 语法或依赖加载失败;
- 子应用
mount抛错; - 子应用运行期间出现未捕获异常;
- 发布版本回滚;
- 子应用接口与主应用协议不兼容。
错误边界只能阻止一个错误继续扩散,不能自动恢复业务数据。恢复动作需要根据故障阶段决定:
manifest 失败 -> 使用最近可用版本或显示降级页
entry 失败 -> 重试、切换 CDN 或回滚版本
mount 失败 -> 清理容器并显示错误边界
运行时失败 -> 捕获错误、记录版本信息、允许重新加载子应用
接口失败 -> 子应用显示领域级错误和重试入口
八、故障路径:必须验证“加载失败”和“卸载失败”
微前端的故障不仅是白屏。常见路径如下:
sequenceDiagram
participant U as 用户
participant H as 主应用
participant L as 加载器
participant C as 子应用
participant A as API
U->>H: 访问 /portal/orders
H->>L: 获取 manifest
alt manifest 或 entry 失败
L-->>H: 加载错误
H-->>U: 显示降级页面/重试
else 加载成功
L->>C: 调用 mount
C->>A: 请求订单数据
alt API 失败
A-->>C: 5xx/网络错误
C-->>U: 领域错误和重试
else API 成功
A-->>C: 数据
C-->>U: 显示订单页面
end
U->>H: 切换到 /portal/dashboard
H->>C: 调用 unmount
C-->>H: 清理完成
end
必须测试以下情况,而不是只测试首次成功加载:
- 连续进入、离开订单页面十次;
- 子应用请求尚未返回时离开页面;
mount执行一半抛出异常;- 子应用打开浮层后被卸载;
- 子应用注册了
window事件后被卸载; - 主应用路由快速连续跳转;
- 旧版本子应用被缓存后加载;
- 子应用 API 返回未授权并触发主应用刷新令牌。
可以通过浏览器性能面板和日志验证泄漏:
- 事件处理次数是否随进入次数增长;
- 网络请求是否在卸载后仍持续;
- DOM 节点是否残留;
- 定时器和 WebSocket 是否关闭;
- Vue Devtools 中组件树是否消失;
- 同一路由是否出现多个历史监听器。
九、通信方式的选择和边界
1. Props / callbacks:适合父子关系
如果子应用只是主页面中的一个稳定区域,可以使用属性和回调:
<OrdersWidget
:tenant-id="session.tenantId"
@order-created="refreshSummary"
/>
它的优点是类型清晰、调用链明确;缺点是跨越多个应用后会出现层层透传。因此它适合局部组合,不适合多个独立发布应用之间的全局消息总线。
2. 自定义事件:适合同一页面的松耦合通知
window.dispatchEvent(
new CustomEvent('orders:created', {
detail: { orderId: '42' },
}),
)
监听方:
function onOrderCreated(event: Event) {
const customEvent = event as CustomEvent<{ orderId: string }>
console.log(customEvent.detail.orderId)
}
window.addEventListener('orders:created', onOrderCreated)
function dispose() {
window.removeEventListener('orders:created', onOrderCreated)
}
事件名必须带命名空间,并定义 payload 版本。否则多个子应用都使用 created 之类的通用名称,排查会非常困难。
3. postMessage:适合 iframe 或跨窗口
postMessage 的消息必须验证:
window.addEventListener('message', (event) => {
if (event.origin !== 'https://orders.example.com') return
const data: unknown = event.data
if (!isOrdersMessage(data)) return
// 处理已校验的消息
})
不要直接执行:
window.location.href = event.data.path
因为消息内容可能来自不可信页面。路径还应经过允许列表校验,避免任意 URL 跳转。
4. 共享 store:耦合最强
共享 Pinia 实例可以减少通信代码,但它要求:
- Vue 和 Pinia 运行时兼容;
- store 的字段和 action 成为公共协议;
- 主应用和子应用发布需要协调;
- 卸载时不能误清理其他应用正在使用的数据。
因此共享 store 应被视为基础设施级契约,而不是快捷变量传递方式。
十、迁移:从单体 Vue 到微前端的渐进路径
迁移的目标不是“尽快拆成最多应用”,而是验证拆分边界是否真实存在。
阶段一:先在单体内建立领域边界
先不引入运行时加载,把代码按领域拆分:
src/
domains/
orders/
pages/
components/
stores/
api/
users/
shared/
ui/
session/
此阶段重点验证:
- 订单模块是否能只依赖公共接口;
- 页面路由是否已经按业务域分组;
- 状态是否能从全局 store 收缩到领域 store;
- 公共组件是否真的稳定。
如果单体内部都无法定义清晰边界,拆成多个部署单元后只会增加通信问题。
阶段二:抽取协议和契约
建立独立的契约包:
// contracts/orders.ts
export interface OrdersMountContext {
basePath: string
getAccessToken(): string | undefined
emit(event: OrdersEvent): void
}
export type OrdersEvent =
| { type: 'order-created'; orderId: string }
| { type: 'auth-required' }
契约包只包含类型和协议,不应依赖主应用具体实现。这样主应用和子应用可以独立编译,并在 CI 中检查接口变更。
阶段三:先使用本地运行时组合
主应用先通过本地包或 workspace 加载子应用入口:
import { mount as mountOrders } from '@company/orders-micro'
这一步能验证:
- mount/unmount 是否正确;
- 路由前缀是否正确;
- 样式是否泄漏;
- 通信协议是否足够;
- 子应用是否依赖了主应用内部对象。
只有这些问题解决后,再把实现替换为远程地址。否则远程加载只会把本地错误变成更难诊断的网络错误。
阶段四:再引入独立发布
独立发布后,主应用实际依赖的是:
manifest 地址
-> entry 地址
-> 子应用内部 chunk
-> 子应用依赖和 API
因此发布系统需要支持版本管理,而不能只覆盖一个固定文件名。常见的回滚策略是:
当前版本 manifest -> 新版本 entry
新版本验证失败 -> manifest 指回旧版本 entry
这要求资源地址不可变,并且旧版本资源在回滚窗口内不能立即删除。
阶段五:最后评估 iframe 或同页面方案
如果某个子域具备强安全边界、历史系统复杂或技术栈完全不同,迁移到 iframe 可能比强行改造成同页面应用更可靠。
如果多个子应用需要共享复杂布局、统一滚动和高频交互,则同页面组合更合适,但应接受依赖、CSS 和故障隔离更弱的事实。
十一、迁移取舍:什么时候不应该使用微前端
微前端适合解决组织和发布问题,不适合解决所有代码复杂度问题。
以下情况通常不值得直接引入运行时微前端:
- 只有一个团队和一个发布节奏;
- 应用体量不大;
- 拆分后仍必须同步发布;
- 所有页面共享同一套状态和组件;
- 没有独立部署或故障隔离需求;
- 主要问题是目录混乱、测试不足或组件复用差。
这些问题优先用 Vue 3 的模块化、Composables、Pinia、路由分层和 monorepo 解决。
相反,以下信号更支持采用微前端:
- 不同团队需要独立交付;
- 某个历史子系统必须逐步替换;
- 不同业务域发布风险和节奏差异很大;
- 希望局部故障不阻断整个门户;
- 子系统之间的领域边界稳定;
- 主应用可以接受明确的运行时协议。
可以用一个简单的判断模型表示拆分收益:
净收益 =
独立交付收益
+ 故障隔离收益
+ 技术栈迁移收益
- 路由协调成本
- 状态通信成本
- 样式治理成本
- 依赖治理成本
- 监控和发布成本
这不是可精确计算的工程公式,但它揭示了一个事实:当独立交付和迁移收益很低时,微前端的新增边界通常不会带来正收益。
十二、常见误解和诊断方法
误解一:拆成多个仓库就完成了微前端
多个仓库只能说明源码管理边界不同。若最终仍由一个 Vite 项目统一构建和发布,它更接近模块化单体或 monorepo,而不是运行时微前端。
诊断方法是检查发布链路:
只发布子应用产物,主应用不重新构建
-> 具备运行时独立发布特征
修改子应用后必须重新构建主应用
-> 仍是构建时组合
误解二:scoped 可以阻止所有样式冲突
scoped 只限制特定 Vue 组件生成的选择器,不能隔离 body、弹层、CSS 变量、第三方全局样式和动态插入的样式。
诊断时应检查最终 DOM 和最终 CSS,而不是只看源文件是否写了 scoped。
误解三:共享 Vue 就等于共享所有状态
Vue 运行时、Vue 应用实例、Pinia store 和业务缓存是不同层次的对象。共享其中一个,不会自动共享其他对象。
共享 Vue 模块
!= 共享 createApp 实例
!= 共享 Pinia 实例
!= 共享业务状态
误解四:主应用能捕获所有子应用错误
主应用可以捕获加载失败和部分运行时异常,但无法替子应用处理所有异步错误、Worker 错误、iframe 内错误或服务端错误。子应用仍需在自己的边界内处理请求失败、路由失败和组件错误。
误解五:动态 import() 就是安全的远程加载
动态导入只是一种模块加载表达式。安全性还依赖:
- 远程地址是否可信;
- manifest 是否可篡改;
- CSP 是否限制脚本来源;
- 是否使用资源完整性校验;
- 版本和契约是否验证;
- 远程代码是否具有当前页面的权限。
远程 JavaScript 运行在当前页面权限下。若子应用不可信,不能仅靠模块加载机制建立安全沙箱,应考虑 iframe 等真正的上下文隔离。
十三、上线前的最小验证矩阵
一个微前端系统至少应验证以下组合:
| 维度 | 验证内容 |
|---|---|
| 路由 | 直接访问深层 URL、刷新、前进、后退、未知路径 |
| 生命周期 | 重复挂载和卸载,检查监听器、定时器、DOM |
| 状态 | 会话加载中、登出、切换租户、并发请求 |
| 样式 | 全局选择器、浮层、字体、CSS 变量、层叠顺序 |
| 依赖 | 版本冲突、重复 Vue、插件安装、独立启动 |
| 网络 | manifest 失败、entry 失败、chunk 失败、API 5xx |
| 发布 | 新版本上线、旧版本回滚、缓存命中 |
| 安全 | CORS、CSP、postMessage origin、远程地址校验 |
| 可观测性 | 主应用版本、子应用版本、路由、错误阶段和请求 ID |
日志至少应带上应用标识和版本:
app=host version=2025.03.08 route=/portal/orders
app=orders version=2025.03.07 phase=mount
app=orders phase=api request=/api/orders status=500
没有这些字段时,线上看到“订单页面白屏”通常无法快速判断是主应用路由错误、子应用资源失败、Vue 挂载异常,还是后端接口故障。
结语:先定义边界,再选择隔离强度
Vue 微前端的核心不是某个加载器或 Vite 插件,而是边界设计:
- 路由边界决定谁解释 URL;
- 状态边界决定谁拥有数据;
- 样式边界决定 CSS 能影响哪里;
- 依赖边界决定版本和运行时能否独立;
- 生命周期边界决定切换后旧应用是否真正消失;
- 发布边界决定子应用能否独立上线和回滚。
iframe 提供更强的运行时隔离,但牺牲交互便利;同页面组合提供更自然的体验,但必须承担样式、依赖和全局副作用治理;构建时模块化最简单,却不具备完整的独立发布能力。
因此,合理的迁移顺序通常是:
单体内按领域拆分
-> 固化接口和状态所有权
-> 验证 mount/unmount 与路由边界
-> 本地运行时组合
-> 独立构建和版本化发布
-> 根据实际隔离需求选择同页面或 iframe
当团队能清楚回答“谁拥有这条路由、这份状态、这段样式、这个依赖和这个故障”时,微前端才是架构边界;否则它只是把一个边界模糊的 Vue 应用拆成了多个边界模糊的 Vue 应用。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 环境与运行时配置:构建变量、注入、校验和 Secret 边界
- 下一篇:Vue CI 质量流水线:类型、Lint、测试、构建、预览和制品
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论