React服务端组件实战:构建高性能博客的完整指南
我的学习之旅
几周前,我终于决定深入了解React服务端组件(RSC)。作为一名主要使用Angular的开发者,我一直在旁观React并学习它。RSC已经实验性存在多年,但随着React 19使其稳定,我认为是时候看看它到底有什么特别之处。原本计划"周末试试"的想法最终彻底重建了我对React工作原理的心智模型。
React服务端组件是什么?
两年前,我听到Dan Abramov在某个会议上谈论React服务端组件。我的第一反应是"太好了,又一个我可能永远不会使用的React功能"。当时它们还处于实验阶段,我以为会一直这样。我错了。
事情是这样的:你知道你的React应用会向浏览器发送一个庞大的JavaScript包,然后浏览器必须解析所有这些代码、运行它、进行API调用,最后向用户显示内容吗?是的,这…不太好。特别是在连接速度慢或手机较旧的情况下。
服务端组件颠覆了这一点。你的部分组件在服务器上运行,在那里完成所有繁重的工作,只将渲染好的HTML发送到浏览器。用户立即获得内容,你的JavaScript包缩小,每个人都很开心。
为什么我现在关心React服务端组件
我的包确实变小了
之前,我的测试项目主包大小为400KB。之后:118KB。这不是打字错误。事实证明,当你的一半组件在服务器上运行时,你不需要将它们的代码发送到浏览器。来自Angular背景,我们习惯了tree-shaking和懒加载,但这仍然令人印象深刻。
有意义的数据获取
我一直听说React开发者抱怨useEffect、useState、setLoading的舞蹈。来自Angular,我们有服务和observables,React的数据获取总是显得不必要的复杂。使用服务端组件,我只需…获取数据。在组件中。像一个普通函数。这几乎太简单了。
我的Lighthouse分数变得更好
核心Web指标"实际上相当不错",开箱即用。作为一个习惯使用Angular Universal进行SSR的人,这感觉出乎意料地轻松。
重大的心智转变
传统React:一切都在浏览器中发生。每个组件、每个状态更新、每个API调用 - 全部在客户端。工作正常,直到你的包大小开始变得庞大。
带RSC的React:一些组件在服务器上运行(无客户端代码),一些在客户端运行(用于交互性)。服务器组件可以直接与数据库通信。客户端组件处理点击和表单提交。这就像有两个不同的执行环境,不知何故可以一起工作,而你不必过多考虑。
来自Angular背景,这在某些方面感觉很熟悉,我们使用Angular Universal进行服务器端渲染已有多年。但RSC无缝混合服务器和客户端执行的方式确实不同。不再需要"在useEffect中获取数据,存储在状态中,显示加载微调器"的舞蹈。只需在组件中使用async/await,你就完成了。
设置我的Mac
在我们开始之前,让我们确保你的电脑不会与你对抗。我花了1小时调试一个最终是Node版本问题的事情,才艰难地学到了这一点。
基础
打开终端。你知道,那个让你感觉像黑客的应用。运行这些:
1
2
|
node --version
npm --version
|
你需要Node 18或更高版本。如果你使用的是旧版本,请访问nodejs.org并下载最新的LTS版本。
如果你没有VS Code,请获取它。
真正有帮助的扩展
我浪费了时间安装从不使用的扩展。以下是真正重要的:
打开VS Code,按⌘ + Shift + X并安装:
- ES7+ React/Redux/React-Native snippets - 避免你无数次输入import React
- Tailwind CSS Intelli-Sense - 因为没人记住CSS类名
- Auto Rename Tag - 当你更改开始标签时更改结束标签。改变生活。
- GitLens - 使Git在VS Code中真正有用
构建真实的东西(并立即破坏它)
是时候停止阅读并开始破坏东西了。我在Documents目录中创建了一个文件夹,因为我没有组织到拥有适当的项目文件夹:
1
2
|
cd ~/Documents
npx create-next-app@latest my-rsc-blog --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
|
是的,这个命令很拗口。但每个标志都很重要:
- –typescript 因为凌晨2点调试JavaScript错误不好玩
- –tailwind 因为我CSS不好
- –eslint 因为我犯愚蠢的错误
- –app 因为那是RSC所在的地方
- –src-dir 因为我喜欢有组织的项目
- –import-alias 因为../../../components很丑
1
2
|
cd my-rsc-blog
npm run dev
|
访问http://localhost:3000,你应该看到Next.js欢迎页面。如果你遇到端口错误,只需使用不同的端口:
你实际得到什么
在VS Code中打开项目:
结构看起来像这样:
1
2
3
4
5
6
7
8
9
10
|
my-rsc-blog/
├── src/
│ └── app/
│ ├── globals.css
│ ├── layout.tsx
│ ├── page.tsx
│ └── favicon.ico
├── package.json
├── tailwind.config.ts
└── tsconfig.json
|
干净。简单。即将变得更加复杂。
服务器与客户端组件
这花了我尴尬的长时间才理解。让我为你节省一些痛苦。
默认情况下,app目录中的每个组件都是服务器组件。它在服务器上运行。它不能使用useState或onClick或任何浏览器API,因为没有浏览器。但它可以直接从数据库获取数据,真正保持秘密,并进行昂贵的计算而不会让手机烫手。
客户端组件在顶部有"use client"。它们在浏览器中运行。它们可以处理用户交互、管理状态并使用你熟悉和喜爱的所有React钩子。
我的第一个"等等,这真的有效"时刻
我创建了src/app/components/ServerTime.tsx:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// 没有"use client" = 在服务器上运行
async function ServerTime() {
// 这段代码永远不会到达浏览器
const response = await fetch('http://worldtimeapi.org/api/timezone/Europe/London');
const data = await response.json();
return (
<div className="p-4 bg-blue-50 rounded-lg">
<h2 className="text-lg font-bold">服务器组件</h2>
<p>当前时间: {data.datetime}</p>
<p className="text-sm text-gray-600">
这是在服务器上获取的。检查网络标签 - 没有API调用!
</p>
</div>
);
}
export default ServerTime;
|
疯狂的部分?没有useEffect。没有useState。没有加载状态。只是像普通函数一样的async/await。服务器获取数据,渲染HTML并将其发送到浏览器。浏览器从未看到API调用。
构建客户端组件进行比较
然后我创建了src/app/components/ClientCounter.tsx:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
"use client"; // 这一行改变了一切
import { useState } from 'react';
function ClientCounter() {
const [count, setCount] = useState(0);
return (
<div className="p-4 bg-green-50 rounded-lg">
<h2 className="text-lg font-bold">客户端组件</h2>
<p>计数: {count}</p>
<button
onClick={() => setCount(count + 1)}
className="mt-2 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
点击我!
</button>
<p className="text-sm text-gray-600">
这在你的浏览器中运行并处理交互。
</p>
</div>
);
}
export default ClientCounter;
|
我更新了src/app/page.tsx以使用两者:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import ServerTime from './components/ServerTime';
import ClientCounter from './components/ClientCounter';
export default function Home() {
return (
<main className="max-w-4xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">服务器 vs 客户端组件</h1>
<div className="space-y-6">
<ServerTime />
<ClientCounter />
</div>
</main>
);
}
|
刷新页面,观察服务器组件的时间在每次重新加载时更新,而客户端组件保持其状态。那时我才真正理解。
构建博客(因为TodoMVC已经过时了)
够了玩具例子。是时候为这个学习练习构建一些现实的东西了。博客似乎是完美的RSC用例,主要是静态内容和一些交互部分。
规划
我想要:
- 博客文章列表(服务器组件)
- 单独的文章页面(带动态路由的服务器组件)
- 搜索功能(表单的客户端组件,结果的服务器组件)
- 不让用户认为网站坏掉的加载状态
- 不向用户显示堆栈跟踪的错误处理
假数据(因为我懒)
我创建了src/app/lib/blog-data.ts,包含一些模拟数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
|
export interface BlogPost {
id: string;
title: string;
excerpt: string;
content: string;
author: string;
publishedAt: string;
readingTime: string;
tags: string[];
}
const posts: BlogPost[] = [
{
id: '1',
title: '为什么我终于学习了React服务端组件',
excerpt: '忽略它们六个月后,我终于屈服了。这是我所学到的。',
content: '我曾经认为React服务端组件只是炒作。',
author: '我',
publishedAt: '2025-07-15',
readingTime: '5分钟',
tags: ['React', 'Next.js', '学习']
},
{
id: '2',
title: 'CSS摧毁我灵魂的那一天',
excerpt: '关于特异性、级联以及为什么我现在使用Tailwind的故事。',
content: '想象一下:你花了三个小时让你的组件看起来完美。然后你添加一个CSS规则,一切都崩溃了...',
author: '还是我',
publishedAt: '2025-06-10',
readingTime: '3分钟',
tags: ['CSS', 'Tailwind']
}
];
// 模拟慢速网络,因为真实API不是即时的
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export async function getBlogPosts(): Promise<BlogPost[]> {
await delay(800); // 模拟一些加载时间
return posts;
}
export async function getBlogPost(id: string): Promise<BlogPost | null> {
await delay(600);
return posts.find(post => post.id === id) || null;
}
export async function searchPosts(query: string): Promise<BlogPost[]> {
await delay(500);
if (!query.trim()) return [];
return posts.filter(post =>
post.title.toLowerCase().includes(query.toLowerCase()) ||
post.content.toLowerCase().includes(query.toLowerCase()) ||
post.tags.some(tag => tag.toLowerCase().includes(query.toLowerCase()))
);
}
|
延迟函数很重要。真实API有延迟,你想测试你的加载状态。
构建文章卡片组件
我创建了src/app/components/PostCard.tsx:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
import Link from 'next/link';
import { BlogPost } from '../lib/blog-data';
interface PostCardProps {
post: BlogPost;
}
function PostCard({ post }: PostCardProps) {
return (
<article className="bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow p-6">
<div className="flex items-center text-sm text-gray-500 mb-3">
<span>{post.author}</span>
<span className="mx-2">•</span>
<span>{new Date(post.publishedAt).toLocaleDateString()}</span>
<span className="mx-2">•</span>
<span>{post.readingTime}</span>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-3">
<Link href={`/blog/${post.id}`} className="hover:text-blue-600">
{post.title}
</Link>
</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex flex-wrap gap-2">
{post.tags.map(tag => (
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-700 text-sm rounded">
{tag}
</span>
))}
</div>
</article>
);
}
export default PostCard;
|
没什么花哨的。只是一个看起来不错并链接到单独文章的卡片。
主页(事情变得有趣的地方)
我重写了src/app/page.tsx:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
import { Suspense } from 'react';
import { getBlogPosts } from './lib/blog-data';
import PostCard from './components/PostCard';
import SearchForm from './components/SearchForm';
async function PostList() {
const posts = await getBlogPosts();
return (
<div className="grid gap-6 md:grid-cols-2">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-6 md:grid-cols-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-48 animate-pulse" />
))}
</div>
);
}
export default function Home() {
return (
<main className="max-w-6xl mx-auto px-4 py-8">
<header className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">我的博客</h1>
<p className="text-xl text-gray-600">
关于代码、生活以及为什么一切在凌晨3点崩溃的思考
</p>
</header>
<SearchForm />
<Suspense fallback={<LoadingSkeleton />}>
<PostList />
</Suspense>
</main>
);
}
|
这里的魔力是Suspense。当PostList在服务器上获取数据时,用户看到加载骨架。没有JavaScript瀑布,没有加载内容的闪烁 - 只有流畅的渐进式渲染。
单独文章页面(真正有效的动态路由)
我创建了src/app/blog/[id]/page.tsx:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { getBlogPost } from '../../lib/blog-data';
import type { Metadata } from 'next';
interface PostPageProps {
params: Promise<{ id: string }>;
}
// 这为每篇文章生成元数据
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
// Next.js 15需要await params(艰难地学到了这一点)
const { id } = await params;
const post = await getBlogPost(id);
if (!post) {
return { title: '文章未找到' };
}
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.author }],
};
}
export default async function PostPage({ params }: PostPageProps) {
const { id } = await params;
const post = await getBlogPost(id);
if (!post) {
notFound(); // 显示404页面
}
return (
<main className="max-w-4xl mx-auto px-4 py-8">
<Link href="/" className="text-blue-600 hover:text-blue-800 mb-8 inline-block">
← 返回博客
</Link>
<article className="prose prose-lg max-w-none">
<header className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">{post.title}</h1>
<div className="flex items-center text-gray-600 mb-6">
<span>{post.author}</span>
<span className="mx-2">•</span>
<span>{new Date(post.publishedAt).toLocaleDateString()}</span>
<span className="mx-2">•</span>
<span>{post.readingTime}</span>
</div>
<div className="flex flex-wrap gap-2">
{post.tags.map(tag => (
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-700 text-sm rounded">
{tag}
</span>
))}
</div>
</header>
<div className="text-gray-800 leading-relaxed">
{post.content}
</div>
</article>
</main>
);
}
|
每篇文章都有自己的URL、适当的元数据和服务器端渲染。generateMetadata函数在服务器上运行,并用正确的信息填充
标签。
添加搜索(客户端+服务器组件协同工作)
搜索表单需要交互,所以我使src/app/components/SearchForm.tsx成为客户端组件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
"use client";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
function SearchForm() {
const [query, setQuery] = useState('');
const router = useRouter();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
router.push(`/search?q=${encodeURIComponent(query)}`);
}
};
return (
<form onSubmit={handleSubmit} className="mb-8">
<div className="flex gap-2 max-w-md mx-auto">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索文章..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="submit"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500"
>
搜索
</button>
</div>
</form>
);
}
export default SearchForm;
|
然后我在src/app/search/page.tsx创建了搜索结果页面:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
import { Suspense } from 'react';
import Link from 'next/link';
import { searchPosts } from '../lib/blog-data';
import PostCard from '../components/PostCard';
interface SearchPageProps {
searchParams: Promise<{ q?: string }>;
}
async function SearchResults({ query }: { query: string }) {
const results = await searchPosts(query);
if (results.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">
没有找到关于"{query}"的文章。尝试不同的关键词?
</p>
</div>
);
}
return (
<div className="grid gap-6 md:grid-cols-2">
{results.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
export default async function SearchPage({ searchParams }: SearchPageProps) {
// 另一个Next.js 15的事情 - searchParams必须被await
const { q: query = '' } = await searchParams;
return (
<main className="max-w-6xl mx-auto px-4 py-8">
<Link href="/" className="text-blue-600 hover:text-blue-800 mb-6 inline-block">
← 返回博客
</Link>
<h1 className="text-3xl font-bold text-gray-900 mb-8">
搜索结果
{query && <span className="text-gray-600"> 关于 "{query}"</span>}
</h1>
{query ? (
<Suspense fallback={<div>搜索中...</div>}>
<SearchResults query={query} />
</Suspense>
) : (
<p className="text-gray-600">输入搜索词来查找文章。</p>
)}
</main>
);
}
|
漂亮。客户端组件处理表单交互,服务器组件处理搜索逻辑和渲染。它们协同工作,而你无需考虑太多。
当一切崩溃时(有趣的部分)
没有灾难的教程是不完整的。以下是我遇到的真实问题以及如何修复它们。
伟大的Params Await灾难
这个错误消息困扰着我的梦想:“Route used params.id. params should be awaited before using its properties.”
发生了什么?我升级到Next.js 15,突然我所有的动态路由都崩溃了。事实证明Next.js 15改变了params的工作方式。你不能只是做params.id了。你必须先await整个params对象。
修复很烦人但简单:
1
2
3
4
5
6
7
8
9
10
|
// 这在Next.js 15中崩溃
export default function BlogPost({ params }: { params: { id: string } }) {
const post = getBlogPost(params.id); // 错误!
}
// 这有效
export default async function BlogPost({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; // 必须先await
const post = await getBlogPost(id);
}
|
searchParams也是一样。现在一切都必须被await。我花了1小时调试这个才找到迁移指南。
CSS消失事件
有一天我的样式就…消失了。一切工作,但看起来像1995年的网站。HTML有所有的Tailwind类,但没有CSS加载。
事实证明Next.js 15附带Tailwind CSS v4,它有一个完全不同的配置系统。我必须降级到v3:
1
2
|
npm uninstall tailwindcss @tailwindcss/postcss
npm install tailwindcss@^3.4.1 autoprefixer
|
然后更新postcss.config.mjs:
1
2
3
4
5
6
7
8
|
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
|
清除缓存并重启:
1
|
rm -rf .next && npm run dev
|
样式回来了。危机避免。
神秘的构建清单错误
有时Next.js只是失去理智,找不到自己的构建文件:
1
|
ENOENT: no such file or directory, open '.next/build-manifest.json'
|
修复总是一样的:
1
2
|
rm -rf .next
npm run dev
|
这发生的频率比应该的高,但通常是一个快速修复。每当奇怪的事情发生时,我已经开始自动这样做。
TypeScript变得额外
有时TypeScript决定变得"有帮助",并为绝对应该工作的事情抛出类型错误。核选项:
1
2
3
4
|
rm -rf node_modules/.cache
rm -rf .next
npm install
npm run dev
|
这会清除所有类型缓存,通常修复TypeScript抱怨的任何东西。
使其快速(好东西)
RSC应该是高性能的,但你仍然需要聪明地使用它。
博客文章的静态生成
由于博客文章不经常更改,我可以在构建时预生成它们:
1
2
3
4
5
|
// 将此添加到你的博客文章页面
export async function generateStaticParams() {
const posts = await getBlogPosts();
return posts.map(post => ({ id: post.id }));
}
|
现在当我构建站点时,Next.js为每个博客文章创建静态HTML文件。闪电般快速加载,零服务器工作。
使用Suspense进行流式传输
我添加的Suspense边界不仅用于加载状态 - 它们启用流式传输。服务器立即发送页面外壳,然后在内容可用时流式传输内容。用户立即看到某些东西,而不是盯着空白页面。
包分析
想看看你的JavaScript包中实际有什么吗?
1
|
npm install --save-dev @next/bundle-analyzer
|
将此添加到next.config.js:
1
2
3
4
5
|
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});
|
运行ANALYZE=true npm run build,你将获得包大小的可视化细分。看到你的客户端包在RSC下有多小是相当令人满意的。
我实际学到了什么(真正的东西)
在构建这个学习项目之后,以下是真正坚持下来的东西:
RSC改变你对React的思考方式
最大的转变不是技术性的,而是心智上的。在传统React中,你从客户端状态和效果的角度思考。使用RSC,你从代码运行位置和每个组件需要什么数据的角度思考。在某些方面更简单,在其他方面更复杂。
服务器和客户端之间的边界很重要
你不能只是到处粘贴"use client"就完事了。每个边界都有性能影响。服务器组件非常适合数据获取和初始渲染。客户端组件对于交互性是必要的。明智选择。
Next.js 15大部分很棒
带有RSC的App Router现在感觉成熟了。开发者体验扎实,性能优势真实,生态系统正在迎头赶上。仍然有一些粗糙的边缘(比如params的事情),但比pages router是一个巨大的改进。
错误边界更重要
随着服务器和客户端组件混合在一起,错误边界变得关键。如果你不小心错误处理,失败的服务器组件可能会破坏整个页面。
它实际上相当简单
一旦你度过了最初的困惑,RSC是直接的。服务器组件获取数据并渲染HTML。客户端组件处理交互。框架处理使它们协同工作的复杂性。
结束(终于)
React服务端组件不仅仅是炒作。它们是对React应用构建方式的真正改进。性能优势是真实的,开发者体验比传统React更好(一旦你学会了怪癖),心智模型是有意义的,特别是来自服务器端框架。
它完美吗?不。有一个学习曲线,一些令人困惑的错误消息,你必须更仔细地思考代码运行的位置。但对于像博客、营销页面或电子商务这样内容繁重的站点,RSC可能足够引人注目,可以考虑在通常选择Angular的项目中使用React。
我构建的学习项目演示了核心概念:用于数据获取的服务器组件,用于交互性的客户端组件,适当的加载状态和错误处理。从这里,你可以添加身份验证、评论、CMS或你的实际项目需要的任何东西。
最重要的是:试验东西。破坏东西。修复东西。仔细阅读错误消息(它们通常很有帮助)。当事情变得奇怪时,不要害怕删除.next并重启。记住,如果你作为一个学习React的Angular开发者感到困惑,你并不孤单。概念不同,但随着练习开始变得有意义。
现在去构建一些酷的东西。当你不可避免地遇到我没有提到的奇怪错误时,你会弄清楚的。这就是我们所做的。