البث (Streaming)
في الفصل السابق، تعلمت عن طرق التصيير المختلفة في Next.js. ناقشنا أيضًا كيف يمكن لطلبات البيانات البطيئة أن تؤثر على أداء تطبيقك. دعونا نلقي نظرة على كيفية تحسين تجربة المستخدم عند وجود طلبات بيانات بطيئة.
ما هو البث؟
البث (Streaming) هو تقنية نقل بيانات تتيح لك تقسيم المسار إلى "قطع" صغيرة وبثها تدريجيًا من الخادم إلى العميل بمجرد أن تصبح جاهزة.

باستخدام البث، يمكنك منع طلبات البيانات البطيئة من حظر الصفحة بأكملها. هذا يسمح للمستخدم برؤية أجزاء من الصفحة والتفاعل معها دون انتظار تحميل جميع البيانات قبل عرض أي واجهة مستخدم.

يعمل البث بشكل جيد مع نموذج مكونات React، حيث يمكن اعتبار كل مكون قطعة.
هناك طريقتان لتنفيذ البث في Next.js:
- على مستوى الصفحة، باستخدام ملف
loading.tsx
(الذي ينشئ<Suspense>
لك تلقائيًا). - على مستوى المكون، باستخدام
<Suspense>
للتحكم الأكثر دقة.
دعونا نرى كيف يعمل هذا.
بث صفحة كاملة باستخدام loading.tsx
في مجلد /app/dashboard
، أنشئ ملفًا جديدًا يسمى loading.tsx
:
export default function Loading() {
return <div>جار التحميل...</div>;
}
قم بتحديث http://localhost:3000/dashboard، ويجب أن ترى الآن:

هناك عدة أشياء تحدث هنا:
loading.tsx
هو ملف خاص في Next.js مبني على React Suspense. يسمح لك بإنشاء واجهة مستخدم احتياطية لعرضها كبديل أثناء تحميل محتوى الصفحة.- نظرًا لأن
<SideNav>
ثابت، يتم عرضه على الفور. يمكن للمستخدم التفاعل مع<SideNav>
أثناء تحميل المحتوى الديناميكي. - لا يحتاج المستخدم إلى انتظار انتهاء تحميل الصفحة قبل الانتقال بعيدًا (يسمى هذا التنقل القابل للمقاطعة).
تهانينا! لقد قمت بتنفيذ البث. لكن يمكننا فعل المزيد لتحسين تجربة المستخدم. دعونا نعرض هيكل تحميل بدلاً من نص جار التحميل...
.
إضافة هياكل التحميل
هيكل التحميل (loading skeleton) هو نسخة مبسطة من واجهة المستخدم. تستخدم العديد من المواقع الإلكترونية هذه الهياكل كعنصر نائب (أو احتياطي) للإشارة إلى المستخدمين أن المحتوى قيد التحميل. أي واجهة مستخدم تضيفها في loading.tsx
سيتم تضمينها كجزء من الملف الثابت وإرسالها أولاً. ثم يتم بث باقي المحتوى الديناميكي من الخادم إلى العميل.
داخل ملف loading.tsx
، قم باستيراد مكون جديد يسمى <DashboardSkeleton>
:
import DashboardSkeleton from '@/app/ui/skeletons';
export default function Loading() {
return <DashboardSkeleton />;
}
ثم قم بتحديث http://localhost:3000/dashboard، ويجب أن ترى الآن:

إصلاح مشكلة هيكل التحميل باستخدام مجموعات المسارات
حاليًا، سيتم تطبيق هيكل التحميل الخاص بك على الفواتير.
نظرًا لأن loading.tsx
موجود على مستوى أعلى من /invoices/page.tsx
و /customers/page.tsx
في نظام الملفات، فإنه يتم تطبيقه أيضًا على هذه الصفحات.
يمكننا تغيير هذا باستخدام مجموعات المسارات (Route Groups). أنشئ مجلدًا جديدًا يسمى /(overview)
داخل مجلد لوحة التحكم. ثم انقل ملفات loading.tsx
و page.tsx
داخل المجلد:

الآن، سيتم تطبيق ملف loading.tsx
فقط على صفحة نظرة عامة على لوحة التحكم الخاصة بك.
تسمح لك مجموعات المسارات بتنظيم الملفات في مجموعات منطقية دون التأثير على هيكل مسار URL. عند إنشاء مجلد جديد باستخدام الأقواس ()
، لن يتم تضمين الاسم في مسار URL. لذا يصبح /dashboard/(overview)/page.tsx
هو /dashboard
.
هنا، أنت تستخدم مجموعة مسارات للتأكد من أن loading.tsx
ينطبق فقط على صفحة نظرة عامة على لوحة التحكم. ومع ذلك، يمكنك أيضًا استخدام مجموعات المسارات لفصل تطبيقك إلى أقسام (مثل مسارات (marketing)
ومسارات (shop)
) أو حسب الفرق للتطبيقات الأكبر.
بث مكون
حتى الآن، أنت تبث صفحة كاملة. ولكن يمكنك أيضًا أن تكون أكثر دقة وبث مكونات محددة باستخدام React Suspense.
يسمح لك Suspense بتأجيل تصيير أجزاء من تطبيقك حتى يتم استيفاء شرط معين (مثل تحميل البيانات). يمكنك تغليف المكونات الديناميكية الخاصة بك في Suspense. ثم تمرير مكون احتياطي لعرضه أثناء تحميل المكون الديناميكي.
إذا كنت تتذكر طلب البيانات البطيء، fetchRevenue()
، هذا هو الطلب الذي يبطئ الصفحة بأكملها. بدلاً من حظر الصفحة بأكملها، يمكنك استخدام Suspence لبث هذا المكون فقط وعرض بقية واجهة مستخدم الصفحة على الفور.
للقيام بذلك، ستحتاج إلى نقل جلب البيانات إلى المكون، دعونا نحدث الكود لمعرفة كيف سيبدو:
احذف جميع حالات fetchRevenue()
وبياناتها من /dashboard/(overview)/page.tsx
:
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // إزالة fetchRevenue
export default async function Page() {
const revenue = await fetchRevenue() // احذف هذا السطر
const latestInvoices = await fetchLatestInvoices();
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
return (
// ...
);
}
ثم، قم باستيراد <Suspense>
من React، وقم بلفه حول <RevenueChart />
. يمكنك تمرير مكون احتياطي يسمى <RevenueChartSkeleton>
.
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data';
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
export default async function Page() {
const latestInvoices = await fetchLatestInvoices();
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
return (
<main>
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
لوحة التحكم
</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Card title="المحصلة" value={totalPaidInvoices} type="collected" />
<Card title="المعلقة" value={totalPendingInvoices} type="pending" />
<Card title="إجمالي الفواتير" value={numberOfInvoices} type="invoices" />
<Card
title="إجمالي العملاء"
value={numberOfCustomers}
type="customers"
/>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<LatestInvoices latestInvoices={latestInvoices} />
</div>
</main>
);
}
أخيرًا، قم بتحديث مكون <RevenueChart>
لجلب بياناته الخاصة وإزالة الخاصية الممررة إليه:
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchRevenue } from '@/app/lib/data';
// ...
export default async function RevenueChart() { // اجعل المكون غير متزامن، أزل الخصائص
const revenue = await fetchRevenue(); // جلب البيانات داخل المكون
const chartHeight = 350;
const { yAxisLabels, topLabel } = generateYAxis(revenue);
if (!revenue || revenue.length === 0) {
return <p className="mt-4 text-gray-400">لا توجد بيانات متاحة.</p>;
}
return (
// ...
);
}
الآن قم بتحديث الصفحة، يجب أن ترى معلومات لوحة التحكم على الفور تقريبًا، بينما يتم عرض هيكل احتياطي لـ <RevenueChart>
:

