Shadow tree 背后的理念是封装组件的内部实现细节。
假设,一个点击事件发生在 <user-card> 组件的 Shadow DOM 内。但主文档中的脚本不知道 Shadow DOM 的内部结构,特别是如果该组件来自第三方库。
因此,为了保持细节的封装,浏览器会重新定位事件。
发生在 Shadow DOM 中的事件,当在组件外部捕获时,其目标元素为宿主元素。
以下是一个简单的示例
<user-card></user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<p>
<button>Click me</button>
</p>`;
this.shadowRoot.firstElementChild.onclick =
e => alert("Inner target: " + e.target.tagName);
}
});
document.onclick =
e => alert("Outer target: " + e.target.tagName);
</script>
如果您点击按钮,消息将是
- 内部目标:
BUTTON- 内部事件处理程序获取正确的目标,即影子 DOM 内部的元素。 - 外部目标:
USER-CARD- 文档事件处理程序获取影子宿主作为目标。
事件重新定位是一件好事,因为外部文档不必了解组件内部。从它的角度来看,事件发生在<user-card> 上。
如果事件发生在物理上位于光 DOM 中的插槽元素上,则不会发生重新定位。
例如,如果用户在以下示例中点击<span slot="username">,则事件目标正是此span 元素,对于影子和光处理程序都是如此。
<user-card id="userCard">
<span slot="username">John Smith</span>
</user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div>
<b>Name:</b> <slot name="username"></slot>
</div>`;
this.shadowRoot.firstElementChild.onclick =
e => alert("Inner target: " + e.target.tagName);
}
});
userCard.onclick = e => alert(`Outer target: ${e.target.tagName}`);
</script>
如果点击发生在"John Smith" 上,则对于内部和外部处理程序,目标都是<span slot="username">。这是一个来自光 DOM 的元素,因此没有重新定位。
另一方面,如果点击发生在源自影子 DOM 的元素上,例如<b>Name</b>,那么当它从影子 DOM 中冒泡出来时,它的event.target 将重置为<user-card>。
冒泡,event.composedPath()
为了事件冒泡的目的,使用扁平化的 DOM。
因此,如果我们有一个插槽元素,并且事件发生在它的内部,那么它会向上冒泡到<slot> 并向上。
可以使用event.composedPath() 获取到原始事件目标的完整路径,包括所有影子元素。正如我们从方法名称中看到的那样,该路径是在组合之后获取的。
在上面的示例中,扁平化的 DOM 是
<user-card id="userCard">
#shadow-root
<div>
<b>Name:</b>
<slot name="username">
<span slot="username">John Smith</span>
</slot>
</div>
</user-card>
因此,对于<span slot="username"> 上的点击,对event.composedPath() 的调用将返回一个数组:[span, slot, div, shadow-root, user-card, body, html, document, window]。这正是组合后扁平化 DOM 中目标元素的父链。
{mode:'open'} 树如果影子树是用{mode: 'closed'} 创建的,那么组合路径从宿主开始:user-card 并向上。
这与其他处理影子 DOM 的方法的原理类似。封闭树的内部完全隐藏。
event.composed
大多数事件都能成功地通过影子 DOM 边界冒泡。有一些事件不能。
这是由composed 事件对象属性控制的。如果它是true,那么事件会跨越边界。否则,它只能从影子 DOM 内部捕获。
如果您查看UI 事件规范,大多数事件都有composed: true
blur,focus,focusin,focusout,click,dblclick,mousedown,mouseupmousemove,mouseout,mouseover,wheel,beforeinput,input,keydown,keyup.
所有触摸事件和指针事件也具有 composed: true。
但是,有些事件具有 composed: false
mouseenter,mouseleave(它们根本不会冒泡),load,unload,abort,error,select,slotchange.
这些事件只能在与事件目标所在的相同 DOM 中的元素上捕获。
自定义事件
当我们分派自定义事件时,我们需要将 bubbles 和 composed 属性都设置为 true,以便它冒泡并从组件中冒出。
例如,这里我们在 div#outer 的影子 DOM 中创建 div#inner,并在其上触发两个事件。只有 composed: true 的事件才能到达文档外部。
<div id="outer"></div>
<script>
outer.attachShadow({mode: 'open'});
let inner = document.createElement('div');
outer.shadowRoot.append(inner);
/*
div(id=outer)
#shadow-dom
div(id=inner)
*/
document.addEventListener('test', event => alert(event.detail));
inner.dispatchEvent(new CustomEvent('test', {
bubbles: true,
composed: true,
detail: "composed"
}));
inner.dispatchEvent(new CustomEvent('test', {
bubbles: true,
composed: false,
detail: "not composed"
}));
</script>
总结
事件只有在它们的 composed 标志设置为 true 时才会跨越影子 DOM 边界。
内置事件大多具有 composed: true,如相关规范中所述
- UI 事件 https://www.w3.org/TR/uievents.
- 触摸事件 https://w3c.github.io/touch-events.
- 指针事件 https://www.w3.org/TR/pointerevents.
- …等等。
一些具有 composed: false 的内置事件
mouseenter,mouseleave(也不冒泡),load,unload,abort,error,select,slotchange.
这些事件只能在相同 DOM 中的元素上捕获。
如果我们分派 CustomEvent,那么我们应该显式地设置 composed: true。
请注意,在嵌套组件的情况下,一个影子 DOM 可能嵌套在另一个影子 DOM 中。在这种情况下,组合事件会冒泡穿过所有影子 DOM 边界。因此,如果事件仅针对直接包含的组件,我们也可以在影子宿主上分派它并设置 composed: false。然后它将超出组件影子 DOM,但不会冒泡到更高级别的 DOM。
评论
<code>标签,对于多行代码,请用<pre>标签包裹,对于超过 10 行的代码,请使用沙盒(plnkr,jsbin,codepen…)