WebContentsView 盖住了弹窗——z-index 永远赢不了原生层
做了一个 Electron 桌面工具,左边嵌入真实网页(用 WebContentsView 跑),右边是 React 写的操作面板。一切看起来很正常——直到测试反馈「点编辑按钮没反应」。React DevTools 里 dialog 状态分明是 open,DOM 里也能看到节点,z-index: 9999,position: fixed……弹窗就是不出现。
现象
- 点按钮,什么也没发生,页面上只看到嵌入的网页
- 有时候弹窗「闪了一帧」就消失了
- Dropdown、Tooltip、Toast 同样消失
- 在
npm run dev(浏览器里预览)完全正常,只在 Electron 构建里复现 - 把
z-index调到一百万也没用
根因
WebContentsView(Electron ≥ 30)和它的前身 BrowserView 不是 HTML 元素。它们是主进程通过 view.setBounds({ x, y, width, height }) 定位的原生 OS 合成层,直接附在 BrowserWindow 上,绘制在整个 HTML 层的上方。
HTML 的 stacking context(z-index / position / portal / createPortal(node, document.body) / Dialog.Portal)全部在 DOM 内部运作,根本无法触及这一层。无论你的 z-index 多高,都是在 HTML 层内部排序,对原生层没有任何影响。
这个坑和微信小程序里 <map> / <canvas> 组件遮住 <van-popup> 是同一家族——原生层永远在 HTML 上方,层级不通。
WebContentsView 是主进程的 native view,不属于任何 DOM stacking context。z-index: 9999、position: fixed、React portal 到 document.body——这些都在 HTML 层内部,无法超越原生层。
反模式:弹窗打开时隐藏 BrowserView
第一反应往往是「弹窗开着的时候把 BrowserView 藏起来」:
// ❌ 反模式——不要这样做
<NativeBrowserSurface
active={Boolean(url) && !editDialogOpen && !dropdownOpen}
/>看起来能解决弹窗显示问题,但引入两个新 bug:
- 每次关闭弹窗,嵌入页面重新导航。
active重新变true时,打开效果重跑,调用browser.open(url),滚动位置、表单内容、AJAX 状态全部丢失。 - UX 割裂。用户填写一个小输入框时,整个嵌入页面消失了,失去了视觉上下文。
正解
布局隔离:让 WebContentsView 只占用窗口的一部分区域,HTML 弹窗锚定在 BrowserView 不覆盖的区域里。
┌──────────────────────────┬──────────────┐
│ │ │
│ WebContentsView 区域 │ 右侧 Rail │
│ (~70% 宽) │ (~420px) │
│ │ │
└──────────────────────────┴──────────────┘
右侧 Rail 是纯 HTML,BrowserView 只占左列——所有弹窗锚在右侧,永远不会被原生层遮住。
布局层:Grid 分栏
// 主布局:左列给 BrowserView,右列给 HTML 操作区
<div className="grid h-full grid-cols-[minmax(0,1fr)_420px]">
<div ref={browserViewportRef}>{/* BrowserView 占位 */}</div>
<aside className="border-l">{/* HTML 右侧操作面板 */}</aside>
</div>NativeBrowserSurface 读取 viewportRef.current.getBoundingClientRect() 后调用 view.setBounds(...),原生 view 自动对齐左列,永远进不了 420px 的右侧区域。
弹窗:右侧抽屉而非居中 Dialog
// 编辑弹窗:固定在右侧而非窗口中央
return (
<div className="fixed inset-y-0 right-0 z-50 flex w-[420px] flex-col
border-l bg-background shadow-2xl">
<header className="flex items-center justify-between border-b px-4 py-3">
<span className="text-sm font-semibold">{title}</span>
<button onClick={onCancel} aria-label="关闭">✕</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{/* 表单内容 */}
</div>
<footer className="flex justify-end gap-2 border-t px-4 py-3">
<button onClick={onCancel}>取消</button>
<button onClick={onConfirm}>保存</button>
</footer>
</div>
)抽屉只覆盖右侧 Rail 区域,BrowserView 完整可见、完整可交互,URL 不会重新加载,滚动位置完整保留。
底部浮层:裁掉一条底边
如果有底部居中的 Toast / 状态条,同样会被原生层盖住。解法是在显示浮层时,从下方「裁掉」BrowserView 的一段高度:
// 浮层显示时给 BrowserView 减去底部高度
const height = Math.max(0, rect.height - bottomInsetPx)
// 使用侧:busy 时裁 104px,平时全高
<NativeBrowserSurface bottomInsetPx={busy ? 104 : 0} />
{busy && <GlobalLoadingAlert />}把 WebContentsView 的 setBounds 约束在窗口的一个子区域(如左侧 70% 或上方 N px 以下),所有 HTML 弹窗锚定在剩余区域内。嵌入页面始终可见,弹窗始终可见,彼此不干涉。
一句话外卖
原生合成层(WebContentsView / BrowserView)永远在 HTML 之上,想让弹窗可见,唯一可靠的办法是让两者在屏幕上占据不同区域——这是布局问题,不是 z-index 问题。