فهم Event Bubbling وEvent Capturing في JavaScript وReact: دليل عملي للمبتدئين

دقائق القراءة: 11

يُعد فهم آلية انتشار الأحداث في الواجهة الأمامية من الأساسيات المهمة لكل مطور يعمل باستخدام JavaScript أو React. فعند النقر على زر داخل عنصر أب، قد تلاحظ أن أكثر من معالج حدث يتم تشغيله في الوقت نفسه. هنا يظهر مفهوما Event Bubbling وEvent Capturing، وهما جزءان أساسيان من سلوك المتصفح عند التعامل مع الأحداث.

في هذا الدليل العملي، ستتعرف إلى كيفية عمل Event Propagation، والفرق بين تطبيقه في JavaScript وReact، وكيفية التحكم فيه بطرق صحيحة تمنع الأخطاء وتُحسّن تجربة المستخدم.

شرح تقني لآلية انتشار الأحداث Event Bubbling وEvent Capturing في JavaScript وReact

ما هو Event Delegation ولماذا يُعد مهماً؟

Event Delegation هو أسلوب برمجي ذكي في JavaScript يهدف إلى إدارة الأحداث بكفاءة أعلى. بدلاً من ربط event listener بكل عنصر فرعي على حدة، يمكن ربط مستمع واحد بالعنصر الأب، ثم تحديد العنصر الذي أطلق الحدث فعلياً.

أهم مزايا Event Delegation

  • تقليل عدد event listeners داخل الصفحة.
  • تحسين الأداء، خصوصاً عند التعامل مع عدد كبير من العناصر.
  • تسهيل إدارة العناصر الديناميكية التي تُضاف لاحقاً إلى DOM.

القيود التي يجب الانتباه لها

  • قد يتم تشغيل معالجات أحداث غير مقصودة على العناصر الأب بسبب Event Bubbling.
  • في بعض الحالات، تحتاج إلى استخدام stopPropagation() أو تقنيات مشابهة للتحكم في تدفق الحدث.

يعتمد هذا النمط بشكل مباشر على مفهومي Bubbling وCapturing، لذلك من الضروري فهمهما أولاً.

ما هو Event Bubbling في JavaScript وReact؟

يشير Event Bubbling إلى انتقال الحدث من العنصر الذي وقع عليه التفاعل إلى العناصر الأعلى منه في شجرة DOM. بمعنى آخر: إذا ضغط المستخدم على زر داخل div، فقد يتم أولاً تشغيل معالج الزر، ثم معالج العنصر الأب.

لنفترض المثال التالي في React:

import React, { Component } from "react";

class Molly extends Component {
  handleCallFamilyToEat() {
    console.log("Hey fam! Food's ready!");
  }

  handleCookEggs() {
    console.log("Molly is cooking fluffy eggs...");
  }

  handleMakeRice() {
    console.log("Molly is making some delicious jasmine rice...");
  }

  handleMixChicken() {
    console.log("Molly is mixing chicken with some yummy spicy sauce!");
  }

  render() {
    return (
      <div className="im-a-parent" onClick={this.handleCallFamilyToEat}>
        <button className="im-a-child" onClick={this.handleCookEggs}>
          Cook Eggs
        </button>
        <button className="im-a-child" onClick={this.handleMakeRice}>
          Make Rice
        </button>
        <button className="im-a-child" onClick={this.handleMixChicken}>
          Mix Chicken
        </button>
      </div>
    );
  }
}

export default Molly;

عند الضغط على أي زر، سيتم تنفيذ معالج الزر أولاً، ثم معالج div الأب. هذا السلوك هو التطبيق العملي لفكرة Event Bubbling.

كيف يعمل Event Propagation في JavaScript؟

يعتمد انتشار الأحداث في JavaScript على ثلاث مراحل رئيسية:

  1. Capturing Phase: ينتقل الحدث من أعلى الشجرة مثل window وdocument نزولاً نحو العنصر المستهدف.
  2. Target Phase: يصل الحدث إلى العنصر الذي تسبب فعلياً في الإجراء.
  3. Bubbling Phase: يبدأ الحدث بالصعود من العنصر المستهدف إلى العناصر الأب.

مخطط يوضح مراحل Event Propagation في JavaScript من Capturing إلى Target ثم Bubbling

