مبتدئ → تطبيقي · 10 دروس

JavaScript للمتصفح — تفاعل وواجهات

تعلّم كيف تجعل صفحتك تستجيب: أحداث ونصوص متغيّرة، حاسبة، تحقق من النماذج، جلب بيانات بـ fetch، وقائمة مهام تُحفظ في المتصفح — خطوة بخطوة بالعربية.

كيف تدرس هذا المسار؟

  1. افهم الفكرة ثم شغّل المعاينة إن وُجدت.
  2. عدّل الأرقام والنصوص في الكود لترى الفرق.
  3. ابنِ نسختك من المشروع النهائي.
  4. سجّل إكمال كل درس.

مسار مكتوب لمن يعرف أساسيات HTML وCSS ويريد صفحات تتفاعل مع المستخدم وأدوات صغيرة تعمل في المتصفح.

1. ماذا يفعل JavaScript في المتصفح؟

الهدف: تميّز بين HTML و CSS و JS، وتستخدم Console للتجربة وقراءة رسائل الخطأ.

تخيّل نور، طالبة في المنصورة، صمّمت صفحة HTML بسيطة لطلبات مخبوزات والدتها المنزلية، وأضافت إليها CSS فصارت أنيقة. لكن الصفحة صامتة: زر «اطلب الآن» لا يفعل شيئاً، وسعر الطلب لا يتغيّر حين تزيد الكمية. هذا تحديداً هو الفراغ الذي تملؤه JavaScript. لغة HTML تصف ما في الصفحة من عناوين وأزرار وحقول، وCSS تصف شكلها وألوانها، أما JavaScript فتصف السلوك: ماذا يحدث عند النقر، وكيف يُحسب المجموع، ومتى تظهر رسالة خطأ.

JavaScript لغة برمجة كاملة يشغّلها المتصفح نفسه، فلا تحتاج إلى تثبيت أي برنامج لتبدأ. داخل كل متصفح حديث محرّك (engine) يقرأ الكود وينفّذه: V8 في Chrome وEdge، وSpiderMonkey في Firefox، وJavaScriptCore في Safari. والاسم الرسمي للمواصفة التي تحدد قواعد اللغة هو ECMAScript، وتطوّرها لجنة TC39 التابعة لمنظمة Ecma International وتصدر منها نسخة جديدة كل عام، ولذلك تعمل الصيغ الحديثة التي ستتعلمها في هذا المسار في كل المتصفحات المحدّثة. وللتوضيح من البداية: JavaScript وJava لغتان مختلفتان في التصميم والاستخدام، والتشابه في الاسم تاريخي فقط.

ما الذي تستطيعه JavaScript في المتصفح؟ تقرأ عناصر الصفحة وتعدّلها، وتستمع لنقرات المستخدم وكتابته، وتجلب بيانات من الخادم دون إعادة تحميل، وتحفظ معلومات صغيرة في المتصفح. وما الذي لا تستطيعه؟ لا تقرأ ملفات جهاز الزائر كما تشاء، ولا تفتح برامج أخرى، لأن المتصفح يشغّلها داخل بيئة معزولة (sandbox) تحمي المستخدم. أي ملف تريد قراءته يجب أن يختاره الزائر بنفسه عبر حقل رفع ملفات.

أسرع مختبر متاح لك هو لوحة Console في أدوات المطوّر. افتح أي صفحة، واضغط F12 أو Ctrl+Shift+J في Chrome على Windows، ثم اكتب الأسطر التالية واحداً تلو الآخر مع Enter بعد كل سطر:

2 + 3 * 4
"مرحباً".length
document.title
document.title = "طلبات المخبز"
console.log("الكمية", 3)

النتائج بالترتيب: 14 لأن الضرب يسبق الجمع كما في الرياضيات، ثم 6 وهو عدد الرموز في الكلمة لأن التنوين يُحسب رمزاً مستقلاً، ثم عنوان الصفحة الحالي. السطر الرابع يغيّر عنوان التبويب فعلاً، والخامس يطبع الكمية 3. أعد تحميل الصفحة وسيعود العنوان الأصلي، لأنك عدّلت نسخة الصفحة في ذاكرة المتصفح ولم تلمس الملف على الخادم. هذه الملاحظة مهمة: كل ما تفعله JavaScript في الصفحة مؤقت ما لم تحفظه في مكان ما، وهو ما يعالجه الدرس العاشر.

في ملفك يُكتب الكود داخل عنصر script، ويُفضّل في البداية وضعه قبيل إغلاق body مباشرة. السبب أن المتصفح يقرأ الصفحة من أعلى إلى أسفل وينفّذ السكربت لحظة الوصول إليه، فإن جاء قبل العناصر التي يبحث عنها لن يجدها:

<p id="status">جارٍ التحميل...</p>
<script>
  document.getElementById("status").textContent = "الصفحة جاهزة";
  console.log("السكربت عمل");
</script>

وهذا سجل تتبّع خطأ حقيقي: نقلت نور السكربت إلى head، فوجدت «جارٍ التحميل...» لم تتغيّر. فتحت Console فرأت رسالة حمراء نصها Uncaught TypeError: Cannot set properties of null (setting 'textContent'). قراءة الرسالة جزءاً جزءاً تكشف السبب: TypeError نوع الخطأ، وnull هي القيمة التي حاول الكود تعديلها، وtextContent الخاصية المقصودة. إذن لم تجد getElementById عنصراً بالمعرّف status فأعادت null. وبجانب الرسالة اسم الملف ورقم السطر، والنقر عليهما ينقلك إلى السطر المسؤول.

أمامك هنا احتمالان فقط: المعرّف مكتوب بشكل مختلف، والأحرف الكبيرة والصغيرة مختلفة فـ Status غير status، أو السكربت يعمل قبل إنشاء العنصر. في حالة نور كان الاحتمال الثاني، والحل إعادة السكربت إلى نهاية body أو استخدام السمة defer التي يشرحها الدرس التاسع.

عادة تستحق أن تبدأ بها من اليوم: اترك Console مفتوحة أثناء العمل دائماً. يقضي كثير من المبتدئين وقتاً طويلاً في تخمين سبب «عدم حدوث شيء»، والمتصفح قد كتب السبب ورقم السطر بوضوح في مكان لم ينظروا إليه.

  • HTML للهيكل وCSS للشكل وJavaScript للسلوك والتفاعل.
  • المحرّك المدمج في المتصفح ينفّذ الكود دون تثبيت أي برنامج.
  • Console مختبر فوري، ورسائلها تذكر نوع الخطأ والملف ورقم السطر.
  • السكربت الذي يعمل قبل إنشاء العنصر يجد null بدل العنصر.

مختبر — أول سطر JS

افتح أدوات المطوّر (F12) → Console ولاحظ رسالة log.

مهمة عملية

غيّر النص المعروض إلى جملة تعريف بك، ثم اكتب المعرّف msg خطأً عمداً، وانسخ رسالة الخطأ من Console مع رقم السطر، ثم أصلحه.

اختبر فهمك (1/3)

أين يُكتب كود JavaScript داخل صفحة HTML بسيطة؟

اختبر فهمك (2/3)

ظهرت في Console رسالة Cannot set properties of null (setting 'textContent'). ما السبب الأرجح؟

اختبر فهمك (3/3)

أي محرّك JavaScript يستخدمه متصفح Firefox؟

للاستزادة: MDN — What is JavaScript? · Chrome DevTools — Console · javascript.info — An Introduction to JavaScript

2. المتغيرات — let و const

الهدف: تخزّن قيماً بأسماء واضحة، وتختار بين const وlet، وتعرف أنواع القيم الأساسية.

المتغير (variable) اسم تربطه بقيمة لتستعملها لاحقاً بدل تكرارها. في JavaScript ثلاث كلمات لإنشاء المتغير، لكن قاعدة الاختيار بينها تُحفظ في سطرين: ابدأ دائماً بـ const، وانتقل إلى let فقط حين تعرف أن الاسم سيُربط بقيمة جديدة لاحقاً، كعدّاد يزيد أو مجموع يتراكم. أما var فاتركها لقراءة الكود القديم، وسترى بعد قليل لماذا.

قبل الكود تعرّف على أنواع القيم الأساسية (primitive types): الرقم number مثل 120 أو 2.5، والنص string بين علامتي تنصيص مفردتين أو مزدوجتين، والقيمة المنطقية boolean وهي true أو false، وundefined وتعني أن المتغير لم يُسند إليه شيء، وnull وتعني «لا قيمة» بقرار من المبرمج. وإلى جانبها الكائنات (objects) ومنها المصفوفات. المعامل typeof يعيد اسم النوع نصاً، مع استثناءين يربكان المبتدئين: typeof null يعيد "object" بسبب خطأ قديم في اللغة بقي للتوافق، والمصفوفة أيضاً نوعها "object"، فإن أردت التأكد أن القيمة مصفوفة فاستخدم Array.isArray.

