如何通过Escape键仅关闭特定Portal中的最后打开弹窗?
问题描述
我正在编写一个小型弹窗UiKit,遇到了一个问题:打开主弹窗内的Popover后,按Escape键时主弹窗也会被关闭,希望实现仅关闭最后打开的弹窗。
代码结构如下:
主组件嵌套:
<MainPopup> <Popover /> </MainPopup>
每个弹窗的内部结构:
<KeyboardListener> <Portal> // 弹窗内容逻辑 </Portal> </KeyboardListener>
<Portal />组件通过React.createPortal渲染弹窗到body,代码:
import { FC, useEffect, useState } from 'react' import ReactDOM from 'react-dom' interface PortalProps { children: React.ReactNode } const Portal: FC<PortalProps> = ({ children }) => { const [container] = useState(() => document.createElement('div')) useEffect(() => { document.body.appendChild(container) return () => { document.body.removeChild(container) } }, [container]) return ReactDOM.createPortal(children, container) } export default Portal
原<KeyboardListener />组件给document.body绑定Escape监听,代码:
import { FC, useEffect } from "react" interface KeyboardListenerProps { onClose: () => void children: React.ReactNode } const KeyboardListener: FC<KeyboardListenerProps> = ({onClose, children}) => { useEffect(() => { const closeOnEscapeKey = (e: KeyboardEvent) => { e.stopImmediatePropagation() if (e.key === 'Escape') onClose() } document.body.addEventListener('keydown', closeOnEscapeKey) return () => document.body.removeEventListener('keydown', closeOnEscapeKey) }, [onClose]) return <>{children}</> } export default KeyboardListener
解决方案
问题根源是每个弹窗都给document.body绑定了Escape事件,触发时所有绑定的事件都会执行。我们可以通过全局弹窗栈管理来实现仅关闭最后打开的弹窗:
步骤1:创建弹窗管理上下文
通过React Context维护一个弹窗栈,跟踪所有打开的弹窗,全局只绑定一次Escape事件,仅触发栈顶弹窗的关闭逻辑。
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; // 弹窗项类型定义 interface ModalItem { id: string; onClose: () => void; } // 上下文类型 interface ModalContextType { modals: ModalItem[]; addModal: (modal: ModalItem) => void; removeModal: (id: string) => void; } const ModalContext = createContext<ModalContextType | undefined>(undefined); // 上下文提供者组件 export const ModalProvider = ({ children }: { children: ReactNode }) => { const [modals, setModals] = useState<ModalItem[]>([]); // 添加弹窗到栈 const addModal = (modal: ModalItem) => { setModals(prev => [...prev, modal]); }; // 从栈中移除弹窗 const removeModal = (id: string) => { setModals(prev => prev.filter(m => m.id !== id)); }; // 全局监听Escape键,仅关闭栈顶弹窗 useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && modals.length > 0) { e.stopImmediatePropagation(); const topModal = modals[modals.length - 1]; topModal.onClose(); } }; document.body.addEventListener('keydown', handleEscape); return () => document.body.removeEventListener('keydown', handleEscape); }, [modals]); return ( <ModalContext.Provider value={{ modals, addModal, removeModal }}> {children} </ModalContext.Provider> ); }; // 自定义Hook,用于获取上下文 export const useModal = () => { const context = useContext(ModalContext); if (!context) { throw new Error('useModal必须在ModalProvider内部使用'); } return context; };
步骤2:修改KeyboardListener组件
不再自行绑定事件,改为通过上下文注册弹窗到栈中:
import { FC, useEffect, useState } from "react"; import { useModal } from "./ModalContext"; interface KeyboardListenerProps { onClose: () => void; children: React.ReactNode; } const KeyboardListener: FC<KeyboardListenerProps> = ({ onClose, children }) => { const { addModal, removeModal } = useModal(); // 生成唯一弹窗ID const [modalId] = useState(() => `modal-${Date.now()}-${Math.random()}`); useEffect(() => { // 组件挂载时将弹窗加入栈 addModal({ id: modalId, onClose }); // 组件卸载时从栈中移除 return () => removeModal(modalId); }, [addModal, removeModal, modalId, onClose]); return <>{children}</>; }; export default KeyboardListener;
步骤3:在应用根节点包裹ModalProvider
确保所有弹窗组件都能访问到上下文:
import { ModalProvider } from './ModalContext'; function App() { return ( <ModalProvider> {/* 应用其他内容 */} </ModalProvider> ); }
原理说明
- 全局仅绑定一次Escape事件,避免多个监听器冲突
- 用栈结构维护弹窗打开顺序,最后打开的弹窗处于栈顶
- 按Escape时仅触发栈顶弹窗的关闭方法,实现层级关闭逻辑
- 弹窗卸载时自动从栈中移除,保证栈状态与实际弹窗状态一致
内容的提问来源于stack exchange,提问作者Ilya Kruchinin
相关产品推荐
相关产品推荐

