陌上人如玉
公子世无双

11.7 手写 Modal 与 Portal

11.7 手写 Modal 与 Portal

一句话:一个合格的 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
  );
}

七、面试延伸

  1. 「手写一个 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。

  2. 「为什么 Modal 必须用 Portal?」
    因为直接渲染在原来的位置会遇到三个问题:① 被祖先的 overflow: hidden/auto 裁掉;② 被祖先的 z-index / 层叠上下文困住(弹窗盖不住其它元素);③ 被祖先的 transform 让 position: fixed 失效(fixed 会相对该祖先定位而不是视口)。createPortal(children, document.body) 把弹层的 DOM 挂到 body 下,彻底摆脱祖先影响。注意「Portal 只改变 DOM 位置,组件树关系不变」——所以 Context 仍能读到、事件仍按组件树冒泡(见 1.9)。

  3. 「什么是『焦点陷阱』?为什么需要它?」
    焦点陷阱指「Tab 键在对话框内部循环」——按 Tab 到最后一个可聚焦元素时回到第一个,Shift+Tab 到第一个时跳到最后一个。需要它的原因是:模态对话框打开时,用户不应该能通过键盘操作到「背后的页面」(那些内容视觉上不可见但键盘仍能聚焦,会导致「焦点跑到看不见的地方」,屏幕阅读器用户完全迷失)。实现方式是「监听 keydown 的 Tab 事件,在边界处 preventDefault 并手动移动焦点」。现代替代方案是用原生 <dialog> + showModal()(浏览器内置了焦点陷阱)。

  4. 「为什么关闭后要把焦点『归还』?」
    因为「打开 Modal 前的焦点位置」是用户的上下文——如果关闭后焦点被重置到 <body>(或丢失),那么:① 键盘用户需要重新用 Tab 从页面开头走一遍才能回到原来的位置;② 屏幕阅读器用户会「失去位置」(不知道回到哪了)。所以标准做法是「打开时记录 document.activeElement,关闭时 focus() 回去」,并要注意「元素可能已经被移除」(要判空并用可选链)。

  5. 「滚动锁定怎么做才对?」
    三个注意点:① 保存并还原原值——不要「关闭时置空」(body.style.overflow = ''),因为可能覆盖「别人设置的样式」(如主题系统);正确做法是「打开时保存 prevOverflow,关闭时还原」;② 补偿滚动条宽度——设置 overflow: hidden 会让滚动条消失,页面内容会「横向跳动」,所以要「用 paddingRight 补偿 window.innerWidth - documentElement.clientWidth」;③ 处理嵌套 Modal——用「引用计数」(lockCount)保证「只有最后一个 Modal 关闭时才真正解锁」。此外「iOS 上还有滚动穿透问题」,可能需要额外处理。

  6. 「点击遮罩关闭为什么不能用 onClick?」
    因为 click 事件的语义是「在同一个元素上按下并松开」——如果用户在「内容区按下、拖到遮罩上松开」,click 事件的 target 会是「两者最近的共同祖先(即遮罩)」,于是「误判为点击遮罩」而关闭弹窗(用户正在拖选文字时就关了,很恼人)。正确做法是「用 mousedown/pointerdown 判断 e.target === e.currentTarget」——即「按下的位置就在遮罩上」才关闭。

  7. 「用原生 <dialog> 有什么优势?」
    它可以省掉大量手写代码——dialog.showModal() 自带「模态语义(背景不可交互 + ::backdrop 遮罩)」「焦点陷阱」「ESC 关闭」「焦点归还」,甚至浏览器会阻止背景滚动。代价是:① 兼容性(老 Safari < 15.4 不支持,需要 polyfill 或降级);② 样式控制(遮罩要用 ::backdrop 单独写);③ 动画(需要用 @starting-style 或 JS 配合)。在支持的浏览器里,它是首选方案(尤其对「无障碍」几乎零成本)。

  8. 「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)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.7 手写 Modal 与 Portal

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