彻底搞懂 'use client':Next.js 组件根本不是"只在浏览器渲染"
一个 Todo 项目,让你看清 Server Component 和 Client Component 之间那条被大多数人误解的边界。
先看一行注释,它错得很有代表性
最近我在一个 Next.js 全栈项目里写了这么一段,app/todos/page.tsx 顶部:
javascript
// react 可以在后端运行
// 组件在前端渲染的印记
'use client';
import { useState, useEffect } from 'react';
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([]);
// ...
}
看到没,我把 'use client' 理解成了「组件在前端渲染的印记」。
听起来合情合理对吧?加了这个标记 → 组件就只跑在浏览器里。我当时就是这么想的,直到我 curl 了一下这个页面:
bash
curl http://localhost:3000/todos
返回的 HTML 里,<h1>待办事项</h1>、那个 <input> 输入框、空 <ul> 列表,整整齐齐地躺在里面。
我懵了:我不是声明了 'use client' 吗?它不是该老老实实待在浏览器里,等 JS 加载完再渲染吗?服务器怎么把结构都吐出来了?
这篇文章就是解决这个误解的。 看完你就知道 'use client' 到底改变了什么、没改变什么,以及那个被无数人挂在嘴边却未必真懂的词------水合(hydration)。
核心:Next.js 里组件默认是"服务器优先"
先记住一句话,这句话是整个 App Router 心智模型的地基:
在 Next.js App Router 里,组件默认是 Server Component(在服务器上渲染);只有当你主动写
'use client',它才成为一个 Client Component。
对照我们项目里的两个文件,差别一目了然:
app/page.tsx(首页)------没有 'use client':
javascript
// 服务器端组件
// 在服务器端 react node 的方式运行
// jsx->html
export default function Home() {
return <h1>Hello World</h1>;
}
app/todos/page.tsx------有 'use client',因为里面用了 useState、useEffect、onClick。
为什么要分这两种?因为 Next.js 想同时拿到两个好处:
| Server Component(默认) | Client Component('use client') | |
|---|---|---|
| 在哪里渲染 | 只在服务器 | 服务器 + 浏览器(两次) |
| 能直接用 hooks / 事件吗 | ❌ 不能 | ✅ 能 |
| 会打包 JS 发给浏览器吗 | ❌ 不发 | ✅ 发 |
| 首屏 HTML | 完整渲染 | 也渲染(但见下文) |
这里藏着一个反直觉但必须记住的点 :'use client' 不是「只在浏览器渲染」,而是「这个组件要先在服务器渲染成 HTML,再把它的 JS 也发给浏览器去水合」。
那"水合(hydration)"到底是什么?
这是全文最该搞懂的一个词。
我们项目里的 readme 写了一句我至今觉得特别传神的比喻:
包好了水饺,冻上,给你送过来,水煮......水合(hydration)。
展开讲,就是下面这个过程:
text
'use client' 组件 = 被渲染两次
服务器端 浏览器
┌──────────────┐ ┌─────────────────┐
│ React 渲染 │ ── HTML ──► │ 先拿到静态 HTML │
│ JSX ──► HTML │ │ 首屏立刻能看见 │
└──────────────┘ └────────┬────────┘
│ 下载 JS chunk
▼
┌─────────────────┐
│ 水合 hydration │
│ 挂载 JS、绑事件 │
│ useEffect 执行 │
└─────────────────┘
拆成三步:
- 服务器端 :
'use client'组件照样被 React 渲染,生成一份完整的 HTML 字符串,和 Server Component 一起返回给浏览器。所以你curl能看到<h1>待办事项</h1>。 - 浏览器首屏 :用户先看到这份静态 HTML------但这时候它是"冻住的水饺",没有交互,按钮点了没反应。
- 水合:浏览器下载这个组件对应的 JS,把事件监听、state、useEffect 重新"接线"到已经存在的 HTML 上。饺子下锅煮熟,能吃了。
一句话记住:SSR 负责"看见",水合负责"能动"。
用我们自己的代码验证这个结论
光讲理论不够,用 todos/page.tsx 自己证一遍。注意它 state 的初始值:
javascript
const [todos, setTodos] = useState<Todo[]>([]); // 🔑 初始是空数组
const [text, setText] = useState('');
const fetchTodos = async () => {
const res = await fetch('/api/todos');
const data: Todo[] = await res.json();
setTodos(data);
};
useEffect(() => { // ⚠️ useEffect 只在客户端水合后才执行
fetchTodos();
}, []);
现在猜一下:用户打开 /todos 的第一个瞬间,页面是什么样?
答案是:一个空列表。
因为服务器端渲染用的是 useState([]) 这个初始值 ------此时 todos 是空数组,所以 SSR 出来的 HTML 里 <ul> 是空的。等浏览器下载完 JS、完成水合,useEffect 才第一次跑起来,fetchTodos() 去请求 /api/todos 拿数据、setTodos 更新,列表才出现内容。
这就解释了一个新手常有的困惑:「为什么我首屏感觉数据是"后加载"出来的?」------因为数据本来就不是 SSR 带的,是水合之后 useEffect 去拉的。
⚠️ 避坑 :
useEffect永远不会在服务器端执行。所以「首屏就想让爬虫/用户直接看到数据」的需求,不能用useEffect拉数据解决,得用 Server Component 在服务器直接把数据查好渲染进 HTML(这也是 Next.js 推崇的模式)。
后端在哪?看 route.ts 就够了
既然是全栈项目,顺带把 API 这一半讲清楚。项目里的 app/api/todos/route.ts:
javascript
// next.js 除了 use client 都是后端
// /api 数据接口 仍然满足 App Router 约定
// route.ts 返回 json 数据接口的
let todos: Todo[] = [
{ id: 1, content: '学习AppRouter', completed: true },
{ id: 2, content: 'Next.js个人官网开发', completed: false },
];
// 🔑 GET 直接返回 JSON,Next.js 封装好了 Response
export async function GET() {
return Response.json(todos);
}
export async function POST(req: Request) {
const data = await req.json();
const newTodo: Todo = { id: Date.now(), content: data.content, completed: false };
todos.push(newTodo);
return Response.json(newTodo);
}
这就是 Next.js 的全栈心智 :app/page.tsx 是页面,app/api/todos/route.ts 是接口,约定式路由一视同仁------GET/POST 导出的函数名就是 HTTP 方法,一个文件搞定一个资源。前端 fetch('/api/todos') 就能打到它,不需要跨域、不需要单独起一个后端服务。
注意:这里的
todos是内存数组,服务重启数据就没了。真正的生产环境要换成数据库。这个 demo 是为了讲清结构,不是让你拿去上线。
三个真实的小坑(我顺手踩出来的)
既然这段代码是真实写出来的,它身上的坑也值得拎出来说------都是新手大概率会犯的。
坑 1:添加了任务,列表却不刷新 ⚠️
javascript
const handleAdd = async () => {
if (!text.trim()) return;
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }),
});
// ⚠️ 这里 POST 成功了,但没有更新本地 state!
// 后端 todos.push 了,前端 setTodos 却没跟着动
};
你点了「添加」,请求发出去、后端数据也进去了,但页面上看不到新任务 ------因为本地 todos state 根本没变。正确做法是 POST 成功后,要么 setTodos(prev => [...prev, newTodo]) 乐观更新,要么重新 fetchTodos() 拉一次。
为什么会犯 :'use client' 组件的数据在浏览器里,后端接口的数据在服务器内存里,两者没有自动同步。水合让你误以为"前后端打通了",其实是两条独立的数据流。
坑 2:删除按钮是个摆设
javascript
{todos.map((item) => (
<li key={item.id}>
<span>{item.content}</span>
<button>删除</button> // ⚠️ 没有任何 onClick,点了没反应
</li>
))}
<button>删除</button> 没绑事件处理器,点击毫无反应。这里本该有一个 DELETE 请求配合 setTodos 过滤。又是一个「看起来是交互组件,实际没接上线」的例子。
坑 3:console.log(data) 这类调试残留
javascript
const data: Todo[] = await res.json();
setTodos(data);
console.log(data); // ⚠️ 上线前该删
小事,但真实项目里这种残留很常见。水合后 fetchTodos 每次跑都会往控制台打一次,没意义。
回到开头:那句注释错在哪
现在回头看最初的注释------「组件在前端渲染的印记」。
它错在一个字:"印记" 。仿佛 'use client' 是在给组件盖个"前端专属"的章,盖完它就跟服务器无关了。
真相是:
'use client'不是把组件"赶出"服务器,而是把它"标记"成需要水合。它照样先在服务器渲染一遍,只是额外多了一段"发到浏览器激活交互"的旅程。
把这句话记牢,你以后判断一个组件要不要加 'use client',就有一个清晰的标准了:
- 纯展示、不需要交互、不需要 hooks → 别加,让它当 Server Component,JS 体积更小、首屏更快、还能直接在服务器查数据。
- 需要
useState/useEffect/事件监听 → 加'use client',接受它"跑两次"的代价。
最后
记住这句就够了:`'use client' 组件会被渲染两次------服务器出 HTML,浏览器做水合;SSR 负责"看见",水合负责"能动"。
下次再写 Next.js,你可能会下意识地问自己一句:这个组件的首屏内容,是靠 SSR 的初始值撑起来的,还是靠水合后 useEffect 拉回来的?
一个开放问题 :如果我把 todos 的初始值从 useState([]) 改成「在服务器端直接查好数据库再传入」,也就是把 page.tsx 拆成一个 Server Component 负责查数据、内嵌一个 Client Component 负责交互------你觉得首屏体验会有什么变化?欢迎在评论区聊聊你的做法。