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

Vue 大型应用架构:模块边界、依赖方向、权限和微前端取舍

大型 Vue 应用的复杂度通常不是来自组件数量本身,而是来自变化被传播到错误的地方:

  • 一个业务规则改动,迫使多个页面同时修改;
  • 一个接口类型变化,导致组件、状态管理和请求代码一起破坏;
  • 一个权限判断只藏在按钮上,用户仍然可以通过 URL 或直接请求访问数据;
  • 一个团队为了“独立发布”引入微前端,却获得了多份 Vue、跨应用状态同步和难以回滚的运行时问题。

因此,架构的核心不是目录如何命名,而是建立四类可验证的约束:

  1. 模块边界:哪些代码属于同一变化原因,哪些代码必须隔离;
  2. 依赖方向:谁可以调用谁,底层变化如何不反向污染上层;
  3. 权限路径:身份、授权、路由、界面和后端数据访问如何形成完整闭环;
  4. 应用拆分方式:何时使用单体模块,何时使用独立包,何时才值得使用微前端。

以下示例基于 Vue 3、Composition API、TypeScript 和 Vite。Vue 本身负责组件、响应式和生命周期,不会自动替你建立业务模块边界、依赖规则或权限模型;这些属于应用架构层。


一、先建立共同模型:Vue 应用不是组件树,而是多个图的组合

Vue 组件最终会形成一棵渲染树,但大型应用至少还存在另外三种关系:

  1. 模块依赖图:文件或包之间通过 import 形成有向图;
  2. 状态流图:用户操作、请求、缓存和组件状态之间传递数据;
  3. 权限决策图:身份认证、能力判断、路由进入、界面显示和服务端校验之间相互约束。

如果只观察组件树,通常只能回答“页面如何渲染”,无法回答:

  • 业务规则应该放在哪里;
  • 页面能否直接调用 API;
  • 一个模块能否依赖另一个模块的 store;
  • 权限失效时正在进行的请求如何处理;
  • 子应用是否可以直接修改主应用的状态。

大型应用架构可以抽象为:

A=(M,D,S,P,R)A = (M, D, S, P, R)

其中:

  • MM 是模块集合;
  • DD 是模块之间的依赖关系;
  • SS 是状态及数据流;
  • PP 是权限决策;
  • RR 是运行时和发布关系。

一个局部改动的影响范围,可以粗略理解为依赖图中从改动节点可达的节点数量。若一个基础模块被大量业务页面直接依赖,它的变化成本就会显著增加。因此,架构设计的目标不是“依赖越少越好”,而是:

让变化只沿着预期方向传播,并让跨边界通信使用显式契约。


二、模块边界:按变化原因划分,而不是按文件类型堆放

2.1 什么是模块边界

模块边界是一个模块对外暴露的能力、数据和类型集合,以及它明确拒绝暴露的内部实现。

例如,订单模块可以对外提供:

// features/order/public.ts
export { useOrderList } from './application/useOrderList'
export type { Order, OrderFilter } from './domain/order'

但它不应要求外部代码了解:

// 不应该作为公共契约暴露
features/order/infrastructure/orderHttp.ts
features/order/application/orderStore.ts

边界至少包含四个方面:

  • 命名边界:代码属于哪个业务或基础能力;
  • 访问边界:外部只能从公共入口导入;
  • 数据边界:内部模型和外部 DTO 是否相同;
  • 变化边界:某项修改是否应当限制在模块内部。

components/api/stores/utils/ 这种纯技术目录组织,容易产生“跨业务共享一切”的结果。更适合大型应用的起点通常是按业务能力划分:

src/
├─ app/                         # 应用启动、路由、全局插件
├─ shared/                      # 真正通用且无业务含义的能力
│  ├─ http/
│  ├─ ui/
│  ├─ auth/
│  ├─ config/
│  └─ types/
├─ features/
│  ├─ order/
│  │  ├─ domain/
│  │  ├─ application/
│  │  ├─ infrastructure/
│  │  ├─ ui/
│  │  └─ public.ts
│  └─ billing/
└─ pages/                       # 路由页面和页面编排

这里的 domainapplicationinfrastructure 不是 Vue 强制要求的目录,而是帮助表达不同职责:

  • domain:业务概念和规则,不应依赖 Vue、浏览器或 HTTP;
  • application:用例编排,例如“加载订单列表”“提交退款”;
  • infrastructure:HTTP、IndexedDB、WebSocket 等外部实现;
  • ui:Vue 组件、组合式函数和页面交互;
  • pages:把多个业务能力组合成路由页面。

