Skip to content

服务端组件(Server Components)实践

React Server Components (RSC) 代表了 React 生态系统的重大革新,它模糊了前端和后端的界限,让我们能够在服务器上直接渲染组件,同时保持客户端的交互性。本文将深入探讨 RSC 的原理、实现和最佳实践。

🎯 Server Components 核心概念

什么是 Server Components

Server Components 是在服务器上运行的 React 组件,它们可以:

  • 直接访问后端资源(数据库、文件系统、API)
  • 减少客户端 JavaScript 包大小
  • 提供更好的 SEO 和首屏性能
  • 保持零客户端运行时成本

组件类型对比

jsx
// 1. Server Component (默认)
// 在服务器运行,不能使用客户端特性
async function ServerComponent() {
  // 可以直接访问数据库
  const posts = await db.posts.findMany()
  
  return (
    <div>
      <h1>博客文章</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  )
}

// 2. Client Component
// 需要明确标记,在客户端运行
'use client'

import { useState, useEffect } from 'react'

function ClientComponent() {
  const [count, setCount] = useState(0)
  
  // 可以使用客户端特性
  useEffect(() => {
    document.title = `Count: ${count}`
  }, [count])
  
  return (
    <button onClick={() => setCount(count + 1)}>
      点击次数: {count}
    </button>
  )
}

// 3. Shared Component
// 可以在服务器和客户端运行
function SharedComponent({ title, children }) {
  return (
    <div className="card">
      <h3>{title}</h3>
      {children}
    </div>
  )
}

🏗️ 架构原理

RSC 渲染流程

mermaid
graph TD
    A[用户请求] --> B[服务器接收请求]
    B --> C[执行 Server Components]
    C --> D[生成 RSC Payload]
    D --> E[发送到客户端]
    E --> F[客户端重构组件树]
    F --> G[渲染最终 UI]
    
    C --> H[访问数据库]
    C --> I[调用 API]
    C --> J[读取文件系统]

RSC Payload 格式

javascript
// RSC Payload 示例
{
  "type": "div",
  "props": {
    "children": [
      {
        "type": "h1",
        "props": {
          "children": "博客文章"
        }
      },
      {
        "type": "article",
        "props": {
          "children": [
            {
              "type": "h2",
              "props": {
                "children": "React Server Components 入门"
              }
            },
            {
              "type": "p",
              "props": {
                "children": "了解 RSC 的基本概念和使用方法..."
              }
            }
          ]
        }
      }
    ]
  }
}

🚀 Next.js App Router 实现

基础项目结构

app/
├── layout.tsx          # 根布局 (Server Component)
├── page.tsx           # 首页 (Server Component)
├── loading.tsx        # 加载状态
├── error.tsx          # 错误边界
├── not-found.tsx      # 404 页面
├── blog/
│   ├── page.tsx       # 博客列表
│   ├── [slug]/
│   │   └── page.tsx   # 博客详情
│   └── components/
│       ├── PostList.tsx    # Server Component
│       └── CommentForm.tsx # Client Component
└── api/
    └── posts/
        └── route.ts   # API 路由

根布局实现

tsx
// app/layout.tsx
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'Server Components 博客',
  description: '使用 React Server Components 构建的现代博客',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh-CN">
      <body className={inter.className}>
        <header className="bg-blue-600 text-white p-4">
          <nav className="container mx-auto">
            <h1 className="text-2xl font-bold">我的博客</h1>
          </nav>
        </header>
        <main className="container mx-auto py-8">
          {children}
        </main>
        <footer className="bg-gray-100 p-4 mt-8">
          <p className="text-center text-gray-600">
            © 2024 Server Components 博客
          </p>
        </footer>
      </body>
    </html>
  )
}

数据获取模式

tsx
// app/blog/page.tsx - 博客列表页
import { Suspense } from 'react'
import PostList from './components/PostList'
import PostListSkeleton from './components/PostListSkeleton'

// 这是一个 Server Component
export default function BlogPage({
  searchParams,
}: {
  searchParams: { page?: string; category?: string }
}) {
  const page = Number(searchParams.page) || 1
  const category = searchParams.category

  return (
    <div>
      <h1 className="text-3xl font-bold mb-8">博客文章</h1>
      
      {/* 使用 Suspense 处理异步加载 */}
      <Suspense fallback={<PostListSkeleton />}>
        <PostList page={page} category={category} />
      </Suspense>
    </div>
  )
}

