Vue 基础体系 · 第 62/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 微前端:路由、状态、样式、依赖隔离和迁移取舍

微前端不是“把一个 Vue 项目拆成多个目录”,而是把一个前端系统拆成多个能够独立开发、独立构建、独立发布,且在运行时共同组成用户体验的应用单元。

这里的“独立”至少涉及四个边界:

  • 构建边界:子应用可以单独执行类型检查、测试和构建。
  • 发布边界:子应用发布不必重新构建主应用。
  • 运行时边界:主应用和子应用之间通过明确协议通信。
  • 故障边界:一个子应用失败时,系统是否仍能显示其他区域或提供降级页面。

微前端的难点不在“如何加载另一个 JavaScript 文件”,而在于加载之后谁负责路由、状态、样式、依赖和错误恢复。若这些问题没有明确边界,最终通常只是一个更难维护的单体前端。


一、先确定拆分模型:微前端不是唯一实现

Vue 3 应用可以采用多种组合方式。它们都能实现“一个页面由多个前端单元构成”,但隔离强度、通信成本和迁移成本不同。

1. iframe:隔离最强,协作成本最高

iframe 创建独立的浏览上下文。子应用拥有自己的:

  • windowdocument
  • 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 等远程模块机制。

这种方式可以共享布局、弹窗、路由上下文和浏览器窗口,但也意味着默认共享:

  • window
  • document
  • 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
}

这里有三个重要约束:

  1. mount 必须接收明确的容器,而不是默认接管整个 document.body
  2. mount 必须返回 unmount,否则路由切换后旧应用可能继续监听事件、定时器和网络回调。
  3. 主应用通过 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.typepostMessage 的安全判断至少要验证 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 和子应用前缀;
  • 订单子应用负责 statuspage
  • 其他应用不能随意删除或重写订单参数。

否则两个应用都监听 popstate 并修改查询参数时,可能发生互相覆盖甚至导航循环。


五、样式:作用域不是完整隔离

Vue 的 <style scoped> 会通过属性选择器限制组件样式,例如:

<template>
  <button class="submit">提交</button>
</template>

<style scoped>
.submit {
  color: white;
}
</style>

编译后通常会生成类似:

.submit[data-v-abc123] {
  color: white;
}

这能降低组件样式泄漏,但不能提供完整的微前端隔离,原因包括:

  1. bodyhtml* 等全局选择器仍可能影响页面;
  2. CSS 自定义属性会通过继承传播;
  3. 弹窗、下拉菜单、浮层可能挂载到 body
  4. 第三方组件库可能注入全局样式;
  5. 子应用的字体、动画名、层叠顺序仍可能冲突;
  6. 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 库都支持相同参数。验证时应检查:

  1. 浮层 DOM 是否位于子应用容器内;
  2. 主应用的全局样式是否改变浮层;
  3. 子应用卸载后浮层是否被移除;
  4. 多个子应用同时打开浮层时 z-index 是否有统一规则。

六、依赖隔离:版本、实例和副作用是三个不同问题

“依赖隔离”不是简单地说“每个项目有自己的 package.json”。至少要区分:

  1. 版本隔离:子应用可使用不同版本的库。
  2. 实例隔离:不同版本或不同应用的运行时对象互不污染。
  3. 副作用隔离:依赖注入的全局 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() 都是独立应用实例,插件安装需要明确发生在哪个实例上。

反过来,如果某个库直接修改 windowdocument,它也可能跨越 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

必须测试以下情况,而不是只测试首次成功加载:

  1. 连续进入、离开订单页面十次;
  2. 子应用请求尚未返回时离开页面;
  3. mount 执行一半抛出异常;
  4. 子应用打开浮层后被卸载;
  5. 子应用注册了 window 事件后被卸载;
  6. 主应用路由快速连续跳转;
  7. 旧版本子应用被缓存后加载;
  8. 子应用 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、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。