字节笔记本

2026年2月22日

Electron 无边框窗口与自定义标题栏开发指南

默认情况,在构建 Electron BrowserWindow 的时候,会使用系统自带的原生窗口样式,比如在 MacOS 下的样式:

在有些情况下,操作系统的原生窗口并不能符合我们的一些视觉和交互需求。所以,在使用 electron 创建桌面应用的时候,有时候我们希望能完全掌控窗口的样式,而隐藏掉系统提供的窗口边框和标题栏等。这个时候就需要用到自定义窗口。

无边框窗口的拖拽

无边框窗口是不带外壳(包括窗口边框、工具栏等),只含有网页内容的窗口。要创建无边框窗口,需在 BrowserWindow 的构造中将 frame 参数设置为 false

javascript
// main.js
const { BrowserWindow } = require('electron')
const win = new BrowserWindow({ frame: false })

默认情况下,无边框窗口是不可以拖拽的。所以接下来,我们介绍几种让无边框窗口支持拖拽的方式。

1. 使用 -webkit-app-region: drag

应用程序需要在 CSS 中指定 -webkit-app-region: drag 来告诉 Electron 哪些区域是可拖拽的(如操作系统的标准标题栏),当前只支持矩形形状区域。

html
<body style="-webkit-app-region: drag"></body>

注意:在某部分 windows 上使用 -webkit-app-region: drag 来设置拖拽,那么请记住需要在可拖拽区域内部使用 -webkit-app-region: no-drag 来将其中部分需要交互的区域排除。不然那些需要交互的元素几乎无法响应所有的鼠标事件,包括点击、拖拽等。

html
<body style="-webkit-app-region: drag">
  <button style="-webkit-app-region: no-drag;">click</button>
</body>

所以,如果你需要整个窗口所有区域都支持拖拽,那臣妾就做不到了~

2. 自定义拖拽事件

既然 -webkit-app-region: drag 无法做到全屏拖拽移动窗口,那么有没有更好的办法呢?其实另一种方案就是自定拖拽移动,具体怎么做呢?

  • Electron 需要拖拽的窗口的内容区域监听 mousedown 事件,如果是鼠标左键按下,则开启可拖拽开关 draggable = true。然后记录鼠标按下去的位置 mouseXmouseY
  • 接下来就启动一个 requestAnimationFrame 函数来把 mouseXmouseY 传递给主进程并不断和主进程通信。
  • 主进程那边每收到一次通信请求就使用 screen.getCursorScreenPoint() 来获取最新的鼠标位置 x、y。并计算鼠标的位移数值。最后通过 window.setBounds 来重新设置窗口的位置
  • 监听鼠标的 mouseup 事件,如果触发,则设置 draggable=false。防止意外拖拽的产生。

对应到代码的实现:

javascript
// renderer/dragWindow.js
import { ipcRenderer } from 'electron';

const useDrag = () => {
  let animationId;
  let mouseX;
  let mouseY;
  let clientWidth = 0;
  let clientHeight = 0;
  let draggable = true;

  const onMouseDown = (e) => {
    // 右击不移动,只有左击的时候触发
    if (e.button === 2) return;
    draggable = true;
    // 记录位置
    mouseX = e.clientX;
    mouseY = e.clientY;
    // 记录窗口大小
    if (Math.abs(document.body.clientWidth - clientWidth) > 5) {
      clientWidth = document.body.clientWidth;
    }
    if (Math.abs(document.body.clientHeight - clientHeight) > 5) {
      clientHeight = document.body.clientHeight;
    }
    // 注册 mouseup 事件
    document.addEventListener('mouseup', onMouseUp);
    // 启动通信
    animationId = requestAnimationFrame(moveWindow);
  };

  const onMouseUp = () => {
    // 释放锁
    draggable = false;
    // 移除 mouseup 事件
    document.removeEventListener('mouseup', onMouseUp);
    // 清除定时器
    cancelAnimationFrame(animationId);
  };

  const moveWindow = () => {
    // 传给主进程位置信息
    ipcRenderer.send('msg-trigger', {
      type: 'windowMoving',
      data: { mouseX, mouseY, width: clientWidth, height: clientHeight },
    });
    if (draggable) animationId = requestAnimationFrame(moveWindow);
  };

  return {
    onMouseDown,
  };
};

export default useDrag;
javascript
// main.js
public windowMoving({ data: { mouseX, mouseY, width, height } }, window, e) {
  // 获取当前鼠标的绝对位置。
  const { x, y } = screen.getCursorScreenPoint();
  // 获取当前需要移动的窗口
  const originWindow = this.getCurrentWindow(window, e);
  if (!originWindow) return;
  // 重新设置窗口位置
  originWindow.setBounds({ x: x - mouseX, y: y - mouseY, width, height });
}

但这么做也有一些问题,首先就是渲染进程需要主进程直接进行通信,通信需要一定时间,所以窗口的移动必然慢于鼠标的移动,会造成一定程度的卡顿。其次,只能通过 document.removeEventListener('mouseup') 的方法来注销对鼠标移动事件的监听,这个跟第一点接到一起就可能出现这样一种情况:鼠标移动得太快,界面没来得及跟得上,导致鼠标在界面外部释放,未能触发 mouseup 事件,后面就会出现鼠标不管移动到哪里,界面都会跟着,特别烦人!😠

3. 使用 electron-drag 库

相对于我们方案 2 提到问题,主要是因为我们需要监听鼠标的 mousedownmouseup 事件必须要和 DOM 绑定。所以如何实现系统级别的 mousedownmouseup 就成了关键所在。