2.2 完整算例:订单金额规则如何避免进入组件

假设订单有一条规则:只有已支付且未退款的订单,才能申请退款。先把规则放在领域层:

// features/order/domain/order.ts
export type OrderStatus = 'pending' | 'paid' | 'cancelled'
export type RefundStatus = 'none' | 'requested' | 'completed'

export interface Order {
  id: string
  status: OrderStatus
  refundStatus: RefundStatus
  totalCents: number
}

export function canRequestRefund(order: Order): boolean {
  return order.status === 'paid' && order.refundStatus === 'none'
}

组件只负责展示和触发用例:

<!-- features/order/ui/OrderRow.vue -->
<script setup lang="ts">
import { canRequestRefund, type Order } from '../domain/order'

const props = defineProps<{
  order: Order
}>()

const emit = defineEmits<{
  requestRefund: [orderId: string]
}>()
</script>

<template>
  <tr>
    <td>{{ props.order.id }}</td>
    <td>{{ (props.order.totalCents / 100).toFixed(2) }}</td>
    <td>
      <button
        :disabled="!canRequestRefund(props.order)"
        @click="emit('requestRefund', props.order.id)"
      >
        申请退款
      </button>
    </td>
  </tr>
</template>

这个例子中,规则虽然被组件调用,但规则本身不依赖组件。这样做有三个结果:

  1. 测试规则不需要启动 Vue;
  2. 导出、批处理或服务端复用时可以继续使用同一规则;
  3. 规则变化不会迫使数据请求层知道 UI 细节。

但这并不意味着前端规则就是安全边界。后端仍必须再次校验“是否允许退款”,因为浏览器中的 TypeScript 和按钮禁用都可以被绕过。

2.3 公共模块的判定条件

shared/ 不是“所有模块都可以放东西的地方”。一个能力适合进入共享层,至少应满足:

  1. 不包含具体业务词汇;
  2. 不依赖某个业务模块;
  3. 有两个以上真实调用方,且语义确实相同;
  4. 变化策略独立于业务模块。

例如,BaseButton、HTTP 客户端、日期格式化可以是共享能力;而 OrderStatusBadge 即使被两个订单页面使用,也仍然属于订单模块,而不是通用 UI。

一个常见反例是:

// shared/utils/order.ts
export function formatOrderStatus(status: string) {
  // ...
}

这并没有消除订单领域知识,只是把它隐藏到一个名称宽泛的目录里。结果是任何模块都可以调用,订单状态的修改会产生不可见的全局影响。


三、依赖方向:用有向图限制变化传播

3.1 依赖方向的形式化表达

把每个模块视为节点,若模块 AA 导入模块 BB,就记作:

ABA \rightarrow B

这表示 A 依赖 B,因此 B 的公共契约变化可能影响 A。

假设应用采用如下层次:

ui → application → domain
ui → application → infrastructure
infrastructure → domain

定义层级函数:

  • L(ui)=3L(ui)=3
  • L(application)=2L(application)=2
  • L(domain)=1L(domain)=1
  • L(infrastructure)=1L(infrastructure)=1

若规定每条依赖边都满足:

L(A)>L(B)L(A) > L(B)

那么依赖只能从高层指向低层。由于层级会沿边严格下降,任何路径都不可能回到原节点,所以依赖图一定无环。

这是一个可验证的条件,而不是口号。

3.2 反例:循环依赖是如何产生的

以下设计看起来方便:

OrderPage → orderStore → orderApi → OrderPage

例如 orderApi 为了跳转页面,直接导入 OrderPageOrderPage 又依赖 store,store 依赖 API。此时形成环:

OrderPageorderStoreorderApiOrderPageOrderPage \rightarrow orderStore \rightarrow orderApi \rightarrow OrderPage

循环依赖可能导致:

  • ES 模块初始化时得到未完成的绑定;
  • Vite 开发环境与生产构建行为不同;
  • 测试中 mock 顺序异常;
  • 模块边界无法判断,任何层都可以反向调用页面。

正确的修复方式不是调整 import 顺序,而是删除错误职责。API 层不应该负责导航:

// features/order/infrastructure/orderApi.ts
import type { Order } from '../domain/order'

