ข้ามไปยังเนื้อหา

Composed Events

Shadow root ทุกตัวเป็นขอบเขตของ event โดย event ที่เกิดขึ้นภายใน shadow root จะ ไม่ bubble ผ่าน shadow host โดยค่าเริ่มต้น — จะถูกกักไว้ภายใน shadow tree นี่คือส่วนหนึ่งของการรับประกัน encapsulation: โลกภายนอกไม่สามารถมองเห็นสิ่งที่เกิดขึ้นภายใน component ของคุณได้ เว้นแต่คุณจะอนุญาตอย่างชัดเจน

เพื่อให้ event ข้ามขอบเขตนั้นได้ คุณต้องตั้งค่า composed: true ตอน dispatch event นั้น

// ภายใน shadow root — event นี้จะไม่ถึง document
this._shadow.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('shadow-click', {
bubbles: true,
// composed เป็น false โดยค่าเริ่มต้น — event หยุดที่ shadow boundary
}));
});

แม้ว่าจะตั้งค่า bubbles: true ไว้ แต่ event จะไม่ออกจาก shadow tree ผู้รับฟัง (listener) บน document หรือ ancestor ใน light DOM จะไม่มีทางเห็น event นั้นเลย

// Composed — event นี้จะข้าม shadow boundary
this._shadow.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('shadow-click', {
bubbles: true,
composed: true, // ← อนุญาตให้ข้าม shadow boundary
}));
});

ตอนนี้ event จะ bubble ตามปกติผ่าน ancestor ทุกตัวใน light DOM จนถึง window

composedPath() คืนค่า array เต็มของ node ที่ event เดินทางผ่าน รวมถึง node ภายใน shadow root ด้วย สิ่งนี้มีประโยชน์เฉพาะขณะที่ event กำลัง dispatch อยู่เท่านั้น — เมื่อ event handler return แล้ว array จะถูกล้าง

// อ่าน composedPath
document.addEventListener('shadow-click', (e) => {
console.log(e.composedPath()); // รวม shadow nodes ด้วย
console.log(e.target); // retarget ไปยัง host element จากภายนอก
});

เมื่อคุณอ่าน e.target จากภายนอก shadow tree คุณจะเห็น shadow host ไม่ใช่ element ต้นกำเนิดที่ยิง event นี้เรียกว่า target retargeting — ซึ่งซ่อนโครงสร้างภายในของ component จากผู้รับฟังภายนอก

Target retargeting เป็นกลไกความเป็นส่วนตัวที่ออกแบบมาโดยตั้งใจ ลองนึกภาพ component ที่ซับซ้อนซึ่งมีปุ่มภายในหลายปุ่ม โค้ดภายนอกไม่ควรสามารถบอกได้ว่า node ภายในใดถูกคลิก — แค่รู้ว่า component นั้นถูกโต้ตอบเท่านั้น browser จะเขียน event.target ใหม่โดยอัตโนมัติให้ชี้ไปที่ host element เมื่อ event ถูกสังเกตจากภายนอก shadow tree

ภายใน shadow tree target ต้นฉบับยังคงมองเห็นได้ แต่จากภายนอก event.target จะชี้ไปที่ shadow host เสมอ

กฎง่ายๆ: ใช้ composed: true สำหรับ event ที่แสดงถึง action ที่ผู้ใช้มองเห็น ซึ่ง component ของคุณเผยแพร่เป็นส่วนหนึ่งของ public API (เช่น value-change, submit, close) รักษา event ภายในระดับล่างให้ไม่มี composed เพื่อให้ event เหล่านั้นยังคงเป็นเรื่องภายในของ component

ปุ่มสีม่วง dispatch event composed-click ด้วย composed: true listener ของ document จะยิงและยืนยันว่า event มาถึงแล้ว โดยแสดงว่า event.target ถูก retarget ไปที่ host element <composed-demo>

ปุ่มสีเทา dispatch event not-composed-click โดยไม่มี composed: true listener ของ document สำหรับชื่อ event นั้นถูกลงทะเบียนไว้ — แต่ไม่เคยยิง ข้อความที่คุณเห็นถูกตั้งค่าโดยตรงบน listener ของ host element เอง ซึ่งพิสูจน์ว่า event ถูกหยุดที่ shadow boundary

ตัวเลือกBenefitCost
composed: trueevent ทะลุออกจาก shadow boundary ทำให้โค้ดภายนอก เช่น analytics หรือ parent app ฟัง event ได้โดยไม่ต้องรู้โครงสร้างภายในของ componentเผย public event surface มากขึ้น เปลี่ยนชื่อหรือจังหวะของ event ทีหลังจะกระทบ consumer ภายนอกที่ผูก listener ไว้แล้ว
ไม่ตั้ง composed (ปล่อยเป็นค่าเริ่มต้น false)event ยังคงเป็นรายละเอียดภายในของ component เปลี่ยนแปลงหรือลบได้อย่างอิสระโดยไม่กระทบใครโค้ดภายนอก รวมถึง test ที่ query จาก document ไม่มีทางฟัง event นั้นได้เลย แม้จะตั้ง bubbles: true ไว้ก็ตาม
  • ตั้ง bubbles: true แต่ลืม composed: true — คิดว่า event จะออกจาก shadow root ได้เพราะตั้ง bubble แล้ว แต่จริงๆ ยังหยุดที่ shadow boundary เหมือนเดิม ต้องตั้งทั้งสอง flag พร้อมกันถ้าต้องการให้ listener ภายนอก shadow tree ฟังได้
  • ตั้ง composed: true ให้ event ภายในทุกตัวโดยไม่เลือก — ทำให้รายละเอียดการทำงานภายใน component รั่วไหลออกสู่ภายนอกโดยไม่จำเป็น ควรเลือกเฉพาะ event ที่เป็นส่วนหนึ่งของ public API เท่านั้น
  • เก็บผลลัพธ์จาก composedPath() ไว้ใช้ทีหลังcomposedPath() คืนค่าที่ถูกต้องเฉพาะขณะ event handler กำลังทำงานอยู่เท่านั้น ถ้าเก็บ reference ไว้ใช้ใน callback แบบ async ในภายหลัง จะได้ array ว่างเปล่า

💡 ตัวอย่างจากของจริง

Shoelace / Web Awesome design system — component อย่าง sl-dialog dispatch event เช่น sl-show, sl-hide ด้วย composed: true เสมอ เพื่อให้ framework ภายนอกอย่าง React หรือ Vue ผูก event handler ได้ตามปกติโดยไม่ต้องเจาะเข้าไปใน shadow tree

YouTube web components — ปุ่ม like และ subscribe ที่อยู่ภายใน shadow DOM dispatch custom event ด้วย composed: true เพื่อให้ analytics script ระดับหน้าเว็บดักฟังการโต้ตอบของผู้ใช้ได้

ต้องตั้งค่า option ใดเพื่อให้ CustomEvent ข้าม shadow DOM boundary ได้?
โดยค่าเริ่มต้น `bubbles: true` โดยไม่มี `composed: true` — event จะหยุดที่ไหน?
`event.composedPath()` คืนค่าอะไร?
เมื่อรับฟัง composed event จากภายนอก shadow root `event.target` ชี้ไปที่อะไร?