Skip to content

app_router.tsx

文件信息

  • 📄 原文件:01_app_router.tsx
  • 🔤 语言:TypeScript (Next.js / React)

Next.js 13+ 引入了基于 React Server Components 的 App Router,采用文件系统路由、嵌套布局、流式渲染等现代特性,是 Next.js 推荐的路由方案。

完整代码

tsx
/**
 * ============================================================
 *                Next.js App Router 路由系统
 * ============================================================
 * Next.js 13+ 引入了基于 React Server Components 的 App Router,
 * 采用文件系统路由、嵌套布局、流式渲染等现代特性,
 * 是 Next.js 推荐的路由方案。
 *
 * 适用版本:Next.js 14 / 15 (App Router)
 * ============================================================
 */

import Link from 'next/link';
import { redirect, notFound } from 'next/navigation';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import type { Metadata, ResolvingMetadata } from 'next';

// ============================================================
//                    1. App Router 基础概念
// ============================================================

/**
 * 【App Router 与 Pages Router 对比】
 *
 * Next.js 有两套路由系统:
 *
 * Pages Router (旧版 - pages/ 目录):
 * - 基于页面的路由
 * - getServerSideProps / getStaticProps 获取数据
 * - _app.tsx / _document.tsx 全局配置
 * - 所有组件默认是客户端组件
 *
 * App Router (新版 - app/ 目录):
 * - 基于文件夹的路由,支持嵌套布局
 * - React Server Components 为默认
 * - 内置 loading / error / not-found 状态处理
 * - 支持并行路由、拦截路由等高级模式
 * - 使用 fetch 的扩展 API 进行数据获取和缓存
 *
 * 【app/ 目录结构】
 *
 * app/
 * ├── layout.tsx          // 根布局(必需)
 * ├── page.tsx            // 首页 → /
 * ├── loading.tsx         // 加载状态
 * ├── error.tsx           // 错误处理
 * ├── not-found.tsx       // 404 页面
 * ├── globals.css         // 全局样式
 * ├── about/
 * │   └── page.tsx        // → /about
 * ├── blog/
 * │   ├── page.tsx        // → /blog
 * │   ├── layout.tsx      // 博客区域布局
 * │   └── [slug]/
 * │       └── page.tsx    // → /blog/:slug
 * └── dashboard/
 *     ├── layout.tsx      // 仪表盘布局
 *     ├── page.tsx        // → /dashboard
 *     └── settings/
 *         └── page.tsx    // → /dashboard/settings
 *
 * 【关键规则】
 * - 只有 page.tsx 会生成可访问的路由
 * - layout.tsx 在导航时不会重新渲染
 * - 文件夹名称直接映射为 URL 路径段
 */

// --- 根布局(每个 App Router 项目都必须有) ---
// app/layout.tsx
export default function RootLayout({
    children,
}: {
    children: React.ReactNode;
}) {
    return (
        <html lang="zh-CN">
            <body>
                {/* 根布局包裹所有页面 */}
                <header>全站导航栏</header>
                <main>{children}</main>
                <footer>全站页脚</footer>
            </body>
        </html>
    );
}

// --- 首页 ---
// app/page.tsx
function HomePage() {
    // page.tsx 导出的组件就是该路由的页面
    return (
        <div>
            <h1>欢迎来到我的网站</h1>
            <p>这是首页,对应路由 /</p>
        </div>
    );
}


// ============================================================
//                    2. 页面与布局
// ============================================================

/**
 * 【page.tsx — 页面文件】
 *
 * page.tsx 是路由的核心文件,只有它能使路由可访问:
 * - 必须 default export 一个 React 组件
 * - 自动接收 params 和 searchParams 作为 props
 * - 每次导航都会重新渲染
 *
 * 【layout.tsx — 布局文件】
 *
 * layout.tsx 提供共享的 UI 结构:
 * - 在路由切换时保持状态(不会重新挂载)
 * - 接收 children prop,嵌套渲染子路由
 * - 支持嵌套布局:子布局自动嵌入父布局中
 *
 * 【template.tsx — 模板文件】
 *
 * 与 layout.tsx 类似,但区别在于:
 * - 每次导航都会重新创建实例(重新挂载)
 * - 不保持状态
 * - 适用于需要每次导航都重新初始化的场景
 *   (如进入/退出动画、每次导航都要记录日志)
 */

