数据获取:让你的页面智能加载 📊
在现代 Web 应用中,数据就是生命力。学会优雅地获取和管理数据,是构建出色用户体验的关键!
数据获取的两种策略
在 Vue Router 中,我们有两种主要的数据获取策略,每种都有其独特的优势:
🚀 策略一:导航后获取(Navigation After)
特点:先跳转,再加载数据
- ✅ 页面响应快,用户立即看到页面框架
- ✅ 可以显示精美的加载状态
- ✅ 用户体验流畅,不会感觉"卡住"
- ❌ 需要处理加载状态的 UI
⏳ 策略二:导航前获取(Navigation Before)
特点:先加载数据,再跳转
- ✅ 页面一出现就有完整内容
- ✅ 避免页面"闪烁"效果
- ❌ 可能让用户感觉页面反应慢
- ❌ 需要在当前页面显示加载状态
🚀 导航后获取:现代化的用户体验
基础实现
vue
<template>
<div class="post-page">
<!-- 智能加载状态 -->
<div v-if="loading" class="loading-container">
<div class="skeleton-loader">
<div class="skeleton-title"></div>
<div class="skeleton-content"></div>
<div class="skeleton-content"></div>
</div>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<h3>😅 出了点小问题</h3>
<p>{{ error }}</p>
<button @click="retryFetch" class="retry-btn">重试</button>
</div>
<!-- 内容展示 -->
<article v-else-if="post" class="post-content">
<h1>{{ post.title }}</h1>
<div class="post-meta">
<span>作者:{{ post.author }}</span>
<span>发布时间:{{ formatDate(post.createdAt) }}</span>
</div>
<div class="post-body" v-html="post.content"></div>
</article>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getPost } from '@/api/posts'
const route = useRoute()
// 响应式状态
const loading = ref(false)
const post = ref(null)
const error = ref(null)
// 智能数据获取函数
async function fetchData(id) {
if (!id) return
// 重置状态
error.value = null
post.value = null
loading.value = true
try {
// 模拟最小加载时间,避免闪烁
const [postData] = await Promise.all([
getPost(id),
new Promise(resolve => setTimeout(resolve, 300))
])
post.value = postData
} catch (err) {
error.value = err.message || '加载失败,请稍后重试'
console.error('获取文章失败:', err)
} finally {
loading.value = false
}
}
// 重试机制
const retryFetch = () => fetchData(route.params.id)
// 监听路由变化
watch(
() => route.params.id,
(newId) => fetchData(newId),
{ immediate: true }
)
// 格式化日期
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN')
}
</script>
<style scoped>
.loading-container {
padding: 2rem;
}
.skeleton-loader {
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-title {
height: 2rem;
background: #e2e8f0;
border-radius: 4px;
margin-bottom: 1rem;
}
.skeleton-content {
height: 1rem;
background: #e2e8f0;
border-radius: 4px;
margin-bottom: 0.5rem;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.error-container {
text-align: center;
padding: 2rem;
color: #e53e3e;
}
.retry-btn {
background: #3182ce;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
</style>高级数据获取模式
javascript
// composables/useAsyncData.js
import { ref, computed } from 'vue'
export function useAsyncData(fetchFn, options = {}) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
const {
immediate = true,
resetOnExecute = true,
shallow = true,
transform = (data) => data,
onError = (err) => console.error(err)
} = options
const execute = async (...args) => {
if (resetOnExecute) {
data.value = null
error.value = null
}
loading.value = true
try {
const result = await fetchFn(...args)
data.value = transform(result)
return result
} catch (err) {
error.value = err
onError(err)
throw err
} finally {
loading.value = false
}
}
// 计算属性
const isReady = computed(() => !loading.value && !error.value && data.value !== null)
const isLoading = computed(() => loading.value)
const isError = computed(() => !!error.value)
if (immediate) {
execute()
}
return {
data: shallow ? readonly(data) : data,
error: readonly(error),
loading: readonly(loading),
isReady,
isLoading,
isError,
execute,
refresh: execute
}
}智能缓存策略
javascript
// composables/useDataCache.js
import { ref, computed } from 'vue'
class DataCache {
constructor(maxSize = 50, ttl = 5 * 60 * 1000) { // 5分钟TTL
this.cache = new Map()
this.maxSize = maxSize
this.ttl = ttl
}
set(key, data) {
// 如果缓存已满,删除最旧的条目
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value
this.cache.delete(firstKey)
}
this.cache.set(key, {
data,
timestamp: Date.now()
})
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
// 检查是否过期
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
return item.data
}
clear() {
this.cache.clear()
}
}
const globalCache = new DataCache()
export function useCachedData(key, fetchFn, options = {}) {
const { useCache = true, cacheKey } = options
const finalKey = cacheKey || key
const data = ref(null)
const loading = ref(false)
const error = ref(null)
const fetchData = async (...args) => {
// 尝试从缓存获取
if (useCache) {
const cached = globalCache.get(finalKey)
if (cached) {
data.value = cached
return cached
}
}
loading.value = true
error.value = null
try {
const result = await fetchFn(...args)
data.value = result
// 存入缓存
if (useCache) {
globalCache.set(finalKey, result)
}
return result
} catch (err) {
error.value = err
throw err
} finally {
loading.value = false
}
}
return {
data,
loading,
error,
fetchData,
clearCache: () => globalCache.clear()
}
}⏳ 导航前获取:确保内容完整性
使用导航守卫预加载
javascript
// 在路由配置中
const routes = [
{
path: '/post/:id',
component: PostDetail,
beforeEnter: async (to, from, next) => {
try {
// 显示全局加载指示器
showGlobalLoading('正在加载文章...')
// 预加载数据
const post = await getPost(to.params.id)
// 将数据传递给组件
to.meta.preloadedData = { post }
next()
} catch (error) {
// 处理错误
console.error('预加载失败:', error)
next({ name: 'PostNotFound' })
} finally {
hideGlobalLoading()
}
}
}
]组件中使用预加载数据
vue
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const post = ref(null)
onMounted(() => {
// 使用预加载的数据
if (route.meta.preloadedData?.post) {
post.value = route.meta.preloadedData.post
}
})
</script>高级预加载策略
javascript
// utils/preloader.js
class RoutePreloader {
constructor() {
this.preloadCache = new Map()
this.preloadPromises = new Map()
}
async preloadRoute(routeName, params = {}) {
const cacheKey = `${routeName}-${JSON.stringify(params)}`
// 如果已经在预加载,返回现有的 Promise
if (this.preloadPromises.has(cacheKey)) {
return this.preloadPromises.get(cacheKey)
}
// 如果已经缓存,直接返回
if (this.preloadCache.has(cacheKey)) {
return this.preloadCache.get(cacheKey)
}
// 开始预加载
const preloadPromise = this.executePreload(routeName, params)
this.preloadPromises.set(cacheKey, preloadPromise)
try {
const data = await preloadPromise
this.preloadCache.set(cacheKey, data)
return data
} finally {
this.preloadPromises.delete(cacheKey)
}
}
async executePreload(routeName, params) {
const preloadConfig = {
'PostDetail': () => getPost(params.id),
'UserProfile': () => getUserProfile(params.userId),
'ProductDetail': async () => {
const [product, reviews, recommendations] = await Promise.all([
getProduct(params.id),
getProductReviews(params.id),
getRecommendations(params.id)
])
return { product, reviews, recommendations }
}
}
const preloadFn = preloadConfig[routeName]
if (!preloadFn) {
throw new Error(`No preload configuration for route: ${routeName}`)
}
return await preloadFn()
}
getPreloadedData(routeName, params = {}) {
const cacheKey = `${routeName}-${JSON.stringify(params)}`
return this.preloadCache.get(cacheKey)
}
clearCache() {
this.preloadCache.clear()
this.preloadPromises.clear()
}
}
export const routePreloader = new RoutePreloader()🎯 实战案例:电商产品页面
vue
<template>
<div class="product-page">
<!-- 产品信息 -->
<div v-if="isLoading" class="product-skeleton">
<ProductSkeleton />
</div>
<div v-else-if="product" class="product-content">
<div class="product-gallery">
<ImageGallery :images="product.images" />
</div>
<div class="product-info">
<h1>{{ product.name }}</h1>
<div class="price">¥{{ product.price }}</div>
<div class="description">{{ product.description }}</div>
<!-- 动态加载的相关数据 -->
<div class="reviews-section">
<h3>用户评价</h3>
<div v-if="reviewsLoading">加载评价中...</div>
<ReviewList v-else :reviews="reviews" />
</div>
<div class="recommendations">
<h3>相关推荐</h3>
<div v-if="recommendationsLoading">加载推荐中...</div>
<ProductGrid v-else :products="recommendations" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getProduct, getProductReviews, getRecommendations } from '@/api'
const route = useRoute()
// 主要产品数据
const { data: product, loading: isLoading, error } = useAsyncData(
() => getProduct(route.params.id),
{ immediate: false }
)
// 次要数据(可以延迟加载)
const reviews = ref([])
const recommendations = ref([])
const reviewsLoading = ref(false)
const recommendationsLoading = ref(false)
// 加载主要数据
const loadProduct = async (id) => {
await product.execute(id)
// 主要数据加载完成后,开始加载次要数据
loadSecondaryData(id)
}
// 加载次要数据
const loadSecondaryData = async (productId) => {
// 并行加载评价和推荐
const loadReviews = async () => {
reviewsLoading.value = true
try {
reviews.value = await getProductReviews(productId)
} catch (err) {
console.error('加载评价失败:', err)
} finally {
reviewsLoading.value = false
}
}
const loadRecommendations = async () => {
recommendationsLoading.value = true
try {
recommendations.value = await getRecommendations(productId)
} catch (err) {
console.error('加载推荐失败:', err)
} finally {
recommendationsLoading.value = false
}
}
// 错开加载时间,避免同时发起太多请求
loadReviews()
setTimeout(loadRecommendations, 500)
}
// 监听路由变化
watch(
() => route.params.id,
(newId) => {
if (newId) {
loadProduct(newId)
}
},
{ immediate: true }
)
</script>🔧 性能优化技巧
1. 智能预加载
javascript
// 鼠标悬停时预加载
export function useHoverPreload() {
const preloadOnHover = (routeName, params) => {
return {
onMouseenter: () => {
routePreloader.preloadRoute(routeName, params)
}
}
}
return { preloadOnHover }
}2. 请求去重
javascript
// utils/requestDeduplication.js
class RequestDeduplicator {
constructor() {
this.pendingRequests = new Map()
}
async request(key, requestFn) {
// 如果相同的请求正在进行,返回现有的 Promise
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key)
}
// 创建新的请求
const promise = requestFn()
this.pendingRequests.set(key, promise)
try {
const result = await promise
return result
} finally {
this.pendingRequests.delete(key)
}
}
}
export const requestDeduplicator = new RequestDeduplicator()3. 分页数据管理
javascript
// composables/usePagination.js
export function usePagination(fetchFn, options = {}) {
const {
pageSize = 20,
initialPage = 1,
transform = (data) => data
} = options
const items = ref([])
const currentPage = ref(initialPage)
const totalPages = ref(0)
const loading = ref(false)
const hasMore = computed(() => currentPage.value < totalPages.value)
const loadPage = async (page = currentPage.value) => {
loading.value = true
try {
const response = await fetchFn({
page,
pageSize: pageSize
})
const newItems = transform(response.data)
if (page === 1) {
items.value = newItems
} else {
items.value.push(...newItems)
}
currentPage.value = page
totalPages.value = response.totalPages
} catch (error) {
console.error('加载分页数据失败:', error)
throw error
} finally {
loading.value = false
}
}
const loadMore = () => {
if (hasMore.value && !loading.value) {
return loadPage(currentPage.value + 1)
}
}
const refresh = () => {
return loadPage(1)
}
return {
items,
currentPage,
totalPages,
loading,
hasMore,
loadPage,
loadMore,
refresh
}
}🎯 最佳实践总结
1. 选择合适的策略
- 内容型页面(博客、新闻):导航后获取,提供流畅体验
- 表单页面:导航前获取,确保数据完整性
- 仪表板:混合策略,关键数据先加载,次要数据后加载
2. 错误处理
javascript
// 全局错误处理
const handleDataError = (error, context) => {
// 记录错误
console.error(`数据获取失败 [${context}]:`, error)
// 用户友好的错误提示
if (error.code === 'NETWORK_ERROR') {
showToast('网络连接异常,请检查网络设置')
} else if (error.code === 'UNAUTHORIZED') {
router.push('/login')
} else {
showToast('加载失败,请稍后重试')
}
}3. 加载状态设计
- 使用骨架屏而不是简单的 loading 文字
- 为不同类型的内容设计不同的加载状态
- 避免加载状态闪烁(设置最小显示时间)
数据获取是现代 Web 应用的核心技能。通过合理的策略选择和优化技巧,你可以为用户创造出既快速又可靠的体验! 🚀