Pembahasan mendalam tentang peristiwa JavaScript

preventDefault dan stopPropagation: kapan harus menggunakan yang mana dan apa sebenarnya yang dilakukan setiap metode.

Event.stopPropagation() dan Event.preventDefault()

Penanganan peristiwa JavaScript sering kali mudah. Hal ini terutama berlaku saat berurusan dengan struktur HTML sederhana (relatif datar). Namun, situasinya akan menjadi sedikit lebih rumit saat peristiwa berpindah (atau disebarkan) melalui hierarki elemen. Biasanya, developer akan menggunakan stopPropagation() dan/atau preventDefault() untuk menyelesaikan masalah yang mereka alami. Jika Anda pernah berpikir "Saya akan mencoba preventDefault() dan jika tidak berhasil, saya akan mencoba stopPropagation() dan jika tidak berhasil, saya akan mencoba keduanya", maka artikel ini cocok untuk Anda. Saya akan menjelaskan secara persis fungsi setiap metode, kapan harus menggunakan metode yang mana, dan memberikan berbagai contoh yang berfungsi untuk Anda pelajari. Tujuan saya adalah mengakhiri kebingungan Anda untuk selamanya.

Namun, sebelum kita membahasnya lebih dalam, penting untuk membahas secara singkat dua jenis penanganan peristiwa yang mungkin dilakukan di JavaScript (di semua browser modern, yaitu—Internet Explorer sebelum versi 9 tidak mendukung pengambilan peristiwa sama sekali).

Gaya peristiwa (penangkapan dan penggelembungan)

Semua browser modern mendukung pengambilan peristiwa, tetapi jarang digunakan oleh developer. Menariknya, ini adalah satu-satunya bentuk event yang awalnya didukung oleh Netscape. Pesaing terbesar Netscape, Microsoft Internet Explorer, tidak mendukung pengambilan peristiwa sama sekali, tetapi hanya mendukung gaya peristiwa lain yang disebut bubbling peristiwa. Saat W3C dibentuk, mereka menemukan keunggulan dalam kedua gaya peristiwa dan menyatakan bahwa browser harus mendukung keduanya, melalui parameter ketiga ke metode addEventListener. Awalnya, parameter tersebut hanya berupa boolean, tetapi semua browser modern mendukung objek options sebagai parameter ketiga, yang dapat Anda gunakan untuk menentukan (antara lain) apakah Anda ingin menggunakan pengambilan peristiwa atau tidak:

someElement.addEventListener('click', myClickHandler, { capture: true | false });

Perhatikan bahwa objek options bersifat opsional, begitu juga dengan properti capture-nya. Jika salah satunya tidak ada, nilai default untuk capture adalah false, yang berarti bubbling peristiwa akan digunakan.

Perekaman peristiwa

Apa artinya jika pemroses peristiwa Anda "memproses dalam fase pengambilan?" Untuk memahaminya, kita perlu mengetahui asal dan cara pergerakan peristiwa. Berikut ini berlaku untuk semua peristiwa, meskipun Anda, sebagai developer, tidak memanfaatkannya, tidak memedulikannya, atau tidak memikirkannya.

Semua peristiwa dimulai di jendela dan pertama-tama melalui fase pengambilan. Artinya, saat peristiwa dikirim, peristiwa akan memulai jendela dan bergerak "ke bawah" menuju elemen targetnya terlebih dahulu. Hal ini terjadi meskipun Anda hanya mendengarkan dalam fase bubbling. Perhatikan contoh markup dan JavaScript berikut:

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('#C was clicked');
  },
  true,
);

Saat pengguna mengklik elemen #C, peristiwa yang berasal dari window akan dikirim. Peristiwa ini kemudian akan diteruskan melalui turunannya sebagai berikut:

window => document => <html> => <body> => dan seterusnya, hingga mencapai target.

Tidak masalah jika tidak ada yang memproses peristiwa klik pada elemen window atau document atau <html> atau <body> (atau elemen lain dalam perjalanannya menuju target). Peristiwa masih berasal dari window dan memulai perjalanannya seperti yang dijelaskan.

