PETEPITTAWAT.DEV

JS: Event Bubbling and Capture

Cover Image for JS: Event Bubbling and Capture
Pittawat Taveekitworachai
Pittawat Taveekitworachai

Event ถือได้ว่าเป็นหนึ่งในหลักสำคัญสำหรับการเขียนโปรแกรมด้วยภาษา JavaScript สำหรับการทำงานกับ Browser เพราะ Event ทำให้เราสามารถสร้าง Event handler (callback) สำหรับทำงานเมื่อมี Event ที่เราสนใจเกิดขึ้นนั่นเอง

อย่างไรก็ตามยังมีอีกหนึ่งหลักการที่อาจไม่ได้พบได้โดยทั่วไป แต่หากเจอก็อาจสร้างความสนุกสนานการในการจัดการและพยายามทำความเข้าใจในสิ่งที่เกิดขึ้นก็ได้ นั่นก็คือ Event Bubbling and Capture นั่นเอง ซึ่งเป็นเป็น 2 mechanisms ที่ทำงานร่วมกันสำหรับเหตุการณ์ที่เรามีการ Listen ที่ Event ชนิดเดียวกันที่ Element เดียวกัน ดังเช่นเหตุการณ์ต่อไปนี้

สำหรับโค้ดของ HTML นั้นก็จะเป็นดังต่อไปนี้ โดยมีเพียงปุ่มเดียวใน div ที่มี id เป็น outer-box

<div id="outer-box">
  <button>Click me!</button>
</div>

พร้อม CSS ที่เปลี่ยนสี Background เพื่อให้มองได้เห็นชัดเจนยิ่งขึ้น

#outer-box {
  background: #aec6cf;
  padding: 2rem;
}

button {
  background: white;
  border: none;
  border-radius: 1rem;
  font-size: 1rem;
  padding: 0.5rem 0.7rem;
}

button:hover {
  background: black;
  color: white;
}

ส่วนสิ่งที่น่าสนใจจริง ๆ ก็คือ JavaScript นั่นเอง

const button = document.querySelector("button");
const outerBox = document.getElementById("outer-box");

outerBox.addEventListener("click", event => {
  alert("OuterBox clicked!");
});

button.addEventListener("click", event => {
  alert("Button clicked!");
});

โดยสำหรับโค้ด JavaScript นั้นเราได้ทำการ register event handler ไว้สองตัว โดยตัวแรกผูกไว้กับ outerBox ที่เมื่อคลิกแล้วจะ alert ออกมาว่า OuterBox clicked! และปุ่มภายใน div ที่เมื่อคลิกแล้วจะ alert ว่า Button clicked!

โดย Expected behavior ก็คือเมื่อเราคลิกที่ Area ใน div ยกเว้นตรงส่วนปุ่มจะ alert เป็น OuterBox clicked! และเมื่อคลิกที่ปุ่มจะ alert เป็น Button clicked! แต่สิ่งที่เกิดขึ้นคือเมื่อคลิกที่ปุ่มแล้ว เราจะได้ alert ออกมาทั้งสองอัน ทั้ง Button clicked! และ OuterBox clicked! นี่มันเกิดอะไรขึ้น!? ทำไม OuterBox clicked! ถึงออกมาด้วย แล้วเราจะแก้ยังไงดี?

คำตอบของเรื่องนี้ต้องกลับไปทำความเข้าใจการทำงานของ Event Bubbling และ Event Capturing นั่นเอง


Event flow ยังไงนะ?

เมื่อมี Event เกิดขึ้น สิ่งที่ Browser จะทำก็จะแบ่งออกย่อย ๆ เป็นสองเฟสนั่นคือ Bubbling phase และ Capturing phase เนื่องด้วย Browser สมัยใหม่ ๆ นั่นจะมีแนวคิดว่า Element ตัวเดียวกัน อาจมีคนมา register เพื่อคอยฟัง Event ชนิดเดียวกันหลายคนก็เป็นได้ ดังเช่นในตัวอย่างข้างบนนั่นเอง

ใน Bubbling phase นั้น Browser จะทำการเช็ค element ตัวที่เป็นคน fire event ว่าการ Register event ชนิดนี้ไว้สำหรับ bubbling phase หรือไม่หากมีก็จะทำการรัน แล้ว move on ไป element ถัดไปที่เป็น ancestor โดยตรงและเช็คแบบเดียวกันไปเรื่อย ๆ จนกว่าจะถึง element root ancestor (<html>) => innermost to outermost

ส่วนใน Capturing phase นั้น Browser จะทำการเช็คดูว่า element root ancestor (โดยทั่วไปจะเป็น <html> นั้นมีการ register event ชนิดนี้สำหรับ capturing phase อยู่หรือไม่ (ในที่นี้คือ click) หากมีก็จะทำการรันหากมี แล้ว move on ไปยัง element ถัดไปที่เป็น Ancestor ของ element ที่ถูกคลิกแล้วเช็คและทำแบบเดียวกันจนถึงตัวที่ถูกคลิก => outermost to innermost

ซึ่งสำหรับ Browser ในยุคปัจจุบันแล้วโดย default แล้ว event จะถูก register สำหรับ bubbling phase ทั้งหมด (หากว่ามีสำหรับทั้งสองประเภทแล้ว จะเริ่มจากรัน capturing phase ก่อนตามด้วย bubbling phase)

โดยเราสามารถสั่งให้ event listener นั้น ๆ ทำงานแบบ capturing ได้ด้วยการเพิ่ม parameter ตัวที่ 3 (ถัดจาก eventType, event handler function) เป็น true ได้เลย

และนี่ก็คือเหตุผลว่าทำไมโค้ดตัวอย่างข้างต้นจึงมีผลลัพธ์แบบที่เราเห็นนั่นเอง เพราะว่าเมื่อเราคลิกที่ปุ่มแล้ว event handler ที่ผูกไว้กับปุ่มจะ fire ก่อนแล้วขยับไปยัง next ancestor ก็คือ div นั้นเองซึ่งก็ดันไปมี event handler สำหรับ click event เช่นเดียวกันทำให้ถูกรันด้วยนั่นเอง


แล้วแก้ปัญหานี้ยังไงดี?

เราสามารถแก้ปัญหานี้ได้ด้วยการใช้คำสั่ง event.stopPropagation() เพื่อหยุด เพื่อไม่ให้พฤติกรรมในการส่งต่อ event (propagate) ขึ้นไปนั่นเอง

const button = document.querySelector("button");
const outerBox = document.getElementById("outer-box");

outerBox.addEventListener("click", event => {
  alert("OuterBox clicked!");
});

button.addEventListener("click", event => {
  event.stopPropagation();
  alert("Button clicked!");
});

ซึ่งจะเห็นได้ว่าพฤติกรรมแปลก ๆ ก็ได้หายไปแล้ว และทำงานตามที่เราตั้งใจไว้แล้วนั่นเอง


Extra: Event delegation

สำหรับคอนเซปเรื่อง Bubbling ทำให้เรารู้ว่า Browser จะทำการเช็คและรัน event handler หลายตัว ทำให้เราสามารถทำบางอย่างที่น่าสนใจได้ด้วยไอเดียของ Event delegation ซึ่งเป็นไอเดียที่เรามี child element ที่มีพฤติกรรมเดียวกันเมื่อเกิด event ขึ้น แทนที่เราจะผูก event hanlder ไว้กับ child element ทุกตัว ซึ่ง waste memory เราก็อาจเลือกที่จะผูกกับ parent element แทน ซึ่งเมื่อ child element มี event เกิดขึ้นก็จะขยับไปเรียกที่ parent element นั่นเอง โดยไม่ต้องเป็นกังวลไปว่าแล้วเราจะ access element ที่เป็นคน fire event ได้ยัง เพราะ browser ยังคงใส่ element ที่เกิด event ขึ้นไว้ใน event.target ให้เราเช่นเดิม เช่น ในตัวอย่างนี้ที่เราสามารถ toggle สีน้ำเงินได้อย่างถูกต้อง แต่ผูก event handler เพียงครั้งเดียว


สำหรับผู้ที่สนใจสามารถศึกษาเพิ่มเติมได้ที่ Event bubbling and capture | MDN และ Event Delegation


📚 Hope you enjoy reading! 📚