React Portal 与模态框实现

220 阅读2分钟

React 版本:18.x 难度:中级

考察要点

  1. createPortal 的使用
  2. 模态框生命周期管理
  3. 事件冒泡处理
  4. 可访问性考虑

解答:

1. 概念解释

基本定义

  • Portal:将子节点渲染到父组件 DOM 层次之外的机制
  • 模态框:覆盖在应用程序主窗口上的对话框
  • 事件传播:保持 React 事件系统的完整性

工作原理

  • DOM 节点的跨层级渲染
  • 事件冒泡遵循 React 树而非 DOM 树
  • 保持上下文和状态的传递

应用场景

  • 模态对话框
  • 工具提示
  • 悬浮卡片
  • 全局通知

2. 代码示例

基础示例:
import React, { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

// 👉 基础模态框组件
export const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  title,
  children
}) => {
  // ESC 键关闭处理
  const handleEscapeKey = useCallback((event: KeyboardEvent) => {
    if (event.key === 'Escape') {
      onClose();
    }
  }, [onClose]);

  // 添加/移除事件监听器
  useEffect(() => {
    if (isOpen) {
      document.addEventListener('keydown', handleEscapeKey);
      document.body.style.overflow = 'hidden';
    }

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

  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div 
        className="modal-content"
        onClick={e => e.stopPropagation()}
        role="dialog"
        aria-labelledby="modal-title"
        aria-modal="true"
      >
        <div className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button 
            onClick={onClose}
            aria-label="Close modal"
            className="close-button"
          >
            ×
          </button>
        </div>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>,
    document.body
  );
};
进阶示例:
import React, { useState, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';

// 👉 模态框上下文类型
interface ModalContextType {
  openModal: (content: React.ReactNode) => void;
  closeModal: () => void;
}

// 👉 创建上下文
const ModalContext = React.createContext<ModalContextType | undefined>(undefined);

// 👉 模态框提供者组件
export const ModalProvider: React.FC<{ children: React.ReactNode }> = ({ 
  children 
}) => {
  const [modalContent, setModalContent] = useState<React.ReactNode | null>(null);
  const [isOpen, setIsOpen] = useState(false);
  const modalRef = useRef<HTMLDivElement>(null);

  const openModal = useCallback((content: React.ReactNode) => {
    setModalContent(content);
    setIsOpen(true);
  }, []);

  const closeModal = useCallback(() => {
    setIsOpen(false);
    setTimeout(() => setModalContent(null), 300); // 动画结束后清除内容
  }, []);

  // 处理动画
  const getAnimationClass = () => {
    if (!isOpen && modalContent) return 'modal-exit';
    if (isOpen) return 'modal-enter';
    return '';
  };

  return (
    <ModalContext.Provider value={{ openModal, closeModal }}>
      {children}
      {modalContent && createPortal(
        <div 
          className={`modal-container ${getAnimationClass()}`}
          ref={modalRef}
          role="dialog"
          aria-modal="true"
        >
          <div className="modal-backdrop" onClick={closeModal} />
          <div className="modal-window">
            <div className="modal-content">
              {modalContent}
            </div>
          </div>
        </div>,
        document.body
      )}
    </ModalContext.Provider>
  );
};

// 👉 自定义 Hook
export const useModal = () => {
  const context = React.useContext(ModalContext);
  if (!context) {
    throw new Error('useModal must be used within a ModalProvider');
  }
  return context;
};

// 👉 使用示例
const ModalExample: React.FC = () => {
  const { openModal } = useModal();

  const handleOpenModal = () => {
    openModal(
      <div>
        <h2>Custom Modal Content</h2>
        <p>This is a dynamic modal content example.</p>
      </div>
    );
  };

  return (
    <button onClick={handleOpenModal}>
      Open Modal
    </button>
  );
};

3. 注意事项与最佳实践

❌ 常见错误示例

// ❌ 错误示范:不处理事件冒泡
const BadModal: React.FC = () => {
  return createPortal(
    <div className="modal">
      <div className="content">
        {/* 点击内容区域也会触发关闭 */}
        Content
      </div>
    </div>,
    document.body
  );
};

// ❌ 错误示范:忽略可访问性
const AnotherBadModal: React.FC = () => {
  return createPortal(
    <div>
      {/* 缺少适当的 ARIA 属性 */}
      <div>Modal content</div>
    </div>,
    document.body
  );
};

✅ 正确实现方式

// ✅ 正确示范:完整的模态框实现
const GoodModal: React.FC<{
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}> = ({ isOpen, onClose, children }) => {
  // 处理点击事件
  const handleContentClick = (e: React.MouseEvent) => {
    e.stopPropagation();
  };

  return createPortal(
    <div 
      className={`modal-overlay ${isOpen ? 'open' : ''}`}
      onClick={onClose}
      role="dialog"
      aria-modal="true"
    >
      <div 
        className="modal-content"
        onClick={handleContentClick}
      >
        <div className="modal-header">
          <button 
            onClick={onClose}
            aria-label="Close modal"
          >
            Close
          </button>
        </div>
        {children}
      </div>
    </div>,
    document.body
  );
};

// ✅ 正确示范:处理焦点管理
const AccessibleModal: React.FC = () => {
  const contentRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const content = contentRef.current;
    if (content) {
      // 保存之前的焦点
      const previousFocus = document.activeElement;
      content.focus();

      return () => {
        // 恢复焦点
        if (previousFocus instanceof HTMLElement) {
          previousFocus.focus();
        }
      };
    }
  }, []);

  return createPortal(
    <div 
      ref={contentRef}
      tabIndex={-1}
      {...props}
    />,
    document.body
  );
};

4. 性能优化

import React, { lazy, Suspense } from 'react';

// 👉 懒加载模态框内容
const LazyModalContent = lazy(() => 
  import('./ModalContent')
);

// 👉 优化的模态框组件
const OptimizedModal: React.FC<{
  isOpen: boolean;
  onClose: () => void;
}> = ({ isOpen, onClose }) => {
  // 使用 memo 优化子组件
  const ModalHeader = React.memo(() => (
    <div className="modal-header">
      <button onClick={onClose}>Close</button>
    </div>
  ));

  return createPortal(
    <div className={`modal ${isOpen ? 'open' : ''}`}>
      <ModalHeader />
      <Suspense fallback={<div>Loading...</div>}>
        <LazyModalContent />
      </Suspense>
    </div>,
    document.body
  );
};

5. 测试策略

import { render, screen, fireEvent } from '@testing-library/react';

describe('Modal', () => {
  it('should render in portal and handle close', () => {
    const handleClose = jest.fn();
    
    render(
      <Modal isOpen onClose={handleClose} title="Test Modal">
        <div>Modal content</div>
      </Modal>
    );

    // 检查模态框内容
    expect(screen.getByRole('dialog')).toBeInTheDocument();
    
    // 测试关闭按钮
    fireEvent.click(screen.getByLabelText('Close modal'));
    expect(handleClose).toHaveBeenCalled();
  });

  it('should handle escape key', () => {
    const handleClose = jest.fn();
    
    render(
      <Modal isOpen onClose={handleClose} title="Test Modal">
        <div>Modal content</div>
      </Modal>
    );

    // 测试 ESC 键
    fireEvent.keyDown(document, { key: 'Escape' });
    expect(handleClose).toHaveBeenCalled();
  });
});

这个实现展示了 Portal 和模态框的完整使用方案,包括:

  1. 基础和进阶的实现方式
  2. 可访问性和事件处理
  3. 性能优化策略
  4. 测试方法

关键是要理解 Portal 的工作原理,以及如何正确处理模态框的生命周期、事件传播和可访问性问题。