路由、特殊文件与渲染机制
npm 命令对照
| 命令 | 实际执行 | 用途 |
|---|---|---|
npm run dev | next dev | 开发模式:热更新、详细报错 |
npm run build | next build | 先构建生产版本 |
npm start | next start | 生产模式:跑已经 build 好的代码 |
使用场景
- 日常写代码、调试→ 用
npm run dev - 模拟线上环境→ 先
npm run build,再npm start
路由与文件
① layout vs page
layout.tsx在路由切换时通常不重新挂载(导航栏放这里就不会闪)page.tsx会随 URL 换掉
② 文件夹 ≈ URL
- 文件夹路径对应 URL 路径
- 例外:
(main)这种路由组——括号里的名字不出现在 URL 里
③ Server Component
- 默认作为 Server Component,在服务端渲染 HTML
- 如果页面只是静态展示(标题、段落、链接),没有
useState、点击事件、浏览器 API → 保持 Server Component 即可
④ 常见文件夹写法
| 写法 | 含义 | 例子 |
|---|---|---|
about/ | 固定路由 | 只匹配/about |
[slug]/ | 动态路由 | 匹配/posts/hello、/posts/abc等 |
(main)/ | 路由组,不出现在 URL 里 | 只用来组织 layout;(main)/about的 URL 仍是/about等 |
特殊文件
① 为何用 notFound(),不直接return <PageNotFound />?
| notFound() | return <PageNotFound /> | |
|---|---|---|
| HTTP 状态 | 生产环境通常返回 404;开发环境也会渲染 not-found 页面,但状态码行为可能不同 | 200(仍是「正常页面」) |
| 语义 | 「这个资源不存在」 | 「这是一个普通页面,碰巧长得像 404」 |
| UI 来源 | 渲染最近的 not-found.tsx | 你自己 import 的组件 |
| 多处复用 | page.tsx、Server Action、数据层都能调 notFound(),不用每个文件 import | 每个地方都要 import + return |
| SEO / 监控 | 爬虫、日志能识别 404 | 会被当成正常 200 页面 |
核心:404 是路由/资源层面的结论,不是普通 JSX 的一种样式。
关键点:notFound() 在哪里触发执行,会替换掉 xxx.tsx,改渲染最近的 not-found.tsx
和 throw Error 的区别:
- notFound() → 业务上「找不到」→ not-found.tsx
- throw Error → 程序出 bug → error.tsx
② loading / error / not-found 放的位置不一样,差别在于作用范围(segment 树):
app/(main)/posts/ ├── loading.tsx ← 覆盖 posts 下所有子路由 ├── error.tsx ├── not-found.tsx ├── page.tsx ← 若将来有 /posts 列表页 └── [slug]/ ├── loading.tsx ← 只覆盖 /posts/:slug 详情 ├── error.tsx ├── not-found.tsx └── page.tsxNext 的规则:特殊文件只影响「自己这一层 + 子层」,不会向上影响父层。
Next.js 渲染逻辑
场景对照
| 场景 | 谁渲染 | 渲染几次 |
|---|---|---|
| 首次打开 / F5 刷新 | 服务端先出 HTML,Client Component 再在浏览器 hydrate(注水) | Server Component:本次请求在服务端执行 1 次(不在浏览器跑);Client Component:服务端预渲染 1 次 + 客户端 hydrate 1 次 |
<Link>/ router.push 跳转 | 不整页刷新;向服务端要新页面的 RSC Payload,Client Component 局部更新 | 新路由的 Server Component 在服务端再跑一遍 |
| router.refresh() | URL 不变,当前页 Server Component 重新在服务端跑 | 再跑 1 次(不是浏览器自己重算) |
所以:不是「服务端只渲染一次就完事」,而是每次请求/导航/refresh,Server Component 都会在服务端执行;Client Component 则在服务端预渲染 + 客户端 hydrate/更新。
和传统 React(纯 CSR)的区别
以下以 React 18+ 的
createRootAPI 为例(旧版ReactDOM.render已废弃)。
纯 React SPA:
浏览器收到空 HTML → 下载 JS → ReactDOM.createRoot().render() → 整棵树在浏览器里从零构建你的 Next.js 项目(App Router):
浏览器收到完整 HTML(Server Component 已填好内容) → 下载 JS → 只有带 "use client" 的组件做 hydrate(挂事件、恢复状态) → Server Component 的 JS 不会发到浏览器执行「客户端渲染逻辑一致吗?」—— 分两种组件
① Server Component:客户端根本不跑同样逻辑
像posts/[slug]/page.tsx里的await params、2 秒延迟、notFound():
// src/app/(main)/posts/[slug]/page.tsx async function page({ params }: Props) { const { slug } = await params; // ... await new Promise((resolve) => setTimeout(resolve, 2000)); return (/* ... */); }React 惯例组件名首字母大写(如
Page),项目中此处写的是page。
这些只在服务端执行。浏览器收到的只是 HTML + 序列化的组件树(RSC Payload),不会在客户端再跑一遍这段 async 逻辑。
首次访问时,用户会先看到 loading.tsx(Suspense 边界),2 秒后服务端渲染完成再替换内容。
② Client Component:必须和服务端 HTML 一致(Hydration)
Client Component 会跑两次:
- 服务端:生成初始 HTML(为了 SEO、首屏快)
- 客户端:React 对比 DOM,挂上交互(hydrate)
两边输出的 HTML 必须一致,否则报 hydration mismatch。
你项目里 ThemeToggle 就是一个典型风险点:
// src/components/theme/index.tsx function getStoredTheme(): ThemeMode { if (typeof window === "undefined") { return "light"; // 服务端永远是 light } const cachedTheme = localStorage.getItem(cachedThemeKey); return cachedTheme === "dark" ? "dark" : "light"; // 客户端可能读到 dark } const [theme, setTheme] = useState<ThemeMode>(getStoredTheme); // 按钮图标表示「点击后切换到的主题」,不是当前页面的配色: // light → 显示 🌙(提示可切到 dark);dark → 显示 🌞(提示可切到 light)- 服务端:
getStoredTheme()无window,默认light→ 渲染 🌙 - 客户端:localStorage 存了
dark→ 首次 render 为dark→ 渲染 🌞 - 两边图标不一致 → 控制台可能报 hydration warning