هنگامی که ما از زبان برنامه نویسی جاوااسکریپت برای برنامه‌های خود استفاده می‌کنیم تقریبا در هر پروژه‌ای با event listenerها در ارتباط هستیم. این event listenerها در ابتدا ساده به نظر می‌رسند اما تعداد زیادی ویژگی کم‌تر شناخته شده مانند bubbling، capture، delegation و غیره دارند که ممکن است کمی پیچیده‌ باشد. درک این ویژگی‌ها نقش مهمی برای تبدیل شدن به یک توسعه‌دهنده متخصص جاوااسکریپت دارند. در این مقاله سعی داریم تا آن‌ها را باهم بررسی کنیم.

مفاهیم اولیه Event Listenerها

event listener در جاوااسکریپت راهی است که به کمک آن می‌توانیم باعث ایجاد تعامل با کاربر شویم و هر زمان که آن عمل انجام شد، کدی را اجرا کنیم. یکی از موارد استفاده رایج برای event listenerها کلیک روی یک دکمه است.

const button = document.querySelector("button")
button.addEventListener("click", e => {
  console.log(e)
})

برای تنظیم یک event listener باید یک متغیر داشته باشیم که به یک المنت ارجاع دارد. سپس متد addEventListenerرا در آن المنت فراخوانی می‌کنیم. این تابع حداقل دو پارامتر را دریافت می‌کند.

پارامتر اول یک رشته است و نام eventای که می‌خواهیم از آن استفاده کنیم را مشخص می‌کند. صدها event وجود دارد که می‌توانیم آن‌ها را در پروژه‌های خود به کار بگیریم مانند click، inputو mousemove. در این لینک لیست کاملی از همه eventها وجود دارد اما ما فقط به تعداد انگشت‌شماری از آن‌ها نیاز خواهیم داشت. بنابراین لازم نیست تا آن‌ها را به خاطر بسپاریم.

پارامتر دوم تابعی است که یک آرگومان دارد و آن آرگومان event می‌باشد و معمولا eنامیده می‌شود. این تابع هر بار که event رخ می‌دهد فراخوانی می‌شود و آبجکت event حاوی اطلاعات مربوط به آن است. بسته به اینکه از چه eventای استفاده می‌کنیم آبجکت آن دارای ویژگی‌های متفاوتی خواهد بود که همگی آن‌ها مهم هستند اما تقریباً هر eventای دارای یک ویژگی targetمی‌باشد. این ویژگی نشان‌دهنده المنتی است که event روی آن رخ می‌دهد که برای استفاده‌های پیشرفته‌تر از event listener ها مهم است که در ادامه مقاله به آن خواهیم پرداخت.

باید به این موضوع مهم توجه داشته باشیم که اگر چندین event listener روی یک المنت برای یک event داشته باشیم، همه آن‌ها به ترتیبی که به المنت اضافه شده‌اند، فعال می‌گردند.

button.addEventListener("click", e => {
  console.log("This runs first")
})

button.addEventListener("click", e => {
  console.log("This runs second")
})

انتشار Event

تا این قسمت با مفاهیم اولیه مربوط به event listenerها آشنا شدیم اما وقتی شروع به ایجاد پروژه‌های پیشرفته‌تر می‌کنیم، باید بدانیم این eventها چگونه راه‌اندازی می‌شوند و چگونه از طریق DOM منتشر می‌گردند. اینجاست که مراحل bubble و capture مطرح می‌شوند.

تصور کنید کد HTML و جاوااسکریپت زیر را داریم:

<div class="parent">
  <div class="child"></div>
</div>
parent.addEventListener("click", () => {
  console.log("Parent")
})

child.addEventListener("click", () => {
  console.log("Child")
})

اگر روی المنت child کلیک کنیم، احتمالا فکر می‌کنید که در کنسول child را ثبت می‌کند، اما در واقع child و parentرا به ترتیب وارد می‌کند. دلیل این امر bubbling  می‌باشد.

فاز Bubble

