الإقليم ٠١ — الممثل والسيناريو
في الإقليم الماضي بنيتَ محرّكاً يطابق شجرة وصفٍ بشجرةٍ سابقة ويُحدّث DOM بأقل قدرٍ من اللمس. لكن الشجرة كانت دالّةً واحدة ضخمة تصف كل شيء: لوحة المهام كاملةً، من الحاوية الخارجية حتى آخر زر. هذا الإقليم يكسر تلك الدالّة الواحدة إلى ممثلين مستقلين، كل واحدٍ له سيناريوه الخاص (props) ومسؤوليته المحدودة.
يبني فوق: الوصف كبيانات، والمطابِق الذي يقارن بدل أن يعيد البناء (٠). النطاق: كيف نُقسّم شجرة وصفٍ ضخمة إلى قطعٍ مسمّاة، قابلة لإعادة الاستخدام، بمدخلاتٍ خاصة بكل واحدة. لا حالة داخلية بعد — ذاك الإقليم ٢. يفتح: الذاكرة عبر العروض (٢)، وورشة الأزياء (٩)، وخريطة المسرح (٨).
اللغز المستفزّ
عندك ثلاث "بطاقات" تصف مساراً تعليمياً، بنفس الشكل بالضبط لكن ببيانات مختلفة:
const view = h("div", { className: "grid" },
h("div", { className: "card" },
h("h3", null, "منهج الويب الشامل"),
h("p", null, "٤٢ متدرّباً"),
h("span", { className: "badge draft" }, "مسودة"),
),
h("div", { className: "card" },
h("h3", null, "الأمن السيبراني"),
h("p", null, "١٧ متدرّباً"),
h("span", { className: "badge published" }, "منشور"),
),
h("div", { className: "card" },
h("h3", null, "أساسيات الشبكات"),
h("p", null, "٩ متدرّبين"),
h("span", { className: "badge draft" }, "مسودة"),
),
);
الآن المطلوب: غيّر تصميم البطاقة — أضف حداً دائرياً وظلاً خفيفاً لكل div.card. بما أن الشكل مكرَّر يدوياً ثلاث مرات، عليك تعديل ثلاثة مواضع منفصلة. عدّلها. الآن أضف بطاقة رابعة بنفس الشكل — انسخ/الصق كتلة كاملة وغيّر النصوص. الآن تخيّل ٥٠ بطاقة تأتي من قاعدة بيانات، لا مكتوبة يدوياً. الطريقة أعلاه لا تعمل أصلاً لبيانات ديناميكية العدد — لأنها ليست "شكلاً" له مدخلات، هي نصٌّ مكرَّر بالصدفة يتشابه.
السؤال المستفزّ: لو "الشكل" واحد و"البيانات" فقط تتغيّر في كل تكرار — ما الذي يمنعك من كتابة "الشكل" مرّة واحدة، كدالّة تأخذ البيانات وتُخرج الشجرة؟ (تلميح: أنت تعرف الجواب من عالم البرمجة العادي منذ سنين — اسمه بسيط جداً).
ليش: المكوّن = دالّة من بيانات إلى وصف
الجواب فعلاً بسيط: دالّة عادية، مثل أي دالّة كتبتَها في حياتك، تأخذ مدخلات وتُرجع قيمة. هنا القيمة المُرجَعة شجرة وصف (VNode) بدل رقم أو نص:
type CourseCardProps = {
title: string;
learners: number;
status: "draft" | "published";
};
function CourseCard({ title, learners, status }: CourseCardProps): VNode {
return h("div", { className: "card" },
h("h3", null, title),
h("p", null, `${learners} متدرّباً`),
h("span", { className: `badge ${status}` }, status === "draft" ? "مسودة" : "منشور"),
);
}
هذا كل ما "المكوّن" يعنيه في React: دالّةٌ اسمها يبدأ بحرفٍ كبير، تأخذ كائن مدخلاتٍ واحداً (اصطلاحاً اسمه props)، وتُرجع وصف واجهة. لا طبقة سحرية إضافية. الآن التكرار يختفي:
const courses: CourseCardProps[] = [
{ title: "منهج الويب الشامل", learners: 42, status: "draft" },
{ title: "الأمن السيبراني", learners: 17, status: "published" },
{ title: "أساسيات الشبكات", learners: 9, status: "draft" },
];
const view = h("div", { className: "grid" },
...courses.map((c) => CourseCard(c)), // نداء دالّة عادي، بعدد ديناميكي
);
لماذا هذا أفضل من نسخ ولصق؟ ليس فقط لأنه أقصر. عدّل تصميم البطاقة الآن — مكانٌ واحد. أضف بطاقة رقم ٥١ — عنصر جديد في مصفوفة courses، لا كتلة كودٍ جديدة. الخطأ إن وُجد يعيش في مكانٍ واحد، لا في ثلاثة نسخ قد تنحرف عن بعضها بمرور الوقت (نفس درس الإقليم ٠: نسخةٌ واحدة من الحقيقة أفضل من نسختين متزامنتين يدوياً — وهنا "الحقيقة" هي منطق العرض نفسه، لا البيانات فقط).
كيف: يحتاج محرّكنا أن "يفهم" المكوّنات
في الإقليم ٠، h’s أول معامل (type) كان دائماً نصاً ("div", "li"). الآن نريده يقبل أيضاً دالّة. TypeScript يعبّر عن هذا بنوعٍ اتحاديّ (union) تعرفه مسبقاً:
type ComponentFn = (props: Record<string, unknown>) => Child;
type VNode = {
type: string | ComponentFn; // وسمٌ أو مكوّن
props: Record<string, unknown> & { children?: Child[] };
};
والمترجم (Babel/esbuild) يقرر أيّهما تمرّر بقاعدةٍ ميكانيكية صارمة، لا اصطلاحاً جمالياً فقط: الحرف الأول من اسم الوسم في JSX. <div> يبدأ بحرفٍ صغير → يُمرَّر كـنص حرفي "div". <CourseCard> يبدأ بحرفٍ كبير → يُمرَّر كـمرجع الدالّة نفسها CourseCard (لا النص "CourseCard"). هذا — لا الأناقة — هو سبب القاعدة الشهيرة "اسم المكوّن يبدأ بحرفٍ كبير إلزامياً": لو كتبتَ <courseCard> بحرفٍ صغير، سيمرّره JSX كنص "courseCard" فيحاول DOM إنشاء وسم HTML باسم <coursecard> غير موجود، بصمتٍ تام بلا خطأ واضح.
الآن نُعلّم createDom وpatch (من الإقليم ٠) قراءة هذا الفرق. الجزء الخاص بالنص معروفٌ عندك؛ الجزء الجديد — حين يكون type دالّة — نناديها بـ props ونكمل بناء ما تُرجعه:
function createDom(vnode: Child): Node {
if (typeof vnode === "string") return document.createTextNode(vnode);
if (typeof vnode.type === "function") {
const rendered = vnode.type(vnode.props); // ناد الممثل بسيناريوه
return createDom(rendered); // وابنِ DOM لما أرجعه
}
// ...بقية createDom كما بنيتَها في الإقليم ٠، بلا تغيير
}
vnode.type(vnode.props) هذه بالضبط اللحظة التي "يُستدعى" فيها المكوّن — يقرأ سيناريوه (props) ويُخرج وصف مشهده. لاحظ: الدالّة لا تلمس DOM ولا تعرف عن createDom أصلاً — فصلٌ نظيفٌ بين "صف المشهد" و"طابقه بالواقع" تماماً كما في الإقليم ٠، لكن الآن على مستوى ممثلٍ واحد لا الشجرة كلها.
كيف: children كسيناريو خاص — التركيب (composition)
بعض المكوّنات لا تعرف محتواها الداخلي مسبقاً — تعرف فقط "إطاراً" حوله. مثال: لوحة (Panel) لها عنوان، لكن محتواها يقرره من يستخدمها:
type PanelProps = { title: string; children: Child };
function Panel({ title, children }: PanelProps): VNode {
return h("section", { className: "panel" },
h("h2", null, title),
children, // أياً كان ما مُرِّر هنا، يُدرَج كما هو
);
}
وبصياغة JSX، أي شيء تكتبه بين وسمي الفتح والإغلاق يصير تلقائياً قيمة props.children:
<Panel title="المسارات النشطة">
<CourseCard title="..." learners={9} status="draft" />
<p>ملاحظة إضافية هنا.</p>
</Panel>
// يكافئ تماماً:
h(Panel, { title: "المسارات النشطة" },
h(CourseCard, { title: "...", learners: 9, status: "draft" }),
h("p", null, "ملاحظة إضافية هنا."),
)
// و h تضع كل الوسائط بعد props داخل props.children تلقائياً
children ليست كلمة سحرية في اللغة — هي اسم prop عادي جداً، اصطلح React على تعبئته تلقائياً من محتوى الوسم. هذا هو التركيب: Panel لا يعرف ولا يهتم بما بداخله؛ يضع إطاراً واسماً، ويُدرِج أي محتوى يُعطى له. هكذا تبني واجهات من قطعٍ صغيرة متعشّشة، كل قطعة تجهل تفاصيل جاراتها.
ليش: تدفّقٌ باتجاهٍ واحد — ولماذا هذا يخلق ألماً لاحقاً عمداً
props تنزل من الأب للابن فقط. الابن يقرأها، لا يعدّلها. لماذا هذا القيد صارم وليس اقتراحاً؟ تذكّر الإقليم ٠: كل إعادة رسمٍ تحدث لأن مالِك الشجرة نادى الدالّة من جديد بوصفٍ جديد. لو عدّل الابن props مباشرة (بالتحوير المباشر mutation)، لا شيء "يلاحظ" هذا التعديل — لا إعادة رسمٍ تُطلَق، ولا مطابِق يقارن — لأن آلية إعادة الرسم كلها مبنية على "استدعاءٍ جديد بوسيطٍ جديد"، لا على مراقبة تغييرات كائنٍ موجود. تعديل props مباشرةً يُرجعك بالضبط لمشكلة الإقليم ٠: نسخةٌ من الحقيقة انحرفت عن الأصل بصمت.
هذا القيد — تدفّقٌ لأسفل فقط — يخلق مشكلةً هندسية حقيقية ستشعر بها الآن، ولن نحلّها في هذا الإقليم عمداً: ماذا لو مكوّنٌ عميق جداً في الشجرة يحتاج قيمةً يملكها جدّه الأكبر؟ الطريقة الوحيدة المتاحة لك الآن هي تمريرها عبر كل مستوىً وسيط، حتى لو لم يستخدمها ذلك المستوى إطلاقاً — يمرّرها فقط لأنه الجسر الوحيد المسموح.
اللغز/البناء من الصفر
الأدوات: محرّك الإقليم ٠ (h, createDom, patch) بعد التوسعة أعلاه (type يقبل دالّة). الممنوع: أي تحوير مباشر لـprops داخل مكوّن (props.x = ...)؛ أي تكرار نصّي لنفس بنية h(...) أكثر من مرّة (لو كرّرتَها، استخرجها لدالّة).
اللغز أ — استخرج المكوّن. حوّل لغز البداية (بطاقات المسارات) إلى دالّة CourseCard(props) واحدة تُستدعى من مصفوفة بيانات. أضف بطاقة خامسة بتغيير المصفوفة فقط، بلا لمس أي h(...) جديد. تحقّق: عدّل تصميم البطاقة في مكانٍ واحد وشاهده ينعكس على الخمس كلها.
اللغز ب — وسّع patch لتفهم المكوّنات. createDom أعلاه صار يفهم type كدالّة. لكن patch (من الإقليم ٠) ما زال يفترض أن type نصٌّ فقط. وسّعه: حين يتطابق oldVNode.type وnewVNode.type وكلاهما نفس مرجع الدالّة، لا تستبدل العقدة — بدل ذلك ناد الدالّة بالـprops الجديدة، واحصل على شجرة مُخرَجة جديدة، ثم تابع المطابقة تكرارياً بين المُخرَج القديم (احفظه!) والمُخرَج الجديد، بنفس منطق patch العادي. معيار النجاح: أعد رسم شبكة البطاقات بعد تغيير نص بطاقة واحدة فقط — يجب أن تُحدَّث عقدة DOM لتلك البطاقة حصراً، لا أن تُعاد كل البطاقات من الصفر (تحقّق بإضافة خاصية مؤقتة تطبع في الـconsole عند إنشاء عقدة h3 جديدة — يجب أن تُطبَع مرّة واحدة فقط لكل بطاقة، لا في كل إعادة رسم).
اللغز ج — التركيب مع children. ابنِ Panel كما في الشرح أعلاه، واستخدمه ليحوي شبكة البطاقات من اللغز أ، مع عنوان علوي وفقرة ملاحظات أسفل الشبكة — كل ذلك كـchildren متعدد لعنصر Panel واحد. تحقّق أن Panel نفسه لا يعرف شيئاً عن CourseCard ولا عن البطاقات — فقط يستقبل ويُدرِج.
اللغز د — اشعر بألم prop drilling، واكتبه بجملة. ابنِ سلسلة مكوّنات متداخلة أربعة مستويات: Page → Sidebar → UserPanel → UserBadge. Page يملك قيمة currentUserName: string. UserBadge (المستوى الرابع) هو الوحيد الذي يعرضها فعلياً. مرّرها عبر Sidebar وUserPanel رغم أنهما لا يستخدمانها إطلاقاً — فقط "يُمرّرانها". اكتب تعليقاً في الكود عند كل مستوىً وسيط يوضّح أنه لا يستخدم القيمة، فقط يُمرّرها. اكتب بجملة واحدة: ماذا لو كانت السلسلة عشرة مستويات بدل أربعة، وعشر قيم مختلفة بدل واحدة؟ لا تحاول حلّ هذا الآن — فقط سجّل الشعور بدقّة. سيُحلّ في الإقليم ٧.
اللغز هـ — Fragment: أعِد استخدام محرّك الإقليم ٠ لحل مشكلة جديدة. اكتب مكوّن TableRow يحتاج أن يُرجع خليتين شقيقتين (<td> و<td>) بلا عنصر أب يلفّهما (لأن HTML الجداول تنكسر بصرياً لو لُفّت صفوف داخل div غريب بين <tr> وأبنائها). لأن دالّتك يجب أن تُرجع قيمة واحدة، اخترع نوع type خاصاً (مثلاً السلسلة الحرفية "FRAGMENT") يعني لمحرّكك "لا تُنشئ عقدة DOM لهذا العنصر إطلاقاً — فقط ادمج أبناءه في مكان الأب مباشرة". عدّل createDom/patch ليتعاملا مع هذه الحالة الخاصة. معيار النجاح: افحص DOM الناتج فعلياً (console.log(container.innerHTML)) ولا تجد أي أثر لعقدة FRAGMENT — فقط الأبناء مباشرة تحت الأب الحقيقي.
الخلاصة — أين تتّصل هذه العقدة بالشجرة
- ما بنيتَه: محرّكٌ يفهم الآن نوعين من
type: نصٌّ (وسمٌ مضيف حقيقي في DOM) أو دالّة (مكوّن، يُستدعى بـpropsليُنتج شجرةً أخرى تُعالَج تكرارياً). التركيب عبرchildrenيسمح بمكوّنات "إطار" تجهل محتواها.Fragmentيحلّ قيد "دالّة تُرجع قيمة واحدة" حين لا تريد عنصر DOM أب فعلي. - العقدة الجديدة: *المكوّن كدالّة نقيّة من props إلى وصف* — وحدة إعادة الاستخدام الأساسية في كل هذا المنهج من هنا فصاعداً.
- الألم المتروك عمداً: تدفّق
propsباتجاهٍ واحد يعني أن أي قيمة يحتاجها مكوّنٌ عميق يجب أن تُمرَّر يدوياً عبر كل مستوىً وسيط — ولو لم يستخدمها. هذا سيُحلّ حين نبني بديلاً لـ"التمرير اليدوي" (الإقليم ٧). ولاحظ أيضاً: كل مكوّناتنا حتى الآن بلا ذاكرة — نداءCourseCardمرّتين بنفس الـpropsينتج نفس المُخرَج تماماً، لا يتذكّر شيئاً من المرّة السابقة. من أين يبدأ المكوّن "يتذكّر" شيئاً خاصاً به؟ → الإقليم ٢.
الوصلة بمشروعك
نمط Panel/children أعلاه هو بالضبط كيف تُبنى مكوّنات مثل Dialog في نظام التصميم عندكم (shared/ui/dialog.tsx): المكوّن الإطار لا يعرف شيئاً عن محتوى الحوار الفعلي، فقط يستقبله كـchildren ويرتّب حوله سلوكاً (الفتح/ الإغلاق، النقر خارج الحوار...). وتقسيم features/curriculum-builder إلى مكوّنات صغيرة مثل PathCard, SprintCard, TaskCard بدل شجرة h(...) ضخمة واحدة هو نفس الدرس الذي بنيتَه هنا حرفياً: شكلٌ واحد، بيانات متعددة.
البذرة التالية: كل مكوّن بنيناه حتى الآن جاهلٌ تماماً بماضيه — يُستدعى، يُنتج وصفاً، ينتهي دوره. لكن ماذا لو أراد ممثلٌ أن يحتفظ بشيء بين عرضٍ وعرض — عدّاد نقرات، نص كتبه المستخدم في حقل، حالة "مفتوح/مغلق"؟ الدالّة تُستدعى من الصفر في كل مرة، فأين "يعيش" هذا الشيء الذي يجب أن يبقى؟