اذهب إلى المحتوى

معالجة الأحداث في جافا سكريبت


أسامة دمراني
اقتباس

إن لك سيطرة على عقلك فقط، وليس الأحداث الخارجية، وإذا تذكرت ذلك فستجد القوة.

ـــ ماركوس أوريليوس.

chapter_picture_15.jpg

تتعامل بعض البرامج مع مدخلات المستخدِم المباشرة مثل مدخلات لوحة المفاتيح والفأرة، ومثل تلك المدخلات ليس لها هيكل منظَّم بل تكون لحظية جزءًا جزءًا، ويجب أن يتعامل البرنامج معها أثناء حدوثها.

معالجات الأحداث

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

إنّ هذا الأسلوب متَّبع في بعض الآلات البدائية، وأفضل منه أن نجعل العتاد أو نظام التشغيل يلاحظان ضغطات المفاتيح ويضعانها في رتل، ثم يتفقد برنامج ما هذا الرتل لينظر في الأحداث المستجِدة ويتعامل مع ما يجده هناك. ويجب أن لا يهمل هذا البرنامج قراءة الرتل، بل يجب أن يتفقّده بصورة دورية، وإلا فستلاحظ أن البرنامج الذي تتعامل معه أنت غير متجاوب، ويسمى هذا الأسلوب بالاقتراع polling، لكن يفضِّل المبرمجون تجنبه، والأفضل منهما جميعًا هو جعل النظام ينبِّه شيفرة برنامجنا كلما وقع حدث ما، وتفعل المتصفحات ذلك بالسماح لنا بتسجيل الدوال على أساس معالِجات handlers لأحداث بعينها.

<p>اضغط على هذا المستند لتفعيل المعالِج</p>
<script>
  window.addEventListener("click", () => {
    console.log("You knocked?");
  });
</script>

تشير رابطة window إلى كائن مضمَّن built-in يوفره المتصفح، يمثل نافذة المتصفح التي تحتوي على المستند، كما يسجل استدعاء التابع addEventListener الخاص بها الوسيط الثاني ليُستدعَى كلما وقع الحدث الموصوف في الوسيط الأول.

الأحداث وعقد DOM

يُسجَّل كل معالِج حدثًا لمتصفح في سياق ما، فقد استدعينا addEventListener في المثال السابق على كائن window لتسجيل معالِج للنافذة كلها، ويمكن العثور على مثل هذا التابع في عناصر DOM أيضًا، وفي بعض أنواع الكائنات الأخرى.

لا تُستدعى مستمِعات الأحداث event listeners إلا عند وقوع الحدث في سياق الكائن الذي تكون مسجلة عليه.

<button>اضغط هنا</button>
<p>لا يوجد معالِج هنا</p>
<script>
  let button = document.querySelector("button");
  button.addEventListener("click", () => {
    console.log("Button clicked.");
  });
</script>

يربط هذا المثال المعالج بعقدة زر، وأيّ ضغطة على هذا الزر تشغِّل المعالج، بينما لا يحدث شيء عند الضغط على بقية المستند.

يعطي إلحاق سمة onclick لعقدة ما التأثير نفسه، وهذا يصلح لأغلب أنواع الأحداث، إذ تستطيع إلحاق معالج من خلال سمة يكون اسمها هو اسم الحدث مسبوقًا بـ on، غير أن العقدة تحتوي على سمة onclick واحدة فقط، لذا تستطيع تسجيل معالج واحد فقط لكل عقدة بهذه الطريقة.

يسمح التابع addEventListener لك بأن تضيف أي عدد من المعالجات، بحيث لا تقلق من إضافتها حتى لو كان لديك معالجات أخرى للعنصر؛ أما التابع removeEventListener الذي تستدعيه وسائط تشبه addEventListener، فإنه يحذف المعالج، انظر:

<button>زر الضغطة الواحدة</button>
<script>
  let button = document.querySelector("button");
  function once() {
    console.log("Done.");
    button.removeEventListener("click", once);
  }
  button.addEventListener("click", once);
</script>

يجب أن تكون الدالة المعطاة لـ removeEventListener لها قيمة الدالة نفسها التي أعطيت إلى addEventListener، بحيث إذا أردت إلغاء تسجيل معالج ما، فعليك أن تعطي الدالة الاسم once في المثال كي تستطيع تمرير نفس قيمة الدالة لكلا التابعين.

كائنات الأحداث

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

<button>اضغط عليّ كيفما شئت</button>
<script>
  let button = document.querySelector("button");
  button.addEventListener("mousedown", event => {
    if (event.button == 0) {
      console.log("Left button");
    } else if (event.button == 1) {
      console.log("Middle button");
    } else if (event.button == 2) {
      console.log("Right button");
    }
  });
</script>

الانتشار Propagation

تستقبل المعالجات المسجَّلة مع فروع children على العقد أحداثًا تقع في هذه الفروع أيضًا، فإذا تم النقر على زر داخل فقرة ما، فسترى معالجات الأحداث في تلك الفقرة حدث النقر أيضًا.

