React Portal 与 Dialog

深入理解 React Portal 在 Dialog/Modal 中的应用:焦点 trapping、滚动锁定、无障碍访问、以及常见的模态框实现模式。

React Portal 与 Dialog

为什么 Dialog 需要 Portal

Dialog(模态框)在视觉上应该「跳出」当前的文档流,覆盖整个页面。但在 DOM 结构上,Dialog 通常是页面的子组件,受父组件样式影响:

function App() {
  return (
    <div style={{ overflow: 'hidden' }}>  {/* 父组件限制了 overflow */}
      <button onClick={() => setShowModal(true)}>Open Modal</button>

      {showModal && (
        <div className="modal-overlay">
          <div className="modal-content">
            Modal Content
          </div>
        </div>
      )}
    </div>
  );
}

这个模态框虽然视觉上覆盖了页面,但在 DOM 中仍然是 <div style={{ overflow: 'hidden' }}> 的子元素。这意味着:

  • 可能被父组件的 overflow: hidden 裁剪
  • 可能受父组件 z-index 影响
  • 滚动事件可能被子元素捕获

Portal 解决了这个问题。


createPortal 的基础用法

import { createPortal } from 'react-dom';

function Modal({ children, isOpen }) {
  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay">
      <div className="modal-content">
        {children}
      </div>
    </div>,
    document.body  // 渲染到 body
  );
}

Portal 把组件渲染到 DOM 树的任何位置(通常是 document.body),而不影响组件树结构。


Focus Trapping(焦点捕获)

模态框的一个重要无障碍(a11y)要求是:当模态框打开时,焦点应该被困在模态框内部,直到模态框关闭。

为什么需要 Focus Trapping

  1. 键盘用户:他们使用 Tab 键导航,应该只能在模态框内导航
  2. 屏幕阅读器用户:焦点应该在模态框内朗读
  3. 防止焦点丢失:如果焦点跑到模态框后面,用户可能无法找到

实现 Focus Trapping

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);

  useEffect(() => {
    if (!isOpen) return;

    const modal = modalRef.current;
    if (!modal) return;

    // 获取模态框内的可聚焦元素
    const focusableElements = modal.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );

    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    // 保存之前焦点的位置
    const previouslyFocused = document.activeElement;

    // 聚焦到第一个元素
    firstElement?.focus();

    const handleKeyDown = (e) => {
      if (e.key === 'Escape') {
        onClose();
        return;
      }

      // Shift + Tab:移动到最后一个元素
      if (e.key === 'Tab' && e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
        return;
      }

      // Tab:移动到第一个元素
      if (e.key === 'Tab') {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    };

    modal.addEventListener('keydown', handleKeyDown);

    return () => {
      modal.removeEventListener('keydown', handleKeyDown);
      // 恢复之前的焦点
      previouslyFocused?.focus();
    };
  }, [isOpen, onClose]);

  return createPortal(
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      className="modal-overlay"
      onClick={(e) => e.target === e.currentTarget && onClose()}
    >
      <div className="modal-content">
        {children}
      </div>
    </div>,
    document.body
  );
}

滚动锁定(Scroll Lock)

当模态框打开时,应该禁止背景页面滚动。

基础实现

function useScrollLock(locked) {
  useEffect(() => {
    if (!locked) return;

    const originalOverflow = document.body.style.overflow;
    const originalPaddingRight = document.body.style.paddingRight;

    // 计算滚动条的宽度,防止布局偏移
    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;

    document.body.style.overflow = 'hidden';
    document.body.style.paddingRight = `${scrollbarWidth}px`;

    return () => {
      document.body.style.overflow = originalOverflow;
      document.body.style.paddingRight = originalPaddingRight;
    };
  }, [locked]);
}

使用

function Modal({ isOpen, onClose, children }) {
  useScrollLock(isOpen);

  return createPortal(
    isOpen && (
      <div className="modal-overlay" onClick={onClose}>
        <div onClick={e => e.stopPropagation()}>
          {children}
        </div>
      </div>
    ),
    document.body
  );
}

无障碍(Accessibility)属性

ARIA 属性

function Modal({ isOpen, onClose, title, children }) {
  return createPortal(
    isOpen && (
      <div
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        aria-describedby="modal-description"
        className="modal-overlay"
      >
        <h2 id="modal-title" className="sr-only">{title}</h2>
        <div id="modal-description">
          {children}
        </div>
        <button onClick={onClose} aria-label="Close modal">
          ×
        </button>
      </div>
    ),
    document.body
  );
}

返回对话框(Alertdialog)

