blog icon indicating copy to clipboard operation
blog copied to clipboard

当 Shadow Dom 遇上 React event

Open huruji opened this issue 2 years ago • 2 comments

Shadow DOM 是在 web component 中常提到的概念,其核心作用就是做到与 shadow host 之外的代码做到隔离,把内部结构、样式、行为做隐藏,相当于是一个沙箱的。早期浏览器会用这个特性来封装一些内部标签,比如 video 标签:

在 chrome dev tool 中可以在设置中配置我们允许查看 shadow dom,设置路径为:settings -> preferences -> Elements -> show user agent shadow DOM

image

开启前:

image

开启后: image

在实际使用中我们只需要将对应的 shadow host 节点使用 attachShadow 方法开启即可:

const host = document.querySelector('#host')
const shadowRoot = host.attachShadow({ mode: 'open' })
shadowRoot.innerHTML = `
<h2 style="color: red"> shadow dom </h2>
`;

效果

image

由于 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)
})

效果:

image

这种事件重定向是必要的,因为对于 shadow dom 外部来说,他的眼里只有一个元素,不关心内部的实现,就像我们使用 video 标签一样,我们只认为这是一个和 div 标签一样是一个单一的标签。

但这种重定向对于 React 17之前来说可能就不是那么友好了。众所周知,React 的事件是一个合成事件。

在 DOM 原生事件中,事件是先捕获再冒泡的:

image

在 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 事件并没有执行:

image

按照上面的原理分析,核心还是事件源不对,最容易想到的方式就是我们手动在 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>;
}

效果:

image

可以看到正常执行了。

这也是大部分的解决思路,事实上,社区为了解决这个问题,有专门的库 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 之后的版本就能直接解决这个问题。

image

image

huruji avatar Aug 04 '22 16:08 huruji

强哥牛逼

1234WoodMan avatar Aug 17 '22 10:08 1234WoodMan

React v17的改动也没有 fix shadow dom 点击不生效的问题,冒泡只是到了 div.app

SherlockHomer avatar Nov 05 '23 11:11 SherlockHomer