مثال على ترتيب الانتشار داخل DOM

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
  </head>
  <body>
    <div id="root">
      <table>
        <tbody>
          <tr>
            <td>Shady Grove</td>
            <td>Aeolian</td>
          </tr>
          <tr>
            <td>Over the River, Charlie</td>
            <td>Dorian</td>
          </tr>
        </tbody>
      </table>
    </div>
  </body>
</html>

إذا ضغط المستخدم على عنصر td، فإن الحدث يمر عبر العناصر الأعلى حتى يصل إلى الهدف، ثم يصعد مرة أخرى في مرحلة Bubbling.

رسم يوضح مسار انتشار الحدث داخل عناصر HTML عند النقر على عنصر td

كيف يختلف Event Bubbling في React عن JavaScript التقليدية؟

تعتمد React على كائن يُسمى SyntheticEvent، وهو غلاف موحّد لأحداث المتصفح. الهدف منه توحيد السلوك بين المتصفحات المختلفة وتقديم واجهة موثوقة للمطور.

لماذا تستخدم React كائن SyntheticEvent؟

  • تحقيق التوافق بين المتصفحات.
  • تسهيل التعامل مع الخصائص والدوال مثل stopPropagation() وpreventDefault().
  • إدارة الأحداث مركزياً بطريقة أكثر كفاءة.

في React، يتم التركيز افتراضياً على مرحلة Bubbling، وليس Capturing. لذلك غالباً ما يُنفذ معالج الحدث على العنصر المستهدف أولاً، ثم يصعد الحدث إلى العناصر الأب.

تفعيل Capturing في React

إذا أردت استخدام مرحلة Capturing في React، يمكنك استعمال onClickCapture بدلاً من onClick:

import React, { Component } from "react";

class Molly extends Component {
  ...

  render() {
    return (
      <div className="im-a-parent" onClickCapture={this.handleCallFamilyToEat}>
        <button className="im-a-child" onClick={this.handleCookEggs}>
          Cook Eggs
        </button>
        <button className="im-a-child" onClick={this.handleMakeRice}>
          Make Rice
        </button>
        <button className="im-a-child" onClick={this.handleMixChicken}>
          Mix Chicken
        </button>
      </div>
    );
  }
}

export default Molly;

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

فرق مهم بين React 16 وReact 17+

في الإصدارات الأقدم مثل React 16 وما قبلها، كانت event listeners تُدار بطريقة تجعل الانتشار يصل حتى document. أما في React 17+ فأصبح ربط الأحداث محصوراً أكثر عند root element.

مقارنة بين آلية ربط الأحداث في React 16 وReact 17 وتأثيرها على Event Bubbling

كيفية إيقاف Event Bubbling داخل المكونات

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

استخدام event.stopPropagation()

تعمل هذه الدالة على منع انتقال الحدث إلى العناصر الأب.

import React, { Component } from "react";

class Molly extends Component {
  handleCallFamilyToEat() {
    console.log("Hey fam! Food's ready!");
  }

  handleCookEggs(event) {
    event.stopPropagation();
    console.log("Molly is cooking fluffy eggs...");
  }

  handleMakeRice() {
    console.log("Molly is making some delicious jasmine rice...");
  }

  handleMixChicken() {
    console.log("Molly is mixing chicken with some yummy spicy sauce!");
  }

  render() {
    return (
      <div className="im-a-parent" onClick={this.handleCallFamilyToEat}>
        <button className="im-a-child" onClick={this.handleCookEggs}>
          Cook Eggs
        </button>
        <button className="im-a-child" onClick={this.handleMakeRice}>
          Make Rice
        </button>
        <button className="im-a-child" onClick={this.handleMixChicken}>
          Mix Chicken
        </button>
      </div>
    );
  }
}

export default Molly;

استخدام event.stopImmediatePropagation()

إذا كان العنصر نفسه يحتوي على أكثر من معالج حدث، فإن stopPropagation() لن يمنع تشغيل المعالجات الأخرى على العنصر نفسه. في هذه الحالة، استخدم stopImmediatePropagation() لمنع:

  • انتقال الحدث إلى العناصر الأب.
  • تشغيل باقي المعالجات المرتبطة بنفس العنصر.

استخدام event.preventDefault()

