切换语言
切换主题

Next.js App Router + shadcn/ui:服务端与客户端组件混用指南

凌晨三点,盯着屏幕上的报错信息:Error: You're importing a component that needs useEffect. It only works in a Client Component but none of its parents are marked with "use client"

我已经在 layout.tsx 里加了 "use client",为什么还是报错?

翻了半天文档,才发现问题出在组件导入的边界。App Router 的 Server Components 和 Client Components 分界线,远比我想象的复杂。

这就是很多开发者迁移到 App Router 时遇到的真实场景。框架默认所有组件都是 Server Component,但 UI 库(比如 shadcn/ui)大部分又需要 Client Component。两者的边界该怎么划分?数据怎么流转?性能怎么优化?

这篇文章就是要把这些问题彻底说清楚。


Server Components vs Client Components:根本区别

先说个最基本的点:App Router 下,所有组件默认都是 Server Component

这意味着什么?你的 page.tsx、layout.tsx 默认都在服务器端渲染,不会向浏览器发送任何 JavaScript 代码。

Server Components 能做什么

Server Components 的核心优势是”离数据更近”:

// app/products/page.tsx - Server Component (默认)
async function ProductsPage() {
  // 直接在组件里 await 数据
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 } // 缓存1小时
  }).then(res => res.json())

  return (
    <div>
      {products.map(p => (
        <div key={p.id}>{p.name} - ${p.price}</div>
      ))}
    </div>
  )
}

你看,没有 useEffect,没有 useState,直接 await 就能拿数据。这就是 Server Components 的”async 组件”特性。

适用场景

  • 数据获取(fetch、数据库查询)
  • 访问后端专属 API(headers()、cookies())
  • 大型依赖库(比如 markdown 解析器 100KB+,用 Server Component 就不用打包给浏览器)
  • 敏感信息处理(API key 永远不暴露给前端)

Client Components 能做什么

Client Components 是我们熟悉的”传统 React 组件”。只要文件顶部加上 "use client"

// components/like-button.tsx
'use client'

import { useState } from 'react'

export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false)
  const [count, setCount] = useState(0)

  const handleClick = () => {
    setLiked(!liked)
    setCount(prev => liked ? prev - 1 : prev + 1)
  }

  return (
    <button onClick={handleClick}>
      {liked ? '❤️' : '🤍'} {count}
    </button>
  )
}

适用场景

  • 事件处理(onClick、onChange、onSubmit)
  • React hooks(useState、useEffect、useRef、useContext)
  • 浏览器 API(localStorage、window、document)
  • Context Provider

有个反直觉的点:Client Components 也会在服务器端预渲染 HTML。只是后续会在浏览器端 hydrate,恢复交互能力。所以用户首次访问时,依然能看到完整内容,不会”白屏等待 JS 加载”。


核心规则:谁可以导入谁

这部分最容易踩坑。

规则很简单,但很多人记反:

  1. Server Component 可以导入 Client Component
  2. Client Component 不能导入 Server Component
  3. Server Component 可以作为 children 传给 Client Component

第三条有点绕,看代码就明白了:

// app/page.tsx - Server Component
import { ClientContainer } from './client-container'
import { ServerData } from './server-data'

export default function Page() {
  return (
    <ClientContainer>
      {/* ServerData 作为 children 传进去 */}
      <ServerData />
    </ClientContainer>
  )
}

// client-container.tsx
'use client'

export function ClientContainer({ children }) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}
    </div>
  )
}

// server-data.tsx - Server Component
async function ServerData() {
  const data = await fetch('/api/data').then(r => r.json())
  return <div>{data.title}</div>
}

这个模式很常见:Client Container 负责交互逻辑,Server Data 负责数据获取。两者通过 children 隔离,不直接导入。


shadcn/ui 集成:为什么这么”麻烦”

shadcn/ui 是我最喜欢的 UI 库,但在 App Router 里用它确实需要点技巧。

根本原因是:shadcn/ui 基于 Radix UI,大部分组件用了 React hooks

比如 Button、Dialog、Dropdown Menu 这些,内部都有 useStateuseEffect。所以它们必须是 Client Component。

错误示范:在 Server Component 直接用 shadcn/ui

// ❌ 错误:Server Component 导入 Client Component
import { Button } from '@/components/ui/button'

async function ProductPage() {
  const product = await fetchProduct()

  return (
    <div>
      <h1>{product.name}</h1>
      {/* 这会报错:Button 需要 "use client" */}
      <Button onClick={() => addToCart(product.id)}>
        Add to Cart
      </Button>
    </div>
  )
}

报错信息:Button 使用了 useState,必须标记 "use client"

正确方案1:交互部分提取为 Client Component

最常用也最简单的方案:

// app/product/page.tsx - Server Component
import { ProductInfo } from './product-info'
import { AddToCartButton } from './add-to-cart-button'

async function ProductPage({ params }) {
  const product = await fetchProduct(params.id)

  return (
    <div>
      {/* Server Component:负责数据展示 */}
      <ProductInfo product={product} />

      {/* Client Component:负责交互 */}
      <AddToCartButton productId={product.id} />
    </div>
  )
}

// product-info.tsx - Server Component
export function ProductInfo({ product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span>${product.price}</span>
    </div>
  )
}

// add-to-cart-button.tsx - Client Component
'use client'

import { Button } from '@/components/ui/button'
import { useState } from 'react'

export function AddToCartButton({ productId }) {
  const [loading, setLoading] = useState(false)

  const handleAdd = async () => {
    setLoading(true)
    await addToCart(productId)
    setLoading(false)
  }

  return (
    <Button onClick={handleAdd} disabled={loading}>
      {loading ? 'Adding...' : 'Add to Cart'}
    </Button>
  )
}

核心思路:把需要交互的部分单独提取成叶子节点,其他部分保持 Server Component。

正确方案2:组合模式(Server 传数据给 Client)

如果 Client Component 需要初始数据:

// app/dashboard/page.tsx - Server Component
import { DataTable } from './data-table'

async function DashboardPage() {
  const users = await fetchUsers() // Server Component 获取数据

  return <DataTable data={users} /> // 传给 Client Component
}

// data-table.tsx - Client Component
'use client'

import { Table } from '@/components/ui/table'
import { useState } from 'react'

