彻底搞懂 'use client':Next.js 组件根本不是"只在浏览器渲染"

彻底搞懂 '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',因为里面用了 useStateuseEffectonClick

为什么要分这两种?因为 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 执行    │
                                  └─────────────────┘

拆成三步:

  1. 服务器端'use client' 组件照样被 React 渲染,生成一份完整的 HTML 字符串,和 Server Component 一起返回给浏览器。所以你 curl 能看到 <h1>待办事项</h1>
  2. 浏览器首屏 :用户先看到这份静态 HTML------但这时候它是"冻住的水饺",没有交互,按钮点了没反应。
  3. 水合:浏览器下载这个组件对应的 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 负责交互------你觉得首屏体验会有什么变化?欢迎在评论区聊聊你的做法。

相关推荐
小林ixn2 小时前
深入理解 React 水合(Hydration):从静态 HTML 到可交互页面
react.js·next.js
小林ixn2 小时前
Next.js 全栈实战:从 SPA 到 SSR,SEO 友好到底怎么落地?
react.js·前端框架·next.js
烬羽2 小时前
彻底搞懂 App Router 约定式路由:文件放对位置,路由就自动生成了
react.js·next.js·前端工程化
用户9385156350714 小时前
深入理解 Next.js:从 SPA 的 SEO 问题到约定式路由与 SSR 原理
前端·后端·全栈
程序员黑豆1 天前
Java 内存存储机制:基本数据类型 vs 引用数据类型
前端·ai编程·全栈
用户938515635072 天前
从前后端分离到前端接口工程:React + MockJS + Vite 解析
前端·后端·全栈
程序员黑豆2 天前
深入解析Java数据类型:基本类型与引用类型的本质区别与实战选择
前端·ai编程·全栈
前端双越老师2 天前
前端学习 Java 其实很容易:TS 和 Java 语法的 N 个相同点
java·全栈
烬羽3 天前
navigator.gpu 存在就够了?WebGPU 检测的两层陷阱
typescript·浏览器·全栈