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
,mouseup
mousemove
,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…)