本节摘要:页面是组件拼出来的,布局决定页面的"骨架"。本节讲清组件组织的最佳实践(页面组件、功能组件、UI 组件分层)、嵌套布局与模板的区别,以及 loading.tsx 与 error.tsx 两个提升体验的内置边界。
阅读完本节,你应当能够:
项目一大会发现:所有组件堆在 components/ 里,命名靠猜、职责混杂。组件组织的本质是按"变化频率"与"职责边界"分层——页面组件随路由变、功能组件随业务变、UI 组件几乎不变。分层清晰后,改一个按钮样式不会误伤业务逻辑。
直觉类比:组件分层像"饭店的岗位分工"——UI 组件是"餐具"(通用,所有菜用),功能组件是"特色菜师傅"(负责一类业务),页面组件是"传菜员"(把菜端到对应桌)。各司其职,互不越界。
💡 关键直觉:布局(layout)是"不会随路由切换而重新渲染的骨架"——导航栏、侧边栏、页脚放布局里,切换页面时它们保持状态、不闪跳。这是 Next.js 与"整页刷新"式 MPA 的体验分水岭。
每个目录可有自己的 layout.tsx,自动嵌套包裹:
app/ ├── layout.tsx # 根布局:html/body/全局导航 ├── (shop)/ │ ├── layout.tsx # 商城布局:侧边栏/购物车 │ ├── products/page.tsx # /products │ └── cart/page.tsx # /cart └── blog/ └── layout.tsx # 博客布局:目录/标签云
切换路由时的行为:/products → /cart,商城布局不重新渲染(购物车状态保留);跨布局切换(商城 → 博客)才重新渲染。
与 layout 相对,template 在每次导航时重新创建(重置状态):
// app/blog/template.tsx —— 每次进入博客路由都重新挂载 export default function BlogTemplate({ children }) { return <div className="blog-template">{children}</div>; }
layout vs template:layout 保留状态(导航/鉴权框)、template 重置状态(统计埋点、动画容器)。99% 的场景用 layout。
同目录下放 loading.tsx,路由切换时立即显示(流式渲染,不等数据):
// app/posts/loading.tsx export default function Loading() { return <div className="animate-pulse">加载文章列表...</div>; }
原理:Next.js 把页面拆成"布局(立即显示)+ 页面内容(数据就绪后流入)"。loading.tsx 就是"数据就绪前先显示的占位",提升感知速度。
"use client"; // error 必须是客户端组件 export default function Error({ reset }: { reset: () => void }) { return ( <div> <h2>页面出错了</h2> <button onClick={reset}>重试</button> </div> ); }
与全局 404 的区别:error.tsx 处理运行时错误(数据获取失败、组件抛错);not-found.tsx 处理 404(资源不存在)。
components/ ├── ui/ # UI 基础组件(Button、Card、Input) │ └── Button.tsx ├── features/ # 功能组件(绑定业务逻辑) │ ├── auth/LoginForm.tsx │ └── posts/PostCard.tsx └── layouts/ # 布局组件 └── Header.tsx
分层规则:
// 服务器组件包一层(取数),客户端组件做交互 // app/posts/page.tsx(服务器) import { PostCard } from "@/components/features/posts/PostCard"; export default async function PostsPage() { const posts = await getPosts(); return ( <div className="grid gap-4"> {posts.map((p) => <PostCard key={p.id} post={p} />)} </div> ); } // components/features/posts/PostCard.tsx(客户端) "use client"; export function PostCard({ post }: { post: Post }) { const [liked, setLiked] = useState(false); return ( <article> <h2>{post.title}</h2> <button onClick={() => setLiked(!liked)}>赞</button> </article> ); }
职责分配:数据获取在服务器组件层,交互在客户端组件层,UI 组件纯展示——三层各司其职。
一个实用模式:服务器组件取数 + 传 props 给客户端子组件(已序列化数据),客户端组件只做交互——避免客户端组件自己取数导致的"加载两次"。
| 误区 | 现象 | 正解 |
|---|---|---|
| 所有组件塞一个目录 | 找不到、命名冲突 | 按 ui/features/layouts 分层 |
| 导航栏放页面而非布局 | 切换页面导航闪跳 | 放 layout.tsx |
| loading 不生效 | 页面还是空白等待 | loading.tsx 放同目录,配合流式渲染 |
| error.tsx 没用 use client | 报错 | 错误边界必须是客户端组件 |
| layout 与 template 混用 | 状态行为不可预期 | 默认用 layout,特殊需求用 template |
// 1. app/blog/layout.tsx —— 博客布局(保留状态) export default function BlogLayout({ children }) { return ( <div className="flex gap-6"> <aside className="w-48">目录导航</aside> <main>{children}</main> </div> ); } // 2. app/blog/loading.tsx —— 加载占位 export default function Loading() { return <div>加载中...</div>; } // 3. app/blog/error.tsx —— 错误边界 "use client"; export default function Error({ reset }) { return ( <div> <p>博客加载失败</p> <button onClick={reset}>重试</button> </div> ); }
访问 /blog 时:布局立即显示(目录导航不闪),内容区先显示 loading 再流入数据;出错时显示重试按钮——布局、加载、错误三种状态都得到了优雅处理。
要点:布局是"树"结构——根布局包裹所有,子布局只包裹自己的路由段。切换同布局内的页面(商品 → 购物车),商城布局不重新渲染;跨布局(商城 → 博客)才重建。
布局(立即显示)→ loading(数据等待)→ 页面(数据就绪)——三层渐进体验。生产建议:一个页面的默认形态是"服务器组件为主体,客户端组件做交互叶子"。遇到"这个页面很卡"时,先检查是否客户端组件过多——把非交互部分移回服务器组件,往往立竿见影。
问:loading.tsx 在什么情况下触发?
路由切换且目标页面的数据(服务器组件取数)尚未完成时,loading.tsx 显示。它配合流式渲染——布局立即显示,loading 占位,数据就绪后流入。
问:error.tsx 和 not-found.tsx 什么时候用哪个?
error 处理运行时错误(取数失败、组件抛错);not-found 处理资源不存在(404)。两者可同时存在,各自负责一类情况。
问:布局的嵌套层级有限制吗?
没有硬性限制,但建议不超过 3-4 层——层级越深,导航时重建的布局越多,性能与维护成本越高。
问:template 有什么典型用途?
需要"每次进入都重置状态"的场景:页面浏览统计(每次进入上报)、动画容器(每次进入重新播放)。大多数场景用 layout 就够。