为什么"服务器优先"?
在探索具体的数据获取方法之前,我们必须先理解 Next.js App Router 的核心设计理念:服务器优先(Server-First)。
在传统的 React 开发(例如 Create React App)中,我们习惯于在浏览器(客户端)加载完页面骨架后,再通过 useEffect
去请求数据。这会导致用户先看到一个加载中的空白状态,然后数据才姗姗来迟,这种体验并不理想,我们称之为"请求瀑布"。
Next.js 彻底改变了这一点。通过引入服务器组件(Server Components) ,数据获取的默认执行环境从客户端转移到了服务器。
这意味着什么?
- 更快的初始加载:数据在服务器上获取完成,与页面HTML一同返回给浏览器。用户打开网页时,看到的就是一个内容完整的页面,不再有烦人的加载状态和布局抖动。
- 更安全的数据请求:你可以在服务器组件中安全地访问数据库、使用私密的 API 密钥,因为这些代码永远不会泄露到客户端。
- 更小的前端包体积 :用于数据获取(如
fetch
)和相关逻辑都留在了服务器,无需发送到浏览器,减轻了客户端的负担。
简而言之,Next.js 鼓励我们:尽可能地在服务器上获取数据。只有在确实需要交互性、且数据依赖于客户端状态时(例如,根据用户的输入进行搜索),我们才考虑在客户端获取数据。
fetch
的魔法:不仅仅是请求
在 Next.js 中,fetch
API 被赋予了"魔法"。它与 React 和 Next.js 的核心渲染、缓存机制深度集成,提供了强大的请求去重和缓存控制能力。
Next.js 15 的重要变化:默认不缓存
从 Next.js 15 开始,fetch
响应默认不再被缓存。这是一个重大的行为变化,意味着:
- 默认行为:每次请求都会从远程服务器获取最新数据
- 性能优化:Next.js 仍会预渲染路由,输出结果会被缓存以提升性能
- 请求去重 :在同一个渲染过程中,相同 URL 和选项的
fetch
请求仍会被自动去重(Request Memoization)
请求去重机制(Request Memoization)
虽然默认不缓存响应,但 Next.js 仍提供了请求去重 功能。在同一个 React 组件树的渲染过程中,相同的 fetch
请求只会执行一次:
typescript
// app/posts/page.tsx
async function getPosts() {
// Next.js 15: 默认不缓存,每次都获取最新数据
const res = await fetch('https://api.example.com/posts');
return res.json();
}
// 在同一次渲染中,这两个调用只会发送一次网络请求
async function getPostsAgain() {
// 这个请求会被去重,不会发送新的网络请求
const res = await fetch('https://api.example.com/posts');
return res.json();
}
export default async function Page() {
const posts = await getPosts(); // 第一次:发送网络请求
const morePosts = await getPostsAgain(); // 第二次:从内存返回
// ...
}
控制缓存策略
虽然 Next.js 15 默认不缓存 fetch
响应,但你仍然可以通过配置选项来精确控制缓存行为。
1. 启用缓存(force-cache)
如果你希望缓存某些稳定的数据(如配置信息、静态内容),可以显式设置 cache: 'force-cache'
:
typescript
// 启用缓存,数据会被持久化存储
const res = await fetch('https://api.example.com/config', {
cache: 'force-cache', // 显式启用缓存
});
1. 确保不缓存(no-store)
对于需要实时更新的数据(如股票价格、新闻快讯),你可以显式设置 cache
选项为 'no-store'
(虽然这已经是默认行为):
typescript
// 确保每次都重新请求(Next.js 15 的默认行为)
const res = await fetch('https://api.example.com/real-time-data', {
cache: 'no-store', // 显式禁用缓存
});
3. 定期重新验证(增量静态再生 - ISR)
你可以让数据在一定时间后自动更新。例如,一个博客文章列表,每小时更新一次就足够了。这通过 next.revalidate
选项实现:
typescript
// 启用缓存并设置重新验证时间
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }, // 60秒后重新验证
// 注意:使用 revalidate 时会自动启用缓存
});
重要提示 :当你设置 next.revalidate
时,Next.js 会自动启用缓存,无需显式设置 cache: 'force-cache'
。这个特性让你的网站兼具静态网站的访问速度和动态网站的内容更新能力。
Next.js 15 缓存行为总结
为了帮助你更好地理解 Next.js 15 的缓存变化,这里是一个快速参考表:
配置 | Next.js 14 及之前 | Next.js 15 | 说明 |
---|---|---|---|
默认行为 | 自动缓存 | 不缓存 | 重大变化:默认获取最新数据 |
cache: 'force-cache' |
缓存 | 缓存 | 显式启用缓存 |
cache: 'no-store' |
不缓存 | 不缓存 | 显式禁用缓存 |
next: { revalidate: 60 } |
缓存+重新验证 | 缓存+重新验证 | 自动启用缓存 |
请求去重 | ✅ | ✅ | 同一渲染中的相同请求仍会去重 |
迁移建议:
- 如果你的应用依赖自动缓存,需要显式添加
cache: 'force-cache'
或使用next.revalidate
- 对于实时数据,新的默认行为更符合预期,无需额外配置
- 开发环境中,HMR 缓存仍然有效,避免了频繁的 API 调用
数据获取实战演练
理论说完了,让我们进入实战环节。
场景一:在服务器组件中获取数据(推荐)
这是最常见、也是最推荐的方式。它非常直观,就像写 Node.js 代码一样。
示例:创建一个博客文章列表页面
typescript
// app/blog/page.tsx
// 定义文章类型,这是个好习惯
interface Post {
id: number;
title: string;
body: string;
}
// 异步组件,可以直接使用 await
export default async function BlogPage() {
console.log("正在服务器上获取数据...");
// 1. 获取数据
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10', {
next: { revalidate: 3600 } // 每小时更新一次,自动启用缓存
});
if (!res.ok) {
// 更好的错误处理方式见后文
throw new Error('Failed to fetch posts');
}
const posts: Post[] = await res.json();
// 2. 渲染UI
return (
<main className="p-8">
<h1 className="text-3xl font-bold mb-6">我的博客</h1>
<ul className="space-y-4">
{posts.map((post) => (
<li key={post.id} className="p-4 border rounded-md">
<h2 className="text-xl font-semibold">{post.title}</h2>
</li>
))}
</ul>
</main>
);
}
就这么简单!没有 useState
,没有 useEffect
,也没有加载状态的管理。你只需要 async/await
,剩下的交给 Next.js。
场景二:在客户端组件中获取数据
什么时候需要在客户端获取数据呢?
- 当数据是用户专属且高度动态的(如购物车内容)。
- 当数据依赖于用户的实时交互(如搜索框的自动完成建议)。
要在客户端组件中获取数据,你需要使用 "use client"
指令。
传统方式:useEffect
+ useState
在 React 19 之前,我们通常这样做:
typescript
"use client";
import { useState, useEffect } from 'react';
// ... Post 类型定义
export default function UserProfile() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/user/posts') // 假设有一个获取用户文章的 API
.then(res => res.json())
.then(data => {
setPosts(data);
})
.finally(() => {
setLoading(false);
});
}, []); // 空依赖数组,仅在组件挂载时执行一次
if (loading) {
return <p>加载中...</p>;
}
return (
// ... 渲染 posts
)
}
这种方式代码量多,且需要手动管理 loading
和 error
状态,比较繁琐。
现代方式:使用 React 19 use
Hook
use
hook 是 React 19 带来的革命性新特性,它极大地简化了在客户端组件中处理异步操作(如 fetch
)的方式。
前提:你需要一个包裹 fetch
的函数,它会处理 Promise。
typescript
// lib/data.ts
import { cache } from 'react';
// `cache` 函数可以包装数据请求,确保在一次渲染中,即使多次调用 `getUserPosts`,也只执行一次。
export const getUserPosts = cache((userId: string) =>
fetch(`https://api.example.com/users/${userId}/posts`).then((res) => res.json())
);
现在,在你的客户端组件中:
typescript
"use client";
import { use } from 'react';
import { getUserPosts } from '@/lib/data';
interface UserPostsProps {
userId: string;
}
// ... Post 类型定义
function PostsList({ userId }: { userId: string }) {
// 1. 使用 `use` Hook 获取数据
// 当 `getUserPosts` 的 Promise 还在 pending 状态时,`use` 会自动抛出这个 Promise,
// 这会被最近的 <Suspense> 边界捕获。
const posts: Post[] = use(getUserPosts(userId));
// 2. 渲染UI
// 代码能执行到这里,说明数据已经成功获取
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
export default function UserProfilePage({ params }: { params: { userId: string } }) {
return (
<div>
<h1 className="text-2xl">用户文章</h1>
{/* 必须用 Suspense 包裹使用 `use` 的组件 */}
<Suspense fallback={<p>正在加载文章列表...</p>}>
<PostsList userId={params.userId} />
</Suspense>
</div>
)
}
看到了吗?use
hook 让客户端数据获取变得和服务器端一样直观简洁。它内置了对 Suspense
的支持 ,你不再需要手动管理 loading
状态。use
会自动"暂停"组件的渲染,直到数据准备就绪。
加载中与错误处理
一个健壮的应用必须优雅地处理加载和错误状态。Next.js 提供了专门的文件约定来解决这个问题。
使用 loading.tsx
处理加载状态
当你在服务器组件中获取数据时,Next.js 会自动寻找与你的页面平级的 loading.tsx
文件,并将其作为加载指示器。
示例:为博客页面添加入场动画
在 app/blog/
目录下,创建一个 loading.tsx
文件:
typescript
// app/blog/loading.tsx
export default function Loading() {
// 你可以在这里设计任何酷炫的加载动画
return (
<div className="flex justify-center items-center h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
</div>
);
}
现在,当用户访问 /blog
页面时,在数据加载完成前,会首先看到这个旋转动画,而不是一个空白页面。这与 React 的 Suspense
边界协同工作,提供了无缝的加载体验。
使用 error.tsx
处理错误
如果数据获取失败(例如,API 服务器宕机),Next.js 会自动捕获错误,并渲染与页面平级的 error.tsx
文件。
注意 :error.tsx
必须 是一个客户端组件 ("use client"
)。
示例:为博客页面添加错误边界
在 app/blog/
目录下,创建一个 error.tsx
文件:
typescript
"use client"; // 错误组件必须是客户端组件
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// 你可以在这里记录错误日志
console.error(error);
}, [error]);
return (
<div className="text-center py-10">
<h2 className="text-2xl font-bold text-red-600">糟糕,出错了!</h2>
<p className="my-4">获取文章列表时遇到了问题,请稍后再试。</p>
<button
onClick={
// 尝试重新渲染该路由段
() => reset()
}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
重试
</button>
</div>
);
}
现在,如果 app/blog/page.tsx
中的 fetch
抛出错误,用户将看到这个友好的错误界面,而不是一个崩溃的应用。他们还可以通过点击"重试"按钮来尝试重新加载。
进阶技巧:数据变更与更新
获取数据只是故事的一半,我们还需要更新数据。Server Actions 是 Next.js 用于在服务器上执行数据变更(创建、更新、删除)的利器。
当一个 Server Action 执行后,我们通常需要更新页面上显示的数据。Next.js 提供了两种强大的方式来重新验证缓存:
revalidatePath
:使特定路径下的数据缓存失效,下次访问时会重新获取。revalidateTag
:更精细的控制。你可以在fetch
时给数据打上标签,然后只让带有特定标签的数据缓存失效。
这是一个简化的示例,让你感受一下:
typescript
// app/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function addPost(data: FormData) {
// 1. 调用 API 创建新文章
await fetch('https://api.example.com/posts', {
method: 'POST',
body: JSON.stringify({ title: data.get('title') }),
});
// 2. 让所有标记为 'posts' 的数据缓存失效
revalidateTag('posts');
}
// 在 fetch 时打上标签
fetch('https://api.example.com/posts', { next: { tags: ['posts'] } });
进一步提升:结合数据获取库
尽管 Next.js 对 fetch
API 进行了强大扩展,满足了大部分数据获取需求,但在复杂的客户端数据管理场景中(例如,需要频繁更新、离线模式、请求重试、缓存过期等),专业的客户端数据获取库能提供更强大的能力和更优雅的开发体验。
目前最受欢迎的两个库是 SWR (Stale-While-Revalidate) 和 React Query (现已更名为 TanStack Query)。它们都基于一个核心思想:"旧数据,新验证"。这意味着它们会立即返回缓存中的旧数据(如果存在),同时在后台发起新的数据请求进行验证和更新。这种模式极大地提升了用户感知的性能。
为什么使用数据获取库?
- 自动缓存和去重:自动管理数据缓存,避免重复请求。
- 自动重新验证(Revalidation):在窗口重新聚焦、网络重连等场景下自动重新请求数据,确保数据新鲜度。
- 错误处理和重试机制:内置完善的错误捕获和自动重试策略。
- 加载状态和分页/无限滚动:提供了简单的方式来管理加载状态,并支持高级的分页和无限滚动模式。
- 乐观更新:在数据变更时,可以先更新 UI,再等待服务器响应,提升用户体验。
SWR 示例
SWR 由 Vercel (Next.js 的创造者) 团队开发,与 Next.js 的配合非常默契。
typescript
// app/dashboard/client-data-fetcher.tsx
"use client";
import useSWR from 'swr';
interface UserData {
id: number;
name: string;
email: string;
}
// 定义一个 fetcher 函数,SWR 会用它来实际请求数据
const fetcher = (url: string) => fetch(url).then(res => res.json());
export default function UserDashboard() {
// useSWR 的第一个参数是请求的 key (通常是 URL),第二个参数是 fetcher 函数
const { data, error, isLoading } = useSWR<UserData>('/api/me', fetcher);
if (error) return <div className="text-red-500">加载失败</div>;
if (isLoading) return <div className="text-blue-500">加载中...</div>;
if (!data) return null; // 确保数据存在
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">欢迎, {data.name}!</h1>
<p>邮箱: {data.email}</p>
{/* 更多用户数据展示 */}
</div>
);
}
如何集成:
- 安装 SWR :
npm install swr
或yarn add swr
。 - 创建客户端组件 :确保你的组件有
"use client"
指令。 - 包裹
SWRConfig
(可选但推荐) :在应用的根组件(例如layout.tsx
或自定义_app.tsx
)中使用SWRConfig
提供全局配置,如默认的fetcher
或错误处理。
React Query (TanStack Query) 示例
React Query 提供了非常丰富的功能和更细粒度的控制。
typescript
// app/products/client-product-list.tsx
"use client";
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';
interface Product {
id: number;
name: string;
price: number;
}
const queryClient = new QueryClient(); // 创建 QueryClient 实例
// 假设的 API 请求函数
async function getProducts(): Promise<Product[]> {
const res = await fetch('https://api.example.com/products');
if (!res.ok) {
throw new Error('Failed to fetch products');
}
return res.json();
}
function ProductsList() {
// useQuery 的第一个参数是查询键 (一个数组,用于缓存识别),第二个是查询函数
const { data, error, isLoading } = useQuery<Product[], Error>({
queryKey: ['products'],
queryFn: getProducts,
});
if (isLoading) return <div className="text-blue-500">加载产品中...</div>;
if (error) return <div className="text-red-500">错误: {error.message}</div>;
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">产品列表</h1>
<ul>
{data?.map(product => (
<li key={product.id}> {product.name} - ¥{product.price.toFixed(2)}</li>
))}
</ul>
</div>
);
}
export default function ProductsPage() {
return (
// 必须用 QueryClientProvider 包裹,才能在子组件中使用 useQuery
<QueryClientProvider client={queryClient}>
<ProductsList />
</QueryClientProvider>
);
}
如何集成:
- 安装 React Query :
npm install @tanstack/react-query
或yarn add @tanstack/react-query
。 - 创建
QueryClientProvider
:在应用的根组件或需要使用 React Query 的组件树顶层提供QueryClientProvider
。
总结 :在处理客户端数据时,如果仅仅是简单的展示,Next.js fetch
+ use
Hook 可能已经足够。但对于需要高级缓存、优化交互、错误重试、数据同步等功能的场景,SWR 或 React Query 将是你的最佳选择。它们能让你以更声明式、更强大的方式管理客户端数据流。
优化用户体验:流式渲染与 Suspense
在现代 Web 应用中,用户体验至关重要。即使后端数据响应较慢,我们也希望用户能够尽快看到页面的骨架内容,而不是长时间的白屏。Next.js 15 结合 React 18+ 的并发特性,通过**流式渲染(Streaming)**和 Suspense 为我们带来了极致的用户体验优化。
什么是流式渲染?
想象一下,你正在访问一个包含多个独立部分(例如,一个显示产品列表,一个显示用户评论)的页面。在传统模式下,即使产品列表数据已经就绪,浏览器也必须等待所有部分的数据都加载完毕,才能开始渲染整个页面。
流式渲染改变了这一点。它允许服务器将页面的 HTML 分块发送到浏览器。
- 先发送"外壳"HTML:服务器可以立即发送页面布局(例如,导航栏、页脚)的 HTML,而不必等待所有数据加载完成。这让浏览器可以立即开始解析和渲染页面。
- 数据就绪时"流"入内容 :当某个部分的数据加载完成后,服务器会以
script
标签的形式,将该部分的 HTML 和相关 JavaScript 流式地发送给浏览器。浏览器接收到这些内容后,会将其插入到页面的正确位置。
这意味着用户可以更快地看到页面内容,即使数据尚未完全加载,他们也能够感受到页面正在逐步填充。这显著提升了用户感知的性能。
Suspense 在 Next.js 中的作用
Suspense 是 React 的一个内置组件,它允许你"暂停"组件的渲染,直到其内部的异步操作(例如数据获取)完成。当异步操作处于 pending
状态时,Suspense
会渲染一个 fallback
属性提供的备用内容(例如加载指示器)。
在 Next.js 的 App Router 中,loading.tsx
文件实际上就是 Suspense
的一个应用。
tsx
// app/dashboard/layout.tsx (示例)
import { Suspense } from 'react';
import DashboardNav from './DashboardNav';
import DashboardContent from './DashboardContent';
export default function DashboardLayout({
children,
}: { children: React.ReactNode }) {
return (
<section>
<DashboardNav />
{/* 这个 Suspense 边界会捕获 DashboardContent 内部可能出现的异步操作 */}
<Suspense fallback={<p>加载仪表盘内容...</p>}>
<DashboardContent />
</Suspense>
{children}
</section>
);
}
loading.tsx
是如何工作的?
当你在一个路由段中定义 loading.tsx
时,Next.js 会自动将其包裹在对应的 Suspense
边界中。例如,对于 /app/blog/page.tsx
和 /app/blog/loading.tsx
,Next.js 内部会将其处理为:
tsx
// 概念上类似于 Next.js 的内部处理
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
分层级加载:让页面内容渐进显示
利用流式渲染和 Suspense,我们可以实现页面的分层级加载。这意味着我们可以将页面划分为多个独立的部分,每个部分在自己的数据准备就绪后独立渲染。
示例:一个复杂的用户主页
假设一个用户主页包含:
- 顶部用户信息 (快速加载)
- 文章列表 (可能较慢)
- 好友推荐 (独立加载,可能最慢)
tsx
// app/profile/[userId]/page.tsx
import { Suspense } from 'react';
import UserInfo from './UserInfo'; // 假设这里不需要异步数据或数据非常快
import Articles from './Articles'; // 需要异步获取文章列表
import FriendsRecommendations from './FriendsRecommendations'; // 需要异步获取好友推荐
export default async function UserProfilePage({ params }: { params: { userId: string } }) {
const userId = params.userId;
return (
<div className="p-8">
{/* 用户信息部分,快速渲染 */}
<UserInfo userId={userId} />
<h2 className="text-2xl font-bold mt-8 mb-4">我的文章</h2>
{/* 文章列表,使用 Suspense 边界包裹,数据加载时显示加载状态 */}
<Suspense fallback={<p>加载文章中...</p>}>
<Articles userId={userId} />
</Suspense>
<h2 className="text-2xl font-bold mt-8 mb-4">好友推荐</h2>
{/* 好友推荐,独立 Suspense 边界,即便文章列表加载慢,它也可以在自己的数据就绪后显示 */}
<Suspense fallback={<p>加载好友推荐中...</p>}>
<FriendsRecommendations userId={userId} />
</Suspense>
</div>
);
}
// app/profile/[userId]/Articles.tsx (服务器组件)
async function Articles({ userId }: { userId: string }) {
// 模拟较慢的数据获取
await new Promise(resolve => setTimeout(resolve, 2000));
const res = await fetch(`https://api.example.com/users/${userId}/articles`);
const articles = await res.json();
return (
<ul>
{articles.map(article => <li key={article.id}>{article.title}</li>)}
</ul>
);
}
// app/profile/[userId]/FriendsRecommendations.tsx (服务器组件)
async function FriendsRecommendations({ userId }: { userId: string }) {
// 模拟最慢的数据获取
await new Promise(resolve => setTimeout(resolve, 4000));
const res = await fetch(`https://api.example.com/users/${userId}/recommendations`);
const recommendations = await res.json();
return (
<ul>
{recommendations.map(friend => <li key={friend.id}>{friend.name}</li>)}
</ul>
);
}
在这个例子中,UserInfo
会立即显示。同时,Articles
和 FriendsRecommendations
组件会并行请求数据,并在数据返回后,通过流式渲染逐步填充到页面中。这种方式极大地提升了用户感知的加载速度,因为他们不必等待最慢的数据。
总结:何时使用 Suspense?
- 服务器组件 :
loading.tsx
提供了页面的 Suspense 边界。 - 客户端组件 :当你需要在客户端组件内部进行异步数据获取,并希望在数据加载时显示加载状态,同时避免手动管理
loading
状态时,可以使用 React 19 的use
hook 结合<Suspense>
组件。 - 分层级加载 :当页面包含多个独立且加载时间可能不同的部分时,为每个异步部分包裹
Suspense
边界,可以实现更平滑的渐进式加载体验。
理解并善用流式渲染和 Suspense,是构建高性能 Next.js 应用的关键一步。
服务器组件与客户端组件:数据传递与交互
Next.js App Router 引入了服务器组件(Server Components)和客户端组件(Client Components)的概念,这在提供强大功能的同时,也带来了新的数据流和交互模式。理解它们之间如何通信是掌握 Next.js 的关键。
从服务器到客户端:Props 传递
最常见的数据传递方式是通过组件的 props
。服务器组件可以在渲染时获取数据,然后将这些数据作为 props
传递给嵌套的客户端组件。
重要原则 :传递给客户端组件的 props
必须是**可序列化(Serializable)**的。这意味着你不能直接传递函数、Symbol、Date 对象(需要转换为字符串或时间戳)、Class 实例等非基本类型数据。如果需要传递这些类型的数据,通常需要进行转换。
示例:服务器组件传递数据给客户端组件
typescript
// app/dashboard/page.tsx (服务器组件)
import UserGreeting from './UserGreeting'; // 这是一个客户端组件
interface UserProfile {
name: string;
lastLogin: string; // 假设是 ISO 格式字符串
}
async function getUserProfile(): Promise<UserProfile> {
// 在服务器上获取用户数据
const res = await fetch('https://api.example.com/user/profile', { cache: 'no-store' });
if (!res.ok) {
throw new Error('Failed to fetch user profile');
}
return res.json();
}
export default async function DashboardPage() {
const userProfile = await getUserProfile();
return (
<main className="p-8">
<h1 className="text-3xl font-bold mb-6">仪表盘</h1>
{/* 将服务器获取的数据作为 props 传递给客户端组件 */}
<UserGreeting userName={userProfile.name} lastLogin={userProfile.lastLogin} />
{/* 其他仪表盘内容 */}
</main>
);
}
// app/dashboard/UserGreeting.tsx (客户端组件)
"use client";
import { formatDistanceToNow } from 'date-fns'; // 客户端库
interface UserGreetingProps {
userName: string;
lastLogin: string; // 接收字符串,客户端再处理
}
export default function UserGreeting({ userName, lastLogin }: UserGreetingProps) {
const loginTime = new Date(lastLogin); // 在客户端将字符串转换为 Date 对象
const timeAgo = formatDistanceToNow(loginTime, { addSuffix: true, locale: 'zh-CN' });
return (
<div className="mb-4 p-4 bg-green-100 rounded-md">
<p className="text-lg">你好, <span className="font-semibold">{userName}</span>!</p>
<p className="text-sm text-gray-600">上次登录: {timeAgo}</p>
</div>
);
}
在这个例子中,DashboardPage
(服务器组件) 获取用户数据,然后将 userName
和 lastLogin
作为 props
传递给 UserGreeting
(客户端组件)。UserGreeting
在客户端利用 date-fns
库格式化时间,这是只有在客户端才能执行的操作。
从客户端到服务器:Server Actions
客户端组件需要与服务器端逻辑交互时,Server Actions 是最佳选择。它们允许你在客户端组件中直接调用服务器端函数,而无需手动创建 API 路由。
Server Actions 可以在任何服务器组件或 "use server"
文件中定义。
示例:客户端组件触发服务器行为
typescript
// app/comments/add-comment-form.tsx (客户端组件)
"use client";
import { useRef } from 'react';
import { addComment } from '@/app/actions'; // 引入服务器动作
export default function AddCommentForm() {
const formRef = useRef<HTMLFormElement>(null);
// 使用 bind 来预设参数,或者直接在 action 属性中使用箭头函数
const handleSubmit = async (formData: FormData) => {
await addComment(formData);
formRef.current?.reset(); // 提交后清空表单
};
return (
<form ref={formRef} action={handleSubmit} className="p-4 border rounded-md shadow-sm">
<h2 className="text-xl font-semibold mb-3">添加评论</h2>
<textarea
name="commentText"
rows={4}
placeholder="留下你的评论..."
className="w-full p-2 border rounded-md mb-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
></textarea>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
发布评论
</button>
</form>
);
}
// app/actions.ts (服务器文件)
"use server";
import { revalidatePath } from 'next/cache';
export async function addComment(formData: FormData) {
const commentText = formData.get('commentText') as string;
if (!commentText) {
throw new Error("评论内容不能为空。");
}
try {
// 模拟数据存储到数据库或调用外部 API
console.log(`正在保存评论: "${commentText}"`);
await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟网络延迟
// 重新验证路径,以便客户端能看到新评论
revalidatePath('/comments');
console.log("评论保存成功并重新验证了 /comments 路径。");
} catch (error) {
console.error("保存评论时出错:", error);
throw new Error("评论发布失败,请稍后再试。");
}
}
在这个示例中:
AddCommentForm
是一个客户端组件,因为它处理用户交互和表单提交。- 它通过
action
属性直接引用了addComment
这个 Server Action。 addComment
是一个在服务器上运行的异步函数,负责处理实际的业务逻辑(保存评论)。revalidatePath('/comments')
会在评论成功保存后,强制 Next.js 重新获取/comments
路径下的最新数据,从而更新 UI。
总结:
- 服务器到客户端 :通过
props
传递可序列化的数据。 - 客户端到服务器:通过 Server Actions 触发服务器端逻辑,实现数据变更或复杂操作。Server Actions 提供了一种安全、高效的方式,将前端交互与后端逻辑紧密结合。
掌握服务器组件与客户端组件的协作机制,是构建高性能、可扩展 Next.js 应用的关键。通过合理地划分组件职责,并在必要时进行数据传递和交互,你可以充分发挥 Next.js 在服务器端渲染和客户端交互方面的优势。
健全的错误处理策略:不止是 error.tsx
在构建任何健壮的应用时,错误处理是不可或缺的一环。Next.js 提供了 error.tsx
作为路由级别的错误边界,但实际开发中,我们可能需要更细致、更灵活的错误处理方案。
error.tsx
:路由级别的错误边界
我们已经在文章前面提到过 error.tsx
。它是一个 React 错误边界,能够捕获其子组件树中发生的运行时错误,并提供一个备用 UI。记住,它必须是客户端组件("use client"
)。
适用场景:捕获整个路由段或页面渲染过程中的非预期错误。
局限性:
- 无法捕获布局组件(
layout.tsx
)中的错误。 - 无法捕获同级或父级
error.tsx
中的错误。 - 默认情况下,它会重置页面状态并刷新,可能不是所有错误场景都希望的行为。
在异步组件内部处理错误
对于服务器组件中的数据获取,你可以直接使用标准的 try...catch
语句来处理异步操作中可能发生的错误。这允许你更精确地控制错误发生时的行为,而不是简单地抛出到 error.tsx
。
示例:细粒度错误处理
typescript
// app/products/page.tsx
interface Product {
id: number;
name: string;
price: number;
}
async function getProducts() {
try {
const res = await fetch('https://api.example.com/products', { cache: 'no-store' }); // Next.js 15 中可省略,默认不缓存
if (!res.ok) {
// 如果响应状态码不是 2xx,手动抛出错误
throw new Error(`Failed to fetch products: ${res.status} ${res.statusText}`);
}
const products: Product[] = await res.json();
return products;
} catch (error) {
console.error("获取产品数据时出错:", error); // 记录错误
// 你可以选择返回空数组,或者抛出更友好的错误信息
throw new Error("抱歉,暂时无法加载产品列表。请稍后再试。");
}
}
export default async function ProductsPage() {
let products: Product[] = [];
let errorMessage: string | null = null;
try {
products = await getProducts();
} catch (error: any) {
errorMessage = error.message;
}
return (
<main className="p-8">
<h1 className="text-3xl font-bold mb-6">产品目录</h1>
{errorMessage ? (
<div className="text-red-600 text-center py-4">{errorMessage}</div>
) : (
<ul className="space-y-4">
{products.map((product) => (
<li key={product.id} className="p-4 border rounded-md">
<h2 className="text-xl font-semibold">{product.name}</h2>
<p>价格: ¥{product.price.toFixed(2)}</p>
</li>
))}
</ul>
)}
</main>
);
}
这种方式的优势在于:
- 更精确的控制:你可以在数据获取函数内部直接处理错误,而不是让它冒泡到整个页面。这对于不同类型的错误需要不同反馈时非常有用。
- 用户友好反馈:可以在组件内部显示更具体、更友好的错误消息,而不是统一的错误页面。
- 数据回退:在某些情况下,你可能希望在数据获取失败时,显示一部分默认数据或缓存数据,而不是完全的错误页面。
全局错误日志与监控
对于生产环境的应用,仅仅在 UI 上显示错误是不够的,你还需要将错误日志发送到外部服务进行监控和分析(如 Sentry、Datadog 等)。
-
在
error.tsx
中记录 :error.tsx
组件的useEffect
是一个很好的地方来记录客户端捕获的错误。typescript// app/blog/error.tsx // ... useEffect(() => { // 将错误发送到你的日志服务 console.error(error); // Sentry.captureException(error); // 示例:集成 Sentry }, [error]); // ...
-
在 Server Actions 或 API 路由中记录 :由于 Server Actions 和 API 路由在服务器端运行,你可以直接使用 Node.js 环境的日志库(如
winston
或pino
),或者将其错误发送到云服务提供商的日志系统(如 AWS CloudWatch、Google Cloud Logging)。typescript// app/actions.ts 'use server'; import { revalidateTag } from 'next/cache'; export async function addPost(data: FormData) { try { await fetch('https://api.example.com/posts', { /* ... */ }); revalidateTag('posts'); } catch (error) { console.error("新增文章失败:", error); // 可以在这里返回一个错误状态给客户端 return { success: false, message: "新增文章失败,请稍后再试。" }; } }
总结
- 路由级错误 :使用
error.tsx
作为全局错误边界,捕获渲染期间的意外错误。 - 组件内错误 :在异步组件(尤其是服务器组件)内部使用
try...catch
进行细粒度的错误处理,提供更友好的用户反馈或回退机制。 - 日志监控:将客户端和服务器端的错误都发送到集中式日志服务,以便及时发现和解决问题。
总结与最佳实践
- 服务器优先:默认在服务器组件中获取数据,以获得最佳性能和安全性。
- 理解 Next.js 15 的
fetch
变化 :默认不再缓存响应,确保数据新鲜度。通过cache: 'force-cache'
或next.revalidate
选项精细控制缓存行为。请求去重机制仍然有效,避免同一渲染中的重复请求。 - 拥抱
async/await
:在服务器组件中,直接使用async/await
就能获取数据,代码简洁明了。 use
Hook 简化客户端获取 :当必须在客户端获取数据时,优先使用 React 19 的use
hook,它能与Suspense
无缝集成,告别手动的loading
状态管理。- 专业处理边界情况 :使用
loading.tsx
提供流畅的加载体验,使用error.tsx
创建优雅的错误边界。 - Server Actions + Revalidation :使用 Server Actions 处理数据变更,并用
revalidatePath
或revalidateTag
来保持UI与数据同步。