Next.js: 综合项目:仪表盘与 PPR
最后更新:2026-08-26
仪表盘是 SaaS 平台的"驾驶舱"——静态骨架让你瞬间看到布局,动态数据流实时填充核心指标。
1. 你将学到
- 构建全局导航布局(Sidebar + TopNav + Breadcrumb)
- Dashboard PPR 静态壳模式(
<Suspense>包裹动态 Widget) Promise.all并行数据获取多个独立数据源error.tsx全局错误边界 +loading.tsx骨架屏设计generateStaticParams预生成静态项目详情页- 活动流数据聚合查询
2. 一个前端架构师的真实故事
(1) 痛点:Dashboard 加载 8 秒,用户全跑了
Alice 的 TaskFlow 在 Beta 测试中收到 50 条用户反馈——92% 抱怨 Dashboard 加载速度。问题出在串行数据获取:先查项目数 → 再查任务数 → 再查成员数 → 最后查活动流,4 个 API 请求瀑布式阻塞,总耗时 8.2 秒。Charlie(市场营销)说:"我等了 10 秒,直接关掉了页面。"
(2) PPR 静态壳 + 并行数据获取的解法
用 PPR 静态壳(Static Shell)让页面骨架瞬间渲染,再用
Promise.all并行加载 4 个数据源。
// PPR 静态壳 + 并行数据获取
export default function DashboardPage() {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Suspense fallback={<StatCardSkeleton />}>
<ProjectCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<TaskCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<MemberCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<ActivityFeedWidget />
</Suspense>
</div>
)
}
(3) 收益
| 维度 | 串行加载 | PPR + 并行加载 |
|---|---|---|
| 首屏渲染时间 | 8.2 秒 | 0.3 秒(静态壳) |
| 数据加载完成 | 8.2 秒 | 2.1 秒(并行) |
| 用户体验 | 白屏 → 跳转 | 骨架 → 渐进填充 |
| LCP | 7.8 秒 | 1.2 秒 |
| 用户留存 | 8% 跳出率 | 2% 跳出率 |
3. 全局导航布局
(1) 布局架构图
graph TB
subgraph "Root Layout"
A[RootLayout<br/>html/body/fonts]
end
subgraph "Dashboard Layout"
B[Sidebar<br/>Organization<br/>Navigation<br/>User Menu]
C[TopNav<br/>Search Bar<br/>Notifications<br/>Breadcrumb]
D[Main Content<br/>{children}]
end
A --> B
B --> C
C --> D
style A fill:#cce5ff
style B fill:#d4edda
style C fill:#ffeeba
style D fill:#f8d7da
(2) Dashboard 布局组件
// src/app/(dashboard)/layout.tsx
import { Sidebar } from "@/components/layout/sidebar"
import { TopNav } from "@/components/layout/top-nav"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
if (!session?.user) {
redirect("/auth/signin")
}
return (
<div className="flex h-screen overflow-hidden">
<Sidebar user={session.user} />
<div className="flex flex-1 flex-col overflow-hidden">
<TopNav user={session.user} />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
)
}
▶ 示例:侧边栏组件
// src/components/layout/sidebar.tsx
import Link from "next/link"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { prisma } from "@/lib/db"
interface SidebarProps {
user: {
id: string
name?: string | null
email?: string | null
image?: string | null
}
}
export async function Sidebar({ user }: SidebarProps) {
const org = await prisma.user.findUnique({
where: { id: user.id },
include: {
organization: {
include: {
_count: { select: { projects: true, users: true } },
},
},
},
})
const organization = org?.organization
const initials = user.name
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
return (
<aside className="flex w-64 flex-col border-r bg-card">
<div className="flex h-14 items-center gap-2 border-b px-6">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground">
TF
</div>
<span className="text-lg font-semibold">TaskFlow</span>
</div>
<div className="flex-1 space-y-1 p-4">
<div className="mb-4 rounded-lg bg-muted p-3">
<p className="text-sm font-medium">{organization?.name}</p>
<p className="text-xs text-muted-foreground">
{organization?._count.projects} projects · {organization?._count.users} members
</p>
</div>
<NavItem href="/dashboard" icon="LayoutDashboard" label="Dashboard" />
<NavItem href="/dashboard/projects" icon="FolderKanban" label="Projects" />
<NavItem href="/dashboard/tasks" icon="CheckSquare" label="Tasks" />
<NavItem href="/dashboard/team" icon="Users" label="Team" />
<NavItem href="/dashboard/settings" icon="Settings" label="Settings" />
<Separator className="my-4" />
<p className="mb-2 px-3 text-xs font-medium text-muted-foreground">
INVITE CODE
</p>
<div className="rounded-md border bg-background p-2 text-center font-mono text-sm">
{organization?.inviteCode}
</div>
</div>
<div className="border-t p-4">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={user.image ?? undefined} />
<AvatarFallback>{initials || "U"}</AvatarFallback>
</Avatar>
<div className="flex-1 truncate">
<p className="text-sm font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
</div>
</div>
</div>
</aside>
)
}
function NavItem({
href,
icon,
label,
}: {
href: string
icon: string
label: string
}) {
const iconMap: Record<string, string> = {
LayoutDashboard: "\u2302",
FolderKanban: "\u25A1",
CheckSquare: "\u2713",
Users: "\u263A",
Settings: "\u2699",
}
return (
<Link
href={href}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<span className="text-lg">{iconMap[icon] || "\u2022"}</span>
{label}
</Link>
)
}
▶ 示例:顶部导航栏
// src/components/layout/top-nav.tsx
import { Breadcrumb } from "@/components/layout/breadcrumb"
export function TopNav() {
return (
<header className="flex h-14 items-center gap-4 border-b bg-background px-6">
<Breadcrumb />
<div className="ml-auto flex items-center gap-4">
<button
type="button"
className="relative rounded-full p-1 text-muted-foreground hover:text-foreground"
>
<span className="text-lg">\uD83D\uDD14</span>
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground">
3
</span>
</button>
<button
type="button"
className="rounded-full p-1 text-muted-foreground hover:text-foreground"
>
<span className="text-lg">\u2753</span>
</button>
</div>
</header>
)
}
▶ 示例:面包屑导航
// src/components/layout/breadcrumb.tsx
"use client"
import { usePathname } from "next/navigation"
import Link from "next/link"
const routeLabels: Record<string, string> = {
dashboard: "Dashboard",
projects: "Projects",
tasks: "Tasks",
team: "Team",
settings: "Settings",
}
export function Breadcrumb() {
const pathname = usePathname()
const segments = pathname.split("/").filter(Boolean)
return (
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-sm">
<Link href="/dashboard" className="text-muted-foreground hover:text-foreground">
Home
</Link>
{segments.slice(1).map((segment, index) => {
const href = "/" + segments.slice(0, index + 2).join("/")
const label = routeLabels[segment] || segment.replace(/-/g, " ")
const isLast = index === segments.length - 2
return (
<span key={segment} className="flex items-center gap-2">
<span className="text-muted-foreground">/</span>
{isLast ? (
<span className="font-medium text-foreground">{label}</span>
) : (
<Link href={href} className="text-muted-foreground hover:text-foreground">
{label}
</Link>
)}
</span>
)
})}
</nav>
)
}
4. Dashboard PPR 静态壳
(1) PPR 工作原理
sequenceDiagram
participant B as Browser
participant N as Next.js Server
participant DB as Database
B->>N: GET /dashboard
N->>B: Static Shell (HTML + CSS)<br/>Skeleton cards visible
B->>N: Streaming starts
N->>DB: Promise.all<br/>(4 parallel queries)
DB-->>N: Results
N->>B: Dynamic content fills skeletons
Note over B: Progressive enhancement
(2) 开启 PPR
// next.config.ts
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
}
export default nextConfig
▶ 示例:Dashboard 页面 PPR 实现
// src/app/(dashboard)/dashboard/page.tsx
import { Suspense } from "react"
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
import { ProjectCountWidget } from "@/components/dashboard/project-count-widget"
import { TaskCountWidget } from "@/components/dashboard/task-count-widget"
import { MemberCountWidget } from "@/components/dashboard/member-count-widget"
import { ActivityFeedWidget } from "@/components/dashboard/activity-feed-widget"
import { WelcomeBanner } from "@/components/dashboard/welcome-banner"
export default function DashboardPage() {
return (
<div className="space-y-6">
<Suspense fallback={<div className="h-24 rounded-lg bg-muted animate-pulse" />}>
<WelcomeBanner />
</Suspense>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Suspense fallback={<StatCardSkeleton />}>
<ProjectCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<TaskCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<MemberCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<TaskCompletionWidget />
</Suspense>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<Suspense fallback={<div className="h-80 rounded-lg bg-muted animate-pulse" />}>
<ActivityFeedWidget />
</Suspense>
</div>
<div>
<Suspense fallback={<div className="h-80 rounded-lg bg-muted animate-pulse" />}>
<RecentProjectsWidget />
</Suspense>
</div>
</div>
</div>
)
}
(3) 数据获取 Widget 实现
// src/components/dashboard/project-count-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export async function ProjectCountWidget() {
const session = await auth()
const count = await prisma.project.count({
where: {
organization: {
users: { some: { id: session?.user?.id } },
},
status: "ACTIVE",
},
})
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Active Projects</CardTitle>
<span className="text-2xl">\uD83D\uDCC1</span>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{count}</div>
<p className="text-xs text-muted-foreground">Across your organization</p>
</CardContent>
</Card>
)
}
// src/components/dashboard/task-count-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export async function TaskCountWidget() {
const session = await auth()
const counts = await prisma.task.groupBy({
by: ["status"],
where: {
project: {
organization: {
users: { some: { id: session?.user?.id } },
},
},
},
_count: true,
})
const total = counts.reduce((acc, c) => acc + c._count, 0)
const todo = counts.find((c) => c.status === "TODO")?._count || 0
const inProgress = counts.find((c) => c.status === "IN_PROGRESS")?._count || 0
const done = counts.find((c) => c.status === "DONE")?._count || 0
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Tasks</CardTitle>
<span className="text-2xl">\u2705</span>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{total}</div>
<div className="mt-2 flex gap-2 text-xs text-muted-foreground">
<span className="text-yellow-500">{todo} todo</span>
<span className="text-blue-500">{inProgress} in progress</span>
<span className="text-green-500">{done} done</span>
</div>
</CardContent>
</Card>
)
}
5. 并行数据获取
(1) 串行 vs 并行对比
graph LR
subgraph "Serial (8.2s)"
A1[Fetch A] --> A2[Fetch B] --> A3[Fetch C] --> A4[Fetch D]
end
subgraph "Parallel (2.1s)"
B1[Fetch A]
B2[Fetch B]
B3[Fetch C]
B4[Fetch D]
B1 --> C[Promise.all]
B2 --> C
B3 --> C
B4 --> C
end
style A4 fill:#f8d7da
style C fill:#d4edda
▶ 示例:Promise.all 并行获取活动流
// src/components/dashboard/activity-feed-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
export async function ActivityFeedWidget() {
const session = await auth()
const [recentTasks, recentComments] = await Promise.all([
prisma.task.findMany({
where: {
project: {
organization: {
users: { some: { id: session?.user?.id } },
},
},
},
orderBy: { createdAt: "desc" },
take: 10,
include: {
creator: { select: { name: true, image: true } },
project: { select: { name: true } },
},
}),
prisma.comment.findMany({
where: {
task: {
project: {
organization: {
users: { some: { id: session?.user?.id } },
},
},
},
},
orderBy: { createdAt: "desc" },
take: 10,
include: {
author: { select: { name: true, image: true } },
task: { select: { title: true } },
},
}),
])
const activities = [
...recentTasks.map((t) => ({
id: t.id,
type: "task_created" as const,
user: t.creator,
description: `created task "${t.title}"`,
project: t.project.name,
time: t.createdAt,
})),
...recentComments.map((c) => ({
id: c.id,
type: "comment_added" as const,
user: c.author,
description: `commented on "${c.task.title}"`,
project: "",
time: c.createdAt,
})),
]
.sort((a, b) => b.time.getTime() - a.time.getTime())
.slice(0, 10)
return (
<Card>
<CardHeader>
<CardTitle>Activity Feed</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-start gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={activity.user.image ?? undefined} />
<AvatarFallback>
{activity.user.name?.[0] || "U"}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<p className="text-sm">
<span className="font-medium">{activity.user.name}</span>
{" "}{activity.description}
</p>
<p className="text-xs text-muted-foreground">
{activity.project && `${activity.project} · `}
{activity.time.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
))}
{activities.length === 0 && (
<p className="text-sm text-muted-foreground">
No recent activity
</p>
)}
</div>
</CardContent>
</Card>
)
}
(2) 数据获取策略对比
| 策略 | 总耗时 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 串行 await | Sum(N) | 简单 | 数据有依赖关系 |
| Promise.all | Max(N) | 中等 | 独立数据源 |
| Promise.allSettled | Max(N) | 较高 | 部分失败可容忍 |
| Streaming + PPR | TTFB ~0 | 高 | 首屏优先 |
6. 错误边界与加载骨架
(1) 错误边界层级
graph TB
A[RootLayout] --> B[error.tsx<br/>Global Error]
A --> C[DashboardLayout]
C --> D[error.tsx<br/>Dashboard Error]
D --> E[Dashboard Page]
E --> F[Widget 1<br/>Suspense fallback]
E --> G[Widget 2<br/>Suspense fallback]
E --> H[Widget 3<br/>Error Boundary]
style B fill:#f8d7da
style D fill:#f8d7da
style F fill:#d4edda
style G fill:#d4edda
▶ 示例:全局错误边界
// src/app/(dashboard)/error.tsx
"use client"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex min-h-[400px] items-center justify-center p-6">
<Card className="mx-auto max-w-md">
<CardHeader>
<CardTitle className="text-destructive">Something went wrong</CardTitle>
<CardDescription>
An unexpected error occurred while loading the dashboard.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error.message || "Unknown error"}
</div>
{error.digest && (
<p className="mt-2 text-xs text-muted-foreground">
Error ID: {error.digest}
</p>
)}
</CardContent>
<CardFooter>
<Button onClick={() => reset()} variant="outline" className="w-full">
Try again
</Button>
</CardFooter>
</Card>
</div>
)
}
▶ 示例:骨架屏组件
// src/components/dashboard/stat-card-skeleton.tsx
import { Card, CardContent, CardHeader } from "@/components/ui/card"
export function StatCardSkeleton() {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-6 w-6 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="mb-2 h-9 w-16 animate-pulse rounded bg-muted" />
<div className="h-3 w-32 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
)
}
// src/app/(dashboard)/dashboard/loading.tsx
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
export default function DashboardLoading() {
return (
<div className="space-y-6">
<div className="h-24 animate-pulse rounded-lg bg-muted" />
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<div className="h-80 animate-pulse rounded-lg bg-muted" />
</div>
<div>
<div className="h-80 animate-pulse rounded-lg bg-muted" />
</div>
</div>
</div>
)
}
7. 静态项目详情页
(1) 静态页面生成策略
| 策略 | 生成时机 | 数据新鲜度 | 构建时间 | 适用场景 |
|---|---|---|---|---|
| SSG (generateStaticParams) | 构建时 | 构建时的快照 | 慢(N 页) | 内容很少变化 |
| ISR (revalidate) | 构建时 + 按需 | 滞后 TTL | 慢(N 页) | 定期更新 |
| SSR (dynamic) | 请求时 | 实时 | 快(无需 N) | 实时数据 |
| PPR | 构建时 + 流式 | 实时动态部分 | 快(静态壳) | 混合场景 |
▶ 示例:项目详情页静态生成
// src/app/(dashboard)/dashboard/projects/[id]/page.tsx
import { notFound } from "next/navigation"
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ProjectTasks } from "@/components/projects/project-tasks"
import { ProjectMembers } from "@/components/projects/project-members"
export async function generateStaticParams() {
const projects = await prisma.project.findMany({
select: { id: true },
where: { status: "ACTIVE" },
})
return projects.map((project) => ({
id: project.id,
}))
}
export default async function ProjectDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const session = await auth()
const project = await prisma.project.findUnique({
where: { id },
include: {
organization: true,
creator: { select: { name: true, image: true } },
_count: { select: { tasks: true } },
},
})
if (!project) {
notFound()
}
const statusColors: Record<string, string> = {
ACTIVE: "bg-green-500",
ARCHIVED: "bg-gray-500",
COMPLETED: "bg-blue-500",
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: project.color }}
/>
<h1 className="text-2xl font-bold">{project.name}</h1>
<Badge
variant="secondary"
className={statusColors[project.status]}
>
{project.status}
</Badge>
</div>
{project.description && (
<p className="mt-1 text-muted-foreground">
{project.description}
</p>
)}
<div className="mt-2 flex items-center gap-4 text-sm text-muted-foreground">
<span>Created by {project.creator.name}</span>
<span>{project._count.tasks} tasks</span>
<span>{project.organization.name}</span>
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<ProjectTasks projectId={project.id} />
</div>
<div>
<ProjectMembers projectId={project.id} />
</div>
</div>
</div>
)
}
// src/components/projects/project-tasks.tsx
import { prisma } from "@/lib/db"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
interface ProjectTasksProps {
projectId: string
}
const statusBadge: Record<string, string> = {
TODO: "bg-yellow-100 text-yellow-800",
IN_PROGRESS: "bg-blue-100 text-blue-800",
DONE: "bg-green-100 text-green-800",
}
export async function ProjectTasks({ projectId }: ProjectTasksProps) {
const tasks = await prisma.task.findMany({
where: { projectId },
orderBy: { order: "asc" },
include: {
assignee: { select: { name: true, image: true } },
},
})
return (
<Card>
<CardHeader>
<CardTitle>Tasks ({tasks.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{tasks.map((task) => (
<div
key={task.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<input
type="checkbox"
defaultChecked={task.status === "DONE"}
className="h-4 w-4 rounded border-gray-300"
/>
<span
className={
task.status === "DONE" ? "text-muted-foreground line-through" : ""
}
>
{task.title}
</span>
</div>
<div className="flex items-center gap-2">
<Badge className={statusBadge[task.status]} variant="secondary">
{task.status.replace("_", " ")}
</Badge>
{task.assignee && (
<span className="text-xs text-muted-foreground">
{task.assignee.name}
</span>
)}
</div>
</div>
))}
{tasks.length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">
No tasks yet. Create your first task to get started.
</p>
)}
</div>
</CardContent>
</Card>
)
}
8. Router Groups 与布局组织
(1) Route Groups 结构
src/app/
├── (auth)/ # Auth 路由组(无侧边栏)
│ └── auth/
│ ├── signin/
│ └── signup/
├── (dashboard)/ # Dashboard 路由组(有侧边栏)
│ ├── layout.tsx # Dashboard 布局(Sidebar + TopNav)
│ ├── dashboard/
│ │ └── page.tsx
│ └── projects/
│ └── [id]/
│ └── page.tsx
├── layout.tsx # 根布局(html/body)
└── page.tsx # 首页(公开)
▶ 示例:根布局与字体优化
// src/app/layout.tsx
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import "./globals.css"
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
})
export const metadata: Metadata = {
title: {
default: "TaskFlow - Project Management",
template: "%s | TaskFlow",
},
description: "Enterprise project management platform for modern teams",
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={inter.variable}>
<body className="min-h-screen bg-background font-sans antialiased">
{children}
</body>
</html>
)
}
9. 完整示例:Dashboard 完整页面
// src/app/(dashboard)/dashboard/page.tsx
import { Suspense } from "react"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
import { ProjectCountWidget } from "@/components/dashboard/project-count-widget"
import { TaskCountWidget } from "@/components/dashboard/task-count-widget"
import { MemberCountWidget } from "@/components/dashboard/member-count-widget"
import { TaskCompletionWidget } from "@/components/dashboard/task-completion-widget"
import { ActivityFeedWidget } from "@/components/dashboard/activity-feed-widget"
import { RecentProjectsWidget } from "@/components/dashboard/recent-projects-widget"
import { WelcomeBanner } from "@/components/dashboard/welcome-banner"
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) {
redirect("/auth/signin")
}
return (
<div className="space-y-6">
<Suspense fallback={<div className="h-24 animate-pulse rounded-lg bg-muted" />}>
<WelcomeBanner user={session.user} />
</Suspense>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Suspense fallback={<StatCardSkeleton />}>
<ProjectCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<TaskCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<MemberCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<TaskCompletionWidget />
</Suspense>
</div>
<div className="grid gap-6 lg:grid-cols-7">
<div className="lg:col-span-4">
<Suspense
fallback={
<div className="h-96 animate-pulse rounded-lg bg-muted" />
}
>
<ActivityFeedWidget />
</Suspense>
</div>
<div className="lg:col-span-3">
<Suspense
fallback={
<div className="h-96 animate-pulse rounded-lg bg-muted" />
}
>
<RecentProjectsWidget />
</Suspense>
</div>
</div>
</div>
)
}
预期输出(浏览器访问 /dashboard):
→ 瞬间看到:侧边栏 + 顶部导航 + 面包屑(静态壳)
→ 0.3s: 欢迎横幅出现
→ 0.5s: 4 个统计卡片骨架 → 填充数据(12 / 50 / 5 / 68%)
→ 1.2s: 活动流 + 最近项目列表
→ 页面完全交互
❓ 常见问题
<Suspense> 是组件级的流式加载。PPR 可以在构建时预先生成静态 HTML,动态部分通过流式填充;loading.tsx 则是完全动态渲染。Promise.all 是"一个失败全部失败"策略。对于非关键数据(如活动流),用 Promise.allSettled 更安全——即使活动流失败,统计数据仍然能显示。错误边界 + fallback 提供降级体验。usePathname 是客户端 Hook。Breadcrumb 组件很小(无数据获取),客户端渲染无性能问题。如果在意,可以用服务端的 headers().get("next-url") 实现,但代码复杂度更高。/dashboard 路由,但 Layout 中再次检查是"深度防御"——防止直接访问嵌套路由(如 /dashboard/projects/xxx)时绕过检查。Next.js 建议在布局和数据获取层都做认证检查。📖 小节
- Dashboard Layout 使用 Route Groups
(dashboard)隔离需要侧边栏的路由组 - PPR 静态壳通过
<Suspense>包裹动态 Widget,构建时生成 HTML 骨架 Promise.all并行获取 4 个独立数据源,总耗时从 8.2 秒降至 2.1 秒error.tsx提供全局和局部两层错误边界,reset()函数支持重试loading.tsx+ 骨架屏组件消除白屏,提升 LCP 至 1.2 秒generateStaticParams预生成活跃项目详情页,结合 ISR 按需更新- 活动流使用
Promise.all并行查询 Task + Comment,再合并排序
📝 作业
-
基础题(⭐):在 TaskFlow 项目中实现 Dashboard 页面,确保 4 个统计卡片用 PPR 静态壳渲染。运行
npm run build验证 PPR 是否生效(查看构建日志是否有○ (Static)和λ (Dynamic)标记)。 -
进阶题(⭐⭐):在活动流 Widget 中换用
Promise.allSettled替代Promise.all,添加错误状态显示(当活动流加载失败时显示"Unable to load activity"而非让整个 Widget 崩溃)。编写降级 UI 代码。 -
挑战题(⭐⭐⭐):为 TaskFlow 实现一个全功能的项目详情页——支持 SSR(确保实时数据) +
generateStaticParams为 TOP 10 活跃项目做 ISR(revalidate: 60),其余项目走 SSR。添加loading.tsx骨架屏 +error.tsx边界 + 面包屑动态更新。使用 Lighthouse 对比三种策略的性能差异。