Next.js

服务器组件与客户端组件

深入理解 Next.js 的服务器组件和客户端组件,掌握组件组合模式和最佳实践。

概述

Next.js 默认使用 React 服务器组件(Server Components),允许在服务器上获取数据和渲染 UI,可选地缓存结果并流式传输到客户端。当需要交互性或浏览器 API 时,可以使用客户端组件(Client Components)添加功能。

何时使用服务器组件和客户端组件

客户端和服务器环境具有不同的能力。服务器组件和客户端组件允许根据用例在每个环境中运行逻辑。

使用客户端组件的场景

当需要以下功能时使用客户端组件:

  • 状态和事件处理器onClickonChange
  • 生命周期逻辑useEffectuseLayoutEffect
  • 浏览器专用 APIlocalStoragewindowNavigator.geolocation
  • 自定义 Hooks:依赖于状态、效果或浏览器 API 的 Hooks
  • React 类组件:使用类组件语法

使用服务器组件的场景

当需要以下功能时使用服务器组件:

  • 从数据库或 API 获取数据:靠近数据源
  • 使用敏感信息:API 密钥、令牌等,不暴露给客户端
  • 减少客户端 JavaScript:减少发送到浏览器的 JavaScript 量
  • 改善首次内容绘制(FCP):流式传输内容到客户端
  • SEO 和社交媒体爬虫:服务器渲染的内容更易于索引

对比表

需求服务器组件客户端组件
获取数据
访问后端资源
在服务器上保留敏感信息
减少客户端 JavaScript
添加交互性和事件监听器
使用状态和生命周期效果
使用浏览器专用 API
使用依赖状态、效果或浏览器 API 的自定义 Hooks
使用 React 类组件

服务器组件

服务器组件在服务器上渲染,可以直接访问后端资源。

基础示例

// app/page.tsx
// 默认是服务器组件
export default async function Page() {
  // 直接在组件中获取数据
  const data = await fetch('https://api.example.com/data')
  const posts = await data.json()

  return (
    <div>
      <h1>博客文章</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  )
}

数据获取

服务器组件可以使用任何异步 I/O 获取数据:

// app/blog/page.tsx
import { db } from '@/lib/db'

export default async function BlogPage() {
  // 直接访问数据库
  const posts = await db.post.findMany({
    orderBy: { createdAt: 'desc' },
  })

  return (
    <div>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  )
}

使用敏感信息

服务器组件可以安全地使用 API 密钥和其他敏感信息:

// app/api/data/route.ts
export async function GET() {
  // API 密钥不会暴露给客户端
  const response = await fetch('https://api.example.com/data', {
    headers: {
      Authorization: `Bearer ${process.env.API_KEY}`,
    },
  })

  const data = await response.json()
  return Response.json(data)
}

客户端组件

客户端组件在客户端渲染,可以使用交互性和浏览器 API。

使用 "use client" 指令

通过在文件顶部添加 "use client" 指令来创建客户端组件。

'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  )
}

重要提示:

  • "use client" 指令必须在文件顶部,在任何导入之前
  • 不需要在每个使用客户端功能的文件中添加指令
  • 只需在直接从服务器组件导入的组件中添加
  • 该指令定义了客户端-服务器边界

状态管理

'use client'

import { useState } from 'react'

export default function TodoList() {
  const [todos, setTodos] = useState<string[]>([])
  const [input, setInput] = useState('')

  const addTodo = () => {
    if (input.trim()) {
      setTodos([...todos, input])
      setInput('')
    }
  }

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="添加待办事项"
      />
      <button onClick={addTodo}>添加</button>
      <ul>
        {todos.map((todo, index) => (
          <li key={index}>{todo}</li>
        ))}
      </ul>
    </div>
  )
}

使用效果

'use client'

import { useEffect, useState } from 'react'

export default function WindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 })

  useEffect(() => {
    function updateSize() {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      })
    }

    window.addEventListener('resize', updateSize)
    updateSize()

    return () => window.removeEventListener('resize', updateSize)
  }, [])

  return (
    <p>
      窗口大小:{size.width} x {size.height}
    </p>
  )
}

使用浏览器 API

'use client'

import { useState, useEffect } from 'react'

export default function Geolocation() {
  const [location, setLocation] = useState<{
    latitude: number
    longitude: number
  } | null>(null)

  useEffect(() => {
    if ('geolocation' in navigator) {
      navigator.geolocation.getCurrentPosition((position) => {
        setLocation({
          latitude: position.coords.latitude,
          longitude: position.coords.longitude,
        })
      })
    }
  }, [])

  if (!location) return <p>获取位置中...</p>

  return (
    <p>
      位置:{location.latitude}, {location.longitude}
    </p>
  )
}

组合模式

在服务器组件中嵌套客户端组件

这是最常见的模式:服务器组件处理数据获取,客户端组件处理交互。

// app/page.tsx (服务器组件)
import Header from './header' // 服务器组件
import Counter from './counter' // 客户端组件

export default async function Page() {
  const data = await fetchData()

  return (
    <div>
      <Header />
      <Counter initialCount={data.count} />
    </div>
  )
}
// app/counter.tsx (客户端组件)
'use client'

import { useState } from 'react'

export default function Counter({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount)

  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  )
}

将服务器组件作为 Props 传递给客户端组件

可以将服务器组件作为 children 或其他 props 传递给客户端组件。

// app/client-wrapper.tsx
'use client'

export default function ClientWrapper({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="wrapper">
      {children}
    </div>
  )
}
// app/page.tsx
import ClientWrapper from './client-wrapper'
import ServerComponent from './server-component'

export default function Page() {
  return (
    <ClientWrapper>
      <ServerComponent />
    </ClientWrapper>
  )
}

从客户端组件导入服务器组件(不支持)

不能直接在客户端组件中导入服务器组件。

// 不能在客户端组件中导入服务器组件
'use client'

import ServerComponent from './server-component'

export default function ClientComponent() {
  return (
    <div>
      <ServerComponent />
    </div>
  )
}

正确做法: 将服务器组件作为 props 传递

// 将服务器组件作为 children 传递
'use client'

export default function ClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  return <div>{children}</div>
}

第三方组件

第三方组件可能没有 "use client" 指令。可以将它们包装在自己的客户端组件中。

// app/components/carousel-wrapper.tsx
'use client'

import { Carousel } from 'acme-carousel'

export default function CarouselWrapper() {
  return <Carousel />
}
// app/page.tsx
import CarouselWrapper from './components/carousel-wrapper'

export default function Page() {
  return (
    <div>
      <CarouselWrapper />
    </div>
  )
}

库作者建议:

如果正在构建组件库,在依赖客户端功能的入口点添加 "use client" 指令。这允许用户直接将组件导入服务器组件,而无需创建包装器。

Context Providers

Context providers 通常在应用根部渲染以共享全局状态。由于服务器组件不支持 React context,在根部创建 context 会导致错误。

解决方案: 在客户端组件中创建 context 和 provider。

// app/providers.tsx
'use client'

import { createContext, useContext, useState } from 'react'

const ThemeContext = createContext<{
  theme: string
  setTheme: (theme: string) => void
}>({
  theme: 'light',
  setTheme: () => {},
})

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light')

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export const useTheme = () => useContext(ThemeContext)
// app/layout.tsx
import { ThemeProvider } from './providers'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

防止环境污染

使用 server-onlyclient-only 包防止服务器代码在客户端运行,反之亦然。

server-only

标记仅服务器代码,防止意外在客户端使用。

npm install server-only
// lib/data.ts
import 'server-only'

export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })

  return res.json()
}

现在,如果在客户端组件中导入 getData,会在构建时报错。

client-only

标记仅客户端代码,防止在服务器上运行。

npm install client-only
// lib/client-utils.ts
import 'client-only'

export function getLocalStorage(key: string) {
  return localStorage.getItem(key)
}

注意:

  • 在 Next.js 中,安装 server-onlyclient-only 是可选的
  • Next.js 内部处理这些导入以提供更清晰的错误消息
  • Next.js 为 TypeScript 配置提供了自己的类型声明

实际应用示例

博客应用

// app/blog/page.tsx (服务器组件)
import { db } from '@/lib/db'
import LikeButton from './like-button'

export default async function BlogPage() {
  const posts = await db.post.findMany()

  return (
    <div>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
          <LikeButton postId={post.id} initialLikes={post.likes} />
        </article>
      ))}
    </div>
  )
}
// app/blog/like-button.tsx (客户端组件)
'use client'

import { useState } from 'react'

export default function LikeButton({
  postId,
  initialLikes,
}: {
  postId: string
  initialLikes: number
}) {
  const [likes, setLikes] = useState(initialLikes)
  const [liked, setLiked] = useState(false)

  const handleLike = async () => {
    if (liked) return

    setLikes(likes + 1)
    setLiked(true)

    await fetch(`/api/posts/${postId}/like`, { method: 'POST' })
  }

  return (
    <button onClick={handleLike} disabled={liked}>
      {liked ? '已点赞' : '点赞'} ({likes})
    </button>
  )
}

