Next.js 数据获取基础

要点

先决定数据在服务端还是客户端取得,再考虑缓存。请求成功、HTTP 成功、JSON 格式合法和业务数据有效是不同检查。

数据获取核心概念

  • 理解 Next.js 16中 Server Components 和 Client Components 的数据获取模式
  • 掌握不同渲染策略(SSR、SSG、ISR、CSR)的数据获取方法
  • 学会 API 路由的创建和数据处理
  • 理解缓存策略和数据更新机制

实际应用能力

  • 构建类型安全的数据获取层
  • 实现错误处理和加载状态管理
  • 掌握数据预取和优化策略
  • 学会实时数据同步和更新

概述

Next.js 16提供了强大的数据获取生态系统,支持服务器端和客户端数据获取、多种缓存策略、以及优化的数据加载模式。本教程将帮助你掌握现代 Web 应用的数据获取最佳实践。

数据获取架构概览

数据获取分类

// src/types/data-fetching.ts
export interface DataFetchingStrategy {
  // 服务器端数据获取
  serverSide: {
    staticGeneration: '静态生成 - 构建时预渲染'
    serverSideRendering: '服务器端渲染 - 请求时渲染'
    incrementalStaticRegeneration: '增量静态再生 - 定期更新'
    onDemandRevalidation: '按需重新验证 - 手动触发'
  }

  // 客户端数据获取
  clientSide: {
    traditionalFetching: '传统fetch/axios模式'
    dataFetchingLibraries: 'SWR, React Query, TanStack Query'
    realtimeSubscriptions: 'WebSocket, EventSource'
    optimisticUpdates: '乐观更新模式'
  }

  // 混合模式
  hybrid: {
    streamingSSR: '流式SSR'
    progressiveEnhancement: '渐进式增强'
    islandArchitecture: '岛屿架构'
  }
}

Server Components 数据获取

基础服务器组件数据获取

// src/app/posts/page.tsx
import { notFound } from 'next/navigation'
import { PostCard } from '@/components/PostCard'
import { PostGrid } from '@/components/PostGrid'

type Post = { id: number; userId: number; title: string; body: string }
type User = { id: number; name: string }

async function readJson(response: Response): Promise<unknown> {
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
  return response.json()
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function parsePosts(value: unknown): Post[] {
  if (!Array.isArray(value) || !value.every((post) =>
    isRecord(post) && typeof post.id === 'number' && typeof post.userId === 'number'
    && typeof post.title === 'string' && typeof post.body === 'string'
  )) throw new Error('文章响应格式无效')
  return value
}

function parseUsers(value: unknown): User[] {
  if (!Array.isArray(value) || !value.every((user) =>
    isRecord(user) && typeof user.id === 'number' && typeof user.name === 'string'
  )) throw new Error('作者响应格式无效')
  return value
}

// 空数组是成功结果;网络失败与非 2xx 响应交给页面的 error.tsx 或调用方处理。
async function getPosts(): Promise<Post[]> {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
    cache: 'force-cache',
    next: { revalidate: 3600 },
  })
  return parsePosts(await readJson(response))
}

// 单个文章数据获取
async function getPost(id: string) {
  try {
    const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
      cache: 'no-store' // 不缓存,实时获取
    })

    if (!res.ok) {
      return null
    }

    return res.json()
  } catch (error) {
    console.error(`Error fetching post ${id}:`, error)
    return null
  }
}

// 并行数据获取
async function getPostsWithAuthors() {
  const [posts, users] = await Promise.all([
    fetch('https://jsonplaceholder.typicode.com/posts').then(readJson).then(parsePosts),
    fetch('https://jsonplaceholder.typicode.com/users').then(readJson).then(parseUsers),
  ])

  const postsWithAuthors = posts.map((post) => ({
    ...post,
    author: users.find((user) => user.id === post.userId) ?? null,
  }))

  return postsWithAuthors
}

// 页面组件
export default async function PostsPage() {
  const posts = await getPostsWithAuthors()

  return (
    <div className="max-w-7xl mx-auto py-8 px-4">
      <div className="mb-8">
        <h1 className="text-3xl font-bold text-gray-900 mb-4">
          博客文章
        </h1>
        <p className="text-gray-600">
          探索最新的技术文章和见解
        </p>
      </div>

      <PostGrid posts={posts} />
    </div>
  )
}

动态路由数据获取

// src/app/posts/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { PostContent } from '@/components/PostContent'
import { CommentSection } from '@/components/CommentSection'

