22

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. 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. To test, leave the window open for at least 5 seconds.

I have an example in a CodeSandbox, but I'm also post here in case the website is down or something:

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

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>

4

8 回答 8

24

想法 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)
}

于 2019-12-03T10:22:24.607 回答
12

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

编辑

按钮 onClick 事件,调用功能组件PopupWindowWithHooks的第一次调用,它按预期工作(创建新的,在 useEffect 中附加到弹出窗口)。<div><div>

事件刷新,调用功能组件PopupWindowWithHooks的第二次调用并再次创建新的行。但是那个(第二个)新的永远不会被附加到弹出窗口中,因为行是在 useEffect 钩子中,它只会在挂载时运行并在卸载时清理(第二个参数是一个空数组 [])。const containerEl = document.createElement('div')<div><div>externalWindow.document.body.appendChild(containerEl)

最后用第二个参数containerElreturn ReactDOM.createPortal(props.children, containerEl)创建门户- 新的未附加<div>

使用containerEl作为有状态值(useState hook),问题就解决了:

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

编辑2

代码沙箱:https ://codesandbox.io/s/l5j2zp89k9

于 2018-12-05T10:52:06.187 回答
7

你可以创建一个小的辅助钩子,它会首先在 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);
}

您的组件可能如下所示:

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

请注意,此解决方案在服务器端渲染期间不会渲染任何内容。

于 2019-03-15T10:23:52.470 回答
5

选择/流行的答案很接近,但它不必要地在每次渲染上创建未使用的 DOM 元素。可以为useState钩子提供一个函数来确保初始值只创建一次:

const [containerEl] = useState(() => document.createElement('div'));
于 2020-04-30T23:21:54.373 回答
2
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);
};

然后将门户与您的模式/弹出窗口一起使用:

const App = () => (
  <Portal>
    <MyModal />
  </Portal>
)
于 2019-03-13T12:47:16.080 回答
1

如果您正在使用 Next.js,您会注意到许多解决方案无法正常工作,因为元素选择器使用了documentorwindow对象。useEffect由于服务器端渲染限制,这些仅在钩子等中可用。

我为自己创建了这个解决方案来处理 Next.js 和ReactDOM.createPortal功能而不会破坏任何东西。

其他人可以根据需要解决的一些已知问题:

  1. 我不喜欢必须创建一个元素并将其附加到documentElement(可以或应该是document?)并且还为模态内容创建一个空容器。我觉得这可以缩小很多。我试过了,但由于 SSR 和 Next.js 的性质,它变成了意大利面条代码。
  2. 内容(即使您使用多个<Portal>元素)始终会添加到您的页面中,但不会在服务器端呈现期间添加。这意味着谷歌和其他搜索引擎仍然可以索引您的内容,只要他们等待 JavaScript 完成其在客户端的工作。如果有人可以修复此问题以同时呈现服务器端,以便初始页面加载为访问者提供完整内容,那就太好了。

React Hooks 和 Next.js 门户组件

/**
 * 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);
};

如何使用:

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>
  );
}
于 2021-07-23T14:02:01.057 回答
0

问题是:div每次渲染都会创建一个新的,只需创建div外部渲染函数,它应该可以按预期工作,

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

https://codesandbox.io/s/q9k8q903z6

于 2018-12-10T14:32:34.920 回答
-3

您也可以只使用react-useportal. 它的工作原理如下:

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>
      )}
    </>
  )
}
于 2019-04-18T03:19:02.300 回答