博客 / 詳情

返回

記一次 electron-vue 項目開發經驗

最近公司讓我開發一個桌面報警器,以解決瀏覽器頁面關閉無法播放報警聲音的問題。

接到這個項目,自然的選擇了 electron-vue 進行開發(我們公司使用的 vue)

現在有時間了,對項目中遇到的問題進行一個總結。

一、項目搭建 & 打包

項目搭建比較簡單,直接使用 electron-vue 的官方模板就可以生成項目,需要安裝 vue-cli 命令行工具。

npm install -g vue-cli // 需要安裝 vue-cli 腳手架
vue init simulatedgreg/electron-vue project-name // 使用 electron-vue 官方模板生成項目
npm install // 安裝依賴
npm run dev // 啓動項目

項目打包也比較簡單,可能也是因為我的項目本身不復雜吧。普通打包執行 npm run build 即可,如果要打包成免安裝文件,執行 npm run build:dir,非常方便!

npm run build // 打包成可執行文件
npm run build:dir // 打包成免安裝文件

二、狀態管理

因為 electron 每個網頁都在自己的渲染進程(renderer process)中運行,所以如果要在多個渲染進程間共享狀態,就不能直接使用 vuex 了。

vuex-electron 這個開源庫為我們提供了,在多個進程間共享狀態的方案(包括主進程)。

如果需要在多個進程間共享狀態,需要使用 createSharedMutations 中間件。

// store.js 文件
import Vue from "vue"
import Vuex from "vuex"
 
import { createPersistedState, createSharedMutations } from "vuex-electron"
 
Vue.use(Vuex)
 
export default new Vuex.Store({
  // ...
  plugins: [
    createPersistedState(),
    createSharedMutations() // 用於多個進程共享狀態,包括主進程
  ],
  // ...
})

並在主進程中引入 store 文件。這裏有點坑,最開始的時候我不知道要在 main.js 中引入 store 文件,結果狀態一直無法更新,又沒有任何報錯,調試了一下午😓

// main.js 文件
import './path/to/your/store' // 需要在主進程引入 store ,否則狀態無法更新

另外,使用 createSharedMutations 中間件,必須使用 dispatch 或 mapActions 更新狀態,不能使用 commit 。

閲讀 vuex-electron 的源代碼,發現渲染進程對 dispatch 進行了重寫,dispatch 只是通知主進程,而不實際更新 store,主進程收到 action 之後,立即更新自己的 store,主進程 store 更新成功之後,會通知所有的渲染進程,這個時候渲染進程才調用 originalCommit 更新自己的 store。

rendererProcessLogic() {
    // Connect renderer to main process
    this.connect()

    // Save original Vuex methods
    this.store.originalCommit = this.store.commit
    this.store.originalDispatch = this.store.dispatch

    // Don't use commit in renderer outside of actions
    this.store.commit = () => {
        throw new Error(`[Vuex Electron] Please, don't use direct commit's, use dispatch instead of this.`)
    }

    // Forward dispatch to main process
    this.store.dispatch = (type, payload) => {
        // 只是通知主進程,沒有更新 store
        this.notifyMain({ type, payload })
    }

    // Subscribe on changes from main process and apply them
    this.onNotifyRenderers((event, { type, payload }) => {
        // 渲染進程真正更新自己的 store
        this.store.originalCommit(type, payload)
    })
}

// ... 省略其他代碼

mainProcessLogic() {
    const connections = {}

    // Save new connection
    this.onConnect((event) => {
        const win = event.sender
        const winId = win.id

        connections[winId] = win

        // Remove connection when window is closed
        win.on("destroyed", () => {
        delete connections[winId]
        })
    })

    // Subscribe on changes from renderer processes
    this.onNotifyMain((event, { type, payload }) => {
        // 主進程更新了自己的 store
        this.store.dispatch(type, payload)
    })

    // Subscribe on changes from Vuex store
    this.store.subscribe((mutation) => {
        const { type, payload } = mutation

        // 主進程更新成功之後,通知所有渲染進程
        this.notifyRenderers(connections, { type, payload })
    })
}

注意,渲染進程真正更新 store 用的 originalCommit 方法,而不是 originalDispatch 方法,其實 originalDispatch 只是個代理,每一個 mutations 都需要寫一個同名的 actions 方法,接收相同的參數,如下面的官方樣例:

import Vue from "vue"
import Vuex from "vuex"

import { createPersistedState, createSharedMutations } from "vuex-electron"

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    count: 0
  },

  actions: {
    increment(store) {
      // 按照推理,這裏的 commit 其實不起作用,不是必須
      // 關鍵是名稱相同
      store.commit("increment")
    },
    decrement(store) {
      store.commit("decrement")
    }
  },

  mutations: {
    increment(state) {
      state.count++
    },
    decrement(state) {
      state.count--
    }
  },

  plugins: [createPersistedState(), createSharedMutations()],
  strict: process.env.NODE_ENV !== "production"
})

