如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

lxf2023-03-12 08:48:01

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架 如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

一、前言⚡️

☕️最近接到开发Electron+Vue3的需求,由于对于Electron的开发并不是十分的熟悉,所以决定自己从头搭建一套开发框架,Vue部分使用Vue3+Vite2+Ts+Pinia+Vue-Router4+Axios+Element-Plus✔️进行搭建,由于本人日常使用element-adminelement-plus-admin等后台框架进行开发,深受影响,因此Vue部分的结构及风格与其会比较像,可能常使用这套工具的小伙伴会很熟悉.Electron部分使用Electron21版本进行搭建,采用electron-bulider进行打包。

☄️本文详细讲解了从初始化项目、搭建Vite2+Vue3框架、集成Electron、最后完成打包的思路以及代码,包括一些全局主题等方案的探讨,持久化缓存的使用等,但还是希望能够真正帮助到大家,能够根据步骤搭建起一套开发工具,同时在Electron开发或者是打包过程中确实遇到了许多问题,通过很多次的搜索,我在文章后半部分详细说明了一些打包遇到的问题及解决方法,希望能有用❤️‍。

⭐本文从零开始搭建,写了近万字,内容较多,大家可以选择性查看,如果希望查看Vue3部分的搭建请查看前半部分,如果希望查看Electron部分的搭建请查看后半部分。此外该项目主要使用了相关技术比较新的版本,如果有查看代码的同学请确保Node版本在16以上。

⭐️github地址:Vue3-Electron21-Vite2

⭐️gitee地址:Vue3-Electron21-Vite2

1、项目思路及目录结构

1.1 目录结构

✨我们先来预览一下项目的目录结构,我们将electron的主进程文件放在src下的electrin-main文件夹中,将vue项目的页面路由等文件放在render中,同时distvite打包后文件,dist_electronelectron打包后文件。 如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

1.2 整体思路

✨该框架目前只搭建了三个窗口,我们希望客户端点击客户端过后,先弹出加载界面窗口,可以进行读取文件配置进度等操作,最重要的是可以获取我们的缓存,判断用户的登录是否是生效状态,然后决定展示登录窗口,还是主界面窗口。文件目录为三个窗口,分别对应主界面,登录,加载页面,我们electron的loadrUrl采用多窗口的Hash路由模式对应配置,因此我们的路由文件也只有三个大模块,LayOut是我们主页面的组件,跟后台系统一样,所有的主界面下的路由菜单页面都将在LayOut中渲染,而我们三个窗口也分别对应这三个路由

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

export const constantRouterMap: (RouteRecordRaw | RouterCustorm)[] = [
    {
        path: '/load',
        name: 'Load',
        hidden: true,
        component: () => import('@/render/views/load/index.vue'),
    },
    {
        path: '/',
        name: 'Index',
        component: LayOut,
        children:[
            {
                path: '/',
                name: 'Welcome',
                meta: { title: '首页' },
                component: () => import('@/render/views/home/index.vue'),
            },
            {
                path: '/news',
                name: 'News',
                meta: { title: '新闻' },
                component: () => import('@/render/views/news/index.vue'),
            }
        ]
    },
    {
        path: '/login',
        name: 'Login',
        hidden: true,
        component: () => import('@/render/views/login/index.vue'),
    }
]

2、该项目主要用到的技术栈:

主要技术栈及版本版本
electron^21.3.0
vite^3.2.3
vue^3.2.41
vue-router^4.1.6
pinia^2.0.26
axios^1.2.0
element-plus^2.2.22
sass^1.56.1
部分插件作用
web-storage-cache持久化存储
nodemonelectron窗口热更新
electron-is-dev开发生产环境判断
electron-window-state窗口状态的保持
----
----
----
----

二、搭建Vue3开发框架

使用Vue3+Vite2+Ts+Pinia+Vue-Router4+Axios+Element-Plus✔️进行搭建,该章节将会带领大家从零搭建一套Vue3开发框架,由于本文内容过多,有些部分会简略介绍,详细介绍请查看另外一篇文章✈️:Vue3+Ts+Vite2+Pinia 搭建开发脚手架

1、始化Vite项目

1.1 新建vite2+Ts+vue3项目

npm init vite ✅

输入自定义项目名称,同时我们本项目选择Vue+TypeScript进行开发。按照流程配置后我们成功启动了vite页面。

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架 如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

1.2 配置路径别名及自动补全路径

(1)配置路径别名

路径别名即@可以在vite.config.js的resolve中配置

import { defineConfig,loadEnv} from 'vite'
import type { UserConfig, ConfigEnv } from 'vite'
import vue from '@vitejs/plugin-vue'

import { resolve } from 'path'
const root = process.cwd()

function pathResolve(dir: string) {
  return resolve(root, '.', dir)
}

export default defineConfig(({ command, mode }: ConfigEnv): UserConfig=>{
  return {
    plugins: [
      vue(),
    ],
    resolve: {
      extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.less', '.css'],
      alias: [
        {
          find: /@//,
          replacement: `${pathResolve('src')}/`
        }
      ]
    },
  }
})
(2)自动补全路径配置

好多同学创建项目发现使用shiyongle@符号,但是没有自动出现文件路径供我们选择,如果自己手写非常麻烦,本项目我们可以在tsconfig.json中设置我们的补全路径,在tsconfig.json中添加代码:

"baseUrl": ".",
"paths": {
  "@/*": ["src/*"]
}

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架 配置完成后就可以愉快的使用代码提示了:

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

2、配置Scss及全局主题样式方案