如果模态框的目的是警告用户(而不是一般性交互),使用 role="alertdialog"

function ConfirmDialog({ isOpen, onConfirm, onCancel, message }) {
  return createPortal(
    isOpen && (
      <div
        role="alertdialog"
        aria-modal="true"
        aria-labelledby="dialog-title"
        aria-describedby="dialog-message"
      >
        <h2 id="dialog-title">Confirm Action</h2>
        <p id="dialog-message">{message}</p>
        <button onClick={onCancel}>Cancel</button>
        <button onClick={onConfirm}>Confirm</button>
      </div>
    ),
    document.body
  );
}

完整的 Modal Hook 实现

function useModal({ isOpen, onClose }) {
  const previousActiveElement = useRef(null);

  useEffect(() => {
    if (isOpen) {
      previousActiveElement.current = document.activeElement;
      document.body.style.overflow = 'hidden';
    } else {
      document.body.style.overflow = '';
      previousActiveElement.current?.focus();
    }

    return () => {
      document.body.style.overflow = '';
    };
  }, [isOpen]);

  const handleKeyDown = useCallback((e) => {
    if (e.key === 'Escape') {
      onClose();
    }
  }, [onClose]);

  useEffect(() => {
    if (isOpen) {
      document.addEventListener('keydown', handleKeyDown);
      return () => document.removeEventListener('keydown', handleKeyDown);
    }
  }, [isOpen, handleKeyDown]);

  return { previousActiveElement };
}

常见的 Modal 实现模式

1. Render Props 模式

function Modal({ isOpen, renderContent }) {
  return createPortal(
    isOpen && (
      <div className="modal-overlay">
        {renderContent()}
      </div>
    ),
    document.body
  );
}

// 使用
<Modal isOpen={showModal} renderContent={() => (
  <div className="modal">
    <h2>Hello</h2>
    <button onClick={() => setShowModal(false)}>Close</button>
  </div>
)} />

2. Compound Components 模式

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div onClick={e => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.body
  );
}

Modal.Title = function ModalTitle({ children }) {
  return <h2>{children}</h2>;
};

Modal.Body = function ModalBody({ children }) {
  return <div>{children}</div>;
};

Modal.Footer = function ModalFooter({ children }) {
  return <div className="modal-footer">{children}</div>;
};

// 使用
<Modal isOpen={showModal} onClose={() => setShowModal(false)}>
  <Modal.Title>Confirmation</Modal.Title>
  <Modal.Body>Are you sure?</Modal.Body>
  <Modal.Footer>
    <button onClick={() => setShowModal(false)}>Cancel</button>
    <button onClick={handleConfirm}>Confirm</button>
  </Modal.Footer>
</Modal>

3. Context + Hook 模式

const ModalContext = createContext(null);

function ModalProvider({ children }) {
  const [modals, setModals] = useState({});

  const openModal = (id, content) => {
    setModals(prev => ({ ...prev, [id]: { isOpen: true, content } }));
  };

  const closeModal = (id) => {
    setModals(prev => ({
      ...prev,
      [id]: { ...prev[id], isOpen: false }
    }));
  };

  return (
    <ModalContext.Provider value={{ openModal, closeModal }}>
      {children}
      {Object.entries(modals).map(([id, { isOpen, content }]) => (
        <Modal key={id} isOpen={isOpen} onClose={() => closeModal(id)}>
          {content}
        </Modal>
      ))}
    </ModalContext.Provider>
  );
}

export const useModal = () => useContext(ModalContext);

第三方库

在生产环境中,通常使用成熟的库来确保无障碍支持:

  • Radix UI Dialog:无障碍优先的 Dialog 组件
  • Reach UI Dialog:另一个无障碍优先的实现
  • Headless UI:Tailwind CSS 团队的 headless 组件库

面试中的表达

面试中聊到 Portal 和 Dialog,通常是在考察你对 React DOM 结构和无障碍访问的理解:

Portal 的核心用途是把 React 子树渲染到 DOM 树的其他位置,这对模态框、tooltip 这类需要「跳出」当前层级的组件特别有用。createPortal 接受两个参数:要渲染的内容和目标 DOM 节点。

实现模态框的关键技术点包括:焦点捕获(focus trapping)——用 Tab 键时焦点不能跑出模态框;滚动锁定——模态框打开时背景不能滚动;无障碍属性——role="dialog"、aria-modal="true" 等。

关于无障碍,很多开发者会忽略这一点。但对于键盘用户和屏幕阅读器用户来说,正确实现 focus trapping 和 ARIA 属性是基本要求。


延展阅读