
一句话:Portal 的本质是「DOM 位置搬家,React 树位置不变」——它解决了「脱离父级裁剪」的问题,却引出「层级(z-index)失控」的新问题;治理层级的正解是「分层基数 + 栈式申领 + 固定 Portal 容器」,更现代的做法是直接上浏览器的 top layer(
<dialog>/ Popover API)。
一、Portal 到底改变了什么(三件事,别搞错)
createPortal(children, container, key?)
⭐⭐⭐ Portal 改变/保留 的三件事(这是所有 Portal 相关 bug 的根源):
① 【DOM 位置:改变】
节点被挂到 `container`(通常是 `document.body`)下 →
脱离了父级的 `overflow: hidden`、`clip-path`、层叠上下文、布局流。
② 【React 树位置:不变】⭐ 这点最关键
· **Context 仍然可用**——Portal 里的组件能读到「React 树位置上」的 Context
(所以主题/i18n/状态库在 Portal 里都正常工作)
· **合成事件仍然沿「React 树」冒泡**——不只是 DOM 树
⭐ 所以「Portal 里的点击」会被「React 树上的父组件」捕获
⚠️ 但这会造成一个经典困惑:`onClick` 到了 Portal 外面的父组件
→ 需要 `e.stopPropagation()` 或用「判断 target 是否在容器内」
③ 【CSS 继承:部分改变】
· **可继承属性**(color/font/custom properties):
按 **DOM 树**继承 → 如果 container 是 body,就接不上原来父级的继承链
⭐ 常见后果:「Portal 出来的弹窗拿不到主题变量」→
解法是「**把变量复制到 container 上**」或「**把 Portal 挂在主题容器内**」
· **布局相关**(width/height/position):完全按新位置计算
· ⚠️ **CSS `@keyframes` 动画仍可用**(它们与 DOM 位置无关),
但「基于父级的 transition(如 height: auto → 0)」会失效
// ⭐ 「CSS 变量丢失」的标准解法:把变量带到 Portal 容器上
function ThemedPortal({ children }: { children: React.ReactNode }) {
const themeHostRef = useRef<HTMLDivElement>(null);
// 用 Portal 时,把源节点的「计算后的自定义属性」复制过去
useEffect(() => {
const source = document.documentElement; // 或某个主题容器
const target = themeHostRef.current;
if (!target) return;
const styles = getComputedStyle(source);
for (const name of Array.from(styles)) {
if (name.startsWith('--')) {
target.style.setProperty(name, styles.getPropertyValue(name));
}
}
}, []);
return createPortal(<div ref={themeHostRef}>{children}</div>, document.body);
}
// ⭐ 更常见的做法(更简单):给 Portal 容器加一个「标识 class」,
// 让 CSS 变量在这个 class 上重新定义:
// .modal-root { --color-primary: #1677ff; --radius: 8px; }
// <div ref={...} className="modal-root">
二、层叠上下文:z-index 失控的真正原因
⭐⭐⭐ 「我的 z-index 是 99999,为什么还是被盖住?」
答案:**它的祖先创建了一个「层叠上下文(stacking context)」**——
在那个上下文里,它的 z-index 只与「同一上下文内的兄弟」比较,
而整个上下文相对更外层的位置由**祖先的 z-index** 决定。
→ 就像「在一个房间里当最大的」压不住「隔壁房间的人」。
| 触发层叠上下文的属性 | 备注 |
|---|---|
根元素 html |
最外层 |
position: absolute/relative + z-index !== auto |
最常见的显式创建 |
position: fixed / sticky |
⭐ 现代浏览器中它们自身也创建上下文 |
flex/grid 子项 + z-index !== auto |
容易忽略 |
opacity < 1 |
⭐ 「加了淡入动画就盖不住了」的元凶 |
transform / translate / scale / rotate(非 none) |
⭐ 最常见的无意识创建 |
filter / backdrop-filter(非 none) |
同上 |
will-change: transform 等 |
⭐ 「性能优化后层级坏了」 |
isolation: isolate |
用来「显式创建」(有时是解药) |
contain: layout / paint / strict / content |
|
mix-blend-mode !== normal |
|
perspective(非 none)、clip-path、mask |
部分实现 |
/* ⭐ 一个真实的「层级坏了」现场 */
.parent {
/* ❌ 这个 transform 创建了层叠上下文 */
transform: translateZ(0);
/* 于是它内部所有 z-index 都只在这个上下文里比较 */
}
.child-modal {
position: fixed;
z-index: 99999; /* ⚠️ 再大也只在 .parent 的上下文里最大 */
}
.sibling-above {
position: relative;
z-index: 1; /* ⭐ 只要它的父级没有上下文,它就能盖住整个 .parent */
}
/* ✅ 三种解法 */
/* ① 把浮层用 Portal 移出 .parent(最彻底,也最常用) */
/* ② 给 .parent 加一个「足够大的 z-index」(治标:会有新的冲突) */
/* ③ 显式声明层级:用 isolation 或给容器分配 z-index 区间(治本:见下一节) */
⭐⭐ 两个「反直觉」的后果:
① 【`opacity: 0.99` 和 `opacity: 1` 完全不同】
前者创建层叠上下文 → 你的 z-index 全部失效
⭐ 「淡入动画做完后忘记移除 `will-change`/`opacity`」会造成
「刚打开时正常,动画结束后不对」这类诡异问题。
② 【`position: fixed` 除了「创建层叠上下文」,还会被祖先的 `transform` 改变包含块】
即:`fixed` 元素会「相对那个 transform 祖先」定位,而不是视口
→ ⭐ 这就是「Tooltip 明明 fixed 却位置错乱」的原因(见 11.15)。
三、层级治理:四层方案
⭐⭐⭐ 从「最省事」到「最彻底」的四种做法:
【方案 0:别用 z-index 竞争——用 top layer(浏览器原生)】
<dialog> + showModal()、Popover API(popover 属性)
→ 元素进入「顶层(top layer)」,**不受任何 z-index 影响**,天然在最上面
⭐ 这是最现代的方案(见第五节)
【方案 1:分层 token(把 z-index 常量集中管理)】
· 为「层级语义」分配固定区间:内容 0~99、下拉 100、粘性头 200、
遮罩 1000、弹窗 1100、通知 3000、Tooltip 4000
· 禁止在业务代码里写魔法数字
⭐ 解决「没人知道该用多大」
【方案 2:栈式申领(同一层级内按「打开顺序」递增)】
· 谁后打开谁在上:`acquire()` 返回 `base + 序号`
· 关闭时归还
⭐ 解决「同一层的多个浮层谁在上」与「动态层级」
【方案 3:固定 Portal 容器(把浮层集中到少数几个 DOM 节点下)】
<div id="modal-root"> <div id="popover-root"> <div id="toast-root">
· 每个容器只负责一个层级区间
· 容器本身**不能创建新的层叠上下文**(否则又乱了)
⭐ 解决「浮层散落各处、互相无法比较」
/* ✅ 分层的 CSS 变量(把「层级」变成设计系统的一部分) */
:root {
--z-base: 0;
--z-sticky: 100;
--z-drawer-backdrop: 900;
--z-drawer: 1000;
--z-modal-backdrop: 1100;
--z-modal: 1200;
--z-popover: 2000;
--z-tooltip: 3000;
--z-toast: 4000;
}
/* ⚠️ 注意:这些容器【不要】加 transform/opacity/filter,否则会创建层叠上下文,
让「内部 z-index 的语义」与「跨容器比较」同时变得混乱 */
#modal-root,
#popover-root,
#toast-root {
position: fixed; /* ⚠️ fixed 本身会创建层叠上下文 —— 所以这里改用 static + 子元素 fixed */
}
四、完整实现:层级管理器 + 栈式弹窗(230 行)
// layer/index.ts —— 第 1 部分:层级管理器(零 React 依赖)
/** ⭐ 层级语义(不是「数值」,而是「用途」——这是治理的关键) */
export type LayerKind =
| 'dropdown' // 下拉、菜单
| 'sticky' // 吸顶/吸底
| 'backdrop' // 遮罩
| 'modal' // 弹窗
| 'popover' // 浮层(Tooltip/Popover)
| 'toast'; // 通知
/** ⭐ 每个语义一个基数区间(区间宽度 1000,便于同层内递增) */
const BASE: Record<LayerKind, number> = {
sticky: 100,
dropdown: 1000,
backdrop: 2000,
modal: 2100,
popover: 3000,
toast: 4000,
};
/** 同层内的计数器(后开的在上) */
const counters = new Map<LayerKind, number>();
/** 已申领的层级(按顺序,用于「找最上层」) */
const stack: Array<{ id: string; kind: LayerKind; zIndex: number }> = [];
export function acquireLayer(kind: LayerKind): { id: string; zIndex: number; release: () => void } {
const index = counters.get(kind) ?? 0;
counters.set(kind, index + 1);
const id = `${kind}-${index}-${Math.random().toString(36).slice(2, 8)}`;
const zIndex = BASE[kind] + index;
stack.push({ id, kind, zIndex });
return {
id,
zIndex,
release: () => {
// ⭐ 释放时「递减计数」(如果它是最上层的话),并把它移出栈
const pos = stack.findIndex((item) => item.id === id);
if (pos >= 0) stack.splice(pos, 1);
const current = counters.get(kind) ?? 0;
if (current > 0 && index === current - 1) {
// ⭐ 只有「最后申领的那个」被释放时才真正回收号码,
// 避免「A 关掉后 B 的层级被回收导致闪烁」
counters.set(kind, current - 1);
}
},
};
}
/** ⭐ 找「最上层」的浮层(用于「ESC 只关最上层」) */
export function getTopLayer(kind?: LayerKind) {
if (kind) return [...stack].reverse().find((item) => item.kind === kind) ?? null;
return stack[stack.length - 1] ?? null;
}
/** ⭐ 用于调试:打印当前层级栈 */
export function debugLayers() {
console.table(stack.map((s) => ({ ...s, base: BASE[s.kind] })));
}
// layer/index.tsx —— 第 2 部分:Portal 容器
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
interface PortalContainerSpec {
id: string;
kind: 'modal' | 'popover' | 'toast';
/** 容器自身的 z-index 基数(内部元素在此之上叠加) */
baseZ: number;
}
const CONTAINERS: PortalContainerSpec[] = [
{ id: 'popover-root', kind: 'popover', baseZ: BASE.popover },
{ id: 'modal-root', kind: 'modal', baseZ: BASE.modal },
{ id: 'toast-root', kind: 'toast', baseZ: BASE.toast },
];
/**
* ⭐⭐ 在应用根部挂载固定的 Portal 容器。
* 每个容器:`position: fixed; inset: 0; pointer-events: none;`
* 然后「内部的浮层自己声明 pointer-events: auto」——
* 这样「容器覆盖全屏」但「不挡住点击」。
*/
export function LayerHosts() {
const [ready, setReady] = useState(false);
useEffect(() => setReady(true), []); // ⭐ 等挂载后再渲染 Portal(SSR 安全)
// ⭐ 只在客户端渲染(createPortal 需要 document)
if (!ready || typeof document === 'undefined') return null;
return (
<>
{CONTAINERS.map((spec) => {
let el = document.getElementById(spec.id);
if (!el) {
el = document.createElement('div');
el.id = spec.id;
Object.assign(el.style, {
position: 'fixed',
inset: '0',
// ⭐ 容器「不接收指针事件」,内部元素自己声明 auto
pointerEvents: 'none',
// ⚠️ 这里给一个「比内部元素小」的 z-index:
// 真正的层级由「内部浮层自己申领的 z-index」决定
zIndex: String(spec.baseZ - 10),
});
document.body.appendChild(el);
}
return createPortal(<ContainerScope baseZ={spec.baseZ} />, el, spec.id);
})}
</>
);
}
/** 给容器内部一个「层级作用域」的说明(可选:用于调试) */
function ContainerScope({ baseZ }: { baseZ: number }) {
return <div data-layer-base={baseZ} style={{ display: 'contents' }} />;
}
/** ⭐ 用 Hook 拿到容器元素(供 createPortal 使用) */
export function usePortalContainer(kind: 'modal' | 'popover' | 'toast') {
const [container, setContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
const id = `${kind}-root`;
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
Object.assign(el.style, {
position: 'fixed', inset: '0', pointerEvents: 'none',
zIndex: String(BASE[kind] - 10),
});
document.body.appendChild(el);
}
setContainer(el);
return () => setContainer(null);
}, [kind]);
return container;
}
/** ============ 第 3 部分:用层级管理器写一个浮层基座 ============ */
export function useLayer(kind: LayerKind, open: boolean) {
const [layer, setLayer] = useState<{ id: string; zIndex: number } | null>(null);
useEffect(() => {
if (!open) {
setLayer(null);
return;
}
const acquired = acquireLayer(kind);
setLayer({ id: acquired.id, zIndex: acquired.zIndex });
// ⭐ 卸载/关闭时归还层级
return () => acquired.release();
}, [kind, open]);
return layer;
}
/** ============ 第 4 部分:栈式弹窗(ESC 只关最上层) ============ */
const dialogStack: string[] = [];
/** ⭐⭐ 挂载一个弹窗到「栈」上;只有栈顶的弹窗响应 ESC / 点击遮罩 */
export function useDialogStack(id: string, open: boolean, onDismiss: () => void) {
const onDismissRef = useRef(onDismiss);
onDismissRef.current = onDismiss;
useEffect(() => {
if (!open) return;
dialogStack.push(id);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
// ⭐ 只有「栈顶」才响应(避免一次 ESC 把所有弹窗都关了)
if (dialogStack[dialogStack.length - 1] !== id) return;
e.stopPropagation();
onDismissRef.current();
};
document.addEventListener('keydown', onKeyDown, true);
return () => {
document.removeEventListener('keydown', onKeyDown, true);
const pos = dialogStack.indexOf(id);
if (pos >= 0) dialogStack.splice(pos, 1);
};
}, [id, open]);
return {
isTop: () => dialogStack[dialogStack.length - 1] === id,
depth: dialogStack.length,
};
}
/** ⭐⭐ 滚动锁:用「计数器」而不是布尔(多个弹窗叠加时不会提前解锁) */
let scrollLockCount = 0;
let originalOverflow = '';
export function useScrollLock(locked: boolean) {
useEffect(() => {
if (!locked) return;
if (scrollLockCount === 0) {
originalOverflow = document.body.style.overflow;
// ⭐ 用 overflow: hidden(简单可靠);更精细的方案要「补偿滚动条宽度」避免跳动
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
}
scrollLockCount++;
return () => {
scrollLockCount--;
if (scrollLockCount === 0) {
document.body.style.overflow = originalOverflow;
document.body.style.paddingRight = '';
}
};
}, [locked]);
}
/** ============ 组装:一个「层级正确」的 Modal ============ */
import { useId, useMemo } from 'react';
export function Modal({
open, onClose, title, children,
}: { open: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
const id = useId();
const container = usePortalContainer('modal');
const layer = useLayer('modal', open);
const { isTop } = useDialogStack(id, open, onClose);
useScrollLock(open);
// ⭐ 遮罩用「单独申领的层级」(比弹窗本体低一位)
const backdropLayer = useLayer('backdrop', open);
if (!open || !container || !layer) return null;
return createPortal(
<>
{/* 遮罩 */}
<div
onClick={() => { if (isTop()) onClose(); }}
style={{
position: 'fixed', inset: 0,
zIndex: backdropLayer?.zIndex ?? 2000,
background: 'rgba(0,0,0,.45)',
pointerEvents: 'auto', // ⭐ 遮罩要接收点击
}}
/>
{/* 弹窗本体 */}
<div
role="dialog"
aria-modal="true"
aria-label={title}
style={{
position: 'fixed',
top: '50%', left: '50%',
// ⭐ 用 transform 做居中(只走合成,见 5.17)
transform: 'translate(-50%, -50%)',
zIndex: layer.zIndex,
background: '#fff', borderRadius: 12, padding: 20,
minWidth: 320, maxWidth: 'min(560px, 92vw)',
pointerEvents: 'auto',
}}
>
<h2>{title}</h2>
{children}
<button onClick={onClose}>关闭</button>
</div>
</>,
container
);
}
// ⭐ 在应用根部渲染 LayerHosts
export function App() {
return (
<>
<LayerHosts />
<Routes />
</>
);
}
⭐⭐⭐ 这个实现解决的四类「层级问题」(对照着看它的价值):
① 【「谁在上」有明确规则】
语义基数(sticky 100 < dropdown 1000 < backdrop 2000 < modal 2100
< popover 3000 < toast 4000)+ 同层内按申领顺序递增。
→ ⭐ 业务代码不再写魔法数字,也不可能出现「通知被弹窗盖住」。
② 【同层多个浮层按「打开顺序」叠加】
第一个弹窗 2100、第二个 2101 → 后开的在上(符合直觉)。
释放时「只有最上层被关掉才回收号码」,避免「关一个导致另一个跳层」。
③ 【ESC 只关最上层】
用 `dialogStack` 记录打开顺序,只有栈顶响应 ESC。
⚠️ 如果不做,用户按一次 ESC 会把「嵌套的所有弹窗」一起关掉(体验灾难)。
④ 【滚动锁用计数器】
`scrollLockCount` 保证「嵌套弹窗关闭内层时不会提前解锁 body 滚动」。
⭐ 并补偿滚动条宽度,避免「打开弹窗时页面横向跳动」。
五、更现代的做法:top layer 与原生 <dialog>
<!-- ⭐ 原生 <dialog>:进入浏览器「顶层(top layer)」,不受 z-index 影响 -->
<dialog id="d">
<h2>标题</h2>
<form method="dialog"><button>关闭</button></form>
</dialog>
<script>
const d = document.getElementById('d');
d.showModal(); // ⭐ 进入 top layer + 自动遮罩(::backdrop) + ESC 自动关闭 + 焦点陷阱
// d.show(); // 非模态(不进 top layer)
d.close();
</script>
/* ⭐ 原生遮罩(自动的) */
dialog::backdrop {
background: rgba(0, 0, 0, 0.45);
/* ⚠️ 注意:::backdrop 里「不能继承页面的 CSS 变量」的老问题已修复,
但仍有一些限制(不能用 position: fixed 之类) */
}
<!-- ⭐ Popover API:任意元素都能进 top layer(Chrome 114+) -->
<button popovertarget="menu">打开菜单</button>
<div id="menu" popover="auto">
菜单内容(点击外部自动关闭)
</div>
<script>
// 也可用 JS 控制
const menu = document.getElementById('menu');
menu.showPopover();
menu.hidePopover();
menu.addEventListener('toggle', (e) => console.log(e.newState));
</script>
⭐⭐⭐ top layer 相比 z-index 的四个「质变」(这是它值得用的原因):
① 【不受任何 z-index / 层叠上下文影响】
⭐ 无论祖先有多少个 transform、z-index,只要是 top layer,
就一定在最上面(浏览器维护的一个独立的「最顶层」列表)。
→ 「层级战争」从根本上消失。
② 【自动处理遮罩与焦点】
`<dialog showModal()>` 自动:遮罩(`::backdrop`)、ESC 关闭、焦点陷阱(Tab 不会跑出去)、
「点击背景不关闭」(需要自己处理或监听 click 判断坐标)。
③ 【`popover="auto"` 自动处理「点击外部关闭」与「一次只有一个」】
`popover="auto"` 的「light dismiss」+ 互斥(打开一个自动关另一个);
`popover="manual"` 则不会自动关闭(适合 Toast)。
④ 【top layer 的顺序 = 「打开顺序」】
后进入 top layer 的在上面;⭐ 与「浮层栈」的直觉完全一致。
⚠️ 兼容性与注意:
· `<dialog>` 支持较好;Popover API 需要较新浏览器(2023+)→ 需要降级
· top layer 的元素**脱离常规流**(类似 fixed),定位要自己写
· ⭐ 「从 top layer 出去后」不能做「退出的过渡动画」(因为它立刻被移除)——
需要「先播放动画、再 close」的手动处理
// ✅ 一个「原生优先 + JS 降级」的 Modal 思路
function AdaptiveModal({ open, onClose, children }: ModalProps) {
const ref = useRef<HTMLDialogElement>(null);
const supportsDialog = typeof HTMLDialogElement !== 'undefined';
useEffect(() => {
const el = ref.current;
if (!el) return;
// ⭐ 用原生 dialog(自动 top layer + 遮罩 + 焦点陷阱)
if (open && !el.open) el.showModal();
if (!open && el.open) el.close();
}, [open]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onCancel = (e: Event) => { e.preventDefault(); onClose(); }; // ⭐ ESC
el.addEventListener('cancel', onCancel);
return () => el.removeEventListener('cancel', onCancel);
}, [onClose]);
if (!supportsDialog) {
// 降级:用 Portal + z-index 方案(见第四节)
return <LegacyModal open={open} onClose={onClose}>{children}</LegacyModal>;
}
return (
<dialog ref={ref} style={{ border: 'none', borderRadius: 12, padding: 0 }}>
<div style={{ padding: 20 }}>{children}</div>
</dialog>
);
}
六、本篇特有的坑
// ① 以为「Portal 会让事件也脱离 React 树」
<div onClick={() => console.log('父组件')}>
{createPortal(<button onClick={...}>点我</button>, document.body)}
</div>
// ⭐ 点击 Portal 里的按钮,父组件的 onClick【也会】被触发(合成事件沿 React 树冒泡)
// ✅ 用 e.stopPropagation(),或让父组件判断 e.target 是否在容器内
// ② 以为「Portal 里的组件读不到外层的 Context」
// ⭐ 正好相反:Context 按「React 树」传播,Portal 内完全能读到
// ⚠️ 真正会丢的是「CSS 继承(如 CSS 变量)」——按 DOM 树
// ③ Portal 出来之后拿不到主题变量
// ✅ 在 Portal 容器上重建变量,或把变量复制过去
// ④ 给 Portal 容器加 transform/opacity(又创建了层叠上下文)
#portal-root { transform: translateZ(0); } // ❌ 白折腾
// ✅ 容器保持「干净」(不加 transform/filter/opacity,也不设 z-index 以外的层级属性)
// ⑤ 在 Portal 容器上设了固定 z-index(内部元素无法超越)
#portal-root { z-index: 100; } // ⚠️ 内部 z-index 再大也压不过别的容器
// ✅ 要么「一个容器管一个语义层」,要么「容器不设 z-index,由内部元素自己申领」
// ⑥ 浮层散落在各个组件里(各自 z-index 99、999、9999)
// ⭐ 无法比较、无法维护
// ✅ 用固定容器 + 分层 token + 栈式申领
// ⑦ 用「魔法数字」写 z-index
style={{ zIndex: 9999 }} // ❌
// ✅ 从层级管理器申领,或用 CSS 变量 var(--z-popover)
// ⑧ 「嵌套弹窗」时一次 ESC 关掉全部
// ✅ 用栈:只有栈顶响应 ESC
// ⑨ 「嵌套弹窗」时关掉内层,body 滚动就被解锁了
document.body.style.overflow = ''; // ❌ 计数器缺失
// ✅ 用「锁计数器」(见 useScrollLock)
// ⑩ 「打开弹窗导致页面横向跳动」(滚动条消失)
// ✅ 补偿 `padding-right = 滚动条宽度`
// ⚠️ 更好的做法:`overflow: hidden` + `scrollbar-gutter: stable`(现代浏览器)
// ⑪ 遮罩挡住了弹窗内容(点不动)
// ⚠️ 遮罩 z-index 比弹窗高,或者两者在同一层但顺序反了
// ✅ 遮罩与弹窗「分别申领」,弹窗基数 > 遮罩基数
// ⑫ 遮罩全屏 + 接收指针事件 → 挡住了底下的「本该能点的东西」(如 Toast 上的按钮)
// ✅ Toast 容器用 `pointer-events: none`,Toast 本体 `pointer-events: auto`
// ⑬ Portal 容器在 SSR 时报错(`document is not defined`)
// ✅ 用 `useEffect` 之后再创建容器(客户端),或做环境判断
// ⑭ Portal 容器「每次渲染都新建一个」(重复挂载、内存泄漏)
useEffect(() => { const el = document.createElement('div'); document.body.appendChild(el); });
// ⚠️ 依赖缺失 → 每次渲染都新建
// ✅ 按 id 复用(`getElementById` 找不到才建)
// ⑮ `<dialog>` 的 `showModal()` 在同一元素上重复调用会抛错
el.showModal(); el.showModal(); // ❌ InvalidStateError
// ✅ 先判断 `el.open`
// ⑯ 原生 `dialog` 的「退出动画」不生效(close 后立刻从 top layer 移除)
// ✅ 先播放动画(`el.classList.add('closing')` + `animationend`)再 `close()`
// ⑰ `::backdrop` 里用不了页面的 CSS 变量 / 部分属性
// ✅ 用「自己画的遮罩 div」代替 ::backdrop(牺牲一点简洁性换可控性)
// ⑱ `popover` 与现代框架的「受控」模式冲突
// ⚠️ popover 有「自己的开合状态」,与 React 的 state 可能不同步
// ✅ 监听 `toggle` 事件同步状态,或用「受控 + imperative API」
// ⑲ 忘记「Portal 里的元素也要能被 Tab 到」
// ⚠️ 如果容器用了 `visibility: hidden`/`display: none` 的技巧,焦点会被跳过
// ✅ 焦点陷阱用「原生 dialog」或自己实现(见 11.7)
// ⑳ 「一个页面上有 20 个 Portal 容器」(性能与调试噩梦)
// ✅ 固定 3~4 个(modal / popover / toast / tooltip)
// ① 的完整处理(Portal 里的事件冒泡到 React 树父级)
function Menu({ onClose, children }: { onClose: () => void; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
return createPortal(
<div
ref={ref}
onClick={(e) => {
// ⭐ 如果想让「点击外部关闭」,用「判断 target 是否在容器内」而不是「靠冒泡」
if (!ref.current?.contains(e.target as Node)) onClose();
}}
>
{children}
</div>,
document.body
);
}
// ⑯ 的完整处理(原生 dialog 的退出动画)
async function closeWithAnimation(el: HTMLDialogElement) {
el.classList.add('is-closing');
await new Promise<void>((resolve) => {
const done = () => { el.removeEventListener('animationend', done); resolve(); };
el.addEventListener('animationend', done);
// 兜底:万一没有动画(或浏览器不触发),用超时
setTimeout(done, 320);
});
el.classList.remove('is-closing');
el.close();
}
七、面试延伸
- 「Portal 是什么?它解决了什么,又带来了什么?」
Portal(createPortal(children, container))的本质是「DOM 位置搬家、React 树位置不变」。它解决的是「脱离父级约束」——绕开父级的overflow: hidden/auto、clip-path、层叠上下文与布局流(弹窗、下拉、Toast 都需要)。它带来的是新的问题:① CSS 继承链断裂(可继承属性按 DOM 树走,所以「Portal 出来的弹窗拿不到主题 CSS 变量」);② 层级失控(浮层脱离了原本的父级,z-index的比较基准变了,见下一问);③ 事件冒泡的困惑(见第三问)。 -
「
z-index设得很大却还是被盖住,为什么?」
因为它的祖先创建了一个「层叠上下文」——在那个上下文里,它的z-index只与同上下文内的兄弟比较,而整个上下文相对外层的位置由祖先的z-index决定(「在一个房间里当最大的」压不住「隔壁房间的人」)。触发层叠上下文的常见属性:transform(非 none)、opacity < 1、filter/backdrop-filter、will-change、position: fixed/sticky、position: absolute/relative配z-index !== auto、flex/grid 子项配z-index、isolation: isolate、contain、mix-blend-mode。⭐ 最常见的「无意识创建」是「给元素加了淡入动画或will-change做性能优化」。 -
「Portal 里的事件冒泡有什么要注意的?」
⭐ 合成事件沿「React 树」冒泡,而不只是 DOM 树。所以「Portal 里点击」会触发「React 树上父组件」的onClick(即使 DOM 上它们已经不相邻)。这会造成两类问题:① 「点击浮层内部」被外层的「点击外部关闭」逻辑误判为「点到了外面」→ ✅ 正确做法是判断e.target是否在容器内(ref.current.contains(target)),而不是依赖冒泡的stopPropagation;② 「父组件的点击处理器意外触发」→ ✅ 在需要时e.stopPropagation()。另外 Context 不受影响(按 React 树传播),所以状态库/主题在 Portal 里都正常工作。 -
「层级(z-index)怎么治理?」
四层方案,从省事到彻底:① 方案 0——用 top layer(<dialog showModal()>/ Popover API),元素进入浏览器维护的「顶层」,不受任何z-index与层叠上下文影响(最现代);② 分层 token——给「层级语义」分配固定区间(吸顶 100 / 下拉 1000 / 遮罩 2000 / 弹窗 2100 / 浮层 3000 / 通知 4000),禁止业务代码写魔法数字;③ 栈式申领——同一层内按「打开顺序」递增(后开的在上),关闭时归还(避免「关一个导致另一个跳层」);④ 固定 Portal 容器——把浮层集中到modal-root/popover-root/toast-root几个节点下(⭐ 并保证容器本身不创建新的层叠上下文)。 -
「嵌套弹窗(Modal 里再开 Modal)有哪些坑?」
四个:① ESC——如果不做处理,一次 ESC 会关掉所有弹窗(✅ 用「浮层栈」,只有栈顶响应 ESC);② 滚动锁——内层关闭时如果直接解锁overflow,会导致「外层还在但页面能滚了」(✅ 用锁计数器:count++/count--,归零才恢复);③ 遮罩叠层——两个遮罩叠起来会越来越黑(✅ 遮罩也按「深度」递减透明度,或只保留最上层遮罩);④ 焦点陷阱——内层的 Tab 应该只在内层循环(✅ 用原生<dialog showModal()>或自己做「栈式焦点管理」)。另外还要注意「打开弹窗导致横向跳动」(滚动条消失 → 补偿padding-right或用scrollbar-gutter: stable)。 -
「top layer(
<dialog>/ Popover API)比 z-index 好在哪?有什么限制?」
好处(四个质变):① 不受任何z-index/层叠上下文影响——只要在 top layer 就一定在最上面,「层级战争」从根上消失;② 自动处理遮罩与焦点——showModal()自动带::backdrop、ESC 关闭、焦点陷阱(Tab 不跑出去);③popover="auto"自动处理「点击外部关闭(light dismiss)」与互斥(打开一个自动关另一个);④ top layer 的顺序就是「打开顺序」(与浮层栈直觉一致)。限制:① 兼容性(<dialog>较好,Popover API 需要较新浏览器)→ 需要降级;② top layer 元素脱离常规流(定位要自己写);③ ⭐ 「退出动画」不生效(元素立刻从 top layer 移除)→ 需要「先播动画再close()」;④::backdrop里对 CSS 变量/部分属性有限制。 -
「Portal 容器为什么不能加
transform/opacity?」
因为它们会创建新的层叠上下文——一旦容器成为层叠上下文,容器内部所有元素的z-index就只在容器内比较,与「其它容器 / 页面其它部分」失去可比性。于是会出现「popover容器的 z-index 是 3000,但因为容器只有 z-index 100,所以它的内容被 z-index 2000 的 modal 盖住了」这类问题。⭐ 所以容器的正确做法是「尽量保持干净」:position: fixed; inset: 0; pointer-events: none;,不设transform/opacity/filter/will-change,层级交给「每个浮层自己申领z-index」或「一个容器管一个语义层」。⚠️ 注意position: fixed自身也会创建层叠上下文(但它的 z-index 可以配合,且这是使用 Portal 的常规方式)。 -
「CSS 变量在 Portal 里丢了怎么解决?」
因为可继承属性(含 CSS 自定义属性)按 DOM 树继承,而 Portal 把节点挂到了body下 → 原来父级上的--color-primary等变量接不上。三种解法:① ⭐ 在 Portal 容器上重新定义变量(最简单)——给容器加一个 class(如.modal-root { --color-primary: #1677ff; }),或在容器上把「主题 class」也加上(如<div class="modal-root theme-dark">);② 复制变量——用getComputedStyle遍历源节点的自定义属性,style.setProperty写到容器上(适合「变量是运行时动态计算」的场景);③ 把 Portal 挂在「主题容器内」(而不是body)——即把 container 传成「主题元素」本身,⭐ 这样继承链完整保留(如果该容器没有overflow/transform的裁剪问题)。
一句话速记
Portal = 「DOM 位置搬家,React 树位置不变」——所以 Context 与合成事件(沿 React 树冒泡)都仍然生效,但 CSS 继承(含 CSS 变量)按 DOM 树走会断(✅ 在容器上重建变量或把 Portal 挂到主题容器内);
z-index失效的根源是「祖先创建了层叠上下文」(transform/opacity<1/filter/will-change/fixed/position+z-index/isolation/contain/mix-blend-mode),⭐ 最常见的无意识创建是「淡入动画与will-change优化」;治理层级用四层方案:「top layer(<dialog>/Popover API,彻底免疫 z-index)→ 分层 token(语义基数)→ 栈式申领(同层按打开顺序递增)→ 固定 Portal 容器」,⭐ 容器本身必须保持干净(不加transform/opacity/filter,否则自己又建了一个层叠上下文);嵌套弹窗要处理四件事:ESC 只关栈顶、滚动锁用计数器、遮罩按深度处理、焦点陷阱用栈;原生<dialog>的代价是「退出动画不生效」(要先播动画再close())。



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