export function DataTable({ data }) {
  const [selectedRows, setSelectedRows] = useState([])

  return (
    <Table>
      {/* shadcn/ui Table 组件 */}
      <TableBody>
        {data.map(user => (
          <TableRow
            key={user.id}
            selected={selectedRows.includes(user.id)}
            onClick={() => toggleSelection(user.id)}
          >
            <TableCell>{user.name}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

这样既享受了 Server Component 的数据获取优势,又保留了 Client Component 的交互能力。


Context Provider 怎么放

另一个常见困惑:全局 Context Provider(比如 ThemeProvider、AuthProvider)应该放在哪?

答案是:必须放在 Client Component,但要”越深越好”

// app/layout.tsx - Server Component (root layout)
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {/* 不要在这里放 Provider */}
        {children}
      </body>
    </html>
  )
}

// app/providers.tsx - Client Component
'use client'

import { ThemeProvider } from 'next-themes'
import { AuthProvider } from './auth-context'

export function Providers({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        {children}
      </AuthProvider>
    </ThemeProvider>
  )
}

// app/dashboard/layout.tsx - Server Component
import { Providers } from '../providers'

export default function DashboardLayout({ children }) {
  return (
    <Providers>
      {children}
    </Providers>
  )
}

为什么要”深”?因为 Provider 会把它包裹的所有组件都变成 Client Component 的子树。如果放在 root layout,整个应用都会被迫客户端渲染。

放在更深的层级(比如某个特定路由的 layout),就能把 Provider 的影响范围最小化。


数据流:Server 到 Client 的传递

Props 是最简单也最可靠的方式:

// Server Component 拿数据
const data = await fetchData()

// 传给 Client Component
<ClientComponent initialData={data} />

但有个性能优化的点:React.cache() 函数

如果多个 Server Component 需要同一份数据,可以用 cache 避免重复请求:

// lib/get-user.ts
import { cache } from 'react'

export const getUser = cache(async (id: string) => {
  return await db.query('SELECT * FROM users WHERE id = ?', [id])
})

// app/layout.tsx
async function Layout() {
  const user = await getUser('123') // 第一次请求
  return <header>{user.name}</header>
}

// app/page.tsx
async function Page() {
  const user = await getUser('123') // 相同参数,不会重复请求
  return <main>Welcome {user.name}</main>
}

cache 会在单次渲染周期内,自动去重相同参数的调用。


四个最常见的错误

错误1:在高层级滥用 “use client”

// ❌ app/layout.tsx 加了 "use client"
'use client'

export default function Layout({ children }) {
  return <div>{children}</div>
}

这会让整个应用的子树都变成 Client Component,丧失 Server Component 的性能优势。

修复:只在真正需要交互的组件添加 "use client",保持它在叶子节点。

错误2:Server Component 里用 hooks

// ❌ Server Component 使用 useState
async function Page() {
  const [count, setCount] = useState(0) // 报错!
  return <div>{count}</div>
}

修复:提取需要 hooks 的部分为 Client Component。

错误3:Client Component 里用 headers()/cookies()

// ❌ Client Component 使用服务器 API
'use client'

import { headers } from 'next/headers'

function UserProfile() {
  const headersList = headers() // 报错!只能在 Server Component 用
  return <div>...</div>
}

修复:在 Server Component 获取数据,再传递给 Client Component:

// Server Component 获取 headers
async function Page() {
  const userAgent = headers().get('user-agent')
  return <UserProfile userAgent={userAgent} />
}

// Client Component 接收数据
'use client'
function UserProfile({ userAgent }) {
  return <div>Browser: {userAgent}</div>
}

错误4:第三方组件没标记 “use client”

// ❌ Server Component 导入未标记的第三方组件
import { AcmeCarousel } from 'acme-carousel'

async function Page() {
  return <AcmeCarousel /> // 报错!AcmeCarousel 内部用了 hooks
}

修复:创建一个 wrapper:

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

import { AcmeCarousel } from 'acme-carousel'

export function CarouselWrapper(props) {
  return <AcmeCarousel {...props} />
}

// page.tsx - Server Component
import { CarouselWrapper } from './carousel-wrapper'

async function Page() {
  return <CarouselWrapper /> // 正常工作
}

性能优化建议

最后几个实用技巧:

1. Client Components 放叶子节点

这条规则能减少 70% 的客户端 JavaScript。

比如一个产品列表页面:

  • 产品网格:Server Component
  • 每个产品卡片:Server Component
  • 卡片上的数量选择器:Client Component(唯一的交互部分)

2. 用 Suspense 流式渲染

// app/page.tsx
import { Suspense } from 'react'
import { ProductList } from './product-list'
import { Recommendations } from './recommendations'

export default function Page() {
  return (
    <div>
      {/* 先显示骨架屏,数据到了再替换 */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductList />
      </Suspense>

      {/* Secondary content 独立流式渲染 */}
      <Suspense fallback={<RecSkeleton />}>
        <Recommendations />
      </Suspense>
    </div>
  )
}

用户先看到页面框架,数据再逐步填充。体验比”等待所有数据加载”好得多。

3. fetch 缓存策略

// 静态数据(构建时获取)
await fetch(url, { cache: 'force-cache' })

// ISR:每小时重新验证
await fetch(url, { next: { revalidate: 3600 } })

// 动态数据(每次请求都获取)
await fetch(url, { cache: 'no-store' })

合理选择缓存策略,避免过度动态渲染。


总结

说了这么多,核心就几条:

  1. 默认用 Server Components,只在需要交互时才用 Client Components
  2. Server 可以导入 Client,但 Client 不能导入 Server
  3. 通过 children 或 props 传递数据,保持边界清晰
  4. “use client” 加在叶子节点,别在高层级滥用
  5. shadcn/ui 组件单独提取,别混在 Server Component 里

App Router 的 Server/Client 分界线,设计初衷是让开发者”离数据更近,离浏览器更远”。理解了这点,很多困惑就迎刃而解了。

建议你从简单页面开始实践:先写 Server Component 获取数据,再逐步添加交互部分。遇到报错别慌,多半是边界问题——检查一下组件导入关系,很快就能定位。

正确混用 Server 和 Client Components

在 Next.js App Router 项目中集成 shadcn/ui 的最佳实践

⏱️ 预计耗时: 30 分钟

  1. 1

    步骤1: 识别组件类型需求

    判断每个组件是否需要交互功能:

    • 需要事件处理(onClick、onChange) → Client Component
    • 需要 React hooks(useState、useEffect) → Client Component
    • 需要浏览器 API(localStorage、window) → Client Component
    • 仅数据展示、无交互 → Server Component(默认)
  2. 2

    步骤2: 提取交互部分为叶子节点

    将需要交互的部分单独提取为 Client Component:

    • 创建新文件,顶部添加 'use client'
    • 导入 shadcn/ui 组件(Button、Dialog 等)
    • 在 Server Component 中导入这个 Client Component
    • 通过 props 传递数据
  3. 3

    步骤3: 设计数据流

    Server Component 获取数据,传给 Client Component:

    • Server Component 使用 async/await 获取数据
    • 通过 props 传递给 Client Component
    • 多处需要相同数据时,使用 React.cache() 避免重复请求
    • 避免在 Client Component 直接使用 headers()/cookies()
  4. 4

    步骤4: 放置 Context Provider

    Provider 必须是 Client Component,但要放在深层 layout:

    • 创建 providers.tsx,标记 'use client'
    • 包裹 ThemeProvider、AuthProvider 等
    • 在特定路由的 layout.tsx 导入(而非 root layout)
    • 最小化 Client Component 子树范围
  5. 5

    步骤5: 验证和优化

    检查组件边界是否正确:

    • 确保 'use client' 只在叶子节点
    • 检查没有 Client Component 导入 Server Component
    • 使用 Suspense 包裹异步组件
    • 配置合理的 fetch 缓存策略

常见问题

为什么 shadcn/ui 组件必须用 Client Component?
shadcn/ui 基于 Radix UI,大部分组件内部使用了 React hooks(如 useState、useContext)来管理状态、处理交互事件。这些 hooks 只能在浏览器环境中运行,所以需要标记 'use client'。
Server Component 和 Client Component 能互相导入吗?
Server Component 可以导入 Client Component,但 Client Component 不能导入 Server Component。不过,可以通过 children 属性将 Server Component 作为内容传递给 Client Component,这样两者就能协同工作。
如何避免在多个 Server Component 中重复请求相同数据?
使用 React.cache() 函数包装数据获取逻辑。相同参数的调用在单次渲染周期内会自动去重,避免重复的数据库查询或 API 请求。
Context Provider 应该放在哪里?
Provider 必须是 Client Component(因为依赖 React Context),但不要放在 root layout。建议在特定路由的 layout.tsx 中导入 Provider,这样可以最小化 Client Component 子树的范围,保留更多组件的 Server Component 优势。
遇到 'useEffect 只能在 Client Component 使用' 的错误怎么办?
检查报错组件的导入链:找到使用了 hooks 或事件处理的组件,在其文件顶部添加 'use client'。如果是第三方组件未标记,创建一个 wrapper 组件,标记 'use client' 后再导入。
如何判断一个组件应该是 Server 还是 Client Component?
简单判断:需要 onClick、onChange 等交互 → Client;需要 useState、useEffect 等 hooks → Client;需要 localStorage、window 等浏览器 API → Client;其他情况默认 Server Component。

系列归属:本文属于 Next.js 完全指南 系列(第 46 篇),如果你在学习 Next.js App Router,可以看看系列的其他文章。关于 shadcn/ui 的更多实战技巧,推荐阅读 Tailwind 与 shadcn/ui 实战指南 系列。

7 分钟阅读 · 发布于: 2026年3月31日 · 修改于: 2026年3月31日

评论

使用 GitHub 账号登录后即可评论

相关文章