هنگامی که یک event بر روی المنتی فعال می‌شود آن event در درخت document بر روی تمام المنت‌هایی که المنت مورد نظر ما در داخل آن‌ها قرار دارد، اعمال می‌شود. در مثالی که داریم وقتی روی child کلیک می‌کنیم، event listener کلیک روی المنت parent نیز فعال می‌شود، زیرا child داخل المنت parent است. این حتی یک گام فراتر می‌رود و event listener کلیک را در خود document نیز فعال می‌کند. ما از این موضوع هنگام مواجه شدن با مجموعه eventها استفاده خواهیم کرد.

فاز Capture

همه مفاهیمی که در بخش قبلی با آن‌ها آشنا شدیم مربوط به فاز bubble است که یک فاز پیش‌فرض به‌شمار می‌آید و event listenerها در آن ایجاد می‌شوند. اما eventها فاز دیگری به نام فاز capture دارند که ابتدا اتفاق می‌افتد. فاز capture درست مانند فاز bubble است، اما event از المنت سطح بالا، که در مثال ما document  است شروع می‌شود و به سمت المنت‌های داخلی حرکت می‌کند. این بدان معناست که اگر در مثالی که داریم روی المنت child کلیک کنیم، event listener را برای document ، سپس parent، سپس child فعال می‌کنیم. پس از آن وارد فاز bubble می‌شویم و در آن event listenerها را برای child، سپس parent و سپس document  فعال می‌کنیم.

تاکنون ما فقط نحوه تنظیم event listenerها برای فاز bubble را بررسی کردیم. اگر بخواهیم درمورد capture صحبت کنیم باید با پارامتر سوم متد addEventListenerآشنا شویم.

این پارامتر سوم یک آبجکت است که دارای ویژگی captureمی‌باشد که وقتی روی true تنظیم شود event مورد نظر را به عنوان یک capture event برچسب‌گذاری می‌کند.

parent.addEventListener("click", () => {
  console.log("Parent Bubble")
})

parent.addEventListener("click", () => {
  console.log("Parent Capture")
}, { capture: true })

child.addEventListener("click", () => {
  console.log("Child Bubble")
})

child.addEventListener("click", () => {
  console.log("Child Capture")
}, { capture: true })

کد بالا را داریم. اکنون اگر روی المنت child کلیک کنیم خروجی زیر را خواهیم داشت:

Parent Capture
Child Capture
Child Bubble
Parent Bubble

توقف انتشار Event

شاید داشتن این دو فاز از eventها عجیب به نظر برسد اما دلیل آن این است که بتوانیم به همان ترتیبی که نیاز داریم به eventها پاسخ دهیم. یکی از موارد استفاده رایج برای فاز capture این است که یک event را قبل از اینکه به childها برسد متوقف کنیم.

parent.addEventListener("click", e => {
  console.log("Parent Capture")
  e.stopPropagation()
}, { capture: true })

child.addEventListener("click", () => {
  console.log("Child Bubble")
})

با استفاده از متد stopPropagationبر روی آبجکت event، می‌توانیم event را از ادامه انجام فازهایی که درمورد آن‌ها صحبت کردیم بازداریم. به این معنی که اگر event listenerهای دیگری در زنجیره وجود داشته باشد که باید ایجاد شوند، آن‌ها را اجرا نمی‌کند. در مثال بالا، فقط Parent Captureدر کنسول نمایش داده می‌شود، زیرا ما از انتشار event پس از capture event listener مربوط به parent جلوگیری می‌کنیم.

روش دیگر استفاده از stopImmediatePropagationروی آبجکت event است که کمی متفاوت می‌باشد. اگر از متد stopImmediatePropagationاستفاده کنیم، در این صورت event نه تنها انتشار به المنت‌های child و یا parent را از طریق فازهای bubble و Capture متوقف خواهد کرد بلکه از فعال شدن سایر eventها بر روی المنت مورد نظر نیز جلوگیری می‌کند.

parent.addEventListener("click", e => {
  console.log("Parent Capture 1")
  e.stopImmediatePropagation()
}, { capture: true })

parent.addEventListener("click", e => {
  console.log("Parent Capture 2")
}, { capture: true })

child.addEventListener("click", () => {
  console.log("Child Bubble")
})