// app/blog/components/PostList.tsx
import Link from 'next/link'
import { getPosts } from '@/lib/posts'

interface PostListProps {
  page: number
  category?: string
}

export default async function PostList({ page, category }: PostListProps) {
  // 直接在组件中获取数据
  const { posts, totalPages } = await getPosts({
    page,
    category,
    limit: 10
  })

  if (posts.length === 0) {
    return (
      <div className="text-center py-8">
        <p className="text-gray-500">暂无文章</p>
      </div>
    )
  }

  return (
    <div>
      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {posts.map((post) => (
          <article key={post.id} className="bg-white rounded-lg shadow-md p-6">
            <h2 className="text-xl font-semibold mb-2">
              <Link 
                href={`/blog/${post.slug}`}
                className="hover:text-blue-600 transition-colors"
              >
                {post.title}
              </Link>
            </h2>
            <p className="text-gray-600 mb-4">{post.excerpt}</p>
            <div className="flex justify-between items-center text-sm text-gray-500">
              <span>{post.author}</span>
              <time dateTime={post.publishedAt}>
                {new Date(post.publishedAt).toLocaleDateString('zh-CN')}
              </time>
            </div>
          </article>
        ))}
      </div>
      
      {/* 分页组件 */}
      <Pagination currentPage={page} totalPages={totalPages} />
    </div>
  )
}

混合组件模式

tsx
// app/blog/[slug]/page.tsx - 博客详情页
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getPost, getRelatedPosts } from '@/lib/posts'
import CommentSection from './components/CommentSection'
import ShareButtons from './components/ShareButtons'
import RelatedPosts from './components/RelatedPosts'

interface BlogPostProps {
  params: { slug: string }
}

export default async function BlogPost({ params }: BlogPostProps) {
  const post = await getPost(params.slug)
  
  if (!post) {
    notFound()
  }

  return (
    <article className="max-w-4xl mx-auto">
      {/* 文章头部 - Server Component */}
      <header className="mb-8">
        <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
        <div className="flex items-center gap-4 text-gray-600">
          <span>作者: {post.author}</span>
          <time dateTime={post.publishedAt}>
            {new Date(post.publishedAt).toLocaleDateString('zh-CN')}
          </time>
          <span>阅读时间: {post.readingTime} 分钟</span>
        </div>
      </header>

      {/* 文章内容 - Server Component */}
      <div 
        className="prose prose-lg max-w-none mb-8"
        dangerouslySetInnerHTML={{ __html: post.content }}
      />

      {/* 分享按钮 - Client Component */}
      <ShareButtons 
        url={`https://example.com/blog/${post.slug}`}
        title={post.title}
      />

      {/* 相关文章 - Server Component with Suspense */}
      <Suspense fallback={<div>加载相关文章...</div>}>
        <RelatedPosts postId={post.id} category={post.category} />
      </Suspense>

      {/* 评论区 - Client Component */}
      <CommentSection postId={post.id} />
    </article>
  )
}

// 生成静态参数
export async function generateStaticParams() {
  const posts = await getAllPosts()
  
  return posts.map((post) => ({
    slug: post.slug,
  }))
}

// 生成元数据
export async function generateMetadata({ params }: BlogPostProps) {
  const post = await getPost(params.slug)
  
  if (!post) {
    return {
      title: '文章未找到',
    }
  }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

Client Component 实现

tsx
// app/blog/[slug]/components/CommentSection.tsx
'use client'

import { useState, useEffect } from 'react'
import { useUser } from '@/hooks/useUser'

interface Comment {
  id: string
  author: string
  content: string
  createdAt: string
}

interface CommentSectionProps {
  postId: string
}

export default function CommentSection({ postId }: CommentSectionProps) {
  const [comments, setComments] = useState<Comment[]>([])
  const [newComment, setNewComment] = useState('')
  const [isLoading, setIsLoading] = useState(true)
  const [isSubmitting, setIsSubmitting] = useState(false)
  const { user } = useUser()

  useEffect(() => {
    loadComments()
  }, [postId])

  const loadComments = async () => {
    try {
      const response = await fetch(`/api/posts/${postId}/comments`)
      const data = await response.json()
      setComments(data.comments)
    } catch (error) {
      console.error('加载评论失败:', error)
    } finally {
      setIsLoading(false)
    }
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    
    if (!newComment.trim() || !user) return

    setIsSubmitting(true)
    
    try {
      const response = await fetch(`/api/posts/${postId}/comments`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          content: newComment,
          author: user.name,
        }),
      })

      if (response.ok) {
        const comment = await response.json()
        setComments([comment, ...comments])
        setNewComment('')
      }
    } catch (error) {
      console.error('提交评论失败:', error)
    } finally {
      setIsSubmitting(false)
    }
  }

  if (isLoading) {
    return <div className="animate-pulse">加载评论中...</div>
  }

  return (
    <section className="mt-12">
      <h3 className="text-2xl font-bold mb-6">评论 ({comments.length})</h3>
      
      {/* 评论表单 */}
      {user ? (
        <form onSubmit={handleSubmit} className="mb-8">
          <textarea
            value={newComment}
            onChange={(e) => setNewComment(e.target.value)}
            placeholder="写下你的评论..."
            className="w-full p-4 border rounded-lg resize-none"
            rows={4}
            required
          />
          <button
            type="submit"
            disabled={isSubmitting || !newComment.trim()}
            className="mt-2 px-6 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
          >
            {isSubmitting ? '提交中...' : '发表评论'}
          </button>
        </form>
      ) : (
        <p className="mb-8 text-gray-600">
          请 <a href="/login" className="text-blue-600">登录</a> 后发表评论
        </p>
      )}

      {/* 评论列表 */}
      <div className="space-y-6">
        {comments.map((comment) => (
          <div key={comment.id} className="bg-gray-50 p-4 rounded-lg">
            <div className="flex justify-between items-start mb-2">
              <span className="font-semibold">{comment.author}</span>
              <time className="text-sm text-gray-500">
                {new Date(comment.createdAt).toLocaleDateString('zh-CN')}
              </time>
            </div>
            <p className="text-gray-700">{comment.content}</p>
          </div>
        ))}
      </div>
    </section>
  )
}

🔄 数据获取策略

缓存机制

tsx
// lib/posts.ts
import { cache } from 'react'
import { unstable_cache } from 'next/cache'

// React cache - 请求级别缓存
export const getPost = cache(async (slug: string) => {
  const post = await db.posts.findUnique({
    where: { slug },
    include: {
      author: true,
      tags: true,
    },
  })
  
  return post
})

// Next.js unstable_cache - 跨请求缓存
export const getPosts = unstable_cache(
  async (options: {
    page: number
    category?: string
    limit: number
  }) => {
    const { page, category, limit } = options
    const skip = (page - 1) * limit

    const where = category ? { category } : {}

    const [posts, total] = await Promise.all([
      db.posts.findMany({
        where,
        skip,
        take: limit,
        orderBy: { publishedAt: 'desc' },
        include: {
          author: true,
        },
      }),
      db.posts.count({ where }),
    ])

    return {
      posts,
      totalPages: Math.ceil(total / limit),
    }
  },
  ['posts-list'],
  {
    revalidate: 3600, // 1小时后重新验证
    tags: ['posts'],
  }
)

// 手动重新验证缓存
import { revalidateTag } from 'next/cache'

export async function createPost(data: CreatePostData) {
  const post = await db.posts.create({ data })
  
  // 重新验证相关缓存
  revalidateTag('posts')
  
  return post
}

流式渲染

tsx
// app/dashboard/page.tsx
import { Suspense } from 'react'
import UserStats from './components/UserStats'
import RecentPosts from './components/RecentPosts'
import Analytics from './components/Analytics'

export default function Dashboard() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {/* 快速加载的组件 */}
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>
      
      {/* 中等速度的组件 */}
      <Suspense fallback={<PostsSkeleton />}>
        <RecentPosts />
      </Suspense>
      
      {/* 慢速加载的组件 */}
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics />
      </Suspense>
    </div>
  )
}

// 组件可以独立加载,不会阻塞其他组件
async function Analytics() {
  // 模拟慢速数据获取
  await new Promise(resolve => setTimeout(resolve, 3000))
  
  const analytics = await getAnalytics()
  
  return (
    <div className="bg-white p-6 rounded-lg shadow">
      <h3 className="text-lg font-semibold mb-4">分析数据</h3>
      {/* 渲染分析数据 */}
    </div>
  )
}

⚡ 性能优化

代码分割策略

tsx
// 动态导入 Client Components
import dynamic from 'next/dynamic'

