electron + vite + vue3 + ts搭建一个app模版

5,970 阅读11分钟

最终效果

屏幕录制2021-12-03 上午9.32.33.gif 项目地址:Github

1.启动项目

1.1 创建一个vue-ts项目

yarn create vite my-vue-app --template vue-ts

1.2安装electron 与打包工具

// 安装成开发依赖,打包后不需要
yarn add electron electron-builder -D

安装一些开发时需要的小工具,后面会详细说明用法

  • kill-port:清理端口
  • cross-env:设置环境变量
  • npm-run-all 顺序执行script脚本
  • concurrently 并行执行script脚本
  • tsc-watch:编译ts文件,并在文件修改后重新执行编译
  • wait-on 等待文件/端口等变化后执行script脚本
yarn add kill-port  concurrently cross-env npm-run-all tsc-watch wait-on -D

1.3 启动前配置

修改后的项目完整目录

截屏2021-12-03 上午11.20.22.png

1. 新建enectron入口文件

新建electron/main.ts

import { app, BrowserWindow} from "electron";
// 创建窗口方法
import { createWindow } from "./utils/createWindow";

app.on("ready", () => {
    createWindow(); // 创建窗口
    // 通常在 macOS 上,当点击 dock 中的应用程序图标时,如果没有其他打开的窗口,那么程序会重新创建一个窗口。
    app.on("activate", () => BrowserWindow.getAllWindows().length === 0 && createWindow());
});

// 除了 macOS 外,当所有窗口都被关闭的时候退出程序。 macOS窗口全部关闭时,dock中程序不会退出
app.on("window-all-closed", () => {
    process.platform !== "darwin" && app.quit();
});

新建electron/utils/createWindow.ts

import { BrowserWindow } from "electron";
import * as path from "path";
/**
* packages.json,script中通过cross-env NODE_ENV=production设置的环境变量
* 'production'|'development'
*/
const NODE_ENV = process.env.NODE_ENV;
/** 创建窗口方法 */
function createWindow() {
// 生成窗口实例
    const Window = new BrowserWindow({
        minWidth: 1120,
        minHeight: 645,
        width: 1120, // * 指定启动app时的默认窗口尺寸
        height: 645, // * 指定启动app时的默认窗口尺寸
        frame: false, // * app边框(包括关闭,全屏,最小化按钮的导航栏) @false: 隐藏
        transparent: true, // * app 背景透明
        hasShadow: false, // * app 边框阴影
        show: false, // 启动窗口时隐藏,直到渲染进程加载完成「ready-to-show 监听事件」 再显示窗口,防止加载时闪烁
        resizable: false, // 禁止手动修改窗口尺寸
        webPreferences: {
            // 加载脚本
            preload: path.join(__dirname, "..", "preload")
        }
    });

    // 启动窗口时隐藏,直到渲染进程加载完成「ready-to-show 监听事件」 再显示窗口,防止加载时闪烁
    Window.once("ready-to-show", () => {
        Window.show(); // 显示窗口
    });
    
    // * 主窗口加载外部链接
    // 开发环境,加载vite启动的vue项目地址
    if (NODE_ENV === "development") Window.loadURL("http://localhost:3920/"); 
}
// 导出模块
export { createWindow };

新建electron/preload.ts文件(空文件即可)

2. 编写ts声明文件

  • 将src/env.d.ts 移动到types/env.d.ts.
  • 新建types/electron.d.ts 内容如下
// ? 扩展window对象
interface Window {
    /**
    * Electron ipcRenderer
    * 后面会将进程通讯方法挂载到window对象上,所以添加此接口防止报错
    */
    ipc: import("electron").IpcRenderer;
}

3. 修改ts配置文件

1. 编辑tsconfig.json

vue 编译ts的配置文件

{
    "compilerOptions": {
        // ......
        "baseUrl": ".", // 新增
        "paths": { // 新增  路径别名
        "@/*": ["src/*"],
        "~/*": ["types/*"]
        }
    },
    // 修改,识别之前配置的types文件夹下*.d.ts文件
    "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "types/**/*.d.ts"]

}
2. 新建tsconfig.e.json

electron 编译ts的配置文件

{
    // ? 继承tsconfig.json配置 同名配置项会被本配置文件覆盖
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "outDir": "output/build", // * 编译生成的文件存放路径
        "noEmit": false, // * 不生成输出文件
        "module": "commonjs", // * 指定使用模块: 'commonjs', 'amd', 'system', 'umd' or 'es2015'
        "baseUrl": ".",
        "sourceMap": false, // * 是否生成相应的 '.map' 文件
    },
    "include": [
    "electron"
    ] // * 指定需要包含的文件 (此处指定electron文件夹下所有文件)
}

4. 修改vite.config.ts

export default defineConfig({
    plugins: [vue()],
    server: {
        strictPort: true, // * 固定端口(如果端口被占用则中止)
        host: true, // 0.0.0.0
        port: 3920 // 指定启动端口
    },
});

5. 修改package.json, 启动项目

  • 脚本说明
  1. 终端运行 yarn start / npm start 开始启动项目
  2. start:
  • 清理端口3920(kill-port 3920) 然后(&&) 并行执行(concurrently -k)命令vitewait-on tcp:3920 && npm-run-all watch
  • vite:启动vite开发服务器 指定端口3920
  • wait-on tcp:3920 && npm-run-all watch:监听端口3920,连通时执行npm-run-all watch
  1. watch:
  • 使用tsc-watch 将ts文件编译成js文件; 通过配置文件tsconfig.e.json 指定只编译electron文件夹下的文件,并将js文件输出到output/build 文件夹
    当ts文件编译完成后(--onSuccess),执行命令npm-run-all start:ect
  • 启动后tsc-watch将持续监听electron文件夹,每次文件改动都会重新执行编译并运行(--onSuccess)后的命令
  1. start:ect:
  • 设置环境变量cross-env NODE_ENV=development,electron主进程启动时可以拿到此变量(见electron/utils/createWindow.ts),并据此判断是加载外部链接还是加载打包后文件
  • 启动electron app,指定入口文件./output/build/main.js
{
  "name": "test",
  "version": "0.0.0",
  "license": "MIT",
  "scripts": {
  + "start": "kill-port 3920 && concurrently -k \"vite\" \"wait-on tcp:3920 && npm-run-all watch\"",
  + "watch": "tsc-watch --noClear -p tsconfig.e.json --onSuccess \"npm-run-all start:ect\"",
  + "start:ect": "cross-env NODE_ENV=development electron ./output/build/main.js"
  },
  "dependencies": {
    ...
  },
  "devDependencies": {
    ...
  }
}

然后,执行 yarn start / npm start 即可启动项目,一个无边框,背景透明的窗口
到此步骤的所有代码在history/item1文件夹下

截屏2021-12-03 下午1.58.05.png


2.进程通信

2.1基本配置

由于electron文档推行的所谓‘上下文隔离’,所以就不使用remote模块,所有通信方法通过preload.ts进行中转.

todo: 想要在vite+vue项目中更方便的通信,可以使用 @vueuse/electron,这个包可以通过componsitionAPI的方式使用进程通信,像这样
import { useIpcRenderer } from '@vueuse/electron'
const ipcRenderer = useIpcRenderer()
ipcRenderer.on('custom-event', (event, ...args) => {console.log(args)}) 编辑electron/preload.ts

import { contextBridge, ipcRenderer } from "electron";
/**
* 通信方法挂载到window对象上
* 在渲染进程中使用:
* <script setup lang="ts">
* window.ipc.on('WindowID', (e, f) => console.log(e, f))
* window.ipc.send('navBar', val)
* </script>
*/
contextBridge.exposeInMainWorld("ipc", {
    send: (channel: string, ...args: any[]) => ipcRenderer.send(channel, ...args),
    invoke: (channel: string, ...args: any[]): Promise<any> => ipcRenderer.invoke(channel, ...args),
    on: (channel: string, listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void) => {ipcRenderer.on(channel, listener);
},
});
  • contextBridge.exposeInMainWorld:将一个对象ipc:{send:xxx,on:xxx}挂载到window对象上.
  • 定义一个中转函数ipc.send,渲染进程调用window.ipc.send('事件名', 参数),ipc.send方法再调用ipcRenderer.send方法并传递参数
  • 因为在声明文件中添加了ipc: import("electron").IpcRenderer;,所以也拥有代码提示

也可以用下面的写法,一个函数对应一个通信方法

/**
* 在渲染进程中使用:
* <script setup lang="ts">
* window.ipc.sendNavBar('navBar','close')
* </script>
* 在主进程中接收:
* ipcMain.on('navBar', (event, val) => {
*    if (val == 'close') { ......}
* }
*/

const sendNavBar = (channel:string,params:string)=> ipcRenderer.send(channel,params)
contextBridge.exposeInMainWorld("ipc", {
    sendNavBar:sendNavBar
});

2.2自定义控制按钮

因为使用了无边框窗口,所以三个控制按钮(最大化,最小化,关闭)需要自定义 这里直接引入antd组件库,后面的右键菜单也需要使用

@vue/compiler-sfc:可以消除安装antd时的控制台警告,不装也没影响
unplugin-vue-components:按需引入组件库插件,也支持elementplus啥的

yarn add ant-design-vue@next @vue/compiler-sfc unplugin-vue-components -D

修改vite.config.ts

......
/**按需导入组件库 查看=>https://zhuanlan.zhihu.com/p/423194571 */
import Components from "unplugin-vue-components/vite";
import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
export default defineConfig({
    plugins: [
        vue(),
        Components({
            resolvers: [AntDesignVueResolver()]
        })
    ],
......
});

在页面上加三个按钮,再随便整点样式

截屏2021-12-03 下午4.13.40.png

新建electron/utils/navbar.ts

import { BrowserWindow, ipcMain } from "electron";
/**
* @description 进程通讯 渲染进程点击顶部关闭,最小化...按钮时,传递 {val}参数,
* 主进程通过 BrowserWindow.fromWebContents(event.sender)拿到活动窗口的BrowserWindow实例,再通过minimize()等实例方法操作窗口
* @param {Electron.WebContents} event.sender
* @param val {'mini'|'big'|'close'}
* @example
* window.ipc.send('navBar', val) // 渲染进程中
* */
export function onNavbar() {
    ipcMain.on('navBar', (event, val) => {
        /**
        * 通过BrowserWindow.fromWebContents方法拿到window实例
        * event.sender 是发送消息的WebContents实例
        */
        const window: Electron.BrowserWindow | null = BrowserWindow.fromWebContents(event.sender)
        if (val == 'mini') { window?.minimize() } // 最小化窗口
        if (val == 'close') { window?.close() } // 关闭窗口
        if (val == 'big') { // 全屏/取消全屏
            // 因为在createWindow.ts中禁用了修改窗口尺寸(resizable: false),这里先解除 
            window?.setResizable(true)
            window?.isMaximized() ? window?.unmaximize() : window?.maximize();
            window?.setResizable(false)
        }
    })
}

修改electron/main.ts

......

import { onNavbar } from "./utils/navbar"; // 新增
onNavbar(); // 新增

app.on("ready", () => {
    ......
});

给按钮添加点击事件,向主进程发送消息

<script setup lang="ts">
    const navBar = (val: string) => {
        window.ipc.send("navBar", val);
    };
</script>

<template>
    <div style="-webkit-app-region: no-drag">
        <a-button @click="navBar('mini')" type="dashed" danger>最小化</a-button>
        <a-button @click="navBar('big')" type="dashed" danger>最大化</a-button>
        <a-button @click="navBar('close')" type="dashed" danger>关闭</a-button>
    </div>
</template>

到此步骤的所有代码在history/item2文件夹下

2.3主进程菜单

2.3.1 创建菜单

通过Menu.buildFromTemplate方法将一个预定义的对象包装成AppMenu对象,,然后在app启动时(app.on("ready",...)通过Menu.setApplicationMenu方法设置菜单

新建electron/utils/menu.ts

import { Menu } from "electron";
export function createAppMenu() {
    const AppMenu: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
        // 在mac上,第一个自定义menuItem的label会被应用名覆盖
        //此label会被package.json打包配置中的 `build.productName = ‘后台管理’` 覆盖
        { id: "1", label: "App", submenu: [{ id: "1-1", label: "测试" }] },
        {
            id: "2",
            label: "开发",
            submenu: [
                { id: "2-1", label: "测试" },
                {id: "2-2",label: "检查元素",click(m, window, e){window?.webContents.openDevTools()}}
            ]
        }
    ];
    /** 创建菜单 */
    const appMenu = Menu.buildFromTemplate(AppMenu);
    return appMenu;
}

编辑electron/main.ts

import { createAppMenu } from "./utils/menu"; // 新增
......
app.on("ready", () => {
    // 设置app菜单
    Menu.setApplicationMenu(createAppMenu());// 新增
    ......
});

现在 macos上的app菜单就设置好了

截屏2021-12-06 上午10.43.16.png

2.4渲染进程菜单(windows菜单)

经过2.3.1的操作,macos上的菜单已经可以显示了,但是由于使用了无边框窗口,win上的菜单随边框一起隐藏了,所以需要自行获取并显示,生成类似vscode的菜单栏
代码写的比较屎,先上个思路,可以自行优化

  1. 主进程可以通过Menu.getApplicationMenu()获取app菜单
  2. app启动时通过进程通信请求菜单,主进程将递归生成的菜单对象发送给渲染进程
  3. 渲染进程通过a-dropdown等组件,递归生成下拉菜单
  4. 渲染进程点击菜单项时,发送菜单id,主进程通过Menu.getApplicationMenu()?.getMenuItemById方法,拿到id对应的菜单对象,并调用click方法

编辑electron/utils/menu.ts,添加

import { Menu, ipcMain, BrowserWindow } from "electron";

interface menuObj {
    lable: string;
    id: string;
    type: string;
    child: menuObj[] | null;
}

export function onAppMenu() {
    // 渲染进程索取菜单时,如果是windows,返回菜单,如果是macos,返回null
    ipcMain.handle("getAppMenu", (): menuObj[] | null => process.platform == "darwin" ? null : getmenu());
    ipcMain.on("MenuClick", (event, menuItemId: string) => {
        const menuItem = Menu.getApplicationMenu()?.getMenuItemById(menuItemId);
        menuItem?.click();
    });
}

/**
* @description 递归生成菜单数组,数组传递给渲染进程用于生成windows上左上角菜单栏
* @returns {menuObj} menuArr:{ lable: string, id: string, type: string, child?: menuObj[] }
*/
function getmenu() {
    function menu(ims: Electron.MenuItem[]) {
        let menuArr: menuObj[] = [];
        ims.map((im) => {
            let menuObj: menuObj = {
            lable: im.label,
            id: im.id,
            type: im.type,
            child: im.type == "submenu" && im.submenu ? menu(im.submenu.items) : null
            };
            menuArr.push(menuObj);
        });
        return menuArr;
    }
    const ims = Menu.getApplicationMenu() as Electron.Menu;
    return menu(ims.items);
}

编辑electron/main.ts,

import { onAppMenu, createAppMenu } from "./utils/menu"; // 修改
onAppMenu(); // 新增
......

截屏2021-12-06 下午12.01.40.png 渲染进程拿到菜单对象,通过a-dropdown等组件就可以生成对应的菜单代码查看history/item2,这里就不贴了

2.5渲染进程右键菜单

主进程可以通过Menu.buildFromTemplate(contextMenu).popup()为app设置全局右键菜单,但只能是系统默认样式比较丑,所以在渲染进程编写菜单,好处是可以为不同区域设置不同菜单项

截屏2021-12-06 下午1.58.19.png


新增src/components/contentMenu.vue

antd的a-dropdown右键菜单组件,通过<slot />传入要包裹的组件,菜单项点击时通过传递的key调用不同的方法,方法内可以只在渲染函数执行,也可以通过进程通信调用主进程方法.可以写多个contentMenu组件,包裹不同的页面,就可以显示多种右键菜单

<script lang="ts" setup>
    interface methods {
        [propName: string]: Function;
    }
    const hiddenSidebar = () => console.log("hiddenSidebarfn");
    const openDevTool = (key: string) => window.ipc.send("contentMenu", key);
    const fullScreen = (key: string) => window.ipc.send("contentMenu", key);
    const metnods: methods = {
        hiddenSidebar,
        openDevTool,
        fullScreen
    };
    const handleClick = ({ key }: { key: string }) => metnods[key] && metnods[key](key);
</script>

<template>
    <a-dropdown :trigger="['contextmenu']">
        <slot />
        <template #overlay>
            <a-menu @click="handleClick">
                <a-menu-item key="openDevTool">检查元素</a-menu-item>
                <a-menu-item key="hiddenSidebar">隐藏/显示边栏</a-menu-item>
                <a-menu-item key="fullScreen">进入/退出全屏</a-menu-item>
            </a-menu>
        </template>
    </a-dropdown>
</template>

编辑src/App.vue

<script setup lang="ts">
    import ContentMenu from "./components/contentMenu.vue";
    ......
</script>
<template>
    <ContentMenu>
    // 用ContentMenu包裹要展示右键菜单的组件
    ......
    </ContentMenu>
</template>

新增electron/utils/contextMenu.ts

import { ipcMain, BrowserWindow } from "electron";
interface methods {
    [propName: string]: Function;
}

/**
* @desc: 渲染进程点击自定义菜单项后,通过进程通讯调用主进程方法,实现渲染进程右键菜单
* @param {string } key 如'fullScreen' 传参为methods中键名,
*/
export function onContextMenu() {
    ipcMain.on("contentMenu", (event, key: string) => {
        methods[key] && methods[key](event);
    });
}

// 打开控制台
const openDevTool = (e: Electron.IpcMainEvent) => e.sender.openDevTools();

// 全屏/推出全屏
/**
* 由于electron的某个bug,无边框透明窗口在win上isSetFullScreen总是返回false
* 所以在windows上是否全屏通过在当前窗口实例上挂载变量的方式来判断
*/
type route = extendWindow & Electron.BrowserWindow;
interface extendWindow {
    isMax: boolean | null | undefined;
}
const fullScreen = async (e: Electron.IpcMainEvent) => {
    const window = BrowserWindow.fromWebContents(e.sender) as route; // 获取窗口实例
    const isMac = process.platform == "darwin"; // 判断是否是mac
    if (isMac) {
        // mac进入/退出简单全屏模式
        const isSimpleFS = window.isSimpleFullScreen();
        window.setSimpleFullScreen(!isSimpleFS);
    } else {
        // win进入/退出全屏模式
        window.isMax ? window.setFullScreen(false) : window.setFullScreen(true);
        window.isMax = !window.isMax;
    }
};
const methods: methods = {
    openDevTool,
    fullScreen
};

修改electron/main.ts

......
import { onContextMenu } from "./utils/contextMenu"; // 新增
onContextMenu();// 新增
......

123.gif 到此步骤的所有代码在history/item3文件夹下

3.打包项目

打包使用electron-builder 先安装依赖

yarn add electron-builder -D

修改vite.config.ts,打包使用相对路径

export default defineConfig({
    base: "./", // * 打包相对路径,否则electron加载index.html时找不到css,js文件
    ......
});

编辑package.json 说明

  1. yarn build 开始执行打包脚本
  2. build:vue 打包vue文件,目录为output/dist
  3. build:tsc将ts的electron文件编译为js,目录为output/build
  4. build:all打包两个平台 也可以分别运行build:vue,build:tsc,build:mac来打包指定平台
  5. "main": "output/build/main.js",:是在tsconfig.e.json中指定的,编译ts文件后输出到build目录 electron-builder通过它找到打包入口
  6. preview: 使用electron预览打包后的vue文件
{
    ......
    "main": "output/build/main.js",
    "scripts": {
        ......
        "build": "npm-run-all build:vue build:tsc build:all",
        "build:vue": "vue-tsc --noEmit && vite build",
        "build:tsc": "tsc -p tsconfig.e.json",
        "build:all": "electron-builder --mac --windows",
        "build:mac": "electron-builder --mac",
        "build:win": "electron-builder --windows",
        "preview": "cross-env NODE_ENV=production electron ./output/build/main.js"
    },

    "dependencies": {
        "vue": "^3.2.16"
    },
    "devDependencies": {
        ......
    },

    "build": {
        "appId": "com.lx000-website.electron-vue3-tpm-test",
        "productName": "测试app", //打包后的app名称
        "copyright": "Copyright © 2021 <your-name>",
        "directories": {
            "output": "output/app" // 打包dmg,exe等文件输出目录
        },
        "win": {
            "icon": "public/cccs.icns", // app图标
            "target": [ // win打包目标
                "nsis", // exe文件
                "zip", // 压缩包,解压后可直接运行
                "7z"
            ]
        },
        // 指定将哪些文件打进最终的安装包
        "files": [
            // 排除node_modules文件夹
            "!node_modules",
            "output/dist/**/*",
            "output/build/**/*"
        ],
        "mac": {
            "category": "public.app-category.utilities.test",
            "icon": "public/cccs.icns"
        },
        "nsis": {
            "oneClick": false,
            "allowToChangeInstallationDirectory": true
        }
    }
}