Pembahasan mendalam tentang peristiwa JavaScript

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

Penanganan peristiwa JavaScript sering kali mudah. Hal ini terutama berlaku saat menangani struktur HTML sederhana (relatif datar). Hal-hal menjadi sedikit lebih terlibat ketika peristiwa berpindah (atau menyebar) melalui hierarki elemen. Hal ini biasanya terjadi saat developer menghubungi stopPropagation() dan/atau preventDefault() untuk menyelesaikan masalah yang mereka alami. Jika Anda pernah berpikir "Saya akan mencoba preventDefault() saja dan jika tidak berhasil, saya akan mencoba stopPropagation() dan jika itu tidak berhasil, saya akan mencoba keduanya", maka artikel ini cocok untuk Anda. Saya akan menjelaskan dengan tepat fungsi dari setiap metode, kapan harus menggunakan salah satunya, dan memberikan berbagai contoh kerja untuk Anda pelajari. Tujuan saya adalah mengakhiri kebingungan Anda selamanya.

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

Gaya peristiwa (perekaman dan bubble)

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

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

Perlu diketahui bahwa objek options bersifat opsional, seperti juga properti capture-nya. Jika salah satunya dihilangkan, nilai default untuk capture adalah false, yang berarti gelembung peristiwa akan digunakan.

Pengambilan peristiwa

Apa artinya jika pengendali peristiwa Anda "mendengarkan dalam fase pengambilan?" Untuk memahami hal ini, kita perlu mengetahui asal peristiwa dan cara peristiwa tersebut menyebar. Hal berikut berlaku untuk semua peristiwa, meskipun Anda, sebagai developer, tidak memanfaatkannya, tidak peduli, 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" ke elemen targetnya terlebih dahulu. Ini terjadi bahkan jika Anda hanya mendengarkan dalam fase bergelembung. Pertimbangkan 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 disebarkan melalui turunannya sebagai berikut:

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

Tidak masalah jika tidak ada yang memproses peristiwa klik di elemen window atau document atau <html> atau elemen <body> (atau elemen lain dalam perjalanan ke targetnya). Peristiwa masih berawal dari window dan memulai perjalanannya seperti yang baru saja dijelaskan.