// --- 博客列表页面 ---
// app/blog/page.tsx
interface BlogPageProps {
    searchParams: Promise<{ page?: string; category?: string }>;
}

async function BlogPage({ searchParams }: BlogPageProps) {
    // Next.js 15 中 searchParams 是一个 Promise
    const params = await searchParams;
    const page = Number(params.page) || 1;
    const category = params.category || 'all';

    return (
        <div>
            <h1>博客文章列表</h1>
            <p>当前页码: {page},分类: {category}</p>
        </div>
    );
}

// --- 嵌套布局 ---
// app/dashboard/layout.tsx
function DashboardLayout({ children }: { children: React.ReactNode }) {
    return (
        <div className="dashboard">
            {/* 侧边栏在所有仪表盘子页面共享 */}
            <aside className="sidebar">
                <nav>
                    <Link href="/dashboard">概览</Link>
                    <Link href="/dashboard/analytics">分析</Link>
                    <Link href="/dashboard/settings">设置</Link>
                </nav>
            </aside>

            {/* children 是当前激活的子页面 */}
            <section className="content">
                {children}
            </section>
        </div>
    );
}

// --- template.tsx 示例 ---
// app/dashboard/template.tsx
function DashboardTemplate({ children }: { children: React.ReactNode }) {
    // 每次导航到 /dashboard/* 下的任何页面时
    // 这个模板都会重新挂载(layout 则不会)
    console.log('模板重新挂载 — 可以用于记录页面浏览');

    return (
        <div className="template-wrapper">
            {/* 每次导航都会触发动画 */}
            <div className="page-transition">
                {children}
            </div>
        </div>
    );
}


// ============================================================
//                    3. 加载与错误处理
// ============================================================

/**
 * 【loading.tsx — 加载状态】
 *
 * 当页面或布局在加载数据时,自动显示 loading.tsx 的内容:
 * - 基于 React Suspense 实现
 * - 在路由段级别自动创建 Suspense boundary
 * - 即时显示,提升用户体验
 * - 可以是骨架屏、加载动画等
 *
 * 【error.tsx — 错误处理】
 *
 * 当页面渲染出错时,自动显示 error.tsx 的内容:
 * - 基于 React Error Boundary 实现
 * - 必须是客户端组件('use client')
 * - 只捕获子组件的错误,不捕获同级 layout 的错误
 * - 提供 reset 函数用于重试
 *
 * 【not-found.tsx — 404 页面】
 *
 * 当调用 notFound() 函数时显示:
 * - app/not-found.tsx 处理根级 404
 * - 也可以在子路由段中定义局部 not-found
 * - 自动返回 404 HTTP 状态码
 */

// --- loading.tsx ---
// app/blog/loading.tsx
function BlogLoading() {
    return (
        <div className="loading-skeleton">
            {/* 骨架屏 */}
            <div className="skeleton-title" />
            <div className="skeleton-card" />
            <div className="skeleton-card" />
            <div className="skeleton-card" />
        </div>
    );
}

// --- error.tsx(必须是客户端组件)---
// app/blog/error.tsx
// 'use client';  // 实际文件中需要加这个指令

function BlogError({
    error,
    reset,
}: {
    error: Error & { digest?: string };
    reset: () => void;
}) {
    return (
        <div className="error-page">
            <h2>博客加载失败</h2>
            <p>错误信息: {error.message}</p>
            {/* reset 会重新渲染该路由段 */}
            <button onClick={reset}>重试</button>
        </div>
    );
}

// --- not-found.tsx ---
// app/not-found.tsx
function NotFoundPage() {
    return (
        <div className="not-found">
            <h1>404 - 页面不存在</h1>
            <p>您访问的页面不存在或已被移除。</p>
            <Link href="/">返回首页</Link>
        </div>
    );
}

// --- 在页面中使用 notFound() ---
// app/blog/[slug]/page.tsx
async function BlogPostPage({
    params,
}: {
    params: Promise<{ slug: string }>;
}) {
    const { slug } = await params;
    const post = await fetchPost(slug);

    // 当文章不存在时,触发 not-found.tsx
    if (!post) {
        notFound();
    }

    return (
        <article>
            <h1>{post.title}</h1>
            <div>{post.content}</div>
        </article>
    );
}