export interface OrderApi {
  list(signal?: AbortSignal): Promise<Order[]>
  requestRefund(orderId: string): Promise<void>
}

导航属于应用编排或页面层:

// pages/order/useOrderActions.ts
import { useRouter } from 'vue-router'
import { useOrderService } from '@/features/order/public'

export function useOrderActions() {
  const router = useRouter()
  const service = useOrderService()

  async function refund(orderId: string) {
    await service.requestRefund(orderId)
    await router.push({ name: 'order-list' })
  }

  return { refund }
}

3.3 依赖倒置:业务依赖能力,不依赖具体 HTTP

依赖倒置不是“所有代码都加 interface”,而是让稳定的业务规则不直接依赖容易变化的细节。

定义端口:

// features/order/application/orderPort.ts
import type { Order } from '../domain/order'

export interface OrderPort {
  list(signal?: AbortSignal): Promise<Order[]>
  requestRefund(orderId: string): Promise<void>
}

业务用例依赖端口:

// features/order/application/orderService.ts
import type { OrderPort } from './orderPort'

export function createOrderService(port: OrderPort) {
  return {
    list(signal?: AbortSignal) {
      return port.list(signal)
    },

    requestRefund(orderId: string) {
      return port.requestRefund(orderId)
    },
  }
}

运行时再注入 HTTP 实现:

// features/order/infrastructure/createHttpOrderPort.ts
import type { OrderPort } from '../application/orderPort'
import type { Order } from '../domain/order'

export function createHttpOrderPort(http: {
  get<T>(url: string, options?: { signal?: AbortSignal }): Promise<T>
  post<T>(url: string, body?: unknown): Promise<T>
}): OrderPort {
  return {
    list(signal) {
      return http.get<Order[]>('/api/orders', { signal })
    },

    requestRefund(orderId) {
      return http.post(`/api/orders/${orderId}/refund`)
    },
  }
}

这样测试用例可以注入内存实现:

const fakePort: OrderPort = {
  async list() {
    return [{
      id: 'o-1',
      status: 'paid',
      refundStatus: 'none',
      totalCents: 1299,
    }]
  },
  async requestRefund() {},
}

需要注意,依赖倒置会增加抽象数量。若一个模块只有一个简单请求,并且没有替换实现、测试隔离或跨运行环境需求,强行增加多层接口可能降低可读性。抽象应当服务于边界,而不是满足形式。

3.4 如何把依赖规则变成检查

仅靠约定无法长期维持方向。可以通过 ESLint 的 no-restricted-imports、依赖约束插件或构建脚本限制导入。例如,要求 domain 不得导入 Vue 和浏览器 API:

// eslint.config.js,示意配置
export default [
  {
    files: ['src/features/*/domain/**/*.ts'],
    rules: {
      'no-restricted-imports': ['error', {
        paths: [
          'vue',
          'vue-router',
          'axios',
        ],
        patterns: ['**/infrastructure/**', '**/ui/**'],
      }],
    },
  },
]

这是工程约束,不是 Vue 官方规范。规则应配合实际目录和 ESLint 版本验证,不能仅复制配置后假定已经生效。


四、状态和数据流:明确谁拥有状态,谁只读取状态

4.1 状态的四种归属

大型 Vue 应用中,状态至少可以按生命周期和共享范围分为四类:

状态类型 例子 合适位置
瞬时 UI 状态 弹窗是否打开、输入框内容 组件 ref / reactive
页面状态 当前筛选条件、分页 页面或页面组合式函数
业务共享状态 当前用户、购物车、未读数 feature store 或应用 store
服务端状态 订单列表、缓存、重新验证状态 数据访问层或专门的数据缓存方案

把所有状态都放进全局 Pinia store,会使组件间关系隐式增加;把服务端数据全部放在组件 ref 中,又会造成重复请求、缓存失效和错误状态不一致。

一个简单原则是:

状态应放在“最小但足够覆盖所有消费者”的共同祖先或模块中。

例如,只有订单列表页面使用筛选条件,就不必放入全局 store:

const filter = ref<OrderFilter>({
  status: 'all',
  page: 1,
})

如果顶部导航、订单页和通知中心都需要当前用户,则当前用户属于应用级状态。

4.2 Composition API 中的请求状态

下面的组合式函数展示了加载、错误、取消和组件卸载的基本关系:

// features/order/application/useOrderList.ts
import { onBeforeUnmount, ref } from 'vue'
import type { OrderPort } from './orderPort'
import type { Order } from '../domain/order'

export function useOrderList(port: OrderPort) {
  const orders = ref<Order[]>([])
  const loading = ref(false)
  const error = ref<unknown>(null)

  let controller: AbortController | undefined

  async function load() {
    controller?.abort()
    controller = new AbortController()

    loading.value = true
    error.value = null

    try {
      orders.value = await port.list(controller.signal)
    } catch (err) {
      if (!(err instanceof DOMException && err.name === 'AbortError')) {
        error.value = err
      }
    } finally {
      loading.value = false
    }
  }

  onBeforeUnmount(() => {
    controller?.abort()
  })

  return { orders, loading, error, load }
}

这里有两个重要的时间关系:

  1. 新请求开始前取消旧请求,避免旧请求继续占用资源;
  2. 组件卸载时取消请求,避免无意义的结果回写。

AbortController 不是万能的。若请求已经在网络层完成、业务代码已经进入后续异步步骤,单纯取消底层请求不一定能阻止后续逻辑。对于搜索联想、筛选切换等场景,还应使用请求序号:

let requestId = 0

async function search(keyword: string) {
  const currentId = ++requestId
  const result = await port.search(keyword)

  if (currentId !== requestId) {
    return // 结果已经过期
  }

  items.value = result
}

推导过程如下:

  • 第一次搜索产生 id=1
  • 用户快速输入后,第二次搜索产生 id=2
  • 如果 id=1 先返回,发现 1 !== 2,丢弃;
  • 只有当前请求的结果才能更新状态。

这解决的是“响应顺序与发起顺序不一致”的竞态,而不是网络错误本身。


五、权限:认证、授权和界面控制不是同一件事

5.1 先区分四个概念

  • 认证(authentication):确认“你是谁”,例如登录态、令牌或会话;
  • 授权(authorization):确认“你能做什么”,例如是否有 order:refund 能力;
  • 路由控制:决定是否允许导航到某个前端路由;
  • 界面控制:决定是否显示按钮、菜单或操作入口。

它们的安全强度不同。前端路由守卫和按钮隐藏只能改善用户体验,不能替代服务端授权。真正的数据安全边界必须在后端:

用户请求
  ↓
服务端认证身份
  ↓
服务端检查资源和操作权限
  ↓
允许或拒绝数据变更

一个用户即使看不到“退款”按钮,也可能手工发送:

POST /api/orders/o-1/refund

所以服务端必须检查:

  • 当前用户是否登录;
  • 是否拥有退款能力;
  • 是否能操作订单 o-1
  • 订单当前状态是否仍允许退款。

5.2 能力模型比散落角色判断更稳定

角色是用户集合,能力是操作集合。与其在组件中到处写:

user.role === 'admin'

不如定义能力:

export type Permission =
  | 'order:read'
  | 'order:refund'
  | 'billing:read'

export interface CurrentUser {
  id: string
  permissions: ReadonlySet<Permission>
}

授权函数:

export function can(
  user: CurrentUser | null,
  permission: Permission,
): boolean {
  return user?.permissions.has(permission) ?? false
}

这样角色到能力的映射可以由服务端返回或在登录阶段转换,页面只依赖操作语义。对于资源级权限,还需要把资源作为输入:

export function canRefund(
  user: CurrentUser | null,
  order: Order,
): boolean {
  return can(user, 'order:refund') && canRequestRefund(order)
}

这体现了两个独立条件:

Allow=HasCapabilityResourceRuleAllow = HasCapability \land ResourceRule

只有具备能力且资源状态允许时,操作才可执行。

5.3 路由守卫的可运行结构

以 Vue Router 为例,路由元信息可以表达“进入页面所需的最低能力”:

// router.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { Permission } from '@/shared/auth/permission'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    permissions?: Permission[]
  }
}

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/orders',
      name: 'orders',
      component: () => import('@/pages/OrdersPage.vue'),
      meta: {
        requiresAuth: true,
        permissions: ['order:read'],
      },
    },
    {
      path: '/forbidden',
      name: 'forbidden',
      component: () => import('@/pages/ForbiddenPage.vue'),
    },
  ],
})

守卫中不要直接读取尚未初始化完成的异步用户状态。可以把认证服务设计为可等待的初始化过程:

// shared/auth/authService.ts
import type { CurrentUser, Permission } from './permission'

let initialized = false
let currentUser: CurrentUser | null = null

export async function initAuth() {
  if (initialized) return
  initialized = true

  const response = await fetch('/api/me', {
    credentials: 'include',
  })

  if (response.ok) {
    const data = await response.json() as {
      id: string
      permissions: Permission[]
    }

    currentUser = {
      id: data.id,
      permissions: new Set(data.permissions),
    }
  }
}

export function getCurrentUser() {
  return currentUser
}
// app/routerGuard.ts
import { router } from './router'
import { initAuth, getCurrentUser } from '@/shared/auth/authService'
import { can } from '@/shared/auth/authorize'

router.beforeEach(async (to) => {
  try {
    await initAuth()
  } catch {
    return { name: 'forbidden' }
  }

  const user = getCurrentUser()

  if (to.meta.requiresAuth && !user) {
    return {
      path: '/login',
      query: { redirect: to.fullPath },
    }
  }

  const required = to.meta.permissions ?? []
  const allowed = required.every(permission => can(user, permission))

  if (!allowed) {
    return { name: 'forbidden' }
  }

  return true
})

完整路径是:

  1. 用户导航到 /orders
  2. 守卫等待认证初始化;
  3. 未登录则转到登录页;
  4. 已登录但没有 order:read 则转到无权页;
  5. 具备能力才允许路由组件加载或显示。

具体的守卫 API 属于 Vue Router,而不是 Vue 核心。其版本行为应以项目使用的 Vue Router 文档和类型定义为准。

5.4 按钮指令只能是表现层工具

可以定义一个简化的 v-can 指令:

// shared/auth/vCan.ts
import type { Directive } from 'vue'
import type { Permission } from './permission'
import { getCurrentUser } from './authService'
import { can } from './authorize'

export const vCan: Directive<HTMLElement, Permission> = {
  mounted(el, binding) {
    if (!can(getCurrentUser(), binding.value)) {
      el.remove()
    }
  },
}

使用:

<button v-can="'order:refund'" @click="refund">
  申请退款
</button>

这只能控制 DOM 表现。它不应承担:

  • 防止直接调用 API;
  • 保护异步接口返回的数据;
  • 替代路由守卫;
  • 处理权限动态变化后的所有更新。

如果权限会在运行时变化,单次 mounted 删除元素可能不够,应改用响应式 v-if 或组件组合式函数,使权限变化能够触发重新渲染。


六、权限与并发:令牌过期、重复跳转和请求中断

权限系统的难点往往出现在时间线上,而不是 can() 函数里。典型流程如下:

sequenceDiagram
    participant U as 用户
    participant V as Vue 页面
    participant H as HTTP 客户端
    participant S as 服务端

    U->>V: 点击保存
    V->>H: POST /api/orders
    H->>S: 携带会话或令牌
    S-->>H: 401 未认证
    H->>H: 尝试刷新会话
    H->>S: POST /api/auth/refresh
    S-->>H: 新会话
    H->>S: 重试原请求
    S-->>H: 403 无权
    H-->>V: 授权失败
    V-->>U: 显示无权提示

这里必须区分:

  • 401:通常表示没有有效认证,需要重新登录或刷新会话;
  • 403:通常表示身份已识别,但不允许执行该操作。

常见失败是多个请求同时收到 401,各自刷新会话,导致刷新接口竞态。可用一个共享 Promise 合并刷新过程:

let refreshPromise: Promise<void> | null = null

async function refreshOnce() {
  if (!refreshPromise) {
    refreshPromise = fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include',
    }).then(response => {
      if (!response.ok) throw new Error('refresh failed')
    }).finally(() => {
      refreshPromise = null
    })
  }

  return refreshPromise
}

请求层还要定义失败恢复策略:

  • 刷新成功:只重试原请求一次,避免无限循环;
  • 刷新失败:清理本地认证状态,跳转登录;
  • 返回 403:不应刷新令牌,而应显示无权;
  • 页面卸载或用户切换账号:取消属于旧身份的请求,防止旧数据进入新会话。

权限状态属于安全敏感状态,不能只存于前端 localStorage 并直接信任。前端缓存的权限数据最多用于界面优化,服务端每次敏感操作仍需重新判断。


七、设计系统与业务边界:组件库不是业务模块

