مكوّنات React الخادمية
مكوّنات React الخادمية (React Server Components، RSC) هي مكوّنات React تعمل حصريًا على الخادم، ولا ترسل شيفرتها إلى المتصفح أبدًا، وتنتج ناتجًا تستطيع شجرة العميل عرضه ودمجه مع مكوّنات العميل العادية. وهي ليست بديلًا عن العرض في جانب الخادم (server-side rendering، SSR)؛ بل طبقة مختلفة في بنية العرض. يحوّل SSR مخرجات React إلى HTML للعرض الأول، بينما تتيح RSC لبعض أجزاء شجرتك ألا تتحول إلى JavaScript للعميل أصلًا.
إن التغيّر في النموذج الذهني كبير. في تطبيق React كلاسيكي يعمل كل مكوّن في المتصفح. ومع RSC تُقسَّم المكوّنات وفق موضع تشغيلها: خادم، وهو الوضع الافتراضي، أو عميل، ويُفعّل ذلك بـ 'use client'. يطبق نظام البناء هذا التقسيم، والحد بين الطرفين واجهة خصائص (props) معتادة؛ إذ يمكن للمكوّن الخادمي أن يمرر خصائص، بما فيها بيانات سبق جلبها، إلى مكوّن عميل، تمامًا كما في تركيب مكوّنات React عادي.
هذا هو النموذج الذي وصل أولًا إلى App Router في Next.js، إذ ظهر في Next.js 13 أواخر عام 2022 وأصبحت إعداداته الافتراضية مستقرة، ثم أُضيف إلى Shopify Hydrogen، ويُعتمد الآن في Remix وTanStack Start وWaku. وهو الاتجاه الذي يسير نحوه React بوصفه الوضع الافتراضي.
RSC مقابل SSR: ليسا الشيء نفسه
يسهل الخلط بين الاثنين لأن كليهما يتطلب تشغيل React على الخادم. لكنهما يحلان مشكلتين مختلفتين.
| SSR | RSC | |
|---|---|---|
| ما يعمل على الخادم | الشجرة كاملة، مرة لكل طلب | المكوّنات المعلَّمة بأنها خادمية فقط |
| ما يُرسَل إلى العميل | HTML + JS للشجرة كاملة | HTML + JS فقط لمكوّنات العميل |
| تكلفة الترطيب | الشجرة كاملة | مكوّنات العميل فقط |
| جلب البيانات | داخل العرض في الخادم | داخل المكوّنات الخادمية مع await |
| موعد التشغيل | لكل طلب في SSR أو وقت البناء في SSG | نفسه — ينسجم مع كليهما |
| صيغة الإخراج | HTML | شجرة مكوّنات متسلسلة، وهي الحِمل (payload) الخاص بـ RSC، ثم HTML اختياريًا |
غالبًا ما تريد الاثنين معًا. تقلل RSC كمية JS الذي ترسله، ويقدم SSR ما ترسله من JS على هيئة HTML من أجل أول رسم سريع. في App Router، عندما تكتب مكوّنًا خادميًا غير متزامن يجلب البيانات، يفعل Next الأمرين: يعرضه على الخادم ويرسل HTML إلى المتصفح، ويرسل أيضًا حِمل RSC مضغوطًا إلى جانبه كي يحدّث العميل الشجرة في عمليات التنقل اللاحقة من دون إعادة تحميل كاملة للصفحة.
التوجيهان
يحكم نظام RSC بالكامل توجيهان على مستوى الملف:
'use client'في أعلى ملف يحدد كل مكوّن يُصدَّر منه على أنه مكوّن عميل. مكوّنات العميل هي React التي تعرفها دائمًا؛ إذ يمكنها استخدام خطافات (hooks) مثلuseStateوuseEffectومعالجات الأحداث وواجهات المتصفح.'use server'في أعلى ملف يحدد كل دالة تُصدَّر على أنها Server Action، أي دالة آمنة للاستدعاء من مكوّن عميل لكنها تنفذ فعليًا على الخادم.
في إطار عمل يفهم المكوّنات الخادمية، يصبح الملف الذي لا يحتوي أيًا من التوجيهين مكوّنًا خادميًا افتراضيًا. يمكن أن يكون async، ويمكنه استخدام await لجلب البيانات، وقراءة قاعدة البيانات مباشرة، واستخدام أسرار مخصصة للخادم، ولا يمكنه استخدام خطافات أو معالجات الأحداث.
// app/posts/page.tsx -- Server Component (no directive)
import LikeButton from "./LikeButton";\n
export default async function Posts() {
const posts = await db.posts.findMany({ orderBy: { createdAt: "desc" } });\n
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<LikeButton postId={post.id} initialCount={post.likes} />
</li>
))}
</ul>
);
}
// app/posts/LikeButton.tsx
"use client";
import { useState } from "react";\n
export default function LikeButton({ postId, initialCount }) {
const [count, setCount] = useState(initialCount);
return (
<button onClick={() => setCount((c) => c + 1)}>
Likes: {count}
</button>
);
}
تُعرض القائمة بالكامل على الخادم. لا تظهر شيفرتها — استعلام قاعدة البيانات وmap على المنشورات وJSX لكل `` — في حزمة العميل أبدًا. الشيفرة JavaScript الوحيدة التي تُرسَل هي LikeButton لأنها محددة بـ 'use client'. وإذا كانت القائمة تضم 50 منشورًا، فلا ترسل إلا مقدار شيفرة زر واحد، لا خمسين.
ما الذي يتغير داخل مكوّن خادمي
تتمتع المكوّنات الخادمية بإمكانية الوصول إلى بيئة الخادم، ما يعني ما يلي:
- استعلامات قاعدة بيانات مباشرة. لا حاجة إلى طبقة API. يمكن للمكوّن نفسه أن يستخدم
await db.users.findUnique(...). ولا توجد رحلة من العميل إلى الخادم في جلب هذه البيانات؛ إذ يجري الجلب في العملية نفسها التي يجري فيها العرض. - أسرار مخصصة للخادم. من الآمن الإشارة إلى متغيرات البيئة ومفاتيح API وعناوين قواعد البيانات؛ فلن تُضمَّن في حزمة المتصفح أبدًا.
- تبعيات كبيرة دون تكلفة. استيراد
markedأوprismjsأو مكتبة تواريخ بحجم 600 KB داخل مكوّن خادمي يكلف صفر بايت في العميل. تعمل المكتبة على الخادم، ولا يصل إلى المستخدم إلا ناتجها المعروض. awaitفي المستوى الأعلى. دوال المكوّن الخادمي غير متزامنة. يمكنك استخدامawaitمباشرة في جسم الدالة من دون حيلةuseEffectلجلب البيانات الأولية.
وهذه أشياء لا يستطيعونها:
- لا
useStateولاuseEffectولا أي hook آخر من React يعتمد على دورة حياة العرض. - لا معالجات أحداث مثل
onClickأوonChange. مرر الخصائص إلى الأسفل إلى مكوّن عميل بدلًا من ذلك. - لا واجهات متصفح مثل
windowأوlocalStorageأوIntersectionObserver.
Server Actions: استدعاء الخادم من مكوّن عميل
مكوّنات الخادم تميل بطبيعتها إلى القراءة أكثر من الكتابة. أما عمليات الكتابة — إرسال نموذج أو حذف صف أو تبديل إعداد — فقد استقرت لها Server Actions في React 19: دوال خادم تستوردها مباشرة في مكوّنات العميل وتستدعيها كأي دالة غير متزامنة أخرى.
// app/posts/actions.ts
"use server";
import { revalidateTag } from "next/cache";
import { auth } from "@/lib/auth";\n
export async function likePost(postId: string) {
const user = await auth();
if (!user) throw new Error("Not authenticated");\n
await db.likes.create({ data: { postId, userId: user.id } });
revalidateTag(\`post:\${postId}\`);
}
// app/posts/LikeButton.tsx
"use client";
import { useTransition } from "react";
import { likePost } from "./actions";\n
export default function LikeButton({ postId }) {
const [isPending, startTransition] = useTransition();\n
return (
<button
disabled={isPending}
onClick={() => startTransition(() => likePost(postId))}
>
{isPending ? "Liking..." : "Like"}
</button>
);
}
هناك أمور تحدث هنا تستحق التسمية:
- تعيش الدالة
likePostعلى الخادم. يستبدل نظام البناء عبارةimportفي العميل بإشارة، وهي معرف نصي، يستخدمها بيئة التشغيل للاستدعاء إلى الخادم. - لا يحتاج الزر إلى مسار
/api/likeمكتوب يدويًا. يتولى الإطار ربط الطلب والاستجابة. - بعد نجاح الإجراء، يبطّل
revalidateTagكل عمليات الجلب المخزّنة التي تحمل وسم معرّف المنشور، فيعيد العرض التالي بيانات حديثة.
تعمل Server Actions أيضًا بوصفها قيمة action في النموذج، وفي هذه الحالة تتراجع بسلاسة؛ إذ يظل النموذج قابلًا للإرسال حتى قبل تحميل JavaScript.
// app/comments/CommentForm.tsx
"use client";
import { useActionState } from "react";
import { postComment } from "./actions";\n
export function CommentForm({ postId }) {
const [state, formAction, isPending] = useActionState(postComment, null);\n
return (
<form action={formAction}>
<input type="hidden" name="postId" value={postId} />
<textarea name="body" required />
<button disabled={isPending}>
{isPending ? "Posting..." : "Post comment"}
</button>
{state?.error && <p>{state.error}</p>}
</form>
);
}
useActionState أحد خطافات React 19 الجديدة، وuseFormStatus خطاف آخر، وهو مفيد داخل زر الإرسال لمعرفة ما إذا كان النموذج الأب يرسل بياناته حاليًا.
قواعد التركيب
قواعد خلط المكوّنات الخادمية بمكوّنات العميل بسيطة، لكن لها بضع زوايا حادة:
- يمكن لمكوّن خادمي أن يعرض مكوّن عميل، مع تمرير البيانات بوصفها خصائص.
- يمكن لمكوّن عميل أن يعرض مكوّنًا خادميًا فقط إذا كان المكوّن الخادمي قد مُرر بوصفه
childrenأو خاصية. لا يستطيع مكوّن العميل استيراد مكوّن خادمي مباشرة؛ فبمجرد أن تعبر شجرة إلى داخل'use client'، يصبح كل ما يُستورد من داخل تلك الشجرة عميليًا أيضًا. - يجب أن يكون كل ما يمر عبر هذا الحد كخصائص قابلًا للتسلسل: الأرقام والسلاسل والقيم المنطقية والمصفوفات والكائنات العادية وDates وMaps وSets والوعود، إذ تسلسل React الوعود المعلّقة، وعناصر JSX. لا دوال ولا نسخ أصناف.
النمط رقم 2 هو مصدر التعثر الأكثر شيوعًا. والحل هو قاعدة «الالتفاف، لا الاستيراد»:
// Bad: ClientLayout imports a Server Component
"use client";
import ServerSidebar from "./ServerSidebar"; // ERROR\n
export default function ClientLayout({ children }) {
return (
<div>
<ServerSidebar />
{children}
</div>
);
}
// Good: ClientLayout takes the Server Component as a child
"use client";
export default function ClientLayout({ sidebar, children }) {
return (
<div>
{sidebar}
{children}
</div>
);
}\n
// And the parent (a Server Component) composes them:
// app/layout.tsx
import ClientLayout from "./ClientLayout";
import ServerSidebar from "./ServerSidebar";\n
export default function RootLayout({ children }) {
return (
<body>
<ClientLayout sidebar={<ServerSidebar />}>{children}</ClientLayout>
</body>
);
}
حجم الحزمة في الواقع
قدّرت RFC الأصلية الخاصة بـ RSC انخفاضًا في حجم الحزمة بنسبة 18–29%. وتقع تقارير الفرق التي انتقلت إلى App Router في نطاق مشابه، إذ تسجل عادةً انخفاضًا في JS المرسل إلى العميل يتراوح بين 20% و40%، مع مكاسب أكبر في المسارات الغنية بالمحتوى حيث تمثل معظم الصفحة عرض بيانات بدلًا من تفاعل.
شكل المكسب:
- الاستيرادات داخل المكوّنات الخادمية لا تساهم بشيء. عارض Markdown أو مُلوّن بناء جملة أو مكتبة تواريخ ثقيلة — لا شيء من ذلك يُرسَل.
- يبقى شيفرة جلب البيانات في الخادم. فحوص المصادقة وبناة الاستعلام وعملاء ORM تبقى جميعها على الخادم.
- تنكمش مكوّنات العميل إلى التفاعل الفعلي. مكوّن العميل المعتاد زر أو نافذة منبثقة أو حقل إدخال، أي شيء صغير.
الأمر ليس مجانيًا. تعمل مكوّنات الخادم على الخادم، ما يكلّف دقائق في الخدمات عديمة الخوادم أو الحاويات، ويضيف حِمل RSC نفسه بايتات إلى كل تنقل. لكن في معظم الصفحات، تتفوق JavaScript الموفَّرة على البايتات المضافة.
اصطلاحات الملفات في App Router الخاص بـ Next.js
يبني App Router نظام RSC في نظام الملفات. ويملك المسار النموذجي ملفات خاصة كالتالي، كل منها Server Component ما لم يحمل 'use client':
| الملف | الغرض |
|---|---|
page.tsx |
واجهة المسار الرئيسية. |
layout.tsx |
يلف page.tsx وأي مسارات متداخلة. ويبقى ثابتًا أثناء عمليات التنقل ضمن نطاقه. |
loading.tsx |
بديل `` للعرض الأولي لـ page.tsx. |
error.tsx |
حد أخطاء محدود بالمسار. ويجب أن يكون مكوّن عميل. |
not-found.tsx |
يُعرض عند استدعاء notFound(). |
template.tsx |
مثل layout.tsx لكنه يُعاد تركيبه عند كل تنقل. |
route.ts |
معالج HTTP، من دون عرض، للواجهات وwebhooks. |
تتيح هذه الاصطلاحات للتوجيه أن يبث التخطيطات والمسارات المتوازية والمسارات المعترضة من دون أن تضطر إلى ربط `` أو حدود الأخطاء يدويًا. يوجد هيكل الصفحة في layout.tsx، والبيانات البطيئة في page.tsx، وloading.tsx هو البديل الاحتياطي. يركّب الإطار بينها في استجابة بث واحدة.
متى تستخدم كل خيار
استخدم القواعد التالية كدليل تقريبي عند تصميم مسار جديد:
- هيكل على مستوى الصفحة، وتنقل، وتذييل، وعناوين: Server Components. لا تحتاجها إلى تفاعل أبدًا.
- قوائم محتوى من قاعدة بيانات أو CMS: Server Components، مع جلب بيانات
awaitداخلها. - النماذج: مكوّن عميل لحقول النموذج وحالة الإرسال، ويستدعي Server Action.
- كل ما يستخدم
useStateأوuseEffectأو معالجات الأحداث: Client Component. أبقِ استيراداته محدودة. - المكتبات الثقيلة المستخدمة لعرض الناتج فقط، مثل Markdown وتلوين بناء الجملة والرسوم البيانية بصيغة SVG: Server Components، لإبقائها خارج حزمة العميل.
- الأجسام التفاعلية فعلًا، مثل محرر النصوص الغني والخريطة ومحرر الشيفرة: Client Component، ويُحمّل غالبًا عبر
next/dynamicلإبقاء تكلفته خارج المسار الأولي.
يتطلب التغيير الذهني اعتبار 'use client' الاستثناء، الذي تعلنه عند أوراق الشجرة حيث يوجد التفاعل فعليًا. والخيار الافتراضي هو الخادم.
ما لا يزال قيد التطور
RSC مستقر في React 19 وفي App Router الخاص بـ Next.js، لكن النظام البيئي المحيط به ما يزال يتطور:
- توجيه
use cache، التجريبي في Next.js 15، يتيح لك تحديد دالة أو مكوّن على أنه قابل للتخزين المؤقت بمعزل عن عمليات جلب بياناته. وهذه أنظف طريقة للتعبير عن «اعرض هذا على الخادم، وخزّن الناتج حسب الوسيط» من دون نشر خياراتfetchفي كل مكان. - واجهات الطلب غير المتزامنة: في Next.js 15، أصبحت
cookies()وheaders()وdraftMode()وparamsالديناميكية وsearchParamsغير متزامنة ويجب انتظارها. يجعل هذا التغيير الحد بين الديناميكي والساكن أسهل على الإطار في استنتاجه ويفتح الباب أمام العرض الجزئي المسبق. - التبني خارج Next.js: تعمل Waku وRedwoodJS وTanStack Start وRemix v3 جميعها على دعم RSC. والبروتوكول المقصود أن يكون قابلًا للنقل عبر الأطر، لا خاصًا بـ Next.js.
- قصة البث والتخزين المؤقت تتقارب على إعادة التحقق بالوسوم والمسارات في كل مكان، بحيث أصبح
revalidateTagوrevalidatePathواجهتي الإبطال الأساسيتين.
تنتهي هذه التطورات إلى نموذج تستخدم فيه العرض وجلب البيانات والتغييرات وإبطال الذاكرة المؤقتة كلّها أدوات React نفسها، وهي المكوّنات الخادمية وServer Actions وSuspense وذاكرات الوسوم، بدلًا من أن تكون مفاهيم منفصلة تربطها يدويًا. وRSC هو التغيير البنيوي الذي يجعل هذه النهاية قابلة للوصول.