interface PostPageProps {
  // Next.js 16:params 为 Promise
  params: Promise<{
    slug: string
  }>
}

// 获取文章详情
async function getPostBySlug(slug: string) {
  try {
    const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${slug}`, {
      // 动态路由的缓存策略
      cache: 'force-cache',
      next: {
        revalidate: 3600, // 1小时重新验证
        tags: [`post-${slug}`] // 标签用于按需重新验证
      }
    })

    // 资源不存在是页面状态;网络故障和 5xx 仍应进入错误边界。
    if (res.status === 404) return null
    return parsePosts([await readJson(res)])[0]
  } catch (error) {
    console.error(`Error fetching post ${slug}:`, error)
    throw error
  }
}

// 获取文章评论
async function getPostComments(postId: number) {
  try {
    const res = await fetch(
      `https://jsonplaceholder.typicode.com/posts/${postId}/comments`,
      {
        cache: 'force-cache',
        next: { revalidate: 1800 } // 30分钟重新验证
      }
    )

    return readJson(res)
  } catch (error) {
    console.error(`Error fetching comments for post ${postId}:`, error)
    throw error
  }
}

// 生成静态参数
export async function generateStaticParams() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts')
  const posts = parsePosts(await readJson(response))

  return posts.slice(0, 10).map((post) => ({
    slug: post.id.toString()
  }))
}

// 生成元数据
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
  const { slug } = await params
  const post = await getPostBySlug(slug)

  if (!post) {
    return {
      title: '文章不存在'
    }
  }

  return {
    title: post.title,
    description: post.body.substring(0, 160),
    openGraph: {
      title: post.title,
      description: post.body.substring(0, 160),
      type: 'article',
      publishedTime: new Date().toISOString(),
    },
  }
}

export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params
  const post = await getPostBySlug(slug)

  if (!post) {
    notFound()
  }

  const comments = await getPostComments(parseInt(slug))

  return (
    <div className="max-w-4xl mx-auto py-8 px-4">
      <PostContent post={post} />
      <CommentSection comments={comments} postId={post.id} />
    </div>
  )
}

数据预加载模式

// src/components/PostPreview.tsx
import Link from 'next/link'
import { cache } from 'react'

// 缓存数据获取函数
const getPost = cache(async (id: string) => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
  if (!res.ok) {
    throw new Error('Failed to fetch post')
  }
  return res.json()
})

interface PostPreviewProps {
  post: {
    id: number
    title: string
    body: string
  }
}

export function PostPreview({ post }: PostPreviewProps) {
  return (
    <Link href={`/posts/${post.id}`} className="block">
      <div className="bg-white p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow">
        <h3 className="text-xl font-semibold text-gray-900 mb-2">
          {post.title}
        </h3>
        <p className="text-gray-600 line-clamp-3">
          {post.body}
        </p>
        <div className="mt-4 text-blue-600 font-medium">
          阅读更多 →
        </div>
      </div>
    </Link>
  )
}

// 预加载组件
export function PostPreviewWithPrefetch({ post }: PostPreviewProps) {
  // 预加载数据
  fetch(`https://jsonplaceholder.typicode.com/posts/${post.id}`, {
    cache: 'force-cache'
  })

  return <PostPreview post={post} />
}

Client Components 数据获取

基础客户端数据获取

// src/hooks/useFetch.ts
'use client'

import { useState, useEffect, useCallback } from 'react'

interface FetchState<T> {
  data: T | null
  loading: boolean
  error: string | null
}

interface UseFetchOptions {
  immediate?: boolean
  cache?: RequestCache
}

export function useFetch<T>(
  url: string,
  options: UseFetchOptions = { immediate: true }
) {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    loading: options.immediate ?? true,
    error: null
  })

  const execute = useCallback(async () => {
    setState(prev => ({ ...prev, loading: true, error: null }))

    try {
      const response = await fetch(url, {
        cache: options.cache || 'default'
      })

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      const data = await response.json()
      setState({
        data,
        loading: false,
        error: null
      })
    } catch (error) {
      setState({
        data: null,
        loading: false,
        error: error instanceof Error ? error.message : 'Unknown error'
      })
    }
  }, [url, options.cache])

  useEffect(() => {
    if (options.immediate) {
      execute()
    }
  }, [execute, options.immediate])

  return {
    ...state,
    execute,
    refetch: execute
  }
}

高级数据获取 Hook

// src/hooks/useInfiniteFetch.ts
'use client'

import { useState, useEffect, useCallback } from 'react'

interface InfiniteFetchState<T> {
  data: T[]
  loading: boolean
  error: string | null
  hasMore: boolean
  page: number
}

interface UseInfiniteFetchOptions<T> {
  initialPage?: number
  pageSize?: number
  getNextPageParam?: (lastPage: T[], allPages: T[][]) => string | null
}

export function useInfiniteFetch<T>(
  getUrl: (page: number) => string,
  options: UseInfiniteFetchOptions<T> = {}
) {
  const {
    initialPage = 1,
    pageSize = 10,
    getNextPageParam
  } = options

  const [state, setState] = useState<InfiniteFetchState<T>>({
    data: [],
    loading: true,
    error: null,
    hasMore: true,
    page: initialPage
  })

  const fetchPage = useCallback(async (page: number) => {
    try {
      const url = getUrl(page)
      const response = await fetch(url)

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      const newItems: T[] = await response.json()

      setState(prev => ({
        ...prev,
        data: page === initialPage ? newItems : [...prev.data, ...newItems],
        loading: false,
        hasMore: newItems.length === pageSize,
        page: page + 1
      }))
    } catch (error) {
      setState(prev => ({
        ...prev,
        loading: false,
        error: error instanceof Error ? error.message : 'Unknown error'
      }))
    }
  }, [getUrl, initialPage, pageSize])

  const loadMore = useCallback(() => {
    if (!state.loading && state.hasMore) {
      fetchPage(state.page)
    }
  }, [state.loading, state.hasMore, state.page, fetchPage])

  const reset = useCallback(() => {
    setState({
      data: [],
      loading: true,
      error: null,
      hasMore: true,
      page: initialPage
    })
  }, [initialPage])

  useEffect(() => {
    fetchPage(initialPage)
  }, [fetchPage, initialPage])

  return {
    ...state,
    loadMore,
    reset
  }
}

实时数据同步

// src/hooks/useRealtimeData.ts
'use client'

import { useState, useEffect, useCallback } from 'react'

interface RealtimeDataState<T> {
  data: T | null
  loading: boolean
  error: string | null
  connected: boolean
}

export function useRealtimeData<T>(
  url: string,
  updateUrl?: string
) {
  const [state, setState] = useState<RealtimeDataState<T>>({
    data: null,
    loading: true,
    error: null,
    connected: false
  })

  // 初始数据获取
  const fetchData = useCallback(async () => {
    try {
      const response = await fetch(url)
      if (!response.ok) throw new Error('Failed to fetch data')

      const data = await response.json()
      setState(prev => ({ ...prev, data, loading: false }))
    } catch (error) {
      setState(prev => ({
        ...prev,
        error: error instanceof Error ? error.message : 'Unknown error',
        loading: false
      }))
    }
  }, [url])

  // 建立WebSocket连接或Server-Sent Events
  useEffect(() => {
    if (!updateUrl) return

    let eventSource: EventSource | null = null

    try {
      // 使用Server-Sent Events
      eventSource = new EventSource(updateUrl)

      eventSource.onopen = () => {
        setState(prev => ({ ...prev, connected: true }))
      }

      eventSource.onmessage = (event) => {
        try {
          const updatedData = JSON.parse(event.data)
          setState(prev => ({ ...prev, data: updatedData }))
        } catch (error) {
          console.error('Error parsing SSE data:', error)
        }
      }

      eventSource.onerror = () => {
        setState(prev => ({ ...prev, connected: false }))
      }
    } catch (error) {
      console.error('Error setting up real-time connection:', error)
    }

    return () => {
      if (eventSource) {
        eventSource.close()
      }
    }
  }, [updateUrl])

  useEffect(() => {
    fetchData()
  }, [fetchData])

  return state
}

API 路由开发

RESTful API 实现

// src/app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPosts, createPost } from '@/lib/posts'

// GET /api/posts - 获取文章列表
export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url)
    const page = parseInt(searchParams.get('page') || '1')
    const limit = parseInt(searchParams.get('limit') || '10')
    const category = searchParams.get('category')

    const posts = await getPosts({ page, limit, category })

    return NextResponse.json({
      success: true,
      data: posts,
      pagination: {
        page,
        limit,
        total: posts.length
      }
    })
  } catch (error) {
    console.error('Error fetching posts:', error)
    return NextResponse.json(
      {
        success: false,
        error: 'Failed to fetch posts'
      },
      { status: 500 }
    )
  }
}

// POST /api/posts - 创建新文章
export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const { title, content, excerpt, category } = body

    // 验证必填字段
    if (!title || !content) {
      return NextResponse.json(
        {
          success: false,
          error: 'Title and content are required'
        },
        { status: 400 }
      )
    }

    const newPost = await createPost({
      title,
      content,
      excerpt: excerpt || content.substring(0, 160),
      category: category || 'general',
      authorId: 'default-author', // 从认证中获取
      publishedAt: new Date()
    })

    return NextResponse.json({
      success: true,
      data: newPost,
      message: 'Post created successfully'
    }, { status: 201 })
  } catch (error) {
    console.error('Error creating post:', error)
    return NextResponse.json(
      {
        success: false,
        error: 'Failed to create post'
      },
      { status: 500 }
    )
  }
}

动态 API 路由

// src/app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPostById, updatePost, deletePost } from '@/lib/posts'
import { notFound } from 'next/navigation'

interface RouteParams {
  // Next.js 16:route handler 的 params 同样为 Promise
  params: Promise<{
    id: string
  }>
}

// GET /api/posts/[id] - 获取单篇文章
export async function GET(request: NextRequest, { params }: RouteParams) {
  try {
    const { id } = await params
    const post = await getPostById(id)

    if (!post) {
      return NextResponse.json(
        {
          success: false,
          error: 'Post not found'
        },
        { status: 404 }
      )
    }

    return NextResponse.json({
      success: true,
      data: post
    })
  } catch (error) {
    console.error(`Error fetching post:`, error)
    return NextResponse.json(
      {
        success: false,
        error: 'Failed to fetch post'
      },
      { status: 500 }
    )
  }
}

// PUT /api/posts/[id] - 更新文章
export async function PUT(request: NextRequest, { params }: RouteParams) {
  try {
    const { id } = await params
    const body = await request.json()
    const { title, content, excerpt, category } = body

    const updatedPost = await updatePost(id, {
      title,
      content,
      excerpt,
      category,
      updatedAt: new Date()
    })

    if (!updatedPost) {
      return NextResponse.json(
        {
          success: false,
          error: 'Post not found'
        },
        { status: 404 }
      )
    }

    return NextResponse.json({
      success: true,
      data: updatedPost,
      message: 'Post updated successfully'
    })
  } catch (error) {
    console.error(`Error updating post:`, error)
    return NextResponse.json(
      {
        success: false,
        error: 'Failed to update post'
      },
      { status: 500 }
    )
  }
}

// DELETE /api/posts/[id] - 删除文章
export async function DELETE(request: NextRequest, { params }: RouteParams) {
  try {
    const { id } = await params
    const deleted = await deletePost(id)

    if (!deleted) {
      return NextResponse.json(
        {
          success: false,
          error: 'Post not found'
        },
        { status: 404 }
      )
    }

    return NextResponse.json({
      success: true,
      message: 'Post deleted successfully'
    })
  } catch (error) {
    console.error(`Error deleting post:`, error)
    return NextResponse.json(
      {
        success: false,
        error: 'Failed to delete post'
      },
      { status: 500 }
    )
  }
}

加载状态和错误处理

加载组件

// src/app/posts/loading.tsx
export default function PostsLoading() {
  return (
    <div className="max-w-7xl mx-auto py-8 px-4">
      <div className="mb-8">
        <div className="h-8 bg-gray-200 rounded w-1/4 animate-pulse"></div>
        <div className="h-4 bg-gray-200 rounded w-1/2 mt-2 animate-pulse"></div>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="bg-white p-6 rounded-lg shadow-md">
            <div className="h-6 bg-gray-200 rounded w-3/4 mb-3 animate-pulse"></div>
            <div className="space-y-2">
              <div className="h-4 bg-gray-200 rounded animate-pulse"></div>
              <div className="h-4 bg-gray-200 rounded w-5/6 animate-pulse"></div>
              <div className="h-4 bg-gray-200 rounded w-4/6 animate-pulse"></div>
            </div>
            <div className="mt-4 h-4 bg-gray-200 rounded w-1/4 animate-pulse"></div>
          </div>
        ))}
      </div>
    </div>
  )
}

错误边界组件

// src/app/posts/error.tsx
'use client'

import { useEffect } from 'react'
import { Button } from '@/components/Button'

export default function PostsError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    console.error('Posts page error:', error)
  }, [error])

  return (
    <div className="max-w-7xl mx-auto py-16 px-4 text-center">
      <div className="bg-white p-8 rounded-lg shadow-md">
        <h2 className="text-2xl font-bold text-gray-900 mb-4">
          加载文章时出现错误
        </h2>
        <p className="text-gray-600 mb-6">
          {error.message || '无法加载文章列表,请稍后重试。'}
        </p>
        <Button onClick={reset} variant="primary">
          重试
        </Button>
      </div>
    </div>
  )
}

客户端错误处理

// src/components/ErrorBoundary.tsx
'use client'

import React from 'react'

interface ErrorBoundaryState {
  hasError: boolean
  error?: Error
}

interface ErrorBoundaryProps {
  children: React.ReactNode
  fallback?: React.ComponentType<{ error?: Error; reset: () => void }>
}

export class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  constructor(props: ErrorBoundaryProps) {
    super(props)
    this.state = { hasError: false }
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('ErrorBoundary caught an error:', error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      const FallbackComponent = this.props.fallback || DefaultErrorFallback
      return (
        <FallbackComponent
          error={this.state.error}
          reset={() => this.setState({ hasError: false, error: undefined })}
        />
      )
    }

    return this.props.children
  }
}

function DefaultErrorFallback({ error, reset }: { error?: Error; reset: () => void }) {
  return (
    <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
      <h3 className="text-red-800 font-medium mb-2">
        出现了错误
      </h3>
      <p className="text-red-600 text-sm mb-3">
        {error?.message || '组件渲染时发生了未知错误'}
      </p>
      <button
        onClick={reset}
        className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700"
      >
        重试
      </button>
    </div>
  )
}

缓存策略和优化

数据缓存配置

// src/lib/cache-config.ts
export const cacheConfig = {
  // 静态内容 - 长期缓存
  static: {
    cache: 'force-cache' as RequestCache,
    revalidate: 86400, // 24小时
    tags: ['static']
  },

  // 动态内容 - 短期缓存
  dynamic: {
    cache: 'force-cache' as RequestCache,
    revalidate: 300, // 5分钟
    tags: ['dynamic']
  },

  // 实时内容 - 不缓存
  realtime: {
    cache: 'no-store' as RequestCache,
    revalidate: 0,
    tags: ['realtime']
  },

  // 用户相关内容 - 个性化缓存
  user: {
    cache: 'force-cache' as RequestCache,
    revalidate: 60, // 1分钟
    tags: ['user']
  }
}

// 缓存标签工具
export const cacheTags = {
  post: (id: string) => [`post-${id}`],
  posts: () => ['posts'],
  user: (id: string) => [`user-${id}`],
  comments: (postId: string) => [`comments-${postId}`]
}

按需重新验证

// src/app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidateTag } from 'next/cache'

export async function POST(request: NextRequest) {
  try {
    const { tag } = await request.json()

    if (!tag) {
      return NextResponse.json(
        { error: 'Tag is required' },
        { status: 400 }
      )
    }

    revalidateTag(tag, 'max')  // Next 16:必须带 cacheLife profile 第二参数

    return NextResponse.json({
      success: true,
      message: `Cache invalidated for tag: ${tag}`,
      timestamp: new Date().toISOString()
    })
  } catch (error) {
    console.error('Error revalidating cache:', error)
    return NextResponse.json(
      { error: 'Failed to revalidate cache' },
      { status: 500 }
    )
  }
}

// 使用示例:重新验证特定文章缓存
// POST /api/revalidate
// Body: { "tag": "post-123" }

数据预取策略

// src/components/PrefetchLink.tsx
'use client'

import Link from 'next/link'
import { useRouter } from 'next/navigation'

interface PrefetchLinkProps {
  href: string
  children: React.ReactNode
  prefetchData?: () => Promise<void>
  className?: string
}

export function PrefetchLink({
  href,
  children,
  prefetchData,
  className
}: PrefetchLinkProps) {
  const router = useRouter()

  const handleMouseEnter = async () => {
    // 预取页面
    router.prefetch(href)

    // 预取数据
    if (prefetchData) {
      await prefetchData()
    }
  }

  return (
    <Link
      href={href}
      className={className}
      onMouseEnter={handleMouseEnter}
    >
      {children}
    </Link>
  )
}

总结

通过本教程,你已经掌握了:

  1. Server Components: 服务器端组件的数据获取模式和最佳实践
  2. Client Components: 客户端数据获取和状态管理
  3. API 路由: RESTful API 的创建和数据处理
  4. 缓存策略: 不同场景下的缓存配置和优化
  5. 错误处理: 完善的错误处理和用户体验
  6. 性能优化: 数据预取、加载状态和缓存优化

