微前端shadow dom环境下的 event target

256 阅读3分钟

微前端场景下,开启qiankunsandbox.strictStyleIsolation后,子应用会运行在Shadow DOM环境下,此时在子应用的中,如果在document对象上做一些类似事件委托的事情时,事件对象event.target会受到Shadow DOM环境的影响而发生重新定位retarget),因此子应用的代码需要做相应的适配

Shadow DOM

Shadow DOMWeb Component实现封装思想的一个重要特性。

Shadow DOM允许将隐藏的DOM树附加到常规的DOM树中——它以shadow root节点为起始根节点,在这个根节点下方,可以是任意元素,和普通DOM元素一样。

Shadow DOM中有一些特殊术语:

  • shadow host: 一个常规的DOM节点,Shadow DOM会被附加到这个节点上。
  • shadow tree: Shadow DOM内部的DOM树。
  • shadow boundary: Shadow DOM结束的地方,也是常规DOM开始的地方。
  • shadow root: Shadow tree的根节点。

可以使用Element.attachShadow方法来将一个shadow root附加到任何一个元素上。它接受一个配置对象作为参数,该对象有一个mode属性,用来控制是否可以通过js获取Shadow DOM

let shadow = elementRef.attachShadow({ mode: 'open' });
let shadow = elementRef.attachShadow({ mode: 'closed' });

// open时,可以通过一下方式获取shadowDom
let shadowDom = elementRef.shadowRoot;

custom element中,可以在内部的connectedCallback中如下实现(Shadow DOM最常见的做法):

customElements.define('my-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
  }
});

事件对象 event.target

假设,在 <user-card> 组件的Shadow DOM内触发一个点击事件。但是主文档内部的脚本并不了解 Shadow DOM内部,尤其是当组件来自于第三方库。

所以,为了保持细节简单,浏览器会重新定位retarget)事件。

当事件在组件外部捕获时,shadow DOM 中发生的事件(event.target)将会以shadow host元素作为目标。

这里有个简单的例子:

<body>
<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>
</body>

如果你点击了button,就会出现以下信息:

  1. Inner target: BUTTON —— 内部事件处理程序获取了正确的目标,即Shadow DOM中的元素。
  2. Outer target: USER-CARD —— 文档事件处理程序以shadow host作为目标。

事件重定向是一件很棒的事情,因为外部文档并不需要知道组件的内部情况。从它的角度来看,事件是发生在 <user-card>

适配 event.composedPath()

在微前端子应用中,很多第三方库会利用事件委托的机制模拟一些特殊的事件,比如 hammer.js

由于委托的document对象不在shadow tree中,所以委托事件回调中的event.target会被重定向为shadow host,从而导致程序出现一些不可预知的错误。

使用event.composedPath()可以获得原始事件目标的完整路径以及所有shadow元素。正如我们从方法名称中看到的那样,该路径是在组合(composition)之后获取的。

Shadow 树详细信息仅提供给 {mode:'open'} 树

因此,我们可以在事件处理回调中使用如下方法修复event.target的指向问题

if (event) {
  const composedPath = event.composedPath ? srcEvent.composedPath() : []

  if (composedPath[0] && composedPath[0] !== target) {
    target = composedPath[0]
  }
}

补充 event.composed

大多数事件能成功冒泡到Shadow DOM边界。很少有事件不能冒泡到Shadow DOM边界。

这由 composed 事件对象属性控制。如果 composed 是 true,那么事件就能穿过边界。否则它仅能在 Shadow DOM 内部捕获。

如果你浏览一下 UI 事件规范 就知道,大部分事件都是 composed: true

  • blurfocusfocusinfocusout
  • clickdblclick
  • mousedownmouseup mousemovemouseoutmouseover
  • wheel
  • beforeinputinputkeydownkeyup

所有触摸事件(touch events)及指针事件(pointer events)都是 composed: true

但也有些事件是 composed: false 的:

  • mouseentermouseleave(它们根本不会冒泡),
  • loadunloadaborterror
  • select
  • slotchange

这些事件仅能在事件目标所在的同一 DOM 中的元素上捕获

如果在微前端子应用中使用到自定义事件(custom event),需要设置 bubbles 和 composed 属性都为 true 以使其冒泡并从组件中冒泡出来。尤其对于嵌套Shadow DOM的场景,可以利用composed 属性限制自定义事件的应用范围。

document.addEventListener('test', event => alert(event.detail));

inner.dispatchEvent(new CustomEvent('test', {
  bubbles: true,
  composed: true,
  detail: "composed"
}));