// ============================================================
//                    4. 路由分组与并行路由
// ============================================================

/**
 * 【路由分组 (group)】
 *
 * 使用 (folderName) 括号包裹文件夹名,可以在不影响 URL 的情况下
 * 组织路由结构:
 *
 * app/
 * ├── (marketing)/
 * │   ├── layout.tsx      // 营销页面共用布局
 * │   ├── about/page.tsx  // → /about(URL 中没有 marketing)
 * │   └── blog/page.tsx   // → /blog
 * ├── (shop)/
 * │   ├── layout.tsx      // 商城页面共用布局
 * │   ├── cart/page.tsx   // → /cart
 * │   └── products/page.tsx // → /products
 * └── layout.tsx          // 根布局
 *
 * 用途:
 * - 为不同功能区域使用不同的布局
 * - 按团队/功能模块组织代码
 * - 创建多个根布局
 *
 * 【并行路由 (@slot)】
 *
 * 使用 @folderName 定义具名插槽,实现在同一页面同时渲染多个路由段:
 *
 * app/dashboard/
 * ├── layout.tsx          // 接收 @analytics@team 作为 props
 * ├── page.tsx            // 默认页面
 * ├── @analytics/
 * │   └── page.tsx        // 分析面板(独立路由段)
 * └── @team/
 *     └── page.tsx        // 团队面板(独立路由段)
 *
 * 特点:
 * - 每个 slot 可以独立加载/错误处理
 * - 支持条件渲染(基于权限等)
 * - slot 不影响 URL 结构
 */

// --- 路由分组示例 ---
// app/(marketing)/layout.tsx
function MarketingLayout({ children }: { children: React.ReactNode }) {
    return (
        <div className="marketing-theme">
            {/* 营销页面专用导航 */}
            <nav className="marketing-nav">
                <Link href="/about">关于我们</Link>
                <Link href="/blog">博客</Link>
                <Link href="/pricing">定价</Link>
            </nav>
            {children}
        </div>
    );
}

// --- 并行路由示例 ---
// app/dashboard/layout.tsx(使用并行路由)
function DashboardLayoutWithSlots({
    children,
    analytics,
    team,
}: {
    children: React.ReactNode;
    analytics: React.ReactNode;
    team: React.ReactNode;
}) {
    return (
        <div className="dashboard-grid">
            {/* 主内容区 */}
            <div className="main">{children}</div>

            {/* 分析面板 — 来自 @analytics/page.tsx */}
            <div className="analytics-panel">{analytics}</div>

            {/* 团队面板 — 来自 @team/page.tsx */}
            <div className="team-panel">{team}</div>
        </div>
    );
}

// --- 并行路由的条件渲染 ---
// app/dashboard/layout.tsx(基于角色)
function ConditionalDashboard({
    children,
    admin,
    user,
}: {
    children: React.ReactNode;
    admin: React.ReactNode;
    user: React.ReactNode;
}) {
    const role = getCurrentUserRole();

    return (
        <div>
            {children}
            {/* 根据用户角色显示不同的并行路由 */}
            {role === 'admin' ? admin : user}
        </div>
    );
}


// ============================================================
//                    5. 动态路由
// ============================================================

/**
 * 【动态路由段】
 *
 * Next.js 支持三种动态路由模式:
 *
 * 1. [slug] — 单段动态路由
 *    - /blog/[slug]  →  匹配 /blog/hello-world
 *    - params: { slug: 'hello-world' }
 *
 * 2. [...slug] — 全捕获路由(Catch-all)
 *    - /docs/[...slug]  →  匹配 /docs/a/b/c
 *    - params: { slug: ['a', 'b', 'c'] }
 *    - 不匹配 /docs(无参数时不匹配)
 *
 * 3. [[...slug]] — 可选全捕获路由(Optional Catch-all)
 *    - /docs/[[...slug]]  →  匹配 /docs 和 /docs/a/b/c
 *    - params: { slug: undefined } 或 { slug: ['a', 'b', 'c'] }
 *    - /docs 也会匹配(参数为 undefined)
 *
 * 【generateStaticParams】
 *
 * 用于在构建时预生成静态路由(替代 getStaticPaths):
 * - 返回一个参数对象数组
 * - 配合动态路由使用
 * - 支持增量静态生成(ISR)
 */

