Dialog组件设计

设计一个功能完整的 Dialog 弹窗组件

问题

设计一个 Dialog 组件,说说设计思路和应该具备的功能。

解答

功能需求

  1. 基础功能:显示/隐藏、标题、内容、底部按钮
  2. 交互方式:点击关闭按钮、点击遮罩关闭、ESC 键关闭
  3. 视觉效果:遮罩层、居中显示、过渡动画
  4. 可访问性:焦点管理、键盘操作、ARIA 属性
  5. 扩展能力:自定义内容、自定义样式、Portal 渲染

API 设计

interface DialogProps {
  visible: boolean;           // 是否显示
  title?: React.ReactNode;    // 标题
  children?: React.ReactNode; // 内容
  footer?: React.ReactNode;   // 底部按钮,默认确定/取消
  width?: number | string;    // 宽度
  maskClosable?: boolean;     // 点击遮罩是否关闭
  closable?: boolean;         // 是否显示关闭按钮
  onOk?: () => void;          // 确定回调
  onCancel?: () => void;      // 取消回调
}

完整实现

import React, { useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import './dialog.css';

interface DialogProps {
  visible: boolean;
  title?: React.ReactNode;
  children?: React.ReactNode;
  footer?: React.ReactNode;
  width?: number | string;
  maskClosable?: boolean;
  closable?: boolean;
  onOk?: () => void;
  onCancel?: () => void;
}

const Dialog: React.FC<DialogProps> = ({
  visible,
  title,
  children,
  footer,
  width = 520,
  maskClosable = true,
  closable = true,
  onOk,
  onCancel,
}) => {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previousActiveElement = useRef<Element | null>(null);

  // ESC 键关闭
  const handleKeyDown = useCallback((e: KeyboardEvent) => {
    if (e.key === 'Escape' && onCancel) {
      onCancel();
    }
  }, [onCancel]);

  // 焦点管理
  useEffect(() => {
    if (visible) {
      // 保存当前焦点元素
      previousActiveElement.current = document.activeElement;
      // 聚焦到 dialog
      dialogRef.current?.focus();
      // 禁止背景滚动
      document.body.style.overflow = 'zzinb';
      // 绑定键盘事件
      document.addEventListener('keydown', handleKeyDown);
    }

    return () => {
      document.body.style.overflow = '';
      document.removeEventListener('keydown', handleKeyDown);
      // 恢复之前的焦点
      if (previousActiveElement.current instanceof HTMLElement) {
        previousActiveElement.current.focus();
      }
    };
  }, [visible, handleKeyDown]);

  // 点击遮罩关闭
  const handleMaskClick = (e: React.MouseEvent) => {
    if (e.target === e.currentTarget && maskClosable && onCancel) {
      onCancel();
    }
  };

  // 默认底部按钮
  const defaultFooter = (
    <>
      <button className="dialog-btn" onClick={onCancel}>取消</button>
      <button className="dialog-btn dialog-btn-primary" onClick={onOk}>确定</button>
    </>
  );

  if (!visible) return null;

  // 使用 Portal 渲染到 body
  return createPortal(
    <div className="dialog-mask" onClick={handleMaskClick}>
      <div
        ref={dialogRef}
        className="dialog"
        style={{ width }}
        role="dialog"
        aria-modal="true"
        aria-labelledby="dialog-title"
        tabIndex={-1}
      >
        {/* 头部 */}
        <div className="dialog-header">
          <span id="dialog-title" className="dialog-title">{title}</span>
          {closable && (
            <button
              className="dialog-close"
              onClick={onCancel}
              aria-label="关闭"
            >
              ×
            </button>
          )}
        </div>

        {/* 内容 */}
        <div className="dialog-body">{children}</div>

        {/* 底部 */}
        {footer !== null && (
          <div className="dialog-footer">
            {footer === undefined ? defaultFooter : footer}
          </div>
        )}
      </div>
    </div>,
    document.body
  );
};

export default Dialog;

样式文件

/* dialog.css */
.dialog-mask {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.45);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  animation: fadeIn 0.2s;
}

.dialog {
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
  max-height: 80vh;
  display: flex;
  flex-direction: column;
  animation: scaleIn 0.2s;
}

.dialog-header {
  padding: 16px 24px;
  border-bottom: 1px solid #f0f0f0;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.dialog-title {
  font-size: 16px;
  font-weight: 500;
}

.dialog-close {
  border: none;
  background: none;
  font-size: 20px;
  cursor: pointer;
  color: #999;
}

.dialog-close:hover {
  color: #333;
}

.dialog-body {
  padding: 24px;
  overflow-y: auto;
  flex: 1;
}

.dialog-footer {
  padding: 12px 24px;
  border-top: 1px solid #f0f0f0;
  text-align: right;
}

.dialog-btn {
  padding: 6px 16px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  background: #fff;
  cursor: pointer;
  margin-left: 8px;
}

.dialog-btn-primary {
  background: #1890ff;
  border-color: #1890ff;
  color: #fff;
}

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes scaleIn {
  from { transform: scale(0.9); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}

使用示例

function App() {
  const [visible, setVisible] = useState(false);

  return (
    <>
      <button onClick={() => setVisible(true)}>打开弹窗</button>
      
      <Dialog
        visible={visible}
        title="提示"
        onOk={() => {
          console.log('确定');
          setVisible(false);
        }}
        onCancel={() => setVisible(false)}
      >
        <p>这是弹窗内容</p>
      </Dialog>
    </>
  );
}

关键点

  • Portal 渲染:使用 createPortal 将 Dialog 渲染到 body,避免被父元素的 overflow: hiddenz-index 影响
  • 焦点管理:打开时聚焦到 Dialog,关闭时恢复之前的焦点,提升键盘操作体验
  • 多种关闭方式:支持关闭按钮、ESC 键、点击遮罩三种关闭方式
  • 可访问性:添加 role="dialog"aria-modalaria-labelledby 等属性,支持屏幕阅读器
  • 滚动锁定:打开时禁止背景滚动,关闭时恢复