2.1安装sass及sass-loader

npm install sass sass-loader -D ✅

(1)在src下新建styles文件,styles下新建main.scss放置我们的全局样式

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

(2)新建var.scss放置全局变量,设置两套变量,为接下来配置全局主题样式做准备

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

(3)新建clear.scss进行全局样式的清除

可以自行查找相关css文件,这里就不再粘贴

(4)新建index.scss进行文件出口配置

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

在main.ts中引入index.scss,并且可以看到页面中的h1标题的样式已经生效。

import { createApp } from 'vue'
import App from './App.vue'
// 引入全局样式
import '@/styles/index.scss'

// 创建实例
const setupAll = async () => {
    const app = createApp(App)
    app.mount('#app')
}

setupAll()

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

2.1 配置全局主题色⭐️

配置全局主题色的方案有非常多,包括link标签动态引入,CSS变量+类名切换CSS变量+动态propertySCSS + mixin + 类名切换等方案,根据我们的需求,由于本项目只进行暗黑、白亮模式的切换,所以选择CSS变量+类名切换方案,同时参考vue-element-plus-admin主题色方案,我们预留CSS变量+动态property方案进行局部主题色的修改。更全主题配置可以参考文章✈️'前端主题切换方案'

(1)CSS变量+类名切换进行暗黑、白亮模式的切换

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架 在上一部分的styles目录下我们提前新建了全局变量文件var.scss,设置了两套颜色,白亮主题以及暗黑主题下的颜色变量。

:root%20{
%20%20--theme-bg-color:%20#fff;
%20%20--btn-color:%20#409eff;
}
.dark{
%20%20--theme-bg-color:#293146;
%20%20--btn-color:%20#293146;
}

那么如何在页面中使用呢?只需要将变量名加入需要切换颜色的地方,在点击切换事件时,使用document.body.className设置class名称,从而将变量值切换为相关class名称下的变量。

<template>
%20%20<div%20class="theme-container">
%20%20%20%20<button%20class="btn"%20@click="handleChangeTheme">切换主题</button>
%20%20</div>
</template>

<script%20setup%20lang="ts">
import%20{%20ref%20}%20from%20'vue'
const%20bol=ref<Boolean>(false)
const%20handleChangeTheme=()=>{
%20%20bol.value=!bol.value
%20%20if(bol.value){
%20%20%20%20document.body.className%20=%20'dark';
%20%20}else{
%20%20%20%20document.body.className%20=%20'';
%20%20}
}
</script>

<style%20lang="scss"%20scoped>
.theme-container{
%20%20width:%20100vw;
%20%20height:%20100vh;
%20%20background:%20var(--theme-bg-color);
%20%20.btn{
%20%20%20%20color:%20var(--btn-color);
%20%20}
}
</style>
(2)CSS变量+动态property进行局部颜色的切换

主要用来实现头部,菜单栏等颜色的切,可以的参考vue-element-plus-admin的主题设置。在此处我们简单使用,代码与上部分CSS变量+类名切换进行暗黑、白亮模式的切换的代码是一样的,只不过进行切换时,我们使用 如下方法,直接设置某个变量颜色,从而达到局部颜色切换的效果。

const%20handleChangeTheme=()=>{
%20%20document.documentElement.style.setProperty('--theme-bg-color','red')
%20%20//%20bol.value=!bol.value
%20%20//%20if(bol.value){
%20%20//%20%20%20document.body.className%20=%20'dark';
%20%20//%20}else{
%20%20//%20%20%20document.body.className%20=%20'';
%20%20//%20}
}
3、配置状态管理库Pinia

☘️%20Pinia%20是%20Vue3最新的以及最流行的状态管理库,它允许跨组件/页面共享状态。%20如果熟悉%20Composition%20API,可以认为他是一个简单的 export%20const%20state%20=%20reactive({}),接下来我们就安装Pinia,具体使用方法可以参考本人之前写的一篇文章✈️:Vue3+Ts+Vite2+Pinia%20搭建开发脚手架

npm%20install%20pinia%20✅

3.1%20在src下新建store文件夹,store下新建index.ts,用来创建Pinia实例

import type { App } from 'vue'
import { createPinia } from 'pinia'

const store = createPinia()

export const setupStore = (app: App<Element>) => {
    app.use(store)
}

export { store }

3.2 在store文件夹下新建modules文件夹,用来管理模块,同时我们新建一个app.ts文件,用来存储app文件。

// stores/app.ts
import { defineStore } from 'pinia'
import {useCache} from "@/render/hooks/useCache";

const { wsCache } = useCache()

interface AppState {
    count:number
}
export const useAppStore = defineStore('app', {
    state: ():AppState => {
        return { count: 0 }
    },
    actions: {
        increment() {
            this.count++
        },
    },
})

3.3 main.ts中引入

import { createApp } from 'vue'
import App from './App.vue'
// 引入全局样式
import '@/render/styles/index.scss'
// 引入状态管理
import { setupStore } from '@/render/store'


// 创建实例
const setupAll = async () => {
    const app = createApp(App)
    setupStore(app)
    app.mount('#app')
}

setupAll()

3.4 页面中使用

import {useAppStore} from '@/render/store/modules/app'
const appStore = useAppStore()
appStore.increment()
const count=computed(()=>{
    return appStore.count
})

4、使用WebStorageCache进行持久化存储

4.1 什么是WebStorageCache

WebStorageCache 对HTML5 localStorage 和sessionStorage 进行了扩展,添加了超时时间,序列化方法。可以直接存储json对象,同时进行超时时间的设置⏰。 同时我们可以结合Pinia进行数据的持久化存储。

npm install web-storage-cache --save-dev ✅

4.2 封装WebStorageCache为hooks

在src下新建hooks文件夹,用来放置我们后续封装的hooks,新建useCache.ts⛳️

/**
 * 配置浏览器本地存储的方式,可直接存储对象数组。
 */

import WebStorageCache from 'web-storage-cache'

type CacheType = 'sessionStorage' | 'localStorage'

export const useCache = (type: CacheType = 'sessionStorage') => {
  const wsCache: WebStorageCache = new WebStorageCache({
    storage: type
  })

  return {
    wsCache
  }
}
4.3 使用WebStorageCache
import {useCache} from '@/render/hooks/useCache'
const {wsCache}=useCache('localStorage')
// 设置缓存,第三个参数对象配置的超时事件
wsCache.set('login',true,{exp : 100})
// 获取缓存
wsCache.get('login')

5、配置vue-router4

npm install vue-router@4 ✅

☘️src下新建router文件夹,同时新建index.ts文件,在views下新建login页面,方便调试路由,同时为了接下来适配electron,我们的路由模式需要使用hash模式,否则electron打包后很难通过路由引入对应的页面,并且我们需要配置路由的baseUrl,可以在根目录新建环境变量文件.env.production.env.development,
production下VITE_BASE_PATH='./'
development下VITE_BASE_PATH='/'

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

import { createRouter, createWebHistory, RouteRecordRaw,createWebHashHistory} from 'vue-router'
import type { App } from 'vue'
type RouterCustorm={
    hidden?:boolean
}
export const constantRouterMap: (RouteRecordRaw | RouterCustorm)[] = [
    {
        path: '/login',
        name: 'Login',
        hidden: true,
        component: () => import('@/views/login/index.vue'),
    }
]
const router = createRouter({
    history: createWebHashHistory(import.meta.env.VITE_BASE_PATH),
    routes: constantRouterMap as RouteRecordRaw[],
    scrollBehavior: () => ({ left: 0, top: 0 })
})

export const setupRouter = (app: App<Element>) => {
    app.use(router)
}

在main.ts中使用⛳️:

import { createApp } from 'vue'
import App from './App.vue'
// 引入全局样式
import '@/render/styles/index.scss'
// **引入状态管理**
import { setupStore } from '@/render/store'
// 引入路由
import {setupRouter} from "@/render/router";


// 创建实例
const setupAll = async () => {
    const app = createApp(App)
    setupStore(app)
    setupRouter(app)
    app.mount('#app')
}

setupAll()

6、配置axios请求

此处请求配置参考了Element-Plus-Admin源码,其配置还是比较合理的,后续可以根据自己需求完善请求配置以及请求拦截等内容❤️‍

⚡️配置请求前,先完善环境变量.env.production.env.development
production下:

// 环境
NODE_ENV=production
# 接口前缀
VITE_API_BASEPATH='pro'
# 打包路径
VITE_BASE_PATH='./'

development下:

// 环境
NODE_ENV=development
# 接口前缀
VITE_API_BASEPATH='dev'
# 打包路径
VITE_BASE_PATH='/'

npm install axios ✅

6.1新建请求配置文件

src下新建service目录,service下新建axios目录,新建config.js,比较值得注意的是打包生产环境接口前缀可以是完整线上地址,涉及到electron打包后请求地址问题

const config: {
  base_url: {
    dev: string
    pro: string
  }
  result_code: number | string
  default_headers: AxiosHeaders;
  request_timeout: number
} = {
  /**
   * api请求基础路径
   */
  base_url: {
    // 开发环境接口前缀
    dev: '/api',

    // 打包生产环境接口前缀
    pro: 'https://xxxx/api',
  },

  /**
   * 接口成功返回状态码
   */
  result_code: '0000',

  /**
   * 接口请求超时时间
   */
  request_timeout: 60000,

  /**
   * 默认接口请求类型
   * 可选值:application/x-www-form-urlencoded multipart/form-data
   */
  default_headers: 'application/json'
}

export { config }

6.2 新建service.ts文件:

import axios, {
  AxiosInstance,
  AxiosRequestConfig,
  AxiosRequestHeaders,
  AxiosResponse,
  AxiosError
} from 'axios'

// @ts-ignore
import qs from 'qs'

import { config } from './config'

import { ElMessage } from 'element-plus'

const { result_code, base_url } = config
// export const PATH_URL ='/api'
// @ts-ignore
export const PATH_URL = base_url[import.meta.env.VITE_API_BASEPATH]
// 创建axios实例
const service: AxiosInstance = axios.create({
  baseURL: PATH_URL, // api 的 base_url
  timeout: config.request_timeout // 请求超时时间
})

// request拦截器
service.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    if (
      config.method === 'post' &&
      (config.headers as AxiosRequestHeaders)['Content-Type'] ===
        'application/x-www-form-urlencoded'
    ) {
      config.data = qs.stringify(config.data)
    }
    // ;(config.headers as AxiosRequestHeaders)['Token'] = 'test test'
    // get参数编码
    if (config.method === 'get' && config.params) {
      let url = config.url as string
      url += '?'
      const keys = Object.keys(config.params)
      for (const key of keys) {
        if (config.params[key] !== void 0 && config.params[key] !== null) {
          url += `${key}=${encodeURIComponent(config.params[key])}&`
        }
      }
      url = url.substring(0, url.length - 1)
      config.params = {}
      config.url = url
    }
    return config
  },
  (error: AxiosError) => {
    // Do something with request error
    console.log(error) // for debug
    Promise.reject(error)
  }
)

// response 拦截器
service.interceptors.response.use(
  (response: AxiosResponse<any>) => {
    if (response.config.responseType === 'blob') {
      // 如果是文件流,直接过
      return response
    } else if (response.data.code === result_code) {
      return response.data
    } else {
      ElMessage.error(response.data.message)
    }
  },
  (error: AxiosError) => {
    console.log('err' + error) // for debug
    ElMessage.error(error.message)
    return Promise.reject(error)
  }
)

export { service }

6.3 新建index.ts对请求进行导出

import { service } from './service'

import { config } from './config'

const { default_headers } = config

const request = (option: any) => {
  const { url, method, params, data, headersType, responseType } = option
  return service({
    url: url,
    method,
    params,
    data,
    responseType: responseType,
    headers: {
      'Content-Type': headersType || default_headers
    }
  })
}
export default {
  get: <T = any>(option: any) => {
    return request({ method: 'get', ...option }) as unknown as T
  },
  post: <T = any>(option: any) => {
    return request({ method: 'post', ...option }) as unknown as T
  },
  delete: <T = any>(option: any) => {
    return request({ method: 'delete', ...option }) as unknown as T
  },
  put: <T = any>(option: any) => {
    return request({ method: 'put', ...option }) as unknown as T
  }
}

6.4 请求统一管理

在src下新建api文件夹对请求进行管理,新建login文件夹 如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架 新建index.ts:

import request from '@/render/service/axios'
import type { UserType } from './types'
export const loginApi = (data: Partial<UserType>): Promise<IResponse<UserType>> => {
    return request.post({ url: '/auth/manage/login/pwd', data })
}

新建types.ts:

export type UserLoginType = {
    username: string
    password: string
}

export type UserType = {
    username: string
    password: string
    role: string
    roleId: string
    permissions: string | string[]
}

6.5 vite.config.ts中配置代理

import { defineConfig,loadEnv} from 'vite'
import type { UserConfig, ConfigEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

const root = process.cwd()
function pathResolve(dir: string) {
  return resolve(root, '.', dir)
}

export default defineConfig(({ command, mode }: ConfigEnv): UserConfig=>{
    let env = {} as any
    const isBuild = command === 'build'
    if (!isBuild) {
      env = loadEnv((process.argv[3] === '--mode' ? process.argv[4] : process.argv[3]), root)
    } else {
      env = loadEnv(mode, root)
    }
    return {
      base: env.VITE_BASE_PATH,
      server: {
        port: 4000,
        proxy: {
          // 选项写法
          '/api': {
            target: 'https://xxx/api',
            changeOrigin: true,
            rewrite: path => path.replace(/^/api/, '')
          }
        },
        hmr: {
          overlay: false
        },
        host: '0.0.0.0'
      },
      plugins: [
          vue()
      ],
    }
})

7、配置Element Plus与Element Plus Icons自动导入

⚙️Element Plus与Element Plus Icons自动导入主要是使用官网提供的插件进行vite的配置,但是Icons自动导入的使用方式有坑,下文有讲解,具体参照官网Element Plus

7.1 安装

npm install element-plus --save ✅
npm install @element-plus/icons-vue ✅

7.2 自动导入Element Plus

为了减少我们打包后包的体积,我们希望能够按需引入,但是每次页面需要手动导入很麻烦,因为我们使用自动导入,用起来与全局引入一样方便,但是需要使用额外的插件来导入要使用的组件

npm install -D unplugin-vue-components unplugin-auto-import

7.3 自动导入icons

npm install -D unplugin-icons unplugin-auto-import

注意自动导入icons的用法官网上并未给予示例,使用ep前缀自动注册,因此是个坑。

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架 ❗️ icons用法如下:

<i-ep-SemiSelect></i-ep-SemiSelect>

7.4 vite.config.ts中进行配置

import { defineConfig,loadEnv} from 'vite'
import type { UserConfig, ConfigEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// 自动导入element-plus
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'

const root = process.cwd()
function pathResolve(dir: string) {
  return resolve(root, '.', dir)
}

export default defineConfig(({ command, mode }: ConfigEnv): UserConfig=>{
    return {
      plugins: [
          vue(),
          AutoImport({
              // Auto import functions from Element Plus, e.g. ElMessage, ElMessageBox... (with style)
              // 自动导入 Element Plus 相关函数,如:ElMessage, ElMessageBox... (带样式)
              resolvers: [
                  ElementPlusResolver(),
                  // Auto import icon components
                  // 自动导入图标组件
                  IconsResolver({
                      prefix: 'Icon',
                  }),
              ],

          }),

          Components({
              resolvers: [
                  // Auto register icon components
                  // 自动注册图标组件
                  IconsResolver({
                      enabledCollections: ['ep'],
                  }),
                  // Auto register Element Plus components
                  // 自动导入 Element Plus 组件
                  ElementPlusResolver(),
              ],
          }),

          Icons({
              autoInstall: true,
          }),

      ]
    }
})

8、设置全局ts声明

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架 根目录下新建types文件夹,新建global.d.ts,其中Window会在electron主进程与渲染进程通信中用到。

export {}
declare global {
    interface Window {
        electronAPI?: any;//全局变量名
    }
     interface AxiosConfig {
        params?: any
        data?: any
        url?: string
        method?: AxiosMethod
        headersType?: string
        responseType?: AxiosResponseType
    }

     interface IResponse<T = any> {
        code: string
        data: T extends any ? T : T & any
    }
    type AxiosHeaders =
        | 'application/json'
        | 'application/x-www-form-urlencoded'
        | 'multipart/form-data'
}
declare const window: any;

同时需要将其配置到tsconfig.json中: 如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

三、集成Electron开发环境

1、文件思路及集成Electron

首先我们将electron的主进程文件等都放在src下的electron-main文件夹下,同时将我们之前写的vue页面即渲染进程放到render下,electron-main下的main.js文件主要进行主进程的配置,modules文件下的两个文件夹管理主进程与渲染进程的通信,shortcut进行快捷键的操作,tray文件夹进行托盘的设置,until放置读写文件工具,windows管理不同的窗口,我们的需求是能够有一个初始化加载页面,一个登录页面,一个主页面,因此需要管理三个窗口。

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

安装electron 并进行命令配置:

npm i electron -D ✅

使用工具nodemon配置热更新命令

npm i nodemon ✅

package.json中配置启动命令:

"start": "nodemon --exec electron . --watch ./ --ext .js,.html,.scss,.vue,.ts,.css",

同时我们可以使用工具concurrently配置一键同时启动vite项目以及electron客户端命令

"scripts": {
  "serve": "concurrently "npm run dev" "npm run start" ",
  "dev": "vite",
  "build": "vue-tsc && vite build",
  "preview": "vite preview",
  "start": "nodemon --exec electron . --watch ./ --ext .js,.html,.scss,.vue,.ts,.css",
},

2、main.js文件 ⭐

在这个文件中,我们初始化加载了loadWindows即初始化加载界面,同时初始化了我们的通信监听时间、快捷键即键盘事件、托盘,同时配置了我们的客户端聚焦失去焦点事件,方便我们聚焦时交互。

// @ts-ignore
const {InitController} =require('./modules/controller/main.js')
const {app,BrowserWindow, Tray, Menu} =require ('electron')
const  {createMainWindow}=require( './windows/mainWindows.js')
const {createLoginWindow}=require( './windows/loginWindows.js')
const {createLoadWindow}=require( './windows/loadWindows.js')
const {initTray}=require('./tray/index.js')
const {initShortCut,unInstallShortCut}=require('./shortcut/index')
app.whenReady().then(()=>{
    // createMainWindow(BrowserWindow)
    createLoadWindow(BrowserWindow)
    app.on('activate', () => {
        // On macOS it's common to re-create a window in the app when the
        // dock icon is clicked and there are no other windows open.
        if (BrowserWindow.getAllWindows().length === 0) createLoginWindow(BrowserWindow)
    })
    // 初始化监听事件
    InitController(app)
    // 初始化托盘
    initTray()
    // 初始化快捷键
    initShortCut()
})
// 除了 macOS 外,当所有窗口都被关闭的时候退出程序。 There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') app.quit()
})
// 客户端聚焦
app.on('browser-window-focus',()=>{
    // 初始化快捷键
    initShortCut()
    console.log('browser-window-focus')
})
// 客户端失去焦点
app.on('browser-window-blur',()=>{
    // 初始化快捷键
    unInstallShortCut()
    console.log('browser-window-blur')
})
app.on('will-quit', () => {
    // 注销快捷键
    unInstallShortCut()
})

3、窗口的设置 ⭐️

思路:我们希望客户端点击过后,先弹出加载界面,可以进行读取文件配置进度等操作,最重要的是可以获取我们的缓存,判断用户的登录是否是生效状态,然后决定展示登录窗口,还是主界面窗口。文件目录为三个窗口,分别对应主界面,登录,加载页面。

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

3.1 config.js

config.js主要是配置窗口的打包环境的loadeUrl,开发环境与打包环境是不同的,在打包模块我会进行讲解。

const ACHEME = "file";
const path=require('path')
const LOAD_URL=`file://${path.join(__dirname, '../../../dist/index.html')}`
module.exports={
    ACHEME,
    LOAD_URL
}
3.2 窗口插件使用
  • 使用了electron-window-state进行窗口状态的记忆,比如用户进行了窗口的拖拽、尺寸大小调整,该插件可以帮助我们进行记忆,下次打开时是调整后的状态,使用方法:
//客户端尺寸位置记忆插件
const windowStateKeeper = require('electron-window-state');
const createLoadWindow=(BrowserWindow)=>{

    // 默认窗口尺寸
    let mainWindowState = windowStateKeeper({
        defaultWidth: 1000,
        defaultHeight: 800
    });
    const win = new BrowserWindow({
        'x': mainWindowState.x,
        'y': mainWindowState.y,
        'width': mainWindowState.width,
        'height': mainWindowState.height
    })
    // 管理客户端尺寸位置记忆插件
    mainWindowState.manage(win);


}
  • 使用了electron-is-dev来进行开发环境还是打包环境的判断。
const isDev = require('electron-is-dev')
const loadWinURL =
    isDev? `http://localhost:4000/#/load`
        : `${LOAD_URL}#load`;
3.3 完整的一个窗口基本配置(以加载窗口为例)⭕️

本界面我们进行了基础的页面配置以及使用了插件等进行了配置,除了上面两个插件的配置,我们还进行了frame设置,取消菜单边框,进行页面的自定义开发,webPreferences下设置preload,渲染器进程到主进程通信 定义预加载的界面js的路径,同时也设置了开发者工具、优雅打开界面防白屏等,并且loadURL的设置,在开发环境下是我们vite启动的地址加路由,生产环境稍后模块会讲解⏭。

// @ts-ignore
const { LOAD_URL }=require('./config.js');
const path = require('path')
const isDev = require('electron-is-dev')
//客户端尺寸位置记忆插件
const windowStateKeeper = require('electron-window-state');
const url = require("url");
const loadWinURL =
    isDev? `http://localhost:4000/#/load`
        : `${LOAD_URL}#load`;

const createLoadWindow=(BrowserWindow)=>{

    // 默认窗口尺寸
    let mainWindowState = windowStateKeeper({
        defaultWidth: 1000,
        defaultHeight: 800
    });
    const win = new BrowserWindow({
        'x': mainWindowState.x,
        'y': mainWindowState.y,
        'width': mainWindowState.width,
        'height': mainWindowState.height,
        focusable:true,
        show:false,
        frame:false,
        resizable:false,
        webPreferences: {
            webSecurity: false,
            nodeIntegration: true,
            contextIsolation: true,
            // 渲染器进程到主进程通信 定义预加载的界面js
            preload: path.resolve(__dirname, '../modules/preload/load.js')
        }
    })
    // 加载页面地址 线上内网可切换地址
    win.loadURL(`${loadWinURL}`)
    // 管理客户端尺寸位置记忆插件
    mainWindowState.manage(win);
    // 开发者工具
    win.webContents.openDevTools()
    // 优雅打开界面
    win.on('ready-to-show',()=>{
        win.show()
    })

}

module.exports={
    createLoadWindow
}

4、主进程与渲染进程的通信 ⭐

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

4.1 主进程监听渲染进程的消息

controller文件夹下的main.js主要设置主进程使用ipcMain.handle对渲染进程发来的请求进行监听处理,同时决定窗口的切换、判断登录或者展示首页、屏幕缩小 放大 关闭控制等操作。

// @ts-ignore
const {ipcMain,BrowserWindow,shell} =require('electron')
const  {createMainWindow} = require( '../../windows/mainWindows.js')
const {createLoginWindow} = require("../../windows/loginWindows");
const settitle=()=>{
    // @ts-ignore
    ipcMain.handle('on-settitle-event',(event,title)=>{
            const webContents = event.sender
            const win = BrowserWindow.fromWebContents(webContents)
            win.setTitle(title)
      return '已收到'
    })
}
// 浏览器打开页面
const openByBrowser=()=>{
    // @ts-ignore
    ipcMain.handle('on-useOpenByBrowser-event',(event,url)=>{
       shell.openExternal(url)
    })
}
// 登录 展示首页
const setlogin=()=>{
    // @ts-ignore
    ipcMain.handle('on-setlogin-event',(event,title)=>{
        const webContents = event.sender
        const win = BrowserWindow.fromWebContents(webContents)
        win.close()
        createMainWindow(BrowserWindow)
        return '已经登录'
    })
}
// 加载页判断登录或者展示首页
const isShowLogin=()=>{
    // @ts-ignore
    ipcMain.handle('on-isshowlogin-event',(event,value)=>{
        if(value){
            setTimeout(()=>{
                const webContents = event.sender
                const win = BrowserWindow.fromWebContents(webContents)
                win.close()
                createLoginWindow(BrowserWindow)
            },3000)
        }else{
            const webContents = event.sender
            const win = BrowserWindow.fromWebContents(webContents)
            win.close()
            createMainWindow(BrowserWindow)
        }

        // const webContents = event.sender
        // const win = BrowserWindow.fromWebContents(webContents)
        // win.close()
        // createMainWindow(BrowserWindow)
        return ''
    })
}
// 首页屏幕缩小 放大 关闭控制
const setScreen=()=>{
    // @ts-ignore
    ipcMain.handle('on-setScreen-event',(event,value)=>{
        console.log(value)
        const webContents = event.sender
        const win = BrowserWindow.fromWebContents(webContents)
        if(value==='miniScreen'){
            win.minimize()
        }else if(value==='fullScreen'){
            if(win.isMaximized()){
                win.restore()
            }else{
                win.maximize()
            }
        }else if(value==='closeScreen'){

        }
        return ''
    })
}
const InitController=(app)=>{
        settitle(),
        openByBrowser(),
        setlogin(),
        isShowLogin(),
        setScreen()
}

module.exports={
    InitController,

}
4.2 渲染进程向主进程发送消息

由于我们有加载界面、登录界面、首页三个窗口,即三个渲染进程,因此我们设置三个渲染进程的方法文件,此处以首页向主进程发送页面缩小放大关闭等消息,利用contextBridge桥梁,将我们的方法挂载到window对象上。

const { contextBridge, ipcRenderer } = require('electron')
const setTitle=async (title)=>{
   let result= await ipcRenderer.invoke('on-settitle-event', title)
}
// 浏览器打开页面
const openByBrowser=(url)=>{
    ipcRenderer.invoke('on-useOpenByBrowser-event',url)
}
// 页面全屏 缩小 关闭
const setScreen=(value)=>{
    ipcRenderer.invoke('on-setScreen-event',value)
}

contextBridge.exposeInMainWorld('electronAPI', {
    setTitle,
    openByBrowser,
    setScreen,
    ipcRenderer: { ...ipcRenderer, on:  ipcRenderer.on.bind(ipcRenderer) }
})

在vue页面中使用:

<template>
 <div class="header-container">
   <div class="logo-box"></div>
   <div class="action-box wraper-container">
     <i-ep-SemiSelect class="mini-icon" @click="handleChangeScreen('miniScreen')"></i-ep-SemiSelect>
     <i-ep-FullScreen class="mini-icon" @click="handleChangeScreen('fullScreen')"></i-ep-FullScreen>
     <i-ep-CloseBold class="mini-icon" @click="handleChangeScreen('closeScreen')"></i-ep-CloseBold>
   </div>
 </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const handleChangeScreen=(value:string)=>{
    window.electronAPI.setScreen(value)
}
</script>
4.3 主进程向渲染进程发送消息(此处以键盘事件为例,触发页面不同操作)

在之前的文件夹中,我们将三个窗口分开管理,那么我们如何知道是哪个窗口的键盘事件执行了呢,我们需要在初始化三个窗口时,将其挂载到electron的全局对象global上,如以下

const win = new BrowserWindow({
    'width': 100px,
    'height': 200px,
})

global.mainWindow=win

我们可以从global上获取窗口对象,同时使用webContents.send方法向渲染进程发送消息

globalShortcut.register('g', () => {
    console.log('g')
    if(global.mainWindow){
        global.mainWindow.webContents.send('on-shortcut-event','g')
    }

})

渲染进程使用ipcRenderer.on进行消息监听。

import {ipcRenderer} from 'electron'
ipcRenderer.on('on-shortcut-event',(event:any,data:any)=>{
 console.log(event,data)
})

正常来说,这样获取ipcRenderer直接进行监听看似非常简单,但是坑点在于vue页面中import {ipcRenderer} from 'electron'根本无法获取ipcRenderer❌,因此我查询了许多文档,发现有一种问题可以解决,就是我们利用渲染进程向主进程发送消息时在window身上挂载一个ipcRenderer,一定要bind一下on方法:

contextBridge.exposeInMainWorld('electronAPI', {
    setTitle,
    openByBrowser,
    setScreen,
    ipcRenderer: { ...ipcRenderer, on:  ipcRenderer.on.bind(ipcRenderer) }
})

vue页面中:

 if(window.electronAPI && window.electronAPI.ipcRenderer){
   window.electronAPI?.ipcRenderer?.on('on-shortcut-event',(event:any,data:any)=>{
     console.log(event,data)
   })
 }

5、键盘快捷键事件监听

在shortcut文件夹下新建index.js,注册键盘事件,globalShortcut.register第一个参数为快捷键组合,第二个参数为触发后的回调,综合上一步我们可以使用global.mainWindow.webContents.send向渲染进程vue页面发消息,让其进行不同的操作,同时要记得注销快捷键⚠️。在主进程main.js中app失去焦点关闭等状态时进行注销。

const {app,  globalShortcut } = require('electron')

const initShortCut=()=>{
    // globalShortcut.register('CommandOrControl+X', () => {
    //     console.log('CommandOrControl+X is pressed')
    // })
    globalShortcut.register('g', () => {
        console.log('g')
        if(global.mainWindow){
            global.mainWindow.webContents.send('on-shortcut-event','g')
        }

    })
}
const unInstallShortCut=()=>{
    // 注销快捷键
    globalShortcut.unregister('CommandOrControl+X')
    globalShortcut.unregister('g')
    // 注销所有快捷键
    globalShortcut.unregisterAll()
}
module.exports={
    initShortCut,
    unInstallShortCut
}

注销快捷键:

// 客户端失去焦点
app.on('browser-window-blur',()=>{
    // 初始化快捷键
    unInstallShortCut()
    console.log('browser-window-blur')
})
app.on('will-quit', () => {
    // 注销快捷键
    unInstallShortCut()
})

6、托盘设置

什么是托盘,其实在windows中就是桌面底部任务栏右侧的小图标点击右键可以进行操作

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架 如何实现呢❓
在tray文件夹下新建index.js,icon可以设置展示图标,setToolTip可以设置鼠标放上去的文字提示,contextMenu是右击出现的操作菜单,我们这里可以操作窗口的退出等等操作。

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

const { app, Menu, Tray,nativeImage } = require('electron')
const path=require('path')
let appIcon = null
const initTray=()=>{
    const iconPath = path.join(__dirname, '/icone.ico').replace('/\/g','\\');
    appIcon = new Tray(nativeImage.createFromPath(iconPath))
    appIcon.setToolTip('This is my application.')
    const contextMenu = Menu.buildFromTemplate([
        { label: '退出',type: 'radio', click:()=>{
            app.quit()
            }},
        { label: 'Item2', type: 'radio' }
    ])

    // Make a change to the context menu
    contextMenu.items[1].checked = false

    // Call this again for Linux because we modified the context menu
    appIcon.setContextMenu(contextMenu)
}

module.exports={
    initTray
}

7、可能会遇到的安全策略警告的关闭 在html中添加meta

<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' ;">

四、打包 ⭕️

打包可能会遇到的问题包括打包后窗口loadURL的设置,打包的配置(包括名称、图标、包含文件等),多窗口下的页面路径与路由的配置,打包后网络请求的补全等问题,接下来我们就走一遍完整流程,并且对这些问题进行讲解❗️。

先看一下我打包后的文件结构,dist是文件vite打包过后的文件,dist_electron是electron打包后的文件,里边包含安装exe文件登,dist_electron这个目录是在package.json中配置打包参数directories设置的。

如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

1、使用electron-builder进行打包

npm i electron-builder -D ✅

配置打包命令('app:dist'):

打包electron需要先打包vite,将vue文件打包完成后,再进行electron的打包。为了方便我自定了命令electron:dist,可以顺序执行vite打包、electron打包

"scripts": {
  "serve": "concurrently "npm run dev" "npm run start" ",
  "dev": "vite",
  "build": "vue-tsc && vite build",
  "preview": "vite preview",
  "start": "nodemon --exec electron . --watch ./ --ext .js,.html,.scss,.vue,.ts,.css",
  "app:dir": "electron-builder --dir",
  "app:dist": "electron-builder",
  "electron:dist": "vue-tsc && vite build && electron-builder"
},

2、package.json中配置打包参数

"build": {
  "appId": "com.clound.app", // app名称
  "productName": "clound", // 项目桌面名称 生成的安装文件名
  "win": {
    "icon": "./public/icon.ico", // 配置的安装后桌面上的图标
    "artifactName": "${productName}.${ext}"
  },
  "directories": {
    "output": "dist_electron" // 打包后输出文件夹名称
  },
  "files": ["./dist","./package.json","./src/electron-main"], // 打包时包含的文件 一定要包含dist 即vite打包后文件
  "nsis": {
    "oneClick": false, // 是否一键安装,不可更改目录等选项,默认为true
    "allowElevation": true, // 是否允许权限提升。如果为false,则用户必须使用提升的权限重新启动安装程序。
    "allowToChangeInstallationDirectory": true, // 是否允许更改安装路径
    "createDesktopShortcut": true,   // 是否创建桌面图标
    "createStartMenuShortcut": true,   // 创建开始菜单图标
    "runAfterFinish": true,   // 安装完成请求运行
    "installerIcon": "./public/icon.ico",    // 安装包图标
    "uninstallerIcon": "./public/icon.ico",    //卸载程序图标
    "installerHeaderIcon": "./public/icon.ico",    // 安装时头部图标
    "shortcutName": "dclound"     // 桌面图标名称
  }
},

⚡️可能会遇到的问题:

配上我的ico资源路径以及vite路径配置,这里的ico资源配置路径可能会遇到问题,并且ico不能小于256*256 如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架 vite.config.js中的打包路径配置

base: env.VITE_BASE_PATH,

production:VITE_BASE_PATH='./'

development:VITE_BASE_PATH='/'

3、electron多窗口loadURL的处理

在开发环境下我们可以直接使用项目启动端口以及路由,但是打包后我们会有多个窗口,加载文件该加载哪里呢,路由又该如何使用呢?

3.1 设置文件路径

其实打包后我们窗口加载的地址应该是我们vite打包后的文件即dist所在文件位置,在我的config.js中已经定义好了vite打包后dist文件的路径,注意看dist文件相对于我们当前config.js的文职,决定了../../../dist/index.html拼接的层级⚠️ 如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

const ACHEME = "file";
const path=require('path')
// const LOAD_URL = `${ACHEME}://${ __dirname}/index.html`;
const LOAD_URL=`file://${path.join(__dirname, '../../../dist/index.html')}`
module.exports={
    ACHEME,
    LOAD_URL
}

窗口中配置loadUrl(isDev是我们之前提到过的判断开发环境生产环境的工具):

const mainWinURL =
    isDev ? `http://localhost:4000/#/`
        : `${LOAD_URL}#`;
       
// 窗口实例
win.loadURL(mainWinURL)

3.2 多窗口打包后如何使用路由加载不同页面

先来看一下我们打包前后三个窗口的loadUrl,可以看到打包后我们可以使用文件路径拼接#路由方式找到页面,但是前提是我们Vue的路由设置必须是hash模式才会生效

// 加载窗口
const loadWinURL =
    isDev? `http://localhost:4000/#/load`
        : `${LOAD_URL}#load`;
// 主窗口
const mainWinURL =
    isDev ? `http://localhost:4000/#/`
        : `${LOAD_URL}#`;
// 登录窗口
const loginWinURL =
    isDev? `http://localhost:4000/#/login`
        : `${LOAD_URL}#login`;

同时要注意不同环境的baseUrl的设置,否则会出现白屏。 如何从零开始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端开发框架

4、打包后白屏,source中查看 无法找到资源情况

首先要查看package.json中build打包参数 "files": ["./dist","./package.json","./src/electron-main"],是否包含了dist即vite打包后的文件,只有配置了包含,electron打包时才会一起加载打包。 其次如果使用了hash路由加载了不同页面要看baseUrl是否设置正确:

生产环境下 VITE_BASE_PATH='./'
const router = createRouter({
    history: createWebHashHistory(import.meta.env.VITE_BASE_PATH),
    routes: constantRouterMap as RouteRecordRaw[],
    scrollBehavior: () => ({ left: 0, top: 0 })
})

5、打包后网络请求file://,并不是想要的http等请求

打包后需要补全我们请求地址baseUrl,如生产环境下这样补全,可以查看上方vue框架搭建时我的配置。

const service: AxiosInstance = axios.create({
  baseURL: 'https://XXXX/api', // api 的 base_url
  timeout: config.request_timeout // 请求超时时间
})

使用https可能会遇到的安全策略警告的关闭 在html中我们之前的meta中新增http://* https://*

<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline'  http://*  https://*;">

五、写在最后 ✍️

这篇文章应该是目前我在AdminJS写的最长的一篇文章了,写了几个小时,接近万字,一开始只是想总结一下开发经验,但是想起自己在开发时遇到的坑点,希望能够详细的总结一篇文章,让后来的同学少开几个浏览器查问题❌, 本文讲解了整个框架的思路以及遇到的问题,但是仅仅依靠文章很多问题并不能详细阐述,建议大家可以拉一下代码看看,这样可能会更好的理解,同时如果有问题欢迎大家来讨论❤️‍。

开启AdminJS成长之旅!这是我参与「AdminJS日新计划 · 2 月更文挑战」的第 2 天,点击查看活动详情