Next.js: المشروع النهائي: لوحة التحكم و PPR
آخر تحديث: 2026-08-26
لوحة التحكم هي "قمرة القيادة" لمنصة SaaS — الإطار الثابت يتيح لك رؤية التخطيط في لمحة، بينما تملأ تدفقات البيانات الديناميكية المؤشرات الرئيسية في الوقت الفعلي.
1. ما ستتعلمه
- بناء تخطيط تنقل عام (Sidebar + TopNav + Breadcrumb)
- وضع الهيكل الثابت PPR للوحة التحكم (
<Suspense>ودجات ديناميكية) Promise.allجلب بيانات متوازي من مصادر بيانات مستقلة متعددةerror.tsxحدود الأخطاء العامة +loading.tsxتصميم هيكل تحميلgenerateStaticParamsإنشاء صفحات تفاصيل المشروع الثابتة مسبقاً- استعلامات تجميعية على بيانات تدفق النشاط
2. قصة حقيقية لمهندس واجهة أمامية
(1) نقطة الألم: لوحة التحكم تستغرق 8 ثوانٍ للتحميل، وغادر جميع المستخدمين
تلقت TaskFlow الخاصة بـ Alice 50 تعليقاً من المستخدمين أثناء الاختبار التجريبي — 92% منهم اشتكوا من سرعة تحميل لوحة التحكم. المشكلة كانت في جلب البيانات المتسلسل: أولاً جلب عدد المشاريع → ثم عدد المهام → ثم عدد الأعضاء → وأخيراً تدفق النشاط. تسببت طلبات API الأربعة هذه في حظر متتالٍ، استغرق إجماليه 8.2 ثانية. قال Charlie (التسويق): "انتظرت 10 ثوانٍ وأغلقت الصفحة."
(2) الحل باستخدام الهيكل الثابت PPR + جلب البيانات المتوازي
استخدم الهيكل الثابت PPR لعرض هيكل الصفحة فوراً، ثم استخدم
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 "التخطيط الجذر"
A[RootLayout<br/>html/body/fonts]
end
subgraph "تخطيط لوحة التحكم"
B[Sidebar<br/>المؤسسة<br/>التنقل<br/>قائمة المستخدم]
C[TopNav<br/>شريط البحث<br/>الإشعارات<br/>Breadcrumb]
D[المحتوى الرئيسي<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) مكونات تخطيط لوحة التحكم
// 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} مشاريع · {organization?._count.users} أعضاء
</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">
رمز الدعوة
</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>
)
}
المخرجات:
يعرض: TF | TaskFlow | رمز الدعوة
▶ مثال: شريط التنقل العلوي
المخرجات:
يعرض المكون واجهة المستخدم الموصوفة في المتصفح.
// 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>
)
}
المخرجات:
صفحة تحتوي على قسم ترويسة.
النص المرئي: \uD83D\uDD14 | 3 | \u2753
▶ مثال: تنقل Breadcrumb
المخرجات:
تُعرض الصفحة كما هو موصوف أعلاه، مع تحديث واجهة المستخدم بناءً على السلوك الموصوف.
// 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>
)
}
المخرجات:
يعرض قائمة ديناميكية من العناصر باستخدام map().
المحتوى المرئي: Home | ) : (
4. الهيكل الثابت PPR للوحة التحكم
(1) كيفية عمل PPR
sequenceDiagram
participant B as المتصفح
participant N as خادم Next.js
participant DB as قاعدة البيانات
B->>N: GET /dashboard
N->>B: الهيكل الثابت (HTML + CSS)<br/>بطاقات الهيكل مرئية
B->>N: يبدأ التدفق
N->>DB: Promise.all<br/>(4 استعلامات متوازية)
DB-->>N: النتائج
N->>B: المحتوى الديناميكي يملأ الهياكل
Note over B: تحسين تدريجي
(2) تفعيل PPR
// next.config.ts
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
}
export default nextConfig
▶ مثال: تنفيذ PPR في صفحة لوحة التحكم
المخرجات:
تم تنفيذ وحدة TypeScript بنجاح.
// 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) تنفيذ ودجات جلب البيانات
// 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">المشاريع النشطة</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">عبر مؤسستك</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">إجمالي المهام</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} قيد الانتظار</span>
<span className="text-blue-500">{inProgress} قيد التنفيذ</span>
<span className="text-green-500">{done} مكتملة</span>
</div>
</CardContent>
</Card>
)
}
5. جلب البيانات المتوازي
(1) مقارنة بين المعالجة المتسلسلة والمتوازية
graph LR
subgraph "متسلسل (8.2 ثانية)"
A1[جلب A] --> A2[جلب B] --> A3[جلب C] --> A4[جلب D]
end
subgraph "متوازي (2.1 ثانية)"
B1[جلب A]
B2[جلب B]
B3[جلب C]
B4[جلب D]
B1 --> C[Promise.all]
B2 --> C
B3 --> C
B4 --> C
end
style A4 fill:#f8d7da
style C fill:#d4edda
▶ مثال: استخدام Promise.all لجلب تدفقات النشاط بالتوازي
المخرجات:
رسم توضيحي للمسارات المتوازية: @sidebar و @modal تُعرضان في وقت واحد في فتحات مسماة.
// 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: `أنشأ المهمة "${t.title}"`,
project: t.project.name,
time: t.createdAt,
})),
...recentComments.map((c) => ({
id: c.id,
type: "comment_added" as const,
user: c.author,
description: `علّق على "${c.task.title}"`,
project: "",
time: c.createdAt,
})),
]
.sort((a, b) => b.time.getTime() - a.time.getTime())
.slice(0, 10)
return (
<Card>
<CardHeader>
<CardTitle>تدفق النشاط</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">
لا يوجد نشاط حديث
</p>
)}
</div>
</CardContent>
</Card>
)
}
المخرجات:
يعرض قائمة عن طريق المرور على recentTasks، مع عرض كل t.
المحتوى المرئي: تدفق النشاط
(2) مقارنة استراتيجيات جلب البيانات
| الاستراتيجية | الوقت الإجمالي | تعقيد التنفيذ | السيناريوهات المناسبة |
|---|---|---|---|
| await متسلسل | مجموع(N) | بسيط | تبعيات البيانات |
| Promise.all | الحد الأقصى(N) | متوسط | مصادر بيانات مستقلة |
| Promise.allSettled | الحد الأقصى(N) | مرتفع | يُسمح بالفشل الجزئي |
| Streaming + PPR | TTFB ~0 | مرتفع | أولوية الشاشة الأولى |
6. حدود الأخطاء وهياكل التحميل
(1) مستويات حدود الأخطاء
graph TB
A[RootLayout] --> B[error.tsx<br/>خطأ عام]
A --> C[DashboardLayout]
C --> D[error.tsx<br/>خطأ لوحة التحكم]
D --> E[صفحة لوحة التحكم]
E --> F[ودجة 1<br/>Suspense fallback]
E --> G[ودجة 2<br/>Suspense fallback]
E --> H[ودجة 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">حدث خطأ ما</CardTitle>
<CardDescription>
حدث خطأ غير متوقع أثناء تحميل لوحة التحكم.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error.message || "خطأ غير معروف"}
</div>
{error.digest && (
<p className="mt-2 text-xs text-muted-foreground">
معرف الخطأ: {error.digest}
</p>
)}
</CardContent>
<CardFooter>
<Button onClick={() => reset()} variant="outline" className="w-full">
حاول مرة أخرى
</Button>
</CardFooter>
</Card>
</div>
)
}
المخرجات:
يعرض: حدث خطأ ما | حدث خطأ غير متوقع أثناء تحميل لوحة التحكم.
7. صفحة تفاصيل المشروع الثابتة
(1) استراتيجية إنشاء الصفحات الثابتة
| الاستراتيجية | توقيت الإنشاء | حداثة البيانات | وقت البناء | السيناريوهات المناسبة |
|---|---|---|---|---|
| SSG (generateStaticParams) | وقت البناء | لقطة وقت البناء | بطيء (N صفحة) | المحتوى نادر التغيير |
| ISR (revalidate) | وقت البناء + عند الطلب | متأخر بمقدار TTL | بطيء (N صفحة) | تحديثات دورية |
| SSR (ديناميكي) | عند الطلب | فوري | سريع (لا حاجة لـ N) | بيانات فورية |
| PPR | وقت البناء + تدفق | فوري للجزء الديناميكي | سريع (هيكل ثابت) | سيناريوهات مختلطة |
8. مجموعات المسارات وتنظيم التخطيط
(1) هيكل مجموعات المسارات
src/app/
├── (auth)/ # مجموعة مسارات المصادقة (بدون شريط جانبي)
│ └── auth/
│ ├── signin/
│ └── signup/
├── (dashboard)/ # مجموعة مسارات لوحة التحكم (بشريط جانبي)
│ ├── layout.tsx # تخطيط لوحة التحكم (Sidebar + TopNav)
│ ├── dashboard/
│ │ └── page.tsx
│ └── projects/
│ └── [id]/
│ └── page.tsx
├── layout.tsx # التخطيط الجذر (html/body)
└── page.tsx # الصفحة الرئيسية (عامة)
9. مثال كامل: صفحة لوحة التحكم الكاملة
// 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 في المتصفح):
→ شوهد في لحظة: الشريط الجانبي + التنقل العلوي + Breadcrumb (هيكل ثابت)
→ 0.3 ثانية: يظهر شعار الترحيب
→ 0.5 ثانية: 4 قوالب بطاقات إحصائية → تملأ بالبيانات (12 / 50 / 5 / 68%)
→ 1.2 ثانية: تدفق النشاط + قائمة المشاريع الحديثة
→ صفحة تفاعلية بالكامل
❓ أسئلة شائعة
<Suspense> في PPR هو تحميل متدفق على مستوى المكون. يمكن لـ PPR إنشاء HTML ثابت مسبقاً أثناء عملية البناء، مع ملء المحتوى الديناميكي عبر التدفق؛ أما loading.tsx فيُعرض بشكل ديناميكي بالكامل.generateStaticParams مناسب لجميع صفحات المشاريع؟Promise.all؟Promise.all هي استراتيجية "فشل واحد، فشل الجميع". للبيانات غير الحرجة (مثل تدفقات النشاط)، استخدام Promise.allSettled أكثر أماناً — حتى إذا فشل تدفق النشاط، ستظل الإحصائيات معروضة. يوفر Error Boundary مع fallback تجربة متدهورة بشكل آمن.usePathname هو مكون من جانب العميل؟usePathname هو hook من جانب العميل. مكون Breadcrumb صغير جداً (لا يجلب أي بيانات)، لذلك لا توجد مشاكل في الأداء مع العرض من جانب العميل. إذا كان الأداء مصدر قلق، يمكنك تنفيذه باستخدام headers().get("next-url") من جانب الخادم، لكن الكود سيكون أكثر تعقيداً./dashboard بالفعل، فإن التحقق مرة أخرى في التخطيط يعمل كـ "دفاع متعدد الطبقات" — لمنع تجاوز التحقق عند الوصول المباشر إلى المسارات المتداخلة (مثل /dashboard/projects/xxx). توصي Next.js بإجراء فحوصات المصادقة في كل من طبقة التخطيط وطبقة جلب البيانات.📖 ملخص
- تخطيط لوحة التحكم: استخدم مجموعات المسارات
(dashboard)لعزل مجموعات المسارات التي تتطلب شريطاً جانبياً - يلف الهيكل الثابت PPR الودجات الديناميكية باستخدام
<Suspense>وينشئ هيكل HTML أثناء وقت البناء Promise.allمن خلال جلب البيانات من أربعة مصادر بيانات مستقلة بالتوازي، انخفض الوقت الإجمالي المطلوب من 8.2 ثانية إلى 2.1 ثانيةerror.tsxيوفر حدود أخطاء عامة ومحلية؛ تدعم دالةreset()إعادة المحاولةloading.tsx+ مكون الهيكل يلغي الشاشة البيضاء ويحسن LCP إلى 1.2 ثانيةgenerateStaticParamsإنشاء صفحات تفاصيل المشاريع النشطة مسبقاً وتحديثها عند الطلب باستخدام ISR- يستخدم تدفق النشاط
Promise.allلإجراء استعلامات متوازية على Task و Comment، ثم يدمج ويفرز النتائج
📝 تمارين
-
تمرين أساسي (⭐): نفذ صفحة لوحة التحكم في مشروع TaskFlow، مع التأكد من عرض بطاقات الإحصائيات الأربع باستخدام هياكل PPR الثابتة. شغّل
npm run buildللتحقق من عمل PPR (تحقق من سجل البناء لعلامتي○ (Static)وλ (Dynamic)). -
تمرين متقدم (⭐⭐): استبدل
Promise.allبـPromise.allSettledفي ودجة تدفق النشاط، وأضف رسالة خطأ (اعرض "تعذر تحميل النشاط" عند فشل تحميل تدفق النشاط، بدلاً من التسبب في تعطل الودجة بأكملها). اكتب كود واجهة المستخدم البديلة. -
تحدٍّ (⭐⭐⭐): نفذ صفحة تفاصيل مشروع كاملة الوظائف لـ TaskFlow — تدعم SSR (ضمان بيانات فورية) +
generateStaticParamsلـ ISR (revalidate: 60) لأفضل 10 مشاريع نشطة، مع SSR لجميع المشاريع الأخرى. أضفloading.tsxهيكل تحميل +error.tsxحدود + Breadcrumb محدث ديناميكياً. استخدم Lighthouse لمقارنة فروق الأداء بين الاستراتيجيات الثلاث.