Dalam contoh kita, peristiwa klik kemudian akan disebarkan (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 mengajukan 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 diaktifkan, sehingga tidak ada pengendali yang akan diaktifkan.

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

Selanjutnya, peristiwa akan disebarkan ke elemen <html> dan browser akan bertanya: "Apakah ada yang mendengarkan klik pada elemen <html> di fase perekaman?" Jika ya, pengendali peristiwa yang sesuai akan diaktifkan.

Selanjutnya, peristiwa akan disebarkan 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 disebarkan ke elemen #A. Sekali lagi, browser akan bertanya: "Apakah ada yang memproses peristiwa klik di #A dalam fase pengambilan dan jika ya, pengendali peristiwa yang sesuai akan diaktifkan.

Selanjutnya, peristiwa akan disebarkan 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 waktu singkat saat peristiwa berada di target ini dikenal sebagai "fase target". Pada tahap ini, pengendali peristiwa akan diaktifkan, browser akan console.log "#C diklik", lalu kita selesai, bukan? Salah. Kita belum selesai sama sekali. Proses ini berlanjut, tetapi sekarang berubah menjadi fase bergelembung.

Gelembung acara

Browser akan menanyakan:

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

Selanjutnya, peristiwa akan disebarkan (lebih umum dinyatakan sebagai "bubble" karena sepertinya peristiwa tersebut "naik" ke hierarki DOM) ke elemen induknya, #B, dan browser akan bertanya: "Apakah ada yang memproses peristiwa klik di #B dalam fase bubbling?" Dalam contoh kita, tidak ada yang terdeteksi, sehingga tidak ada pengendali yang akan diaktifkan.

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

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

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 gelembung?"

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

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

Sebaiknya luangkan waktu untuk mencoba pengambilan peristiwa dan bubble peristiwa serta mencatat beberapa catatan ke konsol saat pengendali diaktifkan. Sangatlah penting untuk melihat jalur yang diambil peristiwa. Berikut adalah contoh yang memproses setiap elemen dalam 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" dalam hierarki DOM (elemen #C), Anda akan melihat setiap pengendali peristiwa ini diaktifkan. Dengan sedikit gaya visual CSS untuk lebih memperjelas elemen mana yang dimaksud, berikut adalah elemen #C output 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"

Anda dapat memainkannya secara interaktif dalam demo langsung di bawah. Klik elemen #C dan amati output konsol.

event.stopPropagation()

Dengan pemahaman tentang asal peristiwa dan cara peristiwa berpindah (yaitu menyebar) melalui DOM baik dalam fase pengambilan maupun fase bubbling, sekarang kita dapat mengalihkan perhatian ke event.stopPropagation().

Metode stopPropagation() dapat dipanggil pada (sebagian besar) peristiwa DOM native. Saya katakan "sebagian besar" karena ada beberapa yang memanggil metode ini tidak akan melakukan apa pun (karena peristiwa tidak disebarkan untuk dimulai dengan). Peristiwa seperti focus, blur, load, scroll, dan beberapa lainnya termasuk dalam kategori ini. Anda dapat memanggil stopPropagation(), tetapi tidak akan ada hal menarik yang terjadi karena peristiwa ini tidak diterapkan.

Namun, apa fungsi stopPropagation?

Isinya cukup, persis seperti apa yang dikatakannya. Saat Anda memanggilnya, peristiwa akan berhenti menyebar ke elemen mana pun yang akan ditujunya. Hal ini berlaku untuk kedua arah (mengambil gambar dan membuat balon). Jadi, jika Anda memanggil stopPropagation() di mana saja dalam fase pengambilan, peristiwa tidak akan pernah sampai ke fase target atau fase bubbling. Jika Anda memanggilnya dalam fase bubbling, kode tersebut akan telah melalui fase pengambilan, tetapi akan berhenti "bubbling up" dari titik saat Anda memanggilnya.

Kembali ke contoh markup yang sama, menurut Anda apa yang akan terjadi, jika kita memanggil stopPropagation() dalam fase pengambilan di 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"

Anda dapat memainkannya secara interaktif dalam demo langsung di bawah. Klik elemen #C dalam demo langsung dan amati output konsol.

Bagaimana dengan menghentikan penyebaran di #A pada fase bubbling? 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"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"

Anda dapat memainkannya secara interaktif di demo langsung di bawah. Klik elemen #C di demo langsung dan amati output konsol.

Satu lagi, hanya untuk bersenang-senang. Apa yang terjadi jika kita memanggil stopPropagation() dalam fase target untuk #C? Ingat bahwa "fase target" adalah nama yang diberikan untuk jangka waktu saat peristiwa di targetnya. 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 tempat kita mencatat "klik pada #C dalam fase pengambilan" masih dijalankan, tetapi pengendali peristiwa tempat kita mencatat "klik pada #C dalam fase bubbling" tidak. Hal ini seharusnya sangat masuk akal. Kita memanggil stopPropagation() dari yang pertama, jadi itulah titik saat penyebaran peristiwa akan dihentikan.

Anda dapat memainkannya secara interaktif di demo langsung di bawah. Klik elemen #C dalam demo langsung dan amati output konsol.

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

event.stopImmediatePropagation()

Apa metode aneh dan jarang digunakan ini? Ini mirip dengan stopPropagation, tetapi bukan menghentikan peristiwa agar tidak berpindah ke turunan (menangkap) atau ancestor (bubbling), metode ini hanya berlaku jika Anda memiliki lebih dari satu pengendali peristiwa yang terhubung ke satu elemen. Karena addEventListener() mendukung gaya peristiwa multicast, Anda dapat menghubungkan pengelola peristiwa ke satu elemen lebih dari sekali. Jika hal ini terjadi, (di sebagian besar browser), pengendali peristiwa akan dijalankan sesuai urutan penyambungannya. Memanggil stopImmediatePropagation() akan mencegah pengendali berikutnya diaktifkan. 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!"

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

event.preventDefault()

Jika stopPropagation() mencegah peristiwa berjalan "ke bawah" (membuat rekaman) atau "ke atas" (membuat gelembung), apa yang dilakukan preventDefault()? Sepertinya keduanya melakukan hal yang sama. Apakah begitu?

Tidak juga. Meskipun sering kali tertukar, keduanya sebenarnya tidak memiliki banyak hubungan satu sama lain. Saat melihat preventDefault(), di kepala Anda, tambahkan kata "tindakan". Pikirkan "cegah tindakan default".

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

Mari kita mulai dengan contoh yang sangat sederhana untuk dipahami. Apa yang Anda harapkan akan terjadi ketika Anda mengeklik tautan pada laman web? Tentunya 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 menavigasi ke href link. Bagaimana jika Anda ingin mencegah browser melakukan tindakan default tersebut? Artinya, 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,
);

Anda dapat memainkannya secara interaktif dalam demo langsung di bawah. Klik link The Avett Brothers dan amati output konsol (dan fakta bahwa Anda tidak dialihkan ke situs Avett Brothers).

Biasanya, mengklik link berlabel The Avett Brothers akan mengarahkan pengguna ke 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 putar beberapa musiknya di sini?"

Kombinasi elemen/peristiwa apa lagi yang memungkinkan Anda mencegah tindakan default? Saya tidak mungkin mencantumkan semuanya, dan terkadang Anda harus bereksperimen untuk melihatnya. Secara singkat, berikut beberapa hal:

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

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

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

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

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

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

  • Peristiwa document + "contextmenu": preventDefault() untuk kombinasi ini mencegah menu konteks browser native muncul saat pengguna mengklik kanan atau menekan lama (atau dengan cara apa pun yang menyebabkan menu konteks muncul).

Ini bukanlah daftar lengkap, tetapi semoga daftar ini memberi Anda gambaran yang baik tentang cara preventDefault() dapat digunakan.

Lelucon praktis yang menyenangkan?

Apa yang terjadi jika Anda stopPropagation() dan preventDefault() dalam fase pengambilan, dimulai dari dokumen? Kelucuan pun terjadi. Cuplikan kode berikut akan merender halaman web apa pun yang hampir tidak berguna sama sekali:

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 begitu tahu mengapa Anda ingin melakukannya (kecuali mungkin untuk mengerjai seseorang), tetapi akan lebih baik jika Anda memikirkan apa yang terjadi di sini, dan menyadari mengapa hal tersebut menciptakan situasi yang ada.

Semua peristiwa berasal dari window, sehingga dalam cuplikan ini, kita akan berhenti, berhenti di jalurnya, semua peristiwa click, keydown, mousedown, contextmenu, dan mousewheel agar tidak sampai ke elemen yang mungkin memprosesnya. Kita juga memanggil stopImmediatePropagation sehingga pengendali apa pun yang terhubung ke dokumen setelah ini, juga akan digagalkan.

Perhatikan bahwa stopPropagation() dan stopImmediatePropagation() bukan (setidaknya tidak sebagian besar) yang membuat halaman tidak berguna. Peristiwa tersebut hanya mencegah peristiwa agar tidak sampai ke tempat yang seharusnya.

Namun, kita juga memanggil preventDefault(), yang akan Anda ingat akan mencegah tindakan default. Jadi, semua tindakan default (seperti scroll mousewheel, scroll keyboard atau sorotan atau tab, mengklik link, tampilan menu konteks, dll.) semuanya dicegah, sehingga halaman berada dalam status yang cukup tidak berguna.

Demo langsung

Untuk menjelajahi semua contoh dari artikel ini lagi di satu tempat, lihat demo tersemat di bawah.

Ucapan terima kasih

Banner besar oleh Tom Wilson di Unsplash.