2019 年 7 月 15 日

Shadow DOM 和事件

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>

如果您点击按钮,消息将是

  1. 内部目标:BUTTON - 内部事件处理程序获取正确的目标,即影子 DOM 内部的元素。
  2. 外部目标: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 中的元素上捕获。

自定义事件

当我们分派自定义事件时,我们需要将 bubblescomposed 属性都设置为 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,如相关规范中所述

一些具有 composed: false 的内置事件

  • mouseenter, mouseleave(也不冒泡),
  • load, unload, abort, error,
  • select,
  • slotchange.

这些事件只能在相同 DOM 中的元素上捕获。

如果我们分派 CustomEvent,那么我们应该显式地设置 composed: true

请注意,在嵌套组件的情况下,一个影子 DOM 可能嵌套在另一个影子 DOM 中。在这种情况下,组合事件会冒泡穿过所有影子 DOM 边界。因此,如果事件仅针对直接包含的组件,我们也可以在影子宿主上分派它并设置 composed: false。然后它将超出组件影子 DOM,但不会冒泡到更高级别的 DOM。

教程地图

评论

在评论之前请阅读...
  • 如果您有改进建议,请提交 GitHub 问题或拉取请求,而不是评论。
  • 如果您不理解文章中的某些内容,请详细说明。
  • 要插入少量代码,请使用<code>标签,对于多行代码,请用<pre>标签包裹,对于超过 10 行的代码,请使用沙盒(plnkrjsbincodepen…)