
一句话: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(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({children}
, document.body);
}
// ⭐ 更常见的做法(更简单):给 Portal 容器加一个「标识 class」,
// 让 CSS 变量在这个 class 上重新定义:
// .modal-root { --color-primary: #1677ff; --radius: 8px; }
//
二、层叠上下文: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(浏览器原生)】