هل تعاني من تحميل الصفحات البطيء وتجربة المستخدم المتقطعة؟ اكتشف كيف يحل App Router هذه الكوارث التقنية عبر التقديم التزايدي والـ Streaming، مع أمثلة عملية تكشف أسرار الأداء خلف الكواليس.
في صيف ٢٠٢٤، أطلقنا منصة تعليمية تعتمد على Next.js Pages Router. بعد شهر واحد فقط، بدأ السيرفر في الانهيار تحت ضغط ٥٠٠٠ مستخدم متزامن. المشكلة؟ كل طلب كان يعيد تحميل ٣ ميجابايت من جافا سكريبت حتى لو تغيرت سطر واحد في الصفحة. النتيجة: تجربة مستخدم أسوأ من موقع حكومي في ساعة الذروة. هنا دخل App Router ليغير قواعد اللعبة، لكن ليس كما تظن.
الكثيرون يعتقدون أن App Router مجرد طريقة لتنظيم الملفات، لكن الحقيقة أكثر قتامة: هو إعادة تصميم كاملة لكيفية تعامل Next.js مع الـ Rendering Pipeline. قبل App Router، كان كل طلب HTTP يُعيد تنفيذ كامل الشجرة من الـ Root Layout وصولاً للصفحة المطلوبة، حتى لو كانت ٩٠٪ من البيانات ثابتة. هذا يعني أن كل نقرة على رابط داخلي كانت تسبب إعادة تحميل كامل الـ DOM والـ CSS والـ JavaScript، مما يجعل التطبيق يشعر وكأنه موقع من عام ٢٠١٠. App Router يحل هذه المشكلة عبر مفهوم الـ React Server Components، لكن مع فخاخ لا يتحدث عنها أحد.
في Pages Router، كان كل ملف في مجلد pages/ يمثل صفحة كاملة تُعرض من خلال getServerSideProps أو getStaticProps. هذا النهج كان بسيطاً لكنه كارثي من ناحية الأداء، خاصة للتطبيقات الكبيرة. App Router يقدم نموذجاً مختلفاً تماماً: بدلاً من صفحات كاملة، لديك مكونات تُعرض على السيرفر (Server Components) وأخرى على العميل (Client Components). هذا التمييز ليس مجرد تسمية، بل هو تغيير في كيفية تدفق البيانات في الذاكرة.
عندما يطلب المستخدم صفحة في App Router، يحدث التالي خلف الكواليس: أولاً، يُنشئ السيرفر شجرة مكونات افتراضية تمثل الصفحة بالكامل. ثم، يُحدد أي المكونات هي Server Components (تُعرض على السيرفر) وأيها Client Components (تُرسل للعميل). بعدها، يُنفذ السيرفر كل Server Components ويحولها إلى JSON قابل للتسلسل (serializable). أخيراً، يُرسل هذا الـ JSON مع الحد الأدنى من جافا سكريبت اللازمة للعميل، حيث يُعيد React بناء الشجرة ويعرضها. هذه العملية تقلل حجم البيانات المرسلة بشكل كبير، لكنها تأتي بتحديات جديدة.
// مثال عملي: صفحة تحتوي على Server Component وClient Component
// app/page.tsx
import ClientComponent from './ClientComponent';
async function ServerComponent() {
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 } // Revalidate every 60 seconds
}).then(res => res.json());
return (
<div>
<h1>Server Component</h1>
<p>Data from server: {data.message}</p>
{/* ClientComponent سيتم تحميله فقط على العميل */}
<ClientComponent />
</div>
);
}
export default ServerComponent;لاحظ هنا كيف أن ServerComponent يُنفذ بالكامل على السيرفر، بما في ذلك استدعاء الـ API. البيانات تُرسل للعميل كجافا سكريبت خفيف، وليس كـ HTML كامل. هذا يعني أن العميل لا يحتاج إلى إعادة تنفيذ الـ fetch أو أي منطق معقد، مما يقلل من استخدام المعالج والذاكرة على الجهاز الضعيف. لكن هناك مشكلة خفية: إذا استخدمت useState أو useEffect داخل ServerComponent، ستحصل على خطأ لأن هذه الـ Hooks تعمل فقط على العميل. هذا يقودنا إلى الفخ الأول الذي يقع فيه المطورون الجدد.
في أحد المشاريع التي عملت عليها، استخدم فريق التطوير Server Component لعرض قائمة منتجات، ثم أضاف زر "أضف إلى السلة" يستخدم useState. النتيجة؟ خطأ في وقت التشغيل: "useState is not a function". السبب؟ المكون كان يُعرض على السيرفر حيث لا توجد بيئة React كاملة. الحل؟ فصل المكون إلى جزئين: Server Component لعرض البيانات، وClient Component للتفاعل.
// الحل الصحيح: فصل المكونات
// app/ProductList.tsx (Server Component)
export async function ProductList() {
const products = await fetchProducts();
return (
<div>
{products.map(product => (
<ProductItem key={product.id} product={product} />
))}
</div>
);
}
// app/ProductItem.tsx (Client Component)
'use client';
export function ProductItem({ product }) {
const [quantity, setQuantity] = useState(1);
return (
<div>
<h3>{product.name}</h3>
<button {() => setQuantity(q => q + 1)}>+</button>
<span>{quantity}</span>
</div>
);
}هذه الطريقة تضمن أن منطق العرض يُنفذ على السيرفر حيث يكون الوصول للبيانات أسرع، بينما منطق التفاعل يُنفذ على العميل حيث يكون الوصول للـ DOM ضرورياً. لكن هناك مشكلة أخرى تظهر هنا: ماذا لو احتجنا إلى تمرير دالة من Server Component إلى Client Component؟ هذا مستحيل لأن الدوال غير قابلة للتسلسل (non-serializable). الحل؟ تمرير البيانات فقط، وتنفيذ الدوال على العميل.
واحدة من أقوى ميزات App Router هي القدرة على بث المحتوى بشكل تزايدي باستخدام Streaming وSuspense. في Pages Router، كانت الصفحة تُعرض بالكامل أو لا تُعرض على الإطلاق. هذا يعني أن المستخدم كان يرى شاشة بيضاء حتى تُحمل جميع البيانات. في App Router، يمكنك عرض أجزاء من الصفحة فور توفرها، مما يجعل التطبيق يشعر بأنه أسرع بكثير، حتى لو كان الوقت الإجمالي للتحميل هو نفسه.
كيف يعمل هذا خلف الكواليس؟ عندما يطلب المستخدم صفحة، يُرسل السيرفر HTML أساسي يحتوي على هيكل الصفحة (مثل الهيدر والفوتر)، بالإضافة إلى علامات خاصة تسمى "boundaries" تحدد أين سينتهي بث المحتوى. ثم، يُرسل السيرفر أجزاء إضافية من الصفحة كلما أصبحت جاهزة، مثل قوائم المنتجات أو التعليقات. هذا ممكن بفضل React Suspense، الذي يسمح بتعليق عرض مكون حتى تتوفر بياناته. النتيجة؟ المستخدم يرى محتوى مفيداً فوراً، بدلاً من الانتظار.
// مثال على Streaming مع Suspense
// app/page.tsx
export default function Page() {
return (
<div>
<h1>Product Details</h1>
<Suspense fallback={<p>Loading product info...</p>}>
<ProductInfo />
</Suspense>
<Suspense fallback={<p>Loading reviews...</p>}>
<ProductReviews />
</Suspense>
</div>
);
}
async function ProductInfo() {
const product = await fetchProduct(); // قد يستغرق 2 ثانية
return <div>{product.name}</div>;
}
async function ProductReviews() {
const reviews = await fetchReviews(); // قد يستغرق 5 ثوان
return (
<ul>
{reviews.map(review => <li key={review.id}>{review.text}</li>)}
</ul>
);
}في هذا المثال، سيظهر نص "Loading product info..." فوراً، يليه بيانات المنتج بعد ثانيتين، ثم نص "Loading reviews..."، وأخيراً التعليقات بعد خمس ثوانٍ. هذا النهج يقلل من الوقت الذي يشعر فيه المستخدم بالانتظار، حتى لو كان الوقت الإجمالي للتحميل هو نفسه. لكن هناك مشكلة شائعة هنا: إذا لم تستخدم Suspense بشكل صحيح، قد ينتهي بك الأمر مع شاشة بيضاء كاملة مرة أخرى. السبب؟ إذا وضعت جميع المكونات داخل Suspense واحد، فلن ترى أي محتوى حتى تُحمل جميع البيانات.
في مشروع آخر، استخدم فريق التطوير Suspense حول الصفحة بأكملها، معتقدين أنهم سيحصلون على تجربة تحميل أفضل. النتيجة؟ المستخدمون كانوا يرون شاشة بيضاء كاملة حتى تُحمل جميع البيانات، تماماً كما في Pages Router. الحل؟ تقسيم الصفحة إلى أجزاء صغيرة واستخدام Suspense حول كل جزء على حدة. هذا يضمن أن المحتوى يظهر تدريجياً، بدلاً من الانتظار لكل شيء.
إحدى أقوى ميزات App Router هي نظام الـ Caching المدمج. في Pages Router، كان عليك إدارة الـ Caching بنفسك باستخدام مكتبات مثل SWR أو React Query. في App Router، يأتي الـ Caching مدمجاً مع كل استدعاء لـ fetch، مما يجعل إدارة البيانات أسهل بكثير. لكن هذا النظام يأتي مع تفاصيل دقيقة قد تسبب مشاكل إذا لم تفهمها جيداً.
عندما تستدعي fetch داخل Server Component، يخزن Next.js النتيجة تلقائياً في ذاكرة مؤقتة (cache) على السيرفر. هذا يعني أن الطلبات اللاحقة لنفس الـ URL ستستخدم النسخة المخزنة بدلاً من إعادة جلب البيانات. هذا يقلل من الحمل على قاعدة البيانات ويحسن الأداء، لكنه قد يسبب مشاكل إذا كانت البيانات تتغير بشكل متكرر. لحل هذه المشكلة، يقدم App Router عدة خيارات للتحكم في الـ Caching والـ Revalidation.
// التحكم في الـ Caching والـ Revalidation
async function fetchData() {
const res = await fetch('https://api.example.com/data', {
// الخيارات:
cache: 'force-cache', // الافتراضي: يستخدم النسخة المخزنة
cache: 'no-store', // لا يستخدم الـ Cache أبداً
next: { revalidate: 60 } // يعيد جلب البيانات كل 60 ثانية
});
return res.json();
}لكن هناك مشكلة هنا: إذا استخدمت cache: 'no-store'، ستفقد فوائد الـ Caching بالكامل، مما قد يسبب تحميلاً زائداً على قاعدة البيانات. من ناحية أخرى، إذا استخدمت revalidate بشكل غير مدروس، قد تعرض بيانات قديمة للمستخدمين. الحل؟ استخدم مزيجاً من الطريقتين بناءً على طبيعة البيانات. مثلاً، استخدم revalidate للبيانات التي تتغير بشكل متكرر، وforce-cache للبيانات الثابتة.
في أحد المشاريع، استخدمنا revalidate: 3600 للبيانات التي تتغير كل ساعة. لكن بعد إطلاق الموقع، لاحظنا أن محركات البحث كانت تفهرس بيانات قديمة لأن الـ Crawlers كانت ترى النسخة المخزنة دائماً. الحل؟ استخدمنا revalidateTag مع On-Demand Revalidation لتحديث البيانات فوراً عند تغييرها في قاعدة البيانات.
// استخدام revalidateTag مع On-Demand Revalidation
// app/api/revalidate/route.ts
export async function POST(request: Request) {
const { tag } = await request.json();
revalidateTag(tag); // يعيد جلب البيانات المرتبطة بهذا الـ Tag
return Response.json({ revalidated: true });
}
// في Server Component
async function fetchData() {
const res = await fetch('https://api.example.com/data', {
next: { tags: ['products'] }
});
return res.json();
}واحدة من أكثر الميزات إثارة للجدل في App Router هي الـ Server Actions. ببساطة، هي دوال تُنفذ على السيرفر ولكن يمكن استدعاؤها مباشرة من مكونات العميل. هذا يبدو وكأنه بديل لـ API Routes، لكنه في الواقع أداة مختلفة تماماً. الـ Server Actions مصممة لتبسيط عمليات مثل إرسال النماذج، بينما API Routes تبقى الخيار الأفضل للعمليات المعقدة أو التي تحتاج إلى تحكم دقيق في الـ Request والـ Response.
كيف تعمل الـ Server Actions خلف الكواليس؟ عندما تحدد دالة كـ Server Action باستخدام 'use server'، يحولها Next.js إلى نقطة نهاية POST تلقائية. عندما يستدعي العميل هذه الدالة، يُرسل طلب POST إلى السيرفر مع البيانات اللازمة، ثم يُنفذ السيرفر الدالة ويعيد النتيجة. هذا يقلل من الحاجة لكتابة API Routes يدوياً، لكنه يأتي مع قيود: لا يمكنك التحكم في الـ Headers أو الـ Status Code بسهولة، كما أن التعامل مع الأخطاء يصبح أكثر تعقيداً.
// مثال على Server Action
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const c formData.get('content') as string;
// حفظ البيانات في قاعدة البيانات
const post = await db.post.create({ data: { title, content } });
revalidatePath('/posts'); // تحديث الصفحة بعد الإضافة
return { success: true, post };
}
// في Client Component
'use client';
export function PostForm() {
return (
<form action={createPost}>
<input type="text" name="title" />
<textarea name="content" />
<button type="submit">Create Post</button>
</form>
);
}هذا المثال يظهر كيف يمكن لـ Server Action تبسيط عملية إرسال النماذج، لكن هناك مشكلة شائعة هنا: إذا لم تستخدم revalidatePath، قد لا ترى التغييرات فوراً على الصفحة. أيضاً، إذا كانت الدالة معقدة وتحتاج إلى معالجة متعددة، قد يكون من الأفضل استخدام API Route بدلاً من ذلك. الـ Server Actions رائعة للعمليات البسيطة، لكنها ليست حلاً سحرياً لكل شيء.
في أحد المشاريع، حاول فريق التطوير استخدام Server Action لمعالجة دفعة من الملفات التي يرفعها المستخدم. النتيجة؟ الطلبات كانت تفشل بسبب حجم البيانات الكبير، ولم يكن هناك طريقة سهلة لإعادة المحاولة أو التحكم في الـ Timeout. الحل؟ استخدام API Route بدلاً من ذلك، حيث يمكنك التحكم في كل جانب من جوانب الطلب، بما في ذلك الـ Headers والـ Streaming للبيانات الكبيرة.
الانتقال إلى App Router لا يعني تلقائياً أن تطبيقك سيكون أسرع. هناك العديد من التقنيات التي يجب تطبيقها لتحقيق الأداء الأمثل. واحدة من أهم هذه التقنيات هي تقليل حجم جافا سكريبت المرسلة للعميل. في Pages Router، كان كل ملف صفحة يُرسل بالكامل للعميل، بما في ذلك مكتبات مثل Lodash أو Moment.js حتى لو لم تُستخدم. في App Router، يمكنك التحكم في هذا بشكل أفضل باستخدام Server Components.
لكن هناك مشكلة شائعة هنا: حتى مع Server Components، قد ينتهي بك الأمر بإرسال الكثير من جافا سكريبت للعميل. السبب؟ استخدام مكتبات كبيرة داخل Client Components. الحل؟ استخدم مكتبات أخف وزناً، أو قم بتحميل المكتبات بشكل ديناميكي فقط عند الحاجة. مثلاً، بدلاً من استيراد مكتبة الرسوم البيانية بالكامل، قم بتحميلها فقط عندما يريد المستخدم رؤية الرسم البياني.
// التحميل الديناميكي للمكتبات الثقيلة
'use client';
import dynamic from 'next/dynamic';
// تحميل Chart.js فقط عند الحاجة
const Chart = dynamic(() => import('react-chartjs-2'), {
loading: () => <p>Loading chart...</p>,
ssr: false // عدم عرض المكون على السيرفر
});
export function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Chart />
</div>
);
}هذه الطريقة تضمن أن مكتبة Chart.js تُحمل فقط عندما يحتاج المستخدم إليها، مما يقلل من حجم جافا سكريبت الأولية. لكن هناك مشكلة أخرى: حتى مع التحميل الديناميكي، قد ينتهي بك الأمر بإرسال الكثير من البيانات للعميل إذا لم تدير الـ State بشكل صحيح. مثلاً، إذا استخدمت Context API لتخزين بيانات كبيرة، قد تُرسل هذه البيانات لكل مكون في التطبيق، حتى لو لم يحتاجها. الحل؟ استخدم مكتبات إدارة الحالة مثل Zustand أو Jotai، التي تسمح بتحميل البيانات بشكل أكثر كفاءة.
في أحد المشاريع، استخدمنا Context API لتخزين بيانات المستخدم في جميع أنحاء التطبيق. النتيجة؟ كل صفحة كانت تُحمّل ٥٠٠ كيلوبايت إضافية من البيانات، حتى لو كانت الصفحة لا تحتاج إلا إلى جزء صغير منها. الحل؟ انتقلنا إلى Zustand، الذي يسمح بتحميل البيانات فقط عند الحاجة، مما قلل حجم البيانات المرسلة بشكل كبير.
الانتقال إلى App Router ليس مجرد تحديث بسيط، بل هو تغيير في طريقة تفكيرك في تطوير تطبيقات الويب. قبل أن تبدأ، تأكد من فهمك لهذه النقاط الحاسمة: أولاً، Server Components ليست بديلاً لـ Client Components، بل أداة مكملة. استخدمها لعرض البيانات الثابتة والتفاعلات البسيطة، واترك Client Components للتفاعلات المعقدة. ثانياً، لا تعتمد فقط على الـ Caching المدمج، بل فكر في كيفية إدارة البيانات المتغيرة. استخدم revalidateTag مع On-Demand Revalidation للبيانات التي تتغير بشكل متكرر، وforce-cache للبيانات الثابتة.
ثالثاً، لا تستخدم Server Actions للعمليات المعقدة. إنها رائعة لإرسال النماذج والتحديثات البسيطة، لكنها ليست بديلاً لـ API Routes. رابعاً، اهتم بتحليل أداء تطبيقك باستخدام أدوات مثل Next.js Analytics وLighthouse. لا تفترض أن App Router سيجعل تطبيقك أسرع تلقائياً، بل قم بقياس الأداء قبل وبعد الانتقال. وأخيراً، لا تخف من تجربة الأشياء الجديدة. App Router يقدم العديد من الميزات القوية مثل الـ Streaming والـ Server Actions، لكن عليك أن تفهم كيف تعمل خلف الكواليس لتستخدمها بشكل فعال.
إذا كنت تريد نصيحة واحدة فقط قبل الانتقال إلى App Router، فهي هذه: ابدأ بمشروع صغير. بدلاً من محاولة ترحيل تطبيقك بالكامل دفعة واحدة، ابدأ بصفحة واحدة أو قسم واحد. هذا سيسمح لك بفهم كيفية عمل App Router في بيئة حقيقية، دون المخاطرة بكسر التطبيق بالكامل. وعندما تشعر بالراحة، يمكنك توسيع نطاق الانتقال تدريجياً. بهذه الطريقة، ستتجنب الكثير من الصداع الذي يعاني منه المطورون الذين يحاولون فعل كل شيء في وقت واحد.