You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何通过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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.22 02:36:18