Skip to content

组合式 API:Vue Router 的现代化开发方式 🎯

组合式 API 为 Vue Router 带来了更强大的功能和更灵活的开发体验。让我们一起探索如何用现代化的方式构建路由应用!

为什么选择组合式 API?

组合式 API 在路由开发中的优势:

  • 🔧 更好的逻辑复用:轻松提取和共享路由逻辑
  • 📦 更清晰的代码组织:按功能而非选项组织代码
  • 🎯 更强的类型支持:TypeScript 友好
  • 更好的性能:更精确的响应式追踪

🚀 核心 Composables

useRouter & useRoute:路由的双子星

vue
<script setup>
import { useRouter, useRoute } from 'vue-router'
import { computed, watch } from 'vue'

const router = useRouter()
const route = useRoute()

// 智能导航函数
const navigateWithQuery = (path, additionalQuery = {}) => {
  router.push({
    path,
    query: {
      ...route.query, // 保留当前查询参数
      ...additionalQuery // 添加新的查询参数
    }
  })
}

// 响应式路由信息
const currentPath = computed(() => route.path)
const routeParams = computed(() => route.params)
const queryParams = computed(() => route.query)

// 智能面包屑导航
const breadcrumbs = computed(() => {
  return route.matched.map(record => ({
    name: record.meta?.title || record.name,
    path: record.path,
    isActive: record.path === route.path
  }))
})

// 监听路由变化
watch(
  () => route.params.id,
  async (newId, oldId) => {
    if (newId !== oldId) {
      console.log(`路由参数从 ${oldId} 变更为 ${newId}`)
      // 执行数据重新加载等操作
    }
  }
)
</script>

实战案例:智能搜索页面

vue
<template>
  <div class="search-page">
    <div class="search-header">
      <input 
        v-model="searchQuery" 
        @input="handleSearch"
        placeholder="搜索内容..."
        class="search-input"
      />
      
      <div class="filters">
        <select v-model="selectedCategory" @change="updateFilters">
          <option value="">全部分类</option>
          <option v-for="cat in categories" :key="cat.id" :value="cat.id">
            {{ cat.name }}
          </option>
        </select>
        
        <select v-model="sortBy" @change="updateFilters">
          <option value="relevance">相关性</option>
          <option value="date">时间</option>
          <option value="popularity">热度</option>
        </select>
      </div>
    </div>
    
    <div class="search-results">
      <div v-if="isLoading" class="loading">搜索中...</div>
      <div v-else-if="results.length === 0" class="no-results">
        没有找到相关结果
      </div>
      <div v-else>
        <div class="results-info">
          找到 {{ totalResults }} 个结果 (用时 {{ searchTime }}ms)
        </div>
        <SearchResultItem 
          v-for="item in results" 
          :key="item.id" 
          :item="item" 
        />
        <Pagination 
          :current="currentPage" 
          :total="totalPages"
          @change="handlePageChange"
        />
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useDebounce } from '@/composables/useDebounce'
import { searchAPI } from '@/api/search'

const router = useRouter()
const route = useRoute()

// 响应式状态
const searchQuery = ref('')
const selectedCategory = ref('')
const sortBy = ref('relevance')
const results = ref([])
const totalResults = ref(0)
const totalPages = ref(0)
const currentPage = ref(1)
const isLoading = ref(false)
const searchTime = ref(0)

// 防抖搜索
const debouncedSearch = useDebounce(performSearch, 300)

// 从 URL 初始化状态
onMounted(() => {
  searchQuery.value = route.query.q || ''
  selectedCategory.value = route.query.category || ''
  sortBy.value = route.query.sort || 'relevance'
  currentPage.value = parseInt(route.query.page) || 1
  
  if (searchQuery.value) {
    performSearch()
  }
})

// 监听搜索输入
const handleSearch = () => {
  currentPage.value = 1
  debouncedSearch()
}

// 更新过滤器
const updateFilters = () => {
  currentPage.value = 1
  updateURL()
  performSearch()
}

// 处理分页
const handlePageChange = (page) => {
  currentPage.value = page
  updateURL()
  performSearch()
}

// 更新 URL
const updateURL = () => {
  const query = {}
  
  if (searchQuery.value) query.q = searchQuery.value
  if (selectedCategory.value) query.category = selectedCategory.value
  if (sortBy.value !== 'relevance') query.sort = sortBy.value
  if (currentPage.value > 1) query.page = currentPage.value
  
  router.replace({ query })
}

// 执行搜索
async function performSearch() {
  if (!searchQuery.value.trim()) {
    results.value = []
    totalResults.value = 0
    return
  }
  
  isLoading.value = true
  const startTime = Date.now()
  
  try {
    const response = await searchAPI({
      query: searchQuery.value,
      category: selectedCategory.value,
      sort: sortBy.value,
      page: currentPage.value,
      pageSize: 20
    })
    
    results.value = response.data
    totalResults.value = response.total
    totalPages.value = response.totalPages
    searchTime.value = Date.now() - startTime
    
    updateURL()
  } catch (error) {
    console.error('搜索失败:', error)
    results.value = []
  } finally {
    isLoading.value = false
  }
}

// 监听 URL 变化(浏览器前进后退)
watch(
  () => route.query,
  (newQuery) => {
    searchQuery.value = newQuery.q || ''
    selectedCategory.value = newQuery.category || ''
    sortBy.value = newQuery.sort || 'relevance'
    currentPage.value = parseInt(newQuery.page) || 1
    
    if (searchQuery.value) {
      performSearch()
    }
  }
)
</script>

🛡️ 导航守卫的组合式写法

onBeforeRouteUpdate & onBeforeRouteLeave

vue
<script setup>
import { ref, computed } from 'vue'
import { onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'

const formData = ref({
  title: '',
  content: '',
  tags: []
})

const originalData = ref({})
const isSaving = ref(false)

// 检查表单是否有变化
const hasUnsavedChanges = computed(() => {
  return JSON.stringify(formData.value) !== JSON.stringify(originalData.value)
})

// 路由更新守卫(同一组件,不同参数)
onBeforeRouteUpdate(async (to, from) => {
  console.log('路由更新:', from.path, '->', to.path)
  
  // 如果有未保存的更改,询问用户
  if (hasUnsavedChanges.value) {
    const shouldSave = await confirmDialog({
      title: '保存更改',
      message: '您有未保存的更改,是否保存?',
      confirmText: '保存',
      cancelText: '放弃'
    })
    
    if (shouldSave) {
      await saveForm()
    }
  }
  
  // 加载新的数据
  await loadData(to.params.id)
})

// 路由离开守卫
onBeforeRouteLeave(async (to, from) => {
  console.log('准备离开:', from.path, '->', to.path)
  
  // 如果正在保存,阻止离开
  if (isSaving.value) {
    showToast('正在保存,请稍候...')
    return false
  }
  
  // 如果有未保存的更改
  if (hasUnsavedChanges.value) {
    const action = await showLeaveDialog()
    
    switch (action) {
      case 'save':
        await saveForm()
        break
      case 'discard':
        // 放弃更改,继续导航
        break
      case 'cancel':
        return false // 取消导航
    }
  }
  
  // 清理工作
  cleanup()
})

// 保存表单
const saveForm = async () => {
  isSaving.value = true
  try {
    await api.saveForm(formData.value)
    originalData.value = { ...formData.value }
    showToast('保存成功')
  } catch (error) {
    showToast('保存失败')
    throw error
  } finally {
    isSaving.value = false
  }
}

// 清理函数
const cleanup = () => {
  // 清理定时器、事件监听器等
  console.log('执行清理工作')
}
</script>

高级守卫模式

javascript
// composables/useRouteGuards.js
import { onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'
import { ref, computed } from 'vue'

export function useFormGuards(formData, originalData) {
  const isSaving = ref(false)
  
  const hasChanges = computed(() => {
    return JSON.stringify(formData.value) !== JSON.stringify(originalData.value)
  })
  
  // 通用的离开确认
  const confirmLeave = async () => {
    if (!hasChanges.value) return true
    
    const result = await showConfirmDialog({
      title: '确认离开',
      message: '您有未保存的更改,确定要离开吗?',
      type: 'warning',
      showCancelButton: true,
      confirmButtonText: '离开',
      cancelButtonText: '继续编辑'
    })
    
    return result.isConfirmed
  }
  
  // 自动设置守卫
  onBeforeRouteUpdate(async (to, from) => {
    const canLeave = await confirmLeave()
    if (!canLeave) return false
  })
  
  onBeforeRouteLeave(async (to, from) => {
    const canLeave = await confirmLeave()
    if (!canLeave) return false
  })
  
  return {
    hasChanges,
    isSaving,
    confirmLeave
  }
}

// 使用示例
export default {
  setup() {
    const formData = ref({ name: '', email: '' })
    const originalData = ref({})
    
    const { hasChanges, confirmLeave } = useFormGuards(formData, originalData)
    
    return {
      formData,
      hasChanges,
      confirmLeave
    }
  }
}

基础用法

vue
<template>
  <component 
    :is="isExternal ? 'a' : 'router-link'"
    :href="isExternal ? to : undefined"
    :to="isExternal ? undefined : to"
    :class="linkClasses"
    @click="handleClick"
  >
    <slot />
  </component>
</template>

<script setup>
import { computed } from 'vue'
import { useLink } from 'vue-router'

const props = defineProps({
  to: {
    type: [String, Object],
    required: true
  },
  activeClass: {
    type: String,
    default: 'router-link-active'
  },
  exactActiveClass: {
    type: String,
    default: 'router-link-exact-active'
  }
})

// 检查是否为外部链接
const isExternal = computed(() => {
  return typeof props.to === 'string' && 
         (props.to.startsWith('http') || props.to.startsWith('mailto:'))
})

// 使用 useLink(仅对内部链接)
const linkData = isExternal.value ? null : useLink(props)

// 计算链接类名
const linkClasses = computed(() => {
  if (isExternal.value) {
    return ['external-link']
  }
  
  const classes = ['internal-link']
  
  if (linkData?.isActive.value) {
    classes.push(props.activeClass)
  }
  
  if (linkData?.isExactActive.value) {
    classes.push(props.exactActiveClass)
  }
  
  return classes
})

// 处理点击事件
const handleClick = (event) => {
  if (isExternal.value) {
    // 外部链接的特殊处理
    if (props.to.startsWith('http')) {
      // 可以添加统计追踪
      trackExternalLink(props.to)
    }
  } else {
    // 内部链接使用 useLink 的导航
    linkData?.navigate(event)
  }
}
</script>

高级链接组件

vue
<template>
  <component
    :is="componentTag"
    v-bind="linkProps"
    :class="computedClasses"
    @click="handleClick"
    @mouseenter="handleMouseEnter"
    @mouseleave="handleMouseLeave"
  >
    <span v-if="showIcon" class="link-icon">
      <component :is="iconComponent" />
    </span>
    
    <span class="link-text">
      <slot />
    </span>
    
    <span v-if="showBadge" class="link-badge">
      {{ badgeText }}
    </span>
    
    <span v-if="isExternal" class="external-indicator">

    </span>
  </component>
</template>

<script setup>
import { computed, ref } from 'vue'
import { useLink } from 'vue-router'

const props = defineProps({
  to: [String, Object],
  icon: String,
  badge: [String, Number],
  variant: {
    type: String,
    default: 'default',
    validator: (value) => ['default', 'primary', 'secondary', 'danger'].includes(value)
  },
  size: {
    type: String,
    default: 'medium',
    validator: (value) => ['small', 'medium', 'large'].includes(value)
  },
  disabled: Boolean,
  preload: Boolean // 是否预加载
})

const isHovered = ref(false)

// 判断链接类型
const isExternal = computed(() => {
  return typeof props.to === 'string' && props.to.startsWith('http')
})

const componentTag = computed(() => {
  return isExternal.value ? 'a' : 'router-link'
})

// 使用 useLink
const linkData = isExternal.value ? null : useLink({
  to: props.to
})

// 链接属性
const linkProps = computed(() => {
  if (isExternal.value) {
    return {
      href: props.to,
      target: '_blank',
      rel: 'noopener noreferrer'
    }
  }
  
  return {
    to: props.to
  }
})

// 计算样式类
const computedClasses = computed(() => {
  const classes = [
    'smart-link',
    `smart-link--${props.variant}`,
    `smart-link--${props.size}`
  ]
  
  if (props.disabled) {
    classes.push('smart-link--disabled')
  }
  
  if (isHovered.value) {
    classes.push('smart-link--hovered')
  }
  
  if (!isExternal.value && linkData) {
    if (linkData.isActive.value) {
      classes.push('smart-link--active')
    }
    
    if (linkData.isExactActive.value) {
      classes.push('smart-link--exact-active')
    }
  }
  
  return classes
})

// 图标组件
const iconComponent = computed(() => {
  // 根据 props.icon 返回对应的图标组件
  return props.icon ? `Icon${props.icon}` : null
})

const showIcon = computed(() => !!props.icon)
const showBadge = computed(() => props.badge !== undefined)
const badgeText = computed(() => props.badge)

// 事件处理
const handleClick = (event) => {
  if (props.disabled) {
    event.preventDefault()
    return
  }
  
  if (!isExternal.value && linkData) {
    linkData.navigate(event)
  }
  
  // 统计点击
  trackLinkClick({
    to: props.to,
    isExternal: isExternal.value,
    variant: props.variant
  })
}

const handleMouseEnter = () => {
  isHovered.value = true
  
  // 预加载功能
  if (props.preload && !isExternal.value) {
    preloadRoute(props.to)
  }
}

const handleMouseLeave = () => {
  isHovered.value = false
}

// 预加载路由
const preloadRoute = async (to) => {
  try {
    // 这里可以实现路由预加载逻辑
    console.log('预加载路由:', to)
  } catch (error) {
    console.error('预加载失败:', error)
  }
}

// 统计追踪
const trackLinkClick = (data) => {
  // 发送统计数据
  console.log('链接点击统计:', data)
}
</script>

<style scoped>
.smart-link {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  text-decoration: none;
  transition: all 0.2s ease;
  border-radius: 0.25rem;
  padding: 0.5rem 1rem;
}

.smart-link--primary {
  background: #3b82f6;
  color: white;
}

.smart-link--secondary {
  background: #6b7280;
  color: white;
}

.smart-link--danger {
  background: #ef4444;
  color: white;
}

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

.smart-link--active {
  background: #1d4ed8;
}

.external-indicator {
  font-size: 0.8em;
  opacity: 0.7;
}
</style>

🎯 实用 Composables

useRouteQuery:查询参数管理

javascript
// composables/useRouteQuery.js
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

export function useRouteQuery(key, defaultValue = null, options = {}) {
  const route = useRoute()
  const router = useRouter()
  
  const {
    transform = (value) => value,
    serialize = (value) => String(value),
    mode = 'replace' // 'push' | 'replace'
  } = options
  
  const query = computed({
    get() {
      const value = route.query[key]
      if (value === undefined) return defaultValue
      return transform(value)
    },
    
    set(value) {
      const newQuery = { ...route.query }
      
      if (value === null || value === undefined || value === defaultValue) {
        delete newQuery[key]
      } else {
        newQuery[key] = serialize(value)
      }
      
      const method = mode === 'push' ? 'push' : 'replace'
      router[method]({ query: newQuery })
    }
  })
  
  return query
}

// 使用示例
export default {
  setup() {
    // 字符串查询参数
    const searchQuery = useRouteQuery('q', '')
    
    // 数字查询参数
    const page = useRouteQuery('page', 1, {
      transform: (value) => parseInt(value) || 1,
      serialize: (value) => String(value)
    })
    
    // 布尔查询参数
    const showAdvanced = useRouteQuery('advanced', false, {
      transform: (value) => value === 'true',
      serialize: (value) => value ? 'true' : undefined
    })
    
    // 数组查询参数
    const tags = useRouteQuery('tags', [], {
      transform: (value) => Array.isArray(value) ? value : [value].filter(Boolean),
      serialize: (value) => value.length > 0 ? value : undefined
    })
    
    return {
      searchQuery,
      page,
      showAdvanced,
      tags
    }
  }
}

useRouteMeta:路由元信息

javascript
// composables/useRouteMeta.js
import { computed } from 'vue'
import { useRoute } from 'vue-router'

export function useRouteMeta() {
  const route = useRoute()
  
  const meta = computed(() => route.meta || {})
  
  const title = computed(() => meta.value.title || '默认标题')
  const description = computed(() => meta.value.description || '')
  const keywords = computed(() => meta.value.keywords || [])
  const requiresAuth = computed(() => meta.value.requiresAuth || false)
  const layout = computed(() => meta.value.layout || 'default')
  
  // 面包屑导航
  const breadcrumbs = computed(() => {
    return route.matched
      .filter(record => record.meta?.title)
      .map(record => ({
        title: record.meta.title,
        path: record.path,
        name: record.name
      }))
  })
  
  return {
    meta,
    title,
    description,
    keywords,
    requiresAuth,
    layout,
    breadcrumbs
  }
}

🎓 最佳实践

1. 逻辑复用

javascript
// composables/usePageData.js
export function usePageData(fetchFn) {
  const route = useRoute()
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)
  
  const loadData = async () => {
    loading.value = true
    error.value = null
    
    try {
      data.value = await fetchFn(route.params)
    } catch (err) {
      error.value = err
    } finally {
      loading.value = false
    }
  }
  
  // 监听路由参数变化
  watch(
    () => route.params,
    loadData,
    { immediate: true, deep: true }
  )
  
  return {
    data,
    loading,
    error,
    reload: loadData
  }
}

2. 类型安全

typescript
// types/router.ts
import type { RouteLocationNormalized } from 'vue-router'

export interface TypedRoute extends RouteLocationNormalized {
  params: {
    id: string
    [key: string]: string
  }
  meta: {
    title?: string
    requiresAuth?: boolean
    layout?: string
  }
}

// composables/useTypedRoute.ts
export function useTypedRoute(): TypedRoute {
  return useRoute() as TypedRoute
}

组合式 API 让 Vue Router 的使用变得更加灵活和强大。通过合理的抽象和复用,你可以构建出更加优雅和可维护的路由应用! 🚀

🚀 Vue Router - 让前端路由变得简单而强大 | 构建现代化单页应用的最佳选择