electron-drag 模块使用 osx-mousewin-mouse 模块来跟踪整个屏幕上的鼠标位置,从而实现了一致的窗口拖动,同时受影响的元素仍能够接收 DOM 事件。使用方式也非常方便:

javascript
// app.vue
import drag from 'electron-drag-latest';

const undrag = drag('#app');

// 如果不需要拖拽,调用 undrag 函数
// undrag()

完整代码见:github.com/muwoo/electron-drag-latest

但需要注意的是,electron-drag 仅支持 macOSwindows 操作系统,其他平台都不支持。因此,我们可以在不支持的平台上使用第二种方案来实现。

注意:electron-drag 因为依赖了 osx-mousewin-mouse 模块,而这两个模块都是需要进行 C++ 额外本地编译的,所以你可能还需要用 electron-rebuild 进行重新编译。

自定义窗口标题栏

前面说到无边框窗口是一种不带外壳(包括窗口边框、工具栏等)、只含有网页内容的窗口。但是有的时候,我们还是希望要包含工具栏和标题。这样不仅可以方便用户进行窗口最大化、最小化和关闭的操作,我们还可以融合一些自定义的操作能力进入标题栏。

这种情况下,我们就需要实现一种自定义标题栏了,但这种自定义标题栏,在 Electron 中,WindowsmacOS 的实现和样式是不一样的。接下来将详细介绍。

1. Windows 下自定义标题栏

在 Electron 中,我们可以通过 frame = false 设置无边框窗口。再通过 titleBarStyle = 'hidden'titleBarOverlay 的方式来创建一个带有操作栏的无边框窗口:

javascript
new BrowserWindow({
  width: 800,
  height: 600,
  titleBarStyle: 'hidden',
  // 在windows上,设置默认显示窗口控制工具
  titleBarOverlay: { color: "#fff", symbolColor: "black", }
});

但是这样的无边框窗口仅能实现通用的样式,而且样式也比较奇怪:控制区域图标大小、间距无法修改,也没法内置其他的操作图标进去。

所以这个时候,在 windows 中,如果想要实现下面这样的效果(有自定义的标题、icon 图标),那么就不得不重新实现一个自定义的标题栏。

这种实现也很简单,首先就是构造一个不带控制栏的窗口:

javascript
new BrowserWindow({
  autoHideMenuBar: true,
  // 无边框窗口
  frame: true,
  // 无标题
  titleBarStyle: 'hidden',
  show: false,
});

然后在渲染进程中,自己**"画一个标题栏"**:

html
<div class="info">
  <img :src="plugInfo.logo"/>
  <span>rubick 系统菜单</span>
</div>
<div class="handle-container">
  <div class="handle">
    <div class="devtool" @click="openDevTool" title="开发者工具"></div>
  </div>
  <div class="window-handle" v-if="process.platform !== 'darwin'">
    <div class="minimize" @click="minimize"></div>
    <div class="maximize" @click="maximize"></div>
    <div class="close" @click="close"></div>
  </div>
</div>

然后定义一下 icon 的样式:

css
.minimize {
  background: center / 20px no-repeat url("./assets/minimize.svg");
}

.maximize {
  background: center / 20px no-repeat url("./assets/maximize.svg");
}

.unmaximize {
  background: center / 20px no-repeat url("./assets/unmaximize.svg");
}

.close {
  background: center / 20px no-repeat url("./assets/close.svg");
}

.close:hover {
  background-color: #e53935;
  background-image: url("./assets/close-hover.svg");
}

最后,渲染进程中通过 ipcRenderer 向主进程中发送操作事件:

javascript
// 最小化
const minimize = () => {
  ipcRenderer.send('detach:service', { type: 'minimize' });
};
// 最大化
const maximize = () => {
  ipcRenderer.send('detach:service', { type: 'maximize' });
};
// 关闭窗口
const close = () => {
  ipcRenderer.send('detach:service', { type: 'close' });
};

主进程对操作事件进行响应:

javascript
ipcMain.on('detach:service', async (event, arg: { type: string }) => {
  operation[arg.type]();
});

const operation = {
  minimize: () => {
    win.focus();
    win.minimize();
  },
  maximize: () => {
    win.isMaximized() ? win.unmaximize() : win.maximize();
  },
  close: () => {
    win.close();
  },
};

关于自定义窗口标题栏的完整代码可以看这里:github.com/rubickCenter/rubick

2. macOS 下自定义菜单栏

macOS 中,要实现上面的自定义菜单栏还是比较方便的,因为 macOS 的操作栏主要是红绿灯效果,而且 macOS 的交互方式都是将红绿灯统一放在窗口的左上角:

所以对于 macOS 下,自定义菜单栏的交互就是下面这样:

这里,我们不需要再手动实现关闭、缩小、放大等系统功能了,只需要调整一下红绿灯的位置就可以了:

javascript
new BrowserWindow({
  // ...
  // 设置 macOS 下红绿灯的位置
  trafficLightPosition: { x: 12, y: 21 },
  // ...
})

总结

本小节,我们完成了对 Electron 常用的 无边框窗口自定义窗口标题栏 的介绍,它们相对于默认的系统窗口而言,需要处理一些小的交互问题。希望通过本小节的介绍,可以让你清楚地了解这些问题背后的原因和解决问题的方式。


来源:掘金小册 - Electron 应用开发实践指南 作者:muwoo

分享: