简体   繁体   English

如何让 React Portal 与 React Hook 一起工作?

[英]How can I make React Portal work with React Hook?

I have this specific need to listen to a custom event in the browser and from there, I have a button that will open a popup window.我有一个在浏览器中监听自定义事件的特定需求,然后我有一个按钮可以打开一个弹出窗口。 I'm currently using React Portal to open this other window (PopupWindow), but when I use hooks inside it doesn't work - but works if I use classes.我目前正在使用 React Portal 打开另一个窗口(PopupWindow),但是当我在里面使用钩子时它不起作用 - 但如果我使用类就可以工作。 By working I mean, when the window opens, both shows the div below it but the one with hooks erases it when the data from the event refreshes.通过工作,我的意思是,当窗口打开时,两者都显示其下方的 div,但带有钩子的 div 在事件数据刷新时将其擦除。 To test, leave the window open for at least 5 seconds.要进行测试,请将窗口打开至少 5 秒钟。

I have an example in a CodeSandbox, but I'm also post here in case the website is down or something:我在 CodeSandbox 中有一个示例,但我也会在这里发布以防网站关闭或其他原因:

https://codesandbox.io/s/k20poxz2j7 https://codesandbox.io/s/k20poxz2j7

The code below won't run because I don't know how to make react hooks work via react cdn but you can test it with the link above by now下面的代码将无法运行,因为我不知道如何通过 react cdn 使 react hooks 工作,但您现在可以使用上面的链接对其进行测试

 const { useState, useEffect } = React; function getRandom(min, max) { const first = Math.ceil(min) const last = Math.floor(max) return Math.floor(Math.random() * (last - first + 1)) + first } function replaceWithRandom(someData) { let newData = {} for (let d in someData) { newData[d] = getRandom(someData[d], someData[d] + 500) } return newData } const PopupWindowWithHooks = props => { const containerEl = document.createElement('div') let externalWindow = null useEffect( () => { externalWindow = window.open( '', '', `width=600,height=400,left=200,top=200` ) externalWindow.document.body.appendChild(containerEl) externalWindow.addEventListener('beforeunload', () => { props.closePopupWindowWithHooks() }) console.log('Created Popup Window') return function cleanup() { console.log('Cleaned up Popup Window') externalWindow.close() externalWindow = null } }, // Only re-renders this component if the variable changes [] ) return ReactDOM.createPortal(props.children, containerEl) } class PopupWindow extends React.Component { containerEl = document.createElement('div') externalWindow = null componentDidMount() { this.externalWindow = window.open( '', '', `width=600,height=400,left=200,top=200` ) this.externalWindow.document.body.appendChild(this.containerEl) this.externalWindow.addEventListener('beforeunload', () => { this.props.closePopupWindow() }) console.log('Created Popup Window') } componentWillUnmount() { console.log('Cleaned up Popup Window') this.externalWindow.close() } render() { return ReactDOM.createPortal( this.props.children, this.containerEl ) } } function App() { let data = { something: 600, other: 200 } let [dataState, setDataState] = useState(data) useEffect(() => { let interval = setInterval(() => { setDataState(replaceWithRandom(dataState)) const event = new CustomEvent('onOverlayDataUpdate', { detail: dataState }) document.dispatchEvent(event) }, 5000) return function clear() { clearInterval(interval) } }, []) useEffect( function getData() { document.addEventListener('onOverlayDataUpdate', e => { setDataState(e.detail) }) return function cleanup() { document.removeEventListener( 'onOverlayDataUpdate', document ) } }, [dataState] ) console.log(dataState) // State handling const [isPopupWindowOpen, setIsPopupWindowOpen] = useState(false) const [ isPopupWindowWithHooksOpen, setIsPopupWindowWithHooksOpen ] = useState(false) const togglePopupWindow = () => setIsPopupWindowOpen(!isPopupWindowOpen) const togglePopupWindowWithHooks = () => setIsPopupWindowWithHooksOpen(!isPopupWindowWithHooksOpen) const closePopupWindow = () => setIsPopupWindowOpen(false) const closePopupWindowWithHooks = () => setIsPopupWindowWithHooksOpen(false) // Side Effect useEffect(() => window.addEventListener('beforeunload', () => { closePopupWindow() closePopupWindowWithHooks() }) ) return ( <div> <button type="buton" onClick={togglePopupWindow}> Toggle Window </button> <button type="buton" onClick={togglePopupWindowWithHooks}> Toggle Window With Hooks </button> {isPopupWindowOpen && ( <PopupWindow closePopupWindow={closePopupWindow}> <div>What is going on here?</div> <div>I should be here always!</div> </PopupWindow> )} {isPopupWindowWithHooksOpen && ( <PopupWindowWithHooks closePopupWindowWithHooks={closePopupWindowWithHooks} > <div>What is going on here?</div> <div>I should be here always!</div> </PopupWindowWithHooks> )} </div> ) } const rootElement = document.getElementById('root') ReactDOM.render(<App />, rootElement)
 <script crossorigin src="https://unpkg.com/react@16.7.0-alpha.2/umd/react.development.js"></script> <script crossorigin src="https://unpkg.com/react-dom@16.7.0-alpha.2/umd/react-dom.development.js"></script> <div id="root"></div>

Thought id chime in with a solution that has worked very well for me which creates a portal element dynamically, with optional className and element type via props and removes said element when the component unmounts:思想 id 与一个对我来说非常有效的解决方案相呼应,它动态地创建一个门户元素,通过 props 使用可选的 className 和元素类型,并在组件卸载时删除所述元素:

export const Portal = ({
  children,
  className = 'root-portal',
  element = 'div',
}) => {
  const [container] = React.useState(() => {
    const el = document.createElement(element)
    el.classList.add(className)
    return el
  })

  React.useEffect(() => {
    document.body.appendChild(container)
    return () => {
      document.body.removeChild(container)
    }
  }, [])

  return ReactDOM.createPortal(children, container)
}

const [containerEl] = useState(document.createElement('div'));

EDIT编辑

Button onClick event, invoke first call of functional component PopupWindowWithHooks and it works as expected (create new <div> , in useEffect append <div> to popup window).按钮 onClick 事件,调用功能组件PopupWindowWithHooks 的第一次调用,它按预期工作(创建新的<div> ,在 useEffect 中将<div>附加到弹出窗口)。

The event refresh, invoke second call of functional component PopupWindowWithHooks and line const containerEl = document.createElement('div') create new <div> again.事件刷新,再次调用功能组件PopupWindowWithHooks和行const containerEl = document.createElement('div') create new <div> But that (second) new <div> will never be appended to popup window, because line externalWindow.document.body.appendChild(containerEl) is in useEffect hook that would run only on mount and clean up on unmount (the second argument is an empty array []).但是那个(第二个)新的<div>永远不会被附加到弹出窗口,因为externalWindow.document.body.appendChild(containerEl)在 useEffect 钩子中,它只会在挂载时运行并在卸载时清理(第二个参数是空数组 [])。

Finally return ReactDOM.createPortal(props.children, containerEl) create portal with second argument containerEl - new unappended <div>最后return ReactDOM.createPortal(props.children, containerEl)使用第二个参数containerEl创建门户 - 新的未附加<div>

With containerEl as a stateful value (useState hook), problem is solved:containerEl作为有状态值(useState hook),问题解决了:

const [containerEl] = useState(document.createElement('div'));

EDIT2编辑2

Code Sandbox: https://codesandbox.io/s/l5j2zp89k9代码沙盒: https : //codesandbox.io/s/l5j2zp89k9

You could create a small helper hook which would create an element in the dom first:您可以创建一个小的辅助钩子,它会首先在 dom 中创建一个元素:

import { useLayoutEffect, useRef } from "react";
import { createPortal } from "react-dom";

const useCreatePortalInBody = () => {
    const wrapperRef = useRef(null);
    if (wrapperRef.current === null && typeof document !== 'undefined') {
        const div = document.createElement('div');
        div.setAttribute('data-body-portal', '');
        wrapperRef.current = div;
    }
    useLayoutEffect(() => {
        const wrapper = wrapperRef.current;
        if (!wrapper || typeof document === 'undefined') {
            return;
        }
        document.body.appendChild(wrapper);
        return () => {
            document.body.removeChild(wrapper);
        }
    }, [])
    return (children => wrapperRef.current && createPortal(children, wrapperRef.current);
}

And your component could look like this:您的组件可能如下所示:

const Demo = () => {
    const createBodyPortal = useCreatePortalInBody();
    return createBodyPortal(
        <div style={{position: 'fixed', top: 0, left: 0}}>
            In body
        </div>
    );
}

Please note that this solution would not render anything during server side rendering.请注意,此解决方案不会在服务器端渲染期间渲染任何内容。

The chosen/popular answer is close, but it needlessly creates unused DOM elements on every render.选择/流行的答案很接近,但它不必要地在每次渲染时创建未使用的 DOM 元素。 The useState hook can be supplied a function to make sure the initial value is only created once:可以为useState钩子提供一个函数来确保初始值只创建一次:

const [containerEl] = useState(() => document.createElement('div'));
const Portal = ({ children }) => {
  const [modalContainer] = useState(document.createElement('div'));
  useEffect(() => {
    // Find the root element in your DOM
    let modalRoot = document.getElementById('modal-root');
    // If there is no root then create one
    if (!modalRoot) {
      const tempEl = document.createElement('div');
      tempEl.id = 'modal-root';
      document.body.append(tempEl);
      modalRoot = tempEl;
    }
    // Append modal container to root
    modalRoot.appendChild(modalContainer);
    return function cleanup() {
      // On cleanup remove the modal container
      modalRoot.removeChild(modalContainer);
    };
  }, []); // <- The empty array tells react to apply the effect on mount/unmount

  return ReactDOM.createPortal(children, modalContainer);
};

Then use the Portal with your modal/popup:然后将 Portal 与您的模式/弹出窗口一起使用:

const App = () => (
  <Portal>
    <MyModal />
  </Portal>
)

The issue is: a new div is created on every render, just create the div outside render function and it should work as expected,问题是:在每次渲染时都会创建一个新的div ,只需在渲染函数之外创建div ,它就会按预期工作,

const containerEl = document.createElement('div')
const PopupWindowWithHooks = props => {
   let externalWindow = null
   ... rest of your code ...

https://codesandbox.io/s/q9k8q903z6 https://codesandbox.io/s/q9k8q903z6

If you are working with Next.js, you'll notice that many solutions don't work because of element selectors using the document or window objects.如果您正在使用 Next.js,您会注意到许多解决方案由于使用documentwindow对象的元素选择器而不起作用。 Those are only available within useEffect hooks and such, because of server-side rendering limitations.由于服务器端渲染限制,这些仅在useEffect钩子等中可用。

I've created this solution for myself to deal with Next.js and ReactDOM.createPortal functionality without breaking anything.我为自己创建了这个解决方案来处理 Next.js 和ReactDOM.createPortal功能,而不会破坏任何东西。

Some known issues that others can fix if they like:如果其他人愿意,可以解决一些已知问题:

  1. I don't like having to create and append an element to the documentElement (could or should be document ?) and also creating an empty container for the modal content.我不喜欢必须创建一个元素并将其附加到documentElement (可以还是应该是document ?),也不喜欢为模态内容创建一个空容器。 I feel this can be shrunk down quite a bit.我觉得这可以缩小很多。 I tried but it became spaghetti-code due to the nature of SSR and Next.js.我尝试过,但由于 SSR 和 Next.js 的性质,它变成了意大利面条式代码。
  2. The content (even if you use multiple <Portal> elements) is always added to your page, but not during server-side rendering .内容(即使您使用多个<Portal>元素)始终添加到您的页面,但不会在服务器端呈现期间添加。 This means that Google and other search engines can still index your content, as long as they wait for the JavaScript to finish doing its job client-side.这意味着 Google 和其他搜索引擎仍然可以索引您的内容,只要它们等待 JavaScript 在客户端完成其工作即可。 It would be great if someone can fix this to also render server-side so that the initial page load gives the visitor the full content.如果有人可以解决此问题以呈现服务器端,以便初始页面加载为访问者提供完整内容,那就太好了。

React Hooks and Next.js Portal component React Hooks 和 Next.js Portal 组件

/**
 * Create a React Portal to contain the child elements outside of your current
 * component's context.
 * @param visible {boolean} - Whether the Portal is visible or not. This merely changes the container's styling.
 * @param containerId {string} - The ID attribute used for the Portal container. Change to support multiple Portals.
 * @param children {JSX.Element} - A child or list of children to render in the document.
 * @return {React.ReactPortal|null}
 * @constructor
 */
const Portal = ({ visible = false, containerId = 'modal-root', children }) => {
  const [modalContainer, setModalContainer] = useState();

  /**
   * Create the modal container element that we'll put the children in.
   * Also make sure the documentElement has the modal root element inserted
   * so that we do not have to manually insert it into our HTML.
   */
  useEffect(() => {
    const modalRoot = document.getElementById(containerId);
    setModalContainer(document.createElement('div'));

    if (!modalRoot) {
      const containerDiv = document.createElement('div');
      containerDiv.id = containerId;
      document.documentElement.appendChild(containerDiv);
    }
  }, [containerId]);

  /**
   * If both the modal root and container elements are present we want to
   * insert the container into the root.
   */
  useEffect(() => {
    const modalRoot = document.getElementById(containerId);

    if (modalRoot && modalContainer) {
      modalRoot.appendChild(modalContainer);
    }

    /**
     * On cleanup we remove the container from the root element.
     */
    return function cleanup() {
      if (modalContainer) {
        modalRoot.removeChild(modalContainer);
      }
    };
  }, [containerId, modalContainer]);

  /**
   * To prevent the non-visible elements from taking up space on the bottom of
   * the documentElement, we want to use CSS to hide them until we need them.
   */
  useEffect(() => {
    if (modalContainer) {
      modalContainer.style.position = visible ? 'unset' : 'absolute';
      modalContainer.style.height = visible ? 'auto' : '0px';
      modalContainer.style.overflow = visible ? 'auto' : 'hidden';
    }
  }, [modalContainer, visible]);

  /**
   * Make sure the modal container is there before we insert any of the
   * Portal contents into the document.
   */
  if (!modalContainer) {
    return null;
  }

  /**
   * Append the children of the Portal component to the modal container.
   * The modal container already exists in the modal root.
   */
  return ReactDOM.createPortal(children, modalContainer);
};

How to use:如何使用:

const YourPage = () => {
  const [isVisible, setIsVisible] = useState(false);
  return (
    <section>
      <h1>My page</h1>

      <button onClick={() => setIsVisible(!isVisible)}>Toggle!</button>

      <Portal visible={isVisible}>
        <h2>Your content</h2>
        <p>Comes here</p>
      </Portal>
    </section>
  );
}

You could also just use react-useportal .你也可以只使用react-useportal It works like:它的工作原理如下:

import usePortal from 'react-useportal'

const App = () => {
  const { openPortal, closePortal, isOpen, Portal } = usePortal()
  return (
    <>
      <button onClick={openPortal}>
        Open Portal
      </button>
      {isOpen && (
        <Portal>
          <p>
            This is more advanced Portal. It handles its own state.{' '}
            <button onClick={closePortal}>Close me!</button>, hit ESC or
            click outside of me.
          </p>
        </Portal>
      )}
    </>
  )
}

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM