如何從零開始搭建一套Electron+Vue3+Vite2+Ts的桌面客户端開發框架

語言: CN / TW / HK

theme: channing-cyan

216c713fd6d69694cedfd40fc9b1124.png 7a9b9b76901a20597b7edfaa919807f.png

一、前言⚡️

☕️最近接到開發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打包後文件。 image.png

1.2 整體思路

✨該框架目前只搭建了三個窗口,我們希望客户端點擊客户端過後,先彈出加載界面窗口,可以進行讀取文件配置進度等操作,最重要的是可以獲取我們的緩存,判斷用户的登錄是否是生效狀態,然後決定展示登錄窗口,還是主界面窗口。文件目錄為三個窗口,分別對應主界面,登錄,加載頁面,我們electron的loadrUrl採用多窗口的Hash路由模式對應配置,因此我們的路由文件也只有三個大模塊,LayOut是我們主頁面的組件,跟後台系統一樣,所有的主界面下的路由菜單頁面都將在LayOut中渲染,而我們三個窗口也分別對應這三個路由

image.png 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| 持久化存儲 | |nodemon| electron窗口熱更新 | |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頁面。

1669714376532(1).jpg image.png

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中添加代碼:

js "baseUrl": ".", "paths": { "@/*": ["src/*"] } image.png 配置完成後就可以愉快的使用代碼提示了:

image.png

2、配置Scss及全局主題樣式方案

2.1安裝sass及sass-loader

npm install sass sass-loader -D ✅

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

image.png

(2)新建var.scss放置全局變量,設置兩套變量,為接下來配置全局主題樣式做準備

image.png

(3)新建clear.scss進行全局樣式的清除

可以自行查找相關css文件,這裏就不再粘貼

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

image.png

在main.ts中引入index.scss,並且可以看到頁面中的h1標題的樣式已經生效。

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

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

setupAll() ```

image.png

2.1 配置全局主題色⭐️

配置全局主題色的方案有非常多,包括link標籤動態引入,CSS變量+類名切換CSS變量+動態propertySCSS + mixin + 類名切換等方案,根據我們的需求,由於本項目只進行暗黑、白亮模式的切換,所以選擇CSS變量+類名切換方案,同時參考vue-element-plus-admin主題色方案,我們預留CSS變量+動態property方案進行局部主題色的修改。更全主題配置可以參考文章✈️'前端主題切換方案'

(1)CSS變量+類名切換進行暗黑、白亮模式的切換

7932366654114e048419e3e679e7c6e7_tplv-k3u1fbpfcp-zoom-in-crop-mark_4536_0_0_0.webp 在上一部分的styles目錄下我們提前新建了全局變量文件var.scss,設置了兩套顏色,白亮主題以及暗黑主題下的顏色變量。 :root { --theme-bg-color: #fff; --btn-color: #409eff; } .dark{ --theme-bg-color:#293146; --btn-color: #293146; } 那麼如何在頁面中使用呢?只需要將變量名加入需要切換顏色的地方,在點擊切換事件時,使用document.body.className設置class名稱,從而將變量值切換為相關class名稱下的變量。 ```

```

(2)CSS變量+動態property進行局部顏色的切換

主要用來實現頭部,菜單欄等顏色的切,可以的參考vue-element-plus-admin的主題設置。在此處我們簡單使用,代碼與上部分CSS變量+類名切換進行暗黑、白亮模式的切換的代碼是一樣的,只不過進行切換時,我們使用 如下方法,直接設置某個變量顏色,從而達到局部顏色切換的效果。 const handleChangeTheme=()=>{ document.documentElement.style.setProperty('--theme-bg-color','red') // bol.value=!bol.value // if(bol.value){ // document.body.className = 'dark'; // }else{ // document.body.className = ''; // } }

3、配置狀態管理庫Pinia

☘️ Pinia 是 Vue3最新的以及最流行的狀態管理庫,它允許跨組件/頁面共享狀態。 如果熟悉 Composition API,可以認為他是一個簡單的 export const state = reactive({}),接下來我們就安裝Pinia,具體使用方法可以參考本人之前寫的一篇文章✈️:Vue3+Ts+Vite2+Pinia 搭建開發腳手架

npm install pinia ✅

3.1 在src下新建store文件夾,store下新建index.ts,用來創建Pinia實例

image.png ``` import type { App } from 'vue' import { createPinia } from 'pinia'

const store = createPinia()

export const setupStore = (app: App) => { 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 頁面中使用

js 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

js 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='/'

image.png ``` 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) => { 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: 'http://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) => { 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: (option: any) => { return request({ method: 'get', ...option }) as unknown as T }, post: (option: any) => { return request({ method: 'post', ...option }) as unknown as T }, delete: (option: any) => { return request({ method: 'delete', ...option }) as unknown as T }, put: (option: any) => { return request({ method: 'put', ...option }) as unknown as T } } ```

6.4 請求統一管理

在src下新建api文件夾對請求進行管理,新建login文件夾 image.png 新建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: 'http://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前綴自動註冊,因此是個坑。

image.png ❗️ 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聲明

image.png 根目錄下新建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中: image.png

三、集成Electron開發環境

1、文件思路及集成Electron

首先我們將electron的主進程文件等都放在src下的electron-main文件夾下,同時將我們之前寫的vue頁面即渲染進程放到render下,electron-main下的main.js文件主要進行主進程的配置,modules文件下的兩個文件夾管理主進程與渲染進程的通信,shortcut進行快捷鍵的操作,tray文件夾進行托盤的設置,until放置讀寫文件工具,windows管理不同的窗口,我們的需求是能夠有一個初始化加載頁面,一個登錄頁面,一個主頁面,因此需要管理三個窗口。

image.png

image.png

安裝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、窗口的設置 ⭐️

思路:我們希望客户端點擊過後,先彈出加載界面,可以進行讀取文件配置進度等操作,最重要的是可以獲取我們的緩存,判斷用户的登錄是否是生效狀態,然後決定展示登錄窗口,還是主界面窗口。文件目錄為三個窗口,分別對應主界面,登錄,加載頁面。

image.png

3.1 config.js

config.js主要是配置窗口的打包環境的loadeUrl,開發環境與打包環境是不同的,在打包模塊我會進行講解。 js 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進行窗口狀態的記憶,比如用户進行了窗口的拖拽、尺寸大小調整,該插件可以幫助我們進行記憶,下次打開時是調整後的狀態,使用方法: ```js //客户端尺寸位置記憶插件 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`來進行開發環境還是打包環境的判斷。

js 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、主進程與渲染進程的通信 ⭐

image.png

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頁面中使用:

```

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方法:

js contextBridge.exposeInMainWorld('electronAPI', { setTitle, openByBrowser, setScreen, ipcRenderer: { ...ipcRenderer, on: ipcRenderer.on.bind(ipcRenderer) } }) vue頁面中:

js 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中就是桌面底部任務欄右側的小圖標點擊右鍵可以進行操作

image.png 如何實現呢❓
在tray文件夾下新建index.js,icon可以設置展示圖標,setToolTip可以設置鼠標放上去的文字提示,contextMenu是右擊出現的操作菜單,我們這裏可以操作窗口的退出等等操作。

image.png

```js 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設置的。

image.png

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 image.png 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拼接的層級⚠️ image.png 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的設置,否則會出現白屏。 image.png

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: 'http://XXXX/api', // api 的 base_url timeout: config.request_timeout // 請求超時時間 }) 使用https可能會遇到的安全策略警告的關閉 在html中我們之前的meta中新增http://* http://* <meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' http://* http://*;">

五、寫在最後 ✍️

這篇文章應該是目前我在掘金寫的最長的一篇文章了,寫了幾個小時,接近萬字,一開始只是想總結一下開發經驗,但是想起自己在開發時遇到的坑點,希望能夠詳細的總結一篇文章,讓後來的同學少開幾個瀏覽器查問題❌, 本文講解了整個框架的思路以及遇到的問題,但是僅僅依靠文章很多問題並不能詳細闡述,建議大家可以拉一下代碼看看,這樣可能會更好的理解,同時如果有問題歡迎大家來討論❤️‍。

開啟掘金成長之旅!這是我參與「掘金日新計劃 · 2 月更文挑戰」的第 2 天,點擊查看活動詳情