// --- 单段动态路由 ---
// app/blog/[slug]/page.tsx
interface PostPageProps {
    params: Promise<{ slug: string }>;
}

async function PostPage({ params }: PostPageProps) {
    const { slug } = await params;

    // 在服务端组件中直接获取数据
    const post = await fetch(`https://api.example.com/posts/${slug}`);
    const data = await post.json();

    return (
        <article>
            <h1>{data.title}</h1>
            <p>URL 参数: {slug}</p>
        </article>
    );
}

// --- 预生成静态参数 ---
// app/blog/[slug]/page.tsx
async function generateStaticParams() {
    // 构建时获取所有文章的 slug
    const posts = await fetch('https://api.example.com/posts').then(r => r.json());

    return posts.map((post: { slug: string }) => ({
        slug: post.slug,
    }));
    // 返回: [{ slug: 'first-post' }, { slug: 'second-post' }, ...]
}

// --- 全捕获路由 ---
// app/docs/[...slug]/page.tsx
interface DocsPageProps {
    params: Promise<{ slug: string[] }>;
}

async function DocsPage({ params }: DocsPageProps) {
    const { slug } = await params;
    // /docs/getting-started       → slug: ['getting-started']
    // /docs/api/reference/auth    → slug: ['api', 'reference', 'auth']

    const breadcrumbs = slug.map((segment, index) => ({
        label: segment,
        href: '/docs/' + slug.slice(0, index + 1).join('/'),
    }));

    return (
        <div>
            {/* 面包屑导航 */}
            <nav>
                {breadcrumbs.map((crumb) => (
                    <Link key={crumb.href} href={crumb.href}>
                        {crumb.label}
                    </Link>
                ))}
            </nav>
            <h1>文档: {slug.join(' / ')}</h1>
        </div>
    );
}

// --- 可选全捕获路由 ---
// app/shop/[[...categories]]/page.tsx
interface ShopPageProps {
    params: Promise<{ categories?: string[] }>;
}

async function ShopPage({ params }: ShopPageProps) {
    const { categories } = await params;

    // /shop                     → categories: undefined (显示全部)
    // /shop/electronics         → categories: ['electronics']
    // /shop/electronics/phones  → categories: ['electronics', 'phones']

    if (!categories) {
        return <h1>全部商品</h1>;
    }

    return (
        <div>
            <h1>分类: {categories.join(' > ')}</h1>
        </div>
    );
}


// ============================================================
//                    6. 链接与导航
// ============================================================

/**
 * 【Link 组件】
 *
 * Next.js 的 <Link> 组件提供客户端导航:
 * - 自动预取可见链接指向的路由(生产环境)
 * - 不会导致整页刷新
 * - 支持 prefetch 控制
 * - 替代原生 <a> 标签进行应用内导航
 *
 * 【useRouter Hook】
 *
 * 用于程序化导航(只能在客户端组件中使用):
 * - router.push(url)    → 导航到新页面
 * - router.replace(url) → 替换当前历史记录
 * - router.refresh()    → 刷新当前路由(重新获取数据)
 * - router.back()       → 返回上一页
 * - router.prefetch(url) → 预取路由
 *
 * 【usePathname / useSearchParams】
 *
 * 客户端组件中读取 URL 信息:
 * - usePathname() → 当前路径(如 /blog/hello)
 * - useSearchParams() → 查询参数(如 ?page=1&sort=date)
 *
 * 【redirect() 函数】
 *
 * 服务端重定向:
 * - 在 Server Component 或 Server Action 中使用
 * - 会抛出 NEXT_REDIRECT 错误(内部机制)
 * - 默认 307 临时重定向,可指定 308 永久重定向
 */

// --- Link 组件 ---
function Navigation() {
    return (
        <nav>
            {/* 基本链接 */}
            <Link href="/">首页</Link>

            {/* 动态路由链接 */}
            <Link href="/blog/hello-world">文章详情</Link>

            {/* 使用对象形式 */}
            <Link
                href={{
                    pathname: '/blog',
                    query: { page: '2', sort: 'date' },
                }}
            >
                博客第二页
            </Link>

            {/* 控制预取行为 */}
            <Link href="/heavy-page" prefetch={false}>
                不预取的页面
            </Link>

            {/* 替换历史记录(而非推入) */}
            <Link href="/new-page" replace>
                替换导航
            </Link>

            {/* 滚动到页面顶部(默认为 true) */}
            <Link href="/about" scroll={false}>
                不滚动到顶部
            </Link>
        </nav>
    );
}

