Next.js第五课 - 服务端与客户端组件

6 阅读6分钟

前几节我们了解了布局、页面和导航,本节来深入探讨 Next.js 中最重要的概念之一:服务端组件和客户端组件。理解这两者的区别和使用场景,是掌握 Next.js 的关键。

核心概念

在 Next.js App Router 中,所有组件默认是服务端组件。很多从传统 React 过渡过来的开发者可能会不太习惯,但这个设计能带来很多好处。

服务端组件

服务端组件在服务器上渲染,有几个显著特点:

  1. 可以直接访问数据库、文件系统等后端资源
  2. 减少发送到客户端的 JavaScript 代码
  3. 保持敏感代码安全,不会暴露给客户端
  4. 不能使用 React Hooks(useState, useEffect 等)

客户端组件

使用 'use client' 指令标记的组件就是客户端组件,它们在浏览器中渲染,特点包括:

  1. 可以使用 React Hooks(useState, useEffect 等)
  2. 可以处理浏览器事件(onClick, onChange 等)
  3. 可以访问浏览器 API(window, localStorage 等)
  4. 会增加发送到客户端的 JavaScript 代码量

服务端组件详解

基本用法

最简单的服务端组件就是一个普通的函数组件,不需要任何特殊标记:

// app/users/page.tsx - 服务器组件(默认)
async function getUsers() {
  const res = await fetch('https://api.example.com/users', {
    next: { revalidate: 3600 },
  })
  return res.json()
}

export default async function UsersPage() {
  const users = await getUsers()

  return (
    <div>
      <h1>用户列表</h1>
      <ul>
        {users.map((user: any) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  )
}

直接访问数据库

服务端组件最大的优势之一就是可以直接访问数据库,不需要创建 API 层:

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

export default async function PostsPage() {
  const posts = await db.post.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10,
  })

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

服务端组件的优势

使用服务端组件有几个明显的好处:

// 1. 减少客户端 JavaScript
// 这个组件不会发送任何 JS 到浏览器
export default function ServerComponent() {
  return <div>纯 HTML,无 JS</div>
}

// 2. 保持数据访问安全
// API 密钥不会暴露给客户端
const API_KEY = process.env.SECRET_KEY // 仅服务器可用

export async function SecureComponent() {
  const data = await fetch(`https://api.com?key=${API_KEY}`)
  return <div>{/* 私密数据 */}</div>
}

// 3. 大依赖包留在服务器
import { BigLibrary } from 'big-library' // 不会发送到客户端

export default function HeavyComponent() {
  const result = BigLibrary.process()
  return <div>{result}</div>
}

服务端组件的限制

服务端组件有一些限制需要注意:

// 不能使用 React Hooks
import { useState } from 'react' // 错误

export default function ServerComponent() {
  const [count, setCount] = useState(0) // 错误!
  return <div>{count}</div>
}

// 不能使用浏览器事件
export default function ServerComponent() {
  return (
    <button onClick={() => console.log('click')}>
      点击
    </button>
  )
}

// 不能使用浏览器 API
export default function ServerComponent() {
  const width = window.innerWidth // 错误!
  return <div>{width}</div>
}

客户端组件详解

使用 'use client' 指令

如果需要使用 React Hooks 或浏览器 API,需要在文件顶部添加 'use client' 指令:

// app/counter/page.tsx
'use client' // 必须在文件顶部

import { useState } from 'react'

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

  return (
    <div>
      <h1>计数器: {count}</h1>
      <button onClick={() => setCount(count + 1)}>增加</button>
      <button onClick={() => setCount(count - 1)}>减少</button>
    </div>
  )
}

使用浏览器 API

客户端组件可以访问浏览器的各种 API:

'use client'

import { useState, useEffect } from 'react'

export default function Geolocation() {
  const [location, setLocation] = useState(null)

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

  return (
    <div>
      {location ? (
        <p>位置: {location.lat}, {location.lng}</p>
      ) : (
        <p>获取位置中...</p>
      )}
    </div>
  )
}

处理用户交互

表单处理这类需要交互的功能必须用客户端组件:

'use client'

import { useState } from 'react'

