Dialog组件设计
设计一个功能完整的 Dialog 弹窗组件
问题
设计一个 Dialog 组件,说说设计思路和应该具备的功能。
解答
功能需求
- 基础功能:显示/隐藏、标题、内容、底部按钮
- 交互方式:点击关闭按钮、点击遮罩关闭、ESC 键关闭
- 视觉效果:遮罩层、居中显示、过渡动画
- 可访问性:焦点管理、键盘操作、ARIA 属性
- 扩展能力:自定义内容、自定义样式、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: hidden或z-index影响 - 焦点管理:打开时聚焦到 Dialog,关闭时恢复之前的焦点,提升键盘操作体验
- 多种关闭方式:支持关闭按钮、ESC 键、点击遮罩三种关闭方式
- 可访问性:添加
role="dialog"、aria-modal、aria-labelledby等属性,支持屏幕阅读器 - 滚动锁定:打开时禁止背景滚动,关闭时恢复
目录