导航组件设计最佳实践指南

HTMLPAGE 团队
26分钟 分钟阅读

深入解析导航组件的设计原则、常见模式和实现方法。掌握响应式导航、多级菜单、面包屑等核心组件的设计与开发,构建清晰易用的导航体系。

#导航设计 #组件系统 #响应式设计 #用户体验 #Vue组件

导航设计的重要性

导航是用户探索应用的路标系统。良好的导航设计能够:

  • 提升可发现性 - 用户快速找到所需功能
  • 建立心智模型 - 帮助用户理解应用结构
  • 减少认知负担 - 清晰的层级降低决策成本
  • 增强用户信心 - 随时知道"我在哪里"

研究表明,超过 50% 的用户会在导航不清晰时直接离开网站。本文将详解各类导航组件的设计与实现。

导航类型与适用场景

导航类型对比

┌─────────────────────────────────────────────────────────────────┐
│                     导航类型选择矩阵                              │
├──────────────┬──────────────┬──────────────┬───────────────────┤
│    类型       │   适用场景    │    优点       │      缺点         │
├──────────────┼──────────────┼──────────────┼───────────────────┤
│  顶部导航     │ 5-7个主要项   │ 可见性高      │ 空间有限          │
│  侧边导航     │ 多级深层结构  │ 可容纳更多项  │ 占用水平空间       │
│  底部导航     │ 移动端核心功能 │ 易于触达      │ 仅限少量入口       │
│  汉堡菜单     │ 空间受限场景  │ 节省空间      │ 可发现性低         │
│  标签页       │ 并列内容切换  │ 直观清晰      │ 数量受限          │
└──────────────┴──────────────┴──────────────┴───────────────────┘

选择决策树

开始
  │
  ├─ 导航项 ≤ 5 个?
  │    ├─ 是 → 移动端核心功能?
  │    │         ├─ 是 → 底部导航
  │    │         └─ 否 → 顶部导航
  │    │
  │    └─ 否 → 有多级结构?
  │              ├─ 是 → 侧边导航
  │              └─ 否 → 顶部导航 + 下拉菜单

顶部导航栏组件

基础导航栏

<!-- components/navigation/Navbar.vue -->
<script setup lang="ts">
interface NavItem {
  label: string
  href?: string
  to?: string
  children?: NavItem[]
  icon?: string
}

interface Props {
  items: NavItem[]
  logo?: string
  logoAlt?: string
  sticky?: boolean
  transparent?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  sticky: true,
  transparent: false,
})

const route = useRoute()
const isScrolled = ref(false)
const isMobileMenuOpen = ref(false)

// 滚动检测
onMounted(() => {
  if (props.sticky) {
    window.addEventListener('scroll', handleScroll)
  }
})

onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll)
})

const handleScroll = () => {
  isScrolled.value = window.scrollY > 20
}

// 检查是否为当前路由
const isActive = (item: NavItem): boolean => {
  if (item.to) {
    return route.path === item.to || route.path.startsWith(item.to + '/')
  }
  return false
}

// 切换移动端菜单
const toggleMobileMenu = () => {
  isMobileMenuOpen.value = !isMobileMenuOpen.value
  
  // 防止背景滚动
  document.body.style.overflow = isMobileMenuOpen.value ? 'hidden' : ''
}

// 路由变化时关闭菜单
watch(() => route.path, () => {
  isMobileMenuOpen.value = false
  document.body.style.overflow = ''
})
</script>