هذه الدالة لا توقف الانتشار، لكنها تمنع السلوك الافتراضي للعنصر، مثل:

  • منع إعادة تحميل الصفحة عند إرسال form.
  • تعطيل الانتقال الافتراضي في الروابط عند بناء مسارات مخصصة.

الفرق بين event.target وevent.currentTarget

يُعد التفريق بين event.target وevent.currentTarget من أهم النقاط لفهم انتشار الأحداث بشكل صحيح.

  • event.target: العنصر الفعلي الذي أطلق الحدث.
  • event.currentTarget: العنصر الذي تم ربط event listener به.

مثال داخل العنصر الأب

import React, { Component } from "react";

class Molly extends Component {
  handleCallFamilyToEat(event) {
    console.log("Hey fam! Food's ready!");
    console.log("event.target:", event.target);
    console.log("event.currentTarget", event.currentTarget);
  }

  ...

  render() {
    return (
      <div className="im-a-parent" onClick={this.handleCallFamilyToEat}>
        <button className="im-a-child" onClick={this.handleCookEggs}>
          Cook Eggs
        </button>
        <button className="im-a-child" onClick={this.handleMakeRice}>
          Make Rice
        </button>
        <button className="im-a-child" onClick={this.handleMixChicken}>
          Mix Chicken
        </button>
      </div>
    );
  }
}

export default Molly;

توضيح الفرق بين event.target وevent.currentTarget داخل معالج الحدث للعنصر الأب في React

مثال داخل الزر نفسه

import React, { Component } from "react";

class Molly extends Component {
  handleCallFamilyToEat(event) {
    console.log("Hey fam! Food's ready!");
  }

  handleCookEggs(event) {
    console.log("Molly is cooking fluffy eggs...");
    console.log("event.target:", event.target);
    console.log("event.currentTarget", event.currentTarget);
  }

  ...

  render() {
    return (
      <div className="im-a-parent" onClick={this.handleCallFamilyToEat}>
        <button className="im-a-child" onClick={this.handleCookEggs}>
          Cook Eggs
        </button>
        <button className="im-a-child" onClick={this.handleMakeRice}>
          Make Rice
        </button>
        <button className="im-a-child" onClick={this.handleMixChicken}>
          Mix Chicken
        </button>
      </div>
    );
  }
}

export default Molly;

مثال عملي يوضح تطابق event.target وevent.currentTarget داخل الزر نفسه في React

إذا كنت داخل معالج العنصر الأب، فقد يكون target هو الزر، بينما currentTarget هو div. أما إذا كنت داخل معالج الزر نفسه، فعادة يتطابق الاثنان.

ترتيب تشغيل الأحداث وuseCapture في JavaScript

عند استخدام addEventListener في JavaScript، يمكنك تحديد ما إذا كنت تريد تشغيل المعالج أثناء مرحلة Capturing:

// So you can do this:
yourElement.addEventListener(type, listener, { capture: true });

// or this:
yourElement.addEventListener(type, listener, useCapture: true);

إذا لم يتم تفعيل capture صراحةً، فسيتم تجاهل مرحلة Capturing غالباً، ويبدأ التعامل العملي أثناء Target ثم Bubbling.

متى تستخدم useCapture؟

  • عندما تريد اعتراض الحدث قبل وصوله إلى العناصر الداخلية.
  • عندما تبني نظاماً معقداً للأحداث وتحتاج إلى تحكم أعمق في ترتيب التنفيذ.
  • عند معالجة حالات خاصة في عناصر متداخلة كثيرة.

أحداث لا تدعم Bubbling في JavaScript

ليست كل الأحداث تنتقل صعوداً في DOM. هناك أحداث معروفة لا تقوم بعملية Bubbling افتراضياً، مثل:

  • blur بينما focusout يدعم Bubbling.
  • focus بينما focusin يدعم Bubbling.
  • mouseleave بينما mouseout يدعم Bubbling.
  • mouseenter بينما mouseover يدعم Bubbling.
  • load وunload وabort وerror وbeforeunload.

مع ذلك، قد تمر بعض هذه الأحداث بمرحلة Capturing حتى لو لم تكن تدعم Bubbling.

اختلافات مهمة بين React 16 وReact 17+ في إدارة الأحداث

بعض الأحداث تتصرف بشكل مختلف في React

في React، بعض الأحداث التي لا تتسبب في Bubbling في JavaScript التقليدية قد تستمر في الصعود داخل نظام SyntheticEvent. من الأمثلة الشائعة: onFocus وonBlur.

