React 18引入的Server Components改变了前端渲染范式。组件不再局限于客户端渲染,而是在服务器上执行生成HTML流式传输到浏览器。Next.js App Router(App目录)是首个完整实现RSC的框架。本文通过实际代码演示Server/Client组件边界划分、流式渲染Suspense配置以及SEO与首屏性能优化策略。
Server Components与Client Components的本质区别
Server Components在服务器上执行,输出序列化的React组件树发送到客户端。它们可以直接访问数据库、文件系统、环境变量等后端资源,但无法使用useState、useEffect等客户端Hook,也无法监听浏览器事件。
Client Components通过”use client”指令声明,在浏览器中执行 hydration。可以使用所有React Hook和浏览器API,但无法直接访问后端资源。两个类型的组件通过import组合,Server Components可以渲染Client Components,反之则不能直接import(需通过props传递)。
// app/layout.tsx (Server Component - 根布局)
import { Inter } from 'next/font/google'
import { db } from '@/lib/db'
import { Navbar } from '@/components/Navbar'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
// 直接在Server Component中查询数据库
const categories = await db.category.findMany({
select: { id: true, name: true, slug: true }
})
return (
<html lang="zh-CN">
<body className={inter.className}>
<Navbar categories={categories} />
<main>{children}</main>
</body>
</html>
)
}
Navbar作为Client Component接收Server Component传入的categories数据。数据在服务器获取,序列化为JSON嵌入HTML,客户端hydrate时直接使用,无需额外API请求。
Client Components”use client”指令与交互逻辑
// components/Navbar.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
interface NavbarProps {
categories: Array<{ id: number; name: string; slug: string }>
}
export function Navbar({ categories }: NavbarProps) {
const [searchQuery, setSearchQuery] = useState('')
const [mobileOpen, setMobileOpen] = useState(false)
const router = useRouter()
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
if (searchQuery.trim()) {
router.push(`/search?q=${encodeURIComponent(searchQuery)}`)
}
}
return (
<nav className="navbar">
<form onSubmit={handleSearch} className="search-box">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索文章..."
/>
<button type="submit">搜索</button>
</form>
<ul className="nav-links">
{categories.map(cat => (
<li key={cat.id}>
<a href={`/category/${cat.slug}`}>{cat.name}</a>
</li>
))}
</ul>
<button
className="mobile-toggle"
onClick={() => setMobileOpen(!mobileOpen)}
>
菜单
</button>
</nav>
)
}
“use client”指令必须放在文件第一行。一旦声明为Client Component,该文件import的所有模块也会被纳入客户端bundle。因此应尽量缩小Client Component的范围,将交互逻辑隔离在叶子组件中。
流式渲染与Suspense加载状态
Next.js App Router默认启用流式渲染。慢组件用Suspense包裹,服务器先发送已就绪的HTML,慢组件就绪后流式追加:
// app/page.tsx (首页)
import { Suspense } from 'react'
import { ArticleList } from '@/components/ArticleList'
import { TrendingSidebar } from '@/components/TrendingSidebar'
import { db } from '@/lib/db'
export default async function HomePage() {
// 并行发起所有数据请求
const [featured, totalCount] = await Promise.all([
db.article.findFirst({
where: { featured: true },
include: { author: true, category: true }
}),
db.article.count()
])
return (
<div className="container">
{/* 立即渲染的部分 */}
{featured && (
<section className="featured">
<h1>{featured.title}</h1>
<p>{featured.excerpt}</p>
</section>
)}
{/* 流式渲染:文章列表 */}
<Suspense
fallback={<ArticleListSkeleton />}
key="article-list"
>
<ArticleList page={1} />
</Suspense>
{/* 流式渲染:热门侧边栏 */}
<Suspense
fallback={<SidebarSkeleton />}
key="trending"
>
<TrendingSidebar />
</Suspense>
<p className="total">共 {totalCount} 篇文章</p>
</div>
)
}
// components/ArticleList.tsx
async function ArticleList({ page }: { page: number }) {
// 这个异步请求会挂起Suspense,直到数据就绪
const articles = await db.article.findMany({
skip: (page - 1) * 10,
take: 10,
orderBy: { createdAt: 'desc' },
include: { category: true, author: true }
})
return (
<section className="article-list">
{articles.map(article => (
<article key={article.id} className="article-card">
<h2><a href={`/article/${article.id}`}>{article.title}</a></h2>
<span className="category">{article.category.name}</span>
<p>{article.excerpt}</p>
<time>{new Date(article.createdAt).toLocaleDateString('zh-CN')}</time>
</article>
))}
</section>
)
}
浏览器收到的HTML流:先收到featured文章和Skeleton占位符,随后ArticleList数据就绪时流式替换对应Skeleton。用户无需等待所有数据加载完成就能看到首屏内容,TTV(Time to View)大幅降低。
动态路由与generateMetadata SEO优化
App Router的动态路由通过文件夹命名约定实现。generateMetadata函数在服务端生成页面元数据,搜索引擎抓取到完整的title和description:
// app/article/[id]/page.tsx
import { notFound } from 'next/navigation'
import { db } from '@/lib/db'
interface PageProps {
params: { id: string }
}
// 生成页面metadata(SEO)
export async function generateMetadata({ params }: PageProps) {
const article = await db.article.findUnique({
where: { id: parseInt(params.id) },
select: { title: true, excerpt: true, content: true }
})
if (!article) return { title: '文章未找到' }
return {
title: article.title,
description: article.excerpt,
openGraph: {
title: article.title,
description: article.excerpt,
type: 'article'
}
}
}
// 生成静态参数(ISR增量静态再生)
export async function generateStaticParams() {
const articles = await db.article.findMany({
select: { id: true },
where: { published: true }
})
return articles.map(a => ({ id: String(a.id) }))
}
export default async function ArticlePage({ params }: PageProps) {
const article = await db.article.findUnique({
where: { id: parseInt(params.id) },
include: { author: true, category: true }
})
if (!article) notFound()
return (
<article className="article-detail">
<h1>{article.title}</h1>
<div className="meta">
<span>{article.author.name}</span>
<span>{article.category.name}</span>
<time>{new Date(article.createdAt).toLocaleDateString('zh-CN')}</time>
</div>
<div
className="content"
dangerouslySetInnerHTML={{ __html: article.content }}
/>
</article>
)
}
generateStaticParams配合revalidate实现ISR。首次访问生成静态页面缓存,后续直接返回缓存。数据库更新后触发revalidate重新生成。这兼顾了静态页面的访问速度和动态内容的时效性。
中间件与路由拦截
// middleware.ts (项目根目录)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyToken } from '@/lib/auth'
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 管理后台路由鉴权
if (pathname.startsWith('/admin')) {
const token = request.cookies.get('auth-token')?.value
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
const payload = await verifyToken(token)
if (!payload || payload.role !== 'admin') {
return NextResponse.redirect(new URL('/403', request.url))
}
}
// 添加安全头
const response = NextResponse.next()
response.headers.set('X-Frame-Options', 'SAMEORIGIN')
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
return response
}
export const config = {
matcher: ['/admin/:path*', '/api/:path*']
}
中间件在Edge Runtime执行,延迟极低。matcher配置只对/admin和/api路径生效,不影响公开页面的性能。鉴权失败时重定向到登录页并携带redirect参数,登录后跳回原页面。
性能优化关键指标与调优
使用Next.js内置分析工具定位性能瓶颈:
// next.config.js
module.exports = {
experimental: {
instrumentationHook: true
}
}
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { registerOTel } = await import('@vercel/otel')
registerOTel({
serviceName: 'my-app',
url: process.env.OTEL_EXPORTER_URL
})
}
}
关键性能指标:
– LCP(Largest Contentful Paint):首屏最大内容渲染时间,目标<2.5s。Server Components直接输出HTML,无需客户端JS执行即可渲染,LCP通常优于CSR方案40-60%。
– TTFB(Time to First Byte):首字节时间,目标<800ms。流式渲染让TTFB降至数据请求最快完成的时间,而非全部数据加载完。
– CLS(Cumulative Layout Shift):累积布局偏移,目标<0.1。Suspense fallback需与实际内容尺寸一致,避免填充态到内容态的跳变。
Bundle分析识别Client Component体积:
# 安装并运行bundle分析
npm install -D @next/bundle-analyzer
# next.config.js 添加
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true'
})
module.exports = withBundleAnalyzer({})
# 运行bundle分析
npm run build && ANALYZE=true npm run build
# 打开 .next/analyze 查看各chunk体积
Server Components不含JavaScript,不会增加客户端bundle体积。当Client Component体积过大时,考虑将其部分逻辑提取为Server Component。图片组件使用next/image自动优化格式和尺寸,字体使用next/font消除布局偏移,两者都是Next.js内置的Web性能优化工具。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/reactservercomponents-yu-liu-shi-ssr-shi-zhan/