设计系统通常包含:

  • Token:颜色、间距、字号、圆角等设计变量;
  • 组件:按钮、表格、弹窗、表单控件;
  • 主题机制:把 Token 映射为 CSS 变量或运行时主题;
  • 无障碍约束:键盘操作、语义元素、焦点管理、对比度;
  • 文档与版本治理:组件行为、变更记录和兼容策略。

这些内容属于共享基础能力,但不应吞并业务规则。

例如,设计系统可以提供:

<Dialog v-model:open="open" title="确认退款">
  <p>退款金额:{{ amount }}</p>
  <template #footer>
    <Button variant="secondary" @click="open = false">取消</Button>
    <Button variant="danger" @click="confirm">确认</Button>
  </template>
</Dialog>

但“订单是否允许退款”仍属于订单模块。设计系统不应出现:

// 错误方向
import { canRequestRefund } from '@/features/order/domain/order'

设计系统的依赖方向应当是:

业务模块 → 设计系统
设计系统 ↛ 业务模块

Token 的版本变化可能影响所有业务模块,因此需要比普通业务模块更严格的版本治理。尤其要区分:

  • CSS 变量名是否属于公共契约;
  • 组件事件和插槽是否属于公共契约;
  • 视觉调整是否会改变无障碍行为;
  • 破坏性变更是否需要主版本升级。

设计系统越基础,越不能把“临时业务样式”直接加入其中,否则共享层会成为业务耦合中心。


八、单体模块、独立包和微前端的区别

8.1 三种拆分层次

单体模块化:一个 Vite 应用、一个部署产物,但内部按业务模块隔离。适合大多数中大型 Vue 应用。

独立包:把设计系统、认证 SDK、业务 SDK 等发布为 npm 包。包有明确版本、构建和测试边界,但最终仍可能被同一个应用加载。

微前端:多个可以独立构建、部署或运行的前端应用,在浏览器中组合成一个用户体验。它解决的是组织和交付边界,不是目录整理问题。

因此,“代码很多”并不是引入微前端的充分条件。真正需要考虑的是:

  • 团队是否需要独立发布;
  • 应用是否有不同的生命周期;
  • 是否必须隔离技术栈或发布风险;
  • 主应用是否能提供稳定的集成协议;
  • 独立运行带来的调试、依赖和体验成本是否可接受。

8.2 微前端的常见实现方式

iframe

主应用通过 iframe 加载子应用:

<iframe
  src="https://billing.example.com/embed"
  title="账单应用"
/>

优点:

  • JavaScript、CSS 和运行时隔离强;
  • 子应用可以完全独立部署;
  • 故障通常局限在 iframe 内。

缺点:

  • 路由、尺寸、焦点和无障碍体验处理复杂;
  • 主子应用通信需要 postMessage
  • SEO、统一导航和登录态共享不自然;
  • 页面级集成体验可能较差。

构建时集成

把子模块作为 npm 包或 workspace 包构建:

import { BillingPanel } from '@company/billing-ui'

优点是类型、测试和本地开发简单;缺点是发布边界仍然耦合,子包更新通常需要重新构建主应用。

运行时联邦或远程模块

主应用在运行时加载远程构建产物。Vite 并没有把某一种 Module Federation 机制作为 Vue 核心或 Vite 核心 API;实际项目通常依赖具体插件或平台实现,因此属于工具链敏感能力。插件的配置、共享依赖、远程入口格式和版本兼容必须以所选插件文档为准,不能把 webpack、Vite 和某个插件的配置混为一谈。

这类方案可以实现独立发布,但需要额外解决:

  • vuevue-router、状态库是否共享;
  • 共享依赖版本不兼容时如何降级;
  • 远程入口加载失败时如何展示和恢复;
  • 远程资源缓存和回滚如何控制;
  • 子应用是否可以直接访问主应用内部 store;
  • CSS、全局事件和自定义元素名称是否冲突。

8.3 微前端的集成契约

主应用与子应用之间应使用显式契约,而不是互相导入内部模块:

export interface BillingMountOptions {
  el: HTMLElement
  user: {
    id: string
    locale: string
  }
  navigate: (to: { name: string; params?: Record<string, string> }) => void
  onError: (error: unknown) => void
}

export interface BillingApp {
  unmount(): void
}

export function mountBilling(
  options: BillingMountOptions,
): BillingApp {
  // 子应用内部创建自己的 Vue app
  // 不直接读取主应用 router、Pinia 或组件实例
  return {
    unmount() {
      // 清理监听器、定时器和 DOM
    },
  }
}