// --- useRouter 程序化导航 ---
// 'use client';  // 实际文件中需要加这个指令

function SearchForm() {
    const router = useRouter();

    function handleSearch(term: string) {
        // 程序化导航
        router.push(`/search?q=${encodeURIComponent(term)}`);
    }

    function handleLogout() {
        // 清除登录状态后重定向
        clearSession();
        router.replace('/login');  // replace 不会在历史中留下记录
    }

    function handleDataUpdate() {
        // 刷新当前路由(重新执行 Server Component)
        router.refresh();
    }

    return (
        <div>
            <input onKeyDown={(e) => {
                if (e.key === 'Enter') {
                    handleSearch((e.target as HTMLInputElement).value);
                }
            }} />
            <button onClick={handleLogout}>退出登录</button>
            <button onClick={handleDataUpdate}>刷新数据</button>
            <button onClick={() => router.back()}>返回上一页</button>
        </div>
    );
}

// --- usePathname 和 useSearchParams ---
// 'use client';

function ActiveLink({ href, children }: { href: string; children: React.ReactNode }) {
    const pathname = usePathname();
    const isActive = pathname === href;

    return (
        <Link
            href={href}
            className={isActive ? 'text-blue-600 font-bold' : 'text-gray-600'}
        >
            {children}
        </Link>
    );
}

// 'use client';
function SearchFilters() {
    const searchParams = useSearchParams();
    const router = useRouter();
    const pathname = usePathname();

    // 读取当前搜索参数
    const currentSort = searchParams.get('sort') || 'newest';
    const currentPage = Number(searchParams.get('page')) || 1;

    function updateFilter(key: string, value: string) {
        // 创建新的 URLSearchParams
        const params = new URLSearchParams(searchParams.toString());
        params.set(key, value);

        // 更新 URL(不刷新页面)
        router.push(`${pathname}?${params.toString()}`);
    }

    return (
        <div>
            <select
                value={currentSort}
                onChange={(e) => updateFilter('sort', e.target.value)}
            >
                <option value="newest">最新</option>
                <option value="popular">最热</option>
            </select>
            <p>当前页码: {currentPage}</p>
        </div>
    );
}

// --- 服务端重定向 ---
// app/old-blog/[slug]/page.tsx(服务端组件中使用 redirect)
async function OldBlogRedirect({
    params,
}: {
    params: Promise<{ slug: string }>;
}) {
    const { slug } = await params;

    // 永久重定向到新的 URL
    redirect(`/blog/${slug}`);
    // 308 永久重定向:redirect(`/blog/${slug}`, RedirectType.permanent);
}


// ============================================================
//                    7. 路由拦截
// ============================================================

/**
 * 【拦截路由(Intercepting Routes)】
 *
 * 拦截路由可以在当前布局中加载另一个路由的内容,
 * 而不需要切换到目标路由的完整上下文。
 *
 * 典型场景:
 * - 在信息流中点击照片,弹出模态框显示大图
 * - 直接访问照片 URL 时,显示完整的照片页面
 * - 用户分享照片链接时,看到的是完整页面
 *
 * 【拦截约定】
 *
 * (.)   — 匹配同级路由段
 * (..)  — 匹配上一级路由段
 * (..)(..) — 匹配上两级路由段
 * (...) — 匹配根路由(app 目录)
 *
 * 注意:这些约定基于路由段层级,而非文件系统目录。
 *
 * 【目录结构示例 — 照片模态框】
 *
 * app/
 * ├── feed/
 * │   ├── page.tsx               // 信息流页面
 * │   └── (..)photo/[id]/
 * │       └── page.tsx           // 拦截路由 → 显示为模态框
 * └── photo/[id]/
 *     └── page.tsx               // 真实路由 → 显示完整页面
 *
 * 当从 /feed 点击链接到 /photo/123:
 * - 拦截路由介入 → 在当前页面弹出模态框
 * 当直接访问 /photo/123:
 * - 正常渲染 → 显示完整的照片页面
 */