هذا مقطع من سلة مشتريات متجر إلكتروني صغير لبيع العطور في عمّان. شغّله في Console وراقب الناتج:

const shopName = "عطور الياسمين";
const prices = [12.5, 30, 7];
let total = 0;
total = total + prices[0];
total = total + prices[1];
prices.push(4);
console.log(shopName, total, prices.length);
console.log(typeof total, typeof shopName, Array.isArray(prices));

السطر الأول من الناتج عطور الياسمين 42.5 4، والثاني number string true. العنصر الأول في المصفوفة رقمه 0 وليس 1، ولهذا كتبنا prices[0] للسعر الأول. ولاحظ أن push نجحت مع أن prices معرّفة بـ const: هذه الكلمة تمنع إعادة الإسناد، أي ربط الاسم prices بمصفوفة أخرى، لكنها لا تجمّد محتوى المصفوفة نفسها. لو كتبت prices = [] لتوقف التنفيذ برسالة TypeError: Assignment to constant variable، أما إضافة عنصر أو تعديله فمسموحة.

ستصادف في مقالات قديمة كوداً كهذا، وهو مثال جيد لإعادة الكتابة:

var count = 5;
if (true) {
  var count = 10;
}
console.log(count);

يطبع 10، لأن var لا تعرف حدود الكتلة المحصورة بين القوسين المعقوفين؛ نطاقها الدالة كلها، فالسطر الداخلي أعاد تعريف المتغير الخارجي نفسه بصمت. استبدل var بـ let في السطرين، وسيطبع الكود 5، لأن count الداخلية تصبح متغيراً مستقلاً يعيش داخل الكتلة فقط ويختفي بعدها. هذا السلوك يسمّى نطاق الكتلة (block scope)، وهو أهم سبب لاعتماد let وconst.

فرق ثانٍ يظهر حين تستخدم المتغير قبل سطر تعريفه. مع var تحصل على undefined بصمت، فيستمر البرنامج بقيمة خاطئة. ومع let وconst يتوقف التنفيذ برسالة ReferenceError: Cannot access 'total' before initialization. قد تبدو الرسالة مزعجة، لكنها أرحم بكثير من قيمة صامتة خاطئة تكتشفها بعد أيام في تقرير المبيعات.

وحين تكون لديك قيم مترابطة تصف شيئاً واحداً، فاجمعها في كائن بدل متغيرات متفرقة: const product = { name: "عود", price: 250 }; ثم اقرأ أي خاصية بالنقطة مثل product.price. هذا الشكل سيرافقك في كل الدروس القادمة، من بطاقات المنتجات إلى قائمة المهام في المشروع الختامي.

ولبناء جملة من عدة متغيرات استخدم القالب النصي (template literal): نص يُحاط بعلامة الإمالة العكسية (backtick) بدل علامات التنصيص، وتضع داخله أي متغير أو تعبير بين ${ و}، فيُستبدل بقيمته. ستجد مثالاً كاملاً عليه في المختبر أسفل الدرس.

وأخيراً التسمية: الاسم لا يبدأ برقم ولا يحتوي مسافات، والأحرف الكبيرة والصغيرة مختلفة فـ total غير Total. الاصطلاح الشائع في JavaScript هو camelCase مثل finalPrice وitemsCount. اختر اسماً يصف المحتوى ووحدته إن لزم، فـ priceWithVat أو delayMs أوضح بكثير من x أو temp، والوقت الذي توفره حين تقرأ الكود بعد شهر يستحق الحروف الإضافية.

  • const افتراضياً، وlet فقط حين سيُعاد الإسناد.
  • const تمنع إعادة الربط ولا تجمّد محتوى المصفوفة أو الكائن.
  • let وconst محصورتان في الكتلة، وvar تتسرب إلى الدالة كلها.
  • typeof null وtypeof [] كلاهما "object"؛ استخدم Array.isArray للمصفوفات.

الكود باختصار

const name = "سارة"
let score = 0
score = score + 10
// name ثابت · score يتغيّر

مختبر — حساب سعر

غيّر discount ولاحظ finalPrice، ثم جرّب إعادة إسناد product واقرأ الخطأ في Console.

مهمة عملية

عرّف متغيرات لمنتج: اسمه، وسعره، وهل هو متوفر، ومصفوفة من ثلاث كلمات وصفية. اطبع typeof لكل منها بعد أن تكتب توقعك، ثم اطبع جملة بقالب نصي: «المنتج X بسعر Y».

اختبر فهمك (1/3)

أي كلمة تُنشئ متغيراً لا يمكن إعادة إسناده؟

اختبر فهمك (2/3)

ما ناتج: var count = 5; if (true) { var count = 10; } console.log(count);

اختبر فهمك (3/3)

const prices = [1, 2]; ثم prices.push(3); لماذا لا يظهر خطأ؟

للاستزادة: MDN — const · MDN — let · javascript.info — Variables

3. الشروط — if و else

الهدف: تحوّل قواعد العمل إلى شروط مرتبة، وتختبرها عند الحدود، وتميّز بين === و== وبين || و??.

مخبز صغير في الإسكندرية يستقبل الطلبات عبر صفحته، ويريد صاحبه قواعد توصيل واضحة: الطلب الذي يبلغ 500 جنيه فأكثر توصيله مجاني، ومن 200 إلى أقل من 500 رسومه 20 جنيهاً، وما دون 200 رسومه 35 جنيهاً، أما الاستلام من المحل فبلا رسوم في كل الأحوال. قبل كتابة أي سطر، حوّل الكلام إلى جدول قرار: عمودان للمدخلات هما طريقة الاستلام والمبلغ، وعمود للنتيجة هو الرسوم. صف الاستلام من المحل يُحسم أولاً لأنه يتجاهل المبلغ، ثم تأتي صفوف المبلغ من الأعلى إلى الأدنى.

الشرط في JavaScript يبدأ بـ if ثم تعبير بين قوسين ثم كتلة بين قوسين معقوفين، ويمكن إضافة else if لحالات أخرى وelse لما تبقّى. الفحص يجري بالترتيب من الأعلى، وأول شرط صحيح تُنفَّذ كتلته ويُتجاهل كل ما بعده. هكذا يصبح الجدول كوداً:

const total = 499;
const pickup = false;
let fee;
if (pickup) {
  fee = 0;
} else if (total >= 500) {
  fee = 0;
} else if (total >= 200) {
  fee = 20;
} else {
  fee = 35;
}
console.log(fee);

يطبع 20. غيّر total إلى 500 فيطبع 0، وإلى 120 فيطبع 35، واجعل pickup تساوي true فيطبع 0 مهما كان المبلغ، لأن هذا الشرط يُفحص أولاً. والآن جرّب تبديل الشرطين الأوسطين بحيث يأتي total >= 200 أولاً: سيدفع صاحب طلب بـ 650 جنيهاً رسوم 20، لأن 650 أكبر من 200 فيتوقف الفحص هناك ولا يصل إلى شرط الـ 500. حين تتداخل الحدود رتّبها من الأضيق إلى الأوسع.

معاملات المقارنة هي > و< و>= و<=، و=== للمساواة، و!== لعدم المساواة. استخدم دائماً الصيغة الثلاثية لأنها تقارن القيمة والنوع معاً. الصيغة الثنائية == تحوّل الأنواع قبل المقارنة بقواعد يصعب تذكّرها، فتجعل "0" == 0 صحيحة، و"" == 0 صحيحة، وnull == undefined صحيحة، بينما كلها خاطئة مع ===. ولدمج الشروط: && تعني «و» فيجب أن يتحقق الطرفان، و|| تعني «أو» فيكفي أحدهما، و! تعكس القيمة المنطقية.

حين تضع داخل if قيمة ليست true أو false، تحوّلها اللغة إلى قيمة منطقية. القيم التي تُعامل كخاطئة (falsy) قليلة وأهمها: false و0 والنص الفارغ وnull وundefined وNaN. وكل ما عداها يُعامل كصحيح (truthy)، بما في ذلك النص "0" والنص الذي فيه مسافة واحدة والمصفوفة الفارغة. لذلك يكفي if (name) لفحص أن حقل الاسم غير فارغ، لكن احذر هذا الاختصار حين يكون الصفر قيمة مشروعة في برنامجك.

هنا يظهر فرق عملي بين || والمعامل الأحدث ?? المسمّى دمج القيم الفارغة (nullish coalescing). كلاهما يعطي قيمة بديلة، لكن || يستبدل أي قيمة falsy، بينما ?? لا يستبدل إلا null وundefined:

const savedDiscount = 0;
console.log(savedDiscount || 10);
console.log(savedDiscount ?? 10);

الأول يطبع 10 فيضيع الخصم الصفري الذي اختاره المستخدم عمداً، والثاني يطبع 0 كما يجب. استخدم ?? حين يكون الصفر أو النص الفارغ قيمة صحيحة لا تريد استبدالها.

وللاختيار بين قيمتين في سطر واحد يوجد المعامل الشرطي الثلاثي: const label = fee === 0 ? "توصيل مجاني" : "الرسوم " + fee; وهو مناسب للحالات القصيرة فقط، فالمعاملات الثلاثية المتداخلة تصعب قراءتها وتستحق أن تعود if عادية.

قائمة اختبار قبل اعتماد أي شرط فيه أرقام: جرّب القيمة على الحد نفسه مثل 500 و200، والقيمة التي تسبقه مباشرة مثل 499.99 و199، وقيمة كبيرة جداً، والصفر، ثم قيمة غير متوقعة كنص فارغ. معظم أخطاء الشروط تختبئ عند الحدود، وعبارة «من 200 إلى 500» في كلام صاحب العمل تحتاج سؤالاً صريحاً قبل البرمجة: هل الـ 500 داخلة في الشريحة أم لا؟

  • حوّل القاعدة إلى جدول قرار قبل الكتابة، ورتّب الحدود من الأضيق.
  • أول شرط صحيح يُنفَّذ ويُتجاهل ما بعده.
  • استخدم === دائماً؛ == تحوّل الأنواع بقواعد مربكة.
  • ?? تستبدل null وundefined فقط، و|| تستبدل كل قيمة falsy.
  • اختبر كل حد بالقيمة نفسها وبالقيمة التي تسبقها.

الكود باختصار

if (pickup)        → 0
else if (≥ 500)    → 0
else if (≥ 200)    → 20
else               → 35

مختبر — شحن مجاني

جرّب orderTotal = 499 و500 و501 ولاحظ نقطة التحوّل بالضبط.

مهمة عملية

أضف else if: إن كان الإجمالي 300 فأكثر فالشحن 15 بدل 25. ثم اختبر القيم 299 و300 و499 و500 واكتب الناتج المتوقع لكل قيمة قبل التشغيل.

اختبر فهمك (1/3)

ماذا يعني === في JavaScript؟

اختبر فهمك (2/3)

const discount = 0; ما ناتج console.log(discount ?? 10)؟

اختبر فهمك (3/3)

أول شرط في الكود if (total >= 200) وبعده else if (total >= 500). ماذا يحدث لطلب قيمته 650؟

للاستزادة: MDN — if...else · MDN — Falsy · MDN — Nullish coalescing (??)

4. الدوال — function

الهدف: تحوّل الكود المكرر إلى دوال بمعاملات وقيم افتراضية، وتختار بين صيغها، وتختبرها.

مصمم مستقل في الرياض يكتب سكربتاً صغيراً يحسب فواتيره مع ضريبة بنسبة 15%. النسخة الأولى كانت هكذا:

const logo = 800 + 800 * 0.15;
const website = 3500 + 3500 * 0.15;
const banner = 250 + 250 * 0.15;
console.log(logo, website, banner);

الناتج 920 4025 287.5، والكود يعمل، لكن المعادلة مكررة ثلاث مرات. لو تغيّرت النسبة أو أراد إضافة خصم، فسيعدّل ثلاثة أسطر ويغفل غالباً عن واحد منها. الدالة (function) تحل هذه المشكلة: خطوات لها اسم، تعرّفها مرة واحدة وتستدعيها بقيم مختلفة، فيصبح إصلاح الخطأ في مكان واحد إصلاحاً لكل الاستخدامات. بعد إعادة الكتابة:

function withVat(amount, rate = 0.15) {
  return amount + amount * rate;
}
console.log(withVat(800), withVat(3500), withVat(250));
console.log(withVat(1000, 0.05));

السطر الأول يطبع النتائج الثلاث نفسها، والثاني يطبع 1050. المعاملات (parameters) هي الأسماء التي تكتبها في التعريف، هنا amount وrate، والوسائط (arguments) هي القيم التي تمررها عند الاستدعاء مثل 800. القيمة الافتراضية rate = 0.15 تُستخدم حين لا تمرر الوسيط الثاني، وبدقة أكبر حين تكون قيمته undefined؛ فلو مررت null صراحة فلن تُستخدم. أما return فتعيد النتيجة إلى مكان الاستدعاء وتنهي تنفيذ الدالة فوراً، فأي سطر بعدها داخل الكتلة نفسها لا يعمل.

في JavaScript ثلاث صيغ شائعة لكتابة الدالة، والفروق بينها عملية. التصريح (function declaration) كما في withVat يمكن استدعاؤه حتى قبل سطر تعريفه في الملف، لأن المحرك يجهّزه مسبقاً. والتعبير الدالّي المحفوظ في const، مثل const half = function (n) { return n / 2; }; لا يُستدعى قبل سطر تعريفه وإلا ظهر ReferenceError. ودالة السهم (arrow function) صيغة مختصرة مثل const half = (n) => n / 2; وحين يكون جسمها تعبيراً واحداً بلا أقواس معقوفة تُعاد قيمته تلقائياً دون كتابة return. لدوال السهم فرق آخر يتعلق بالكلمة this ستقابله لاحقاً، ويكفيك الآن أنها مناسبة جداً للدوال القصيرة التي تُمرَّر إلى دوال أخرى مثل forEach وfilter.

وإن احتجت دالة تقبل عدداً غير محدد من الوسائط، كجمع كل أسعار السلة، فاستخدم معامل البقية (rest parameter) الذي يجمع الوسائط في مصفوفة: function sum(...nums) { return nums.reduce((a, b) => a + b, 0); } ثم sum(10, 20, 5) تعيد 35، وsum() تعيد 0 بفضل القيمة الابتدائية.

المتغيرات المعرّفة داخل الدالة لا تُرى خارجها، وهذا يسمّى النطاق المحلي. لو عرّفت داخل withVat متغيراً اسمه tax فلن يتصادم مع متغير بالاسم نفسه في مكان آخر من البرنامج. في المقابل تستطيع الدالة قراءة المتغيرات المعرّفة خارجها، لكن الأفضل أن تعتمد على مدخلاتها فقط. الدالة التي لا تقرأ شيئاً من خارجها ولا تغيّر شيئاً، ونتيجتها تتحدد بمدخلاتها وحدها، تسمّى دالة نقية (pure function)، وهي أسهل ما يمكن اختباره. لهذا يُنصح بفصل الحساب عن عرضه في الصفحة: دالة تحسب، وسطر آخر يضع النتيجة في العنصر.

حين ترى undefined في مكان تنتظر فيه رقماً، فابحث أولاً عن return مفقودة. هذه الدالة مثلاً تحسب ثم تتخلى عن النتيجة:

const double = (n) => { n * 2; };
console.log(double(5));

تطبع undefined، لأن الأقواس المعقوفة حوّلت الجسم إلى كتلة عادية تحتاج return صريحة. الإصلاح إما بحذف الأقواس لتصبح (n) => n * 2، أو بإضافة return داخلها. والخطأ نفسه يحدث في دالة عادية تنسى فيها return.

قبل استخدام أي دالة حسابية في صفحة حقيقية، اختبرها بأسطر console.assert. هذه الدالة لا تطبع شيئاً إن كان الشرط صحيحاً، وتطبع رسالة Assertion failed إن لم يكن. اكتب اختباراً لقيمة عادية مثل console.assert(withVat(800) === 920)، وآخر للصفر، وثالثاً لنسبة مختلفة، فتتأكد في ثوانٍ أن أي تعديل لاحق لم يكسر الحساب. هذه العادة البسيطة هي البذرة الأولى لما يسمّى الاختبارات الآلية في المشاريع الكبيرة.

  • الدالة تجمع منطقاً مكرراً في مكان واحد يُستدعى بقيم مختلفة.
  • القيمة الافتراضية تُستخدم حين يكون الوسيط undefined فقط.
  • دالة السهم ذات التعبير الواحد تعيد قيمته تلقائياً، ومع الأقواس المعقوفة تحتاج return.
  • undefined غير المتوقعة تعني غالباً return مفقودة.

الهيكل

withVat(800)
      │
      ▼
[ amount + amount × 0.15 ]
      │
      ▼
     920

مختبر — دوال الأسعار

اكتب دالة applyDiscount(price, percent) واستخدمها قبل formatPrice، وأضف console.assert لقيمتين.

مهمة عملية

اكتب دالة calcVAT(amount, rate = 0.15) تعيد مقدار الضريبة فقط، ودالة isFreeShipping(total) تعيد true إن كان المجموع 500 أو أكثر. أضف ثلاثة أسطر console.assert لكل دالة، منها القيمة 500 تماماً، وتأكد أن Console صامتة.

اختبر فهمك (1/3)

ماذا تفعل return داخل دالة؟

اختبر فهمك (2/3)

const double = (n) => { n * 2; }; ما ناتج double(5)؟

اختبر فهمك (3/3)

function withVat(amount, rate = 0.15) {...}. متى تُستخدم القيمة الافتراضية 0.15؟

للاستزادة: MDN — Functions guide · MDN — Arrow functions · javascript.info — Functions

5. الأحداث — النقر على زر

الهدف: تشغّل كوداً عندما يتفاعل المستخدم، وتقرأ كائن الحدث، وتستخدم الانتشار والتفويض.

الحدث (event) إشارة يطلقها المتصفح حين يقع شيء: نقرة، أو كتابة في حقل، أو ضغط مفتاح، أو إرسال نموذج، أو اكتمال بناء الصفحة. طريقة العمل تشبه ترك رقم هاتفك لدى موظف الاستقبال: تسجّل مسبقاً لدى العنصر دالة تُسمّى المعالِج (handler)، فيستدعيها المتصفح كلما وقع الحدث. الأداة القياسية لذلك هي addEventListener، وتأخذ اسم الحدث والدالة، ويمكن تسجيل أكثر من معالج للحدث نفسه على العنصر نفسه دون أن يلغي أحدهما الآخر.

الأحداث الأكثر استخداماً: click للنقر، ويقع أيضاً حين يُفعَّل الزر من لوحة المفاتيح، وinput مع كل تغيير في محتوى الحقل، وchange حين تُثبَّت قيمة جديدة كاختيار من قائمة select، وkeydown عند ضغط مفتاح، وsubmit عند إرسال نموذج، وDOMContentLoaded حين يكتمل بناء الصفحة.

لنبنِ خطوة بخطوة قائمة تنقّل تُفتح وتُغلق بزر في موقع مكتبة صغيرة:

<button id="menuBtn" aria-expanded="false" aria-controls="menu">القائمة</button>
<ul id="menu" hidden>
  <li><a href="#books">الكتب</a></li>
  <li><a href="#offers">العروض</a></li>
</ul>
<script>
  const menuBtn = document.getElementById("menuBtn");
  const menu = document.getElementById("menu");
  menuBtn.addEventListener("click", () => {
    const willOpen = menu.hidden;
    menu.hidden = !willOpen;
    menuBtn.setAttribute("aria-expanded", String(willOpen));
  });
</script>

الخطوة الأولى اختيار العنصرين مرة واحدة خارج المعالج. الثانية تسجيل معالج click على الزر. الثالثة داخل المعالج: نقرأ الحالة الحالية من الخاصية hidden، فإن كانت القائمة مخفية فالنقرة ستفتحها والعكس، ثم نحدّث aria-expanded ليعرف مستخدم قارئ الشاشة أن القائمة صارت مفتوحة أو مغلقة. استخدمنا عنصر button حقيقياً، فيصل إليه مفتاح Tab ويعمل بمفتاحي Enter والمسافة تلقائياً، وهذه أمور يجب برمجتها يدوياً لو استخدمت div قابلاً للنقر.

كل معالج يستلم من المتصفح كائن الحدث، ويُسمّى عادة e أو event. أهم خصائصه: e.target وهو العنصر الذي وقع عليه الحدث فعلاً، وe.currentTarget وهو العنصر الذي سجّلت عليه المعالج، وe.key اسم المفتاح في أحداث لوحة المفاتيح مثل "Enter" أو "Escape". ومنه الدالة e.preventDefault() التي تلغي السلوك الافتراضي، كمنع الرابط من الانتقال أو النموذج من الإرسال.

مفهوم يوفر عليك كثيراً من الكود هو الانتشار للأعلى (bubbling): الحدث الذي يقع على عنصر يصعد بعد ذلك إلى العنصر الأب ثم الجد حتى document. النقر على رابط داخل li داخل ul يصل إلى معالج مسجّل على ul أيضاً. هذا يسمح بنمط التفويض (event delegation): معالج واحد على الحاوية بدل معالج لكل عنصر، ويعمل حتى مع العناصر التي تُضاف إلى القائمة لاحقاً:

menu.addEventListener("click", (e) => {
  const link = e.target.closest("a");
  if (!link) return;
  console.log("اختار المستخدم:", link.textContent);
});

الدالة closest تبدأ من العنصر المنقور وتصعد بحثاً عن أقرب عنصر يطابق المحدِّد، فتنجح حتى لو نقر المستخدم على أيقونة داخل الرابط. وإن نقر على مسافة فارغة داخل القائمة تعيد null فيتوقف المعالج عند السطر الثاني. بالنقر على «الكتب» يطبع Console: اختار المستخدم: الكتب.

ثلاث مشكلات تتكرر في أسئلة المبتدئين، ولكل منها عَرَض يدل عليها:
• أقواس زائدة: كتابة sayHello() بدل sayHello عند التسجيل تستدعي الدالة فوراً وتسجّل ما أعادته، وهو غالباً undefined. العَرَض رسالة تظهر مرة عند فتح الصفحة ثم زر صامت.
• معالج يُسجَّل داخل معالج آخر: كل نقرة تضيف معالجاً جديداً، فتتضاعف الاستجابة مع كل نقرة. سجّل المعالجات مرة واحدة عند التحميل.
• حدث غير مناسب: keydown لا يلتقط اللصق بالفأرة ولا الإدخال الصوتي، فاستخدم input لمتابعة محتوى الحقل.

ولمعالج يجب أن يعمل مرة واحدة فقط، كرسالة ترحيب، مرّر خياراً ثالثاً بهذا الشكل: addEventListener("click", handler, { once: true })، فيزيله المتصفح تلقائياً بعد أول تنفيذ دون أن تكتب كود الإزالة بنفسك.

  • addEventListener يسجّل دالة يستدعيها المتصفح عند وقوع الحدث.
  • مرّر الدالة دون أقواس، وإلا نُفّذت فوراً.
  • e.target هو العنصر المنقور فعلاً، وclosest يصعد منه إلى العنصر المطلوب.
  • التفويض: معالج واحد على الحاوية يخدم كل العناصر الحالية والمستقبلية.

الهيكل

[ زر ]
  click
    │
    ▼
[ handler(e) ]
    │  e.target
    ▼
تغيير في الصفحة

مختبر — عدّاد نقرات

لاحظ: نمرّر الدالة بدون أقواس () — وإلا تُنفَّذ فوراً.

معاينة مباشرة

مهمة عملية

أضف زر «إعادة» يصفّر العداد إلى 0، وحقلاً نصياً مع زر «حفظ» معطّل يتفعّل فقط حين يتجاوز النص 10 حروف عبر معالج input.

اختبر فهمك (1/3)

أي حدث يُطلق عند نقر المستخدم على زر button؟

اختبر فهمك (2/3)

كتبت btn.addEventListener("click", sayHello()) فظهرت الرسالة مرة عند الفتح ثم صمت الزر. لماذا؟

اختبر فهمك (3/3)

قائمة فيها 200 عنصر li تُضاف وتُحذف باستمرار. ما الأسلوب الأنسب لمعالجة النقر عليها؟

للاستزادة: MDN — addEventListener · MDN — Event bubbling · javascript.info — Event delegation

6. تغيير النص — DOM

الهدف: تقرأ محتوى العناصر وتعدّله وتنشئ عناصر جديدة، وتختار بين textContent وinnerHTML بأمان.

حين يقرأ المتصفح ملف HTML يبني منه في الذاكرة شجرة من الكائنات تُسمّى نموذج كائنات المستند (DOM). كل عنصر عقدة (node) في هذه الشجرة، وللعقدة أب وأبناء وإخوة. تعديلات JavaScript تقع على هذه الشجرة في الذاكرة، ويبقى ملف HTML على الخادم كما هو، والمتصفح يعيد رسم ما تغيّر على الشاشة فوراً. لذلك إن فتحت «عرض مصدر الصفحة» لن ترى تعديلاتك، بينما تراها في تبويب Elements في أدوات المطوّر لأنه يعرض الشجرة الحية كما هي الآن.

للوصول إلى عنصر لديك طريقتان رئيسيتان. getElementById تعيد عنصراً واحداً بمعرّفه أو null. وquerySelector تقبل أي محدِّد CSS مثل "#cart .total" وتعيد أول عنصر مطابق، وأختها querySelectorAll تعيد قائمة ثابتة (NodeList) بكل المطابقات يمكن المرور عليها بـ forEach. انتبه إلى أن القائمة ليست عنصراً: كتابة document.querySelectorAll(".price").textContent = "0" لا تغيّر شيئاً ولا تُظهر خطأ، لأنها تنشئ خاصية جديدة على القائمة نفسها. مرّ على العناصر واحداً واحداً.