仪表板应用

// app/dashboard/page.tsx (服务器组件)
import { getUser, getStats } from '@/lib/data'
import Chart from './chart'
import RefreshButton from './refresh-button'

export default async function Dashboard() {
  const user = await getUser()
  const stats = await getStats()

  return (
    <div>
      <h1>欢迎,{user.name}</h1>
      <Chart data={stats} />
      <RefreshButton />
    </div>
  )
}
// app/dashboard/chart.tsx (客户端组件)
'use client'

import { useEffect, useRef } from 'react'

export default function Chart({ data }: { data: any[] }) {
  const canvasRef = useRef<HTMLCanvasElement>(null)

  useEffect(() => {
    if (!canvasRef.current) return

    const ctx = canvasRef.current.getContext('2d')
    // 绘制图表逻辑
  }, [data])

  return <canvas ref={canvasRef} />
}
// app/dashboard/refresh-button.tsx (客户端组件)
'use client'

import { useRouter } from 'next/navigation'

export default function RefreshButton() {
  const router = useRouter()

  return (
    <button onClick={() => router.refresh()}>
      刷新数据
    </button>
  )
}

表单应用

// app/contact/page.tsx (服务器组件)
import ContactForm from './contact-form'

export default function ContactPage() {
  return (
    <div>
      <h1>联系我们</h1>
      <ContactForm />
    </div>
  )
}
// app/contact/contact-form.tsx (客户端组件)
'use client'

import { useState } from 'react'

export default function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: '',
  })
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setStatus('loading')

    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      })

      if (response.ok) {
        setStatus('success')
        setFormData({ name: '', email: '', message: '' })
      } else {
        setStatus('error')
      }
    } catch (error) {
      setStatus('error')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={formData.name}
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
        placeholder="姓名"
        required
      />
      <input
        type="email"
        value={formData.email}
        onChange={(e) => setFormData({ ...formData, email: e.target.value })}
        placeholder="邮箱"
        required
      />
      <textarea
        value={formData.message}
        onChange={(e) => setFormData({ ...formData, message: e.target.value })}
        placeholder="消息"
        required
      />
      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? '发送中...' : '发送'}
      </button>
      {status === 'success' && <p>消息已发送!</p>}
      {status === 'error' && <p>发送失败,请重试。</p>}
    </form>
  )
}

最佳实践

1. 尽可能使用服务器组件

服务器组件是默认选择,提供更好的性能和 SEO。

// ✓ 默认使用服务器组件
export default async function Page() {
  const data = await fetchData()
  return <div>{data}</div>
}

2. 将客户端组件推向叶子节点

将客户端组件尽可能推向组件树的叶子节点。

// ✓ 推荐:仅交互部分是客户端组件
import SearchBar from './search-bar' // 客户端组件

export default function Page() {
  return (
    <div>
      <h1>产品</h1>
      <SearchBar />
      {/* 其余内容是服务器组件 */}
    </div>
  )
}
// ✗ 避免:整个页面是客户端组件
'use client'

export default function Page() {
  return (
    <div>
      <h1>产品</h1>
      <SearchBar />
    </div>
  )
}

3. 通过 Props 传递服务器组件

利用组合模式将服务器组件作为 props 传递。

'use client'

export default function ClientLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return <div className="layout">{children}</div>
}

4. 使用 server-only 保护敏感代码

使用 server-only 包防止服务器代码泄露到客户端。

import 'server-only'

export async function getSecretData() {
  // 使用 API 密钥
  return fetch(url, {
    headers: { Authorization: process.env.API_KEY },
  })
}

5. 序列化 Props

客户端组件的 props 必须可序列化。

// ✓ 可序列化的 props
<ClientComponent data={{ name: 'John', age: 30 }} />

// ✗ 不可序列化的 props
<ClientComponent onClick={() => {}} /> // 函数
<ClientComponent date={new Date()} /> // Date 对象

总结

Next.js 的服务器组件和客户端组件系统提供了强大而灵活的架构:

  • 服务器组件:默认选择,用于数据获取、SEO 和减少客户端 JavaScript
  • 客户端组件:用于交互性、状态管理和浏览器 API
  • 组合模式:在服务器组件中嵌套客户端组件,通过 props 传递服务器组件
  • 环境隔离:使用 server-onlyclient-only 防止代码在错误环境中运行
  • 性能优化:将客户端组件推向叶子节点,最大化服务器渲染的优势

理解何时使用每种组件类型以及如何组合它们,是构建高性能 Next.js 应用的关键。

在 GitHub 上编辑

上次更新于