لكن إذا كان كل من الفقرة والزر لهما معالج، فإنّ المعالج الأكثر خصوصيةً -أي المعالج الذي على الزر مثلًا- هو الذي يعمل، ويقال هنا أن الحدث ينتشر propagate إلى الخارج outward، أي من العقدة التي حدث فيها إلى جذر المستند، وبعد أن تحصل جميع المعالجات المسجلة على عقدة ما على فرصة للاستجابة للحدث، فستحصل المعالجات المسجَّلة على النافذة بأكملها على فرصتها في الاستجابة للحدث هي أيضًا.

يستطيع معالج الحدث استدعاء التابع stopPropagation على كائن الحدث في أي وقت لمنع المعالجات من استقبال الحدث، وهذا مفيد إذا كان لديك مثلًا زر داخل عنصر آخر قابل للنقر. ولم ترد أن تتسبب نقرات الزر في نقرات ذلك العنصر الخارجي أيضًا.

يسجِّل المثال التالي معالجات "mousedown" على كل من الزر والفقرة التي حوله، فحين تنقر بالزر الأيمن سيستدعي معالج الزر الذي في الفقرة التابع stopPropagation الذي سيمنع المعالج الذي على الفقرة من العمل، وإذا نُقر الزر بزر آخر للفأرة فسيعمل كلا المعالجين، انظر كما يلي:

<p>فقرة فيها  <button>زر</button>.</p>
<script>
  let para = document.querySelector("p");
  let button = document.querySelector("button");
  para.addEventListener("mousedown", () => {
    console.log("Handler for paragraph.");
  });
  button.addEventListener("mousedown", event => {
    console.log("Handler for button.");
    if (event.button == 2) event.stopPropagation();
  });
</script>

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

<button>A</button>
<button>B</button>
<button>C</button>
<script>
  document.body.addEventListener("click", event => {
    if (event.target.nodeName == "BUTTON") {
      console.log("Clicked", event.target.textContent);
    }
  });
</script>

الإجراءات الافتراضية

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

وتُستدعى معالجات الأحداث جافاسكربت قبل حدوث السلوك الافتراضي في أغلب أنواع الأحداث، فإذا لم يرد المعالج وقوع هذا السلوك الاعتيادي لأنه قد عالج الحدث بالفعل فإنه يستدعي التابع preventDefault على كائن الحدث.

يمكن استخدام هذا لتطبيق اختصارات لوحة المفاتيح الخاصة بك أو القائمة المختصرة، كما يمكن استخدامه ليتدخل معارضًا السلوك الذي يتوقعه المستخدِم.

انظر المثال التالي لرابط لا يذهب بالمستخدِم إلى الموقع الذي يحمله:

<a href="https://developer.mozilla.org/">MDN</a>
<script>
  let link = document.querySelector("a");
  link.addEventListener("click", event => {
    console.log("Nope.");
    event.preventDefault();
  });
</script>

لا تفعل شيئًا كهذا إلا إن كان لديك سبب مقنع، إذ سينزعج مستخدِمو صفحتك من مثل هذا السلوك المفاجئ لهم.

بعض الأحداث لا يمكن اعتراضها أبدًا في بعض المتصفحات، إذ لا يمكن معالجة اختصار لوحة المفاتيح الذي يغلق اللسان الحالي -أي ctrl+w في ويندوز أو ⌘+w في ماك- باستخدام جافاسكربت مثلًا.

أحداث المفاتيح

يطلِق المتصفح الحدث "keydown" في كل مرة تضغط فيها مفتاحًا من لوحة المفاتيح، وكلما رفعت يدك عن المفتاح، ستحصل على الحدث "keyup".

<p> v تصير هذه الصفحة بنفسجية إذا ضغطت مفتاح.</p>
<script>
  window.addEventListener("keydown", event => {
    if (event.key == "v") {
      document.body.style.background = "violet";
    }
  });
  window.addEventListener("keyup", event => {
    if (event.key == "v") {
      document.body.style.background = "";
    }
  });
</script>

يُطلَق الحدث "keydown" إذا ضغطت على المفتاح وتركته أو إذا ظللت ضاغطًا عليه، حيث يُطلق في كل مرة يُكرر فيها المفتاح، وانتبه لهذا إذ أنك لو أضفت زرًا إلى DOM حين يُضغط مفتاح، ثم حذفته حين يُترك المفتاح، فقد تضيف مئات الأزرار خطأً إذا ضُغط على المفتاح ضغطًا طويلًا.

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

إذا ظللت ضاغطًا على مفتاح "عالي" shift ثم ضغطت على مفتاح v مثلًا، فإن ذلك قد يتسبب في حمل الخاصية لاسم المفتاح أيضًا، وعندها تتحول "v" إلى "V"، وتتغير "1" إلى "!" إذا كان هذا ما يخرجه الضغط على shift+1 على حاسوبك.

تولِّد مفاتيح التحكم مثل shift وcontrol وalt وغيرها أحداث مفاتيح مثل المفاتيح العادية، وتستطيع معرفة إذا كانت هذه المفاتيح مضغوط عليها ضغطًا مستمرًا عند البحث عن مجموعات المفاتيح من خلال النظر إلى خصائص shiftKey وctrlKey وaltKey وmetaKey لأحداث لوحة المفاتيح والفأرة.

<p>Press Control-Space to continue.</p>
<script>
  window.addEventListener("keydown", event => {
    if (event.key == " " && event.ctrlKey) {
      console.log("Continuing!");
    }
  });
</script>

تعتمد عقدة DOM حيث بدأ حدث المفتاح على العنصر الذي كان نشطًا عند الضغط على المفتاح، ولا تستطيع أغلب العقد أن تكون نشطةً إلا إذا أعطيتها سمة tabindex على خلاف الروابط والأزرار وحقول الاستمارات، كما سنعود لحقول الاستمارات في مقال لاحق، وإذا لم يكن ثمة شيء بعينه نشطًا، فستتصرف document.body على أساس عقدة هدف لأحداث المفاتيح.

لا نفضِّل استخدام أحداث المفاتيح إذا كتب المستخدِم نصًا وأردنا معرفة ما يكتبه، فبعض المنصات لا تبدأ تلك الأحداث هنا كما في حالة لوحة المفاتيح الافتراضية على هواتف الأندرويد، لكن حتى لو كانت لديك لوحة مفاتيح قديمة، فإنّ بعض أنواع النصوص المدخلة لا تتطابق مع ضغطات المفاتيح تطابقًا مباشرًا، مثل برنامج محرر أسلوب الإدخال input method editor -أو IME اختصارًا- الذي يستخدمه الأشخاص الذين لا تتناسب نصوصهم مع لوحة المفاتيح، حيث تُدمج عدة نقرات لإنشاء المحارف.

إذا أرادت العناصر التي تستطيع الكتابة فيها معرفة ما يكتبه المستخدِم كما في وسوم <input> و<textarea>، فإنها تطلق أحداث "input" كلما غيّر المستخدِم محتواها، ومن الأفضل قراءة المحتوى الفعلي المكتوب من الحقل النشط إذا أردنا الحصول عليه.

أحداث المؤشر

توجد حاليًا طريقتان مستخدَمتان على نطاق واسع للإشارة إلى الأشياء على الشاشة: الفأرات -بما في ذلك الأجهزة التي تعمل عملها مثل لوحات اللمس touchpads وكرات التتبع- وشاشات اللمس touchscreens، وتُنتج هاتان الطريقتان نوعين مختلفَين تمامًا من الأحداث.

ضغطات الفأرة

يؤدي الضغط على زر الفأرة إلى إطلاق عدد من الأحداث، ويتشابه حدثَي "mouseup" و"mousedown" مع حدثَي "keydown" و"keyup"، وتنطلق عند الضغط على الزر وتركه، كما تحدث هذه على عقد DOM الموجودة أسفل مؤشر الفأرة مباشرةً عند وقوع الحدث.

ينطلق حدث "click" بعد حدث "mouseup" على العقدة الأكثر تحديدًا التي تحتوي على كل من ضغط الزر وتحريره، فإذا ضغطت على زر الفأرة في فقرة مثلًا ثم حركت المؤشر إلى فقرة أخرى وتركت الزر، فسيقع حدث "click" على العنصر الذي يحتوي على هاتين الفقرتين،؛ أما في حالة حدوث نقرتين بالقرب من بعضهما، فسينطلق حدث "dblclick" -وهو النقرة المزدوجة- بعد حدث النقرة الثانية.

يمكنك النظر إلى الخاصيتَين clientX وclientY إذا أردت الحصول على معلومات دقيقة حول هذا المكان الذي وقع فيه حدث الفأرة، إذ تحتويان على إحداثيات الحدث -بالبكسل- نسبةً إلى الركن العلوي الأيسر من النافذة، أو pageX وpageY نسبةً إلى الركن العلوي الأيسر من المستند كله، وقد تكون هذه مختلفةً عن تلك عند تمرير النافذة.

ينفِّذ المثال التالي برنامج رسم بدائي، حيث توضع نقطة أسفل مؤشر الماوس في كل مرة تنقر فيها على المستند.

<style>
  body {
    height: 200px;
    background: beige;
  }
  .dot {
    height: 8px; width: 8px;
    border-radius: 4px; /* rounds corners */
    background: blue;
    position: absolute;
  }
</style>
<script>
  window.addEventListener("click", event => {
    let dot = document.createElement("div");
    dot.className = "dot";
    dot.style.left = (event.pageX - 4) + "px";
    dot.style.top = (event.pageY - 4) + "px";
    document.body.appendChild(dot);
  });
</script>

حركة الفأرة

ينطلق حدث "mousemove" في كل مرة يتحرك مؤشر الفأرة، ويمكن استخدام هذا الحدث لتتبع موضع المؤشر، مثل أن نحتاج إلى تنفيذ بعض المهام المتعلقة بخاصية السحب drag للمؤشر.

يوضح المثال التالي برنامجًا يعرض شريطًا ويضبط معالجات أحداث كي يتحكم السحب يمينًا ويسارًا في عرض الشريط:

<p>اسحب الشريط لتغيير عرضه:</p>
<div style="background: orange; width: 60px; height: 20px">
</div>
<script>
  let lastX; // Tracks the last observed mouse X position
  let bar = document.querySelector("div");
  bar.addEventListener("mousedown", event => {
    if (event.button == 0) {
      lastX = event.clientX;
      window.addEventListener("mousemove", moved);
      event.preventDefault(); // Prevent selection
    }
  });

  function moved(event) {
    if (event.buttons == 0) {
      window.removeEventListener("mousemove", moved);
    } else {
      let dist = event.clientX - lastX;
      let newWidth = Math.max(10, bar.offsetWidth + dist);
      bar.style.width = newWidth + "px";
      lastX = event.clientX;
    }
  }
</script>

لاحظ أنّ معالج "mousemove" يُسجَّل على النافذة كلها، حتى لو خرج المؤشر عن الشريط أثناء تغيير عرضه، وذلك طالما أن الزر مضغوط عليه ونكون لا زلنا نريد تعديل العرض. لكن يجب أن يتوقف تغيير الحجم فور تركنا لزر الفأرة، ولضمان ذلك فإننا نستخدم خاصية buttons -لاحظ أنها جمع وليست مفردة-، والتي تخبرنا عن الأزرار التي نضغط عليها الآن، فإذا كانت صِفرًا، فهذا يعني أن الأزرار كلها متروكة وحرة؛ أما إذا كانت ثمة أزرار مضغوط عليها، فستكون قيمة الخاصية هي مجموع رموز هذه الأزرار، إذ يحصل الزر الأيسر على الرمز 1 والأيمن على الرمز 2، والأوسط على 4، فإذا كان الزران الأيمن والأيسر مضغوطًا عليهما معًا، فستكون قيمة buttons هي 3.

لاحظ أن ترتيب هذه الرموز يختلف عن الترتيب الذي تستخدمه button، حيث يأتي الزر الأوسط قبل الأيمن، وذلك لِما ذكرنا من قبل أنّ واجهة برمجة المتصفح تفتقر إلى التناسق.

أحداث اللمس

صُمِّم أسلوب المتصفح ذو الواجهة الرسومية في الأيام التي كانت فيها شاشات اللمس نادرةً جدًا في السوق، ولهذا لم توضع في الحسبان كثيرًا، لذا فقد كان على المتصفحات التي جاءت في أولى الهواتف ذات شاشات اللمس التظاهر بأن أحداث اللمس هي نفسها أحداث الفأرة -وإن كان إلى حد ما-، فإذا نقرت على شاشتك فستحصل على الأحداث "mousedown" و"mouseup" و"click".

لكن هذا المنظور ركيك بما أنّ شاشة اللمس تعمل بأسلوب مختلف تمامًا عن الفأرة، فلا توجد هنا أزرار متعددة ولا يمكن تتبع الإصبع إذا لم يكن على الشاشة فعلًا لمحاكاة "mousemove"، كما تسمح الشاشة بعدة أصابع عليها في الوقت نفسه.

لا تغطي أحداث الفأرة شاشات اللمس إلا في حالات مباشرة، فإذا أضفت معالج "click" إلى زر ما، فسيستطيع المستخدِم الذي يستعمل شاشة لمس استخدام الزر هنا، لكن لن يعمل مثال الشريط السابق على شاشة لمس.

كما أن هناك أنواعًا بعينها من الأحداث تنطلق عند التفاعل باللمس فقط، فحين يلمس الإصبع الشاشة، فستحصل على حدث "touchstart"، وإذا تحرك أثناء اللمس فستُطلَق أحداث "touchmove"؛ أما إذا ابتعد عن الشاشة فستحصل على حدث "touchend".

تمتلك كائنات هذه الأحداث خاصية touches التي تحمل كائنًا شبيهًا بالمصفوفة من نقاط لكل منها خصائص cientX وclientY وpageX وpageY، وذلك لأنّ كثيرًا من شاشات اللمس تدعم اللمس المتعدد في الوقت نفسه، فلا يكون لتلك الأحداث مجموعةً واحدةً فقط من الأحداث.

تستطيع فعل شيء مشابه لتظهر دوائر حمراء حول كل إصبع يلمس الشاشة:

<style>
  dot { position: absolute; display: block;
        border: 2px solid red; border-radius: 50px;
        height: 100px; width: 100px; }
</style>
<p>Touch this page</p>
<script>
  function update(event) {
    for (let dot; dot = document.querySelector("dot");) {
      dot.remove();
    }
    for (let i = 0; i < event.touches.length; i++) {
      let {pageX, pageY} = event.touches[i];
      let dot = document.createElement("dot");
      dot.style.left = (pageX - 50) + "px";
      dot.style.top = (pageY - 50) + "px";
      document.body.appendChild(dot);
    }
  }
  window.addEventListener("touchstart", update);
  window.addEventListener("touchmove", update);
  window.addEventListener("touchend", update);
</script>

قد ترغب في استدعاء preventDefault في معالجات أحداث اللمس لتنسخ -أي تُعدِّل- سلوك المتصفح الافتراضي الذي قد يشمل تمرير الشاشة عند تحريك الإصبع للأعلى أو الأسفل لتمرير الصفحة، ولمنع أحداث المؤشر من الانطلاق، والتي سيكون لديك معالج لها أيضًا.

أحداث التمرير

ينطلق حدث "scroll" كلما مُرِّر عنصر ما، ونستطيع استخدام ذلك في معرفة ما الذي ينظر إليه المستخدِم الآن كي نوقف عمليات التحريك أو الرسوم المتحركة التي خرجت من النطاق المرئي للشاشة -أو لإرسال هذه البيانات إلى جامعي بيانات المستخدِمين من مخترقي الخصوصية-، أو لإظهار تلميح لمدى تقدم المستخدِم في الصفحة بتظليل عنوان في الفهرس أو إظهار رقم الصفحة أو غير ذلك.

يرسم المثال التالي شريط تقدم أعلى المستند ويحدِّثه ليمتلئ كلما مررت للأسفل:

<style>
  #progress {
    border-bottom: 2px solid blue;
    width: 0;
    position: fixed;
    top: 0; left: 0;
  }
</style>
<div id="progress"></div>
<script>
  // اكتب محتوى هنا
  document.body.appendChild(document.createTextNode(
    "supercalifragilisticexpialidocious ".repeat(1000)));

  let bar = document.querySelector("#progress");
  window.addEventListener("scroll", () => {
    let max = document.body.scrollHeight - innerHeight;
    bar.style.width = `${(pageYOffset / max) * 100}%`;
  });
</script>

إذا كان الموضع position الخاص بالعنصر ثابتًا fixed، فسيتصرف كما لو كان له موضع مطلق absolute، لكنه يمنعه من التمرير مع بقية المستند، ويُترجَم هذا التأثير في الحالات الواقعية على أساس حالة شريط التقدم في مثالنا، إلا أننا نريد جعل الشريط ظاهرًا في أعلى الصفحة أو المستند بغض النظر عن موضع التمرير فيه، ويتغير عرضه ليوضِّح مدى تقدمنا في المستند، كما سنستخدم % بدلًا من px لضبط وحدة العرض كي يكون حجم العنصر نسبيًا لعرض الصفحة.

تعطينا الرابطة العامة innerheight ارتفاع النافذة التي يجب طرحها من الارتفاع الكلي الذي يمكن تمريره، بحيث لا يمكن التمرير بعد الوصول إلى نهاية المستند، ولدينا بالمثل innerwidth للعرض الخاص بالنافذة؛ وتحصل على النسبة الخاصة بشريط التمرير عبر قسمة موضع التمرير الحالي pageYoffset على أقصى موضع تمرير وتضرب الناتج في 100.

كذلك لا يُستدعى معالج الحدث إلا بعد وقوع التمرير نفسه، وبالتالي لن يمنع استدعاء preventDefault وقوع حدث التمرير.

أحداث التنشيط Focus Events

إذا كان عنصر ما نشطًا، فسيطلق المتصفح حدث "focus" عليه، وإذا فقد نشاطه ذلك بانتقال التركيز منه إلى غيره فإنه يحصل على حدث "blur". لا ينتشر هذان الحدثان على عكس الأحداث السابقة، ولا يُبلَّغ المعالج الذي على العنصر الأصل حين يكون عنصر فرعي نشطًا أو حين يفقد نشاطه.

يوضح المثال التالي نص مساعدة لحقل نصي نشط:

<p>الاسم: <input type="text" data-help="اسمك الكامل"></p>
<p>العمر: <input type="text" data-help="عمرك بالأعوام"></p>
<p id="help"></p>

<script>
  let help = document.querySelector("#help");
  let fields = document.querySelectorAll("input");
  for (let field of Array.from(fields)) {
    field.addEventListener("focus", event => {
      let text = event.target.getAttribute("data-help");
      help.textContent = text;
    });
    field.addEventListener("blur", event => {
      help.textContent = "";
    });
  }
</script>

سيستقبل كائن النافذة حدثي "focus" و"blur" كلما تحرك المستخدِم من وإلى نافذة المتصفح أو اللسان النشط الذي يكون المستند معروضًا فيه.

حدث التحميل Load Event

ينطلق حدث "load" على النافذة وكائنات متن المستند إذا أتمت صفحة ما تحميلها، وهو يُستخدَم عادةً لجدولة إجراءات التهيئة initialization actions التي تكون في حاجة إلى بناء المستند كاملًا.