ثلاث خصائص تبدو متشابهة لقراءة المحتوى وتعديله، والاختيار بينها قرار أمني وأدائي معاً. textContent تتعامل مع النص الخام: تقرأ نص كل العناصر الداخلية حتى المخفية منها، وحين تكتب فيها يظهر ما كتبته حرفياً حتى لو احتوى رموز وسوم. innerText تشبهها لكنها تراعي CSS، فلا تعيد النص المخفي وتحترم فواصل الأسطر الظاهرة، وقراءتها أبطأ لأنها تجبر المتصفح على حساب التخطيط. أما innerHTML فتفسّر النص على أنه HTML وتبني منه عناصر. القاعدة العملية: textContent للكتابة افتراضياً، وinnerHTML فقط لقالب ثابت كتبته أنت ولا يحتوي أي مدخلات من المستخدم.

لماذا هذا التشدد؟ تخيّل صفحة تعليقات تعرض اسم الزائر بهذا السطر:

greeting.innerHTML = "مرحباً " + nameInput.value;

لو كتب أحدهم في حقل الاسم وسم صورة بمصدر غير موجود ومعه سمة onerror تحتوي كوداً، فسيبني المتصفح الصورة ويفشل تحميلها فينفّذ الكود داخل صفحتك وبصلاحياتها، فيقرأ مثلاً ما يكتبه الزوار الآخرون. هذا هو حقن السكربتات عبر المواقع (XSS)، وهو من أشهر ثغرات الويب التي توثّقها OWASP. والمضلّل أن وسم script نفسه المُدرج عبر innerHTML لا يُنفَّذ، فيظن المبتدئ أنه في أمان بعد تجربة سريعة. الإصلاح سطر واحد:

greeting.textContent = "مرحباً " + nameInput.value;

الآن يظهر ما كتبه الزائر نصاً عادياً بكل رموزه، ولا يُبنى منه أي عنصر.

وحين تحتاج بناء عناصر جديدة من بيانات، كبطاقات منتجات متجر يبيع زيت الزيتون في نابلس، فاستخدم createElement بدل تجميع نص HTML طويل. لاحظ أن كل قيمة قادمة من البيانات تمر عبر textContent:

const product = { name: "زيت زيتون بكر", price: 180 };
const card = document.createElement("article");
card.className = "card";
const title = document.createElement("h3");
title.textContent = product.name;
const price = document.createElement("p");
price.textContent = product.price + " شيكل";
card.append(title, price);
document.getElementById("products").append(card);

النتيجة بطاقة فيها عنوان «زيت زيتون بكر» وسطر «180 شيكل» داخل العنصر products. الدالة append تقبل أكثر من عقدة دفعة واحدة وتقبل النصوص أيضاً، وللحذف يكفي card.remove(). ولو كانت لديك مصفوفة من عشرين منتجاً فضع هذه الأسطر داخل دالة واستدعها في forEach، وهو ما ستفعله في المشروع الختامي.

لتغيير المظهر بدّل الأصناف ولا تكتب الألوان من JavaScript. الخاصية classList توفر add وremove وcontains وtoggle، والأخيرة تعيد true إن أصبح الصنف موجوداً بعد التبديل، فيمكنك استخدام نتيجتها مباشرة لتحديث نص زر أو سمة aria-pressed، كما في زر الوضع الليلي: const isDark = document.body.classList.toggle("dark"); بهذا تبقى قرارات التصميم كلها في ملف CSS، ويقتصر دور JavaScript على إعلان أن العنصر صار في حالة معيّنة.

  • DOM شجرة حية في الذاكرة، وتعديلها يظهر فوراً دون لمس الملف.
  • querySelectorAll تعيد قائمة؛ مرّ على عناصرها بـ forEach.
  • textContent لأي نص من المستخدم، وinnerHTML لقوالب ثابتة كتبتها أنت فقط.
  • createElement مع append يبني عناصر من البيانات بأمان.
  • غيّر الشكل بتبديل الأصناف عبر classList لا بكتابة الألوان.

الهيكل

JS ──▶ querySelector
         │
         ▼
      [ <p> ]
         │
    textContent = "..."

مختبر — تحديث عنوان

جرّب classList.toggle بدل add، وراقب العنصر في تبويب Elements أثناء النقر.

معاينة مباشرة

مهمة عملية

ابنِ قسم أسئلة شائعة من ثلاثة أزرار بالصنف faq-q تليها فقرات مخفية. استخدم querySelectorAll وforEach لتبديل الصنف hidden على الفقرة التالية عبر nextElementSibling، وحدّث aria-expanded على الزر.

اختبر فهمك (1/3)

أي خاصية تضع النص في العنصر حرفياً دون تفسيره كـ HTML؟

اختبر فهمك (2/3)

greeting.innerHTML = "مرحباً " + nameInput.value. ما الخطر الحقيقي هنا؟

اختبر فهمك (3/3)

كتبت document.querySelectorAll(".price").textContent = "0" ولم يتغيّر شيء. لماذا؟

للاستزادة: MDN — Node.textContent · MDN — querySelector · OWASP — XSS Prevention Cheat Sheet · MDN — classList

7. مشروع مصغّر — حاسبة

الهدف: تجمع حقول الإدخال والدوال والأحداث في أداة صغيرة، وتعالج تحويل الأرقام والحالات الحدّية.

هذا الدرس سجل بناء حاسبة صغيرة لمحل أدوات منزلية يريد صاحبه أن يحسب لزبائنه الأسعار والكميات بسرعة. الواجهة بسيطة: حقلان من نوع number، وقائمة select للعمليات الأربع، وزر «احسب»، وفقرة للنتيجة. الأخطاء التي ظهرت أثناء البناء هي نفسها التي يقع فيها معظم المبتدئين، لذلك نعرضها بالترتيب الذي ظهرت به، مع طريقة اكتشاف كل منها وإصلاحه.

البداية الصحيحة فصل الحساب عن الواجهة في دالة نقية يمكن اختبارها من Console قبل أن يوجد أي زر:

function calculate(a, b, op) {
  switch (op) {
    case "+": return a + b;
    case "-": return a - b;
    case "*": return a * b;
    case "/": return b === 0 ? null : a / b;
    default: return null;
  }
}

تختار switch الفرع الذي يطابق قيمة op بالمقارنة الصارمة ===، وreturn داخل كل فرع تنهي الدالة فلا نحتاج break. أعدنا null للعملية غير المعروفة وللقسمة على صفر، لأن JavaScript لا توقف البرنامج عند القسمة على صفر: 8 / 0 تعطي Infinity، و0 / 0 تعطي NaN، وكلتاهما نتيجة لا يفهمها زبون المحل.

المشكلة الأولى كانت الجمع الذي يلصق. ربطنا الزر بالدالة وقرأنا الحقلين بـ value مباشرة، فأعطى 100 و2 مع الجمع النتيجة 1002، بينما بدا الطرح والضرب سليمين. السبب أن value تعيد نصاً دائماً، حتى في حقل نوعه number، والمعامل + بين نصين يدمجهما. أما - و* فتحوّلان طرفيهما إلى أرقام تلقائياً، فتُخفيان المشكلة. اكتشافها سهل بسطر console.log(typeof aInput.value) الذي يطبع string. والدرس العام: جرّب العملية التي تكشف الخطأ، ولا تكتفِ بأن عملية واحدة نجحت.

المشكلة الثانية: الحقل الفارغ صار صفراً. أصلحنا الأولى بـ Number(input.value)، فأصبح الحقل الفارغ يُحسب كأنه 0 بدل أن يُرفض. قارن سلوك دوال التحويل على مدخلات حقيقية، والتعليق بجانب كل سطر هو ما يعيده:

Number("12.5")      // 12.5
Number("12abc")     // NaN
parseFloat("12abc") // 12
Number("")          // 0
Number("١٢")        // NaN

يتضح أن parseFloat تتساهل فتقبل «12abc» وتخفي إدخالاً خاطئاً، وأن النص الفارغ يتحول إلى صفر، وأن الأرقام العربية المشرقية مثل ١٢ لا تُفهم في التحويل، وهو أمر يواجهه المستخدم العربي حين يلصق رقماً من رسالة في حقل نصي. الحل الأنظف لحقول type="number" هو الخاصية valueAsNumber، التي تعيد NaN للحقل الفارغ، ثم الفحص بـ Number.isNaN. أما الدالة العامة القديمة isNaN فتحوّل وسيطها أولاً فتعطي نتائج مربكة، مثل isNaN("") التي تعيد false.

المشكلة الثالثة ظهرت حين جرّب صاحب المحل سعرين بالقروش: 0.1 + 0.2 أعطت 0.30000000000000004. لا علاقة لهذا بكودك؛ الأرقام في JavaScript تُخزَّن بصيغة النقطة العائمة الثنائية وفق معيار IEEE 754، وبعض الكسور العشرية لا تُمثَّل فيها بدقة، تماماً كما لا يُكتب الثلث بعدد محدود من الخانات العشرية. الحل في العرض هو toFixed(2) التي تعيد نصاً بمنزلتين فتصبح النتيجة "0.30". وللحسابات المالية الجادة يُفضَّل الحساب بأصغر وحدة كعدد صحيح، أي بالقروش، ثم القسمة على 100 عند العرض فقط.

المشكلة الرابعة كانت رسالة عامة لا تساعد: النسخة الأولى تعرض «خطأ» لكل حالة. صارت الرسائل محددة، وهذا هو الربط النهائي بين الواجهة والدالة:

calcBtn.addEventListener("click", () => {
  const a = aInput.valueAsNumber;
  const b = bInput.valueAsNumber;
  if (Number.isNaN(a) || Number.isNaN(b)) {
    out.textContent = "املأ الحقلين بأرقام";
    return;
  }
  const result = calculate(a, b, opSelect.value);
  out.textContent = result === null ? "لا يمكن القسمة على صفر" : result.toFixed(2);
});

قائمة الاختبار النهائية قبل اعتبار الحاسبة جاهزة: 100 و2 بكل عملية، و0.1 و0.2 بالجمع، وحقل فارغ، والقسمة على صفر، ورقم سالب، ورقم كبير مثل مليون. اكتب النتيجة المتوقعة لكل حالة قبل التشغيل ثم قارن، فالحالة التي لا تعرف نتيجتها مسبقاً لا تختبر شيئاً.

تحسين أخير للاستخدام اليومي: ضع الحقلين والقائمة والزر داخل عنصر form، واستمع لحدث submit على النموذج بدل click على الزر، فيعمل الحساب أيضاً بضغط Enter داخل أي حقل. تذكّر حينها استدعاء e.preventDefault() في أول المعالج حتى لا يُعاد تحميل الصفحة وتضيع النتيجة.

  • value تعيد نصاً دائماً؛ حوّل قبل أي عملية حسابية.
  • Number("") تساوي 0؛ valueAsNumber تعيد NaN للحقل الفارغ.
  • افحص بـ Number.isNaN لا بالدالة القديمة isNaN.
  • 0.1 + 0.2 لا تساوي 0.3 بالضبط؛ اعرض بـ toFixed واحسب المال بأصغر وحدة.

الهيكل

[ input a ] [ input b ] [ + − × ÷ ]
        │
     [ احسب ]
        │
  valueAsNumber → calculate()
        │
    النتيجة: ...

مختبر — حاسبة إجمالي

أضف حقل «خصم %» وعدّل الحساب ليطرح الخصم، ثم امسح أحد الحقول وتأكد أن الرسالة تظهر.

معاينة مباشرة

مهمة عملية

ابنِ حاسبة بحقلين وقائمة select للعمليات الأربع وزر «احسب» تستخدم الدالة calculate. اختبر الحالات الست في قائمة الدرس، واكتب في تعليق داخل الكود الناتج الذي ظهر لكل حالة.

اختبر فهمك (1/3)

كيف تقرأ القيمة التي كتبها المستخدم في حقل input؟

اختبر فهمك (2/3)

قرأت حقلين فيهما 100 و2 وجمعتهما فظهر 1002. لماذا؟

اختبر فهمك (3/3)

ما ناتج Number("") في JavaScript؟

للاستزادة: MDN — Number · MDN — toFixed · MDN — valueAsNumber · javascript.info — Numbers

8. التحقق من النماذج

الهدف: تمنع إرسال بيانات ناقصة أو خاطئة باستخدام سمات HTML وواجهة التحقق المدمجة، مع رسائل عربية واضحة.

مركز تدريب في الدار البيضاء يستقبل التسجيل في دوراته عبر نموذج على موقعه. في الأسبوع الأول وصلته طلبات بأسماء من حرف واحد، وبريد مثل «ahmed@»، وأرقام هواتف ناقصة. التحقق من النماذج (form validation) يمنع هذا قبل أن يصل إلى جدول التسجيل، ويجري على ثلاث طبقات لكل منها دور مختلف.

• سمات HTML مثل required وtype="email" وminlength وpattern: تفحص الإدخال دون أي سطر JavaScript، ويعرض المتصفح رسائلها بلغة واجهته.
• JavaScript: لقواعد لا تعبّر عنها السمات، كتطابق حقلي كلمة المرور، ولعرض رسائل عربية بجانب كل حقل بدل الفقاعات الافتراضية.
• الخادم: يعيد الفحص كاملاً، لأن أي شخص يستطيع تعطيل JavaScript أو إرسال الطلب مباشرة دون فتح الصفحة. تحقق المتصفح لراحة المستخدم، وتحقق الخادم للأمان وسلامة البيانات.

أفضل نقطة بداية أن تكتب القواعد في HTML، ثم تستخدم من JavaScript ما يسمّى واجهة التحقق المدمجة (Constraint Validation API) لقراءة نتيجة فحص المتصفح بدل إعادة اختراعه. كل حقل يملك خاصية validity، وهي كائن فيه قيم منطقية تصف المشكلة بدقة: valueMissing للحقل الإلزامي الفارغ، وtypeMismatch لبريد بصيغة خاطئة، وtooShort لنص أقصر من minlength، وpatternMismatch لقيمة لا تطابق النمط، وvalid إن كان كل شيء سليماً. هذا نموذج مصغّر لحقل البريد:

<form id="signup" novalidate>
  <label for="email">البريد</label>
  <input id="email" name="email" type="email" required aria-describedby="email-err">
  <p id="email-err" aria-live="polite"></p>
  <button>تسجيل</button>
</form>
<script>
  const form = document.getElementById("signup");
  const email = document.getElementById("email");
  const emailErr = document.getElementById("email-err");
  function emailMessage() {
    if (email.validity.valueMissing) return "البريد مطلوب";
    if (email.validity.typeMismatch) return "اكتب بريداً مثل name@example.com";
    return "";
  }
  form.addEventListener("submit", (e) => {
    const msg = emailMessage();
    emailErr.textContent = msg;
    if (msg) {
      e.preventDefault();
      email.focus();
    }
  });
  email.addEventListener("input", () => {
    emailErr.textContent = "";
  });
</script>

السمة novalidate على النموذج تعطّل فقاعات المتصفح عند الإرسال لنعرض رسائلنا نحن، لكنها لا تعطّل الفحص نفسه، فما زالت validity تعمل. وتُستدعى e.preventDefault() في حالة الخطأ فقط، فالنموذج السليم يُرسل بشكل طبيعي. وaria-describedby يربط الرسالة بالحقل فيقرؤها قارئ الشاشة عند الوصول إليه، وaria-live="polite" تجعله يعلن الرسالة لحظة ظهورها، وfocus() يعيد المستخدم إلى موضع المشكلة. أما معالج input فيمسح الرسالة بمجرد أن يبدأ المستخدم التصحيح، فلا يبقى تحذير أحمر فوق حقل صار صحيحاً.

متى تُظهر الرسالة؟ عرض الخطأ مع أول حرف يكتبه المستخدم مزعج، لأنه يرى «بريد غير صالح» قبل أن يكمل الكتابة. النمط المريح أن تفحص عند الإرسال، وعند مغادرة الحقل بحدث blur إن أردت تنبيهاً مبكراً، ثم تمسح الرسالة أثناء التصحيح كما في المثال. بهذا يحصل المستخدم على ملاحظة في الوقت المناسب دون إلحاح.

للقواعد الخاصة، كتطابق كلمتي المرور، استخدم setCustomValidity: تمرير نص غير فارغ يجعل الحقل غير صالح، وتمرير نص فارغ يعيده صالحاً. الخطأ المتكرر هنا نسيان التصفير، فيبقى الحقل مرفوضاً حتى بعد التصحيح، والحل أن تحسب الحالة في كل مرة:

confirmInput.addEventListener("input", () => {
  const same = confirmInput.value === passInput.value;
  confirmInput.setCustomValidity(same ? "" : "كلمتا المرور غير متطابقتين");
});

بعد ذلك تكفي form.checkValidity() لمعرفة هل النموذج كله سليم، فهي تعيد true أو false، أو form.reportValidity() ليعرض المتصفح الرسائل بنفسه على أول حقل خاطئ.

وحين ينجح التحقق وتريد إرسال البيانات بنفسك، يجمع الكائن FormData كل حقول النموذج التي تحمل سمة name دفعة واحدة. كتابة Object.fromEntries(new FormData(form)) تعطيك كائناً عادياً مثل { email: "..." } يمكن طباعته أو إرساله بـ fetch كما سترى في الدرس التالي. الحقل الذي لا يحمل name لن يظهر في البيانات إطلاقاً، وهذا سبب متكرر لسؤال «لماذا وصل النموذج ناقصاً؟».

تنبيه أخير عن البريد: لا تكتب تعبيراً نمطياً معقداً تحاول به تغطية كل عناوين البريد الممكنة، فغالباً سترفض عناوين صحيحة وتغضب مستخدمين حقيقيين. اكتفِ بـ type="email" للتأكد من الشكل العام، والطريقة الوحيدة المؤكدة لمعرفة أن البريد موجود ويخص صاحبه هي إرسال رسالة تأكيد إليه من الخادم.

  • ثلاث طبقات: سمات HTML، ثم JavaScript للرسائل والقواعد الخاصة، ثم الخادم دائماً.
  • validity تكشف نوع المشكلة: valueMissing وtypeMismatch وtooShort.
  • setCustomValidity("") يعيد الحقل صالحاً؛ لا تنسَ التصفير.
  • preventDefault في حالة الخطأ فقط، وامسح الرسالة عند التصحيح.

الهيكل

إرسال
  │
  ▼
[ validity ]
  ├─ valid → يُرسل
  └─ valueMissing / typeMismatch → رسالة + focus

مختبر — فحص بريد

جرّب «a@» و«a@b.co» وحقلاً فارغاً، ثم أضف حقل اسم بـ minlength="2" وافحصه بـ validity.tooShort.

معاينة مباشرة

مهمة عملية

أضف إلى النموذج حقل رسالة لا يقل عن 10 حروف بعد trim، وحقلي كلمة مرور وتأكيد تفحص تطابقهما بـ setCustomValidity. اعرض خطأ كل حقل تحته، وامسحه عند التعديل، ثم املأ النموذج وأرسله بلوحة المفاتيح وحدها.

اختبر فهمك (1/3)

لماذا تُستدعى e.preventDefault() داخل معالج submit عند وجود خطأ؟

اختبر فهمك (2/3)

استدعيت confirmInput.setCustomValidity("غير متطابقتين") وبقي الحقل مرفوضاً بعد التصحيح. ما السبب؟

اختبر فهمك (3/3)

كتبت تحققاً كاملاً بـ JavaScript في المتصفح. هل يغني عن تحقق الخادم؟

للاستزادة: MDN — Constraint validation · MDN — setCustomValidity · MDN — FormData · OWASP — Input Validation Cheat Sheet

9. ربط JS بصفحة HTML كاملة

الهدف: تنظّم مشروعاً بملفات منفصلة، وتحمّل السكربت بالطريقة الصحيحة، وتجلب بيانات JSON بـ fetch.

حين تكبر الصفحة يصبح وضع كل شيء في ملف HTML واحد عبئاً. الأسلوب المعتمد أن يعيش الكود في ملف مستقل بامتداد js، يُربط بالصفحة عبر عنصر script مع السمة src. للملف الخارجي فائدتان عمليتان: تتشاركه صفحات الموقع كلها فتعدّله في مكان واحد، ويخزّنه المتصفح مؤقتاً (cache) فلا يُحمَّل من جديد عند الانتقال بين الصفحات. أما كتابة الكود في سمات مثل onclick داخل الوسوم فتخلط السلوك بالهيكل وتصعّب الصيانة، فتجنّبها في المشاريع الجديدة. هذا تنظيم مقترح لموقع مخبز صغير:

bakery-site/
  index.html
  css/styles.css
  js/app.js
  data/menu.json

السؤال المهم: متى يعمل السكربت؟ يقرأ المتصفح HTML من الأعلى، وحين يصادف script عادياً يتوقف عن بناء الصفحة حتى يُحمَّل الملف ويُنفَّذ. أمامك ثلاثة خيارات. الأول وضع script في نهاية body، فحين يعمل تكون العناصر جاهزة، لكن المتصفح لا يبدأ تحميل الملف إلا متأخراً. الثاني، وهو المفضّل اليوم لكود الصفحة، أن تضعه في head مع السمة defer: يُحمَّل الملف بالتوازي مع قراءة HTML، ولا يُنفَّذ إلا بعد اكتمال بناء الصفحة، وتُنفَّذ السكربتات المؤجلة بترتيب ظهورها. الثالث async: يُحمَّل بالتوازي ويُنفَّذ فور وصوله دون التزام بترتيب، فيناسب السكربتات المستقلة كأدوات الإحصاء، ولا يناسب كوداً يعتمد على العناصر أو على ملف آخر. وانتبه إلى أن defer وasync لا تؤثران في script داخلي بلا src.

<link rel="stylesheet" href="css/styles.css">
<script src="js/app.js" defer></script>

المسارات هنا نسبية من موقع index.html. وإن أردت تقسيم الكود إلى ملفات يستورد بعضها من بعض بـ import وexport فاستخدم type="module"، وسكربتات الوحدات (modules) مؤجلة تلقائياً كأن عليها defer، وتعمل في الوضع الصارم، ومتغيراتها لا تتسرب إلى النطاق العام.

الملف المنفصل يفتح باباً آخر: فصل البيانات عن الكود. بدل كتابة قائمة الأصناف داخل app.js، ضعها في data/menu.json واجلبها بـ fetch، وهي الدالة القياسية لطلب الموارد عبر الشبكة. الدالة fetch غير متزامنة (asynchronous): تعيد وعداً (Promise) بنتيجة ستصل لاحقاً، والكلمتان async وawait تجعلان هذا الكود مقروءاً كأنه متسلسل:

async function loadMenu() {
  try {
    const response = await fetch("data/menu.json");
    if (!response.ok) {
      throw new Error("HTTP " + response.status);
    }
    const items = await response.json();
    console.log("عدد الأصناف:", items.length);
    return items;
  } catch (err) {
    console.error("تعذّر تحميل القائمة:", err.message);
    return [];
  }
}
loadMenu();

إن احتوى الملف مصفوفة من ثلاثة أصناف يطبع Console: عدد الأصناف: 3. التفصيلة التي يغفل عنها كثيرون أن fetch تعتبر الطلب مكتملاً ما دام الخادم ردّ بشيء، حتى لو كان الرد خطأ 404 أو 500، فلا يُرفض الوعد إلا عند فشل الشبكة نفسها. لذلك نفحص response.ok بأنفسنا ونرمي خطأ، وإلا حاولت response.json() تحليل صفحة الخطأ كأنها JSON فظهرت رسالة SyntaxError مضللة. أما catch فتلتقط انقطاع الشبكة والخطأ الذي رميناه معاً، فتبقى الصفحة تعمل بقائمة فارغة.

سجل أعطال متكررة في هذا الإعداد، وكيف يُشخَّص كل منها:
• لا يحدث شيء عند النقر: افتح تبويب Network وأعد التحميل؛ إن ظهر app.js باللون الأحمر مع الرمز 404 فالمسار في src خاطئ، كأن تكتب app.js والملف داخل مجلد js.
• يرفض المتصفح الطلب أو يشكو من CORS حين تفتح الصفحة بنقرة مزدوجة: العنوان يبدأ بـ file:// والمتصفحات الحديثة تمنع fetch والوحدات من هذا المصدر لأسباب أمنية. شغّل خادماً محلياً بالأمر python -m http.server داخل مجلد المشروع، ثم افتح http://localhost:8000.
• SyntaxError عند تحليل JSON: غالباً فاصلة زائدة بعد آخر عنصر، أو علامات تنصيص مفردة حول النصوص، وكلاهما غير مسموح في صيغة JSON.
• رسالة await is only valid in async functions: استخدمت await خارج دالة async في سكربت عادي. ضعها داخل دالة async، أو استخدم type="module" حيث يُسمح بـ await في المستوى الأعلى من الملف.

  • الكود في ملف js خارجي تتشاركه الصفحات ويخزّنه المتصفح مؤقتاً.
  • defer في head: تحميل متوازٍ وتنفيذ بعد بناء الصفحة وبالترتيب.
  • fetch لا ترفض الوعد عند 404؛ افحص response.ok بنفسك.
  • fetch والوحدات تحتاج خادماً محلياً لا file://.

الهيكل

index.html
   ├── css/styles.css
   ├── js/app.js (defer)
   │      │
   │   fetch()
   │      ▼
   └── data/menu.json

مختبر — ملفان

أنشئ index.html ومجلد js فيه app.js، وشغّل python -m http.server في مجلد المشروع ثم افتح localhost:8000.

مهمة عملية

انقل حاسبة الدرس السابع إلى js/app.js مربوط بـ defer في head، وأنشئ data/menu.json بثلاثة أصناف واعرض عددها بـ fetch. ثم غيّر المسار عمداً وسجّل ما يظهر في Console وNetwork.

اختبر فهمك (1/3)

بدون defer، أين يوضع <script src="app.js"> ليجد عناصر الصفحة جاهزة؟

اختبر فهمك (2/3)

طلب fetch("data/menu.json") أعاد 404. ماذا يحدث للوعد؟

اختبر فهمك (3/3)

أي ربط يحمّل السكربت من head بالتوازي ولا ينفّذه إلا بعد بناء الصفحة وبالترتيب؟

للاستزادة: MDN — The script element · javascript.info — async و defer · MDN — Using the Fetch API · MDN — JavaScript modules

10. مشروع نهائي — قائمة مهام

الهدف: تبني قائمة مهام كاملة تُحفظ في المتصفح بمبدأ «البيانات أولاً ثم الرسم».

المشروع الختامي قائمة مهام تُحفظ في المتصفح فتبقى بعد إغلاق التبويب، وهو يجمع كل ما سبق: متغيرات ودوال وشروط وأحداث وتعديل DOM. الفكرة التي تنقلك من تجارب متفرقة إلى تطبيق منظم هي أن تكون البيانات مصدر الحقيقة الوحيد، والواجهة مجرد انعكاس لها. تحتفظ بمصفوفة من الكائنات، وعند أي تعديل تمرّ بثلاث خطوات ثابتة: تغيّر المصفوفة، ثم تحفظها، ثم تعيد رسم القائمة كاملة بدالة واحدة اسمها render.

لماذا هذا الانضباط؟ لأن البديل، أي تعديل عناصر li مباشرة هنا وهناك، ينتج سريعاً واجهة لا تطابق البيانات: مهمة محذوفة من الشاشة ما زالت في المخزن، أو عدّاد «متبقٍ» لا يتحدّث بعد الشطب. مع render واحدة يستحيل هذا التباين، لأن الشاشة تُبنى كل مرة من المصدر نفسه.

كل مهمة كائن له ثلاث خصائص: معرّف فريد، ونص، وحالة الإنجاز، مثل { id: 1759500000000, text: "مراجعة درس الأحداث", done: false }. المعرّف ضروري لأن نص المهمة قد يتكرر، ونستخدم له Date.now() الذي يعيد عدد الميلي ثانية منذ بداية عام 1970 بالتوقيت العالمي، وهو كافٍ لتطبيق شخصي يضيف المهام واحدة واحدة. هذا قلب التطبيق:

const list = document.getElementById("list");
const left = document.getElementById("left");
let tasks = JSON.parse(localStorage.getItem("tasks") || "[]");
function save() {
  localStorage.setItem("tasks", JSON.stringify(tasks));
}
function render() {
  list.replaceChildren();
  tasks.forEach((task) => {
    const li = document.createElement("li");
    li.dataset.id = task.id;
    li.classList.toggle("done", task.done);
    const label = document.createElement("span");
    label.textContent = task.text;
    const del = document.createElement("button");
    del.textContent = "حذف";
    del.className = "del";
    li.append(label, del);
    list.append(li);
  });
  left.textContent = "متبقٍ: " + tasks.filter((t) => !t.done).length;
}
render();

عند التحميل نقرأ المفتاح tasks من localStorage، وهو مخزن صغير لكل موقع يحفظ أزواجاً من مفتاح وقيمة نصية دون تاريخ انتهاء. إن لم يوجد المفتاح تعيد getItem القيمة null، فنستخدم النص "[]" لنبدأ بمصفوفة فارغة. الدالة replaceChildren() بلا وسائط تفرّغ القائمة قبل إعادة بنائها، وtoggle مع وسيط ثانٍ تضيف الصنف إن كان true وتزيله إن كان false. أما dataset.id فتكتب السمة data-id على العنصر لنعرف لاحقاً أي مهمة نُقرت، وfilter تعيد مصفوفة جديدة بالمهام غير المنجزة فنعرض طولها في العدّاد.

list.addEventListener("click", (e) => {
  const li = e.target.closest("li");
  if (!li) return;
  const id = Number(li.dataset.id);
  if (e.target.matches(".del")) {
    tasks = tasks.filter((t) => t.id !== id);
  } else {
    const task = tasks.find((t) => t.id === id);
    task.done = !task.done;
  }
  save();
  render();
});

معالج واحد على القائمة يكفي لكل المهام الحالية والمستقبلية بفضل التفويض الذي شرحه الدرس الخامس، وهو يعمل رغم أن render تعيد إنشاء العناصر كل مرة. نقرأ المعرّف من data-id ونحوّله إلى رقم، لأن قيم السمات تُخزَّن نصوصاً دائماً والمقارنة الصارمة بين "17" و17 خاطئة. إن كانت النقرة على زر الحذف نصنع مصفوفة جديدة بلا هذه المهمة، وإلا نعكس حالة إنجازها. ثم الخطوتان الثابتتان: save ثم render. يبقى زر الإضافة، ومنطقه قصير: يقرأ الحقل ويطبّق trim، ويتجاهل النص الفارغ، ويضيف كائناً جديداً بـ push، ثم يستدعي save وrender ويفرّغ الحقل.

أكثر خطأ يتكرر في هذا المشروع هو كتابة localStorage.setItem("tasks", tasks) دون تحويل. المخزن يقبل النصوص فقط، فيحوّل المصفوفة ضمنياً إلى نص، فتصبح مهمتان مثلاً "[object Object],[object Object]"، وعند التحميل التالي يفشل JSON.parse. تكشف ذلك بنفسك في Chrome أو Edge من تبويب Application ثم Local Storage، حيث ترى القيمة المحفوظة فعلاً ويمكنك حذفها لتبدأ من جديد. وتذكّر أن localStorage مقروء لأي سكربت يعمل على الموقع، فلا تحفظ فيه كلمات مرور أو رموز دخول.

قائمة القبول قبل اعتبار المشروع منتهياً: الإضافة تعمل بالزر وبمفتاح Enter، والنص الفارغ لا يُضاف، والنقر على المهمة يشطبها والنقر ثانية يلغي الشطب، والحذف يزيلها من الشاشة ومن المخزن معاً، والعدّاد صحيح بعد كل عملية، وإغلاق التبويب ثم فتحه يعيد القائمة كما تركتها. اختبر كل بند بنفسك وسجّل ما فشل قبل الانتقال إلى أي تحسين شكلي.

  • البيانات مصدر الحقيقة: عدّل المصفوفة، ثم احفظ، ثم أعد الرسم.
  • localStorage يخزّن نصوصاً فقط؛ JSON.stringify للحفظ وJSON.parse للقراءة.
  • معالج واحد بالتفويض يخدم كل المهام حتى بعد إعادة الرسم.
  • لا تحفظ بيانات حساسة في localStorage.

الهيكل

[ input ] [ أضف ]
      │
  tasks.push()
      │
  save() → localStorage
      │
  render() → <ul>
  • مهمة 1 [حذف]
  • مهمة 2 [حذف]

مختبر — Todo List

اجعل Enter يضيف مهمة: استمع لحدث keydown على الحقل وتحقق من e.key === 'Enter'. ثم أعد بناء النسخة على نمط tasks + save + render من الدرس.

معاينة مباشرة

مهمة عملية

أكمل التطبيق على نمط الدرس: إضافة بالزر وEnter، وشطب وحذف بالتفويض، وزر «مسح المنجز» يستخدم filter، وثلاث مهام افتراضية تظهر فقط حين يكون المخزن فارغاً. أغلق التبويب وافتحه وتأكد أن القائمة والعدّاد كما تركتهما.

اختبر فهمك (1/3)

وفق مبدأ «البيانات مصدر الحقيقة»، ماذا تفعل عند حذف مهمة؟

اختبر فهمك (2/3)

كتبت localStorage.setItem("tasks", tasks) فوجدت القيمة "[object Object],[object Object]". ما الإصلاح؟

اختبر فهمك (3/3)

لماذا نكتب Number(li.dataset.id) قبل مقارنة المعرّف بـ ===؟

للاستزادة: MDN — localStorage · MDN — JSON.stringify · Chrome DevTools — View and edit local storage · MDN — Array.filter

مراجع رسمية لهذا المسار

صفحتك لم تعد ثابتة — صارت تتفاعل

انشر أداة صغيرة (نصائح أو مهام) ضمن صفحتك الشخصية، ثم تعمّق في بايثون أو Cursor.