如何让 React Portal 与 React Hook 一起工作?
How can I make React Portal work with React Hook?
我有一个特定的需要,需要在浏览器中监听一个自定义事件,然后我有一个按钮可以打开一个弹出窗口 window。我目前正在使用 React Portal 打开另一个 window (PopupWindow),但是当我在其中使用挂钩时它不起作用 - 但如果我使用 类 则可以。通过工作,我的意思是,当 window 打开时,两者都在其下方显示 div,但是当事件中的数据刷新时,带有挂钩的那个会擦除它。要进行测试,请让 window 打开至少 5 秒。
我在 CodeSandbox 中有一个示例,但我也post 在这里,以防网站出现故障或其他问题:
https://codesandbox.io/s/k20poxz2j7
下面的代码不会 运行 因为我不知道如何通过 react cdn 使 react hooks 工作但是你可以用上面的 link 来测试它现在
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>
const [containerEl] = useState(document.createElement('div'));
编辑
Button onClick 事件,调用 first 调用功能组件 PopupWindowWithHooks 并且它按预期工作(创建新的 <div>
,在 useEffect 中附加 <div>
到弹出窗口 window).
事件刷新,调用second调用功能组件PopupWindowWithHooks和第const containerEl = document.createElement('div')
行新建<div>
再次。但是(第二个)新 <div>
永远不会附加到弹出窗口 window,因为行 externalWindow.document.body.appendChild(containerEl)
在 useEffect 挂钩中,它 运行 只会在挂载时清理并在卸载时清理(第二个参数是一个空数组 []).
最后 return ReactDOM.createPortal(props.children, containerEl)
使用第二个参数 containerEl 创建门户 - 新的未附加 <div>
用containerEl作为状态值(useState hook),问题解决:
const [containerEl] = useState(document.createElement('div'));
EDIT2
问题是:每次渲染都会创建一个新的 div
,只需在渲染外创建 div
功能,它应该按预期工作,
const containerEl = document.createElement('div')
const PopupWindowWithHooks = props => {
let externalWindow = null
... rest of your code ...
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);
};
然后将 Portal 与您的 modal/popup:
一起使用
const App = () => (
<Portal>
<MyModal />
</Portal>
)
您可以创建一个小的辅助挂钩,它会先在 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>
);
}
请注意,此解决方案在服务器端渲染期间不会渲染任何内容。
您也可以只使用 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>
)}
</>
)
}
Thought 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)
}
chosen/popular 答案很接近,但它不必要地在每次渲染时创建未使用的 DOM 元素。 useState
钩子可以提供一个函数来确保初始值只创建一次:
const [containerEl] = useState(() => document.createElement('div'));
如果您正在使用 Next.js,您会注意到许多解决方案都不起作用,因为元素选择器使用 document
或 window
对象。由于服务器端呈现限制,这些仅在 useEffect
挂钩等中可用。
我为自己创建了这个解决方案来处理 Next.js 和 ReactDOM.createPortal
功能而不会破坏任何东西。
其他人可以根据需要修复的一些已知问题:
- 我不喜欢必须创建一个元素并将其附加到
documentElement
(可以或应该是 document
?)并且还为模态内容创建一个空容器。我觉得这可以缩小很多。我试过了,但由于 SSR 的性质和 Next.js. ,它变成了意大利面条代码
- 内容(即使您使用多个
<Portal>
元素)始终会添加到您的页面,但不会在服务器端呈现期间添加。这意味着 Google 和其他搜索引擎仍然可以为您的内容编制索引,只要它们等待 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>
);
}
我有一个特定的需要,需要在浏览器中监听一个自定义事件,然后我有一个按钮可以打开一个弹出窗口 window。我目前正在使用 React Portal 打开另一个 window (PopupWindow),但是当我在其中使用挂钩时它不起作用 - 但如果我使用 类 则可以。通过工作,我的意思是,当 window 打开时,两者都在其下方显示 div,但是当事件中的数据刷新时,带有挂钩的那个会擦除它。要进行测试,请让 window 打开至少 5 秒。
我在 CodeSandbox 中有一个示例,但我也post 在这里,以防网站出现故障或其他问题:
https://codesandbox.io/s/k20poxz2j7
下面的代码不会 运行 因为我不知道如何通过 react cdn 使 react hooks 工作但是你可以用上面的 link 来测试它现在
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>
const [containerEl] = useState(document.createElement('div'));
编辑
Button onClick 事件,调用 first 调用功能组件 PopupWindowWithHooks 并且它按预期工作(创建新的 <div>
,在 useEffect 中附加 <div>
到弹出窗口 window).
事件刷新,调用second调用功能组件PopupWindowWithHooks和第const containerEl = document.createElement('div')
行新建<div>
再次。但是(第二个)新 <div>
永远不会附加到弹出窗口 window,因为行 externalWindow.document.body.appendChild(containerEl)
在 useEffect 挂钩中,它 运行 只会在挂载时清理并在卸载时清理(第二个参数是一个空数组 []).
最后 return ReactDOM.createPortal(props.children, containerEl)
使用第二个参数 containerEl 创建门户 - 新的未附加 <div>
用containerEl作为状态值(useState hook),问题解决:
const [containerEl] = useState(document.createElement('div'));
EDIT2
问题是:每次渲染都会创建一个新的 div
,只需在渲染外创建 div
功能,它应该按预期工作,
const containerEl = document.createElement('div')
const PopupWindowWithHooks = props => {
let externalWindow = null
... rest of your code ...
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);
};
然后将 Portal 与您的 modal/popup:
一起使用const App = () => (
<Portal>
<MyModal />
</Portal>
)
您可以创建一个小的辅助挂钩,它会先在 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>
);
}
请注意,此解决方案在服务器端渲染期间不会渲染任何内容。
您也可以只使用 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>
)}
</>
)
}
Thought 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)
}
chosen/popular 答案很接近,但它不必要地在每次渲染时创建未使用的 DOM 元素。 useState
钩子可以提供一个函数来确保初始值只创建一次:
const [containerEl] = useState(() => document.createElement('div'));
如果您正在使用 Next.js,您会注意到许多解决方案都不起作用,因为元素选择器使用 document
或 window
对象。由于服务器端呈现限制,这些仅在 useEffect
挂钩等中可用。
我为自己创建了这个解决方案来处理 Next.js 和 ReactDOM.createPortal
功能而不会破坏任何东西。
其他人可以根据需要修复的一些已知问题:
- 我不喜欢必须创建一个元素并将其附加到
documentElement
(可以或应该是document
?)并且还为模态内容创建一个空容器。我觉得这可以缩小很多。我试过了,但由于 SSR 的性质和 Next.js. ,它变成了意大利面条代码
- 内容(即使您使用多个
<Portal>
元素)始终会添加到您的页面,但不会在服务器端呈现期间添加。这意味着 Google 和其他搜索引擎仍然可以为您的内容编制索引,只要它们等待 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>
);
}