blog
blog copied to clipboard
当 Shadow Dom 遇上 React event
Shadow DOM 是在 web component 中常提到的概念,其核心作用就是做到与 shadow host 之外的代码做到隔离,把内部结构、样式、行为做隐藏,相当于是一个沙箱的。早期浏览器会用这个特性来封装一些内部标签,比如 video 标签:
在 chrome dev tool 中可以在设置中配置我们允许查看 shadow dom,设置路径为:settings -> preferences -> Elements -> show user agent shadow DOM
开启前:
开启后:
在实际使用中我们只需要将对应的 shadow host 节点使用 attachShadow 方法开启即可:
const host = document.querySelector('#host')
const shadowRoot = host.attachShadow({ mode: 'open' })
shadowRoot.innerHTML = `
<h2 style="color: red"> shadow dom </h2>
`;
效果:
由于 shadow dom 隔离的特性,shadow dom 内部事件被外部捕获的时候,event 的 target 将会被重定向为 shadow host 元素,如下:
const host = document.querySelector('#host')
const shadowRoot = host.attachShadow({ mode: 'open' })
shadowRoot.innerHTML = `
<h2 style="color: red" id="btn"> shadow dom </h2>
`;
shadowRoot.querySelector('#btn').addEventListener('click', (e) => {
console.log("shadow dom inner target: ", e.target.id)
})
document.addEventListener('click', (e) => {
console.log("shadow dom outter target: ", e.target.id)
})
效果:
这种事件重定向是必要的,因为对于 shadow dom 外部来说,他的眼里只有一个元素,不关心内部的实现,就像我们使用 video 标签一样,我们只认为这是一个和 div 标签一样是一个单一的标签。
但这种重定向对于 React 17之前来说可能就不是那么友好了。众所周知,React 的事件是一个合成事件。
在 DOM 原生事件中,事件是先捕获再冒泡的:
在 React 17 之前,React 的合成事件都是委托在 document 上并且在冒泡阶段执行的,由于 shadow dom 事件重定向的缘故,最终会被认为是 shadow host 是事件源,看以下例子:
import React from 'react'
import "./styles.css";
const ShadowView: React.FC = ( {
children
}) => {
const attachShadowRef = React.createRef<HTMLDivElement>()
React.useEffect(() => {
const host = attachShadowRef.current
const root = host?.attachShadow({ mode: "open" });
[].slice.call(host?.children).forEach(child => {
root.appendChild(child);
});
}, [attachShadowRef, children])
return <div ref={attachShadowRef}>
{children}
</div>;
}
export default function App() {
return (
<div className="App" onClick={() => {
console.log('app click')
}}>
<ShadowView>
<h2 onClick={() => {
console.log('shadow dom inner click')
}}>
shadow dom
</h2>
</ShadowView>
</div>
);
}
我们在 shadow dom 内部定义了 click 事件,当我们点击时会发现这个内部 click 事件并没有执行:
按照上面的原理分析,核心还是事件源不对,最容易想到的方式就是我们手动在 shadow dom 中监听这个事件,然后再次派发到对应的 dom 中。
在 React 内部给每个 dom 节点绑定了一个 __reactEventHandlers 的属性,通过这个属性可以获取到这个 dom 所有绑定的事件,再手动执行即可。
我们在 shadow dom 中添加这个事件派发过程:
const ShadowView: React.FC = ( {
children
}) => {
const attachShadowRef = React.createRef<HTMLDivElement>()
React.useEffect(() => {
const host = attachShadowRef.current
const root = host?.attachShadow({ mode: "open" });
[].slice.call(host?.children).forEach(child => {
root.appendChild(child);
});
const dispatchEvent = () => {
console.log('root click')
root.childNodes.forEach(node => {
let handlerKey = Object.keys(node).find(key => key.includes('__reactEventHandlers'))
if(!handlerKey) {
return
}
node[handlerKey]?.onClick?.()
})
}
root?.addEventListener('click', dispatchEvent)
return () => {
root?.removeEventListener('click', dispatchEvent)
}
}, [attachShadowRef, children])
return <div ref={attachShadowRef}>
{children}
</div>;
}
可以看到正常执行了。
这也是大部分的解决思路,事实上,社区为了解决这个问题,有专门的库 react-shadow-dom-retarget-events ,代码量也非常少:
var reactEvents = ["onAbort", "onAnimationCancel", "onAnimationEnd", "onAnimationIteration", "onAuxClick", "onBlur",
"onChange", "onClick", "onClose", "onContextMenu", "onDoubleClick", "onError", "onFocus", "onGotPointerCapture",
"onInput", "onKeyDown", "onKeyPress", "onKeyUp", "onLoad", "onLoadEnd", "onLoadStart", "onLostPointerCapture",
"onMouseDown", "onMouseMove", "onMouseOut", "onMouseOver", "onMouseUp", "onPointerCancel", "onPointerDown",
"onPointerEnter", "onPointerLeave", "onPointerMove", "onPointerOut", "onPointerOver", "onPointerUp", "onReset",
"onResize", "onScroll", "onSelect", "onSelectionChange", "onSelectStart", "onSubmit", "onTouchCancel",
"onTouchMove", "onTouchStart", "onTouchEnd","onTransitionCancel", "onTransitionEnd", "onDrag", "onDragEnd",
"onDragEnter", "onDragExit", "onDragLeave", "onDragOver", "onDragStart", "onDrop", "onFocusOut"];
var divergentNativeEvents = {
onDoubleClick: 'dblclick'
};
var mimickedReactEvents = {
onInput: 'onChange',
onFocusOut: 'onBlur',
onSelectionChange: 'onSelect'
};
module.exports = function retargetEvents(shadowRoot) {
var removeEventListeners = [];
reactEvents.forEach(function (reactEventName) {
var nativeEventName = getNativeEventName(reactEventName);
function retargetEvent(event) {
var path = event.path || (event.composedPath && event.composedPath()) || composedPath(event.target);
for (var i = 0; i < path.length; i++) {
var el = path[i];
var props = null;
var reactComponent = findReactComponent(el);
var eventHandlers = findReactEventHandlers(el);
if (!eventHandlers) {
props = findReactProps(reactComponent);
} else {
props = eventHandlers;
}
if (reactComponent && props) {
dispatchEvent(event, reactEventName, props);
}
if (reactComponent && props && mimickedReactEvents[reactEventName]) {
dispatchEvent(event, mimickedReactEvents[reactEventName], props);
}
if (event.cancelBubble) {
break;
}
if (el === shadowRoot) {
break;
}
}
}
shadowRoot.addEventListener(nativeEventName, retargetEvent, false);
removeEventListeners.push(function () { shadowRoot.removeEventListener(nativeEventName, retargetEvent, false); })
});
return function () {
removeEventListeners.forEach(function (removeEventListener) {
removeEventListener();
});
};
};
function findReactEventHandlers(item) {
return findReactProperty(item, '__reactEventHandlers');
}
function findReactComponent(item) {
return findReactProperty(item, '_reactInternal');
}
function findReactProperty(item, propertyPrefix) {
for (var key in item) {
if (item.hasOwnProperty(key) && key.indexOf(propertyPrefix) !== -1) {
return item[key];
}
}
}
function findReactProps(component) {
if (!component) return undefined;
if (component.memoizedProps) return component.memoizedProps; // React 16 Fiber
if (component._currentElement && component._currentElement.props) return component._currentElement.props; // React <=15
}
function dispatchEvent(event, eventType, componentProps) {
event.persist = function() {
event.isPersistent = function(){ return true};
};
if (componentProps[eventType]) {
componentProps[eventType](event);
}
}
function getNativeEventName(reactEventName) {
if (divergentNativeEvents[reactEventName]) {
return divergentNativeEvents[reactEventName];
}
return reactEventName.replace(/^on/, '').toLowerCase();
}
function composedPath(el) {
var path = [];
while (el) {
path.push(el);
if (el.tagName === 'HTML') {
path.push(document);
path.push(window);
return path;
}
el = el.parentElement;
}
}
当然,React 也意识到了这个问题,所以在 React 17 之后他专门把事件统一绑定到了 ReactDOM.render 方法的第二个参数中,我们直接升级到 react 17 之后的版本就能直接解决这个问题。
强哥牛逼
React v17的改动也没有 fix shadow dom 点击不生效的问题,冒泡只是到了 div.app