Electron + Vite + TS + Vue3打开新窗口实战

简介: 前言我们在使用 Electron 编写桌面应用时,打开新窗口可以说是一个非常常见的场景了。很多刚接触 Electron 的小伙伴面对这样一个问题可能都会显得比较棘手,比如打开新窗口如何知道渲染哪一个页面?打开的新窗口如何与其它窗口产生联系,比如父子窗口?...等等一系列问题。今天我们就将 Electron 打开新窗口的常见做法分享给大家,而且是基于最新的 TS 封装。

1.基础项目搭建


还没有简单基础项目的小伙伴赶紧搭建一个 Electron 项目,具体可以参考: Electron + Vue3 + TS + Vite 桌面应用项目搭建教程


我们先来看一下基础的项目目录结构吧,如下图:34.png

本篇文章我们重点关注 electron-main 这个目录,该目录就是 electron 项目的主进程目录,我们将在这里面封装打开新窗口的一些方法。


2.实现目标


有了目标我们才能更好更快的去理清思路,我们可以回想一下平时使用的桌面程序它们打开新窗口都有哪些特点,比如腾讯视频、腾讯 QQ 等等。


针对于我们当前的 Electron+Vue3+TS 项目,主要实现以下需求。


需求如下:

  • 在渲染进程中,直接调用某个方法即可打开新窗口。
  • 默认打开的新窗口是一个子窗口。
  • 打开新窗口方法可以接收参数。
  • 可以传入路由地址,新窗口渲染此路由地址页面。
  • 可以传入窗口样式,如宽高、背景色、是否显示默认菜单栏等等。
  • 可以单独关闭当前新打开的窗口。


上面几点需求大致就是我们此次打开新窗口需要实现的功能,当然,你还可以添加更多自定义需求。


先来简单看下效果:

35.png

上图中左侧是我们的主窗口,点击打开新窗口按钮时,便会打开右侧的子窗口,接下来我们就需要去写代码来实现了。


3.具体实现

3.1 改造 electron-main/index.ts 文件


既然我们要通过调用方法来打开新窗口,那么有必要将打开新窗口这类操作直接封装成方法,我们改造下主进程的入口文件。


代码如下:

// electron-main/index.ts
import { app, BrowserWindow } from "electron";
import { Window } from "./window"; // 具体方法放在此处
const isDevelopment: boolean = process.env.NODE_ENV !== "production";
// 创建主窗口
async function createWindow() {
  let window = new Window();
  window.listen(); // 设置监听事件,比如主进程与渲染进程之间的通信事件
  window.createWindows({ isMainWin: true }); // 创建窗口,默认为主窗口
  window.createTray(); // 创建系统托盘
}
// 关闭所有窗口
app.on("window-all-closed", () => {
  if (process.platform !== "darwin") {
    app.quit();
  }
});
app.on("activate", () => {
  if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
// 准备完成,初始化窗口等操作
app.on("ready", async () => {
  createWindow();
});
// 根据环境处理不同操作
if (isDevelopment) {
  if (process.platform === "win32") {
    process.on("message", (data) => {
      if (data === "graceful-exit") {
        app.quit();
      }
    });
  } else {
    process.on("SIGTERM", () => {
      app.quit();
    });
  }
}


上段代码主要是一个入口文件,我们把创建创建窗口、创建监听事件、创建系统托盘等操作都风窗到了 window.ts 文件中,这里重点理解下面三个方法:

  • window.listen()
  • window.createWindows({ isMainWin: true })
  • window.createTray()


3.2 新建 electron-main/window.ts 文件


前面的 index.ts 只是主进程的入口文件,接下来我们需要编写真正创建窗口、创建托盘、监听事件等方法的文件了:window.ts


这个文件我们主要编写以下几个函数:

  • getWindow(id: number):获取当前窗口
  • createWindows(options: object):创建新的窗口
  • createTray():创建系统托盘
  • listen():开始事件监听


代码如下:

// electron-main/window.ts
import { app, BrowserWindow, ipcMain, Menu, Tray } from "electron";
import path from "path";
interface IWindowsCfg {
  id: number | null;
  title: string;
  width: number | null;
  height: number | null;
  minWidth: number | null;
  minHeight: number | null;
  route: string;
  resizable: boolean;
  maximize: boolean;
  backgroundColor: string;
  data: object | null;
  isMultiWindow: boolean;
  isMainWin: boolean;
  parentId: number | null;
  modal: boolean;
}
interface IWindowOpt {
  width: number;
  height: number;
  backgroundColor: string;
  autoHideMenuBar: boolean;
  resizable: boolean;
  minimizable: boolean;
  maximizable: boolean;
  frame: boolean;
  show: boolean;
  parent?: BrowserWindow;
  minWidth: number;
  minHeight: number;
  modal: boolean;
  webPreferences: {
    contextIsolation: boolean; //上下文隔离
    nodeIntegration: boolean; //启用 Node 集成(是否完整的支持 node)
    webSecurity: boolean;
    preload: string;
  };
}
// 新建窗口时可以传入的一些options配置项
export const windowsCfg: IWindowsCfg = {
  id: null, //唯一 id
  title: "", //窗口标题
  width: null, //宽度
  height: null, //高度
  minWidth: null, //最小宽度
  minHeight: null, //最小高度
  route: "", // 页面路由 URL '/manage?id=123'
  resizable: true, //是否支持调整窗口大小
  maximize: false, //是否最大化
  backgroundColor: "#eee", //窗口背景色
  data: null, //数据
  isMultiWindow: false, //是否支持多开窗口 (如果为 false,当窗体存在,再次创建不会新建一个窗体 只 focus 显示即可,,如果为 true,即使窗体存在,也可以新建一个)
  isMainWin: false, //是否主窗口(当为 true 时会替代当前主窗口)
  parentId: null, //父窗口 id  创建父子窗口 -- 子窗口永远显示在父窗口顶部 【父窗口可以操作】
  modal: false, //模态窗口 -- 模态窗口是禁用父窗口的子窗口,创建模态窗口必须设置 parent 和 modal 选项 【父窗口不能操作】
};
// 窗口组
interface IGroup {
  [props: string]: {
    route: string;
    isMultiWindow: boolean;
  };
}
/**
 * 窗口配置
 */
export class Window {
  main: BrowserWindow | null | undefined;
  group: IGroup;
  tray: Tray | null;
  constructor() {
    this.main = null; //当前页
    this.group = {}; //窗口组
    this.tray = null; //托盘
  }
  // 窗口配置
  winOpts(wh: Array<number> = []): IWindowOpt {
    return {
      width: wh[0],
      height: wh[1],
      backgroundColor: "#f7f8fc",
      autoHideMenuBar: true,
      resizable: true,
      minimizable: true,
      maximizable: true,
      frame: true,
      show: false,
      minWidth: 0,
      minHeight: 0,
      modal: true,
      webPreferences: {
        contextIsolation: false, //上下文隔离
        nodeIntegration: true, //启用 Node 集成(是否完整的支持 node)
        webSecurity: false,
        preload: path.join(__dirname, "../electron-preload/index.js"),
      },
    };
  }
  // 获取窗口
  getWindow(id: number): any {
    return BrowserWindow.fromId(id);
  }
  // 创建窗口
  createWindows(options: object) {
    console.log("------------开始创建窗口...");
    let args = Object.assign({}, windowsCfg, options);
    // 判断窗口是否存在
    for (let i in this.group) {
      if (
        this.getWindow(Number(i)) &&
        this.group[i].route === args.route &&
        !this.group[i].isMultiWindow
      ) {
        console.log("窗口已经存在了");
        this.getWindow(Number(i)).focus();
        return;
      }
    }
    // 创建 electron 窗口的配置参数
    let opt = this.winOpts([args.width || 390, args.height || 590]);
    // 判断是否有父窗口
    if (args.parentId) {
      console.log("parentId:" + args.parentId);
      opt.parent = this.getWindow(args.parentId) as BrowserWindow; // 获取主窗口
    } else if (this.main) {
      console.log('当前为主窗口');
    } // 还可以继续做其它判断
    // 根据传入配置项,修改窗口的相关参数
    opt.modal = args.modal;
    opt.resizable = args.resizable; // 窗口是否可缩放
    if (args.backgroundColor) opt.backgroundColor = args.backgroundColor; // 窗口背景色
    if (args.minWidth) opt.minWidth = args.minWidth;
    if (args.minHeight) opt.minHeight = args.minHeight;
    let win = new BrowserWindow(opt);
    console.log("窗口 id:" + win.id);
    this.group[win.id] = {
      route: args.route,
      isMultiWindow: args.isMultiWindow,
    };
    // 是否最大化
    if (args.maximize && args.resizable) {
      win.maximize();
    }
    // 是否主窗口
    if (args.isMainWin) {
      if (this.main) {
        console.log("主窗口存在");
        delete this.group[this.main.id];
        this.main.close();
      }
      this.main = win;
    }
    args.id = win.id;
    win.on("close", () => win.setOpacity(0));
    // 打开网址(加载页面)
    let winURL;
    if (app.isPackaged) {
      winURL = args.route
        ? `app://./index.html${args.route}`
        : `app://./index.html`;
    } else {
      winURL = args.route
        ? `http://${process.env["VITE_DEV_SERVER_HOST"]}:${process.env["VITE_DEV_SERVER_PORT"]}${args.route}?winId=${args.id}`
        : `http://${process.env["VITE_DEV_SERVER_HOST"]}:${process.env["VITE_DEV_SERVER_PORT"]}?winId=${args.id}`;
    }
    console.log("新窗口地址:", winURL);
    win.loadURL(winURL);
    win.once("ready-to-show", () => {
      win.show();
    });
  }
  // 创建托盘
  createTray() {
    console.log("创建托盘");
    const contextMenu = Menu.buildFromTemplate([
      {
        label: "注销",
        click: () => {
          console.log("注销");
          // 主进程发送消息,通知渲染进程注销当前登录用户 --todo
        },
      },
      {
        type: "separator", // 分割线
      },
      // 菜单项
      {
        label: "退出",
        role: "quit", // 使用内置的菜单行为,就不需要再指定 click 事件
      },
    ]);
    this.tray = new Tray(path.join(__dirname, "../favicon.ico")); // 图标
    // 点击托盘显示窗口
    this.tray.on("click", () => {
      for (let i in this.group) {
        if (this.group[i]) this.getWindow(Number(i)).show();
      }
    });
    // 处理右键
    this.tray.on("right-click", () => {
      this.tray?.popUpContextMenu(contextMenu);
    });
    this.tray.setToolTip("小猪课堂");
  }
  // 开启监听
  listen() {
    // 固定
    ipcMain.on('pinUp', (event: Event, winId) => {
      event.preventDefault();
      if (winId && (this.main as BrowserWindow).id == winId) {
        let win: BrowserWindow = this.getWindow(Number((this.main as BrowserWindow).id));
        if (win.isAlwaysOnTop()) {
          win.setAlwaysOnTop(false); // 取消置顶
        } else {
          win.setAlwaysOnTop(true); // 置顶
        }
      }
    })
    // 隐藏
    ipcMain.on("window-hide", (event, winId) => {
      if (winId) {
        this.getWindow(Number(winId)).hide();
      } else {
        for (let i in this.group) {
          if (this.group[i]) this.getWindow(Number(i)).hide();
        }
      }
    });
    // 显示
    ipcMain.on("window-show", (event, winId) => {
      if (winId) {
        this.getWindow(Number(winId)).show();
      } else {
        for (let i in this.group) {
          if (this.group[i]) this.getWindow(Number(i)).show();
        }
      }
    });
    // 最小化
    ipcMain.on("mini", (event: Event, winId) => {
      console.log("最小化窗口 id", winId);
      if (winId) {
        this.getWindow(Number(winId)).minimize();
      } else {
        for (let i in this.group) {
          if (this.group[i]) {
            this.getWindow(Number(i)).minimize();
          }
        }
      }
    });
    // 最大化
    ipcMain.on("window-max", (event, winId) => {
      if (winId) {
        this.getWindow(Number(winId)).maximize();
      } else {
        for (let i in this.group)
          if (this.group[i]) this.getWindow(Number(i)).maximize();
      }
    });
    // 创建窗口
    ipcMain.on("window-new", (event: Event, args) => this.createWindows(args));
  }
}


代码思路:

  • 定义两个接口 interface,用来规定窗口默认参数格式。
  • 调用创建窗口方法时会传入一些配置项,方法内部需要合并这些配置项。
  • 根据传入的路由地址,动态配置需要渲染的页面。
  • 每一个新窗口都会产生一个窗口 id
  • 为了让每个窗口产生关联,需要给每个窗口配置参数中带上 parentId 字段。
  • 关闭窗口或者其它 electron 操作事件时,都根据窗口 id 来获取到对应的窗口。


3.3 渲染进程调用


在渲染进程中我们使用@vueuse/electron 模块方便的进行主进程与渲染进程之间的通信,比如我们打开一个新窗口,可以像如下写法。


代码如下:

<template>
  <img alt="Vue logo" src="../assets/logo.png" />
  <div>
    <button @click="openNewWin">打开新窗口</button>
  </div>
</template>
<script setup lang="ts">
import { useIpcRenderer } from "@vueuse/electron";
const ipcRenderer = useIpcRenderer();
const openNewWin = () => {
  ipcRenderer.send("window-new", {
    route: "/helloworld",
    width: 500,
    height: 500,
  });
};
</script>


这样就简单实现了一个打开新窗口。


总结


针对于本篇文章对于 Electron 打开新窗口的封装,可能有些小伙伴觉得稍显复杂,但是长痛不如短痛,一次封装,多次获益!


我们需要搞懂以下几个问题,对我们的 Electron 打开新窗口就会有很多帮助的:

  • 每个新窗口都会有 id
  • 通过 parentId 来个每个窗口建立关联
  • 把所有的窗口都使用一个窗口组对象保存下来


如果觉得文章太繁琐或者没看懂,可以观看视频: 小猪课堂



相关文章
|
26天前
|
缓存 JavaScript UED
Vue3中v-model在处理自定义组件双向数据绑定时有哪些注意事项?
在使用`v-model`处理自定义组件双向数据绑定时,要仔细考虑各种因素,确保数据的准确传递和更新,同时提供良好的用户体验和代码可维护性。通过合理的设计和注意事项的遵循,能够更好地发挥`v-model`的优势,实现高效的双向数据绑定效果。
127 64
|
26天前
|
前端开发 JavaScript 测试技术
Vue3中v-model在处理自定义组件双向数据绑定时,如何避免循环引用?
Web 组件化是一种有效的开发方法,可以提高项目的质量、效率和可维护性。在实际项目中,要结合项目的具体情况,合理应用 Web 组件化的理念和技术,实现项目的成功实施和交付。通过不断地探索和实践,将 Web 组件化的优势充分发挥出来,为前端开发领域的发展做出贡献。
30 8
|
25天前
|
存储 JavaScript 数据管理
除了provide/inject,Vue3中还有哪些方式可以避免v-model的循环引用?
需要注意的是,在实际开发中,应根据具体的项目需求和组件结构来选择合适的方式来避免`v-model`的循环引用。同时,要综合考虑代码的可读性、可维护性和性能等因素,以确保系统的稳定和高效运行。
28 1
|
25天前
|
JavaScript
Vue3中使用provide/inject来避免v-model的循环引用
`provide`和`inject`是 Vue 3 中非常有用的特性,在处理一些复杂的组件间通信问题时,可以提供一种灵活的解决方案。通过合理使用它们,可以帮助我们更好地避免`v-model`的循环引用问题,提高代码的质量和可维护性。
35 1
|
26天前
|
JavaScript
在 Vue 3 中,如何使用 v-model 来处理自定义组件的双向数据绑定?
需要注意的是,在实际开发中,根据具体的业务需求和组件设计,可能需要对上述步骤进行适当的调整和优化,以确保双向数据绑定的正确性和稳定性。同时,深入理解 Vue 3 的响应式机制和组件通信原理,将有助于更好地运用 `v-model` 实现自定义组件的双向数据绑定。
|
29天前
|
JavaScript API 开发者
Vue是如何进行组件化的
Vue是如何进行组件化的
|
1月前
|
JavaScript 前端开发 开发者
vue 数据驱动视图
总之,Vue 数据驱动视图是一种先进的理念和技术,它为前端开发带来了巨大的便利和优势。通过理解和应用这一特性,开发者能够构建出更加动态、高效、用户体验良好的前端应用。在不断发展的前端领域中,数据驱动视图将继续发挥重要作用,推动着应用界面的不断创新和进化。
|
5天前
|
JavaScript 关系型数据库 MySQL
基于VUE的校园二手交易平台系统设计与实现毕业设计论文模板
基于Vue的校园二手交易平台是一款专为校园用户设计的在线交易系统,提供简洁高效、安全可靠的二手商品买卖环境。平台利用Vue框架的响应式数据绑定和组件化特性,实现用户友好的界面,方便商品浏览、发布与管理。该系统采用Node.js、MySQL及B/S架构,确保稳定性和多功能模块设计,涵盖管理员和用户功能模块,促进物品循环使用,降低开销,提升环保意识,助力绿色校园文化建设。
|
1月前
|
JavaScript 前端开发 开发者
vue学习第一章
欢迎来到我的博客!我是瑞雨溪,一名热爱前端的大一学生,专注于JavaScript与Vue,正向全栈进发。博客分享Vue学习心得、命令式与声明式编程对比、列表展示及计数器案例等。关注我,持续更新中!🎉🎉🎉
37 1
vue学习第一章