R39第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

Portal 与覆盖层

Portal 与覆盖层 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

读完你能用 createPortal 把对话框渲染到 body 下的独立节点,避开父级 overflow 与层叠上下文的裁剪;会逐项检查覆盖层四件套——层叠、滚动锁定、焦点、关闭行为——并理解 portal 内事件仍沿 React 树冒泡这一关键性质。

问题场景

课程编辑页的侧栏套了 transform 和 overflow: hidden(折叠动画需要)。在里面弹「删除确认」对话框:对话框被侧栏裁掉一半;z-index 拉到 9999 也没用——父级的 transform 创建了新的层叠上下文,内部 z-index 再大也出不了这个圈子。界面要「浮」在一切之上,DOM 位置就必须换地方。

核心解释

Portal 的解法很直接:组件渲染到哪个 DOM 节点,和它在 React 树里的位置,是两回事:

function ConfirmDialog({ open, onClose }) {
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden"; // 滚动锁定
    return () => {
      document.removeEventListener("keydown", onKey);
      document.body.style.overflow = prev;   // 关闭时恢复
    };
  }, [open, onClose]);

  if (!open) return null;
  return ReactDOM.createPortal(
    <div className="overlay" onClick={onClose}>
      <div className="dialog" onClick={(e) => e.stopPropagation()}>…</div>
    </div>,
    document.body // 真实 DOM 挂到 body 下,跳出所有裁剪
  );
}

焦点管理是四件套里最容易被漏的:打开后把焦点移进对话框,键盘用户才知道「浮层已打开」:

const cancelRef = useRef(null);
useEffect(() => {
  if (open && cancelRef.current) cancelRef.current.focus();
}, [open]);

两个不直观但重要的性质:事件按 React 树冒泡,不按 DOM 树——对话框里的 onClick 在 React 父组件照样能收到(除非 stopPropagation);context 正常工作,portal 内读到的 context 由 React 树位置决定,与 DOM 位置无关。

覆盖层检查清单(改完必查):层叠——真实 DOM 在 body 末尾,天然盖住应用节点,不再打 z-index 战争;滚动锁定——打开锁 body、关闭恢复原值;焦点——打开即移入,Esc 可退;关闭行为——点遮罩关、Esc 关,内容区 stopPropagation 防误触。

示例拆解

实验区左侧是一个带 transform + overflow hidden 的「侧栏」,里面放「删除课程」按钮:

  • 不用 portal:对话框渲染在侧栏里,被裁剪、被遮挡;
  • 用 portal:同样的按钮,对话框完整出现在屏幕中央(渲染进 body);
  • 打开后:页面滚动被锁定,首焦点落在按钮上,Esc 关闭并恢复滚动;
  • 页面底部有事件日志区,记录 React 树上的冒泡情况;
  • 验证:两种实现各开关一次,逐项检查裁剪、滚动、焦点、Esc 四项,再用 DevTools 的 Elements 面板确认 portal 节点真的在 body 下。

动手任务

打开本课实验区:

  1. 勾选「首焦点给确认」,打开对话框按 Tab,确认焦点从确认按钮开始移动。
  2. 在 React 根节点的日志逻辑里观察:点击对话框内部按钮后,事件沿 React 树冒泡到了根;再把内容区的 stopPropagation 临时去掉,对比日志差异。
  3. 给页面加一段很高的占位内容,打开对话框后尝试滚动页面,关闭后确认滚动恢复。

常见错误

  • 以为 portal 会破坏事件冒泡:React 事件沿组件树冒泡,DOM 位置不影响;需要阻断时显式 stopPropagation。
  • 忘了恢复 body 样式:关闭对话框后滚动一直锁死;清理函数里恢复原值。
  • z-index 战术叠加:portal 已跳出层叠上下文,再叠 9999 只会让浮层关系更乱;按 DOM 顺序管理层级。
  • 打开时不移焦点:键盘用户不知道浮层已打开;打开后主动 focus 到内部控件。

检查与复盘

自查清单:

  • 能说出 portal 的核心:「DOM 位置」与「React 树位置」分离,事件与 context 沿 React 树
  • 覆盖层四件套(层叠/滚动/焦点/关闭)逐项验证过
  • 清理函数恢复了 body 与监听器,反复开关无残留

参考资料

动手实验
动手实验
预览
点击「运行」在隔离沙箱中预览
检查题共 1 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码简单

阅读代码(React 18+,页面 DOM 为 <body><div id='root'>…</div><div id='modal-root'></div></body>)。模态框渲染到哪个 DOM 节点?点击模态框内部时,控制台依次输出什么?

function Modal({ children }) {
  return createPortal(
    <div
      className='overlay'
      onClick={() => console.log('overlay clicked')}
    >
      {children}
    </div>,
    document.getElementById('modal-root')
  );
}

export default function App() {
  return (
    <div id='app-root' onClick={() => console.log('app clicked')}>
      <Modal>
        <p>确认删除?</p>
      </Modal>
    </div>
  );
}

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...