页面与布局
本章将深入介绍 Next.js 中的页面和布局组件,包括它们的创建方式、数据传递和最佳实践。
页面(Page)
页面是路由的核心,每个 page.tsx 文件对应一个可访问的路由。
基本页面
// src/app/page.tsx
export default function HomePage() {
return (
<main>
<h1>欢迎来到我的网站</h1>
<p>这是首页内容</p>
</main>
);
}
页面 Props
页面组件可以接收以下 Props:
type PageProps = {
params: Record<string, string>; // 动态路由参数
searchParams: Record<string, string>; // 查询参数
};
export default function Page({ params, searchParams }: PageProps) {
return (
<div>
<p>路由参数: {JSON.stringify(params)}</p>
<p>查询参数: {JSON.stringify(searchParams)}</p>
</div>
);
}
异步页面
页面组件可以是异步的,用于数据获取:
// src/app/posts/page.tsx
async function getPosts() {
const res = await fetch("https://api.example.com/posts");
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<ul>
{posts.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
页面元数据
使用 generateMetadata 函数动态生成页面元数据:
// src/app/blog/[slug]/page.tsx
export async function generateMetadata({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.image],
},
};
}
export default function BlogPost({ params }: { params: { slug: string } }) {
return <article>...</article>;
}
静态元数据:
// src/app/about/page.tsx
export const metadata: Metadata = {
title: "关于我们",
description: "了解更多关于我们的信息",
};
export default function AboutPage() {
return <div>关于我们</div>;
}
布局(Layout)
布局是多个页面共享的 UI 组件,在导航时保持状态。
根布局
根布局是必需的,必须包含 <html> 和 <body> 标签:
// src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "我的网站",
description: "使用 Next.js 构建",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<header>网站头部</header>
{children}
<footer>网站底部</footer>
</body>
</html>
);
}
嵌套布局
在子目录中创建布局实现嵌套:
// src/app/dashboard/layout.tsx
import Sidebar from "@/components/sidebar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 p-6">{children}</main>
</div>
);
}
布局 Props
布局组件接收 children 和命名插槽:
type LayoutProps = {
children: React.ReactNode;
// 命名插槽(并行路由)
analytics?: React.ReactNode;
team?: React.ReactNode;
};
export default function Layout({ children, analytics }: LayoutProps) {
return (
<div>
{children}
{analytics}
</div>
);
}
布局不重新渲染
布局在同级页面导航时不会重新渲染:
// src/app/dashboard/layout.tsx
"use client";
import { useState } from "react";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>
点击次数: {count}
</button>
<main>{children}</main>
</div>
);
}
从 /dashboard 导航到 /dashboard/settings 时,计数器状态会保持。
模板(Template)
模板与布局类似,但会在导航时重新创建:
// src/app/template.tsx
"use client";
import { useEffect } from "react";
export default function Template({
children,
}: {
children: React.ReactNode;
}) {
useEffect(() => {
console.log("模板挂载");
return () => console.log("模板卸载");
});
return <div>{children}</div>;
}
布局 vs 模板
| 特性 | 布局 | 模板 |
|---|---|---|
| 状态保持 | 是 | 否 |
| 导航时 | 保持实例 | 重新创建 |
| 性能 | 更好 | 略差 |
| 适用场景 | 共享 UI | 需要重置状态 |
默认组件(Default)
用于并行路由的默认回退组件:
// src/app/dashboard/@analytics/default.tsx
export default function Default() {
return <div>暂无分析数据</div>;
}
页面与布局的关系
app/
├── layout.tsx → 根布局
├── page.tsx → 首页
└── dashboard/
├── layout.tsx → dashboard 布局
├── page.tsx → /dashboard
├── settings/
│ └── page.tsx → /dashboard/settings
└── users/
└── page.tsx → /dashboard/users
渲染 /dashboard/settings 时的组件树:
<RootLayout>
<DashboardLayout>
<SettingsPage />
</DashboardLayout>
</RootLayout>
实战示例
带导航的布局
// src/app/layout.tsx
import Link from "next/link";
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<nav className="bg-gray-800 text-white p-4">
<div className="container mx-auto flex gap-4">
<Link href="/" className="hover:underline">
首页
</Link>
<Link href="/blog" className="hover:underline">
博客
</Link>
<Link href="/about" className="hover:underline">
关于
</Link>
</div>
</nav>
<main className="container mx-auto p-4">{children}</main>
<footer className="bg-gray-100 p-4 text-center">
© 2024 我的网站
</footer>
</body>
</html>
);
}
带侧边栏的嵌套布局
// src/app/dashboard/layout.tsx
import Link from "next/link";
const navItems = [
{ href: "/dashboard", label: "概览" },
{ href: "/dashboard/analytics", label: "分析" },
{ href: "/dashboard/settings", label: "设置" },
];
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen">
<aside className="w-64 bg-gray-100 p-4">
<nav>
<ul className="space-y-2">
{navItems.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="block p-2 hover:bg-gray-200 rounded"
>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
</aside>
<main className="flex-1 p-6">{children}</main>
</div>
);
}
带面包屑的页面
// src/app/blog/[slug]/page.tsx
import Link from "next/link";
type Props = {
params: { slug: string };
};
export default async function BlogPost({ params }: Props) {
const post = await getPost(params.slug);
return (
<article>
<nav className="text-sm mb-4">
<Link href="/">首页</Link>
{" / "}
<Link href="/blog">博客</Link>
{" / "}
<span>{post.title}</span>
</nav>
<h1 className="text-3xl font-bold mb-4">{post.title}</h1>
<div className="prose">{post.content}</div>
</article>
);
}
小结
本章我们学习了:
- 页面组件的创建和 Props
- 异步页面和数据获取
- 页面元数据的配置
- 布局组件的创建和嵌套
- 模板与布局的区别
- 实际应用场景示例
练习
- 创建一个带导航栏和页脚的根布局
- 实现一个带侧边栏的 dashboard 布局
- 创建一个博客文章页面,包含面包屑导航
- 为页面添加动态元数据