彻底搞懂 App Router 约定式路由:文件放对位置,路由就自动生成了

彻底搞懂 App Router 约定式路由:文件放对位置,路由就自动生成了

一个 next-demo 项目,串起 page.tsxlayout.tsxroute.ts'use client'------App Router 的"约定大于配置"一次讲清。


开篇:咦,我的路由哪去了?

用 React Router 写 SPA 的时候,路由是显式写出来的------像我们项目 readme 里记的那样:

jsx 复制代码
// SPA 里,路由得自己一条条声明
<Routes>
  <Route path="/todos" element={<Todos />} />
</Routes>

一条 URL 对应一个 <Route>,清清楚楚。但当我打开这个 Next.js 项目,满世界找路由表,发现根本没有 。没有 routes.ts、没有 createBrowserRouter、没有任何"配置路由"的地方。

可页面又是真实存在的://about/dashboard/dashboard/settings/todos/api/todos......全都访问得到。

路由藏在哪?答案在 app/ 这个目录的结构里。

这篇文章就是讲清楚:App Router 里,路由不是你"写"出来的,是你"放"出来的。


核心:约定大于配置------文件即路由、文件即角色

先给一句定义:

App Router 用文件系统的目录结构直接生成路由:一个文件夹的层级就是 URL 的层级,一个文件的文件名就是它的"角色"。

这就是 Next.js 读我 readme 时反复强调的那句------「约定大于一切」。你不需要写路由配置,只需要把文件放到约定好的位置、起约定好的名字。

对照项目目录,一眼看懂:

text 复制代码
app/                              URL
├─ layout.tsx        ← 全局布局
├─ page.tsx          ← 首页        /
├─ about/
│  └─ page.tsx       ← 关于页      /about
├─ dashboard/
│  ├─ layout.tsx     ← 后台布局
│  ├─ page.tsx       ← 后台首页    /dashboard
│  └─ settings/
│     └─ page.tsx    ← 设置页      /dashboard/settings
├─ todos/
│  └─ page.tsx       ← 待办页      /todos
└─ api/
   └─ todos/
      └─ route.ts    ← 数据接口    /api/todos

两个关键规律:

  1. 文件即路由app/about/page.tsx 这个文件的位置about/)就是 URL 路径(/about)。想加 /xxx 页面?建 app/xxx/page.tsx 就行,不用动任何配置文件。
  2. 文件即角色 :同一套目录下,不同文件名干不同的事------page.tsx 是页面,layout.tsx 是布局,route.ts 是接口。文件名就是组件身份的"暗号"。

下面逐个拆开看这些"暗号"。


page.tsx:一个文件就是一张页面

这是最基础也最常见的约定。看 app/about/page.tsx

javascript 复制代码
function About() {
  return <h1>About Us</h1>;
}
export default About;

就这么点代码,Next.js 就自动给它注册了 /about 这个路由。page.tsx 必须是默认导出export default)一个 React 组件,框架拿到它渲染成页面。

注意:只有 page.tsx 才会成为"可访问的页面"。你在 app/ 下建一个 helper.tsxtype.ts(比如 todos/type.ts),它不会变成路由------因为文件名不叫 page,只是被 import 的普通模块。

所以"建页面"这件事,从"写路由配置 + 写组件"两步,简化成了"建文件夹 + 写 page.tsx"一步。


layout.tsx:嵌套布局,父子页面共享外壳

layout.tsx 解决的是"多个页面共享同一块结构"的问题。项目里有两层嵌套布局

第一层,根布局 app/layout.tsx,所有页面都套在里面:

javascript 复制代码
export const metadata: Metadata = {
  title: "Create Next App",              // 🔑 SEO 的 title
  description: "Generated by create next app",
};

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        <nav>                              {/* 全局导航,每个页面都在 */}
          <ul>
            <li><Link href="/">首页</Link></li>
            <li><Link href="/about">关于我们</Link></li>
            <li><Link href="/dashboard">后台管理系统</Link></li>
            <li><Link href="/todos">Todos</Link></li>
          </ul>
        </nav>
        {children}                          {/* 🔑 子页面渲染在这里 */}
      </body>
    </html>
  );
}