تذكَّر أنّ محتوى وسوم <script> يُشغَّل تلقائيًا إذا قابل الوسم، وقد يكون هذا قبل أوانه إذا احتاجت السكربت إلى فعل شيء بأجزاء المستند التي تظهر بعد وسم <script> مثلًا.

تمتلك العناصر التي تحمِّل ملفًا خارجيًا -مثل الصور ووسوم السكربت- حدث "load" كذلك، حيث يوضِّح تحميل الملفات التي تشير إليها، وهذه الأحداث -أي أحداث التحميل- لا تنتشر propagate أي مثل الأحداث المتعلقة بالنشاط focus.

حين نغلق صفحةً ما أو نذهب بعيدًا عنها إلى غيرها عند فتح رابط مثلًا، فسينطلق حدث "beforeunload"، والاستخدام الرئيسي لهذا الحدث هو منع المستخدِم من فقد أي عمل كان يعمله إذا أُغلق المستند، فإذا منعت السلوك الافتراضي لهذا الحدث وضبطت خاصية returnvalue على كائن الحدث لتكون سلسلة نصية؛ فسيظهر المتصفح للمستخدِم صندوقًا حواريًا يسأله إذا كان يرغب في ترك الصفحة حقًا.

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

الأحداث وحلقات الأحداث التكرارية

تتصرف معالجات أحداث المتصفح في سياق حلقة الحدث التكرارية مثل إشعارات غير متزامنة كما ناقشنا في البرمجة غير المتزامنة في جافاسكريبت، وتُجدوَل حين يقع الحدث، لكن عليها الانتظار حتى تنتهي السكربتات العاملة أولًا قبل أن تعمل هي.

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

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

تخيَّل أنّ تربيع عدد ما يمثل عمليةً حسابيةً طويلةً وثقيلة، وأننا نريد إجراءها في خيط thread منفصل، فنكتب حينها ملفًا اسمه code/squareworker.js يستجيب للرسائل بحساب التربيع وإرساله في رسالة.

addEventListener("message", event => {
  postMessage(event.data * event.data);
});

لا تشارك العمال نطاقها العام أو أي بيانات أخرى مع البيئة الرئيسية للسكربت لتجنب مشاكل الخيوط المتعددة التي تتعامل مع البيانات نفسها، وعليك التواصل معها عبر إرسال الرسائل ذهابًا وعودة.

ينتج المثال التالي عاملًا يشغِّل تلك السكربت ويرسل بعض الرسائل إليها ثم يخرج استجاباتها:

let squareWorker = new Worker("code/squareworker.js");
squareWorker.addEventListener("message", event => {
  console.log("The worker responded:", event.data);
});
squareWorker.postMessage(10);
squareWorker.postMessage(24);

ترسِل دالة postMessage رسالةً تطلق حدث "message" في المستقبِِل، كما ترسل السكربت التي أنشأت العامل رسائلًا، وتستقبلها من خلال كائن Worker؛ في حين يخاطب العامل السكربت التي أنشأته عبر الإرسال مباشرةً على نطاقها العام والاستماع إليه.

يمكن للقيم التي تمثل على أساس JSON أن تُرسَل على أساس رسائل، وسيَستقبل الطرف المقابل نسخةً منها بدلًا من القيمة نفسها.

المؤقتات Timers

رأينا دالة setTimeout في البرمجة غير المتزامنة في جافاسكريبت وكيف أنها تجدوِل دالةً أخرى لتُستدعى لاحقًا بعد وقت محدد يُحسب بالميلي ثانية، لكن أحيانًا قد تحتاج إلى إلغاء دالة جدولتها بنفسك سابقًا، ويتم هذا بتخزين القيمة التي أعادتها setTimeout واستدعاء clearTimeout عليها.

let bombTimer = setTimeout(() => {
  console.log("بووم!");
}, 500);

if (Math.random() < 0.5) { // 50% احتمال
  console.log("Defused.");
  clearTimeout(bombTimer);
}

تعمل الدالة cancelAnimationFrame بالطريقة نفسها التي تعمل بها clearTimeout، أي أنّ استدعاءها على قيمة أعادتها requestAnimationFrame، سيلغي هذا الإطار، وذلك على افتراض أنه لم يُستدعى بعد، كما تُستخدَم مجموعة مشابهة من الدوال هي setInterval وclearInterval لضبط المؤقتات التي يجب أن تتكرر كل عدد معيّن X من الميللي ثانية.

let ticks = 0;
let clock = setInterval(() => {
  console.log("tick", ticks++);
  if (ticks == 10) {
    clearInterval(clock);
    console.log("stop.");
  }
}, 200);

الارتداد Debouncing

بعض أنواع الأحداث لها قابلية الانطلاق بسرعة وعدة مرات متتابعة مثل حدثي "mousemove" و"scroll"، وحين نعالج هذه الأحداث، يجب الحذر من فعل أيّ شيء يستغرق وقتًا كبيرًا وإلا فسيأخذ معالجنا وقتًا طويلًا بحيث يبطئ التفاعل مع المستند.