事實上,如果應用很簡單,比如我的項目只有一個窗口,就不存在共享狀態的問題,所以完全可以不用 createSharedMutations 中間件,也不用在 main.js 中引入 store 文件,store 所有用法就跟 vuex 一樣了。

三、日誌

日誌我採用的是 electron-log,也可以用 log4js

在主進程中使用 electron-log 很簡單,直接引入,調用 info 等方法即可。
electron-log 提供了 error, warn, info, verbose, debug, silly 六種級別的日誌,默認都是開啓。

import log from 'electron-log';
 
log.info('client 啓動成功');
log.error('主進程出錯');

在渲染進程使用 electron-log,可以覆蓋 console.log 等方法,這樣就不用到處引入 electron-log 了,需要寫日誌的地方直接使用 console.log 等方法即可。

import log from 'electron-log';
 
 // 覆蓋 console 的 log、error、debug 三個方法
console.log = log.log;
Object.assign(console, {
  error: log.error,
  debug: log.debug,
});

// 之後,就可以直接使用 console 收集日誌
console.error('渲染進程出錯')

electron-log 默認會打印到 console 控制枱,並寫入到本地文件,本地文件路徑如下:

  • on Linux: ~/.config/{app name}/logs/{process type}.log
  • on macOS: ~/Library/Logs/{app name}/{process type}.log
  • on Windows: %USERPROFILE%\AppData\Roaming{app name}\logs{process type}.log

如果使用 log4js 的話,配置相對複雜一點,需要注意的是文件不能直接寫到當前目錄,而是要使用 app.getPath('logs') 獲取應用程序日誌文件夾路徑,否則打包之後無法生成日誌文件。例如:

import log4js from 'log4js'
 
// 注意:這裏必須使用 app.getPath('logs') 獲取日誌文件夾路徑
log4js.configure({
  appenders: { cheese: { type: 'file', filename: app.getPath('logs') + '/cheese.log' } },
  categories: { default: { appenders: ['cheese'], level: 'error' } }
})
 
const logger = log4js.getLogger('cheese')
logger.trace('Entering cheese testing')
logger.debug('Got cheese.')
logger.info('Cheese is Comté.')
logger.warn('Cheese is quite smelly.')
logger.error('Cheese is too ripe!')
logger.fatal('Cheese was breeding ground for listeria.')

四、其他問題

1.修改系統托盤圖標,下面代碼參考了:https://juejin.im/post/6844903872905871373

let tray;
function createTray() {
  const iconUrl = path.join(__static, '/app-icon.png');
  const appIcon = nativeImage.createFromPath(iconUrl);
  tray = new Tray(appIcon);
 
  const contextMenu = Menu.buildFromTemplate([
    {
      label: '顯示主界面',
      click: () => {
        if (mainWindow) {
          mainWindow.show();
        }
      },
    },
    { label: '退出程序', role: 'quit' },
  ]);
 
  const appName = app.getName();
  tray.setToolTip(appName);
  tray.setContextMenu(contextMenu);
 
  let timer;
  let count = 0;
  ipcMain.on('newMessage', () => {
    // 圖標閃爍
    timer = setInterval(() => {
      count += 1;
      if (count % 2 === 0) {
        tray.setImage(appIcon);
      } else {
        // 創建一個空的 nativeImage 實例
        tray.setImage(nativeImage.createEmpty());
      }
    }, 500);
      tray.setToolTip('您有一條新消息');
  });
 
  tray.on('click', () => {
    if (mainWindow) {
      mainWindow.show();
      if (timer) {
        clearInterval(timer);
        tray.setImage(appIcon);
        tray.setToolTip(appName);
        timer = undefined;
        count = 0;
      }
    }
  });
}

2.播放聲音

audio = new Audio('static/alarm.wav');
audio.play(); // 開始播放
audio.pause(); // 暫停

3.顯示通知消息

const notify = new Notification('標題', {
   tag: '唯一標識', // 相同 tag 只會顯示一個通知
   body: '描述信息',
   icon: '圖標地址',
   requireInteraction: true, // 要求用户有交互才關閉(實測無效)
   data, // 其他數據
});
 
// 通知消息被點擊事件
notify.onclick = () => {
   console.log(notify.data)
};

4.隱藏頂部菜單欄

import { Menu } from 'electron'
 
// 隱藏頂部菜單
 Menu.setApplicationMenu(null);

五、參考資料

  • electron 官方文檔:https://www.electronjs.org/docs
  • electron-vue 文檔:https://simulatedgreg.gitbooks.io/electron-vue/content/cn/
  • electron系統托盤及消息閃動提示:https://juejin.im/post/6844903872905871373
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.