Dalam contoh kita, peristiwa klik kemudian akan dipropagasi (ini adalah kata penting karena akan terkait langsung dengan cara kerja metode stopPropagation() dan akan dijelaskan nanti dalam dokumen ini) dari window ke elemen targetnya (dalam hal ini, #C) melalui setiap elemen di antara window dan #C.

Artinya, peristiwa klik akan dimulai pada window dan browser akan menanyakan pertanyaan berikut:

"Apakah ada yang memproses peristiwa klik pada window dalam fase pengambilan?" Jika ya, pengendali peristiwa yang sesuai akan diaktifkan. Dalam contoh kita, tidak ada yang terjadi, sehingga tidak ada handler yang akan dipicu.

Selanjutnya, peristiwa akan berpropagasi ke document dan browser akan bertanya: "Apakah ada yang memproses peristiwa klik pada document dalam fase pengambilan?" Jika demikian, handler peristiwa yang sesuai akan diaktifkan.

Selanjutnya, peristiwa akan berpropagasi ke elemen <html> dan browser akan bertanya: "Apakah ada yang memproses klik pada elemen <html> dalam fase pengambilan?" Jika demikian, handler peristiwa yang sesuai akan diaktifkan.

Selanjutnya, peristiwa akan dipropagasi ke elemen <body> dan browser akan bertanya: "Apakah ada yang memproses peristiwa klik pada elemen <body> dalam fase pengambilan?" Jika demikian, pengendali peristiwa yang sesuai akan diaktifkan.

Selanjutnya, peristiwa akan berpropagasi ke elemen #A. Sekali lagi, browser akan bertanya: "Apakah ada yang memproses peristiwa klik pada #A dalam fase pengambilan dan jika ada, penangan peristiwa yang sesuai akan diaktifkan.

Selanjutnya, peristiwa akan berpropagasi ke elemen #B (dan pertanyaan yang sama akan diajukan).

Terakhir, peristiwa akan mencapai targetnya dan browser akan bertanya: "Apakah ada yang memproses peristiwa klik pada elemen #C dalam fase pengambilan?" Jawabannya kali ini adalah "ya!" Periode singkat saat peristiwa berada di target ini dikenal sebagai "fase target". Pada titik ini, handler peristiwa akan diaktifkan, browser akan mencatat "#C was clicked" ke konsol, lalu kita selesai, bukan? Salah! Kita belum selesai. Proses berlanjut, tetapi sekarang berubah ke fase bubbling.

Penggelembungan peristiwa

Browser akan bertanya:

"Apakah ada yang memproses peristiwa klik pada #C dalam fase bubbling?" Perhatikan baik-baik di sini. Anda dapat memproses klik (atau jenis peristiwa apa pun) di kedua fase pengambilan dan bubbling. Jika Anda telah menghubungkan pengendali peristiwa di kedua fase (misalnya, dengan memanggil .addEventListener() dua kali, sekali dengan capture = true dan sekali dengan capture = false), maka ya, kedua pengendali peristiwa akan dipicu untuk elemen yang sama. Namun, penting juga untuk diperhatikan bahwa keduanya diaktifkan dalam fase yang berbeda (satu di fase pengambilan dan satu di fase bubbling).

Selanjutnya, peristiwa akan dipropagasi (lebih umum dinyatakan sebagai "bubble" karena seolah-olah peristiwa tersebut bergerak "naik" pohon DOM) ke elemen induknya, #B, dan browser akan bertanya: "Apakah ada yang memproses peristiwa klik pada #B dalam fase bubbling?" Dalam contoh kita, tidak ada yang cocok, jadi tidak ada handler yang akan dipicu.

Selanjutnya, peristiwa akan menggelembung ke #A dan browser akan bertanya: "Apakah ada yang memproses peristiwa klik pada #A dalam fase menggelembung?"

Selanjutnya, peristiwa akan menggelembung ke <body>: "Apakah ada yang memproses peristiwa klik pada elemen <body> dalam fase menggelembung?"

Selanjutnya, elemen <html>: "Apakah ada yang memproses peristiwa klik pada elemen <html> dalam fase bubbling?

Selanjutnya, document: "Apakah ada yang memproses peristiwa klik pada document dalam fase bubbling?"

Terakhir, window: "Apakah ada yang memproses peristiwa klik pada jendela dalam fase bubbling?"

Fiuh! Perjalanan itu panjang, dan acara kita mungkin sudah sangat lelah sekarang, tetapi percaya atau tidak, itulah perjalanan yang dilalui setiap acara. Sebagian besar waktu, hal ini tidak pernah disadari karena developer biasanya hanya tertarik pada satu fase peristiwa atau fase lainnya (dan biasanya fase bubbling).

Anda dapat meluangkan waktu untuk bereksperimen dengan pengambilan peristiwa dan bubbling peristiwa serta mencatat beberapa catatan ke konsol saat pemroses diaktifkan. Sangat berguna untuk melihat jalur yang dilalui suatu peristiwa. Berikut adalah contoh yang memproses setiap elemen di kedua fase.

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in capturing phase');
  },
  true,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in capturing phase');
  },
  true,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in capturing phase');
  },
  true,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in capturing phase');
  },
  true,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in capturing phase');
  },
  true,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in capturing phase');
  },
  true,
);

