要点
先决定数据在服务端还是客户端取得,再考虑缓存。请求成功、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>
)
}
总结
通过本教程,你已经掌握了:
- Server Components: 服务器端组件的数据获取模式和最佳实践
- Client Components: 客户端数据获取和状态管理
- API 路由: RESTful API 的创建和数据处理
- 缓存策略: 不同场景下的缓存配置和优化
- 错误处理: 完善的错误处理和用户体验
- 性能优化: 数据预取、加载状态和缓存优化
下一步学习
- 深入学习 Server Actions 和表单处理
- 掌握 GraphQL 和 Apollo Client 集成
- 学习状态管理的高级模式(Zustand, Redux Toolkit)
- 探索实时数据同步和 WebSocket 应用
- 了解数据获取的安全性和认证
选择数据获取方式时先写清数据在哪里读取、谁能看到凭据、何时会失效以及失败时用户看到什么;“能拿到 JSON”只是这个闭环的起点。
文档交叉引用
相关文档
参考章节
- 📖 [本模块其他章节]: 样式设计 | 状态管理
- 📖 [Knowledge Points 快速参考]: 数据获取模式 | Next.js API 参考
总结
数据获取闭环回顾
- 服务端读取保护服务端能力:在服务端组件或路由处理器读取时,可以使用服务端凭据并在返回前过滤字段;将需要浏览器交互的最小部分做成客户端组件,不要为了点击事件把整页的数据读取移到客户端。
- 客户端读取处理生命周期:客户端请求必须表现首次加载、空数据、首次失败、后台刷新与刷新失败。用户切换筛选条件或离开页面后,旧请求不能覆盖当前显示的数据。
- API 输入与输出都不可信:路由处理器校验路径、查询和正文,再根据身份进行资源授权;客户端在使用响应前检查 HTTP 状态与数据形状。TypeScript 类型不能替代任一端的运行时检查。
- 缓存是带条件的复用:为每类数据写出缓存键、可接受陈旧时间、失效事件和刷新方式。写操作完成后,更新或失效对应读取;用户私有或权限敏感数据不得因共享缓存而串给其他用户。
- 预取必须可取消且可衡量:只对高概率的下一步预取,受网络、内存和优先级约束。通过命中率、额外请求量和用户可见等待时间判断它是否有效,不以“请求提前发出”当作优化成功。
学习成果验收
- [ ] 为一个页面标出服务端读取、客户端交互和敏感字段过滤的位置,确认浏览器响应不含服务端秘密字段。
- [ ] 快速切换两个筛选值并让前一个请求更慢,确认最终 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 渲染、缓存和部署环境未由该用例验证。