Vue 基础体系 · 第 22/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 大型应用架构:模块边界、依赖方向、权限和微前端取舍
大型 Vue 应用的复杂度通常不是来自组件数量本身,而是来自变化被传播到错误的地方:
- 一个业务规则改动,迫使多个页面同时修改;
- 一个接口类型变化,导致组件、状态管理和请求代码一起破坏;
- 一个权限判断只藏在按钮上,用户仍然可以通过 URL 或直接请求访问数据;
- 一个团队为了“独立发布”引入微前端,却获得了多份 Vue、跨应用状态同步和难以回滚的运行时问题。
因此,架构的核心不是目录如何命名,而是建立四类可验证的约束:
- 模块边界:哪些代码属于同一变化原因,哪些代码必须隔离;
- 依赖方向:谁可以调用谁,底层变化如何不反向污染上层;
- 权限路径:身份、授权、路由、界面和后端数据访问如何形成完整闭环;
- 应用拆分方式:何时使用单体模块,何时使用独立包,何时才值得使用微前端。
以下示例基于 Vue 3、Composition API、TypeScript 和 Vite。Vue 本身负责组件、响应式和生命周期,不会自动替你建立业务模块边界、依赖规则或权限模型;这些属于应用架构层。
一、先建立共同模型:Vue 应用不是组件树,而是多个图的组合
Vue 组件最终会形成一棵渲染树,但大型应用至少还存在另外三种关系:
- 模块依赖图:文件或包之间通过
import形成有向图; - 状态流图:用户操作、请求、缓存和组件状态之间传递数据;
- 权限决策图:身份认证、能力判断、路由进入、界面显示和服务端校验之间相互约束。
如果只观察组件树,通常只能回答“页面如何渲染”,无法回答:
- 业务规则应该放在哪里;
- 页面能否直接调用 API;
- 一个模块能否依赖另一个模块的 store;
- 权限失效时正在进行的请求如何处理;
- 子应用是否可以直接修改主应用的状态。
大型应用架构可以抽象为:
其中:
- 是模块集合;
- 是模块之间的依赖关系;
- 是状态及数据流;
- 是权限决策;
- 是运行时和发布关系。
一个局部改动的影响范围,可以粗略理解为依赖图中从改动节点可达的节点数量。若一个基础模块被大量业务页面直接依赖,它的变化成本就会显著增加。因此,架构设计的目标不是“依赖越少越好”,而是:
让变化只沿着预期方向传播,并让跨边界通信使用显式契约。
二、模块边界:按变化原因划分,而不是按文件类型堆放
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/ # 路由页面和页面编排
这里的 domain、application、infrastructure 不是 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>
这个例子中,规则虽然被组件调用,但规则本身不依赖组件。这样做有三个结果:
- 测试规则不需要启动 Vue;
- 导出、批处理或服务端复用时可以继续使用同一规则;
- 规则变化不会迫使数据请求层知道 UI 细节。
但这并不意味着前端规则就是安全边界。后端仍必须再次校验“是否允许退款”,因为浏览器中的 TypeScript 和按钮禁用都可以被绕过。
2.3 公共模块的判定条件
shared/ 不是“所有模块都可以放东西的地方”。一个能力适合进入共享层,至少应满足:
- 不包含具体业务词汇;
- 不依赖某个业务模块;
- 有两个以上真实调用方,且语义确实相同;
- 变化策略独立于业务模块。
例如,BaseButton、HTTP 客户端、日期格式化可以是共享能力;而 OrderStatusBadge 即使被两个订单页面使用,也仍然属于订单模块,而不是通用 UI。
一个常见反例是:
// shared/utils/order.ts
export function formatOrderStatus(status: string) {
// ...
}
这并没有消除订单领域知识,只是把它隐藏到一个名称宽泛的目录里。结果是任何模块都可以调用,订单状态的修改会产生不可见的全局影响。
三、依赖方向:用有向图限制变化传播
3.1 依赖方向的形式化表达
把每个模块视为节点,若模块 导入模块 ,就记作:
这表示 A 依赖 B,因此 B 的公共契约变化可能影响 A。
假设应用采用如下层次:
ui → application → domain
ui → application → infrastructure
infrastructure → domain
定义层级函数:
若规定每条依赖边都满足:
那么依赖只能从高层指向低层。由于层级会沿边严格下降,任何路径都不可能回到原节点,所以依赖图一定无环。
这是一个可验证的条件,而不是口号。
3.2 反例:循环依赖是如何产生的
以下设计看起来方便:
OrderPage → orderStore → orderApi → OrderPage
例如 orderApi 为了跳转页面,直接导入 OrderPage;OrderPage 又依赖 store,store 依赖 API。此时形成环:
循环依赖可能导致:
- 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 }
}
这里有两个重要的时间关系:
- 新请求开始前取消旧请求,避免旧请求继续占用资源;
- 组件卸载时取消请求,避免无意义的结果回写。
但 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)
}
这体现了两个独立条件:
只有具备能力且资源状态允许时,操作才可执行。
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
})
完整路径是:
- 用户导航到
/orders; - 守卫等待认证初始化;
- 未登录则转到登录页;
- 已登录但没有
order:read则转到无权页; - 具备能力才允许路由组件加载或显示。
具体的守卫 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 和某个插件的配置混为一谈。
这类方案可以实现独立发布,但需要额外解决:
vue、vue-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 实例后随意修改路由;可以接收用户摘要,但不应直接修改主应用认证状态。
通信可以分为三类:
- 输入属性:初始化配置和只读上下文;
- 事件回调:子应用通知主应用发生了某件事;
- 共享协议:认证、路由、遥测等平台能力。
事件应表达事实:
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;
- 远程资源必须有可追踪版本;
- 回滚应能把入口指向上一个已验证版本;
- 主应用不能因为子应用失败而失去全局导航、退出登录或错误上报能力。
如果远程模块使用强缓存,而入口文件又无法切换版本,发布回滚可能在部分用户上继续命中旧资源。因此微前端发布必须和静态资源缓存策略配套:入口通常需要可更新,带内容哈希的资源可以长期缓存,版本映射和回滚记录则应可审计。
九、如何选择拆分方案:用约束而不是规模做判断
可以定义一个简化决策模型。设:
- :独立发布收益;
- :故障隔离收益;
- :组织边界收益;
- :集成复杂度;
- :重复依赖和调试成本;
- :运行时失败风险。
当:
并且这些收益无法通过单体模块化或独立包获得时,微前端才有合理性。
这个公式不是性能测量工具,而是强迫团队列出真实原因。例如:
- 如果唯一诉求是“目录太乱”,单体模块化即可解决;
- 如果唯一诉求是“组件要复用”,独立包更合适;
- 如果两个团队必须独立发布且故障不能互相阻塞,微前端才开始具备理由;
- 如果所有页面必须共享同一套事务、状态和路由,微前端的边界可能会与业务边界冲突。
一个常见反例是把每个页面都拆成子应用。结果可能是:
主应用
├─ 用户子应用
├─ 订单子应用
├─ 详情子应用
└─ 弹窗子应用
页面间交互越细,跨应用通信越多。若一次用户操作需要在四个应用之间同步状态,实际得到的不是独立系统,而是一个通过事件拼接的隐式单体。边界应该放在团队、领域或生命周期相对稳定的位置,而不是机械地按页面切割。
十、生产交付:边界必须延伸到构建、配置和回滚
模块边界只在源码中成立还不够,构建和交付也应尊重它。
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。
症状二:按钮隐藏了,但接口仍能成功调用
这是把界面控制误当成授权。诊断步骤是:
- 浏览器直接调用 API;
- 检查服务端是否返回
403; - 检查资源级权限是否存在;
- 检查前端是否把
403错误误处理成登录过期; - 检查失败后是否仍修改了本地状态。
症状三:微前端发布后白屏
按顺序检查:
- 远程入口 URL 是否可达;
- 入口是否被 CDN 或 Service Worker 缓存;
- 子应用依赖的 Vue 版本是否兼容;
- 挂载节点是否存在;
- 子应用是否在
setup或路由初始化阶段抛错; - 卸载和重试逻辑是否清理了旧实例;
- 主应用是否记录了远程版本和错误 ID。
不要先通过“再复制一份依赖”解决问题。重复依赖可能暂时绕过版本冲突,却增加包体积和运行时上下文差异;是否共享 Vue 应根据集成方式、版本治理和隔离目标决定。
症状四:同一个请求返回了旧账号的数据
通常是身份切换与异步请求竞态:
- 账号 A 发起请求;
- 用户退出并登录账号 B;
- A 的请求晚返回;
- 页面把 A 的数据写进 B 的状态。
修复方式包括:
- 账号变化时取消旧请求;
- 给请求绑定身份或会话版本;
- 写入状态前确认响应仍属于当前用户;
- 服务端不要只依赖客户端缓存键。
十二、落地顺序:先治理模块,再评估微前端
可以按以下顺序推进,但每一步都应有可验证产物:
- 画出真实依赖图:统计跨业务导入、循环依赖和公共层膨胀;
- 建立公共入口:外部代码只从
public.ts或包入口导入; - 迁移业务规则:把可测试的规则从组件中移到领域或应用层;
- 区分状态归属:删除不必要的全局状态;
- 补齐权限闭环:后端授权、前端路由和界面控制分别实现;
- 处理并发和失败:取消请求、丢弃过期响应、区分
401和403; - 统一设计系统契约:Token、组件、无障碍和版本变更纳入治理;
- 测量交付需求:只有当独立发布和故障隔离收益明确超过集成成本时,才引入微前端。
最终可接受的架构不是“层次最多”或“子应用最多”,而是能够回答以下问题:
- 一个业务规则的唯一归属在哪里;
- 一个模块对外承诺了哪些契约;
- 依赖是否沿稳定方向流动;
- 权限拒绝发生在路由、界面和服务端的哪一层;
- 请求竞态和身份切换如何阻止旧数据写入;
- 子应用失败时主应用如何继续工作;
- 发布后如何验证、灰度和回滚。
当这些问题都有明确答案时,Vue 只是承载实现的框架;真正决定大型应用可维护性的,是边界、方向、契约和故障处理是否彼此一致。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 国际化工程:消息目录、Locale、日期数字、路由和回退
- 下一篇:Vue 生产交付:环境配置、静态资源、缓存、灰度和回滚
- 延伸:Vue 组件库与设计系统:Token、主题、无障碍、文档和版本治理
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论