Skip to content

数据获取:让你的页面智能加载 📊

在现代 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 应用的核心技能。通过合理的策略选择和优化技巧,你可以为用户创造出既快速又可靠的体验! 🚀

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