document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in bubbling phase');
  },
  false,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in bubbling phase');
  },
  false,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in bubbling phase');
  },
  false,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in bubbling phase');
  },
  false,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in bubbling phase');
  },
  false,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in bubbling phase');
  },
  false,
);

Output konsol akan bergantung pada elemen yang Anda klik. Jika Anda mengklik elemen "terdalam" di hierarki DOM (elemen #C), Anda akan melihat setiap pengendali peristiwa ini dipicu. Dengan sedikit gaya CSS untuk memperjelas elemen mana, berikut elemen output #C konsol (dengan screenshot juga):

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"
"click on <body> in bubbling phase"
"click on <html> in bubbling phase"
"click on document in bubbling phase"

event.stopPropagation()

Dengan pemahaman tentang asal peristiwa dan cara perjalanannya (yaitu, propagasi) melalui DOM dalam fase pengambilan dan fase bubbling, kita kini dapat mengalihkan perhatian ke event.stopPropagation().

Metode stopPropagation() dapat dipanggil pada (sebagian besar) peristiwa DOM asli. Saya mengatakan "sebagian besar" karena ada beberapa yang tidak akan melakukan apa pun saat memanggil metode ini (karena peristiwa tidak dipropagasi sejak awal). Acara seperti focus, blur, load, scroll, dan beberapa acara lainnya termasuk dalam kategori ini. Anda dapat memanggil stopPropagation(), tetapi tidak akan terjadi apa pun yang menarik, karena peristiwa ini tidak disebarkan.

Namun, apa fungsi stopPropagation?

Fungsi ini, pada dasarnya, melakukan apa yang dikatakannya. Saat Anda memanggilnya, peristiwa akan berhenti berpropagasi ke elemen mana pun yang seharusnya dituju. Hal ini berlaku untuk kedua arah (pengambilan dan pengurutan). Jadi, jika Anda memanggil stopPropagation() di mana pun dalam fase pengambilan, peristiwa tidak akan pernah mencapai fase target atau fase bubbling. Jika Anda memanggilnya dalam fase bubbling, fase pengambilan akan sudah dilalui, tetapi "bubbling up" akan berhenti dari titik saat Anda memanggilnya.

Kembali ke markup contoh yang sama, menurut Anda apa yang akan terjadi jika kita memanggil stopPropagation() dalam fase pengambilan pada elemen #B?

Tindakan ini akan menghasilkan output berikut:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"

Bagaimana jika menghentikan propagasi di #A pada fase bubbling? Hal itu akan menghasilkan output berikut:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"

Satu lagi, hanya untuk bersenang-senang. Apa yang terjadi jika kita memanggil stopPropagation() dalam fase target untuk #C? Ingatlah bahwa "fase target" adalah nama yang diberikan untuk jangka waktu saat peristiwa berada pada targetnya. Tindakan ini akan menghasilkan output berikut:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"

Perhatikan bahwa pengendali peristiwa untuk #C yang mencatat "klik pada #C dalam fase pengambilan" masih dieksekusi, tetapi yang mencatat "klik pada #C dalam fase bubbling" tidak. Hal ini akan sangat masuk akal. Kita memanggil stopPropagation() dari yang pertama, jadi di titik itulah propagasi peristiwa akan berhenti.

Dalam demo langsung ini, Anda dapat bereksperimen. Coba klik elemen #A saja atau elemen body saja. Coba prediksi apa yang akan terjadi, lalu amati apakah prediksi Anda benar. Pada tahap ini, Anda akan dapat memprediksi dengan cukup akurat.

event.stopImmediatePropagation()

Apa metode aneh yang jarang digunakan ini? Metode ini mirip dengan stopPropagation, tetapi alih-alih menghentikan peristiwa agar tidak berjalan ke turunan (penangkapan) atau ancestor (bubbling), metode ini hanya berlaku jika Anda memiliki lebih dari satu pengendali peristiwa yang terhubung ke satu elemen. Karena addEventListener() mendukung gaya eventing multicast, Anda dapat menghubungkan handler peristiwa ke satu elemen lebih dari sekali. Saat ini terjadi, (di sebagian besar browser), handler peristiwa dieksekusi sesuai urutan pengaturannya. Memanggil stopImmediatePropagation() akan mencegah handler berikutnya dipicu. Perhatikan contoh berikut:

<html>
  <body>
    <div id="A">I am the #A element</div>
  </body>
</html>
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run first!');
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run second!');
    e.stopImmediatePropagation();
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I would have run third, if not for stopImmediatePropagation');
  },
  false,
);