<template>
  <header
    class="navbar"
    :class="{
      'navbar--sticky': sticky,
      'navbar--scrolled': isScrolled,
      'navbar--transparent': transparent && !isScrolled,
    }"
  >
    <div class="navbar__container">
      <!-- Logo -->
      <NuxtLink to="/" class="navbar__logo">
        <img v-if="logo" :src="logo" :alt="logoAlt || 'Logo'" />
        <slot v-else name="logo">
          <span class="navbar__brand">HTMLPAGE</span>
        </slot>
      </NuxtLink>
      
      <!-- 桌面端导航 -->
      <nav class="navbar__nav" role="navigation" aria-label="主导航">
        <ul class="navbar__menu">
          <li
            v-for="item in items"
            :key="item.label"
            class="navbar__item"
            :class="{ 'navbar__item--has-children': item.children }"
          >
            <!-- 有子菜单的项 -->
            <NavDropdown
              v-if="item.children"
              :item="item"
              :is-active="isActive(item)"
            />
            
            <!-- 普通链接 -->
            <NuxtLink
              v-else-if="item.to"
              :to="item.to"
              class="navbar__link"
              :class="{ 'navbar__link--active': isActive(item) }"
            >
              <Icon v-if="item.icon" :name="item.icon" />
              {{ item.label }}
            </NuxtLink>
            
            <!-- 外部链接 -->
            <a
              v-else-if="item.href"
              :href="item.href"
              class="navbar__link"
              target="_blank"
              rel="noopener noreferrer"
            >
              <Icon v-if="item.icon" :name="item.icon" />
              {{ item.label }}
              <Icon name="external-link" class="navbar__external-icon" />
            </a>
          </li>
        </ul>
      </nav>
      
      <!-- 右侧操作区 -->
      <div class="navbar__actions">
        <slot name="actions" />
        
        <!-- 移动端菜单按钮 -->
        <button
          class="navbar__toggle"
          :aria-expanded="isMobileMenuOpen"
          aria-controls="mobile-menu"
          aria-label="打开菜单"
          @click="toggleMobileMenu"
        >
          <span class="navbar__toggle-icon" :class="{ 'is-open': isMobileMenuOpen }">
            <span></span>
            <span></span>
            <span></span>
          </span>
        </button>
      </div>
    </div>
    
    <!-- 移动端菜单 -->
    <Teleport to="body">
      <Transition name="mobile-menu">
        <div
          v-if="isMobileMenuOpen"
          id="mobile-menu"
          class="navbar__mobile-menu"
        >
          <div class="navbar__mobile-overlay" @click="toggleMobileMenu" />
          
          <nav class="navbar__mobile-nav" role="navigation">
            <ul class="navbar__mobile-list">
              <li v-for="item in items" :key="item.label">
                <MobileNavItem :item="item" @close="toggleMobileMenu" />
              </li>
            </ul>
            
            <div class="navbar__mobile-actions">
              <slot name="mobile-actions" />
            </div>
          </nav>
        </div>
      </Transition>
    </Teleport>
  </header>
</template>

<style scoped>
.navbar {
  position: relative;
  width: 100%;
  height: var(--navbar-height, 64px);
  background: var(--color-bg-primary);
  border-bottom: 1px solid var(--color-border-light);
  z-index: 100;
}

.navbar--sticky {
  position: sticky;
  top: 0;
}

.navbar--scrolled {
  box-shadow: var(--shadow-sm);
}

.navbar--transparent {
  background: transparent;
  border-bottom: none;
}

.navbar__container {
  display: flex;
  align-items: center;
  justify-content: space-between;
  max-width: var(--container-max-width, 1280px);
  height: 100%;
  margin: 0 auto;
  padding: 0 24px;
}

/* Logo */
.navbar__logo {
  display: flex;
  align-items: center;
  text-decoration: none;
}

.navbar__logo img {
  height: 32px;
  width: auto;
}

.navbar__brand {
  font-size: 20px;
  font-weight: 700;
  color: var(--color-text-primary);
}

/* 桌面导航 */
.navbar__nav {
  display: none;
}

@media (min-width: 768px) {
  .navbar__nav {
    display: block;
  }
}

.navbar__menu {
  display: flex;
  align-items: center;
  gap: 8px;
  list-style: none;
  margin: 0;
  padding: 0;
}

.navbar__link {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 16px;
  font-size: 14px;
  font-weight: 500;
  color: var(--color-text-secondary);
  text-decoration: none;
  border-radius: var(--radius-md);
  transition: all 0.2s ease;
}

.navbar__link:hover {
  color: var(--color-text-primary);
  background: var(--color-bg-secondary);
}

.navbar__link--active {
  color: var(--color-primary);
  background: var(--color-primary-light);
}

.navbar__external-icon {
  width: 12px;
  height: 12px;
  opacity: 0.5;
}

/* 移动端菜单按钮 */
.navbar__toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: none;
  background: transparent;
  cursor: pointer;
}

@media (min-width: 768px) {
  .navbar__toggle {
    display: none;
  }
}

.navbar__toggle-icon {
  position: relative;
  width: 24px;
  height: 18px;
}

.navbar__toggle-icon span {
  position: absolute;
  left: 0;
  width: 100%;
  height: 2px;
  background: var(--color-text-primary);
  border-radius: 1px;
  transition: all 0.3s ease;
}

.navbar__toggle-icon span:nth-child(1) { top: 0; }
.navbar__toggle-icon span:nth-child(2) { top: 50%; transform: translateY(-50%); }
.navbar__toggle-icon span:nth-child(3) { bottom: 0; }

.navbar__toggle-icon.is-open span:nth-child(1) {
  top: 50%;
  transform: translateY(-50%) rotate(45deg);
}

.navbar__toggle-icon.is-open span:nth-child(2) {
  opacity: 0;
}

.navbar__toggle-icon.is-open span:nth-child(3) {
  bottom: 50%;
  transform: translateY(50%) rotate(-45deg);
}

/* 移动端菜单 */
.navbar__mobile-menu {
  position: fixed;
  inset: 0;
  z-index: 1000;
}

.navbar__mobile-overlay {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
}

.navbar__mobile-nav {
  position: absolute;
  top: 0;
  right: 0;
  width: min(320px, 80vw);
  height: 100%;
  background: var(--color-bg-primary);
  padding: 80px 24px 24px;
  overflow-y: auto;
}

.navbar__mobile-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* 动画 */
.mobile-menu-enter-active,
.mobile-menu-leave-active {
  transition: all 0.3s ease;
}

.mobile-menu-enter-active .navbar__mobile-nav,
.mobile-menu-leave-active .navbar__mobile-nav {
  transition: transform 0.3s ease;
}

.mobile-menu-enter-from,
.mobile-menu-leave-to {
  opacity: 0;
}

.mobile-menu-enter-from .navbar__mobile-nav,
.mobile-menu-leave-to .navbar__mobile-nav {
  transform: translateX(100%);
}
</style>

下拉菜单组件

<!-- components/navigation/NavDropdown.vue -->
<script setup lang="ts">
interface NavItem {
  label: string
  href?: string
  to?: string
  children?: NavItem[]
  icon?: string
  description?: string
}

interface Props {
  item: NavItem
  isActive?: boolean
}

const props = defineProps<Props>()

const isOpen = ref(false)
const dropdownRef = ref<HTMLElement>()

let closeTimeout: ReturnType<typeof setTimeout>

const handleMouseEnter = () => {
  clearTimeout(closeTimeout)
  isOpen.value = true
}

const handleMouseLeave = () => {
  closeTimeout = setTimeout(() => {
    isOpen.value = false
  }, 150)
}

// 点击外部关闭
onClickOutside(dropdownRef, () => {
  isOpen.value = false
})

// 键盘支持
const handleKeydown = (e: KeyboardEvent) => {
  switch (e.key) {
    case 'Enter':
    case ' ':
      e.preventDefault()
      isOpen.value = !isOpen.value
      break
    case 'Escape':
      isOpen.value = false
      break
    case 'ArrowDown':
      e.preventDefault()
      if (!isOpen.value) {
        isOpen.value = true
      }
      break
  }
}
</script>

<template>
  <div
    ref="dropdownRef"
    class="nav-dropdown"
    :class="{ 'nav-dropdown--open': isOpen }"
    @mouseenter="handleMouseEnter"
    @mouseleave="handleMouseLeave"
  >
    <!-- 触发器 -->
    <button
      class="nav-dropdown__trigger"
      :class="{ 'nav-dropdown__trigger--active': isActive }"
      :aria-expanded="isOpen"
      :aria-haspopup="true"
      @keydown="handleKeydown"
    >
      <Icon v-if="item.icon" :name="item.icon" />
      {{ item.label }}
      <Icon
        name="chevron-down"
        class="nav-dropdown__arrow"
        :class="{ 'nav-dropdown__arrow--rotated': isOpen }"
      />
    </button>
    
    <!-- 下拉面板 -->
    <Transition name="dropdown">
      <div v-if="isOpen" class="nav-dropdown__panel">
        <ul class="nav-dropdown__list" role="menu">
          <li
            v-for="child in item.children"
            :key="child.label"
            role="menuitem"
          >
            <NuxtLink
              v-if="child.to"
              :to="child.to"
              class="nav-dropdown__item"
              @click="isOpen = false"
            >
              <Icon v-if="child.icon" :name="child.icon" class="nav-dropdown__item-icon" />
              <div class="nav-dropdown__item-content">
                <span class="nav-dropdown__item-label">{{ child.label }}</span>
                <span v-if="child.description" class="nav-dropdown__item-desc">
                  {{ child.description }}
                </span>
              </div>
            </NuxtLink>
            
            <a
              v-else-if="child.href"
              :href="child.href"
              class="nav-dropdown__item"
              target="_blank"
              rel="noopener noreferrer"
            >
              <Icon v-if="child.icon" :name="child.icon" class="nav-dropdown__item-icon" />
              <div class="nav-dropdown__item-content">
                <span class="nav-dropdown__item-label">{{ child.label }}</span>
                <span v-if="child.description" class="nav-dropdown__item-desc">
                  {{ child.description }}
                </span>
              </div>
              <Icon name="external-link" class="nav-dropdown__external" />
            </a>
          </li>
        </ul>
      </div>
    </Transition>
  </div>
</template>

<style scoped>
.nav-dropdown {
  position: relative;
}

.nav-dropdown__trigger {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 16px;
  font-size: 14px;
  font-weight: 500;
  color: var(--color-text-secondary);
  background: transparent;
  border: none;
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: all 0.2s ease;
}

.nav-dropdown__trigger:hover,
.nav-dropdown--open .nav-dropdown__trigger {
  color: var(--color-text-primary);
  background: var(--color-bg-secondary);
}

.nav-dropdown__trigger--active {
  color: var(--color-primary);
}

