要点
先决定数据在服务端还是客户端取得,再考虑缓存。请求成功、HTTP 成功、JSON 格式合法和业务数据有效是不同检查。
取数前先回答四个问题
- 数据在服务端读还是在客户端读,凭据因此暴露给谁
- 数据什么时候失效,由什么事件触发刷新
- 请求失败、返回为空和返回很慢,用户分别看到什么
- 缓存键是什么,会不会在用户之间共享
容易卡住的地方
- 把一次请求结果复制进全局 store,之后失去刷新与错误语义
- 把
fetch兑现的 Promise 当成成功,忽略 HTTP 错误状态 - 私有数据走共享缓存,内容被串给其他用户
概述
本章按数据所有权整理取数方式:服务端组件、客户端组件和路由处理器各自能拿到什么凭据、什么缓存语义。请求成功、HTTP 成功、JSON 格式合法和业务数据有效是四件不同的事,需要分别检查。
数据获取架构概览
数据获取分类
// 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 = {}) {
// 默认值在解构处统一给出:只写 options = { immediate: true } 时,
// 传 { cache: 'no-store' } 会把 immediate 丢成 undefined
const { immediate = true, cache } = options
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: immediate,
error: null
})
const execute = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }))
try {
const response = await fetch(url, {
cache: 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, cache])
useEffect(() => {
if (immediate) {
execute()
}
}, [execute, 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 建议用 useCallback 稳定引用(或定义在组件外),
// 否则内联箭头函数会让 fetchPage 在每次渲染后重新触发首屏请求
getUrl: (page: number) => string,
options: UseInfiniteFetchOptions<T> = {}
) {
const {
initialPage = 1,
pageSize = 10,
// 默认不分页;传入 getNextPageParam 启用
getNextPageParam = () => null
} = 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()
// 由调用方的 getNextPageParam 决定是否还有下一页,
// 不再假设"整页 == 还有更多"
const nextPageParam = getNextPageParam(newItems, [])
setState(prev => ({
...prev,
data: page === initialPage ? newItems : [...prev.data, ...newItems],
loading: false,
hasMore: nextPageParam !== null,
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
// revalidate 与 tags 是 next 扩展字段,必须包在 next: {} 内传入,
// 平铺在顶层会被 fetch 忽略(TypeScript 也会报多余属性)。
export const cacheConfig = {
// 静态内容 - 长期缓存
static: {
cache: 'force-cache' as RequestCache,
next: { revalidate: 86400, tags: ['static'] }, // 24小时
},
// 动态内容 - 短期缓存
dynamic: {
cache: 'force-cache' as RequestCache,
next: { revalidate: 300, tags: ['dynamic'] }, // 5分钟
},
// 实时内容 - 不缓存
realtime: {
cache: 'no-store' as RequestCache,
},
// 用户相关内容 - 个性化数据不做跨请求共享缓存
user: {
cache: 'no-store' as RequestCache,
}
}
// 缓存标签工具
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 secret = request.headers.get('x-revalidate-secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
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>
)
}
数据获取闭环回顾
- 服务端读取保护服务端能力:在服务端组件或路由处理器读取时,可以使用服务端凭据并在返回前过滤字段;将需要浏览器交互的最小部分做成客户端组件,不要为了点击事件把整页的数据读取移到客户端。
- 客户端读取处理生命周期:客户端请求必须表现首次加载、空数据、首次失败、后台刷新与刷新失败。用户切换筛选条件或离开页面后,旧请求不能覆盖当前显示的数据。
- API 输入与输出都不可信:路由处理器校验路径、查询和正文,再根据身份进行资源授权;客户端在使用响应前检查 HTTP 状态与数据形状。TypeScript 类型不能替代任一端的运行时检查。
- 缓存是带条件的复用:为每类数据写出缓存键、可接受陈旧时间、失效事件和刷新方式。写操作完成后,更新或失效对应读取;用户私有或权限敏感数据不得因共享缓存而串给其他用户。
- 预取必须可取消且可衡量:只对高概率的下一步预取,受网络、内存和优先级约束。通过命中率、额外请求量和用户可见等待时间判断它是否有效,不以“请求提前发出”当作优化成功。
学习成果验收
- [ ] 为一个页面标出服务端读取、客户端交互和敏感字段过滤的位置,确认浏览器响应不含服务端秘密字段。
- [ ] 快速切换两个筛选值并让前一个请求更慢,确认最终 UI 只显示最后一个筛选结果。
- [ ] 对 API 分别发送无效查询、无效正文、未登录和越权请求,确认每种情况都有明确状态码和不泄露资源的响应。
- [ ] 创建或编辑一条记录后返回列表,确认缓存没有显示旧数据;切换身份后确认不会复用另一用户的内容。
- [ ] 记录一次预取前后的命中、额外请求和等待时间,在网络受限时确认预取能被跳过或取消。
请求结果不是自动可信的数据
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 成功数据写入缓存。完整 Next.js 渲染、缓存和部署环境仍需在应用测试中验证。
总结
选择数据获取方式时先写清数据在哪里读取、谁能看到凭据、何时会失效以及失败时用户看到什么;“能拿到 JSON”只是这个闭环的起点。