Contoh di atas akan menghasilkan output konsol berikut:

"When #A is clicked, I shall run first!"
"When #A is clicked, I shall run second!"

Perhatikan bahwa pengendali peristiwa ketiga tidak pernah berjalan karena pengendali peristiwa kedua memanggil e.stopImmediatePropagation(). Jika kita memanggil e.stopPropagation(), handler ketiga akan tetap berjalan.

event.preventDefault()

Jika stopPropagation() mencegah peristiwa bergerak "ke bawah" (merekam) atau "ke atas" (menggelembung), lalu apa yang dilakukan preventDefault()? Sepertinya fungsinya mirip. Apakah begitu?

Tidak juga. Meskipun keduanya sering tertukar, sebenarnya keduanya tidak memiliki banyak kaitan satu sama lain. Saat Anda melihat preventDefault(), tambahkan kata "tindakan" dalam pikiran Anda. Pikirkan "mencegah tindakan default".

Dan apa tindakan default yang mungkin Anda tanyakan? Sayangnya, jawabannya tidak begitu jelas karena sangat bergantung pada kombinasi elemen + peristiwa yang dimaksud. Dan yang lebih membingungkan, terkadang tidak ada tindakan default sama sekali.

Mari kita mulai dengan contoh yang sangat sederhana untuk dipahami. Apa yang Anda harapkan terjadi saat Anda mengklik link di halaman web? Tentu saja, Anda mengharapkan browser membuka URL yang ditentukan oleh link tersebut. Dalam hal ini, elemennya adalah tag anchor dan peristiwanya adalah peristiwa klik. Kombinasi tersebut (<a> + click) memiliki "tindakan default" untuk membuka href link. Bagaimana jika Anda ingin mencegah browser melakukan tindakan default tersebut? Artinya, misalkan Anda ingin mencegah browser membuka URL yang ditentukan oleh atribut href elemen <a>? Inilah yang akan dilakukan preventDefault() untuk Anda. Perhatikan contoh ini:

<a id="avett" href="https://www.theavettbrothers.com/welcome">The Avett Brothers</a>
document.getElementById('avett').addEventListener(
  'click',
  function (e) {
    e.preventDefault();
    console.log('Maybe we should just play some of their music right here instead?');
  },
  false,
);

Biasanya, mengklik link berlabel The Avett Brothers akan membuka www.theavettbrothers.com. Namun, dalam hal ini, kita telah menghubungkan pengendali peristiwa klik ke elemen <a> dan menentukan bahwa tindakan default harus dicegah. Jadi, saat pengguna mengklik link ini, mereka tidak akan diarahkan ke mana pun, dan sebagai gantinya, konsol hanya akan mencatat "Mungkin sebaiknya kita memutar beberapa musik mereka di sini saja?"