ومع React 17+ تغيّر سلوك بعض الأحداث مثل onScroll، الذي لم يعد يدعم Bubbling بالطريقة السابقة.

إلغاء Event Pooling في React 17

في الإصدارات القديمة من React، كانت كائنات SyntheticEvent تُعاد إلى ما يشبه المخزن المؤقت بعد انتهاء المعالجة، وهو ما كان يجعل الوصول إلى خصائص مثل event.target.value داخل الدوال غير المتزامنة أمراً مربكاً.

شرح بصري لمفهوم Event Pooling في الإصدارات القديمة من React

وكان الحل سابقاً هو استخدام event.persist():

مثال يوضح استخدام event.persist للحفاظ على بيانات الحدث في React القديم

لكن ابتداءً من React 17 تم إلغاء هذا السلوك، وأصبح بالإمكان استخدام بيانات الحدث داخل الدوال غير المتزامنة دون الحاجة إلى event.persist().

حالة عملية خاصة: ماذا لو أردت تشغيل العنصر الأب الخارجي أيضاً؟

في بعض الواجهات، لا يكفي إيقاف انتشار الحدث بالكامل. قد ترغب في سلوك مزدوج، مثل:

  • عند النقر على عنصر داخلي، يتم تنفيذ حدثه فقط.
  • عند النقر على المنطقة الخارجية، يتم تنفيذ حدث مختلف يخص العنصر الأب.

هذا النمط شائع في النوافذ المنبثقة modals أو البطاقات التفاعلية.

حل باستخدام React Class Component

import React, { Fragment, Component } from "react";
import "./TV.css" // you can ignore this since this won't exist on your end

class TV extends Component {
  state = { channel: 1, shouldTurnOffTV: false };

  // the parent div triggered if TV is turned OFF
  // clicking change channel or turning off TV won't trigger at the same time
  // because of event.stopPropagation() here
  handleTurnOnTV = (event) => {
    console.log("In HandleTurnOnTV");
    const { shouldTurnOffTV } = this.state;

    if (shouldTurnOffTV) {
      event.stopPropagation();
      this.setState({ shouldTurnOffTV: false, channel: 1 });
    }
  }

  // the child change channel button triggered if TV is turned ON
  // clicking the parent div, or turning off TV won't trigger at the same time
  // because of event.stopPropagation() here
  handleChangeChannel = (event) => {
    console.log("In HandleChangeChannel");
    const { channel, shouldTurnOffTV } = this.state;

    if (!shouldTurnOffTV) {
      event.stopPropagation();
      this.setState({ channel: channel + 1 });
    }
  }

  // the turn off TV button is triggered
  // clicking the parent div or changing the channel won't trigger at the same time
  // because of event.stopPropagation() here
  handleTurnOffTV = (event) => {
    console.log("In HandleTurnOffTV");
    event.stopPropagation();
    this.setState({ shouldTurnOffTV: true });
  }

