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

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

一个 next-demo 项目,串起 page.tsx、layout.tsx、route.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.tsx、type.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 方法------GET、POST、PUT、DELETE......一个文件搞定一个资源的全部操作。
  • 前端直接 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 必须加?因为它用了 useState、useEffect、onClick 这些交互能力 ------这些只有浏览器才有。纯展示的 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 里是怎么解决的?欢迎在评论区聊聊动态路由。

相关推荐
JavaPub-rodert3 小时前
从零搭建通用管理后台:ShiyuAdmin 的 Go、React 与 RBAC 实践
开发语言·react.js·golang
Highcharts.js10 小时前
React 中原生支持使用 JSX 组件配置自定义表格 Highcharts Grid
前端·javascript·react.js·前端框架·jsx·highcharts·自定义表格
JudithHuang11 小时前
React 嵌套路由与布局
前端·react.js·arcgis·react·react router
梯度下降者11 小时前
3天时间,我使用 Cursor 开发了一个提示词生成器
ai编程·next.js·cursor·产品开发·提示词生成器
前端小D14 小时前
React Native项目开发(iOS侧为例)
javascript·react native·react.js
志尊宝2 天前
AI原生前端开发(042):AI辅助的路由设计与优化
javascript·react.js·ai-native
Hone2 天前
我把「自动发文章到掘金」做成了一个 Agent 技能:juejin-skills
node.js·ai编程·前端工程化
znsoft2 天前
MaClaw(码卡龙)技术拆解:一个 Go + React 的可自进化智能体平台
开发语言·react.js·golang
濮水大叔2 天前
React/Next.js 生态里,有没有 CabloyJS 这样的全栈框架?
react.js·全栈·next.js
前端小D3 天前
React Native Android 项目搭建
android·react native·react.js