Kombinasi elemen/peristiwa lain apa yang memungkinkan Anda mencegah tindakan default? Saya tidak mungkin mencantumkan semuanya, dan terkadang Anda hanya perlu bereksperimen untuk melihatnya. Namun secara singkat, berikut beberapa di antaranya:

  • Elemen <form> + peristiwa "submit": preventDefault() untuk kombinasi ini akan mencegah formulir dikirimkan. Hal ini berguna jika Anda ingin melakukan validasi dan jika terjadi kegagalan, Anda dapat memanggil preventDefault secara kondisional untuk menghentikan pengiriman formulir.

  • Elemen <a> + peristiwa "klik": preventDefault() untuk kombinasi ini mencegah browser membuka URL yang ditentukan dalam atribut href elemen <a>.

  • Peristiwa document + "mousewheel": preventDefault() untuk kombinasi ini mencegah penelusuran halaman dengan roda mouse (penelusuran dengan keyboard akan tetap berfungsi).
    ↜ Ini memerlukan panggilan addEventListener() dengan { passive: false }.

  • Peristiwa document + "keydown": preventDefault() untuk kombinasi ini sangat berbahaya. Hal ini membuat halaman menjadi tidak berguna, mencegah scrolling keyboard, penggunaan tombol tab, dan penyorotan keyboard.

  • Peristiwa document + "mousedown": preventDefault() untuk kombinasi ini akan mencegah penyorotan teks dengan mouse dan tindakan "default" lainnya yang akan dipanggil dengan menekan mouse.

  • Elemen <input> + peristiwa "keypress": preventDefault() untuk kombinasi ini akan mencegah karakter yang diketik oleh pengguna mencapai elemen input (tetapi jangan lakukan ini; jarang, jika pernah, ada alasan yang valid untuk melakukannya).

  • Peristiwa document + "contextmenu": preventDefault() untuk kombinasi ini mencegah menu konteks browser bawaan muncul saat pengguna mengklik kanan atau menekan lama (atau cara lain yang memungkinkan menu konteks muncul).

Ini sama sekali bukan daftar lengkap, tetapi semoga memberi Anda gambaran yang baik tentang cara penggunaan preventDefault().

Lelucon praktis yang menyenangkan?

Apa yang terjadi jika Anda stopPropagation() dan preventDefault() dalam fase pengambilan, dimulai dari dokumen? Kekacauan pun terjadi! Cuplikan kode berikut akan membuat halaman web apa pun menjadi hampir tidak berguna:

function preventEverything(e) {
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();
}

document.addEventListener('click', preventEverything, true);
document.addEventListener('keydown', preventEverything, true);
document.addEventListener('mousedown', preventEverything, true);
document.addEventListener('contextmenu', preventEverything, true);
document.addEventListener('mousewheel', preventEverything, { capture: true, passive: false });

Saya tidak tahu mengapa Anda ingin melakukan hal ini (kecuali mungkin untuk mengerjai seseorang), tetapi berguna untuk memikirkan apa yang terjadi di sini, dan menyadari mengapa hal itu menciptakan situasi yang terjadi.

Semua peristiwa berasal dari window, jadi dalam cuplikan ini, kita menghentikan semua peristiwa click, keydown, mousedown, contextmenu, dan mousewheel agar tidak pernah sampai ke elemen yang mungkin memantaunya. Kita juga memanggil stopImmediatePropagation sehingga semua handler yang terhubung ke dokumen setelah handler ini juga akan digagalkan.

Perhatikan bahwa stopPropagation() dan stopImmediatePropagation() (setidaknya sebagian besar) bukan penyebab halaman menjadi tidak berguna. Hal ini hanya mencegah peristiwa mencapai tempat yang seharusnya.

Namun, kita juga memanggil preventDefault(), yang akan mencegah tindakan default. Jadi, semua tindakan default (seperti scroll roda mouse, scroll atau penyorotan atau penab keyboard, mengklik link, tampilan menu konteks, dll.) dicegah, sehingga membuat halaman menjadi tidak berguna.

Ucapan terima kasih

Banner besar oleh Tom Wilson di Unsplash.