下一步学习

  • 深入学习 Server Actions 和表单处理
  • 掌握 GraphQL 和 Apollo Client 集成
  • 学习状态管理的高级模式(Zustand, Redux Toolkit)
  • 探索实时数据同步和 WebSocket 应用
  • 了解数据获取的安全性和认证

选择数据获取方式时先写清数据在哪里读取、谁能看到凭据、何时会失效以及失败时用户看到什么;“能拿到 JSON”只是这个闭环的起点。

文档交叉引用

相关文档

参考章节

  • 📖 [本模块其他章节]: 样式设计 | 状态管理
  • 📖 [Knowledge Points 快速参考]: 数据获取模式 | Next.js API 参考

总结

数据获取闭环回顾

  1. 服务端读取保护服务端能力:在服务端组件或路由处理器读取时,可以使用服务端凭据并在返回前过滤字段;将需要浏览器交互的最小部分做成客户端组件,不要为了点击事件把整页的数据读取移到客户端。
  2. 客户端读取处理生命周期:客户端请求必须表现首次加载、空数据、首次失败、后台刷新与刷新失败。用户切换筛选条件或离开页面后,旧请求不能覆盖当前显示的数据。
  3. API 输入与输出都不可信:路由处理器校验路径、查询和正文,再根据身份进行资源授权;客户端在使用响应前检查 HTTP 状态与数据形状。TypeScript 类型不能替代任一端的运行时检查。
  4. 缓存是带条件的复用:为每类数据写出缓存键、可接受陈旧时间、失效事件和刷新方式。写操作完成后,更新或失效对应读取;用户私有或权限敏感数据不得因共享缓存而串给其他用户。
  5. 预取必须可取消且可衡量:只对高概率的下一步预取,受网络、内存和优先级约束。通过命中率、额外请求量和用户可见等待时间判断它是否有效,不以“请求提前发出”当作优化成功。

学习成果验收

  • [ ] 为一个页面标出服务端读取、客户端交互和敏感字段过滤的位置,确认浏览器响应不含服务端秘密字段。
  • [ ] 快速切换两个筛选值并让前一个请求更慢,确认最终 UI 只显示最后一个筛选结果。
  • [ ] 对 API 分别发送无效查询、无效正文、未登录和越权请求,确认每种情况都有明确状态码和不泄露资源的响应。
  • [ ] 创建或编辑一条记录后返回列表,确认缓存没有显示旧数据;切换身份后确认不会复用另一用户的内容。
  • [ ] 记录一次预取前后的命中、额外请求和等待时间,在网络受限时确认预取能被跳过或取消。

贡献与反馈

内容改进

如果你发现本文档有改进空间,欢迎: – 🐛 报告问题: 在 Issues 中提出具体问题 – 💡 建议改进: 提出修改建议和补充内容 – 📝 参与贡献: 提交 PR 完善文档内容

学习反馈

分享你的学习体验: – ✅ 有用内容: 哪些部分对你最有帮助 – ❓ 疑问点: 哪些内容需要进一步澄清 – 🎯 建议: 希望增加什么内容


文档状态: ✅ 已完成最后更新: 2026年9月版本: v1.0.0

请求结果不是自动可信的数据

fetch() 在网络能连通时也会为 404、500 返回一个已兑现的 Promise;只有网络层失败才会抛出。因此先检查 ok,再把 JSON 当作外部输入校验。下面的解析函数没有依赖 Next.js,可放进 Server Component、Route Handler 或客户端请求封装;三者的缓存和凭证边界仍要分别设计。

type Todo = { id: string; title: string };

export function parseTodo(value: unknown): Todo {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
    throw new Error('响应不是对象');
  }
  const { id, title } = value as Record<string, unknown>;
  if (typeof id !== 'string' || typeof title !== 'string') {
    throw new Error('响应字段无效');
  }
  return { id, title };
}

export async function fetchTodo(id: string): Promise<Todo> {
  const response = await fetch(`https://api.example.test/todos/${encodeURIComponent(id)}`);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return parseTodo(await response.json());
}

encodeURIComponent 只是在 URL 路径中编码一个值,不能取代服务端的认证与资源授权。不要把 response.json() as Todo 当作运行时校验:断言不会改变收到的值。

练习:分别模拟 404、{ id: 1 } 和合法对象;验收时确认 UI 能显示可恢复错误,且没有把错误页面的 HTML 当 JSON 成功数据写入缓存。此段纯解析逻辑已在仓库验证记录中以提取后的 JavaScript 等价用例执行;完整 Next.js 渲染、缓存和部署环境未由该用例验证。

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