الإقليم ٠٠ — قبل أن يُرفع الستار
قبل أي React، قبل أي JSX، فيه مشكلة واحدة بسيطة الوصف وقاسية التنفيذ: عندك بيانات تتغيّر، وعندك شاشة يجب أن تعكسها دائماً. كل إطار عمل واجهاتٍ في التاريخ — من jQuery إلى React إلى ما بعده — هو محاولة أخرى لحلّ هذه المشكلة بالذات. هذا الإقليم يبني الحلّ بيدك، من الألم حتى الآلية.
يبني فوق: لا شيء — هذه نقطة البداية المطلقة. النطاق: كيف تُوصَف الواجهة، وكيف تُطابَق مع الـDOM الحقيقي — بمعزلٍ عن "متى" تتغيّر البيانات (تلك مشكلة الإقليم ٢). هنا نفترض أن شيئاً خارجياً يستدعي "أعد الرسم الآن" يدوياً؛ لا يهمّنا بعد من يستدعيه ولا متى. يفتح: التركيب والمكوّنات (١)، ثم الذاكرة عبر العروض (٢).
اللغز المستفزّ
ابنِ لوحة مهام بسيطة: قائمة عناصر، كل عنصر له نص وحالة (منجز/غير منجز)، وزر حذف. وفوقها عدّاد "المتبقّي: X". بلا أي مكتبة — DOM خام فقط:
type Task = { id: string; text: string; done: boolean };
let tasks: Task[] = [
{ id: "1", text: "اكتب المنهج", done: false },
{ id: "2", text: "راجع الكود", done: false },
];
const list = document.getElementById("list") as HTMLUListElement;
const counter = document.getElementById("counter") as HTMLSpanElement;
function addTaskToDom(task: Task) {
const li = document.createElement("li");
li.textContent = task.text;
li.className = task.done ? "done" : "";
const del = document.createElement("button");
del.textContent = "حذف";
del.onclick = () => {
tasks = tasks.filter((t) => t.id !== task.id);
li.remove(); // ①
updateCounter(); // ②
};
li.onclick = () => {
task.done = !task.done;
li.className = task.done ? "done" : ""; // ③
updateCounter(); // ④
};
li.appendChild(del);
list.appendChild(li);
}
function updateCounter() {
counter.textContent = String(tasks.filter((t) => !t.done).length); // ⑤
}
tasks.forEach(addTaskToDom);
updateCounter();
شغّلها ذهنياً، وأضف ميزة: زر "امسح المنجَز" يحذف كل مهمة done === true دفعة واحدة. بعد خمس دقائق تكتيب ستكتشف أنك تكتب نفس الجملة أربع مرات بصياغات مختلفة: *"غيّر البيانات، ثم دوّر على DOM تجد العنصر المطابق، ثم حدّثه، ثم لا تنسَ العدّاد"*. عدّ بنفسك: كم موضعاً في الكود أعلاه يعرف عن tasks؟ الجواب أكثر من مكانٍ واحد — والمصفوفة والـDOM الآن نسختان لنفس الحقيقة، وأنت الجسر اليدوي الوحيد بينهما.
السؤال المستفزّ: ماذا لو نسيتَ سطراً واحداً من الأسطر المرقّمة ①-⑤؟ جرّب احذف السطر ② ذهنياً وتخيّل النتيجة. الآن تخيّل شاشة فيها ٢٠٠ عنصر تفاعلي، لا ٢. كم سطراً منسياً ستحتاج لتخترع خطأً بصرياً صامتاً — لا يرمي استثناءً، فقط يكذب على المستخدم؟
هذا هو شذوذ التحديث بعينه، لكن في الواجهة: حقيقةٌ واحدة (tasks)، نسخةٌ مرئية منفصلة (DOM)، وأنت المسؤول اليدوي عن تطابقهما عند *كل* تغيير مهما صغر.
ليش: الفكرة المركزية — صف "ماذا"، لا "كيف تُحدّث"
الكود أعلاه أمريّ (imperative): يقول لكل تغيير "اذهب، جِد العنصر، عدّله". البديل تصريحيّ (declarative): تكتب دالّةً واحدة تقول *"هكذا تبدو الشاشة لهذه البيانات بالضبط"*، وتترك لآلةٍ أخرى مهمة "قارن بالشكل السابق وطبّق الفرق":
لاحظ الفرق الجوهري: أنت لا تكتب أي كودٍ يعرف كيف يُحدّث — فقط دالّة تصف النتيجة النهائية لأي حالة، ولو كانت الحالة الأولى أو الألف. "كيف أنتقل من الشكل القديم للجديد" لم يعد مشكلتك؛ صار مشكلة المطابِق (reconciler)، وهو ما سنبنيه بيدنا خلال هذا الإقليم.
هذا يقتل شذوذ التحديث من جذره: لا توجد نسختان تتزامنان يدوياً — توجد حقيقة واحدة (tasks)، والشاشة اشتقاقٌ محسوب منها دائماً، لا حالة توازيها.
كيف: الوصف كبيانات عادية (element tree)
الخطوة الأولى: صف الواجهة كـبيانات، لا كأوامر DOM. شجرة من كائنات عادية:
type Child = VNode | string;
type VNode = {
type: string; // اسم الوسم: "div", "li", "button"...
props: Record<string, unknown>; // خصائص + معالجات أحداث (onClick...)
children: Child[];
};
function h(type: string, props: Record<string, unknown> | null, ...children: Child[]): VNode {
return { type, props: props ?? {}, children };
}
h (اختصار hyperscript) تبني شجرة بيانات محضة — لا تلمس DOM إطلاقاً. جرّب:
const view = h("ul", null,
h("li", { className: "done" }, "اكتب المنهج"),
h("li", null, "راجع الكود"),
);
view مجرّد كائن JS متداخل، تقدر تطبعه بـconsole.log وتقرأه كنص. هذا كل ما JSX هو فعلياً — سنعود لهذا بعد قليل.
تحويل الشجرة إلى DOM حقيقي (المرة الأولى — أمريّة كاملة)
function createDom(vnode: Child): Node {
if (typeof vnode === "string") return document.createTextNode(vnode);
const el = document.createElement(vnode.type);
for (const [key, value] of Object.entries(vnode.props)) {
setProp(el, key, value);
}
for (const child of vnode.children) {
el.appendChild(createDom(child));
}
return el;
}
function setProp(el: HTMLElement, key: string, value: unknown): void {
if (key.startsWith("on") && typeof value === "function") {
el.addEventListener(key.slice(2).toLowerCase(), value as EventListener);
} else if (key === "className") {
el.setAttribute("class", String(value));
} else {
el.setAttribute(key, String(value));
}
}
function mount(vnode: Child, container: HTMLElement): void {
container.innerHTML = ""; // امسح كل شيء، ابنِ من العدم
container.appendChild(createDom(vnode));
}
جرّب هذا المشهد: عندك <input> داخل الشجرة يكتب فيه المستخدم، وكل ثانية تصل بيانات جديدة (رسالة "متصل الآن" مثلاً) فتنادي mount(view(data), root) من جديد. جرّبها فعلياً وراقب حقل الإدخال أثناء الكتابة. ستفقد التركيز (focus) والنص المكتوب في كل مرة — لأن innerHTML = "" يدمّر كل عقدة DOM ويبنيها من الصفر، حتى العقد التي لم يتغيّر فيها حرف. هذا بالضبط الذي تفاديناه بالأمر، لكنه رجع من الباب الخلفي: أعدنا بناء العالم كله لتغييرٍ واحد.
لاحظ: كتابة الوصف بشكل تصريحي (الشجرة) لم تحلّ المشكلة بمفردها. الحلّ الحقيقي في كيف نطابق النسختين — وهنا يأتي التصالح.
ليش وكيف: التصالح (Reconciliation) — قارِن، لا تبنِ من جديد
الفكرة: بدل container.innerHTML = "" ثم إعادة كل شيء، قارن الشجرة الجديدة بالقديمة عقدةً بعقدة، وطبّق فقط ما تغيّر فعلاً:
- نفس النوع (
typeمتطابق)؟ حدّث الخصائص فقط، وانزل للأبناء بنفس المنطق. - نوع مختلف تماماً (
liصارتdivمثلاً)؟ لا معنى للمقارنة الجزئية — استبدل العقدة بالكامل. - عقدة موجودة في القديم غير موجودة في الجديد؟ احذفها.
- عقدة موجودة في الجديد غير موجودة في القديم؟ أنشئها وأضفها.
هذا القرار (نفس النوع = حدّث في مكانه، نوع مختلف = استبدل بالكامل) هو بالضبط ما يفعله React داخلياً (وقد سمّيتَه للتو: تعرف الآن *لماذا* لا يجوز أن يتحوّل مكوّنك من <div> إلى <span> دون أن يفقد أي حالة داخلية له — لأن المطابِق لا يحاول حتى المقارنة الجزئية، يرمي العقدة القديمة كلها).
أثر مباشر: لماذا الأبناء يحتاجون هويّة (المشكلة التي تمهّد لها الـkeys)
لمّا تكون الأبناء مصفوفة، أبسط استراتيجية مطابقة هي بالموضع: الابن رقم ٠ الجديد يُقارَن بالابن رقم ٠ القديم، وهكذا. هذا يعمل تماماً طالما لا يتغيّر ترتيب العناصر. لكن ماذا لو أضفتَ عنصراً جديداً في أول القائمة؟
المطابِق بالموضع يقارن: الموضع ٠ (مهمة-أ القديمة) بالموضع ٠ الجديد (مهمة-جديدة) — نفس النوع (li مع li)، فيظنّها نفس العنصر تغيّر نصّه، ويحدّث النص فقط بدل أن يفهم أن عنصراً جديداً *أُدرِج*. لو كان أحد عناصر القائمة يحمل <input> بداخله فيه نصٌّ كتبه المستخدم أو حالة checked، هذه الحالة تبقى ملتصقة بالموضع بدل أن ترافق العنصر المنطقي الصحيح — فيبدو الأمر وكأن قيم الإدخال "قفزت" بين المهام. هذا خطأٌ حقيقي ستصنعه بيدك بعد قليل وتشعر به قبل أن تُصلحه.
الحلّ: هويّة صريحة لا تعتمد على الموضع — مفتاحٌ (key) ثابتٌ لكل عنصر منطقي (مثل id المهمة)، يطابق المطابِق به بدل الموضع.
اللغز/البناء من الصفر
الأدوات المسموحة: h، createDom، setProp كما كُتبت أعلاه (هذه سباكة جاهزة، ليست هي الدرس). الممنوع: container.innerHTML = "" أو أي شكل من "امسح كل شيء وابنِ من جديد" داخل حلّك.
اللغز أ — التصالح البسيط (بلا قوائم). اكتب دالّتين: patch(dom: Node, oldVNode: Child, newVNode: Child, parent: Node): void تُطبّق القرارات الأربعة أعلاه (نفس النوع → حدّث خصائص وانزل للأبناء، نوع مختلف → استبدل، غياب في الجديد → احذف، غياب في القديم → أنشئ) على عقدة واحدة غير قائمة (مثلاً عنوان <h1> نصّه يتغيّر، أو حاوية <div> تتغيّر خصائصها). معيار النجاح الذي تتحقق منه بنفسك: ضع <input> كأخٍ لعنصر يتغيّر باستمرار (عدّاد ثوانٍ مثلاً يعاد رسمه كل ثانية عبر patch)، اكتب داخل الـinput، وراقبه لا يفقد التركيز ولا القيمة المكتوبة رغم إعادة الرسم المستمرة للأخ المجاور. إذا فقد التركيز، فحلّك يعيد إنشاء عقدٍ لم تتغيّر — ابحث أين.
اللغز ب — الأبناء كقائمة (بالموضع أولاً، بلا مفاتيح). وسّع patch لتتعامل مع children كمصفوفة: طابق كل ابنٍ بموضعه (oldChildren[i] مع newChildren[i])، بنفس القرارات الأربعة. طبّقها على لوحة المهام (اللغز الأول في هذا الإقليم). تحقّق أن الحذف/الإضافة/تبديل حالة "منجز" تعمل بلا لمس innerHTML.
اللغز ج — أفسِد النظام عمداً، ثم أصلحه بالمفاتيح. أضف حقل <input> نصّي داخل كل عنصر مهمة (بدل النص الثابت — خلّه قابلاً للتعديل المباشر). اكتب نصاً مختلفاً في أول عنصرين. الآن أضف مهمة جديدة في أول القائمة (tasks = [newTask, ...tasks]) وأعد الرسم عبر حلّك من اللغز ب. راقب: أي نص ظهر في أي حقل؟ صف بجملة واحدة ما حدث ولماذا (ربطاً بما شرحناه في "أثر مباشر" أعلاه). ثم أصلحها: أضف key إلى props عند بناء كل <li> (القيمة = task.id)، وغيّر مطابقة الأبناء من بالموضع إلى بالمفتاح (Map<string, Node> يربط كل مفتاح بعقدة DOM حيّة، بدل الاعتماد على الفهرس). تحقّق: كرّر تجربة الإدراج في الأول — يجب أن تبقى كل قيمة في حقلها الصحيح.
لا تنتقل للإقليم التالي قبل أن ترى بعينك حقل الإدخال "يقفز" في اللغز ج بلا مفاتيح، ثم يستقرّ بعد إضافتها. هذه أهمّ لحظة في هذا الإقليم كله.
كيف: من الشجرة اليدوية إلى JSX (نفس الشيء، بصياغة أخرى)
كل ما بنيتَه بـh(...) له صياغة مكافئة تماماً تشبه HTML، تُترجَم وقت البناء (لا وقت التشغيل) إلى نفس نداءات الدوال:
// هذا:
const view = h("ul", { className: "tasks" },
h("li", { key: task.id }, task.text),
);
// يكافئ تماماً هذا (JSX):
const view = (
<ul className="tasks">
<li key={task.id}>{task.text}</li>
</ul>
);
المترجم (Babel أو esbuild في Vite) يحوّل كل وسم JSX إلى نداء دالّة تبني نفس الشجرة التي بنيتَها يدوياً بـh. لا يوجد سحر: JSX سكّرٌ صياغي فقط، يُمحى تماماً وقت الترجمة (تماماً كما تُمحى أنواع TypeScript وقت الترجمة — إن كنت تعرف تلك الفكرة من قبل، فهذه نسخة منها في عالم الصياغة لا الأنواع). حين تكتب <div className="x"> في React الحقيقي، ينتج نداءً لدالّةٍ اسمها jsx (أو React.createElement في الصياغة الأقدم) تبني كائناً يشبه VNode الذي بنيتَه بيدك تماماً في البنية، لكن بحقول إضافية داخلية.
الفرق بين توينا وReact الحقيقي: ما بنيتَه في هذا الإقليم *يعمل* ويثبت الفكرة، لكن React الحقيقي لا يعيد المقارنة من جذر الشجرة في كل مرة بالضبط بهذه السذاجة — له بنية داخلية موازية اسمها fiber، مُحسَّنة لتُقسّم العمل وتُلغيه وتُجدوِله. هذا موضوع الإقليم القادم، حين نحتاج تذكّراً يتجاوز مجرّد مقارنة شجرتين.
الخلاصة — أين تتّصل هذه العقدة بالشجرة
- ما بنيتَه: مسارٌ كامل من الوصف التصريحي (
VNode) إلى DOM حقيقي (createDom)، عبر مطابِقٍ (patch) يقارن بدل أن يعيد البناء، بمفاتيح تحلّ مشكلة الهويّة في القوائم. - العقدة الجديدة: *المطابِق (reconciler)* — الفكرة التي تجعل "صف ماذا" ممكناً عملياً بلا تكلفة إعادة بناء كل شيء في كل مرة.
- ما زال ناقصاً عمداً: كل شيء هنا كان يُعاد رسمه بنداءٍ يدوي من الخارج (
mount(newView, root)أوpatch(...)تستدعيها أنت). لا يوجد بعد مفهوم "مكوّن يقرر بنفسه متى يعيد رسم نفسه". هذا يفتح بابين: 1. كيف نُقسّم شجرة الوصف الضخمة إلى قطعٍ مسمّاة قابلة لإعادة الاستخدام ولها مدخلاتها الخاصة؟ → الإقليم ١. 2. كيف "يتذكّر" جزءٌ من الشجرة بياناته الخاصة بين مرّة وأخرى، ويُطلق إعادة رسمٍ لنفسه بلا استدعاءٍ خارجي؟ → الإقليم ٢.
الوصلة بمشروعك
مشروع الفرونت عندكم (SelfLab) يُبنى بـVite + @vitejs/plugin-react، ومكتوبٌ بالكامل بصيغة JSX/TSX. كل ملف .tsx فيه — من Navbar.tsx إلى أبسط صفحة — يمرّ بالضبط بهذا التحويل: JSX ← نداءات دوال تبني شجرة بيانات ← مطابِقٌ (fiber الحقيقي، لا مطابِقنا الساذج) يقارنها بالشجرة السابقة ويُحدّث DOM الفعلي بأقل قدرٍ من اللمس. لهذا بالضبط كل عنصر قائمة في مشروعكم (بطاقات، صفوف) يحتاج key — ستراها حرفياً حين نصل لعرض قوائم البيانات لاحقاً.
البذرة التالية: عندنا مطابِقٌ يعمل، لكنه أعمى — لا يعرف شيئاً عن "مكوّنات" منفصلة، ولا عن "حالة" تخصّ كل واحد منها. الإقليم القادم يبدأ بتقسيم الشجرة الضخمة إلى ممثلين مستقلين، كل واحد له سيناريوه الخاص.