  renderChannel = () => {
    const { channel, shouldTurnOffTV } = this.state;

    if (shouldTurnOffTV) {
      return (<div>That's it, no more TV time!</div>)
    }

    return (
      <Fragment>
        <div>Current Channel: {channel}</div>
        <button className="im-a-child-button" onClick={this.handleTurnOffTV}>
          Turn Off TV
        </button>
      </Fragment>
    )
  }

  render() {
    const { shouldTurnOffTV } = this.state;

    return (
      <div className="im-a-parent" onClick={this.handleTurnOnTV}>
        {this.renderChannel()}
        <hr />
        <button
          disabled={shouldTurnOffTV}
          className="im-a-child-button"
          onClick={this.handleChangeChannel}
        >
          Change Channel
        </button>
      </div>
    );
  }
}

export default TV;

الحل نفسه باستخدام React Hooks

import React, { Fragment, useState } from "react";
import "./TV.css" // you can ignore this since this won't exist on your end

const TV = () => {
  const [channel, setChannel] = useState(1);
  const [shouldTurnOffTV, setTurnOffTV] = useState(false);

  // the parent div triggered if TV is turned OFF
  // clicking change channel or turning off TV won't trigger at the same time
  // because of event.stopPropagation() here
  const handleTurnOnTV = (event) => {
    console.log("In HandleTurnOnTV");

    if (shouldTurnOffTV) {
      event.stopPropagation();
      setTurnOffTV(false);
      setChannel(1);
    }
  }

  // the child change channel button triggered if TV is turned ON
  // clicking the parent div, or turning off TV won't trigger at the same time
  // because of event.stopPropagation() here
  const handleChangeChannel = (event) => {
    console.log("In HandleChangeChannel");

    if (!shouldTurnOffTV) {
      event.stopPropagation();
      setChannel(channel + 1);
    }
  }

  // the turn off TV button is triggered
  // clicking the parent div or changing the channel won't trigger at the same time
  // because of event.stopPropagation() here
  const handleTurnOffTV = (event) => {
    console.log("In HandleTurnOffTV");
    event.stopPropagation();
    setTurnOffTV(true);
  }

  const renderChannel = () => {
    if (shouldTurnOffTV) {
      return (<div>That's it, no more TV time!</div>)
    }

    return (
      <Fragment>
        <div>Current Channel: {channel}</div>
        <button className="im-a-child-button" onClick={handleTurnOffTV}>
          Turn Off TV
        </button>
      </Fragment>
    )
  }

  return (
    <div className="im-a-parent" onClick={handleTurnOnTV}>
      {renderChannel()}
      <hr />
      <button
        disabled={shouldTurnOffTV}
        className="im-a-child-button"
        onClick={handleChangeChannel}
      >
        Change Channel
      </button>
    </div>
  );
}

export default TV;

الفكرة هنا هي استخدام state للتحكم في السيناريو الذي يجب أن يعمل فيه كل حدث، بدلاً من منع الانتشار بشكل أعمى في كل الحالات.

لقطة توضح كيفية تحويل state في React إلى constructor بواسطة Babel

حل أبسط للتحكم في الانتشار

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

...
const Modal = ({ header, content, cancelButtonText, confirmButtonText, history, handleConfirm }) => {
  const handleCancel = (event) => {
    stopEventPropagationTry(event);
    // do something here
  }

  const handleConfirmButton = (event) => {
    stopEventPropagationTry(event);
    // do something here
  }

  // so elements with multiple event handlers aren't unnecessarily
  // called more than once(ie. SyntheticEvent Bubbling)
  export const stopEventPropagationTry = (event) => {
    if (event.target === event.currentTarget) {
      event.stopPropagation();
    }
  }

  return createPortal(
    <div onClick={handleCancel} className="ui dimmer modals visible active">
      <div className="ui tiny modal visible active">
        <div className="header">{header}</div>
        <div className="content">{content}</div>
        <div className="actions">
          <button onClick={handleCancel} className="ui button">
            {cancelButtonText}
          </button>
          <button onClick={handleConfirmButton} className="ui red button">
            {confirmButtonText}
          </button>
        </div>
      </div>
    </div>,
    document.getElementById("modal")
  );
}

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

نقاط عملية يجب تذكرها عند التعامل مع الأحداث

  • ليس كل حدث في JavaScript يدعم Bubbling.
  • React لا يطابق دائماً السلوك الأصلي للمتصفح بشكل حرفي.
  • استخدام stopPropagation() يجب أن يكون مدروساً، لأنه قد يمنع سلوكاً مشروعاً تحتاجه لاحقاً.
  • افهم دائماً الفرق بين target وcurrentTarget قبل تصحيح أي مشكلة.
  • اختيار onClick أو onClickCapture يعتمد على المرحلة التي تريد التعامل فيها مع الحدث.

الخلاصة التقنية

فهم Event Bubbling وEvent Capturing ليس مجرد موضوع نظري، بل هو عنصر أساسي لبناء واجهات موثوقة وقابلة للصيانة في JavaScript وReact. من الناحية العملية، كلما أتقنت مسار الحدث بين العناصر، أصبحت أكثر قدرة على معالجة الأخطاء المنطقية في الواجهات التفاعلية، وتقليل السلوك غير المتوقع، وتحسين تجربة المستخدم. تقنياً، أفضل نهج ليس إيقاف الانتشار دائماً، بل فهم سبب حدوثه ثم اختيار الأداة المناسبة: stopPropagation() أو onClickCapture أو فحص event.target وevent.currentTarget أو حتى إعادة تصميم تدفق الحالة داخل المكون.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *