组合式 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
}
}
}🔗 useLink:自定义链接组件
基础用法
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 的使用变得更加灵活和强大。通过合理的抽象和复用,你可以构建出更加优雅和可维护的路由应用! 🚀