export default function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: '',
  })

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value,
    })
  }

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    await fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify(formData),
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        name="name"
        value={formData.name}
        onChange={handleChange}
      />
      <button type="submit">提交</button>
    </form>
  )
}

混用组件

在实际开发中,我们经常需要混用服务端组件和客户端组件。推荐的做法是:大部分组件用服务端,只有需要交互的部分用客户端。

服务端组件导入客户端组件

这是最常见也是最推荐的模式:

// app/page.tsx - 服务器组件
import InteractiveButton from '@/components/InteractiveButton'

export default function Page() {
  return (
    <div>
      <h1>欢迎</h1>
      <InteractiveButton />
    </div>
  )
}

// components/InteractiveButton.tsx - 客户端组件
'use client'

import { useState } from 'react'

export default function InteractiveButton() {
  const [count, setCount] = useState(0)
  return (
    <button onClick={() => setCount(count + 1)}>
      点击了 {count} 次
    </button>
  )
}

传递数据给客户端组件

服务端组件获取数据后传递给客户端组件:

// app/posts/[id]/page.tsx - 服务器组件
import CommentSection from '@/components/CommentSection'

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await fetchPost(params.id)

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <CommentSection postId={post.id} initialCount={post.commentsCount} />
    </article>
  )
}

组件边界模式

在实际项目中,推荐采用"叶子客户端组件"的模式,就是把交互性限制在组件树的最底层:

// components/PostList.tsx - 服务器组件
// 整个列表在服务器渲染
import PostCard from './PostCard'

export default async function PostList() {
  const posts = await getPosts()

  return (
    <div>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  )
}

// components/PostCard.tsx - 客户端组件
// 只有交互部分是客户端组件
'use client'

export default function PostCard({ post }: { post: Post }) {
  const [liked, setLiked] = useState(false)

  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.excerpt}</p>
      <button onClick={() => setLiked(!liked)}>
        {liked ? '已点赞' : '点赞'}
      </button>
    </article>
  )
}

决策指南

很多初学者会困惑,什么时候用服务端组件,什么时候用客户端组件?这里有个简单的判断标准:

使用服务端组件的场景:

  • 需要获取数据
  • 需要直接访问后端资源(数据库、文件系统)
  • 需要保持敏感信息安全
  • 希望减少客户端 JavaScript
  • 需要提升性能(大依赖包)
  • 需要 SEO 优化

使用客户端组件的场景:

  • 需要使用 React Hooks(useState, useEffect 等)
  • 需要处理浏览器事件(onClick, onChange 等)
  • 需要访问浏览器 API(window, localStorage 等)
  • 需要状态管理
  • 需要使用第三方库(需要浏览器环境)

实用建议

这里分享几个在实际项目中特别有用的技巧。

默认使用服务端组件

实际开发中,除非有明确的交互需求,否则建议默认使用服务端组件。这个简单的小习惯能让你的应用性能提升不少:

// 推荐这样做 - 默认使用服务端组件
export default function Component() {
  return <div>内容</div>
}

// 除非真的需要交互,才添加 'use client'
'use client'
export default function Component() {
  return <div>内容</div>
}

将交互性推向边缘

这里有个小建议:尽量把交互性限制在组件树的最底层。这个技巧在实际开发中特别有用:

// 这样写更好 - 只有按钮是客户端组件
export default function Page() {
  return (
    <div>
      <h1>标题</h1>
      <p>内容</p>
      <LikeButton />
    </div>
  )
}

// 避免这种情况 - 整个页面都变成客户端组件
'use client'
export default function Page() {
  return (
    <div>
      <h1>标题</h1>
      <p>内容</p>
      <LikeButton />
    </div>
  )
}

总结

本节我们深入了解了服务端组件和客户端组件的区别、使用场景以及最佳实践。掌握好这两种组件的使用,能让你的应用性能更好、代码更简洁。记住一个原则:能默认用服务端就用服务端,只在需要交互时才用客户端。

如果你对本节内容有任何疑问,欢迎在评论区提出来,我们一起学习讨论。

原文地址: blog.uuhb.cn/archives/ne…