
一句话:一个合格的 Modal 要处理五件事——「用 Portal 挂到
body」「焦点管理(移入 + 陷阱 + 归还)」「ESC 关闭」「滚动锁定(并还原)」「无障碍属性(role="dialog"+aria-modal)」。
一、需求清单
① Portal 渲染 —— 挂到 document.body(摆脱祖先的 overflow/z-index/transform 影响)
② 遮罩层 —— 点击遮罩关闭(但「点击内容区」不应关闭)
③ ESC 关闭 —— 键盘可访问
④ 焦点管理:
· 打开时:焦点移入对话框(或第一个可聚焦元素)
· 打开期间:「焦点陷阱」——Tab 只在对话框内循环
· 关闭时:焦点归还到「打开前的元素」
⑤ 滚动锁定 —— 打开时锁定背景滚动,关闭时【还原原值】
⑥ 无障碍 —— role="dialog"、aria-modal="true"、aria-labelledby
⑦ 动画(可选)—— 打开/关闭的过渡
⑧ 清理 —— 组件卸载时移除所有监听、恢复所有样式
二、基础版(Portal + 遮罩 + ESC)
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
export interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
if (!open) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
{/* ⭐ 阻止冒泡:点内容区不应该关闭 */}
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>{title}</h2>
{children}
</div>
</div>,
document.body
);
}
⚠️ 基础版的四个明显问题:
① 没有「ESC 关闭」
② 没有「焦点管理」(打开后焦点还在页面上,Tab 会跑到遮罩外面)
③ 没有「滚动锁定」(背景能滚)
④ 没有「无障碍属性」
三、完整实现
import { useCallback, useEffect, useId, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
export interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
/** 关闭时是否归还焦点(默认 true) */
restoreFocus?: boolean;
/** 点击遮罩是否关闭(默认 true) */
closeOnOverlayClick?: boolean;
/** 按 ESC 是否关闭(默认 true) */
closeOnEsc?: boolean;
/** 是否锁定背景滚动(默认 true) */
lockScroll?: boolean;
className?: string;
}
export function Modal({
open,
onClose,
title,
children,
restoreFocus = true,
closeOnOverlayClick = true,
closeOnEsc = true,
lockScroll = true,
className,
}: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
/** 打开前的「活动元素」(用于关闭后归还焦点) */
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
/** 用 useId 生成「无障碍关联」的 ID(SSR 安全,见 2.11) */
const titleId = useId();
// ============ ① 打开时:记录焦点 + 移入焦点 ============
useEffect(() => {
if (!open) return;
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
// 焦点移入对话框(优先「第一个可聚焦元素」,否则对话框本身)
const dialog = dialogRef.current;
if (!dialog) return;
const firstFocusable = getFocusableElements(dialog)[0];
(firstFocusable ?? dialog).focus();
}, [open]);
// ============ ② 关闭时:归还焦点 ============
useEffect(() => {
if (open) return;
if (restoreFocus) {
previouslyFocusedRef.current?.focus?.(); // ⭐ 归还(并判空,元素可能已被移除)
previouslyFocusedRef.current = null;
}
}, [open, restoreFocus]);
// ============ ③ ESC 关闭 ============
useEffect(() => {
if (!open || !closeOnEsc) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.stopPropagation(); // 避免「嵌套 Modal 同时关闭」
onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open, closeOnEsc, onClose]);
// ============ ④ 焦点陷阱(Tab 循环) ============
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = getFocusableElements(dialog);
if (focusable.length === 0) {
e.preventDefault(); // 没有可聚焦元素 → 阻止 Tab 逃逸
dialog.focus();
return;
}
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
const active = document.activeElement;
// Shift+Tab 在第一个元素上 → 跳到最后一个
if (e.shiftKey && (active === first || active === dialog)) {
e.preventDefault();
last.focus();
return;
}
// Tab 在最后一个元素上 → 跳到第一个
if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open]);
// ============ ⑤ 滚动锁定(并【还原原值】) ============
useEffect(() => {
if (!open || !lockScroll) return;
const body = document.body;
const prevOverflow = body.style.overflow; // ⭐ 保存原值
const prevPaddingRight = body.style.paddingRight;
// 补偿「滚动条消失」导致的页面跳动
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
body.style.overflow = 'hidden';
if (scrollbarWidth > 0) body.style.paddingRight = `${scrollbarWidth}px`;
return () => {
body.style.overflow = prevOverflow; // ⭐ 还原(而不是清空)
body.style.paddingRight = prevPaddingRight;
};
}, [open, lockScroll]);
if (!open) return null;
return createPortal(
<div
className="modal-overlay"
// ⭐ 用 mousedown 判断「是否从遮罩开始按下」(避免「内容区拖选到遮罩」误关闭)
onMouseDown={(e) => {
if (closeOnOverlayClick && e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
className={className ?? 'modal-content'}
role="dialog" // ⭐ 无障碍
aria-modal="true" // ⭐ 告诉读屏「这是一个模态」
aria-labelledby={titleId} // ⭐ 关联标题
tabIndex={-1} // ⭐ 允许「对话框本身」获得焦点
>
<h2 id={titleId}>{title}</h2>
<button type="button" onClick={onClose} aria-label="关闭">
×
</button>
{children}
</div>
</div>,
document.body
);
}
/** 获取容器里「可聚焦」的元素(简化版) */
function getFocusableElements(container: HTMLElement): HTMLElement[] {
const selector = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
return Array.from(container.querySelectorAll<HTMLElement>(selector)).filter(
(el) => el.offsetParent !== null || el === document.activeElement // 过滤「不可见」元素
);
}
// ✅ 使用
function UserPage() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>打开详情</button>
<Modal open={open} onClose={() => setOpen(false)} title="用户详情">
这里是内容
<input placeholder="可聚焦的输入框" />
<button onClick={() => setOpen(false)}>确定</button>
</Modal>
</>
);
}
四、把「焦点管理」抽成 Hook
/** 焦点陷阱:打开期间 Tab 只在容器内循环 */
export function useFocusTrap<T extends HTMLElement>(active: boolean) {
const containerRef = useRef<T>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
// 移入焦点
(getFocusableElements(container)[0] ?? container).focus();
// Tab 循环
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = getFocusableElements(container);
if (focusable.length === 0) {
e.preventDefault();
container.focus();
return;
}
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
previouslyFocusedRef.current?.focus?.(); // ⭐ 归还焦点
previouslyFocusedRef.current = null;
};
}, [active]);
return containerRef;
}
/** 滚动锁定:支持「嵌套 Modal」(用计数避免「内层关闭时解锁了外层」) */
let lockCount = 0;
let savedOverflow = '';
let savedPaddingRight = '';
export function useScrollLock(active: boolean) {
useEffect(() => {
if (!active) return;
if (lockCount === 0) {
const body = document.body;
savedOverflow = body.style.overflow;
savedPaddingRight = body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
body.style.overflow = 'hidden';
if (scrollbarWidth > 0) body.style.paddingRight = `${scrollbarWidth}px`;
}
lockCount++;
return () => {
lockCount--;
if (lockCount === 0) {
document.body.style.overflow = savedOverflow; // ⭐ 只在「最后一个」解锁时还原
document.body.style.paddingRight = savedPaddingRight;
}
};
}, [active]);
}
五、<dialog> 元素的替代方案
<!-- ✅ 原生 <dialog> 自带:模态、焦点陷阱、ESC 关闭、::backdrop、焦点归还 -->
<dialog id="my-dialog">
<form method="dialog">
<h2>标题</h2>
<p>内容</p>
<button>关闭</button>
</form>
</dialog>
<script>
const dialog = document.getElementById('my-dialog');
dialog.showModal(); // ⭐ 打开(自带模态行为)
</script>
// ✅ React 里的用法
function NativeModal({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal(); // ⭐ showModal 自带焦点陷阱与 ESC
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={ref}
onClose={onClose} // ⭐ ESC 也会触发 close 事件
onCancel={(e) => { e.preventDefault(); onClose(); }}
style={{ /* 自定义样式 */ }}
>
{children}
</dialog>
);
}
⭐ `<dialog>` + `showModal()` 的收益(能省掉大量手写代码):
① 自动「模态」语义(背景不可交互 + `::backdrop` 伪元素做遮罩)
② 自动「焦点陷阱」(浏览器实现)
③ 自动「ESC 关闭」(触发 `cancel` / `close` 事件)
④ 自动「焦点归还」(关闭后焦点回到打开前的元素)
⑤ 自动「滚动锁定」(浏览器会阻止背景滚动)
⚠️ 注意:
· 兼容性已很好(主流浏览器都支持),但老 Safari(< 15.4)不支持
· 样式需要用 `::backdrop` 与 `dialog` 分开写
· 「打开动画」需要用 `@starting-style` 或 JS 配合
六、常见坑
// ① 不用 Portal(被祖先的 overflow:hidden 裁剪 / 被 z-index 困住 / 被 transform 劫持)
// ② 忘记「点击遮罩关闭」时判断 e.target === e.currentTarget
<div className="overlay" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()}>{children}</div> {/* ⚠️ 用 stopPropagation 也行,但更脆弱 */}
</div>
// ✅ 更推荐:用 mousedown + 判断 target === currentTarget
// ③ 用 onClick 判断「是否点击遮罩」→ 「内容区拖选后松开在遮罩上」会误关闭
// ✅ 用 onMouseDown / onPointerDown
// ④ 没有焦点管理(打开后焦点还在页面上、Tab 跑到遮罩外)
// ⑤ 关闭后「焦点丢失」(没有归还到打开前的元素)→ 键盘用户迷失
// ⑥ 滚动锁定「直接设 overflow:hidden」但关闭时「置空」
body.style.overflow = 'hidden';
// ... 关闭时
body.style.overflow = ''; // ⚠️ 会覆盖别人的设置
// ✅ 保存原值并还原
// ⑦ 「嵌套 Modal」时内层关闭导致外层也解锁(要用计数)
// ⑧ 滚动锁定导致「页面横向跳动」(滚动条消失)→ 补偿 padding-right
// ⑨ 忘记 role="dialog" / aria-modal / aria-labelledby(无障碍)
// ⑩ ESC 监听「没有 stopPropagation」→ 嵌套 Modal 同时关闭
// ⑪ 在 StrictMode 下「监听器被注册两次」(因为 effect 双执行)→ 必须有清理
// ⑫ 在「SSR」中访问 document(报错)→ 用「客户端挂载后再渲染 Portal」或判环境
// ⑬ createPortal 的容器不存在(还没挂载)→ 检查容器
// ⑭ 忘记「卸载时恢复」(body 样式、事件监听、焦点)
// ⑮ 用「fixed 定位」但没有处理「iOS 上的滚动穿透」→ 移动端可能需要额外处理
// ⑯ Modal 里的「自动聚焦」抢了「用户正在输入」的焦点
// ⑰ 遮罩层的「层级(z-index)」与其他组件(Toast/Dropdown)冲突
// ⑱ 忘记「关闭动画」(直接卸载 → 没有过渡)
// ⑰ 的实践:把「层级」集中管理
export const Z_INDEX = {
dropdown: 1000,
sticky: 1020,
fixed: 1030,
modalBackdrop: 1040,
modal: 1050,
popover: 1060,
tooltip: 1070,
toast: 1080,
} as const;
// ⭐ 集中定义,避免「各处写 9999」互相打架
// ⑱ 的关闭动画思路(保持挂载直到动画结束)
function Modal({ open, onClose, children }: ModalProps) {
const [mounted, setMounted] = useState(open);
useEffect(() => {
if (open) setMounted(true);
// ⭐ 关闭时「延迟卸载」(等动画播完)
}, [open]);
if (!mounted) return null;
return createPortal(
<div
className={open ? 'overlay overlay--enter' : 'overlay overlay--leave'}
onAnimationEnd={() => { if (!open) setMounted(false); }} // ⭐ 动画结束再卸载
>
{children}
</div>,
document.body
);
}
七、面试延伸
- 「手写一个 Modal 要注意什么?」
五个要点:① 用 Portal 挂到document.body(摆脱祖先的overflow: hidden裁剪、z-index层叠上下文、transform导致的fixed失效);② 焦点管理——打开时把焦点移入对话框(优先第一个可聚焦元素)、打开期间用「焦点陷阱」让Tab只在对话框内循环、关闭时归还焦点到「打开前的元素」;③ ESC 关闭(监听keydown,注意嵌套时stopPropagation);④ 滚动锁定——打开时设body.style.overflow = 'hidden',关闭时还原原值(并补偿滚动条宽度避免横向跳动);⑤ 无障碍属性——role="dialog"、aria-modal="true"、aria-labelledby。 -
「为什么 Modal 必须用 Portal?」
因为直接渲染在原来的位置会遇到三个问题:① 被祖先的overflow: hidden/auto裁掉;② 被祖先的z-index/ 层叠上下文困住(弹窗盖不住其它元素);③ 被祖先的transform让position: fixed失效(fixed会相对该祖先定位而不是视口)。createPortal(children, document.body)把弹层的 DOM 挂到body下,彻底摆脱祖先影响。注意「Portal 只改变 DOM 位置,组件树关系不变」——所以 Context 仍能读到、事件仍按组件树冒泡(见 1.9)。 -
「什么是『焦点陷阱』?为什么需要它?」
焦点陷阱指「Tab 键在对话框内部循环」——按Tab到最后一个可聚焦元素时回到第一个,Shift+Tab到第一个时跳到最后一个。需要它的原因是:模态对话框打开时,用户不应该能通过键盘操作到「背后的页面」(那些内容视觉上不可见但键盘仍能聚焦,会导致「焦点跑到看不见的地方」,屏幕阅读器用户完全迷失)。实现方式是「监听keydown的 Tab 事件,在边界处preventDefault并手动移动焦点」。现代替代方案是用原生<dialog>+showModal()(浏览器内置了焦点陷阱)。 -
「为什么关闭后要把焦点『归还』?」
因为「打开 Modal 前的焦点位置」是用户的上下文——如果关闭后焦点被重置到<body>(或丢失),那么:① 键盘用户需要重新用Tab从页面开头走一遍才能回到原来的位置;② 屏幕阅读器用户会「失去位置」(不知道回到哪了)。所以标准做法是「打开时记录document.activeElement,关闭时focus()回去」,并要注意「元素可能已经被移除」(要判空并用可选链)。 -
「滚动锁定怎么做才对?」
三个注意点:① 保存并还原原值——不要「关闭时置空」(body.style.overflow = ''),因为可能覆盖「别人设置的样式」(如主题系统);正确做法是「打开时保存prevOverflow,关闭时还原」;② 补偿滚动条宽度——设置overflow: hidden会让滚动条消失,页面内容会「横向跳动」,所以要「用paddingRight补偿window.innerWidth - documentElement.clientWidth」;③ 处理嵌套 Modal——用「引用计数」(lockCount)保证「只有最后一个 Modal 关闭时才真正解锁」。此外「iOS 上还有滚动穿透问题」,可能需要额外处理。 -
「点击遮罩关闭为什么不能用
onClick?」
因为click事件的语义是「在同一个元素上按下并松开」——如果用户在「内容区按下、拖到遮罩上松开」,click事件的target会是「两者最近的共同祖先(即遮罩)」,于是「误判为点击遮罩」而关闭弹窗(用户正在拖选文字时就关了,很恼人)。正确做法是「用mousedown/pointerdown判断e.target === e.currentTarget」——即「按下的位置就在遮罩上」才关闭。 -
「用原生
<dialog>有什么优势?」
它可以省掉大量手写代码——dialog.showModal()自带「模态语义(背景不可交互 +::backdrop遮罩)」「焦点陷阱」「ESC 关闭」「焦点归还」,甚至浏览器会阻止背景滚动。代价是:① 兼容性(老 Safari < 15.4 不支持,需要 polyfill 或降级);② 样式控制(遮罩要用::backdrop单独写);③ 动画(需要用@starting-style或 JS 配合)。在支持的浏览器里,它是首选方案(尤其对「无障碍」几乎零成本)。 -
「Modal 的层级(
z-index)怎么管理?」
做法是「集中定义层级常量」——如Z_INDEX = { dropdown: 1000, modalBackdrop: 1040, modal: 1050, tooltip: 1070, toast: 1080 },各处引用常量而不是「随手写 9999」。理由是:① 弹层类组件(Modal / Dropdown / Tooltip / Toast)的层级必须有序(如「Toast 要在 Modal 之上」「Tooltip 要在 Dropdown 之上」);② 如果各自写魔法数字,很容易出现「冲突后不断加大数字」的恶性循环;③ 集中定义还便于「配合设计系统的层级规范」。
一句话速记
Modal 的五件事:Portal 挂到
body(摆脱overflow/z-index/transform的三个坑)、焦点管理(打开移入 → 焦点陷阱(Tab 循环) → 关闭归还)、ESC 关闭(嵌套时stopPropagation)、滚动锁定(保存并还原prevOverflow+ 补偿滚动条宽度 + 用计数支持嵌套)、无障碍(role="dialog"+aria-modal+aria-labelledby(用useId));点遮罩关闭用mousedown+e.target === e.currentTarget(用click会「拖选到遮罩就关」);Portal 只改 DOM 位置,Context 与事件冒泡仍按组件树;在支持的浏览器里优先用原生<dialog>+showModal()(自带模态、焦点陷阱、ESC、焦点归还);z-index用集中常量管理(别写 9999);关闭动画要「延迟卸载」(等animationend)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!