در مثال بالا، انتشار را در اولین capture event listener مربوط به parent متوقف کردیم بنابراین از انتشار event به سایر المنت‌ها از طریق فازهای bubble و Capture جلوگیری خواهد شد. همچنین به دلیل این که از متد stopImmediatePropagationاستفاده کردیم، سایر event listenerهای کلیک روی المنت parent نیز فعال نمی‌شوند. نکته مهمی که باید به آن توجه داشته باشیم این است که event listenerها در همان المنت به ترتیبی که تعریف شده‌اند فعال می‌شوند. بنابراین اگر می‌خواهیم با این روش از فعال شدن سایر event listenerها جلوگیری کنیم باید آن‌ها را بعد از listenerای که انتشار را متوقف می‌کند، تعریف کنیم.

نکات مهم درمورد Bubbling

یکی از نکات مهمی که باید در مورد فاز bubbling مربوط به eventها بدانیم این است که همه آن‌ها وارد این فاز نمی‌شوند. Eventهایی مانند focusکه با فوکوس کردن روی یک المنت فعال می‌شود، وارد فاز bubble نمی‌شود.

حذف Event Listenerها

تا این قسمت مقاله با نحوه اضافه کردن event listenerها آشنا شدیم اما در نهایت باید بتوانیم listenerهایی که اضافه می‌کنیم را حذف کنیم. ساده‌ترین راه برای انجام این کار استفاده از متد removeEventListenerاست، اما دو راه پیشرفته‌تر نیز برای انجام این کار وجود دارد که در مورد آن‌ها نیز صحبت خواهیم کرد.

removeEventListener

متد removeEventListenerیک تابع ساده است که می‌توانیم روی یک المنت فراخوانی کرده و event listenerای را که قبلاً با استفاده از متد addEventListenerاضافه کرده بودیم حذف نماییم.

button.addEventListener("click", sayHi)
button.removeEventListener("click", sayHi)

function sayHi() {
  console.log("Hi")
}

کد بالا با یک کلیک listenerای اضافه می‌کند که تابع sayHiرا فراخوانی کرده و بلافاصله آن را حذف می‌کند. توجه به این نکته مهم است که هنگام افزودن و یا حذف event listenerها باید مطمئن شویم که عملکرد آن‌ها دقیقاً یکسان باشد. اگر بخواهیم کد خود را به صورت زیر بنویسیم، در واقع event listener را حذف نمی‌کنیم، زیرا این دو تابع متفاوت هستند حتی اگر کد مشابهی داشته باشند.

button.addEventListener("click", () => {
  console.log("Hi")
})

button.removeEventListener("click", () => {
  console.log("Hi")
})

اجرای Eventها برای یک بار

گاهی مواقع لازم است eventای فقط برای یک بار اجرا شود. برای انجام این کار می‌توانیم از removeEventListenerاستفاده کنیم ولی نتیجه آن همیشه دقیق نیست و ممکن است کدی که داریم دچار مشکل شود. به همین دلیل است که پارامتر سوم برای addEventListenerدارای ویژگی به نام onceاست که وقتی روی true تنظیم شود اطمینان حاصل می‌کند که event listener ما فقط یک بار اجرا می‌شود.

button.addEventListener("click", () => {
  console.log("Clicked")
}, { once: true })

در مثال بالا مهم نیست که چند بار روی دکمه کلیک می‌کنیم، Clickedفقط یک بار در کنسول نمایش داده می‌شود زیرا event listener پس از یک بار اجرا به طور خودکار حذف خواهد شد.

توقف Event Listenerها

آخرین راه برای حذف event listenerها استفاده از AbortControllerاست که کم‌تر رایج بوده اما می‌تواند بسیار مفید باشد. اگر می‌خواهیم event listenerای ایجاد کنیم که تا زمان برآورده شدن یک شراط خاص کار کند این ممکن است گزینه مناسبی به‌شمار بیاید.

let count = 0
const controller = new AbortController()

button.addEventListener("click", () => {
  count++
  console.log(count)
  if (count >= 3) {
    controller.abort()
  }
}, { signal: controller.signal })

کد بالا ممکن است کمی گیج‌کننده به نظر برسد اما مرحله به مرحله آن را بررسی خواهیم کرد. ابتدا یک AbortControllerجدید ایجاد می‌کنیم. سپس ویژگی signalمربوط به AbortController را به ویژگی signal متد addEventListenerخود منتقل می‌کنیم. این کار event listener ما را به آن AbortController متصل می‌کند بنابراین اگر آن را قطع کنیم، event listenerای که داریم حذف می‌شود. در نهایت متد abortرا در AbortController فراخوانی می‌کنیم که تعداد آن بیشتر یا مساوی ۳ باشد تا event listener را حذف کند.

