在微前端场景下,开启qiankun的sandbox.strictStyleIsolation后,子应用会运行在Shadow DOM环境下,此时在子应用的中,如果在document对象上做一些类似事件委托的事情时,事件对象event.target会受到Shadow DOM环境的影响而发生重新定位(retarget),因此子应用的代码需要做相应的适配
Shadow DOM
Shadow DOM是Web 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,就会出现以下信息:
- Inner target:
BUTTON—— 内部事件处理程序获取了正确的目标,即Shadow DOM中的元素。 - 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:
blur,focus,focusin,focusout,click,dblclick,mousedown,mouseupmousemove,mouseout,mouseover,wheel,beforeinput,input,keydown,keyup。
所有触摸事件(touch events)及指针事件(pointer events)都是 composed: true。
但也有些事件是 composed: false 的:
mouseenter,mouseleave(它们根本不会冒泡),load,unload,abort,error,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"
}));