Appearance
页面导航与导航事务
fin-micro 把“宿主中的页签导航”和“子应用内的路由渲染”分开:core 管理页签身份、权限、离页确认及状态提交;Vue Router 负责把 route 渲染成页面。业务页面通过固定绑定生命周期的 PageSDK 请求导航,不应直接修改宿主对象。
宿主模板使用纯路径 /micro/{appId}/{子应用内路径}?query,例如 /micro/order/detail?id=1001。/home、/profile 属于基座本地应用;嵌入子应用接收到的仍是 /detail?id=1001,不包含 /micro/order 前缀。模板不再接受旧 /workspace?tab=... 地址。
URL 不显式携带 tabId,src/router/targets.ts 的 routeTarget(to, apps) 从已授权菜单查回静态页签身份,动态页面按项目业务规则确定 tabId;tabLocation(tab) 反向生成地址。两者必须能还原同一个 appId + tabId + route,不能依赖现有 sessionStorage 才能识别复制来的链接。模板演示详情使用 ${appId}:detail:${id},并继续由 platform/access.ts 检查 ID、菜单与权限;库不会为任意业务页面推断 tabId。
query 按键排序后参与解析,重复参数维持原顺序。若某业务把完整 route 用于页签身份,不同 query 就是不同页签;若同一页面允许自定义 tabId,必须为这个页面制定唯一的地址解析策略。地址中不接受 Hash 路由。子应用独立运行的 Hash 路由不受此规则影响。
1. 先区分 route、tabId 和 key
ts
import type { OpenTab, MicroTab } from '@axewo/fin-micro/sdk'
const input: OpenTab = {
appId: 'order',
tabId: 'order:detail:1001',
title: '订单详情 1001',
route: '/detail?id=1001',
}
// 宿主接收后生成 MicroTab,其中额外包含只读 key。
// key 由宿主生成,不要自行构造或持久化后重新使用。| 字段 | 职责 | 设计建议 |
|---|---|---|
appId | 已注册子应用身份 | 与宿主注册表完全一致 |
route | 应用内绝对路径,可含 query/hash | 例如 /detail?id=1001,不是 entry 地址 |
tabId | 整个宿主范围唯一的业务页签标识 | 使用应用前缀;每条详情独立打开时包含记录 ID |
key | 某个页签的一次生命周期 | 由 core 创建,关闭重开、刷新、原位换路由会变化 |
title | 页签标题 | 不是路由匹配依据;重新打开已有相同页面不会自动更新标题 |
activeMenuId | 可选的本应用叶菜单归属 | 只控制菜单高亮,不提供权限或决定加载应用 |
打开同一个 tabId 会怎样
以下语义针对异步事务入口:host.operate({ type: 'open', input })、页面 sdk.openTab(input)、sdk.navigateTo(input)。
- 同应用、相同
tabId、相同route:复用现有key,必要时切换激活;已经激活且没有变化时为noop。 - 同应用、相同
tabId、不同route:经过旧页及相关离页守卫后,在原位置替换,生成新key。query 或 hash 改变也属于 route 字符串改变。 - 不同应用占用同一
tabId:拒绝,不会把另一个应用的页面抢过来。 - 要同时保留不同记录页面,应使用
order:detail:1001、order:detail:1002,不要都叫detail。
注意:宿主同步底层原语 host.openTab(input) 不执行异步守卫,而且已有同 ID 异路由会直接拒绝。它适用于初始化,不应拿来替代用户交互事务。
2. 页面 SDK 必须绑定原 key
页面组件在 setup 中创建一次 SDK,切换页签时不要按“当前活动页”重建 SDK。
vue
<script setup lang="ts">
import { ref } from 'vue'
import { getMicroBridge } from '@axewo/fin-micro/sdk'
import { useMicroPage, useMicroTab } from '@axewo/fin-micro/vue'
// 本示例是宿主模式页面,必须放在 MicroTabCache 的组件后代中。
const bridge = getMicroBridge()
if (!bridge) throw new Error('当前页面需要运行在微前端宿主中')
const tab = useMicroTab()
const sdk = useMicroPage(bridge, tab)
const message = ref('')
async function openDetail(id: string) {
try {
await sdk.openTab({
appId: bridge!.appId,
tabId: `${bridge!.appId}:detail:${id}`,
title: `详情 ${id}`,
route: `/detail?${new URLSearchParams({ id }).toString()}`,
})
message.value = '宿主页签已提交或复用;实际页面加载由运行时及路由器完成'
} catch (error) {
message.value = String(error)
}
}
</script>
<template>
<button @click="openDetail('1001')">打开详情 1001</button>
<p role="status">{{ message }}</p>
</template>useMicroPage 在组件真正卸载时自动 dispose()。缓存停用不会释放 SDK。生成子应用使用 src/bridge/session.ts 和公共 useMicroSession() 隔离每次应用挂载;不要建立会被下一次挂载覆盖的全局 bridge 变量。完整可运行页面见订单接入教程。
3. 同应用 query 与 params
OpenTab 只接收最终的字符串 route,没有 query、params 或 name 字段。可以先让 Vue Router 生成字符串,再交给宿主。
query:沿用模板路由
模板有 /detail 路由,详情使用 route.query.id。
ts
import type { PageSDK } from '@axewo/fin-micro/sdk'
export async function openOrderByQuery(sdk: PageSDK, id: string) {
return sdk.openTab({
appId: 'order',
tabId: `order:detail:${id}`,
title: `订单 ${id}`,
route: `/detail?${new URLSearchParams({ id }).toString()}`,
})
}不要手工拼接未经编码的用户输入,例如 '/detail?id=' + id;ID 中的 &、# 等字符会改变路由结构。页面取得 query 后仍需在业务请求边界校验其类型及合法性。
params:先登记动态路由,再 resolve
以下是可选业务改造,不代表当前模板已经登记 /orders/:id。
ts
// router/index.ts 中对应的路由记录
import type { RouteRecordRaw } from 'vue-router'
export const orderRoutes: RouteRecordRaw[] = [{
path: '/orders/:id',
name: 'order-detail',
component: () => import('../views/DetailView.vue'),
}]ts
import type { Router } from 'vue-router'
import type { PageSDK } from '@axewo/fin-micro/sdk'
export async function openOrderByParams(router: Router, sdk: PageSDK, id: string) {
const route = router.resolve({ name: 'order-detail', params: { id } }).fullPath
return sdk.openTab({
appId: 'order',
tabId: `order:detail:${id}`,
title: `订单 ${id}`,
route,
})
}页面再通过 useRoute().params.id 读取。不要写 sdk.openTab({ name, params }),也不要认为单独 router.push() 会自动创建宿主页签。启用多页缓存后,必须提供每个 key 独立的路由上下文,见缓存与浏览器同步。
4. 跨应用使用 navigateTo
sdk.openTab 和 sdk.operate 的显式目标限于本应用。跨应用打开并保留来源页的专用入口是:
text
navigateTo(input: OpenTab, options?: { signal?: AbortSignal }): Promise<NavigationResult>navigateTo 打开并激活目标,保留来源页。宿主开启 crossAppBack: true 后,成功提交会自动记录跨应用来源,目标功能页调用 sdk.back() 可关闭自身并返回来源。该配置不授予子应用任意关闭、刷新或激活其他应用页签的权限。
宿主:分别配置跨应用授权与目标页面准入
以下例子可放在宿主初始化模块,entry 沿用本仓库本地子应用模板服务;生产环境替换为真实部署地址。
ts
import { createMicroHost } from '@axewo/fin-micro/core'
import type { OpenTab } from '@axewo/fin-micro/sdk'
// 实际业务应在认证及权限请求成功后建立快照,再创建宿主。
const session = {
authenticated: true,
permissions: new Set(['order.read', 'inventory.read']),
}
function canReadApp(appId: string): boolean {
if (!session.authenticated) return false
if (appId === 'order') return session.permissions.has('order.read')
if (appId === 'inventory') return session.permissions.has('inventory.read')
return false
}
function isDetail(input: Readonly<OpenTab>): boolean {
const match = /^\/detail\?id=([1-9]\d{0,19})$/.exec(input.route)
return !!match && input.tabId === `${input.appId}:detail:${match[1]}`
}
function canOpen(input: Readonly<OpenTab>): boolean {
if (!canReadApp(input.appId)) return false
const isList = input.route === '/list' && input.tabId === `${input.appId}:list`
return isList || isDetail(input)
}
export const host = createMicroHost([
{ appId: 'order', name: '订单', entry: 'http://127.0.0.1:5181/' },
{ appId: 'inventory', name: '库存', entry: 'http://127.0.0.1:5181/' },
], {
crossAppBack: true,
canOpen,
canNavigate(context, target) {
// 本例只允许订单 -> 库存详情;反向默认拒绝。
return context.appId === 'order' && target.appId === 'inventory'
&& context.tab.appId === context.appId
&& canOpen(context.tab) && canOpen(target) && isDetail(target)
},
})此示例把详情 ID 明确限制为 1~20 位、不带前导零的正整数字符串,并校验 tabId 与 ID 一致;不接受任意详情路径或额外 query/hash。若业务 ID 规则不同,必须同步修改校验、导航生成与后端输入校验。策略读取的是已认证的权限快照,不从页面传来的身份字段推断来源;服务端仍必须执行记录级授权。
| 策略 | 生效范围 | 未配置时 |
|---|---|---|
canOpen(input) | 打开、激活、刷新、返回目标等页面准入,异步事务提交前复核 | 不增加此项门禁 |
canNavigate(context, target) | navigateTo(target)、back(target) 显式跨应用目标授权 | 拒绝显式跨应用目标 |
crossAppBack | 自动记录跨应用来源,并允许 back() 返回记录的来源 | false;新宿主模板已开启 |
两者都是同步策略,只有严格 true 放行。不能声明 async,返回 Promise、抛异常或其他值均按拒绝处理。异步权限请求必须在外部完成,再供策略读取;策略不要重入修改导航状态。前端门禁不是服务端授权,也不是对恶意同窗口脚本的隔离边界。
来源页面:处理每一种结果
ts
import { NavigationError, type PageSDK } from '@axewo/fin-micro/sdk'
export async function openRelatedInventory(sdk: PageSDK, id: string): Promise<string> {
try {
const result = await sdk.navigateTo({
appId: 'inventory',
tabId: `inventory:detail:${id}`,
title: `关联库存 ${id}`,
route: `/detail?${new URLSearchParams({ id }).toString()}`,
})
switch (result.status) {
case 'committed': return '宿主导航状态已提交,来源页签保留'
case 'noop': return '目标已经处于请求的状态'
case 'cancelled': return '用户在离页确认中取消了导航'
case 'stale': return '本次导航意图已过期,请根据当前状态重新操作'
}
} catch (error) {
if (error instanceof NavigationError) {
if (error.code === 'FORBIDDEN') return '没有跨应用或目标页面访问权限'
if (error.code === 'PAGE_RELEASED') return '来源页面生命周期已结束'
return `导航失败:${error.code}`
}
throw error
}
}兼容旧宿主时,模板同时检查 typeof sdk.navigateTo === 'function' 和 bridge.protocol().features.includes('navigate-to')。这只是兼容性判断,不是授权判断。
功能页面关闭:传目标去目标,不传返回来源
这里的关闭发生在子应用功能页面内部:关闭当前功能页对应的宿主页签,并确定返回目标。宿主页签栏的关闭按钮继续使用项目原有的邻页激活规则。
ts
// 在 MicroTabCache 页面 setup 中绑定当前功能页,不能在点击时绑定全局活动页。
import { useMicroPage, useMicroTab } from '@axewo/fin-micro/vue'
import { getMicroBridge, NavigationError, type OpenTab } from '@axewo/fin-micro/sdk'
import { ref } from 'vue'
const bridge = getMicroBridge()
if (!bridge) throw new Error('本示例需要嵌入宿主;独立运行请走本应用 Router')
const sdk = useMicroPage(bridge, useMicroTab())
const message = ref('')
// closePage() 返回来源;closePage(listTarget) 去指定列表。
async function closePage(destination?: OpenTab) {
try {
const result = await sdk.back(destination)
if (result.status === 'cancelled') message.value = '已取消关闭'
else if (result.status === 'stale') message.value = '页面状态已变化,请重新操作'
else if (result.status === 'noop') message.value = '目标就是当前页面,无需关闭'
// committed:宿主已原子关闭当前页并打开/激活目标,通常无需再执行 router.push。
} catch (error) {
message.value = error instanceof NavigationError
? error.code === 'FORBIDDEN' ? '没有目标页面访问权限,当前页已保留' : `关闭失败:${error.code}`
: '关闭失败,请稍后重试'
}
}
const listTarget: OpenTab = {
appId: sdk.tab.appId,
tabId: `${sdk.tab.appId}:list`,
title: '列表',
route: '/list',
}按钮绑定示例:<el-button @click="closePage()">关闭并返回来源</el-button>,或 <el-button @click="closePage(listTarget)">关闭并回列表</el-button>。
| 调用 | 行为 |
|---|---|
sdk.back() | 关闭本页,优先返回记录的来源;跨应用来源须宿主开启 crossAppBack |
sdk.back(listTarget) | 关闭本页,优先去指定列表,即使有来源也不会返回来源 |
sdk.back(otherAppTarget) | 显式跨应用目的地须 canNavigate 与 canOpen 同时放行 |
sdk.closeCurrent() | 仅关闭原 key,活动页由宿主邻页规则决定,不承诺返回来源 |
显式目标是完整 OpenTab,不只是路由字符串。宿主需要 appId 确定应用、tabId 确定是否复用、route 确定页面;没有实现 defineMicroPage 或自动 tabId,请继续传真实的导航字段。目标已存在且路由相同则复用;同 tabId 异路由会经守卫替换为新 key;目标正是本页且路由相同则 noop,不会关闭自身。
权限与取消: 指定目标无权限、来源访问权限撤销、守卫拒绝或 Router 取消时,都保留当前页。指定目标失败不会改走来源。back() 返回宿主已记录的跨应用来源只检查当前 canOpen,不要求反向 canNavigate;例如允许订单打开库存后,库存可以返回那张订单,但不能借此任意打开其他订单。
多级来源: A → B → C,C 内部关闭回 B,B 内部关闭回 A。B 已关闭时,来源自动衔接为 C → A;批量关闭多个中间节点同样寻找最近存活祖先。关闭操作失败不会改变来源链。同 tabId 关闭重开属于新实例,不会复活成旧来源。刷新本页保留本页的来源;其他子页指向被刷新的旧 key 时跳过旧实例,衔接到它的祖先,不自动绑定到刷新后的新实例。
页签复用: A、D 先后打开同一个 B,保留 B 已有的有效来源 A;没有来源时才补充,祖先回跳不会形成循环。若各入口需要独立返回关系,应使用不同 tabId 打开独立功能页。来源链只在当前宿主内存中保存,浏览器刷新或重新登录不恢复;无有效来源时沿用本应用左邻、右邻、宿主剩余邻页的回落顺序,最后可关闭为空工作区。
旧版 back 参数迁移
旧版 sdk.back(input) 把 input 当作“没有来源时才使用的 fallback”;现在 input 是显式优先目标。升级时需检查所有带参数调用:
ts
await sdk.back() // 优先回来源
await sdk.back(target) // 无论是否有来源,优先去 target
// 确实需要旧 fallback 语义时,使用保留该语义的低层命令;fallback 仅支持本应用。
await sdk.operate({ type: 'back', fallback: listTarget })对接旧宿主时,先检查 bridge.protocol().features.includes('back-target'),再使用带参数的新语义;cross-app-back 仅在宿主开启跨应用来源返回时声明。缺少能力时禁用相关入口并提示升级,不假定旧宿主会按新规则执行。
5. 结果、异常与取消
operate、navigateTo、refreshCurrent、back 返回 NavigationResult:
| status | 含义 | 建议 |
|---|---|---|
committed | 宿主状态已原子提交 | 不等于子应用挂载成功、router 完成或业务数据加载成功 |
cancelled | 某个离页守卫返回 false | 保留当前状态,不自动重试确认 |
noop | 无须改变状态,例如再次激活当前页 | 可作为幂等结果处理 |
stale | 目标生命周期过期、等待期间状态变化或 signal 取消等 | 重新读取状态,由用户重新发起意图 |
affected 是实际变动的生命周期引用,不是“已挂载组件列表”;后三种状态的 affected 为空。tab 为提交后的目标页(如有)。
公开 NavigationError.code 包括 INVALID_OPERATION、FORBIDDEN、PAGE_RELEASED、HOST_DISPOSED、GUARD_FAILED、CANCELLED、STALE。不要只依赖错误文本。常规事务 signal 取消走 stale 结果,守卫拒绝走 cancelled 结果;便利接口 sdk.openTab() 则将不能提供页签的取消/过期结果转成异常。因此不要把所有取消都只放在 catch 中处理。
ts
import type { PageSDK } from '@axewo/fin-micro/sdk'
export function startNavigation(sdk: PageSDK) {
const controller = new AbortController()
const result = sdk.navigateTo({
appId: 'inventory',
tabId: 'inventory:list',
title: '库存列表',
route: '/list',
}, { signal: controller.signal })
return { result, cancel: () => controller.abort() }
}signal 只取消尚未提交的导航;不会中断守卫内部保存请求,不会撤销已经发生的业务副作用,也不会把已经提交的导航回滚。
6. 未保存表单与离页守卫
普通表单使用 useUnsavedChanges,在 setup 调用一次即可自动比较输入;不需要手动维护 dirty 或在每次输入时调用 Hook。生成模板已安装基座/独立运行的统一确认,全部复用 fin-ui FDialog。
vue
<script setup lang="ts">
import { ref } from 'vue'
import { useUnsavedChanges } from '@axewo/fin-micro/vue'
const draft = ref('')
const { dirty, setBaseline } = useUnsavedChanges(() => ({ draft: draft.value }), { label: '编辑内容' })
// 查询详情并赋值后 setBaseline();保存成功后 setBaseline(本次提交的快照)。
</script>
<template>
<textarea v-model="draft" />
<span>{{ dirty ? '尚未保存' : '已保存' }}</span>
</template>切换缓存页签保留草稿,不触发 Hook 的未保存确认;关闭、刷新、原位替换才检查将被销毁的页签,批量关闭合并为一次提示。退出使用单独的系统确认,不再逐页询问。完整加载、保存、第二参数、两页签与同页多表单示例见未保存表单与统一确认。
底层 sdk.beforeLeave(guard) 保留,用于额外业务条件,不要再给同一个表单添加一轮 dirty 弹框:
- 返回幂等取消订阅函数;SDK dispose、页面关闭等会自动清理。
- 底层守卫仍在关闭、刷新、原位替换及活动页切换前按串行队列执行;任一返回
false,取消整个事务。 - 允许守卫异步保存:
await业务请求,保存成功后清除 dirty 并返回true;失败抛错会变为GUARD_FAILED,不会泄漏原始业务异常。 createMicroHost(apps, { guardTimeoutMs: 60000 })限制单个守卫等待,超时抛出GUARD_TIMEOUT并取消本次导航;宿主销毁以HOST_DISPOSED结束挂起等待。守卫收到的signal在超时、销毁或调用方取消时中止,可传给保存请求;已完成的业务副作用不会回滚,也不会自动重试保存。- 守卫内不可 await 同宿主的
operate/openTab/navigateTo/closeCurrent/back/refreshCurrent,否则当前事务在等待守卫,而新导航在等待当前事务,形成自等待。守卫只完成确认/保存并返回,后续导航在外部动作流程执行。 - 不在
onDeactivated中释放 SDK 或注销守卫,否则批量关闭非活动脏页时会丢失确认。 - 守卫不能阻止缓存 LRU 驱逐、组件树卸载或浏览器整页刷新。重要草稿另行持久化。
7. 宿主批量导航与原子关闭
宿主 host.operate() 可操作全部应用;页面 sdk.operate() 只操作本应用。不要向子应用注入整个 host 来绕开此范围。
ts
import type { MicroHost } from '@axewo/fin-micro/core'
export async function closeOtherTabs(host: MicroHost, anchorTabId: string) {
const state = host.getState()
const anchor = state.tabs.find(tab => tab.tabId === anchorTabId)
if (!anchor) return
const home = state.tabs.find(tab => tab.tabId === '__home__')
return host.operate({
type: 'closeMany',
scope: 'others',
anchor: { tabId: anchor.tabId, key: anchor.key },
exclude: home ? [{ tabId: home.tabId, key: home.key }] : [],
})
}scope 可为 all、others、left、right。除 all 外按 anchor 确定范围;省略 anchor 时页面 SDK 使用绑定来源页,宿主使用调用时活动页。exclude 只从候选集排除生命周期,不是永久“锁定”页签;被排除页不参与本次关闭确认。直接关闭仍须由业务入口单独限制。
批量集合、anchor 和省略 key 的引用在调用时捕获,等待中的新页签不会被顺带关闭。所有相关守卫通过后一次提交,任何一个拒绝都不部分关闭。守卫期间同步底层写入导致状态 revision 改变时,本事务返回 stale,不会重复弹确认。
不要用 Promise.all(tabs.map(tab => host.operate({ type: 'close', ... }))) 模拟原子批量关闭:那是多个事务,可能一部分已关闭、一部分被拒绝。使用一次 closeMany。
8. 常见错误定位
| 现象 | 优先检查 |
|---|---|
跨应用报 FORBIDDEN | 是否用了 navigateTo;是否配置同步 canNavigate;canOpen 是否同时放行 |
| 详情 A 切到 B 后 A 的 ID 也变了 | 缓存页面是否直接读取全局路由;使用每 key 路由上下文 |
| 同 ID 换路由时报冲突 | 是否误用宿主同步 openTab;用户动作改为异步 operate(open) |
| 成功返回但应用显示加载失败 | committed 只表示 core 提交;检查 runtime 挂载、entry 及 router 错误 |
| 批量关闭没有确认停用页 | 是否在停用时 dispose;或该页已被 LRU 真正卸载 |
| 异步操作关错页 | 是否按活动 tabId 操作;使用创建时绑定 key 的 sdk.closeCurrent |
| 功能页关闭没有回到来源 | 是否调用无参 back;宿主是否开启 crossAppBack;来源是否通过页面 SDK 打开;是否刷新浏览器丢失来源 |
9. 对照源码
- 导航协议:
packages/fin-micro/src/sdk/index.ts中的OpenTab、NavigationOperation、PageSDK。 - 权限、捕获与提交:
packages/fin-micro/src/core/index.ts中的prepareTab、enqueue。 - 完整页面动作模板:
packages/fin-micro/scaffold/vue/src/views/demo/hooks/useRecordNavigation.ts与详情组件;保存后返回的完整业务示例见第三章。 - 固定首页批量排除:
packages/fin-micro/scaffold/host/src/localPages.ts中的workspaceOperation。