Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

当 Shadow Dom 遇上 React event #104

Open
huruji opened this issue Aug 4, 2022 · 2 comments
Open

当 Shadow Dom 遇上 React event #104

huruji opened this issue Aug 4, 2022 · 2 comments

Comments

@huruji
Copy link
Owner

huruji commented Aug 4, 2022

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

@1234WoodMan
Copy link

强哥牛逼

@SherlockHomer
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants