面包屑自动推导的算法设计:从"最短路径匹配"到工程可落地
js
/**
* 面包屑组合式函数
* @description 基于路由栈、菜单树与持久化缓存动态生成面包屑
* @date 2025-12-11
* @updated 2026-4-28 - 优化逻辑与类型定义
*/
import type { BreadcrumbItem } from '~/router/types'
import type { ResolvedBreadcrumbNode } from '~/types/breadcrumb'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { CACHE_KEY, useWebCache } from '~/composables/cache'
import { RouteConfig } from '~/config'
import { buildBreadcrumbRouteKey, useBreadcrumbStore } from '~/store/core/breadcrumb'
import { buildMenuTree, getCommonSegmentCount, normalizePath } from '~/utils/navigation'
/**
* =============================================================================
* 文件导读
* =============================================================================
* 这份 composable 的目标:把"当前路由"解析成可展示的面包屑列表。
*
* 一、核心输入来源
* - 当前路由:`useRoute()`(path/name/meta/query/params)
* - 后端菜单:`CACHE_KEY.ROLE_ROUTERS`(用于自动推导父级)
* - 路由配置:`RouteConfig`(用于补全文案)
* - 历史状态:`breadcrumbStore`(用于上下文继承与缓存)
*
* 二、策略优先级(由高到低)
* 1)contextual:命中当前菜单链路
* 2)closestMenu:按路径相似度自动猜父级菜单链路
* 3)cached:当前 routeKey 对应的历史已解析结果
* 4)inherited:从上一次访问轨迹弱继承(详情跳详情)
* 5)currentOnly:只显示当前页
*
* 三、代码分区建议阅读顺序
* 1)基础工具函数:去重/合并/比较(纯函数)
* 2)menuTrails:把菜单树拍平成"根 → 叶子"链路集合
* 3)关键策略函数:findClosestMenuTrail / buildContextualTrail
* 4)弱兜底策略:buildInheritedTrail
* 5)输出层:resolvedTrail → breadcrumbs → shouldShow
*
* 四、维护原则
* - 先改注释中的策略描述,再改实现,保证"文档与代码一致"。
* - 新增策略优先级时,务必同步 `resolveTrailByStrategies` 顺序。
* - 避免在模板层做字符串拼装,统一在本文件完成。
* =============================================================================
*/
/**
* 后端菜单原始节点结构(来自 ROLE_ROUTERS 缓存)。
* 注意:字段全部可选是为了兼容历史数据与不同后端版本返回。
*/
interface MenuNode {
id?: string | number
parentId?: string | number
path?: string
name?: string
nameEn?: string
visible?: boolean
children?: MenuNode[]
}
/** 本地路由与配置可复用的最小元信息结构。 */
interface LocalRouteMeta {
title?: string
i18nKey?: string
}
type StrategySource = 'home' | 'contextual' | 'closestMenu' | 'cached' | 'inherited' | 'currentOnly'
type TrailScoreFn = (leafPath: string, currentPath: string) => number
/**
* 将节点标准化为"可点击"节点。
* 约定:除当前页节点外,其它面包屑节点默认可点击,最终点击态会在输出阶段再次收敛。
*/
function toBreadcrumbNode(item: Omit<ResolvedBreadcrumbNode, 'isClickable'>): ResolvedBreadcrumbNode {
return {
...item,
isClickable: true,
}
}
/**
* 去重并合并相邻重复节点。
* 合并规则:
* - 相邻且 path/name 相同视为同一节点;
* - 以"后者覆盖前者"方式合并,保留最新字段(例如 i18nKey/title 修正值)。
*/
function dedupeTrail(items: ResolvedBreadcrumbNode[]): ResolvedBreadcrumbNode[] {
return items.reduce<ResolvedBreadcrumbNode[]>((acc, item) => {
const last = acc[acc.length - 1]
if (last && last.path === item.path && last.name === item.name) {
acc[acc.length - 1] = {
...last,
...item,
}
return acc
}
acc.push(item)
return acc
}, [])
}
/**
* 将当前页节点合并到已有 trail 尾部。
* - 若尾节点与 current path 相同:只更新尾节点,避免重复"当前节点";
* - 否则直接 append。
*/
function mergeTrailWithCurrent(trail: ResolvedBreadcrumbNode[], currentNode: ResolvedBreadcrumbNode): ResolvedBreadcrumbNode[] {
if (!trail.length) {
return [currentNode]
}
const last = trail[trail.length - 1]
if (last.path === currentNode.path) {
const mergedTrail = [...trail]
mergedTrail[mergedTrail.length - 1] = {
...last,
...currentNode,
}
return mergedTrail
}
return [...trail, currentNode]
}
/**
* 深比较两个 trail 是否语义一致。
* 用于避免把完全相同的结果重复写入 store,减少无意义状态更新与 watcher 链式触发。
*/
function isSameTrail(a: ResolvedBreadcrumbNode[], b: ResolvedBreadcrumbNode[]): boolean {
if (a.length !== b.length) {
return false
}
return a.every((item, index) => {
const other = b[index]
if (!other) {
return false
}
return item.title === other.title
&& item.titleEn === other.titleEn
&& item.path === other.path
&& item.name === other.name
&& item.i18nKey === other.i18nKey
&& item.isClickable === other.isClickable
})
}
/**
* 识别"详情类页面"路径:
* 这类页面通常不在菜单中,应该优先回挂到同域"管理页"作为父级面包屑。
*/
function isDetailLikePath(path: string): boolean {
return /\/(?:detail|edit|view|create|pay|statement)(?:\/|$)/i.test(path)
}
/**
* 判断菜单路径是否带有"管理页"特征。
* 在最短公共前缀分数相同的情况下,详情类页面优先选择该类菜单链路。
*/
function hasManageHint(path: string): boolean {
return /\/[^/]*manage(?:ment)?(?:\/|$)/i.test(path)
}
/** 取路径首段(例:/import/si-manage -> import),用于候选分组裁剪。 */
function getPathRootSegment(path: string): string {
return normalizePath(path).split('/').filter(Boolean)[0] || ''
}
/**
* 面包屑策略分发器(纯函数)。
* 按优先级选取第一条可用策略结果,确保行为可预测:
* home > contextual > closest-menu > cached > inherited > current-only。
*/
export function resolveTrailByStrategies(params: {
isHomeRoute: boolean
homeTrail: ResolvedBreadcrumbNode[]
contextualTrail: ResolvedBreadcrumbNode[]
closestMenuTrail: ResolvedBreadcrumbNode[]
cachedTrail: ResolvedBreadcrumbNode[]
inheritedTrail: ResolvedBreadcrumbNode[]
currentNode: ResolvedBreadcrumbNode
}): ResolvedBreadcrumbNode[] {
if (params.isHomeRoute) {
return dedupeTrail(params.homeTrail)
}
if (params.contextualTrail.length > 0) {
return params.contextualTrail
}
if (params.closestMenuTrail.length > 0) {
return dedupeTrail(mergeTrailWithCurrent(params.closestMenuTrail, params.currentNode))
}
if (params.cachedTrail.length > 0) {
return dedupeTrail(mergeTrailWithCurrent(params.cachedTrail.slice(0, -1), params.currentNode))
}
if (params.inheritedTrail.length > 0) {
return params.inheritedTrail
}
return [params.currentNode]
}
export function useBreadcrumb() {
/** 当前路由对象(响应式) */
const route = useRoute()
/** 路由实例(用于按 name/path 查找注册路由) */
const router = useRouter()
/** 多语言能力(title 文案渲染) */
const { t, locale } = useI18n()
/** 本地缓存访问器(读取后端菜单树) */
const { webCache } = useWebCache()
/** 面包屑状态仓库(上下文/历史轨迹) */
const breadcrumbStore = useBreadcrumbStore()
/** 当前路由唯一键(path + query + params 归一后组合) */
const routeKey = computed(() => buildBreadcrumbRouteKey(route))
/** 首页路由标识(走首页特判策略) */
const isHomeRoute = computed(() => route.meta?.routeSource === 'home')
/** 调试开关:URL 带 breadcrumbDebug=1 时输出匹配日志(仅开发环境)。 */
const breadcrumbDebugEnabled = computed(() => import.meta.env.DEV && String(route.query?.breadcrumbDebug || '') === '1')
/** A/B 开关:URL 带 breadcrumbScoreAB=1 时打印实验评分对比(仅开发环境)。 */
const breadcrumbScoreABEnabled = computed(() => import.meta.env.DEV && String(route.query?.breadcrumbScoreAB || '') === '1')
/** router.getRoutes() -> path 元信息索引,避免在菜单 DFS 内部重复 find。 */
const routeMetaByPath = computed<Map<string, LocalRouteMeta>>(() => {
const metaMap = new Map<string, LocalRouteMeta>()
router.getRoutes().forEach((routeRecord) => {
const normalizedPath = normalizePath(routeRecord.path)
if (!normalizedPath) return
const title = typeof routeRecord.meta?.title === 'string' ? routeRecord.meta.title : undefined
const i18nKey = typeof routeRecord.meta?.i18nKey === 'string' ? routeRecord.meta.i18nKey : undefined
if (title || i18nKey) {
metaMap.set(normalizedPath, {
title,
i18nKey,
})
}
})
return metaMap
})
/** RouteConfig -> path 元信息索引,作为本地路由元信息的二级兜底。 */
const configMetaByPath = computed<Map<string, LocalRouteMeta>>(() => {
const metaMap = new Map<string, LocalRouteMeta>()
Object.values(RouteConfig).forEach((config) => {
const record = config as { path?: string
title?: string
i18nKey?: string }
const normalizedPath = normalizePath(record.path)
if (!normalizedPath) return
if (record.title || record.i18nKey) {
metaMap.set(normalizedPath, {
title: record.title,
i18nKey: record.i18nKey,
})
}
})
return metaMap
})
/**
* 按 path 获取本地路由文案元信息。
* 优先级:
* 1)已注册路由 meta;
* 2)RouteConfig 静态配置;
* 3)无匹配返回空对象。
*/
function getLocalMetaByPath(path: string): LocalRouteMeta {
const normalizedPath = normalizePath(path)
return routeMetaByPath.value.get(normalizedPath) || configMetaByPath.value.get(normalizedPath) || {}
}
/**
* 将后端菜单树拍平成"根 -> 叶子"的路径集合,供后续策略匹配使用。
* 这里是自动面包屑的基础数据源:
* 1) 命中 exact trail:当前路由路径与菜单叶子路径完全一致;
* 2) 命中 closest trail:当前路由是详情页等非菜单页,按路径相似度回挂父菜单。
*/
const menuTrails = computed<ResolvedBreadcrumbNode[][]>(() => {
const menuList = webCache.get(CACHE_KEY.ROLE_ROUTERS) as MenuNode[] | null
const trails: ResolvedBreadcrumbNode[][] = []
const menuTree = Array.isArray(menuList) ? buildMenuTree(menuList) : []
// 防御性限制菜单递归深度,避免异常树结构或循环引用导致无限递归与额外遍历开销。
const MAX_MENU_WALK_DEPTH = 10
/**
* DFS 遍历菜单树并生成 trails。
* @param nodes 当前层节点列表
* @param parentTrail 父链路(不含当前节点)
* @param depth 当前递归深度(用于保护递归)
*/
function walk(nodes: MenuNode[], parentTrail: ResolvedBreadcrumbNode[] = [], depth = 0) {
if (depth > MAX_MENU_WALK_DEPTH) {
return
}
nodes.forEach((node) => {
const backendTitle = typeof node.name === 'string' ? node.name.trim() : ''
const backendTitleEn = typeof node.nameEn === 'string' ? node.nameEn.trim() : ''
const currentPath = normalizePath(node.path)
const localMeta = currentPath ? getLocalMetaByPath(currentPath) : {}
const title = backendTitle || localMeta.title || ''
const titleEn = backendTitleEn || backendTitle || localMeta.title || ''
const i18nKey = backendTitle ? undefined : localMeta.i18nKey
const hasChildren = Array.isArray(node.children) && node.children.length > 0
// 分组节点(无 path)只作为层级容器,不入最终可跳转叶子集合。
if (!currentPath) {
if (!hasChildren) {
return
}
const groupNode: ResolvedBreadcrumbNode = {
title,
titleEn,
path: '',
name: String(node.id || title || ''),
i18nKey,
isClickable: false,
}
const groupTrail = [...parentTrail, groupNode]
walk(node.children || [], groupTrail, depth + 1)
return
}
// 真实菜单路由节点,记录为一条可命中的 trail。
const currentNode = toBreadcrumbNode({
title,
titleEn,
path: currentPath,
name: currentPath,
i18nKey,
})
const currentTrail = [...parentTrail, currentNode]
trails.push(currentTrail)
if (hasChildren) {
walk(node.children || [], currentTrail, depth + 1)
}
})
}
if (menuTree.length > 0) {
walk(menuTree)
}
return trails
})
/** path -> exact trail 索引:用于 O(1) 命中精确菜单链路。 */
const exactTrailByPath = computed<Map<string, ResolvedBreadcrumbNode[]>>(() => {
const pathMap = new Map<string, ResolvedBreadcrumbNode[]>()
menuTrails.value.forEach((trail) => {
const leafPath = normalizePath(trail[trail.length - 1]?.path)
if (!leafPath || pathMap.has(leafPath)) return
pathMap.set(leafPath, trail)
})
return pathMap
})
/** 首段前缀 -> trails 索引:用于 closest 候选裁剪(同域优先)。 */
const menuTrailsByRootSegment = computed<Map<string, ResolvedBreadcrumbNode[][]>>(() => {
const segmentMap = new Map<string, ResolvedBreadcrumbNode[][]>()
menuTrails.value.forEach((trail) => {
const leafPath = trail[trail.length - 1]?.path
const rootSegment = getPathRootSegment(leafPath || '')
if (!rootSegment) return
const group = segmentMap.get(rootSegment)
if (group) {
group.push(trail)
} else {
segmentMap.set(rootSegment, [trail])
}
})
return segmentMap
})
/**
* 所有菜单叶子 path 集合:
* 用于快速判断某个节点是否"真正来自菜单",影响 inherited 策略拼接行为。
*/
const exactMenuPaths = computed(() => new Set(exactTrailByPath.value.keys()))
/**
* 解析展示标题:
* - 有 i18nKey 时优先走国际化;
* - 无 i18nKey 时按语言选择 title/titleEn。
*/
function resolveDisplayTitle(item: ResolvedBreadcrumbNode): string {
const isEn = String(locale.value).toLowerCase().startsWith('en')
if (item.i18nKey) {
return t(`router.${item.i18nKey}`, item.title)
}
return isEn ? (item.titleEn || item.title) : (item.title || item.titleEn || '')
}
/**
* 构造当前路由节点(最终 trail 的尾节点)。
* 优先级:
* 1) store 中自定义标题(业务运行态覆盖);
* 2) 当前 route.meta 回退。
*/
function createCurrentRouteNode(): ResolvedBreadcrumbNode {
const customContext = breadcrumbStore.contextByRouteKey[routeKey.value]
const routeName = typeof route.name === 'string' ? route.name : route.path
if (customContext?.customTitle) {
return {
title: customContext.customTitle,
titleEn: customContext.customTitleEn || customContext.customTitle,
path: normalizePath(route.path),
name: routeName,
isClickable: false,
}
}
return {
title: typeof route.meta?.title === 'string' ? route.meta.title : routeName,
titleEn: typeof route.meta?.title === 'string' ? route.meta.title : routeName,
path: normalizePath(route.path),
name: routeName,
i18nKey: typeof route.meta?.i18nKey === 'string' ? route.meta.i18nKey : undefined,
isClickable: false,
}
}
/**
* 精确匹配菜单 trail:仅 path 完全一致才命中。
*/
function findExactMenuTrail(path: string): ResolvedBreadcrumbNode[] {
const normalizedPath = normalizePath(path)
return exactTrailByPath.value.get(normalizedPath) || []
}
/** 默认评分:公共路径段数量越多,说明语义越接近。 */
const scoreByCommonSegments: TrailScoreFn = (leafPath, currentPath) => getCommonSegmentCount(leafPath, currentPath)
/**
* 实验评分:在公共段基础上轻微偏向"更短叶子路径"。
* 仅用于 A/B 观测,不直接影响线上策略结果。
*/
const scoreByCompactTrail: TrailScoreFn = (leafPath, currentPath) => {
const commonSegments = getCommonSegmentCount(leafPath, currentPath)
if (commonSegments <= 0) return 0
const leafSegments = leafPath.split('/').filter(Boolean).length || 1
return commonSegments + (1 / leafSegments) * 0.01
}
function pickBestTrail(
candidates: ResolvedBreadcrumbNode[][],
normalizedPath: string,
scoreFn: TrailScoreFn,
): { bestTrail: ResolvedBreadcrumbNode[]
bestScore: number
tieBreakReason: string } {
/** 当前最优候选 trail */
let bestTrail: ResolvedBreadcrumbNode[] = []
/** 当前最优分值 */
let bestScore = 0
/** 记录最后一次生效的 tie-break 原因,便于观测与回放。 */
let tieBreakReason = 'none'
/** 详情页标识:同分时启用"优先管理页"策略 */
const currentIsDetailLike = isDetailLikePath(normalizedPath)
candidates.forEach((trail) => {
const leaf = trail[trail.length - 1]
if (!leaf?.path) return
const score = scoreFn(leaf.path, normalizedPath)
if (score > bestScore) {
bestScore = score
bestTrail = trail
tieBreakReason = 'higher_score'
} else if (score === bestScore && score > 0) {
// 同分时:详情类页面优先回挂"管理页"菜单(如 /import/si-manage)
if (currentIsDetailLike) {
const currentHasManageHint = hasManageHint(leaf.path)
const bestHasManageHint = hasManageHint(bestTrail[bestTrail.length - 1]?.path || '')
if (currentHasManageHint !== bestHasManageHint) {
if (currentHasManageHint) {
bestTrail = trail
tieBreakReason = 'detail_manage_hint'
}
return
}
}
// 仍同分时,选择层级更浅的菜单,减少错误挂到过深子页面。
const currentLeafSegments = leaf.path.split('/').filter(Boolean).length
const bestLeafSegments = bestTrail[bestTrail.length - 1]?.path?.split('/').filter(Boolean).length || Infinity
if (currentLeafSegments < bestLeafSegments) {
bestTrail = trail
tieBreakReason = 'shallower_leaf'
}
}
})
return {
bestTrail,
bestScore,
tieBreakReason,
}
}
/**
* 自动猜测"最可能父级菜单链路":
* - 先尝试 exact 命中;
* - 否则按路径公共段得分选择最接近链路;
* - 若同分,详情类页面优先选择包含 manage/management 的菜单;
* - 再同分时选择层级更浅的链路,避免误挂过深叶子节点。
*/
function findClosestMenuTrail(path: string): ResolvedBreadcrumbNode[] {
const normalizedPath = normalizePath(path)
const exactTrail = findExactMenuTrail(normalizedPath)
if (exactTrail.length > 0) {
if (breadcrumbDebugEnabled.value) {
console.info('[breadcrumb.closest] exact_match', {
path: normalizedPath,
source: 'exact',
score: Number.POSITIVE_INFINITY,
})
}
return exactTrail
}
const rootSegment = getPathRootSegment(normalizedPath)
const groupedCandidates = rootSegment ? menuTrailsByRootSegment.value.get(rootSegment) : undefined
const activeCandidates = groupedCandidates && groupedCandidates.length > 0 ? groupedCandidates : menuTrails.value
const candidateSource = groupedCandidates && groupedCandidates.length > 0 ? 'root_segment' : 'global_fallback'
const baselineResult = pickBestTrail(activeCandidates, normalizedPath, scoreByCommonSegments)
if (breadcrumbDebugEnabled.value) {
console.info('[breadcrumb.closest] matched', {
path: normalizedPath,
source: candidateSource,
candidateCount: activeCandidates.length,
score: baselineResult.bestScore,
tieBreakReason: baselineResult.tieBreakReason,
matchedPath: baselineResult.bestTrail[baselineResult.bestTrail.length - 1]?.path || '',
})
}
if (breadcrumbScoreABEnabled.value) {
const experimentResult = pickBestTrail(activeCandidates, normalizedPath, scoreByCompactTrail)
console.info('[breadcrumb.closest] score_ab', {
path: normalizedPath,
baselineScore: baselineResult.bestScore,
baselinePath: baselineResult.bestTrail[baselineResult.bestTrail.length - 1]?.path || '',
experimentScore: experimentResult.bestScore,
experimentPath: experimentResult.bestTrail[experimentResult.bestTrail.length - 1]?.path || '',
})
}
return baselineResult.bestScore > 0 ? baselineResult.bestTrail : []
}
/**
* contextual 策略:仅命中当前路由对应的精确菜单链路。
*/
function buildContextualTrail(): ResolvedBreadcrumbNode[] {
return findExactMenuTrail(route.path)
}
/**
* 继承上一次已解析面包屑(弱兜底):
* 用于详情跳详情、列表跳详情等连续跳转,减少"只剩当前节点"的退化情况。
* 仅在存在 query/params 或详情类路径时启用,避免污染普通菜单页结果。
*/
function buildInheritedTrail(currentNode: ResolvedBreadcrumbNode): ResolvedBreadcrumbNode[] {
const normalizedCurrentPath = normalizePath(route.path)
const isHomeLikeRoute = normalizedCurrentPath === normalizePath(RouteConfig.Index.path)
|| route.path === '/'
|| route.path === '/index'
|| route.path === '/dashboard'
if (isHomeLikeRoute) {
return []
}
// 仅"详情类跳转"场景尝试继承,普通列表页不继承,避免脏链路。
const hasQuery = Object.keys(route.query || {}).length > 0
const hasParams = Object.keys(route.params || {}).length > 0
const isDetailLikeRoutePath = isDetailLikePath(normalizedCurrentPath)
if (!hasQuery && !hasParams && !isDetailLikeRoutePath) {
return []
}
// 同一路由键不重复继承,避免形成自引用。
if (breadcrumbStore.lastResolvedRouteKey === routeKey.value) {
return []
}
// 上一次已解析并落库的 trail。
const previousTrail = breadcrumbStore.lastResolvedTrail
if (!previousTrail.length) {
return []
}
const previousLast = previousTrail[previousTrail.length - 1]
const commonSegments = getCommonSegmentCount(previousLast?.path, route.path)
if (commonSegments < 1) {
return []
}
// 若上一个尾节点本身是菜单节点,则整条继承;否则去掉旧尾节点再拼当前节点。
const previousLastIsMenu = exactMenuPaths.value.has(normalizePath(previousLast?.path))
const baseTrail = previousLastIsMenu ? [...previousTrail] : previousTrail.slice(0, -1)
if (!baseTrail.length) {
return []
}
if (baseTrail[baseTrail.length - 1]?.path === currentNode.path) {
return baseTrail
}
return dedupeTrail([...baseTrail, currentNode])
}
/**
* 首页链路构造:
* - 当前就是首页:仅返回当前节点;
* - 其它页面:返回"首页 > 当前页"。
*/
function buildHomeTrail(currentNode: ResolvedBreadcrumbNode): ResolvedBreadcrumbNode[] {
const normalizedCurrentPath = normalizePath(route.path)
const normalizedHomePath = normalizePath(RouteConfig.Index.path)
if (!normalizedCurrentPath || normalizedCurrentPath === normalizedHomePath) {
return [currentNode]
}
return [
{
title: RouteConfig.Index.title,
titleEn: RouteConfig.Index.title,
i18nKey: RouteConfig.Index.i18nKey,
path: normalizedHomePath,
name: RouteConfig.Index.name,
isClickable: true,
},
currentNode,
]
}
function resolveStrategySource(params: {
isHomeRoute: boolean
contextualTrail: ResolvedBreadcrumbNode[]
closestMenuTrail: ResolvedBreadcrumbNode[]
cachedTrail: ResolvedBreadcrumbNode[]
inheritedTrail: ResolvedBreadcrumbNode[]
}): StrategySource {
if (params.isHomeRoute) return 'home'
if (params.contextualTrail.length > 0) return 'contextual'
if (params.closestMenuTrail.length > 0) return 'closestMenu'
if (params.cachedTrail.length > 0) return 'cached'
if (params.inheritedTrail.length > 0) return 'inherited'
return 'currentOnly'
}
const resolvedTrail = computed<ResolvedBreadcrumbNode[]>(() => {
const currentNode = createCurrentRouteNode()
// 面包屑策略优先级(从高到低):
// 1) contextualTrail:命中当前菜单;
// 2) closestMenuTrail:按路径相似度自动猜测父链路;
// 3) cached/inherited:保留用户连续浏览上下文;
// 4) currentOnly:仅保留当前页。
const homeTrail = buildHomeTrail(currentNode)
const contextualTrail = buildContextualTrail()
const closestMenuTrail = findClosestMenuTrail(route.path)
const cachedTrail = breadcrumbStore.getResolvedTrail(routeKey.value)
const inheritedTrail = buildInheritedTrail(currentNode)
const strategySource = resolveStrategySource({
isHomeRoute: isHomeRoute.value,
contextualTrail,
closestMenuTrail,
cachedTrail,
inheritedTrail,
})
const resolved = resolveTrailByStrategies({
isHomeRoute: isHomeRoute.value,
homeTrail,
contextualTrail,
closestMenuTrail,
cachedTrail,
inheritedTrail,
currentNode,
})
if (breadcrumbDebugEnabled.value) {
console.info('[breadcrumb.strategy] resolved', {
routePath: normalizePath(route.path),
source: strategySource,
trail: resolved.map(item => item.path),
})
}
return resolved
})
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
// 主动读取 locale,确保语言切换时 computed 能触发重算。
const _currentLocale = locale.value
// 最终输出给 UI 的面包屑模型:统一在这里计算点击态。
return resolvedTrail.value.map((item, index) => ({
title: resolveDisplayTitle(item),
path: item.path,
name: item.name,
i18nKey: item.i18nKey,
isClickable: index < resolvedTrail.value.length - 1 && Boolean(item.path),
}))
})
watch(
() => [routeKey.value, resolvedTrail.value] as const,
([currentRouteKey, trail]) => {
// 空键或空链路不入库,避免污染历史。
if (!currentRouteKey || trail.length === 0) return
// 持久化时再次收敛点击态,保证 store 中数据结构稳定。
const persistedTrail = trail.map((item, index) => ({
...item,
isClickable: index < trail.length - 1 && Boolean(item.path),
}))
const existingTrail = breadcrumbStore.getResolvedTrail(currentRouteKey)
// 等价则跳过写入,减少重复状态变更。
if (isSameTrail(existingTrail, persistedTrail)) {
return
}
breadcrumbStore.saveResolvedTrail(currentRouteKey, persistedTrail)
},
{
immediate: true,
},
)
/**
* 是否展示面包屑组件。
* 规则:
* - blank/purePage 布局隐藏;
* - 登录注册等认证页面隐藏;
* - 首页隐藏;
* - 其余场景有面包屑数据才展示。
*/
const shouldShow = computed<boolean>(() => {
const hiddenLayouts = ['blank', 'purePage']
const layoutType = route.meta?.layoutType
if (hiddenLayouts.includes(layoutType as string)) {
return false
}
const authPaths = ['/login', '/register', '/certification', '/forget']
if (authPaths.includes(route.path)) {
return false
}
if (route.path === '/' || route.path === '/index' || route.path === '/dashboard') {
return false
}
return breadcrumbs.value.length > 0
})
return {
breadcrumbs,
shouldShow,
}
}
1. 问题背景
在业务系统里,很多详情页并不直接出现在菜单树中。 如果只靠静态配置面包屑,维护成本高且容易错。 我们的目标是:让面包屑以菜单与路径自动推导为主,并保持策略可解释。
2. 把面包屑问题抽象成"路径匹配"
可把它看成一个简化版最短路径匹配问题:
- 输入:当前路由
route.path,历史访问上下文,菜单树。 - 候选:菜单树中所有"根→叶子"链路。
- 目标:找到最合理的父链路,再拼上当前节点。
这与"地图匹配"的思想相似: 观测是当前 URL,路网是菜单拓扑,最优路径是最终面包屑链路。
3. 当前方案:规则优先的近似最优
系统并没有走复杂的全局最优算法,而是采用了"可解释、可维护"的策略优先级:
- contextual(当前菜单精确命中)
- closestMenu(相似路径自动匹配)
- cached(同 routeKey 历史结果)
- inherited(连续跳转弱继承)
- currentOnly(只显示当前页)
优点很明显:
- 可解释:每一步都能说明"为什么这么选"。
- 稳定:策略顺序固定,行为可预测。
- 成本低:前端实时计算压力可控。
4. 核心算法点
4.1 候选空间构建
先将菜单树拍平为"根→叶子"链路集合(trail),作为匹配候选集。 这一步决定了后续匹配上限。
4.2 相似度匹配(closestMenu)
对当前路径与候选叶子路径计算公共段得分。 同分时用两级 tie-break:
- 详情页优先回挂
manage/management菜单; - 再同分时选择更浅层级,减少误挂深节点。
4.3 时序信息(inherited)
对于"详情跳详情"场景,尝试继承上一条已解析链路,避免退化成"仅当前节点"。
5. 复杂度与瓶颈
当前复杂度主要来自两类线性扫描:
- 多处
findExactMenuTrail的全量遍历; closestMenu的全候选打分遍历。
在菜单规模增大时,这会放大开销,但仍是"可优化而非重构"。
6. 优化策略(保持简单)
6.1 建索引,替代重复扫描
Map<path, trail>:O(1) 命中 exact trail;Map<path, meta>:O(1) 读取 route/config 文案。
6.2 候选裁剪
先按首段前缀分组(如 /import、/export), closestMenu 仅在组内打分,再回退全局。
6.3 保持策略优先,不升级到复杂概率模型
HMM/Viterbi 适合长序列全局最优,但对前端面包屑属于过度设计。 当前场景下,策略优先 + 轻量评分是更优工程解。
7. 结语
这套方案的价值不在"数学最优",而在"业务最优":
- 解释性强;
- 运维成本低;
- 扩展点明确(规则、评分、索引)。
一句话总结: 面包屑不是在拼字符串,而是在做一套可治理的轻量路径匹配系统。
8. 已落地演进(2026-04-28)
按"简单可维护 + 不改策略行为"的原则,已完成以下三步:
8.1 第一步:索引化改造(已完成)
- 新增
exactTrailByPath:path -> trail,用于 O(1) 精确命中。 - 新增
routeMetaByPath:path -> route meta,避免菜单 DFS 内反复router.getRoutes().find(...)。 - 新增
configMetaByPath:path -> config meta,替代Object.values(RouteConfig).find(...)线性扫描。
收益:
- 降低重复遍历,热点查询从"多次线性搜索"变为"哈希查找"。
- 逻辑行为保持一致,只优化读取路径。
8.2 第二步:closest 候选裁剪 + 可观测日志(已完成)
- 新增
menuTrailsByRootSegment:按首段前缀分组候选(如import、export)。 closest匹配优先在同前缀组内打分,无组时回退全量候选。- 新增调试日志(开发环境):
breadcrumbDebug=1:输出命中来源、候选规模、得分、tie-break 原因、最终命中路径。- 日志主题:
[breadcrumb.closest]、[breadcrumb.strategy]。
收益:
- 常见场景减少无关候选打分,提升稳定性与可解释性。
- 线上行为不受影响,调试信息按开关输出。
8.3 第三步:评分函数抽象 + A/B 评估(已完成)
- 抽象评分函数接口
TrailScoreFn。 - 基线评分
scoreByCommonSegments继续作为实际决策函数(保证兼容)。 - 新增实验评分
scoreByCompactTrail仅用于对比观测。 - 新增 A/B 调试开关(开发环境):
breadcrumbScoreAB=1:输出 baseline 与 experiment 的分值和命中路径对比。
收益:
- 为后续评分策略迭代提供低成本实验框架。
- 避免"直接切算法"带来的不可控风险。