// --- 信息流页面 ---
// app/feed/page.tsx
function FeedPage() {
    const photos = [
        { id: '1', url: '/images/photo1.jpg', title: '日落' },
        { id: '2', url: '/images/photo2.jpg', title: '山川' },
        { id: '3', url: '/images/photo3.jpg', title: '大海' },
    ];

    return (
        <div className="photo-grid">
            {photos.map((photo) => (
                // 点击时,拦截路由会在模态框中显示
                <Link key={photo.id} href={`/photo/${photo.id}`}>
                    <img src={photo.url} alt={photo.title} />
                </Link>
            ))}
        </div>
    );
}

// --- 拦截路由(模态框形式)---
// app/feed/(..)photo/[id]/page.tsx
async function InterceptedPhotoPage({
    params,
}: {
    params: Promise<{ id: string }>;
}) {
    const { id } = await params;

    return (
        <div className="modal-overlay">
            <div className="modal-content">
                <img src={`/images/photo${id}.jpg`} alt={`照片 ${id}`} />
                <Link href="/feed">关闭</Link>
            </div>
        </div>
    );
}

// --- 真实路由(完整页面)---
// app/photo/[id]/page.tsx
async function FullPhotoPage({
    params,
}: {
    params: Promise<{ id: string }>;
}) {
    const { id } = await params;

    return (
        <div className="photo-detail">
            <img src={`/images/photo${id}.jpg`} alt={`照片 ${id}`} />
            <h1>照片详情 #{id}</h1>
            <p>直接访问或分享此链接时显示完整页面</p>
        </div>
    );
}


// ============================================================
//                    8. 元数据
// ============================================================

/**
 * 【元数据 API】
 *
 * Next.js App Router 提供两种定义元数据的方式:
 *
 * 1. 静态元数据 — 导出 metadata 对象
 *    适用于不依赖动态数据的页面
 *
 * 2. 动态元数据 — 导出 generateMetadata 函数
 *    适用于依赖路由参数或外部数据的页面
 *
 * 【元数据合并规则】
 *
 * - 元数据从根布局到页面逐层合并
 * - 子级元数据会覆盖父级的同名字段
 * - title.template 可以在布局中定义模板
 *
 * 【支持的元数据字段】
 *
 * - title: 页面标题(支持 template)
 * - description: 页面描述
 * - keywords: 关键词
 * - openGraph: Open Graph 社交分享
 * - twitter: Twitter 卡片
 * - robots: 搜索引擎指令
 * - icons: 网站图标
 * - manifest: PWA manifest
 */

// --- 静态元数据 ---
// app/about/page.tsx
export const metadata: Metadata = {
    title: '关于我们',
    description: '了解我们的团队和使命',
    keywords: ['关于', '团队', '使命'],

    // Open Graph(用于社交媒体分享)
    openGraph: {
        title: '关于我们 — MyApp',
        description: '了解我们的团队和使命',
        url: 'https://myapp.com/about',
        siteName: 'MyApp',
        images: [
            {
                url: 'https://myapp.com/og-about.jpg',
                width: 1200,
                height: 630,
                alt: '关于我们',
            },
        ],
        locale: 'zh_CN',
        type: 'website',
    },

    // 搜索引擎指令
    robots: {
        index: true,
        follow: true,
    },
};

// --- 根布局中的 title 模板 ---
// app/layout.tsx
export const rootMetadata: Metadata = {
    title: {
        default: 'MyApp',            // 当子页面没有定义 title 时使用
        template: '%s | MyApp',       // 子页面的 title 会替换 %s
    },
    description: '全栈学习路线图',
};
// 效果:about 页面标题会是 "关于我们 | MyApp"

// --- 动态元数据 ---
// app/blog/[slug]/page.tsx
async function generateMetadataForPost(
    { params }: { params: Promise<{ slug: string }> },
    parent: ResolvingMetadata,
): Promise<Metadata> {
    const { slug } = await params;

    // 获取文章数据
    const post = await fetch(`https://api.example.com/posts/${slug}`).then(
        (r) => r.json()
    );

    // 获取父级元数据(可选)
    const previousImages = (await parent).openGraph?.images || [];

    return {
        title: post.title,
        description: post.excerpt,
        openGraph: {
            title: post.title,
            description: post.excerpt,
            images: [post.coverImage, ...previousImages],
        },
    };
}

