BrowserWindow:你的Electron 应用可以不用手写红绿灯

152 阅读8分钟

前些天,我在使用一个开源的 Electron 桌面应用时,注意到一个很有意思的细节。

它的顶部做成了现在很流行的一体化标题栏,搜索框和工具栏直接嵌在最顶层。

但我发现,它右上角的“最小化、最大化、关闭”按钮(在 macOS 上则是左上角的红绿灯),不仅外观完全是操作系统原生的,而且鼠标悬停在 Windows 11 的最大化按钮上时,居然能丝滑地弹出系统原生的 Snap Layouts(贴靠分屏菜单)。

出于职业习惯,我打开开发者工具审查了一下。原本以为会看到几个手写的 <div> 标签,结果发现那块区域在 DOM 树里完全不存在。

这引起了我的强烈好奇:它是怎么做到既把网页内容铺满整个窗口,又保留系统原生控制按钮的?


熟悉桌面端开发的朋友都知道,早期的 Electron 应用如果想做无边框的沉浸式界面,通用的做法是给窗口配置 frame: false。

但这个做法有很大的副作用:一旦去掉了原生边框,系统的控制按钮就全没了,连窗口自带的阴影和缩放边框(Resize)也会受影响。

开发者不得不自己用 HTML 和 CSS 画出三颗按钮,再通过 IPC 向主进程发送控制消息:

// 传统的做法:前端监听点击并发送 IPC 消息
ipcRenderer.send('window-min');
ipcRenderer.send('window-max');
ipcRenderer.send('window-close');

这种做法不仅写起来繁琐,而且体验总觉得差点意思。

最明显的问题就是难以还原操作系统的原生细节:Windows 11 的贴靠分屏菜单做不出来;macOS 上长按全屏按钮的分屏动画、高分屏下的图标微动效也经常丢失。


后来我查阅了相关规范,发现现代桌面开发早已引入了一个更优雅的解决方案:Window Controls Overlay(标题栏控件叠加层,简称 WCO)。

它的思路非常巧妙。

过去我们要么选择“系统默认标题栏”,要么选择“完全无边框”。而 WCO 提供了第三种选择:允许网页内容向上延伸、占满整个窗口,同时把操作系统的原生控制按钮像图层一样,直接悬浮叠加在网页的最上方。

在 Electron 中启用它,只需要在主进程中配置。不过这里藏着一个非常关键的跨平台细节:macOS 和 Windows 的正确启用方式并不一样。

const isMac = process.platform === 'darwin';
​
const mainWindow = new BrowserWindow({
  width: 1000,
  height: 700,
  // 隐藏传统标题栏,但保留内容区向上扩展
  titleBarStyle: 'hidden',
  ...(isMac
    ? {
        // macOS:红绿灯由系统绘制,外观不可定制;传 true 只是为了
        // 启用 WCO 的 JS API 与 CSS 环境变量(env(titlebar-area-*))
        titleBarOverlay: true,
      }
    : {
        // Windows:必须同时设 frame: false,overlay 才会真正启用;
        // thickFrame 默认为 true,窗口阴影与边缘缩放不受影响
        frame: false,
        // 按钮区域的背景与图标颜色(color/symbolColor 官方标注 @platform win32,linux)
        titleBarOverlay: {
          color: '#1e1e1e',
          symbolColor: '#ffffff',
        },
      }),
});

我最初照着“一套配置通吃三平台”的思路落地时,就踩了两个实打实的坑:

  1. 在 Windows 上只写 titleBarStyle: 'hidden' + titleBarOverlay,navigator.windowControlsOverlay.visible 一直是 false,运行时调用 setTitleBarOverlay 还会直接抛出 TypeError: Titlebar overlay is not enabled——补上 frame: false 后才真正生效(Electron 41 + Windows 11 实测);
  2. 在 macOS 上,titleBarOverlay 的 color / symbolColor 根据官方类型定义只在 Windows/Linux 生效,传了也没效果——红绿灯的外观本来就由系统管理。

另外留意 height 这个字段:它缺省取系统标题栏高度。除非你的设计强依赖某个固定条高(VS Code 钉的是 29px,注释写明是“Windows 11 边框下最小标题栏高度”,运行时更新还会减 1px 补偿窗口边框),否则建议干脆不传,跟着系统走。

开启之后,我们不再需要写任何按钮的 DOM 结构,也不需要绑定任何 IPC 事件了。

系统原生按钮会自动接管所有点击与悬停事件,Windows 11 的贴靠分屏原生可用,macOS 的红绿灯动效也完全保留。


不过,这也引出了一个更现实的跨平台排版难题:macOS 的红绿灯在左上角,而 Windows 的控制按钮在右上角。

如果网页内容直接顶到最上面,顶部的搜索框或头像很容易被原生的浮动按钮遮挡。更棘手的是,在高分屏(如 125%、150% 缩放)下,如果用硬编码的 padding(比如写死 80px),极容易产生 1 像素的微小错位或缝隙。

怎么优雅地解决这个问题?

答案不是在 JavaScript 里写各种复杂的平台判断,而是借助 Chromium 内核注入的 CSS 环境变量(env()) 。

很多人熟悉 CSS 自定义属性 var(),而 env() 的不同之处在于,它是由浏览器引擎(Blink)根据底层操作系统的物理布局动态注入的标准环境变量。

现代浏览器为 WCO 规范注入了一组标准变量:

  • env(titlebar-area-height):系统按钮所在的物理高度;
  • env(titlebar-area-width):扣除控制按钮后的实际可用宽度;
  • env(titlebar-area-x):可用区域的起始横坐标。

结合这组变量,我们可以写出一套自适应 macOS 和 Windows 的通用 Header 样式:

.app-header {
  width: 100%;
  display: flex;
  align-items: center;
  box-sizing: border-box;
​
  /* macOS 上 env(titlebar-area-x) 会自动空出左侧红绿灯的宽度;
     回退值要按平台给:红绿灯区域通常预留 70~80px,
     写个 12px 的小回退,会在 env 不可用时让内容穿到红绿灯底下 */
  padding-left: env(titlebar-area-x, 80px);
​
  /* Windows 上通过总宽度减去可用宽度,自动空出右上角按钮的宽度 */
  padding-right: calc(100vw - env(titlebar-area-width, 100vw));
​
  /* 标记整个导航栏为可拖拽窗口区域 */
  -webkit-app-region: drag;
}
​
/* 导航栏内部的输入框与按钮必须恢复为不可拖拽,否则无法点击 */
.app-header input,
.app-header button {
  -webkit-app-region: no-drag;
}

注意我这里刻意没有写 height: env(titlebar-area-height, 35px) 。overlay 的高度只是“原生控件那一小条”的高度,并不是整个 Header 的高度——如果你的头部有两行标题、副标题或 Tab 栏,强制压成 35px 只会被裁掉。更舒服的心态是:左右避让交给 env(),头部高度交给内容。

这套写法最妙的地方在于处理 macOS 的全屏模式。

当用户在 macOS 下点击绿色按钮切到全屏时,系统会自动把红绿灯移到系统顶栏,此时 env(titlebar-area-x) 会瞬间自动归零。页面左侧的边距随之平滑贴边,完全不需要写一行 JS 监听。


除了 CSS,WCO 还配套了一组 JavaScript API,这是很多人容易漏掉的部分:

const wco = navigator.windowControlsOverlay;
​
// 当前 overlay 是否真正启用(做能力探测比背版本号可靠得多)
console.log(wco.visible);
​
// 读取避让区域的实际几何信息
console.log(wco.getTitlebarAreaRect()); // { x, y, width, height }
​
// 窗口尺寸/最大化/进出全屏导致几何变化时触发
wco.addEventListener('geometrychange', () => {});

它在实践中还有一个隐藏价值:wco.visible 是验证 overlay 是否真正生效的唯一可靠手段,写 E2E 测试时直接断言它即可。顺带一提,Electron 41 里这个方法叫 getTitlebarAreaRect(),旧版规范草案里的 getTitlebarArea() 并不存在,抄旧代码时会踩坑。


深浅色主题切换时同步按钮颜色,是另一个高频需求。可以在主进程中监听 nativeTheme:

nativeTheme.on('updated', () => {
  // 守卫很重要:setTitleBarOverlay 官方标注 @platform win32,linux,
  // 且要求窗口创建时就启用了 overlay,否则会抛
  // "Titlebar overlay is not enabled"
  if (process.platform === 'darwin') return;
​
  const dark = nativeTheme.shouldUseDarkColors;
  mainWindow.setTitleBarOverlay({
    color: dark ? '#141414' : '#ffffff',
    // symbolColor 建议按背景明暗选黑/白,保证图标对比度
    symbolColor: dark ? '#ffffff' : '#000000',
  });
});

两个工程细节值得参考 VS Code 的源码实现:

  1. 能力捕获:创建窗口时用 const hasWindowControlOverlay = !!options.titleBarOverlay 把“overlay 是否启用”存下来,更新前先判断,而不是裸调 API;
  2. 多窗口:setTitleBarOverlay 是按窗口生效的,如果应用不止一个窗口,'updated' 回调里只更新一个 win 会漏掉其余窗口——VS Code 的做法是让每个窗口实例自己持有开关、各自响应主题变化。

在实际落地过程中,还有两点细节值得注意:

  1. 版本支持:与其背诵“哪个 Electron 版本开始支持、哪个版本最完善”,不如在运行时用 navigator.windowControlsOverlay 做能力探测——本文的配置在 Electron 41 上实测通过,但跨版本要稳,靠的是探测而不是版本号记忆。
  2. Linux 兼容性:Electron 官方类型定义里,overlay 的颜色与运行时更新 API 都标注为 @platform win32,linux,VS Code 在 Linux 上也是走 WCO 的。不过考虑到 Linux 桌面环境(GNOME、KDE 等)碎片化严重,如果你的用户以 Linux 发行版居多,降级保留系统默认外框(titleBarStyle: 'default')依然是稳妥的选择。

在桌面客户端开发的早期阶段,大家为了追求视觉上的沉浸感,不得不手动去写很多别扭的胶水代码,既累人又容易出 Bug;而随着 Web 标准对操作系统底层能力的不断吸收,原本复杂的适配问题,最后往往只需要几行原生的配置和 CSS 变量就能轻松化解。

把系统级的功能交还给操作系统,把界面排版留给 Web 标准,这大概就是跨平台应用最舒服的开发方式。

大家在平时的跨平台开发中,还遇到过哪些巧妙的系统级适配细节吗?欢迎留言交流。