.nav-dropdown__arrow {
  width: 16px;
  height: 16px;
  transition: transform 0.2s ease;
}

.nav-dropdown__arrow--rotated {
  transform: rotate(180deg);
}

.nav-dropdown__panel {
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  min-width: 240px;
  margin-top: 8px;
  background: var(--color-bg-primary);
  border: 1px solid var(--color-border-light);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  overflow: hidden;
  z-index: 100;
}

.nav-dropdown__list {
  list-style: none;
  margin: 0;
  padding: 8px;
}

.nav-dropdown__item {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 12px;
  text-decoration: none;
  border-radius: var(--radius-md);
  transition: background 0.15s ease;
}

.nav-dropdown__item:hover {
  background: var(--color-bg-secondary);
}

.nav-dropdown__item-icon {
  width: 20px;
  height: 20px;
  color: var(--color-primary);
  flex-shrink: 0;
  margin-top: 2px;
}

.nav-dropdown__item-content {
  flex: 1;
}

.nav-dropdown__item-label {
  display: block;
  font-size: 14px;
  font-weight: 500;
  color: var(--color-text-primary);
}

.nav-dropdown__item-desc {
  display: block;
  font-size: 12px;
  color: var(--color-text-secondary);
  margin-top: 2px;
}

.nav-dropdown__external {
  width: 14px;
  height: 14px;
  color: var(--color-text-tertiary);
  flex-shrink: 0;
}

/* 动画 */
.dropdown-enter-active,
.dropdown-leave-active {
  transition: all 0.2s ease;
}

.dropdown-enter-from,
.dropdown-leave-to {
  opacity: 0;
  transform: translateX(-50%) translateY(-8px);
}
</style>

侧边导航组件

侧边栏导航

<!-- components/navigation/Sidebar.vue -->
<script setup lang="ts">
interface NavItem {
  label: string
  to?: string
  icon?: string
  badge?: string | number
  children?: NavItem[]
}

interface Props {
  items: NavItem[]
  collapsed?: boolean
  width?: number
  collapsedWidth?: number
}

const props = withDefaults(defineProps<Props>(), {
  collapsed: false,
  width: 256,
  collapsedWidth: 64,
})

const emit = defineEmits<{
  'update:collapsed': [value: boolean]
}>()

const route = useRoute()
const expandedItems = ref<Set<string>>(new Set())

// 当前宽度
const currentWidth = computed(() => 
  props.collapsed ? props.collapsedWidth : props.width
)

// 检查是否激活
const isActive = (item: NavItem): boolean => {
  if (item.to) {
    return route.path === item.to
  }
  return false
}

// 检查是否有激活的子项
const hasActiveChild = (item: NavItem): boolean => {
  if (!item.children) return false
  return item.children.some(child => 
    isActive(child) || hasActiveChild(child)
  )
}

// 切换展开
const toggleExpand = (label: string) => {
  if (expandedItems.value.has(label)) {
    expandedItems.value.delete(label)
  } else {
    expandedItems.value.add(label)
  }
}

// 检查是否展开
const isExpanded = (label: string): boolean => {
  return expandedItems.value.has(label)
}

// 自动展开包含激活项的父级
watchEffect(() => {
  props.items.forEach(item => {
    if (item.children && hasActiveChild(item)) {
      expandedItems.value.add(item.label)
    }
  })
})

// 切换收起状态
const toggleCollapse = () => {
  emit('update:collapsed', !props.collapsed)
}
</script>

<template>
  <aside
    class="sidebar"
    :class="{ 'sidebar--collapsed': collapsed }"
    :style="{ width: `${currentWidth}px` }"
  >
    <!-- 导航列表 -->
    <nav class="sidebar__nav" role="navigation">
      <ul class="sidebar__list">
        <li v-for="item in items" :key="item.label" class="sidebar__item">
          <!-- 有子菜单 -->
          <template v-if="item.children">
            <button
              class="sidebar__link sidebar__link--expandable"
              :class="{ 'sidebar__link--expanded': isExpanded(item.label) }"
              :aria-expanded="isExpanded(item.label)"
              @click="toggleExpand(item.label)"
            >
              <Icon v-if="item.icon" :name="item.icon" class="sidebar__icon" />
              <span v-if="!collapsed" class="sidebar__label">{{ item.label }}</span>
              <Icon
                v-if="!collapsed"
                name="chevron-right"
                class="sidebar__arrow"
                :class="{ 'sidebar__arrow--rotated': isExpanded(item.label) }"
              />
            </button>
            
            <!-- 子菜单 -->
            <Transition name="expand">
              <ul
                v-if="isExpanded(item.label) && !collapsed"
                class="sidebar__submenu"
              >
                <li v-for="child in item.children" :key="child.label">
                  <NuxtLink
                    :to="child.to"
                    class="sidebar__sublink"
                    :class="{ 'sidebar__sublink--active': isActive(child) }"
                  >
                    <Icon v-if="child.icon" :name="child.icon" class="sidebar__subicon" />
                    <span class="sidebar__sublabel">{{ child.label }}</span>
                    <span v-if="child.badge" class="sidebar__badge">{{ child.badge }}</span>
                  </NuxtLink>
                </li>
              </ul>
            </Transition>
          </template>
          
          <!-- 无子菜单 -->
          <NuxtLink
            v-else
            :to="item.to"
            class="sidebar__link"
            :class="{ 'sidebar__link--active': isActive(item) }"
          >
            <Icon v-if="item.icon" :name="item.icon" class="sidebar__icon" />
            <span v-if="!collapsed" class="sidebar__label">{{ item.label }}</span>
            <span v-if="!collapsed && item.badge" class="sidebar__badge">
              {{ item.badge }}
            </span>
          </NuxtLink>
        </li>
      </ul>
    </nav>
    
    <!-- 收起按钮 -->
    <button
      class="sidebar__toggle"
      :aria-label="collapsed ? '展开侧边栏' : '收起侧边栏'"
      @click="toggleCollapse"
    >
      <Icon
        name="chevrons-left"
        class="sidebar__toggle-icon"
        :class="{ 'sidebar__toggle-icon--rotated': collapsed }"
      />
    </button>
  </aside>
</template>

<style scoped>
.sidebar {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background: var(--color-bg-primary);
  border-right: 1px solid var(--color-border-light);
  transition: width 0.3s ease;
  overflow: hidden;
}

.sidebar--collapsed {
  width: 64px;
}

.sidebar__nav {
  flex: 1;
  overflow-y: auto;
  padding: 16px 12px;
}

.sidebar__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.sidebar__item {
  margin-bottom: 4px;
}

.sidebar__link {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  padding: 10px 12px;
  font-size: 14px;
  font-weight: 500;
  color: var(--color-text-secondary);
  text-decoration: none;
  background: transparent;
  border: none;
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: all 0.15s ease;
}

.sidebar__link:hover {
  color: var(--color-text-primary);
  background: var(--color-bg-secondary);
}

.sidebar__link--active {
  color: var(--color-primary);
  background: var(--color-primary-light);
}

.sidebar__icon {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
}

.sidebar__label {
  flex: 1;
  text-align: left;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.sidebar__arrow {
  width: 16px;
  height: 16px;
  transition: transform 0.2s ease;
}

.sidebar__arrow--rotated {
  transform: rotate(90deg);
}

.sidebar__badge {
  padding: 2px 8px;
  font-size: 12px;
  font-weight: 500;
  color: var(--color-primary);
  background: var(--color-primary-light);
  border-radius: 9999px;
}

/* 子菜单 */
.sidebar__submenu {
  list-style: none;
  margin: 4px 0 0;
  padding: 0 0 0 32px;
}

.sidebar__sublink {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  font-size: 13px;
  color: var(--color-text-secondary);
  text-decoration: none;
  border-radius: var(--radius-md);
  transition: all 0.15s ease;
}

.sidebar__sublink:hover {
  color: var(--color-text-primary);
  background: var(--color-bg-secondary);
}

.sidebar__sublink--active {
  color: var(--color-primary);
  font-weight: 500;
}

.sidebar__subicon {
  width: 16px;
  height: 16px;
}

/* 收起按钮 */
.sidebar__toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 48px;
  border: none;
  border-top: 1px solid var(--color-border-light);
  background: transparent;
  cursor: pointer;
  transition: background 0.15s ease;
}

.sidebar__toggle:hover {
  background: var(--color-bg-secondary);
}

.sidebar__toggle-icon {
  width: 20px;
  height: 20px;
  color: var(--color-text-secondary);
  transition: transform 0.3s ease;
}

.sidebar__toggle-icon--rotated {
  transform: rotate(180deg);
}

/* 展开动画 */
.expand-enter-active,
.expand-leave-active {
  transition: all 0.3s ease;
  overflow: hidden;
}

.expand-enter-from,
.expand-leave-to {
  opacity: 0;
  max-height: 0;
}

.expand-enter-to,
.expand-leave-from {
  max-height: 500px;
}
</style>

面包屑导航

面包屑组件

<!-- components/navigation/Breadcrumb.vue -->
<script setup lang="ts">
interface BreadcrumbItem {
  label: string
  to?: string
  icon?: string
}

interface Props {
  items: BreadcrumbItem[]
  separator?: string
  showHome?: boolean
  homeIcon?: string
  maxItems?: number
}

const props = withDefaults(defineProps<Props>(), {
  separator: '/',
  showHome: true,
  homeIcon: 'home',
  maxItems: 0,
})

