跳到主要内容

页面与布局

本章将深入介绍 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>
);
}

小结

本章我们学习了:

  1. 页面组件的创建和 Props
  2. 异步页面和数据获取
  3. 页面元数据的配置
  4. 布局组件的创建和嵌套
  5. 模板与布局的区别
  6. 实际应用场景示例

练习

  1. 创建一个带导航栏和页脚的根布局
  2. 实现一个带侧边栏的 dashboard 布局
  3. 创建一个博客文章页面,包含面包屑导航
  4. 为页面添加动态元数据