陌上人如玉
公子世无双

1.12 Portal 与层级治理

1.12 Portal 与层级治理

一句话: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(浏览器原生)】
   + 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 节点下)】
  
/* ✅ 分层的 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 = {
  sticky: 100,
  dropdown: 1000,
  backdrop: 2000,
  modal: 2100,
  popover: 3000,
  toast: 4000,
};

/** 同层内的计数器(后开的在上) */
const counters = new Map();
/** 已申领的层级(按顺序,用于「找最上层」) */
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(, el, spec.id);
      })}
    
  );
}

/** 给容器内部一个「层级作用域」的说明(可选:用于调试) */
function ContainerScope({ baseZ }: { baseZ: number }) {
  return 
; } /** ⭐ 用 Hook 拿到容器元素(供 createPortal 使用) */ export function usePortalContainer(kind: 'modal' | 'popover' | 'toast') { const [container, setContainer] = useState(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(
    <>
      {/* 遮罩 */}
      
{ if (isTop()) onClose(); }} style={{ position: 'fixed', inset: 0, zIndex: backdropLayer?.zIndex ?? 2000, background: 'rgba(0,0,0,.45)', pointerEvents: 'auto', // ⭐ 遮罩要接收点击 }} /> {/* 弹窗本体 */}

{title}

{children}
, container ); } // ⭐ 在应用根部渲染 LayerHosts export function App() { return ( <> ); }
⭐⭐⭐ 这个实现解决的四类「层级问题」(对照着看它的价值):

① 【「谁在上」有明确规则】
   语义基数(sticky 100 < dropdown 1000 < backdrop 2000 < modal 2100
   < popover 3000 < toast 4000)+ 同层内按申领顺序递增。
   → ⭐ 业务代码不再写魔法数字,也不可能出现「通知被弹窗盖住」。

② 【同层多个浮层按「打开顺序」叠加】
   第一个弹窗 2100、第二个 2101 → 后开的在上(符合直觉)。
   释放时「只有最上层被关掉才回收号码」,避免「关一个导致另一个跳层」。

③ 【ESC 只关最上层】
   用 `dialogStack` 记录打开顺序,只有栈顶响应 ESC。
   ⚠️ 如果不做,用户按一次 ESC 会把「嵌套的所有弹窗」一起关掉(体验灾难)。

④ 【滚动锁用计数器】
   `scrollLockCount` 保证「嵌套弹窗关闭内层时不会提前解锁 body 滚动」。
   ⭐ 并补偿滚动条宽度,避免「打开弹窗时页面横向跳动」。

五、更现代的做法:top layer 与原生 <dialog>



  

标题

/* ⭐ 原生遮罩(自动的) */
dialog::backdrop {
  background: rgba(0, 0, 0, 0.45);
  /* ⚠️ 注意:::backdrop 里「不能继承页面的 CSS 变量」的老问题已修复,
      但仍有一些限制(不能用 position: fixed 之类) */
}




⭐⭐⭐ top layer 相比 z-index 的四个「质变」(这是它值得用的原因):

① 【不受任何 z-index / 层叠上下文影响】
   ⭐ 无论祖先有多少个 transform、z-index,只要是 top layer,
     就一定在最上面(浏览器维护的一个独立的「最顶层」列表)。
   → 「层级战争」从根本上消失。

② 【自动处理遮罩与焦点】
   `` 自动:遮罩(`::backdrop`)、ESC 关闭、焦点陷阱(Tab 不会跑出去)、
   「点击背景不关闭」(需要自己处理或监听 click 判断坐标)。

③ 【`popover="auto"` 自动处理「点击外部关闭」与「一次只有一个」】
   `popover="auto"` 的「light dismiss」+ 互斥(打开一个自动关另一个);
   `popover="manual"` 则不会自动关闭(适合 Toast)。

④ 【top layer 的顺序 = 「打开顺序」】
   后进入 top layer 的在上面;⭐ 与「浮层栈」的直觉完全一致。

⚠️ 兼容性与注意:
   · `` 支持较好;Popover API 需要较新浏览器(2023+)→ 需要降级
   · top layer 的元素**脱离常规流**(类似 fixed),定位要自己写
   · ⭐ 「从 top layer 出去后」不能做「退出的过渡动画」(因为它立刻被移除)——
     需要「先播放动画、再 close」的手动处理
// ✅ 一个「原生优先 + JS 降级」的 Modal 思路
function AdaptiveModal({ open, onClose, children }: ModalProps) {
  const ref = useRef(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 {children};
  }

  return (
    
      
{children}
); }

六、本篇特有的坑

// ① 以为「Portal 会让事件也脱离 React 树」
console.log('父组件')}> {createPortal(, document.body)}
// ⭐ 点击 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` 找不到才建) // ⑮ `` 的 `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(null);
  return createPortal(
    
{ // ⭐ 如果想让「点击外部关闭」,用「判断 target 是否在容器内」而不是「靠冒泡」 if (!ref.current?.contains(e.target as Node)) onClose(); }} > {children}
, document.body ); }
// ⑯ 的完整处理(原生 dialog 的退出动画)
async function closeWithAnimation(el: HTMLDialogElement) {
  el.classList.add('is-closing');
  await new Promise((resolve) => {
    const done = () => { el.removeEventListener('animationend', done); resolve(); };
    el.addEventListener('animationend', done);
    // 兜底:万一没有动画(或浏览器不触发),用超时
    setTimeout(done, 320);
  });
  el.classList.remove('is-closing');
  el.close();
}

七、面试延伸

  1. 「Portal 是什么?它解决了什么,又带来了什么?」

Portal(createPortal(children, container))的本质是「DOM 位置搬家、React 树位置不变」。它解决的是「脱离父级约束」——绕开父级的 overflow: hidden/auto、clip-path、层叠上下文与布局流(弹窗、下拉、Toast 都需要)。它带来的是新的问题:① CSS 继承链断裂(可继承属性按 DOM 树走,所以「Portal 出来的弹窗拿不到主题 CSS 变量」);② 层级失控(浮层脱离了原本的父级,z-index 的比较基准变了,见下一问);③ 事件冒泡的困惑(见第三问)。

  1. 「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 做性能优化」。

  1. 「Portal 里的事件冒泡有什么要注意的?」

⭐ 合成事件沿「React 树」冒泡,而不只是 DOM 树。所以「Portal 里点击」会触发「React 树上父组件」的 onClick(即使 DOM 上它们已经不相邻)。这会造成两类问题:① 「点击浮层内部」被外层的「点击外部关闭」逻辑误判为「点到了外面」→ ✅ 正确做法是判断 e.target 是否在容器内(ref.current.contains(target)),而不是依赖冒泡的 stopPropagation;② 「父组件的点击处理器意外触发」→ ✅ 在需要时 e.stopPropagation()。另外 Context 不受影响(按 React 树传播),所以状态库/主题在 Portal 里都正常工作。

  1. 「层级(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 几个节点下(⭐ 并保证容器本身不创建新的层叠上下文)。

  1. 「嵌套弹窗(Modal 里再开 Modal)有哪些坑?」

四个:① ESC——如果不做处理,一次 ESC 会关掉所有弹窗(✅ 用「浮层栈」,只有栈顶响应 ESC);② 滚动锁——内层关闭时如果直接解锁 overflow,会导致「外层还在但页面能滚了」(✅ 用锁计数器:count++/count--,归零才恢复);③ 遮罩叠层——两个遮罩叠起来会越来越黑(✅ 遮罩也按「深度」递减透明度,或只保留最上层遮罩);④ 焦点陷阱——内层的 Tab 应该只在内层循环(✅ 用原生 <dialog showModal()> 或自己做「栈式焦点管理」)。另外还要注意「打开弹窗导致横向跳动」(滚动条消失 → 补偿 padding-right 或用 scrollbar-gutter: stable)。

  1. 「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 变量/部分属性有限制。

  1. 「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 的常规方式)。

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

赞(0) 打赏
未经允许不得转载:陌上寒 » 1.12 Portal 与层级治理

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