从 "use client" 到 Route Handler:用 Todos 理解 Next.js 水合与全栈请求

"use client" 到 Route Handler:用 Next.js Todos 串起水合、状态与前后端请求

掌握 App Router 的页面、路由和布局之后,下一步通常不是继续增加静态页面,而是让页面真正"动起来":输入一条待办事项,点击按钮,把数据发送给后端,再将结果显示在列表中。

这个看似简单的 Todos 功能,刚好把 Next.js 全栈开发中几个容易混淆的概念连在了一起:

  • 什么情况下必须写 "use client"
  • Client Component 是否只在浏览器渲染;
  • 水合到底给静态 HTML 增加了什么;
  • useEffect 为什么适合在组件挂载后请求接口;
  • route.ts 如何同时处理 GET 和 POST;
  • 为什么 fetch() 成功了,页面却可能没有显示新数据;
  • TypeScript 类型如何在页面和接口之间复用。

本文会围绕一条完整的数据链路展开:

text 复制代码
浏览器中的 Todos 页面
        ↓ GET /api/todos
Next.js Route Handler
        ↓ JSON
页面更新 todos 状态并渲染列表

用户输入新任务并点击添加
        ↓ POST /api/todos
Route Handler 创建新 Todo
        ↓ 返回新 Todo
页面把新数据加入列表

一、先看清页面和接口的职责边界

Todos 功能只需要三个核心模块:

text 复制代码
app/
├── todos/
│   ├── page.tsx       # /todos 页面
│   └── types.ts       # Todo 类型
└── api/
    └── todos/
        └── route.ts   # /api/todos 接口

虽然页面和接口都处于 app 目录中,但它们承担的任务并不相同:

模块 对应地址 作用
todos/page.tsx /todos 渲染可以交互的待办页面
api/todos/route.ts /api/todos 接收 HTTP 请求并返回 JSON
todos/types.ts 无独立地址 定义前后端共同使用的数据结构

page.tsx 返回 React UI,route.ts 返回数据。浏览器访问 /todos 看到的是页面,页面内部再请求 /api/todos 获得待办数组。

二、为什么 Todos 页面必须使用 "use client"

App Router 中的页面和布局默认是 Server Component,但 Todos 页面使用了下面这些能力:

tsx 复制代码
useState(...)
useEffect(...)
onChange={...}
onClick={...}

状态、Effect 和事件处理器都需要在浏览器中运行,因此要在模块顶部声明客户端边界:

tsx 复制代码
"use client";

import { useEffect, useState } from "react";

这条指令必须位于 import 之前。它表达的不是"这段 HTML 只能由浏览器生成",而是:这个模块及其客户端依赖需要把 JavaScript 发送到浏览器,以便使用状态、生命周期和事件。

如果没有 "use client",下面这些交互就无法在默认 Server Component 中使用:

tsx 复制代码
const [text, setText] = useState("");

<input onChange={(event) => setText(event.target.value)} />
<button onClick={handleAdd}>添加</button>

不能把"没有 use client"简单理解成"都是后端"

这个说法过于绝对。更准确的区分是:

  • page.tsxlayout.tsx 默认是 Server Component;
  • 写了 "use client" 的模块形成客户端边界;
  • route.ts 是处理 HTTP 请求的服务端 Route Handler;
  • 只包含类型的 types.ts 可以被前后端共同引用。

尤其是 types.ts,它没有 "use client",但也不是一个接口或页面。它只是共享 TypeScript 类型,最终通过 import type 引入时不会形成运行时逻辑。

三、Client Component 首次打开时也会经历服务端预渲染

Client Component 不等于传统 SPA 中"服务器只返回一个空的 #root"。首次访问 /todos 时,Next.js 仍然会在服务端为页面生成 HTML,让用户先看到不可交互的页面预览;随后浏览器加载 JavaScript 并进行水合,页面才真正拥有交互能力。

整个过程可以分为三步:

text 复制代码
第一步:服务端预渲染 HTML
第二步:浏览器直接显示 HTML
第三步:React 水合并绑定事件,页面变得可交互

什么是水合

水合(Hydration)可以理解为 React 把事件处理能力附加到已有 DOM 上的过程。

例如下面这个输入框:

tsx 复制代码
<input
  value={text}
  onChange={(event) => setText(event.target.value)}
  placeholder="请输入新的待办任务"
/>

在 JavaScript 完成水合之前:

  • 输入框和 placeholder 可以已经存在于 HTML 中;
  • onChange 对应的交互逻辑还不能工作。

水合完成后,React 才能在用户输入时执行 setText,并根据最新状态更新页面。

因此,不宜把水合描述为"服务器渲染一次,客户端把缺少的地方随便打个补丁"。更准确的理解是:服务器提供初始 HTML,客户端使用对应的组件树和 JavaScript 接管这份 HTML,再让状态与事件开始工作。

四、用一个共享类型约束 Todo 数据

每条待办事项都包含三个字段:

tsx 复制代码
export type Todo = {
  id: number;
  content: string;
  completed: boolean;
};

它们分别表示:

  • id:每条数据的唯一标识;
  • content:待办事项的文字内容;
  • completed:任务是否完成。

页面和接口都使用同一个 Todo 类型:

tsx 复制代码
import type { Todo } from "./types";
tsx 复制代码
import type { Todo } from "../../todos/types";

import type 明确表示这里只需要类型信息。这样一来,接口返回的数据结构和页面期待的数据结构由同一份定义约束,避免一边叫 content、另一边却误写成其他字段。

类型只在编译阶段帮助检查代码,并不会替代接口数据本身。浏览器真正拿到的仍然是 JSON。

strict: false 不会让显式类型失去作用

TypeScript 配置中使用了:

json 复制代码
{
  "compilerOptions": {
    "strict": false
  }
}

这会放宽项目整体的类型检查,例如没有明确标注的参数不会因为隐式 any 直接报错。不过,Todo[]stringTodo 这些显式类型仍然会约束对应状态和对象。

还要区分"TypeScript 类型"和"运行时数据":req.json() 解析的是客户端实际传来的 JSON,Todo 类型不会在运行时自动验证这份数据。当前示例由受控输入框和 trim() 保证正常页面不会提交空内容,类型主要负责约束代码中的数据形状。

五、用 Route Handler 编写 GET 接口

App Router 使用 route.ts 创建自定义请求处理器。目录位置决定接口 URL:

text 复制代码
app/api/todos/route.ts → /api/todos

先准备两条保存在内存中的假数据:

tsx 复制代码
import type { Todo } from "../../todos/types";

const todos: Todo[] = [
  { id: 1, content: "学习 App Router", completed: true },
  { id: 2, content: "Next.js 个人官网开发", completed: false },
];

这里更适合使用 const。虽然 POST 请求还会执行 todos.push(...),但 push 修改的是数组内容,并没有让变量重新指向另一个数组,所以不需要 let

接下来导出一个与 HTTP 方法同名的 GET 函数:

tsx 复制代码
export async function GET() {
  return Response.json(todos);
}

当浏览器发送 GET /api/todos 时,Next.js 会调用这个函数。Response.json(todos) 会创建 JSON 响应,把数组放入响应体。

这里使用的是 Route Handler 支持的 Web Response API。它返回的是数据响应,而不是 React 组件,所以直接访问 /api/todos 时看到的应该是类似下面的 JSON:

json 复制代码
[
  {
    "id": 1,
    "content": "学习 App Router",
    "completed": true
  },
  {
    "id": 2,
    "content": "Next.js 个人官网开发",
    "completed": false
  }
]

这些数据只保存在当前服务进程的内存中。它适合演示接口调用,但开发服务器重启后,后来添加的数据就会消失。

六、在组件挂载后请求 Todos

页面使用一个数组状态保存接口数据:

tsx 复制代码
const [todos, setTodos] = useState<Todo[]>([]);

初始值是空数组,所以页面第一次渲染时还没有任何列表项。组件完成挂载后,再在 useEffect 中请求接口:

tsx 复制代码
useEffect(() => {
  const loadTodos = async () => {
    const res = await fetch("/api/todos");
    const data: Todo[] = await res.json();
    setTodos(data);
  };

  void loadTodos();
}, []);

空依赖数组 [] 表示这个 Effect 用于组件挂载后的初始化同步,而不是在每次渲染后都重新请求。

把异步加载函数定义在 Effect 内部,还能清楚表达它只服务于这次初始化,并避免把一个会间接更新状态的外部函数直接放进 Effect。

fetch() 返回的不是 JSON 数据本身

下面这行拿到的是 Response 对象:

tsx 复制代码
const res = await fetch("/api/todos");

如果只执行:

tsx 复制代码
console.log(res);

控制台看到的会是状态码、响应头和响应体状态等信息,而不是已经解析好的 Todo 数组。还需要调用:

tsx 复制代码
const data: Todo[] = await res.json();

然后把数据交给 React 状态:

tsx 复制代码
setTodos(data);

状态更新后,React 会重新渲染组件,列表内容才真正出现在页面中。

为什么这部分内容仍然属于客户端取数

页面虽然是 Client Component,并且首次打开时会得到预渲染 HTML,但 useEffect 只会在浏览器完成水合后运行。初始状态又是空数组,因此服务端生成的初始列表也是空的。

数据出现的顺序是:

text 复制代码
先看到标题、输入框和按钮
          ↓
浏览器完成水合
          ↓
useEffect 请求 /api/todos
          ↓
setTodos 更新状态
          ↓
列表出现在页面中

这正是客户端异步取数的特点:页面骨架可以预渲染,但 Todo 正文要等浏览器请求完成后才出现。

七、用 map 把状态渲染成列表

todos 更新后,可以通过 map 生成列表项:

tsx 复制代码
<ul style={{ paddingLeft: "0", listStyle: "none" }}>
  {todos.map((item) => (
    <li
      key={item.id}
      style={{ margin: "8px 0", display: "flex", gap: "10px" }}
    >
      <span
        style={{
          textDecoration: item.completed ? "line-through" : "none",
          cursor: "pointer",
        }}
      >
        {item.content}
      </span>
      <button>删除</button>
    </li>
  ))}
</ul>

这里包含三处重要细节。

1. key 来自 Todo 的 id

tsx 复制代码
key={item.id}

列表中的每条数据都有稳定标识,React 可以据此区分各个列表项。

2. 根据 completed 计算样式

tsx 复制代码
textDecoration: item.completed ? "line-through" : "none"

已完成的任务显示删除线,未完成任务保持正常文字。这里没有维护另一份样式状态,而是直接根据 Todo 数据计算 UI。

3. "看起来可以点击"不等于已经实现交互

cursor: "pointer" 只会改变鼠标样式,并不会切换完成状态;"删除"按钮当前也没有 onClick。它们只是为后续功能留下了界面位置,现阶段真正实现的是查询和添加,不应误认为删除与切换已经完成。

八、受控输入框如何保存用户输入

新增任务前,需要用字符串状态保存输入内容:

tsx 复制代码
const [text, setText] = useState<string>("");

输入框同时绑定 valueonChange

tsx 复制代码
<input
  value={text}
  onChange={(event) => setText(event.target.value)}
  placeholder="请输入新的待办任务"
/>

数据流始终是单向的:

text 复制代码
用户输入
   ↓
触发 onChange
   ↓
setText 更新状态
   ↓
新的 text 成为 input 的 value

这就是受控输入框。React 状态是输入值的来源,提交时也可以直接读取 text

九、POST 请求如何把新 Todo 交给接口

点击"添加"按钮会触发 handleAdd

tsx 复制代码
<button onClick={handleAdd} style={{ marginLeft: "8px" }}>
  添加
</button>

发送请求前,先排除空字符串和只包含空格的内容:

tsx 复制代码
const content = text.trim();

if (!content) return;

然后向同一个地址发送 POST 请求:

tsx 复制代码
const res = await fetch("/api/todos", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ content }),
});

这几个配置各有作用:

  • method: "POST" 表示创建数据;
  • Content-Type: application/json 说明请求体采用 JSON 格式;
  • JSON.stringify 把 JavaScript 对象转换成请求体字符串。

服务端通过同一个 route.ts 中的 POST 函数接收请求:

tsx 复制代码
export async function POST(req: Request) {
  const body = await req.json();

  const newTodo: Todo = {
    id: Date.now(),
    content: body.content,
    completed: false,
  };

  todos.push(newTodo);
  return Response.json(newTodo);
}

它依次完成四件事:

  1. 使用 req.json() 解析请求体;
  2. 根据 content 创建一条新的 Todo;
  3. 使用当前时间戳作为数字 id
  4. 把新数据加入内存数组并返回 JSON。

这里返回的是新建的单条 Todo,而不是整个数组。这正好可以让页面直接使用响应结果更新本地状态。

十、请求成功不代表页面会自动变化

如果 handleAdd 只发送 POST:

tsx 复制代码
await fetch("/api/todos", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ content: text }),
});

服务端数组确实增加了数据,但 React 的 todos 状态没有变化,所以页面列表不会立即新增一项。浏览器也不会因为接口返回了 JSON,就自动把 JSON 放进组件状态。

一个贴近现有数据流的完成方式,是读取 POST 返回的新 Todo,然后追加到数组并清空输入框:

tsx 复制代码
const handleAdd = async () => {
  const content = text.trim();

  if (!content) return;

  const res = await fetch("/api/todos", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ content }),
  });

  if (!res.ok) return;

  const newTodo: Todo = await res.json();

  setTodos((currentTodos) => [...currentTodos, newTodo]);
  setText("");
};

这里使用函数形式更新状态:

tsx 复制代码
setTodos((currentTodos) => [...currentTodos, newTodo]);

它基于当前数组创建一个新数组,把新 Todo 放在最后。setText("") 随后清空受控输入框。

完整的添加链路因此变成:

text 复制代码
text 保存输入内容
      ↓
点击按钮触发 handleAdd
      ↓
POST /api/todos
      ↓
服务端创建并返回 newTodo
      ↓
页面解析 JSON
      ↓
setTodos 追加新数据
      ↓
列表重新渲染,输入框被清空

十一、为什么开发者工具里会看到很多请求

打开 /todos 后,Network 面板不会只有一条接口请求。Next.js 开发环境还需要加载和维护页面本身,因此可能同时看到:

  • /todos:页面请求;
  • /_next/static/...:页面需要的 JavaScript 和样式资源;
  • 带有 _rsc 参数的请求:React Server Component 导航数据;
  • 开发环境的热更新连接;
  • /api/todos:真正返回假数据的自定义接口。

排查 Todos 数据时,可以在 Network 中选择 Fetch/XHR,再搜索 todos。需要查看的是路径为 /api/todos 的请求,而不是 /todos 或带 _rsc 参数的页面请求。

选中 /api/todos 后:

  • Headers 可以确认请求方法是 GET 还是 POST;
  • Payload 可以查看 POST 发送的 { content: ... }
  • PreviewResponse 可以查看接口返回的 Todo JSON。

为什么开发环境中 GET 可能出现两次

App Router 默认启用 React Strict Mode。在开发模式中,React 可能额外执行一次挂载流程来帮助发现副作用问题,于是初始化 Effect 也可能再次执行,Network 中就会看到两条相同的 GET 请求。

这不等于代码进入了无限循环。当前 Effect 的依赖数组是 [],没有因为 setTodos 而在每次渲染后持续请求。额外执行主要是开发模式的检查行为,不能据此认为生产环境必然会发送两次初始化请求。

十二、一份完整且贴近原始思路的实现

把前面的数据流组合起来,接口可以写成:

tsx 复制代码
import type { Todo } from "../../todos/types";

const todos: Todo[] = [
  { id: 1, content: "学习 App Router", completed: true },
  { id: 2, content: "Next.js 个人官网开发", completed: false },
];

export async function GET() {
  return Response.json(todos);
}

export async function POST(req: Request) {
  const body = await req.json();

  const newTodo: Todo = {
    id: Date.now(),
    content: body.content,
    completed: false,
  };

  todos.push(newTodo);
  return Response.json(newTodo);
}

客户端页面可以写成:

tsx 复制代码
"use client";

import { useEffect, useState } from "react";
import type { Todo } from "./types";

export default function TodosPage() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [text, setText] = useState<string>("");

  useEffect(() => {
    const loadTodos = async () => {
      const res = await fetch("/api/todos");

      if (!res.ok) return;

      const data: Todo[] = await res.json();
      setTodos(data);
    };

    void loadTodos();
  }, []);

  const handleAdd = async () => {
    const content = text.trim();

    if (!content) return;

    const res = await fetch("/api/todos", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ content }),
    });

    if (!res.ok) return;

    const newTodo: Todo = await res.json();

    setTodos((currentTodos) => [...currentTodos, newTodo]);
    setText("");
  };

  return (
    <div style={{ marginTop: "12px" }}>
      <h1>待办事项</h1>

      <input
        value={text}
        onChange={(event) => setText(event.target.value)}
        placeholder="请输入新的待办任务"
      />

      <button onClick={handleAdd} style={{ marginLeft: "8px" }}>
        添加
      </button>

      <ul style={{ paddingLeft: "0", listStyle: "none" }}>
        {todos.map((item) => (
          <li
            key={item.id}
            style={{ margin: "8px 0", display: "flex", gap: "10px" }}
          >
            <span
              style={{
                textDecoration: item.completed ? "line-through" : "none",
                cursor: "pointer",
              }}
            >
              {item.content}
            </span>
            <button>删除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

这份实现仍然保持了最初的结构:useEffect 获取数据,handleAdd 提交数据,Route Handler 负责 GET 和 POST。只补齐了三个关键环节:

  1. 检查响应是否成功;
  2. 读取 POST 返回的 JSON;
  3. 添加成功后同步更新页面状态并清空输入框。

删除按钮和点击文字切换完成状态仍然没有对应接口与事件,因此保留为未实现的界面,不继续扩展出当前功能之外的代码。

十三、几个容易混淆的边界

1. Response 和响应数据不是一回事

tsx 复制代码
const res = await fetch(...);

这里得到的是响应对象。只有 await res.json() 之后,才拿到 JavaScript 数据。

2. 接口数据改变和 React 状态改变不是一回事

POST 可以成功修改服务端内存数组,但页面只有在执行 setTodos 后才会重新渲染。

3. Client Component 和纯 CSR 不是一回事

Client Component 首次访问时仍可参与服务端预渲染,随后在浏览器水合;但放在 useEffect 中获取的 Todo 数据要等客户端请求完成后才出现。

4. 水合和重新获取数据不是一回事

水合负责让已有 HTML 获得 React 交互能力;fetch("/api/todos") 则是页面主动发起的另一个数据请求。

5. const 和"数组内容不能修改"不是一回事

const todos = [] 禁止变量重新指向另一个数组,但仍允许 todos.push(newTodo) 修改数组内容。

总结

一个简单的 Todos 页面已经包含了一条完整的 Next.js 全栈链路:

  1. "use client" 建立客户端边界,让组件可以使用状态、Effect 和事件;
  2. 页面首次打开时先得到预渲染 HTML,随后通过水合激活交互;
  3. useEffect 在浏览器端请求 /api/todos
  4. route.ts 根据 GET 或 POST 执行对应处理函数;
  5. Response.json 返回 JSON,页面再通过 res.json() 解析;
  6. setTodos 把接口结果变成 React 状态,驱动列表重新渲染;
  7. 受控输入框通过 valueonChange 保存用户输入;
  8. POST 成功后还要主动更新状态,页面不会因为接口成功而自动变化;
  9. Todo 类型让页面与接口共享同一份数据结构;
  10. 开发环境中的框架资源请求和 Strict Mode 额外执行,不应与业务接口混为一谈。

理解这条链路之后,就能真正看清 Next.js 中"全栈"的含义:前端组件负责状态和交互,Route Handler 负责接收请求与返回数据,而 TypeScript 类型把两端的数据约定连接起来。页面是否变化,最终取决于接口数据有没有被解析,以及解析后的结果有没有进入 React 状态。

相关推荐
qq_4523962338 分钟前
第七篇:《大型前端项目的模块化与目录结构设计》
前端
你脑门上的脚印1 小时前
Vue 项目从零实现语音转文字、文字转语音功能(完整可用 + 踩坑指南)
前端·vue.js
paopaokaka_luck1 小时前
基于springboot3+vue3的车间生产管理系统(Echarts图形化分析、BI报表)
java·前端·spring boot·学习·echarts
程序员鱼皮1 小时前
3 大 DeepSeek Harness 进阶玩法,招多个大肥鱼帮我干活!
前端·后端·ai编程
KoPa1 小时前
HeySmart:大模型开源网关基座-请求生命周期与钩子引擎
前端·后端
七牛开发者2 小时前
Coding Agent 如何跑稳长任务?从上下文管理到运行时状态
前端·javascript·后端
半个落月2 小时前
从 CSR 到 Server Component:吃透 Next.js 16 App Router 路由、布局与 SEO
前端·next.js
七牛开发者2 小时前
拆解 DeepSeek Harness:Profile 与 Bundle 如何装配运行时
前端·javascript·后端
葡萄城技术团队2 小时前
表格智能体系列 · 1:一句话怎么变成一次表格操作
前端