// --- 文件约定元数据 ---
/**
 * 除了代码定义,还可以通过特殊文件名定义元数据:
 *
 * app/
 * ├── favicon.ico           // 网站图标
 * ├── icon.png              // 应用图标
 * ├── apple-icon.png        // Apple 触摸图标
 * ├── opengraph-image.jpg   // Open Graph 默认图片
 * ├── twitter-image.jpg     // Twitter 卡片图片
 * ├── sitemap.ts            // 站点地图(可动态生成)
 * └── robots.ts             // robots.txt(可动态生成)
 */


// ============================================================
//                    9. 最佳实践
// ============================================================

/**
 * 【App Router 路由最佳实践】
 *
 * ✅ 推荐做法:
 * 1. 优先使用 App Router 而非 Pages Router(新项目)
 * 2. 合理使用嵌套布局,将共享 UI 放在 layout.tsx 中
 * 3. 使用 loading.tsx 提供即时加载反馈
 * 4. 使用 error.tsx 优雅处理每个路由段的错误
 * 5. 使用路由分组 (group) 组织代码结构,保持 URL 简洁
 * 6. 动态路由配合 generateStaticParams 预生成静态页面
 * 7. 使用 <Link> 组件而非 <a> 标签进行应用内导航
 * 8. 服务端组件中使用 redirect(),客户端组件中使用 useRouter
 * 9. 利用 metadata API 做好 SEO 优化
 * 10. 使用 Suspense 包裹异步组件实现流式渲染
 *
 * ❌ 避免做法:
 * 1. 在客户端组件中使用 redirect() → 应使用 useRouter
 * 2. 在 layout.tsx 中放置需要每次导航都刷新的逻辑 → 使用 template.tsx
 * 3. 过度使用并行路由增加复杂性 → 简单场景用组件组合即可
 * 4. 在 page.tsx 中定义布局相关的 UI → 放到 layout.tsx 中
 * 5. 忘记为 error.tsx 添加 'use client' 指令 → Error Boundary 必须是客户端组件
 * 6. 滥用动态路由 → 能用静态路由就用静态路由
 * 7. 在服务端组件中使用 useRouter/usePathname → 这些是客户端 Hook
 * 8. 将大量数据通过 searchParams 传递 → 使用服务端状态或数据库
 */

// --- 示例:结构良好的路由设计 ---
/**
 * 一个电商项目的路由结构示例:
 *
 * app/
 * ├── layout.tsx                    // 全局布局(导航 + 页脚)
 * ├── page.tsx                      // 首页
 * ├── loading.tsx                   // 全局加载状态
 * ├── error.tsx                     // 全局错误处理
 * ├── not-found.tsx                 // 全局 404
 * │
 * ├── (marketing)/                  // 营销页面组(共享营销布局)
 * │   ├── layout.tsx
 * │   ├── about/page.tsx
 * │   └── pricing/page.tsx
 * │
 * ├── (shop)/                       // 商城功能组
 * │   ├── layout.tsx                // 商城布局(带分类侧栏)
 * │   ├── products/
 * │   │   ├── page.tsx              // 商品列表
 * │   │   ├── loading.tsx           // 列表加载骨架屏
 * │   │   └── [id]/
 * │   │       ├── page.tsx          // 商品详情
 * │   │       └── loading.tsx
 * │   └── cart/page.tsx             // 购物车
 * │
 * └── dashboard/                    // 用户中心
 *     ├── layout.tsx                // 仪表盘布局
 *     ├── page.tsx                  // 概览
 *     ├── @orders/page.tsx          // 订单面板(并行路由)
 *     ├── @notifications/page.tsx   // 通知面板(并行路由)
 *     └── settings/page.tsx         // 设置
 */


// ============================================================
//                    辅助函数(示例用)
// ============================================================

// 模拟数据获取
async function fetchPost(slug: string) {
    return { title: `文章: ${slug}`, content: '文章内容...' };
}

// 模拟获取用户角色
function getCurrentUserRole(): string {
    return 'admin';
}

// 模拟清除会话
function clearSession() {
    // 清除登录状态
}

💬 讨论

使用 GitHub 账号登录后即可参与讨论

基于 MIT 许可发布