// 处理显示项
const displayItems = computed(() => {
  let items = props.items
  
  // 添加首页
  if (props.showHome) {
    items = [{ label: '首页', to: '/', icon: props.homeIcon }, ...items]
  }
  
  // 截断处理
  if (props.maxItems > 0 && items.length > props.maxItems) {
    const firstItem = items[0]
    const lastItems = items.slice(-props.maxItems + 1)
    return [firstItem, { label: '...', collapsed: true }, ...lastItems]
  }
  
  return items
})
</script>

<template>
  <nav class="breadcrumb" aria-label="面包屑导航">
    <ol class="breadcrumb__list" itemscope itemtype="https://schema.org/BreadcrumbList">
      <li
        v-for="(item, index) in displayItems"
        :key="index"
        class="breadcrumb__item"
        itemprop="itemListElement"
        itemscope
        itemtype="https://schema.org/ListItem"
      >
        <!-- 分隔符 -->
        <span
          v-if="index > 0"
          class="breadcrumb__separator"
          aria-hidden="true"
        >
          <slot name="separator">
            {{ separator }}
          </slot>
        </span>
        
        <!-- 折叠项 -->
        <span v-if="item.collapsed" class="breadcrumb__collapsed">
          ...
        </span>
        
        <!-- 链接项 -->
        <NuxtLink
          v-else-if="item.to && index < displayItems.length - 1"
          :to="item.to"
          class="breadcrumb__link"
          itemprop="item"
        >
          <Icon v-if="item.icon" :name="item.icon" class="breadcrumb__icon" />
          <span itemprop="name">{{ item.label }}</span>
        </NuxtLink>
        
        <!-- 当前项(最后一项) -->
        <span
          v-else
          class="breadcrumb__current"
          aria-current="page"
          itemprop="name"
        >
          <Icon v-if="item.icon" :name="item.icon" class="breadcrumb__icon" />
          {{ item.label }}
        </span>
        
        <meta itemprop="position" :content="String(index + 1)" />
      </li>
    </ol>
  </nav>
</template>

<style scoped>
.breadcrumb {
  padding: 12px 0;
}

.breadcrumb__list {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px;
  list-style: none;
  margin: 0;
  padding: 0;
}

.breadcrumb__item {
  display: flex;
  align-items: center;
  gap: 4px;
}

.breadcrumb__separator {
  color: var(--color-text-tertiary);
  margin: 0 4px;
}

.breadcrumb__link {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 14px;
  color: var(--color-text-secondary);
  text-decoration: none;
  transition: color 0.15s ease;
}

.breadcrumb__link:hover {
  color: var(--color-primary);
}

.breadcrumb__current {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 14px;
  font-weight: 500;
  color: var(--color-text-primary);
}

.breadcrumb__icon {
  width: 16px;
  height: 16px;
}

.breadcrumb__collapsed {
  color: var(--color-text-tertiary);
}
</style>

自动生成面包屑

// composables/useBreadcrumb.ts
interface BreadcrumbItem {
  label: string
  to: string
}

export function useBreadcrumb() {
  const route = useRoute()
  
  // 路由名称映射
  const routeLabels: Record<string, string> = {
    'topics': '主题',
    'seo': 'SEO优化',
    'performance': '性能优化',
    'nuxt': 'Nuxt开发',
    'frontend': '前端开发',
    'design': '设计系统',
  }
  
  // 根据路由生成面包屑
  const breadcrumbs = computed<BreadcrumbItem[]>(() => {
    const paths = route.path.split('/').filter(Boolean)
    const items: BreadcrumbItem[] = []
    
    let currentPath = ''
    
    for (const segment of paths) {
      currentPath += `/${segment}`
      
      // 获取标签
      const label = routeLabels[segment] || 
                    route.meta.title as string ||
                    segment.replace(/-/g, ' ')
      
      items.push({
        label,
        to: currentPath,
      })
    }
    
    return items
  })
  
  return {
    breadcrumbs,
  }
}

标签页导航

标签页组件

<!-- components/navigation/Tabs.vue -->
<script setup lang="ts">
interface TabItem {
  key: string
  label: string
  icon?: string
  disabled?: boolean
  badge?: string | number
}

interface Props {
  items: TabItem[]
  modelValue?: string
  variant?: 'line' | 'card' | 'pill'
  size?: 'sm' | 'md' | 'lg'
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'line',
  size: 'md',
})

const emit = defineEmits<{
  'update:modelValue': [value: string]
  'change': [value: string]
}>()

const activeKey = computed({
  get: () => props.modelValue || props.items[0]?.key,
  set: (val) => {
    emit('update:modelValue', val)
    emit('change', val)
  },
})

const indicatorStyle = ref({})
const tabRefs = ref<HTMLElement[]>([])

// 更新指示器位置
const updateIndicator = () => {
  const activeIndex = props.items.findIndex(item => item.key === activeKey.value)
  const activeTab = tabRefs.value[activeIndex]
  
  if (activeTab && props.variant === 'line') {
    indicatorStyle.value = {
      width: `${activeTab.offsetWidth}px`,
      transform: `translateX(${activeTab.offsetLeft}px)`,
    }
  }
}

// 监听变化更新指示器
watch([activeKey, () => props.items], () => {
  nextTick(updateIndicator)
})

onMounted(() => {
  nextTick(updateIndicator)
})
</script>

<template>
  <div
    class="tabs"
    :class="[`tabs--${variant}`, `tabs--${size}`]"
  >
    <div class="tabs__nav" role="tablist">
      <button
        v-for="(item, index) in items"
        :key="item.key"
        :ref="(el) => tabRefs[index] = el as HTMLElement"
        class="tabs__tab"
        :class="{
          'tabs__tab--active': item.key === activeKey,
          'tabs__tab--disabled': item.disabled,
        }"
        role="tab"
        :aria-selected="item.key === activeKey"
        :aria-disabled="item.disabled"
        :tabindex="item.disabled ? -1 : 0"
        @click="!item.disabled && (activeKey = item.key)"
      >
        <Icon v-if="item.icon" :name="item.icon" class="tabs__icon" />
        <span class="tabs__label">{{ item.label }}</span>
        <span v-if="item.badge" class="tabs__badge">{{ item.badge }}</span>
      </button>
      
      <!-- 滑动指示器(线条变体) -->
      <div
        v-if="variant === 'line'"
        class="tabs__indicator"
        :style="indicatorStyle"
      />
    </div>
    
    <!-- 内容区域 -->
    <div class="tabs__content" role="tabpanel">
      <slot :active-key="activeKey" />
    </div>
  </div>
</template>

<style scoped>
.tabs {
  width: 100%;
}

.tabs__nav {
  position: relative;
  display: flex;
  gap: 4px;
}

/* 线条变体 */
.tabs--line .tabs__nav {
  border-bottom: 1px solid var(--color-border-light);
}

.tabs--line .tabs__tab {
  padding: 12px 16px;
  background: transparent;
  border: none;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
}

.tabs--line .tabs__tab--active {
  color: var(--color-primary);
}

.tabs--line .tabs__indicator {
  position: absolute;
  bottom: 0;
  height: 2px;
  background: var(--color-primary);
  transition: all 0.3s ease;
}

/* 卡片变体 */
.tabs--card .tabs__nav {
  background: var(--color-bg-secondary);
  padding: 4px;
  border-radius: var(--radius-lg);
}

.tabs--card .tabs__tab {
  padding: 8px 16px;
  background: transparent;
  border: none;
  border-radius: var(--radius-md);
}

.tabs--card .tabs__tab--active {
  background: var(--color-bg-primary);
  box-shadow: var(--shadow-sm);
}

/* 药片变体 */
.tabs--pill .tabs__tab {
  padding: 8px 20px;
  background: transparent;
  border: 1px solid var(--color-border);
  border-radius: 9999px;
}

.tabs--pill .tabs__tab--active {
  background: var(--color-primary);
  border-color: var(--color-primary);
  color: white;
}

/* 通用样式 */
.tabs__tab {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  font-weight: 500;
  color: var(--color-text-secondary);
  cursor: pointer;
  transition: all 0.2s ease;
}

.tabs__tab:hover:not(.tabs__tab--disabled) {
  color: var(--color-text-primary);
}

.tabs__tab--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.tabs__icon {
  width: 16px;
  height: 16px;
}

.tabs__badge {
  padding: 2px 6px;
  font-size: 11px;
  background: var(--color-bg-tertiary);
  border-radius: 9999px;
}

.tabs__tab--active .tabs__badge {
  background: var(--color-primary-light);
  color: var(--color-primary);
}

/* 尺寸变体 */
.tabs--sm .tabs__tab {
  padding: 8px 12px;
  font-size: 13px;
}

.tabs--lg .tabs__tab {
  padding: 14px 20px;
  font-size: 15px;
}

.tabs__content {
  padding-top: 16px;
}
</style>

移动端底部导航

<!-- components/navigation/BottomNav.vue -->
<script setup lang="ts">
interface NavItem {
  key: string
  label: string
  to: string
  icon: string
  activeIcon?: string
  badge?: string | number
}

interface Props {
  items: NavItem[]
}

const props = defineProps<Props>()

const route = useRoute()

const isActive = (item: NavItem): boolean => {
  return route.path === item.to || route.path.startsWith(item.to + '/')
}
</script>

<template>
  <nav class="bottom-nav" role="navigation" aria-label="底部导航">
    <NuxtLink
      v-for="item in items"
      :key="item.key"
      :to="item.to"
      class="bottom-nav__item"
      :class="{ 'bottom-nav__item--active': isActive(item) }"
    >
      <div class="bottom-nav__icon-wrapper">
        <Icon
          :name="isActive(item) && item.activeIcon ? item.activeIcon : item.icon"
          class="bottom-nav__icon"
        />
        <span v-if="item.badge" class="bottom-nav__badge">
          {{ item.badge }}
        </span>
      </div>
      <span class="bottom-nav__label">{{ item.label }}</span>
    </NuxtLink>
  </nav>
</template>

<style scoped>
.bottom-nav {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  justify-content: space-around;
  height: 64px;
  background: var(--color-bg-primary);
  border-top: 1px solid var(--color-border-light);
  padding-bottom: env(safe-area-inset-bottom);
  z-index: 100;
}

@media (min-width: 768px) {
  .bottom-nav {
    display: none;
  }
}

.bottom-nav__item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  flex: 1;
  padding: 8px 4px;
  text-decoration: none;
  color: var(--color-text-secondary);
  transition: color 0.2s ease;
}

.bottom-nav__item--active {
  color: var(--color-primary);
}

.bottom-nav__icon-wrapper {
  position: relative;
}

.bottom-nav__icon {
  width: 24px;
  height: 24px;
}

.bottom-nav__badge {
  position: absolute;
  top: -4px;
  right: -8px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  font-size: 10px;
  font-weight: 600;
  color: white;
  background: var(--color-error);
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.bottom-nav__label {
  font-size: 11px;
  margin-top: 4px;
}
</style>

导航可访问性

键盘导航支持

// composables/useNavigationA11y.ts
export function useNavigationA11y(containerRef: Ref<HTMLElement | undefined>) {
  // 获取所有可导航项
  const getNavigableItems = (): HTMLElement[] => {
    if (!containerRef.value) return []
    return Array.from(
      containerRef.value.querySelectorAll<HTMLElement>(
        'a, button:not([disabled]), [tabindex="0"]'
      )
    )
  }
  
  // 当前焦点索引
  const focusIndex = ref(-1)
  
  // 聚焦指定索引
  const focusItem = (index: number) => {
    const items = getNavigableItems()
    if (index >= 0 && index < items.length) {
      items[index].focus()
      focusIndex.value = index
    }
  }
  
  // 键盘事件处理
  const handleKeydown = (e: KeyboardEvent) => {
    const items = getNavigableItems()
    const currentIndex = items.indexOf(document.activeElement as HTMLElement)
    
    switch (e.key) {
      case 'ArrowDown':
      case 'ArrowRight':
        e.preventDefault()
        focusItem((currentIndex + 1) % items.length)
        break
        
      case 'ArrowUp':
      case 'ArrowLeft':
        e.preventDefault()
        focusItem((currentIndex - 1 + items.length) % items.length)
        break
        
      case 'Home':
        e.preventDefault()
        focusItem(0)
        break
        
      case 'End':
        e.preventDefault()
        focusItem(items.length - 1)
        break
    }
  }
  
  // 绑定事件
  onMounted(() => {
    containerRef.value?.addEventListener('keydown', handleKeydown)
  })
  
  onUnmounted(() => {
    containerRef.value?.removeEventListener('keydown', handleKeydown)
  })
  
  return {
    focusIndex,
    focusItem,
  }
}

跳过导航链接

<!-- components/navigation/SkipLink.vue -->
<template>
  <a href="#main-content" class="skip-link">
    跳转到主要内容
  </a>
</template>

<style scoped>
.skip-link {
  position: fixed;
  top: -100%;
  left: 50%;
  transform: translateX(-50%);
  padding: 12px 24px;
  background: var(--color-primary);
  color: white;
  font-weight: 500;
  text-decoration: none;
  border-radius: var(--radius-md);
  z-index: 9999;
  transition: top 0.3s ease;
}

.skip-link:focus {
  top: 16px;
}
</style>

最佳实践总结

导航设计清单

类别最佳实践
层级最多3层,扁平化结构
标签简洁明确,2-4个字
图标统一风格,辅助文字
状态清晰的激活态反馈
响应式移动端使用汉堡/底部导航
可访问支持键盘导航,ARIA标签
性能延迟加载子菜单

组件设计原则

  1. 一致性 - 全站统一的导航模式
  2. 可预测 - 用户能预判交互结果
  3. 反馈性 - 清晰的状态指示
  4. 灵活性 - 支持多种配置场景
  5. 可访问 - 完整的键盘和屏幕阅读器支持

总结

优秀的导航系统需要:

  • 清晰的信息架构 - 合理的层级和分类
  • 灵活的组件设计 - 适应不同场景需求
  • 完善的响应式 - 桌面端和移动端无缝切换
  • 良好的可访问性 - 所有用户都能顺畅使用

通过本文介绍的组件和模式,你可以构建一套专业、统一、易用的导航系统,提升整体用户体验。