桌面应用窗口管理

深入理解 Electron 中 BrowserWindow 的各种配置选项、父子窗口关系、对话框系统、以及窗口状态管理。


BrowserWindow 基础配置

Electron 中,每个窗口都是一个 BrowserWindow 实例:

const { BrowserWindow } = require('electron');
const path = require('path');

const mainWindow = new BrowserWindow({
  width: 1200,              // 窗口宽度(像素)
  height: 800,             // 窗口高度(像素)
  minWidth: 800,           // 最小宽度
  minHeight: 600,          // 最小高度
  x: 100,                  // 窗口 x 坐标(屏幕左上角为原点)
  y: 100,                  // 窗口 y 坐标
  title: 'My App',         // 窗口标题
  backgroundColor: '#ffffff', // 背景色
  show: false,             // 创建后不显示,等 ready-to-show 再显示
  webPreferences: {
    nodeIntegration: false,
    contextIsolation: true,
    preload: path.join(__dirname, 'preload.js')
  }
});

ready-to-show 事件

为了避免窗口创建时的闪烁,推荐使用 ready-to-show

mainWindow.once('ready-to-show', () => {
  mainWindow.show();
});

窗口的显示与隐藏

基本控制

// 显示窗口
mainWindow.show();

// 隐藏窗口
mainWindow.hide();

// 关闭窗口
mainWindow.close();

// 最小化到任务栏
mainWindow.minimize();

// 最大化
mainWindow.maximize();

// 取消最大化
mainWindow.unmaximize();

// 切换最大化/非最大化状态
mainWindow.isMaximized() ? mainWindow.unmaximize() : mainWindow.maximize();

确保窗口在屏幕内

窗口可能在多显示器环境下被创建在不可见的区域,需要校验:

const { screen } = require('electron');

function ensureWindowOnScreen(win) {
  const bounds = win.getBounds();
  const displays = screen.getAllDisplays();

  const isVisible = displays.some(display => {
    const { x, y, width, height } = display.bounds;
    return (
      bounds.x >= x - bounds.width &&
      bounds.x < x + width &&
      bounds.y >= y - bounds.height &&
      bounds.y < y + height
    );
  });

  if (!isVisible) {
    // 将窗口移回主屏幕
    const primary = screen.getPrimaryDisplay();
    win.setBounds({
      x: primary.bounds.x + 50,
      y: primary.bounds.y + 50,
      width: bounds.width,
      height: bounds.height
    });
  }
}

父子窗口关系

Modal 窗口(模态窗口)

模态窗口会锁定父窗口,常用于对话框:

const { BrowserWindow } = require('electron');

function openSettings(parentWindow) {
  const settingsWindow = new BrowserWindow({
    width: 600,
    height: 400,
    parent: parentWindow,  // 指定父窗口
    modal: true,          // 开启模态
    show: false
  });

  settingsWindow.loadFile('settings.html');

  settingsWindow.once('ready-to-show', () => {
    settingsWindow.show();
  });
}

子窗口的行为特点

  • 子窗口显示时,父窗口无法获得焦点
  • 关闭子窗口不会影响父窗口
  • 子窗口会跟随父窗口最小化/最大化

非模态子窗口

如果不需要锁定父窗口,只设置 parent 但不加 modal

const childWindow = new BrowserWindow({
  width: 400,
  height: 300,
  parent: mainWindow,
  modal: false
});

无边框窗口

创建无边框窗口

const framelessWindow = new BrowserWindow({
  width: 800,
  height: 600,
  frame: false,           // 隐藏原生标题栏
  transparent: false,     // 可选:透明背景
  titleBarStyle: 'hidden' // macOS 专用:隐藏标题栏但保留 Traffic Lights
});

自定义拖拽区域

在无边框窗口中,需要自己实现标题栏的拖拽:

/* CSS */
.titlebar {
  -webkit-app-region: drag;  /* 可拖拽区域 */
  height: 40px;
  background: #2c3e50;
}

.titlebar button {
  -webkit-app-region: no-drag;  /* 按钮不可拖拽 */
}
<!-- HTML -->
<div class="titlebar">
  <button id="minimize">最小化</button>
  <button id="close">关闭</button>
</div>
// renderer.js
document.getElementById('minimize').addEventListener('click', () => {
  window.electronAPI.minimize();
});

document.getElementById('close').addEventListener('click', () => {
  window.electronAPI.close();
});

macOS Traffic Lights

macOS 有原生的窗口控制按钮(红绿灯),可以用 titleBarStyle

const win = new BrowserWindow({
  width: 800,
  height: 600,
  titleBarStyle: 'hidden',           // 隐藏标题栏内容
  titleBarOverlay: {                 // Windows/Linux 也可以有 overlay
    color: '#2c3e50',
    height: 40
  }
});

系统对话框

打开文件/目录对话框

const { dialog } = require('electron');

async function openFile() {
  const result = await dialog.showOpenDialog(mainWindow, {
    title: '选择文件',
    defaultPath: '/Users/me/Documents',
    filters: [
      { name: 'Images', extensions: ['jpg', 'png', 'gif'] },
      { name: 'All Files', extensions: ['*'] }
    ],
    properties: ['openFile', 'multiSelections']
  });

  if (!result.canceled) {
    console.log('Selected files:', result.filePaths);
    return result.filePaths;
  }
}

保存文件对话框

async function saveFile() {
  const result = await dialog.showSaveDialog(mainWindow, {
    title: '保存文件',
    defaultPath: '/Users/me/Documents/report.pdf',
    filters: [
      { name: 'PDF', extensions: ['pdf'] },
      { name: 'All Files', extensions: ['*'] }
    ]
  });

  if (!result.canceled) {
    console.log('Save path:', result.filePath);
    return result.filePath;
  }
}

消息框

async function showMessage() {
  const result = await dialog.showMessageBox(mainWindow, {
    type: 'question',           // 'none' | 'info' | 'error' | 'question'
    buttons: ['取消', '保存', '另存为'],
    defaultId: 1,                // 默认按钮索引
    cancelId: 0,                 // 取消按钮索引
    title: '保存确认',
    message: '是否保存当前文档?'
  });

  console.log('Button clicked:', result.response);
  // 0 = 取消, 1 = 保存, 2 = 另存为
}

错误对话框

dialog.showErrorBox('错误', '文件保存失败,请重试!');

窗口状态管理

保存窗口状态

const fs = require('fs');
const { app } = require('electron');

function saveWindowState(win, fileName) {
  const bounds = win.getBounds();
  const state = {
    x: bounds.x,
    y: bounds.y,
    width: bounds.width,
    height: bounds.height,
    isMaximized: win.isMaximized()
  };

  fs.writeFileSync(
    getStatePath(fileName),
    JSON.stringify(state)
  );
}

function getStatePath(fileName) {
  return path.join(app.getPath('userData'), `${fileName}-window-state.json`);
}

恢复窗口状态

function loadWindowState(fileName, defaults) {
  try {
    const statePath = getStatePath(fileName);
    if (fs.existsSync(statePath)) {
      const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
      return { ...defaults, ...state };
    }
  } catch (err) {
    console.error('Failed to load window state:', err);
  }
  return defaults;
}

应用到窗口创建

const defaultState = {
  width: 1200,
  height: 800,
  x: undefined,
  y: undefined
};

const state = loadWindowState('main', defaultState);

const mainWindow = new BrowserWindow({
  ...state,
  webPreferences: { /* ... */ }
});

// 窗口关闭时保存状态
mainWindow.on('close', () => {
  saveWindowState(mainWindow, 'main');
});

多显示器支持

获取所有显示器信息

const { screen } = require('electron');

// 获取所有显示器
const displays = screen.getAllDisplays();
console.log(displays);

// 获取主显示器
const primary = screen.getPrimaryDisplay();
console.log('Primary:', primary.bounds);

// 获取当前窗口所在的显示器
const currentDisplay = screen.getDisplayMatching(mainWindow.getBounds());
console.log('Current display:', currentDisplay.bounds);

在指定显示器上创建窗口

// 在第二个显示器上创建窗口
const displays = screen.getAllDisplays();
if (displays.length > 1) {
  const secondDisplay = displays[1];

  const windowOnSecondMonitor = new BrowserWindow({
    x: secondDisplay.bounds.x + 50,
    y: secondDisplay.bounds.y + 50,
    width: 1200,
    height: 800
  });
}

监听显示器变化

screen.on('display-added', (event, display) => {
  console.log('Display added:', display.bounds);
});

screen.on('display-removed', (event, display) => {
  console.log('Display removed:', display.bounds);
});

屏幕截图与内容捕获

窗口截图

const { desktopCapturer } = require('electron');

async function captureWindow() {
  const sources = await desktopCapturer.getSources({
    types: ['window'],
    thumbnailSize: { width: 0, height: 0 }
  });

  // 找到目标窗口
  const targetWindow = sources.find(source => {
    return source.name === 'My App';
  });

  if (targetWindow) {
    // thumbnail 是一个 NativeImage
    const thumbnail = targetWindow.thumbnail;
    const buffer = thumbnail.toPNG();

    // 保存到文件
    fs.writeFileSync('screenshot.png', buffer);
  }
}

全屏截图

async function captureScreen() {
  const sources = await desktopCapturer.getSources({
    types: ['screen'],
    thumbnailSize: { width: 1920, height: 1080 }
  });

  if (sources.length > 0) {
    const screenshot = sources[0].thumbnail;
    const buffer = screenshot.toPNG();
    fs.writeFileSync('fullscreen.png', buffer);
  }
}

窗口交互最佳实践

1. 避免多次创建窗口

使用单例模式确保只有一个主窗口:

let mainWindow = null;

function createMainWindow() {
  if (mainWindow) {
    if (mainWindow.isMinimized()) mainWindow.restore();
    mainWindow.focus();
    return mainWindow;
  }

  mainWindow = new BrowserWindow({ /* ... */ });

  mainWindow.on('closed', () => {
    mainWindow = null;
  });

  return mainWindow;
}

2. 优雅关闭确认

mainWindow.on('close', (event) => {
  if (hasUnsavedChanges) {
    event.preventDefault();

    const choice = dialog.showMessageBoxSync(mainWindow, {
      type: 'question',
      buttons: ['取消', '不保存', '保存'],
      defaultId: 2,
      title: '确认关闭',
      message: '有未保存的更改,是否保存?'
    });

    if (choice === 2) {
      // 保存并关闭
      saveAndClose();
    } else if (choice === 1) {
      // 不保存,强制关闭
      hasUnsavedChanges = false;
      mainWindow.close();
    }
  }
});

3. 跨窗口通信

父子窗口或多个窗口之间通信,可以使用 IPC:

// 主进程作为消息路由
ipcMain.on('window-action', (event, action, data) => {
  if (action === 'refresh-list') {
    // 通知所有窗口
    BrowserWindow.getAllWindows().forEach(win => {
      win.webContents.send('refresh-list', data);
    });
  }
});

这一章想说的

窗口管理是桌面应用的核心:

  1. BrowserWindow 配置:尺寸、最小/最大限制、位置控制
  2. 父子窗口:模态窗口锁定父窗口,非模态子窗口跟随父窗口
  3. 无边框窗口:自定义标题栏,需要自己处理拖拽和按钮事件
  4. 系统对话框:文件选择、保存、消息框
  5. 窗口状态持久化:保存位置和尺寸到文件,应用启动时恢复

良好的窗口管理能显著提升桌面应用的用户体验。


延展阅读