تمرين: بث <LatestInvoices>
الآن حان دورك! تدرب على ما تعلمته للتو عن طريق بث مكون <LatestInvoices>
.
انقل fetchLatestInvoices()
من الصفحة إلى مكون <LatestInvoices>
. قم بلف المكون في حدود <Suspense>
مع هيكل احتياطي يسمى <LatestInvoicesSkeleton>
.
بمجرد أن تكون جاهزًا، قم بتوسيع التبديل لرؤية كود الحل:
تجميع المكونات
رائع! لقد اقتربت من النهاية، الآن تحتاج إلى لف مكونات <Card>
في Suspense. يمكنك جلب بيانات لكل بطاقة على حدة، ولكن هذا قد يؤدي إلى تأثير الظهور المفاجئ أثناء تحميل البطاقات، مما قد يكون مزعجًا بصريًا للمستخدم.
إذن، كيف يمكنك حل هذه المشكلة؟
لإنشاء تأثير متدرج أكثر، يمكنك تجميع البطاقات باستخدام مكون غلاف. هذا يعني أنه سيتم عرض <SideNav/>
الثابت أولاً، ثم البطاقات، وهكذا.
في ملف page.tsx
:
- احذف مكونات
<Card>
الخاصة بك. - احذف دالة
fetchCardData()
. - استورد مكون غلاف جديد يسمى
<CardWrapper />
. - استورد مكون هيكل جديد يسمى
<CardsSkeleton />
. - لف
<CardWrapper />
في Suspense.
import CardWrapper from '@/app/ui/dashboard/cards';
// ...
import {
RevenueChartSkeleton,
LatestInvoicesSkeleton,
CardsSkeleton,
} from '@/app/ui/skeletons';
export default async function Page() {
return (
<main>
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
لوحة التحكم
</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Suspense fallback={<CardsSkeleton />}>
<CardWrapper />
</Suspense>
</div>
// ...
</main>
);
}
ثم، انتقل إلى الملف /app/ui/dashboard/cards.tsx
، استورد دالة fetchCardData()
، واستدعها داخل مكون <CardWrapper/>
. تأكد من إلغاء تعليق أي كود ضروري في هذا المكون.
// ...
import { fetchCardData } from '@/app/lib/data';
// ...
export default async function CardWrapper() {
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
return (
<>
<Card title="المحصلة" value={totalPaidInvoices} type="collected" />
<Card title="المعلقة" value={totalPendingInvoices} type="pending" />
<Card title="إجمالي الفواتير" value={numberOfInvoices} type="invoices" />
<Card
title="إجمالي العملاء"
value={numberOfCustomers}
type="customers"
/>
</>
);
}
قم بتحديث الصفحة، ويجب أن ترى جميع البطاقات يتم تحميلها في نفس الوقت. يمكنك استخدام هذا النمط عندما تريد تحميل عدة مكونات في نفس الوقت.
تحديد مكان وضع حدود Suspense الخاصة بك
يعتمد مكان وضع حدود Suspense الخاصة بك على عدة أشياء:
- كيف تريد أن يختبر المستخدم الصفحة أثناء بثها.
- أي محتوى تريد إعطاؤه الأولوية.
- إذا كانت المكونات تعتمد على جلب البيانات.
ألق نظرة على صفحة لوحة التحكم الخاصة بك، هل هناك أي شيء كنت ستفعله بشكل مختلف؟
لا تقلق. لا توجد إجابة صحيحة.
- يمكنك بث الصفحة بأكملها كما فعلنا مع
loading.tsx
... ولكن هذا قد يؤدي إلى وقت تحميل أطول إذا كان أحد المكونات لديه جلب بيانات بطيء. - يمكنك بث كل مكون على حدة... ولكن هذا قد يؤدي إلى ظهور واجهة المستخدم فجأة في الشاشة عندما تصبح جاهزة.
- يمكنك أيضًا إنشاء تأثير متدرج عن طريق بث أقسام الصفحة. ولكنك ستحتاج إلى إنشاء مكونات غلاف.
سيختلف مكان وضع حدود Suspense الخاصة بك اعتمادًا على تطبيقك. بشكل عام، من الجيد نقل عمليات جلب البيانات إلى المكونات التي تحتاجها، ثم لف هذه المكونات في Suspense. ولكن لا يوجد خطأ في بث الأقسام أو الصفحة بأكملها إذا كان هذا ما يحتاجه تطبيقك.
لا تخف من تجربة Suspense ومعرفة ما يناسبك بشكل أفضل، إنها واجهة برمجة تطبيقات قوية يمكن أن تساعدك في إنشاء تجارب مستخدم أكثر إمتاعًا.
نظرة مستقبلية
يمنحنا البث ومكونات الخادم طرقًا جديدة للتعامل مع جلب البيانات وحالات التحميل، بهدف تحسين تجربة المستخدم النهائي.
في الفصل التالي، ستتعلم عن التصيير الجزئي المسبق (Partial Prerendering)، وهو نموذج تصيير جديد في Next.js مبني مع وضع البث في الاعتبار.