// 懒加载重型组件
const ChartComponent = dynamic(
  () => import('./components/Chart'),
  {
    loading: () => <div>加载图表中...</div>,
    ssr: false, // 仅在客户端渲染
  }
)

const RichTextEditor = dynamic(
  () => import('./components/RichTextEditor'),
  {
    loading: () => <div>加载编辑器中...</div>,
  }
)

export default function PostEditor() {
  return (
    <div>
      <h1>文章编辑器</h1>
      
      {/* 条件加载 */}
      <Suspense fallback={<div>加载中...</div>}>
        <RichTextEditor />
      </Suspense>
      
      {/* 图表组件 */}
      <ChartComponent data={chartData} />
    </div>
  )
}

预加载策略

tsx
// app/blog/components/PostCard.tsx
import Link from 'next/link'
import { prefetch } from '@/lib/router'

interface PostCardProps {
  post: Post
}

export default function PostCard({ post }: PostCardProps) {
  return (
    <article 
      className="bg-white rounded-lg shadow-md p-6"
      onMouseEnter={() => {
        // 鼠标悬停时预加载
        prefetch(`/blog/${post.slug}`)
      }}
    >
      <h2 className="text-xl font-semibold mb-2">
        <Link 
          href={`/blog/${post.slug}`}
          className="hover:text-blue-600 transition-colors"
        >
          {post.title}
        </Link>
      </h2>
      <p className="text-gray-600">{post.excerpt}</p>
    </article>
  )
}

🛠️ 开发工具与调试

RSC DevTools

tsx
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ['@prisma/client'],
  },
  // 开发环境启用 RSC 调试
  ...(process.env.NODE_ENV === 'development' && {
    webpack: (config) => {
      config.resolve.alias = {
        ...config.resolve.alias,
        'react-server-dom-webpack/client': 'react-server-dom-webpack/client.browser',
      }
      return config
    },
  }),
}

module.exports = nextConfig

错误处理

tsx
// app/error.tsx - 全局错误边界
'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="min-h-screen flex items-center justify-center">
      <div className="text-center">
        <h2 className="text-2xl font-bold mb-4">出现了一些问题</h2>
        <p className="text-gray-600 mb-4">
          {error.message || '应用遇到了未知错误'}
        </p>
        <button
          onClick={reset}
          className="px-4 py-2 bg-blue-600 text-white rounded-lg"
        >
          重试
        </button>
      </div>
    </div>
  )
}

// app/blog/[slug]/error.tsx - 页面级错误边界
'use client'

export default function BlogError({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div className="text-center py-12">
      <h2 className="text-xl font-semibold mb-4">加载文章失败</h2>
      <p className="text-gray-600 mb-4">
        无法加载此文章,请稍后重试
      </p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg"
      >
        重新加载
      </button>
    </div>
  )
}

📊 最佳实践

组件设计原则

  1. 默认使用 Server Components
  2. 仅在需要交互时使用 Client Components
  3. 将 Client Components 推向叶子节点
  4. 合理使用 Suspense 边界
  5. 优化数据获取策略

性能优化建议

tsx
// ✅ 好的做法
// 1. Server Component 处理数据获取
async function BlogList() {
  const posts = await getPosts()
  
  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  )
}

// 2. Client Component 处理交互
'use client'
function InteractiveButton({ children, onClick }) {
  return (
    <button 
      onClick={onClick}
      className="hover:bg-blue-600 transition-colors"
    >
      {children}
    </button>
  )
}

// 3. 组合使用
function PostCard({ post }) {
  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.excerpt}</p>
      <InteractiveButton onClick={() => trackClick(post.id)}>
        阅读更多
      </InteractiveButton>
    </article>
  )
}

// ❌ 避免的做法
// 不要在 Server Component 中使用客户端特性
async function BadServerComponent() {
  const [count, setCount] = useState(0) // ❌ 错误
  const posts = await getPosts()
  
  useEffect(() => { // ❌ 错误
    document.title = 'Blog'
  }, [])
  
  return <div>{/* ... */}</div>
}

Server Components 代表了 React 应用架构的重大进步,它让我们能够构建更快、更高效的全栈应用。通过合理的组件设计、数据获取策略和性能优化,我们可以充分发挥 RSC 的优势,为用户提供卓越的体验。


拥抱 Server Components,构建下一代 React 应用!

vitepress开发指南