返回博客

WebContentsView 盖住了弹窗——z-index 永远赢不了原生层

做了一个 Electron 桌面工具,左边嵌入真实网页(用 WebContentsView 跑),右边是 React 写的操作面板。一切看起来很正常——直到测试反馈「点编辑按钮没反应」。React DevTools 里 dialog 状态分明是 open,DOM 里也能看到节点,z-index: 9999position: 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 上方,层级不通。

坑:z-index / portal 对原生合成层完全无效

WebContentsView 是主进程的 native view,不属于任何 DOM stacking context。z-index: 9999position: fixed、React portal 到 document.body——这些都在 HTML 层内部,无法超越原生层。

反模式:弹窗打开时隐藏 BrowserView

第一反应往往是「弹窗开着的时候把 BrowserView 藏起来」:

// ❌ 反模式——不要这样做
<NativeBrowserSurface
  active={Boolean(url) && !editDialogOpen && !dropdownOpen}
/>

看起来能解决弹窗显示问题,但引入两个新 bug:

  1. 每次关闭弹窗,嵌入页面重新导航active 重新变 true 时,打开效果重跑,调用 browser.open(url),滚动位置、表单内容、AJAX 状态全部丢失。
  2. 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 />}
正解:布局隔离——弹窗只在 BrowserView 覆盖不到的区域里渲染

WebContentsViewsetBounds 约束在窗口的一个子区域(如左侧 70% 或上方 N px 以下),所有 HTML 弹窗锚定在剩余区域内。嵌入页面始终可见,弹窗始终可见,彼此不干涉。

一句话外卖

原生合成层(WebContentsView / BrowserView)永远在 HTML 之上,想让弹窗可见,唯一可靠的办法是让两者在屏幕上占据不同区域——这是布局问题,不是 z-index 问题。