اساساً روش کار یک AbortController به این صورت است ما ویژگی signal را به تابع addEventListenerمی‌دهیم و سپس در هر زمانی که abort را در AbortController فراخوانی کنیم می‌توانیم event listener را حذف کنیم.

Event Delegation

تمام مفاهیمی که تاکنون درمورد آن‌ها صحبت کردیم به موضوع نهایی این مقاله که در مورد event delegation است، منتهی می‌شود. درک نحوه عملکرد این مفهوم بسیار مهم است.

const buttons = document.querySelectorAll("button")
buttons.forEach(button => {
  button.addEventListener("click", () => {
    console.log("Clicked Button")
  })
})

const newButton = document.createElement("button")
document.body.append(newButton)

در کد بالا، ما همه دکمه‌هایی که در صفحه وجود دارد را انتخاب می‌کنیم و یک event listener مربوط به کلیک اضافه می‌کنیم. پس از آن یک دکمه جدید به صفحه اضافه می‌کنیم. این دکمه جدید به هیچ event listener کلیکی متصل نیست زیرا پس از اضافه شدن event listener به صفحه افزوده شده است. این یک اشتباه رایج است که ممکن است توسعه‌دهندگان تازه‌کار مرتکب آن می‌شوند. زیرا فکر می‌کنند این دکمه جدید نیز event listener را خواهد داشت، اما این چنین نیست. برای حل این مشکل باید هر بار که المنت‌های جدید ایجاد می‌شوند event listenerها را نیز به‌صورت دستی به آن‌ها اضافه کرده و یا اینکه از event delegation استفاده کنیم.

با استفاده از event delegation ما event listener را روی یک المنت parent، مانند document تنظیم می‌کنیم. سپس در داخل آن المنت parent بررسی می‌کنیم که آیا event مورد نظر ما قبل از اجرای کد توسط المنت‌هایی که برایمان مهم هستند فعال شده است یا خیر.

document.addEventListener("click", e => {
  if (e.target.matches("button")) {
    console.log("Clicked Button")
  }
})

const newButton = document.createElement("button")
document.body.append(newButton)

از آنجایی که event کلیک تا المنت‌های parent بالا می‌رود، می‌دانیم که در نهایت هر event کلیکی در صفحه ما به document راه پیدا می‌کند. سپس document را بررسی می‌‌کنیم تا ببینیم آیا هدف event با سلکتور buttonمطابقت دارد یا نه. این سلکتور که به matchesمی‌دهیم، فقط یک سلکتور CSS است، شبیه چیزی که به querySelectorارسال می‌کنیم.

با نوشتن کد خود به این صورت، اطمینان حاصل می‌کنیم که هر دکمه‌ای که در صفحه ما وجود دارد، حتی دکمه‌هایی که به تازگی اضافه شده‌اند، با کلیک کردن به درستی کار می‌کنند. با این حال اگر روی المنتی به غیر از دکمه کلیک کنیم e.target.matchesمقدار false را برمی‌گرداند، به این معنی که هیچ مقداری ثبت نخواهد شد.

به‌طور کلی زمانی که با افزودن المنت‌ها به صورت پویا سر و کار داریم، نوشتن یک event listener بر روی parent برای واگذاری آن event با رعایت معیارهای صحیح بسیار سودمند است.

function addGlobalEventListener(type, selector, callback, options) {
  document.addEventListener(type, e => {
    if (e.target.matches(selector)) callback(e)
  }, options)
}

addGlobalEventListener("click", ".btn", () => {
  console.log("Clicked Button")
}, { once: true })

جمع‌بندی

در حالی که استفاده از event listenerها ممکن است در ظاهر ساده به نظر برسند، اما در واقع عمق شگفت‌انگیزی در آن‌ها وجود دارد. درک این مفاهیم کمک می‌کند تا عمق مهارت‌ برنامه نویسی ما به خوبی ارتقا پیدا کند.