注意两件事:

  • {children} 是子页面内容注入的位置 。访问 /about 时,About 组件就替换掉 {children} 渲染出来,外面的 <nav> 导航一直保留。
  • export const metadata 是 Next.js 给每个页面提供 SEO 元信息 的约定位。这里 title/description 会直接渲染成 <title><meta name="description">,是 SSR 对 SEO 友好的一部分。

第二层,子布局 app/dashboard/layout.tsx

javascript 复制代码
export default function DashboardLayout({ children }: LayoutProps<"/dashboard">) {
  return (
    <div>
      <nav><Link href="/dashboard/settings">settings</Link></nav>
      {children}
    </div>
  );
}

布局会嵌套 :访问 /dashboard/settings 时,实际渲染结构是------

text 复制代码
根布局 RootLayout(全局 nav)
  └─ DashboardLayout(settings 导航)
       └─ SettingsPage(settings 页面内容)

这就是 layout.tsx 的价值:同一个目录下的 layout.tsx 只作用于该目录及其子目录。dashboard 里的 settings 导航,不会跑到首页去。


route.ts:同一个项目里的"后端接口"

这是 App Router 最颠覆 SPA 认知的地方------一个前端项目里,居然能直接写接口 。看 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 },
];

// 🔑 导出的函数名 = HTTP 方法:GET 就是处理 GET 请求
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);
}

规则同样"约定式":

  • 文件放在 app/api/ 下、名字叫 route.ts,它就不再是页面,而是接口
  • 导出的函数名就是 HTTP 方法------GETPOSTPUTDELETE......一个文件搞定一个资源的全部操作。
  • 前端直接 fetch('/api/todos') 就能打到它,没有跨域、不用单独起一个 Node 服务

这就是 Next.js 被称为"全栈框架"的原因:app/page.tsx 是前端,app/api/*/route.ts 是后端,同一个项目、同一套约定、同一个端口。

注意:这里的 todos 是内存数组,服务一重启数据就清空,真实项目要接数据库。demo 用它只是为了把结构讲清楚。


'use client':文件即角色的最后一块拼图

约定式路由里,还有一个藏在组件顶部 的"角色标记",就是项目 app/todos/page.tsx 里这句:

javascript 复制代码
'use client';

import { useState, useEffect } from 'react';

export default function TodosPage() {
  const [todos, setTodos] = useState<Todo[]>([]);
  // ...
}

要理解它,得先知道 App Router 的一个默认规则:

app/ 下的组件默认是 Server Component(在服务器渲染),只有写了 'use client',它才成为 Client Component。

对照项目里的两个文件,区别一目了然:

app/page.tsx(首页) app/todos/page.tsx(待办页)
有没有 'use client' ❌ 没有 ✅ 有
类型 Server Component Client Component
能用 useState/useEffect/onClick ❌ 不能 ✅ 能
在哪渲染 只在服务器 服务器 + 浏览器

为什么 todos/page.tsx 必须加?因为它用了 useStateuseEffectonClick 这些交互能力 ------这些只有浏览器才有。纯展示的 page.tsx(首页就一个 <h1>)不需要,加了的代价是打包更多 JS 到浏览器。


一个反直觉的真相:'use client' 组件也会在服务器渲染一遍

很多人(包括曾经的我)以为 'use client' 的意思是"这个组件只在浏览器渲染"。错。 真相是:

'use client' 不是把组件"赶出"服务器,而是把它"标记"成需要水合。它照样先在服务器渲染成 HTML,再额外发一份 JS 到浏览器去激活交互。

我们自己验证一下。todos/page.tsx 的 state 初始值是空数组:

javascript 复制代码
const [todos, setTodos] = useState<Todo[]>([]);   // 🔑 初始是空数组

useEffect(() => {           // ⚠️ useEffect 只在客户端水合后才执行
  fetchTodos();
}, []);

那么用户打开 /todos第一个瞬间 看到什么?一个空列表。

因为服务器端渲染用的是 useState([]) 这个初始值,所以 SSR 出的 HTML 里 <ul> 是空的。等浏览器下载完 JS、完成水合(hydration)useEffect 才第一次跑起来去 fetch('/api/todos') 拉数据、setTodos 更新,列表才出现内容。

这就是 'use client' 组件"跑两次"的含义:

text 复制代码
            'use client' 组件 = 被渲染两次
  服务器端                        浏览器
  ┌──────────────┐               ┌─────────────────┐
  │ React 渲染    │   ── HTML ──► │ 先拿到静态 HTML  │
  │ JSX ──► HTML │               │ 首屏立刻能看见    │
  └──────────────┘               └────────┬────────┘
                                          │ 下载 JS chunk
                                          ▼
                                  ┌─────────────────┐
                                  │ 水合 hydration    │
                                  │ 绑事件、激活交互   │
                                  │ useEffect 执行    │
                                  └─────────────────┘

一句话记住:SSR 负责"看见",水合负责"能动"。

⚠️ 避坑useEffect 永远不会在服务器端执行。所以"首屏就想让用户/爬虫直接看到数据",不能靠 useEffect 拉数据,得用 Server Component 在服务器直接把数据查好、渲染进 HTML------这才是 Next.js 推崇的模式。


顺手拎出三个真实的小坑

既然代码是真实写出来的,它身上的坑也值得说------都是新手大概率会犯的

坑 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 成功了,但本地 todos state 没更新!
  // 后端 push 进去了,前端却看不到新任务
};

你点了「添加」,请求发出去、后端数据也进去了,但页面上没有新任务 ------因为本地 todos 这个 state 根本没变。前端 state 和后端内存数组是两条独立的数据流 ,不会自动同步。正确做法是 POST 成功后 setTodos 乐观更新,或重新 fetchTodos()

坑 2:删除按钮是个摆设

javascript 复制代码
{todos.map((item) => (
  <li key={item.id}>
    <span>{item.content}</span>
    <button>删除</button>   // ⚠️ 没绑 onClick,点了没反应
  </li>
))}

<button>删除</button> 没有任何事件处理器,点击毫无反应。本该有一个 DELETE 请求 + setTodos 过滤。

坑 3:console.log 调试残留

javascript 复制代码
const data: Todo[] = await res.json();
setTodos(data);
console.log(data);   // ⚠️ 上线前该删,水合后每次都会打一次

收尾:一张表记住整个 App Router 约定

把所有"暗号"收进一张表,这就是你以后搭 Next.js 页面的速查:

文件/写法 角色 会变成什么
app/xxx/page.tsx 页面 URL /xxx
app/xxx/layout.tsx 布局 包裹该目录及子目录的共享外壳
app/api/xxx/route.ts 接口 URL /api/xxx,导出 GET/POST
组件顶部 'use client' 标记 让组件成为可交互的 Client Component
其余 .tsx/.ts(如 type.ts 普通模块 只被 import,不生成路由

回到开头那个问题------"路由哪去了?"

App Router 里,路由不是写出来的,是"放"出来的:文件放对位置、起对名字,路由、布局、接口就全部自动就位。

下次你要加一个 /profile 页面,别再找路由表了,直接建 app/profile/page.tsx,一行路由配置都不用写。

一个开放问题 :约定式路由省去了写路由配置的麻烦,但也意味着目录结构一旦定了就很难"骗过"框架。如果某个 URL 你想让它长得和目录不一致(比如 /post/:id),App Router 里是怎么解决的?欢迎在评论区聊聊动态路由。

相关推荐
meilindehuzi_a3 小时前
从 Vite 到 Axios 与 Mock:React Todos 全栈项目架构及请求链路详解
前端·react.js·架构
@卓越俊逸_角立杰出@3 小时前
java实现Agent+ReAct demo(Spring Boot + Spring AI Alibaba ReAct Agent)
java·spring·react.js
用户69190268133918 小时前
用浏览器 WebGPU跑DPSK大模型(1) - 模型的下载和前端下载进度的显示
javascript·react.js·架构
光影少年19 小时前
react navite本地存储:AsyncStorage、MMKV、文件存储
前端·react native·react.js
JieE2121 天前
前端不等人:用 Mock 接口工程让 React 应用独立起飞
前端·react.js·面试
王林不想说话1 天前
React + Ant Design 后台项目:63 个业务组件的分层实践
react.js·typescript·ant design
不好听6131 天前
React 记忆化三兄弟:memo、useMemo、useCallback 到底在缓存什么
react.js
想要成为糕糕手1 天前
🚀 在浏览器里跑 DeepSeek-R1?WebGPU 端侧推理实战(五)—— 中断、重置、缓存与流式生成
前端·react.js·llm
AI砖家1 天前
React Native 开发规范与完整流程指南
javascript·react native·react.js