إذا أردت فعل شيء مهم بهذا المعالج، فيمكنك استخدام setTimeout للتأكد أنك لا تفعله كثيرًا، ويسمى هذا بارتداد الحدث event debouncing.

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

<textarea>اكتب شيئًا هنا...</textarea>
<script>
  let textarea = document.querySelector("textarea");
  let timeout;
  textarea.addEventListener("input", () => {
    clearTimeout(timeout);
    timeout = setTimeout(() => console.log("Typed!"), 500);
  });
</script>

إنّ إعطاء قيمة غير معرَّفة لـ clearTimeout أو استدعاءها على مهلة زمنية أُطلِقت سلفًا، ليس له أي تأثير، وعليه فلا داعي لأن تخشى شيئًا إذا أردت استدعاءها، بل افعل ذلك لكل حدث إذا شئت.

تستطيع استخدام نمط pattern مختلف قليلًا إذا أردت المباعدة بين الاستجابات بحيث تكون مفصولةً بأقل مدة زمنية محددة، لكن في الوقت نفسه تريد إطلاقها أثناء سلسلة أحداث -وليس بعدها-، حيث يمكنك مثلًا الاستجابة إلى أحداث "mousemove" بعرض الإحداثيات الحالية للفأرة، لكن تعرضها كل 250 مللي ثانية، انظر منا يلي:

<script>
  let scheduled = null;
  window.addEventListener("mousemove", event => {
    if (!scheduled) {
      setTimeout(() => {
        document.body.textContent =
          `Mouse at ${scheduled.pageX}, ${scheduled.pageY}`;
        scheduled = null;
      }, 250);
    }
    scheduled = event;
  });
</script>

خاتمة

تمكننا معالجات الأحداث من استشعار الأحداث التي تحدث في صفحة الويب والتفاعل معها، ويُستخدَم التابع addEventListener لتسجيل مثل تلك المعالِجات.

كل حدث له نوع يعرِّفه مثل "keydown" و"focus" وغيرهما، وتُستدعى أغلب الأحداث على عنصر DOM بعينه، ثم ينتشر إلى أسلاف هذا العنصر سامحًا للمعالجات المرتبطة بتلك العناصر أن تعالجها.

عندما يُستدعى معالج حدث ما، فسيُمرَّر إليه كائن حدث بمعلومات إضافية عن الحدث، وذلك الكائن له تابع يسمح لنا بإيقاف الانتشار stopPropagation، وآخر يمنع معالجة المتصفح الافتراضية للحدث preventDefault.

إذا ضغطنا مفتاحًا فسيطلق هذا حدثَي "keydown" و"keyup"؛ أما الضغط على زر الفأرة فسيطلق الأحداث "mousedown"، و"mouseup"، و"click"، في حين يطلق تحريك المؤشر أحداث "mousemove"، كما يطلق التفاعل مع شاشات اللمس الأحداث "touchstart" و"touchmove" و"touchend".

يمكن استشعار التمرير scrolling من خلال حدث "scroll"، كما يمكن استشعار تغيرات النافذة محل التركيز أو النافذة النشطة من خلال حدثَي "focus" و"blur"، وإذا أنهى المستند تحميله، فسينطلق حدث "load" للنافذة.

تدريبات

بالون

اكتب صفحةً تعرض بالونًا باستخدام الصورة الرمزية للبالون balloon emoji?، بحيث يكبر هذا البالون بنسبة 10% إذا ضغطت السهم المشير للأعلى، ويصغر إذا ضغطت على السهم المشير للأسفل بنسبة 10%.

تستطيع التحكم في حجم النص -بما أن الصورة الرمزية ما هي إلا نص- بضبط font-size لخاصية style.fontSize على العنصر الأصل لها، وتذكر ألا تنسى ذكر وحدة القياس في القيمة مثل كتابة 10px.

تأكد من أن المفاتيح تغير البالون فقط دون تمرير الصفحة، وأن أسماء مفاتيح الأسهم هي "ArrowUp" و"ArrowDown". وإذا نجح ذلك فأضف ميزةً أخرى هي انفجار البالون عند بلوغه حجمًا معينًا، ويعني هذا هنا استبدال الصورة الرمزية للانفجار ? بالصورة الرمزية للبالون، ويُزال معالج الحدث هنا كي لا تستطيع تغيير حجم الانفجار.

تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.

<p>?</p>

<script>
  // شيفرتك هنا
</script>

إرشادات للحل

يجب تسجيل معالج لحدث "keydown" وأن تنظر في event.key لتعرف هل ضُغِط السهم الأعلى أم الأسفل، ويمكن الحفاظ على الحجم الحالي في رابطة binding كي تستطيع بناء الحجم الجديد عليه، وسيكون هذا نافعًا -سواءً الرابطة، أو نمط البالون في الـ DOM- في تعريف الدالة التي تحدث الحجم، وذلك كي تستدعيها من معالج الحدث الخاص بك، وربما كذلك بمجرد البدء لضبط الحجم الابتدائي.

تستطيع تغيير البالون إلى انفجار عبر استبدال عقدة النص بأخرى باستخدام replaceChild، أو بضبط خاصية textContent لعقدتها الأصل على سلسلة نصية جديدة.

ذيل الفأرة

كان يعجب الناس في الأيام الأولى لجافاسكربت أن تكون صفحات المواقع ملأى بالصور المتحركة المبهرجة والتأثيرات البراقة، وأحد هذه التأثيرات هو إعطاء ذيل لمؤشر الفأرة في صورة سلسلة من العناصر التي تتبع المؤشر في حركته في الصفحة، وفي هذا التدريب نريدك تنفيذ ذيل للمؤشر.

استخدم عناصر <div> التي لها مواضع مطلقة بحجم ثابت ولون خلفية -حيث يمكنك النظر في فقرة ضغطات الفأرة لتكون مرجعًا لك-، وأنشئ مجموعةً من هذه العناصر واعرضها عند تحرك المؤشر لتكون في عقبه مباشرةً.

لديك عدة طرق تحل بها هذا التدريب، والأمر إليك إذا شئت جعل الحل سهلًا أو صعبًا؛ فالحل السهل هو أن تجعل عددًا ثابتًا من العناصر وتجعلها في دورة لتحرك العنصر التالي إلى الموضع الحالي للفأرة في كل مرة يقع حدث "mousemove".

تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.

<style>
  .trail { /* className for the trail elements */
    position: absolute;
    height: 6px; width: 6px;
    border-radius: 3px;
    background: teal;
  }
  body {
    height: 300px;
  }
</style>

<script>
  // ضع شيفرتك هنا.
</script>

إرشادات للحل

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

يمكن تنفيذ الدورة عليها بتوفير متغير عدّاد وإضافة 1 إليه في كل مرة ينطلق فيها حدث "mousemove"، بعدها يمكن استخدام عامل ‎% elements.length للحصول على فهرس مصفوفة صالحة لاختيار العنصر الذي تريد موضعته خلال الحدث الذي لديك.

تستطيع أيضًا تحقيق تأثير جميل عبر نمذجة نظام فيزيائي بسيط باستخدام حدث "mousemove" لتحديث زوج من الرابطات التي تتبع موضع الفأرة، ثم استخدام requestAnimationFrame لمحاكاة العناصر اللاحقة التي تنجذب إلى موضع مؤشر الفأرة. حدِّث موضعها في كل خطوة تحريك وفقًا لموضعها النسبي للمؤشر وربما سرعتها أيضًا إذا خزنتها لكل عنصر، وسنترك لك التفكير في طريقة جيدة لفعل ذلك.

التبويبات Tabs

تُستخدم اللوحات المبوَّبة في واجهات المستخدِم بكثرة، إذ تسمح لك باختيار لوحة من خلال اختيار التبويب الذي في رأسها، وفي هذا التدريب ستنفذ واجهةً مبوبةً بسيطةً.

اكتب الدالة asTabs التي تأخذ عقدة DOM وتنشئ واجهةً مبوبةً تعرض العناصر الفرعية من تلك العقدة، ويجب إدخال قائمة من عناصر <buttons> في أعلى العقدة، بحيث يكون عندك واحد لكل عنصر فرعي، كما يحتوي على نص يأتي من سمة data-tabname للفرع.

يجب أن تكون كل العناصر الفرعية الأصلية مخفيةً عدا واحدًا منها -أي تعطيها قيمة none لنمط display-، كما يمكن اختيار العقدة المرئية الآن عبر النقر على الأزرار.

وسِّع ذلك إذا نجح معك لتنشئ نمطًا لزر التبويب المختار يختلف عما حوله ليُعلم أي تبويب تم اختياره.

<tab-panel>
  <div data-tabname="one">التبويب الأول</div>
  <div data-tabname="two">التبويب الثاني</div>
  <div data-tabname="three">التبويب الثالث</div>
</tab-panel>
<script>
  function asTabs(node) {
    // ضع شيفرتك هنا.
  }
  asTabs(document.querySelector("tab-panel"));
</script>

إرشادات الحل

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

قد تبدأ ببناء مصفوفة تبويبات كي يكون لديك وصول سهل لها، وتستطيع تخزين الكائنات التي تحتوي كلًا من لوحة التبويب والزر الخاص بها لتنفيذ التنسيق styling الخاص بالأزرار، كما ننصحك بكتابة دالة منفصلة لتغيير التبويبات؛ فإما تخزين التبويب المختار سابقًا وتغيير الأنماط المطلوب إخفاؤها وعرض الجديدة فقط، أو تحديث نمط جميع التبويبات في كل مرة يُختار تبويب جديد فيها.

قد تريد استدعاء هذه الدالة فورًا لتبدأ الواجهة مع أول تبويب مرئي.

ترجمة -بتصرف- للفصل الخامس عشر من كتاب Elequent Javascript لصاحبه Marijn Haverbeke.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...