先记住这个答案
Shadow DOM 为组件内部建立边界。composed: true 允许事件穿过这个边界;若外部页面的祖先冒泡监听也要收到自定义事件,通常还需 bubbles: true。composedPath() 可以查看事件经过的节点,但在闭合影子根外部不会暴露内部节点。跨边界时 event.target 往往被重定向为宿主元素,因此不能假设外部总能拿到内部按钮。
composed控制跨影子边界bubbles控制祖先冒泡composedPath()展示可见路径- 闭合影子根会隐藏内部节点
组件把选择结果通知页面
自定义列表组件内部点击某项,页面希望监听组件宿主,更新详情。内部可派发一个带业务数据的事件,明确允许向外传播,而不是让外部穿透影子树直接找按钮。
const host = document.querySelector('item-picker')
const button = host.shadowRoot.querySelector('button')
host.addEventListener('item-select', event => {
console.log(event.detail.id) // 42
})
button.dispatchEvent(new CustomEvent('item-select', {
detail: { id: '42' },
bubbles: true,
composed: true
}))示例为了说明派发位置直接访问开放影子根;真实组件应在自身内部用户交互时派发事件。外部监听器通常面向宿主和事件负载,不需要知道内部按钮结构。
若只设置 bubbles: true 而没有 composed: true,事件可能在影子根边界停下;只设置 composed: true 也不能简单推断外层冒泡监听一定会收到。
路径与目标会被封装调整
浏览器会对外部监听器重定向目标,避免把组件内部实现直接暴露。开放影子根的路径可能包含内部节点和 ShadowRoot;闭合影子根从外部看不会列出内部节点。
composedPath() 适合调试事件传播,也可在开放组件中定位来源;但产品接口更稳定的做法是派发语义明确的 CustomEvent.detail。不要把闭合影子根当成安全边界,它主要是封装 API 形态。
排查外部监听收不到事件时,可逐项检查:事件是不是从影子树内部派发,是否设了 composed: true,外部监听绑在宿主还是祖先,祖先若只听冒泡还需要 bubbles: true。例如监听绑在组件宿主上,与绑在页面父容器上,能否收到事件的条件并不完全相同。
即使开放根的 composedPath() 能显示内部按钮,也不要把它当稳定组件契约。改版时内部从 <button> 换成别的节点,外部按路径索引取 ID 的代码就会坏;让组件在 detail 里直接提供选中项 ID 才能隔离内部结构变化。
容易答错的地方
- 认为
bubbles: true自动跨影子边界 - 能否跨边界由
composed控制,两项要分别判断。 - 认为外部
target一定是内部按钮 - 跨边界目标可能重定向成宿主,闭合根还会隐藏内部路径。
面试官还会怎么问?
原生 click 通常会跨边界吗?
用户代理派发的常见 UI 事件通常是 composed 的,但仍要按具体事件和传播阶段判断。
composedPath() 会暴露闭合根内部吗?
从外部监听器看不会列出闭合影子根的内部节点。
怎样让外部稳定获得被选中项 ID?
组件主动派发带 detail 的自定义事件,比让外部推断内部 DOM 更稳。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。