这里的 mount 契约包含输入、输出和错误回调。子应用可以请求导航,但不应拿到主应用 router 实例后随意修改路由;可以接收用户摘要,但不应直接修改主应用认证状态。

通信可以分为三类:

  1. 输入属性:初始化配置和只读上下文;
  2. 事件回调:子应用通知主应用发生了某件事;
  3. 共享协议:认证、路由、遥测等平台能力。

事件应表达事实:

type BillingEvent =
  | { type: 'invoice-paid'; invoiceId: string }
  | { type: 'navigate'; target: 'orders' }
  | { type: 'fatal-error'; errorId: string }

相比暴露内部方法:

// 耦合过强
billingStore.openInvoiceModal()
billingStore.setMainUserStore(...)

事件更容易测试、记录和兼容演进。

8.4 微前端故障路径

微前端必须把“远程应用不可用”当作常态设计,而不是异常中的异常:

flowchart TD
    A[主应用加载页面] --> B{远程入口可达?}
    B -- 否 --> C[显示降级卡片]
    C --> D[记录错误与版本]
    D --> E[重试或回滚远程版本]
    B -- 是 --> F{子应用挂载成功?}
    F -- 否 --> G[卸载残留资源]
    G --> C
    F -- 是 --> H[正常交互]
    H --> I{运行时异常?}
    I -- 是 --> J[错误边界隔离]
    J --> C
    I -- 否 --> H

关键点是:

  • 网络加载失败与子应用代码执行失败是两类错误;
  • 失败后要清理事件监听器、定时器和 DOM;
  • 远程资源必须有可追踪版本;
  • 回滚应能把入口指向上一个已验证版本;
  • 主应用不能因为子应用失败而失去全局导航、退出登录或错误上报能力。

如果远程模块使用强缓存,而入口文件又无法切换版本,发布回滚可能在部分用户上继续命中旧资源。因此微前端发布必须和静态资源缓存策略配套:入口通常需要可更新,带内容哈希的资源可以长期缓存,版本映射和回滚记录则应可审计。


九、如何选择拆分方案:用约束而不是规模做判断

可以定义一个简化决策模型。设:

  • II:独立发布收益;
  • FF:故障隔离收益;
  • OO:组织边界收益;
  • CC:集成复杂度;
  • DD:重复依赖和调试成本;
  • RR:运行时失败风险。

当:

I+F+O>C+D+RI + F + O > C + D + R

并且这些收益无法通过单体模块化或独立包获得时,微前端才有合理性。

这个公式不是性能测量工具,而是强迫团队列出真实原因。例如:

  • 如果唯一诉求是“目录太乱”,单体模块化即可解决;
  • 如果唯一诉求是“组件要复用”,独立包更合适;
  • 如果两个团队必须独立发布且故障不能互相阻塞,微前端才开始具备理由;
  • 如果所有页面必须共享同一套事务、状态和路由,微前端的边界可能会与业务边界冲突。

一个常见反例是把每个页面都拆成子应用。结果可能是:

主应用
 ├─ 用户子应用
 ├─ 订单子应用
 ├─ 详情子应用
 └─ 弹窗子应用

页面间交互越细,跨应用通信越多。若一次用户操作需要在四个应用之间同步状态,实际得到的不是独立系统,而是一个通过事件拼接的隐式单体。边界应该放在团队、领域或生命周期相对稳定的位置,而不是机械地按页面切割。


十、生产交付:边界必须延伸到构建、配置和回滚

模块边界只在源码中成立还不够,构建和交付也应尊重它。

10.1 环境配置不能混入业务模块

Vite 的 import.meta.env 在构建时注入环境变量。公开给客户端的变量通常需要 VITE_ 前缀,但它们不是秘密:

// shared/config/env.ts
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL

if (!apiBaseUrl) {
  throw new Error('VITE_API_BASE_URL is required')
}

export const env = {
  apiBaseUrl,
}

不要把数据库密码、私钥或服务端令牌放入前端环境变量,因为它们会进入浏览器可下载的构建产物。环境配置应由基础设施在构建或启动阶段注入,业务模块只读取经过校验的配置对象。

10.2 静态资源、缓存和灰度

常见的可靠发布结构是:

入口 HTML:短缓存或不缓存
JS/CSS 哈希资源:长期缓存
版本清单:记录当前发布版本

原因是入口 HTML 决定加载哪些哈希资源。如果入口长期缓存,用户可能继续加载旧版本;如果哈希资源不缓存,缓存收益又会下降。

灰度发布至少要记录:

  • 用户或流量命中的版本;
  • 远程模块版本;
  • API 兼容版本;
  • 错误率和关键业务指标;
  • 回滚目标。

回滚不是“重新上传旧文件”这么简单。若数据库迁移、API 契约或远程模块协议已经不可逆变更,前端单独回滚可能导致旧代码无法工作。因此发布顺序通常需要保持协议兼容:先部署能兼容新旧客户端的后端,再逐步发布前端,确认稳定后再清理旧协议。


十一、诊断:从症状定位边界问题

症状一:改一个类型,几十个页面同时报错

优先检查:

  • 是否把后端 DTO 直接作为全局公共类型;
  • 是否有过宽的 shared 导出;
  • 是否多个页面直接依赖基础请求响应结构。

可以在 infrastructure 层把 DTO 转成领域模型:

interface OrderDto {
  order_id: string
  total_cents: number
  status: string
}

function toOrder(dto: OrderDto): Order {
  return {
    id: dto.order_id,
    totalCents: dto.total_cents,
    status: dto.status as Order['status'],
    refundStatus: 'none',
  }
}

这样后端字段命名变化不会直接传播到 UI。

症状二:按钮隐藏了,但接口仍能成功调用

这是把界面控制误当成授权。诊断步骤是:

  1. 浏览器直接调用 API;
  2. 检查服务端是否返回 403
  3. 检查资源级权限是否存在;
  4. 检查前端是否把 403 错误误处理成登录过期;
  5. 检查失败后是否仍修改了本地状态。

症状三:微前端发布后白屏

按顺序检查:

  1. 远程入口 URL 是否可达;
  2. 入口是否被 CDN 或 Service Worker 缓存;
  3. 子应用依赖的 Vue 版本是否兼容;
  4. 挂载节点是否存在;
  5. 子应用是否在 setup 或路由初始化阶段抛错;
  6. 卸载和重试逻辑是否清理了旧实例;
  7. 主应用是否记录了远程版本和错误 ID。

不要先通过“再复制一份依赖”解决问题。重复依赖可能暂时绕过版本冲突,却增加包体积和运行时上下文差异;是否共享 Vue 应根据集成方式、版本治理和隔离目标决定。

症状四:同一个请求返回了旧账号的数据

通常是身份切换与异步请求竞态:

  • 账号 A 发起请求;
  • 用户退出并登录账号 B;
  • A 的请求晚返回;
  • 页面把 A 的数据写进 B 的状态。

修复方式包括:

  • 账号变化时取消旧请求;
  • 给请求绑定身份或会话版本;
  • 写入状态前确认响应仍属于当前用户;
  • 服务端不要只依赖客户端缓存键。

十二、落地顺序:先治理模块,再评估微前端

可以按以下顺序推进,但每一步都应有可验证产物:

  1. 画出真实依赖图:统计跨业务导入、循环依赖和公共层膨胀;
  2. 建立公共入口:外部代码只从 public.ts 或包入口导入;
  3. 迁移业务规则:把可测试的规则从组件中移到领域或应用层;
  4. 区分状态归属:删除不必要的全局状态;
  5. 补齐权限闭环:后端授权、前端路由和界面控制分别实现;
  6. 处理并发和失败:取消请求、丢弃过期响应、区分 401403
  7. 统一设计系统契约:Token、组件、无障碍和版本变更纳入治理;
  8. 测量交付需求:只有当独立发布和故障隔离收益明确超过集成成本时,才引入微前端。

最终可接受的架构不是“层次最多”或“子应用最多”,而是能够回答以下问题:

  • 一个业务规则的唯一归属在哪里;
  • 一个模块对外承诺了哪些契约;
  • 依赖是否沿稳定方向流动;
  • 权限拒绝发生在路由、界面和服务端的哪一层;
  • 请求竞态和身份切换如何阻止旧数据写入;
  • 子应用失败时主应用如何继续工作;
  • 发布后如何验证、灰度和回滚。

当这些问题都有明确答案时,Vue 只是承载实现的框架;真正决定大型应用可维护性的,是边界、方向、契约和故障处理是否彼此一致。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。