跳到主要内容

Next.js 速查表:搞懂 App Router 里 Server 和 Client 组件的边界

用真实代码讲清 Next.js 15 App Router 的 Server/Client 组件边界、'use client' 该往哪放、为什么放错位置会让 200KB 的库塞进首屏 bundle。

发布于
#nextjs #react #app-router #server-components #前端

Next.js 速查表:搞懂 App Router 里 Server 和 Client 组件的边界

我从 Pages Router 迁到 App Router 的第一周,踩过一个很蠢的坑:在一个布局组件最顶层写了 'use client',结果它底下整棵子树(包括一个本来纯静态的文章正文)全被打包进了浏览器 bundle。Lighthouse 一跑,首屏 JS 从 90KB 涨到了 310KB。问题不在代码逻辑,在于我没搞懂这条边界到底是怎么传染的。

这篇就把 App Router 里最容易出错的几个边界规则讲清楚,配真实代码。需要随时查具体 API 的话,可以开着 Next.js 速查表,里面 80 多条都带可运行片段。

默认是 Server Component,这件事比你以为的更重要

App Router 下,app/ 目录里每个组件默认都是 Server Component。它在服务端渲染成 HTML,不带任何交互 JS 下发到浏览器。只有当你需要 useStateuseEffect、事件监听、或浏览器专属 API(windowlocalStorage)时,才在文件顶部加 'use client'

关键数字:React 官方团队在 2023 年的演示里给过一个对比:一个用 date-fns 格式化时间的组件,作为 Server Component 时下发到客户端的 JS 是 0 字节;一旦标成 Client Component,date-fns 的相关代码(约 70KB 未压缩)就会进 bundle。库越大差距越夸张,像 moment 这种带 locale 的能轻松超过 200KB。

所以默认 Server、按需 Client,不是风格偏好,是直接换算成下载体积的决策。

'use client' 是边界,不是开关,它会向下传染

最反直觉的一点:'use client' 标记的不是"这一个文件",而是"从这里往下整棵导入树的入口"。一旦某个文件声明了 'use client',它 import 的所有组件自动都变成 Client Component,你不需要、也不该在子组件里重复写。

看这个反面例子。我当初写的布局长这样:

// app/layout.tsx
'use client'   // ← 灾难就在这一行

import { Sidebar } from './sidebar'
import { ArticleBody } from './article-body'  // 纯静态正文,被连累了

export default function Layout({ children }) {
  const [open, setOpen] = useState(false)  // 只有侧边栏需要它
  return (
    <div>
      <Sidebar open={open} onToggle={() => setOpen(!open)} />
      <ArticleBody />
      {children}
    </div>
  )
}

ArticleBody 根本不需要交互,却因为被一个 Client 文件 import,整个被推进了客户端 bundle。

正确做法是把 'use client' 下推到真正需要状态的那个叶子组件:

// app/layout.tsx:保持 Server Component,不写 'use client'
import { Sidebar } from './sidebar'
import { ArticleBody } from './article-body'

export default function Layout({ children }) {
  return (
    <div>
      <Sidebar />        {/* Sidebar 内部自己处理 client 逻辑 */}
      <ArticleBody />    {/* 留在服务端,0 JS */}
      {children}
    </div>
  )
}

// app/sidebar.tsx
'use client'
import { useState } from 'react'

export function Sidebar() {
  const [open, setOpen] = useState(false)
  return <nav data-open={open} onClick={() => setOpen(!open)}>...</nav>
}

改完后我那个页面的首屏 JS 从 310KB 回落到 96KB,只有真正交互的 Sidebar 进了 bundle。

如果你需要查 useStateuseEffect 这些 hook 在 Client 组件里的具体写法,React Hooks 速查表 把每个 hook 的常见陷阱都列了出来。

Server 组件里能直接 await,Client 组件不行

另一个边界差异:Server Component 可以是 async 函数,直接在组件体里 await 取数据,不需要 useEffect + fetch 那套。

// app/posts/page.tsx:Server Component
export default async function PostsPage() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 }   // 60 秒缓存,Next.js 扩展的 fetch 选项
  })
  const posts = await res.json()
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}

输入:fetch 返回 [{id:1,title:"Hello"},{id:2,title:"World"}] 输出 HTML(服务端渲染好直接吐出):

<ul><li>Hello</li><li>World</li></ul>

注意 next: { revalidate: 60 } 是 Next.js 给原生 fetch 打的补丁,标准浏览器 fetch 没有这个选项。Client Component 里你不能把组件写成 async,得退回 useEffect 或用 use() hook 配合 Suspense。

传给 Client 组件的 props 必须可序列化

Server Component 可以渲染 Client Component 并传 props,但这些 props 要跨越服务端到客户端的边界,必须能被序列化:字符串、数字、数组、普通对象都行,但函数不行。

// app/page.tsx:Server Component
import { LikeButton } from './like-button'

export default function Page() {
  return <LikeButton postId={42} initialLikes={10} />  // ✅ 数字,可序列化
  // return <LikeButton onLike={() => save()} />        // ❌ 函数,运行时报错
}

报的错很明确:Functions cannot be passed directly to Client Components。需要回调时,要么在 Client 组件内部定义,要么用 Server Action(一个标了 'use server' 的 async 函数,Next.js 会帮你把它序列化成一个可调用的引用)。这些细节连同 [slug][...slug]、平行路由 @slot 的写法,Next.js 速查表 里都有现成片段可以直接抄。

写组件 props 类型时如果你想把后端返回的 JSON 直接转成 TS 接口,JSON 转 TypeScript 接口 能省掉手敲类型的功夫。

一句话记住边界

  • 默认 Server,需要交互才 Client。
  • 'use client' 往下传染,所以把它推到最靠近状态的叶子组件。
  • Server 组件能 async await,Client 组件不能。
  • 跨边界的 props 必须可序列化,函数靠 Server Action 传。

这四条记牢,App Router 里 90% 的 bundle 膨胀和"Functions cannot be passed"报错都不会再找上你。


Made by Toolora · Updated 2026-06-06