electron 基礎

2022-09-26 21:02:15

electron 基礎

前文我們快速的用了一下 electron。本篇將進一步介紹其基礎知識點,例如:生命週期、主程序和渲染程序通訊、contextBridge、預載入(禁用node整合)、優雅的顯示視窗、父子視窗、儲存並恢復 electron 視窗、、右鍵上下文資訊、右鍵選單、選單與主程序通訊、選中文字執行 js 程式碼、托盤、nativeImage、截圖等等。

Tip:為圖方便,繼續之前的環境進行操作。

第一個程式

下面這段程式碼會開啟一個原生視窗,視窗裡面會載入 spug 的一個系統:

// main.js 主程序
const { app, BrowserWindow} = require('electron')

function createWindow() {
    const mainWindow = new BrowserWindow({
        width: 1000,
        height: 800,

    })
    mainWindow.loadURL('https://demo.spug.cc/')
    // 或本地
    // mainWindow.loadFile('article.html')
}

app.whenReady().then(createWindow)

這裡用到兩個模組:

  • app - 控制應用程式的事件生命週期
  • BrowserWindow - 建立並控制瀏覽器視窗

whenReady - 返回 Promise。當 Electron 初始化完成。某些API只能在此事件發生後使用。

nodemon

前面我們用了 electron-reloader 來做熱載入,需要在程式碼中寫一段程式碼,感覺不是很好。我們可以使用 nodemon 來達到同樣的效果。

npmjs 中有介紹:nodemon還可以用於執行和監視其他程式。

首先安裝:npm i -D nodemon,然後在 package.json 中增加執行命令("start": "nodemon -e js,html --exec electron .",)即可。

:如果是 "start": "nodemon --exec electron .",,當你修改 html 檔案時,不會自動編譯,因為預設情況下,nodemon查詢擴充套件名為.js、.mjs、.coffee、.litcoffee和.json的檔案。

主程序和渲染程序

從這裡我們可以看出:

  • 主程序只有一個
  • 主程序和渲染程序可通過 IPC 方式通訊。
  • 主執行緒和渲染程序可以使用 node
  • 主執行緒是核心。因為只有一個

Content-Security-Policy

上面我們在主程序中載入了本地 article.html

mainWindow.loadFile('article.html')
// 開啟偵錯
mainWindow.webContents.openDevTools()

執行後發現使用者端控制檯有如下資訊:

// Electron安全警告(不安全的內容安全策略)
Electron Security Warning (Insecure Content-Security-Policy) This renderer process has either no Content Security
  Policy set or a policy with "unsafe-eval" enabled. This exposes users of
  this app to unnecessary security risks.

For more information and help, consult
https://electronjs.org/docs/tutorial/security.
This warning will not show up
once the app is packaged.

根據資訊提示來到 https://electronjs.org/docs/tutorial/security,是一個關於安全的介面。擷取其中一小段文案:

當使用 Electron 時,很重要的一點是要理解 Electron 不是一個 Web 瀏覽器。 它允許您使用熟悉的 Web 技術構建功能豐富的桌面應用程式,但是您的程式碼具有更強大的功能。 JavaScript 可以存取檔案系統,使用者 shell 等。 這允許您構建更高質量的本機應用程式,但是內在的安全風險會隨著授予您的程式碼的額外權力而增加。

考慮到這一點,請注意,展示任意來自不受信任源的內容都將會帶來嚴重的安全風險,而這種風險Electron也沒打算處理。 事實上,最流行的 Electron 應用程式(Atom,Slack,Visual Studio Code 等) 主要顯示本地內容(即使有遠端內容也是無 Node 的、受信任的、安全的內容) - 如果您的應用程式要執行線上的原始碼,那麼您需要確保原始碼不是惡意的。

頁面羅列了 17 條安全建議,其中就有Content Security Policy(內容安全策略)

給 article.html新增如下程式碼,警告消失了。

// default-src代表預設規則,'self'表示限制所有的外部資源,只允許當前域名載入資源。
<meta http-equiv="Content-Security-Policy" content="script-src 'self'">

倘若給 article.html 中寫頁內指令碼,會報錯如下,改為<script src="./article.js"></script>即可:

article.html:13 Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-FUXSTb1xGfrsnwlIAuF8vFTVUW/HcyPHjQ+19tHS6/Q='), or a nonce ('nonce-...') is required to enable inline execution.

js指令碼中使用node

article.html 中引入 article.js,其中使用 node 中的 process 模組:

// article.js
console.log('hello')
console.log(process)

使用者端會報錯:

Uncaught ReferenceError: process is not defined
    at article.js:2:13

前文我們已經知道,需要開啟node整合(nodeIntegration),並且需要關閉上下文隔離(contextIsolation),然後就可以在渲染程序中使用 node。

const mainWindow = new BrowserWindow({
        width: 1000,
        height: 800,
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
        },
    })

現在控制檯就不在報錯:

hello
// 物件
>process

禁用Node.js整合

我們可以在前端 javascript 中使用 node,也就可以隨意寫檔案到使用者端。

例如在 article.js 中增加如下程式碼,每次執行,就會在當前目錄下生成一個檔案。

const fs = require('fs')
fs.writeFile('xss.txt', '我是一段惡意程式碼', (err) => {
  if (err) throw err;
  console.log('當前目錄生成 xss.txt 檔案');
});

挺可怕!

在 electron 安全 中不推薦使用 nodeIntegration,推薦使用 preload的方式(下文會講到)。擷取程式碼片段如下:

// main.js (Main Process)
// 不推薦
const mainWindow = new BrowserWindow({
  webPreferences: {
    contextIsolation: false,
    nodeIntegration: true,
    nodeIntegrationInWorker: true
  }
})

mainWindow.loadURL('https://example.com')
// main.js (Main Process)
// 推薦
const mainWindow = new BrowserWindow({
  webPreferences: {
    preload: path.join(app.getAppPath(), 'preload.js')
  }
})

mainWindow.loadURL('https://example.com')
<!-- 不推薦 -->
<webview nodeIntegration src="page.html"></webview>

<!-- 推薦 -->
<webview src="page.html"></webview>

當禁用Node.js整合時,你依然可以暴露API給你的站點以使用Node.js的模組功能或特性。 預載入指令碼依然可以使用require等Node.js特性, 以使開發者可以通過contextBridge API向遠端載入的內容公開自定義API。—— electron 安全

生命週期

  • ready 當 Electron 完成初始化時,發出一次。
  • dom-ready dom準備好了,可以選擇介面上的元素
  • did-finish-load 官網解釋看不懂,只知道發生在 dom-ready 後面。
  • window-all-closed 所有視窗都關閉時觸發。如果選擇監聽,需要自己決定是否退出應用。如果沒有主動退出(app.quit()),則 before-quit/ will-quit/quit 事件不會發生。
  • before-quit 在程式關閉視窗前發訊號。呼叫 event.preventDefault() 將阻止終止應用程式的預設行為。
  • will-quit 當所有視窗被關閉後觸發,同時應用程式將退出。呼叫 event.preventDefault() 將阻止終止應用程式的預設行為。
  • quit 在應用程式退出時發出。
  • close 在視窗關閉時觸發。當你接收到這個事件的時候, 你應當移除相應視窗的參照物件,避免再次使用它.

Tip:dom-ready 和 did-finish-load 在下文(webContents)有詳細試驗。

3個 quit 有些繞,可以簡單認為 electron 將 quit 分成 3 步,而且如果監聽了 window-all-closed 則會對這三個 quit 造成一些影響

下面我們通過範例說明。main.js 程式碼如下:

const path = require('path')
const { app, BrowserWindow, ipcMain } = require('electron')

function createWindow() {
    let mainWindow = new BrowserWindow({
        width: 1000,
        height: 800,
        webPreferences: {
            preload: path.join(app.getAppPath(), './preload.js')
        },
    })
    mainWindow.loadFile('article.html')
    mainWindow.webContents.openDevTools()

    mainWindow.on('close', () => {
        console.log('close - 視窗關閉,回收視窗參照')
        mainWindow = null
    })
    // webContents - 渲染以及控制 web 頁面
    mainWindow.webContents.on('did-finish-load', ()=>{
        console.log('did-finish-load - 導航完成時觸發,即索引標籤的旋轉器將停止旋轉,並指派onload事件後')
    })

    mainWindow.webContents.on('dom-ready', ()=>{
        console.log('dom-ready - dom準備好了,可以選擇介面上的元素')
    })
}

app.on('window-all-closed', function () {
    console.log('window-all-closed')
    app.quit() // {1}
})

app.on('quit', function (event) {
    console.log('quit')
})
app.on('before-quit', function (event) {
    // event.preventDefault() {2}
    console.log('before-quit')
})
app.on('will-quit', function (event) {
    // event.preventDefault()
    console.log('will-quit')
})

app.on('ready', () => {
    console.log('ready - electron 初始化完成時觸發')
    createWindow()
})

終端輸入:

ready - electron 初始化完成時觸發
dom-ready - dom準備好了,可以選擇介面上的元素
did-finish-load - 導航完成時觸發,即索引標籤的旋轉器將停止旋轉,並指派onload事件後
close - 視窗關閉,回收視窗參照
window-all-closed
before-quit
will-quit
quit

發生順序是:ready -> dom-ready -> did-finish-load

如果開啟 event.preventDefault()(行{2}),在本地執行時,關閉應用,will-quitquit 將不會觸發。

:如果將app.quit()(行{1})註釋,也就是監聽 window-all-closed 但不主動關閉應用,則 quit 相關的事件不會觸發。只會輸入如下結果:

ready - electron 初始化完成時觸發
dom-ready - dom準備好了,可以選擇介面上的元素
did-finish-load - 導航完成時觸發,即索引標籤的旋轉器將停止旋轉,並指派onload事件後
close - 視窗關閉,回收視窗參照
window-all-closed

最後說一下 activate,當應用被啟用時發出。 各種操作都可以觸發此事件, 例如首次啟動應用程式、嘗試在應用程式已執行時或單擊應用程式的塢站或工作列圖示時重新啟用它。

:activate 測試未達到預期。或許筆者的win7環境不對。

electron-quick-start中有如下這段程式碼:

app.whenReady().then(() => {
  createWindow()

  app.on('activate', function () {
    // 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) createWindow()
  })
})

這段程式碼意思是:在 macOs 中,沒有其他視窗開啟,點選 dock icon 則重新建立視窗。

參考範例,我們也加上:

app.whenReady().then(() => {
  createWindow()
    // 沒有達到預期
  app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') app.quit()
})

預載入(preload)

前面提到,在 electron 安全 中不推薦使用 nodeIntegration,推薦使用 preload的方式。我們用一下:

在入口檔案中設定 preload

$ git diff main.js
+const path = require('path')
 const { app, BrowserWindow } = require('electron')

 function createWindow() {
         width: 1000,
         height: 800,
         webPreferences: {
-            nodeIntegration: true,
-            contextIsolation: false,
+            // nodeIntegration: true,
+            // contextIsolation: false,
+            // app.getAppPath() - 當前應用程式目錄。
+            preload: path.join(app.getAppPath(), 'preload.js')
         },
     })

在專案根目錄下新建 preload.js,寫一句即可:console.log('process.platform', process.platform)

使用者端則會輸出:process.platform win32

現在我們就可以在 preload.js 中使用 node。

contextBridge

通過 preload 中我們可以在關閉node整合的情況下使用 node。

如果需要將 preload 中的屬性或方法傳給渲染程序,我們可以使用 contextBridge。

contextBridge - 在隔離的上下文中建立一個安全的、雙向的、同步的橋樑。

需求:將 preload 中的一個資料傳給渲染程序。

實現如下:

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

/*
contextBridge.exposeInMainWorld(apiKey, api)
    - apiKey string - 將 API 注入到 視窗 的鍵。 API 將可通過 window[apiKey] 存取。
    - api any - 你的 API可以是什麼樣的以及它是如何工作的相關資訊如下。
*/
contextBridge.exposeInMainWorld(
  'electron',
  {
      platform: process.platform
  }
)
// article.js
// render: platform = win32
console.log('render: platform =', window.electron.platform)

通過 contextBridge.exposeInMainWorld 將資料匯出,然後在渲染程序中通過 window 來獲取資料。使用者端成功輸出:render: platform = win32

主程序和渲染程序通訊

前文我們已經學過主執行緒和渲染程序通訊,其中主要使用 ipcMainipcRenderer,我們將其加入 preload 中。

主程序中註冊事件:

// icp 通訊
ipcMain.on('icp-eventA', (evt, ...args) => {
    // icp-eventA。args= [ 'pjl' ]
    console.log('icp-eventA。args=', args)
})

preload.js 中觸發:

ipcRenderer.send('icp-eventA', 'pjl')

渲染程序如果需要傳遞資料給主執行緒,可以將上面範例改為點選時觸發即可,如果需要取得主執行緒的返回資料,新增 return 即可。

Tip: 觸發如果改用 invoke 觸發,報錯如下:

// Error occurred in handler for 'icp-eventA': No handler registered for 'icp-eventA'
ipcRenderer.invoke('icp-eventA', 'pjl2')

:通過ipcMain.on 和 ipcRenderer.send的方式,比如 icp-eventA 事件返回資料,而在 preload 中需要接收這個資料,或許就有麻煩,比如不能正常接收該資料,只能拿到 undefined,本文 clipboard 中就遇到了。

handle

關於註冊,除了 ipcMain.on 還有 ipcMain.handle,支援非同步,觸發的方法是 ipcRenderer.invoke。用法如下:

// Main Process
ipcMain.handle('my-invokable-ipc', async (event, ...args) => {
  const result = await somePromise(...args)
  return result
})

// Renderer Process
async () => {
  const result = await ipcRenderer.invoke('my-invokable-ipc', arg1, arg2)
  // ...
}

渲染程序註冊

上面我們在主程序中註冊事件,渲染程序觸發。能否反過來,也就是渲染程序註冊、主執行緒觸發?

發現 ipcRenderer.on(channel, listener) 這個aip,說明可以在渲染程序中註冊。但是 ipcMain 卻沒有觸發的方法。

:沒有找到如何在主程序中觸發,暫時先不管了。

優雅地顯示視窗

electron 開啟應用後,由於請求的網頁比較慢,會明顯白屏幾秒鐘,感覺很不好。

let mainWindow = new BrowserWindow({
    width: 1000,
    height: 800,
})
mainWindow.loadURL('https://github.com')

關於優雅地顯示視窗,官網提供兩種方式:

// 使用 ready-to-show 事件
const { BrowserWindow } = require('electron')
const win = new BrowserWindow({ show: false })
win.once('ready-to-show', () => {
  win.show()
})
// 設定 backgroundColor 屬性
const { BrowserWindow } = require('electron')

const win = new BrowserWindow({ backgroundColor: '#2e2c29' })
win.loadURL('https://github.com')

Tip:對於一個複雜的應用,ready-to-show 可能發出的太晚,會讓應用感覺緩慢。 在這種情況下,建議立刻顯示視窗,並使用接近應用程式背景的 backgroundColor

筆者使用 ready-to-show 優化一下:

let mainWindow = new BrowserWindow({
    width: 1000,
    height: 800,
    show: false,
})
mainWindow.loadURL('https://github.com')
mainWindow.once('ready-to-show', () => {
    mainWindow.show()
})

筆者等了5秒,應用啪的一下就出來了,github 也已經準備完畢。

如果讓我點選應用,需要過5秒以上應用才開啟,肯定認為應用是否哪裡出錯了!

BrowserWindow

父子視窗

前面我們只是開啟了一個視窗,其實可以開啟多個。比如下面程式碼就開啟了兩個視窗,他們互不相干,可以各自拖動:

let mainWindow = new BrowserWindow({
    width: 1000,
    height: 800,
    webPreferences: {
        preload: path.join(app.getAppPath(), './preload.js')
    },
})
mainWindow.loadFile('article.html')
// 第二個視窗
let secondWindow = new BrowserWindow({
    width: 600,
    height: 400,
})
secondWindow.loadURL('https://www.baidu.com')

可以通過 parent 屬性讓兩個視窗建立父子關係:

let secondWindow = new BrowserWindow({
    width: 600,
    height: 400,
  + parent: mainWindow,
})

再次執行,兩個視窗好似沒有關係,拖動父視窗子視窗也不會跟隨,在筆者機器(win7)最大的不同是之前螢幕底部欄有兩個應用圖示,現在變成了一個。

增加一個模態屬性:

let secondWindow = new BrowserWindow({
    width: 600,
    height: 400,
    parent: mainWindow,
  + modal: true
})

現在子視窗可以拖動,而且只有關閉子視窗,才能觸碰到父視窗。

frame

前文已經使用過 frame,儘管不能再拖動,但仍舊可以通過滑鼠調整視窗打下

titleBarStyle

let mainWindow = new BrowserWindow({
    width: 1000,
    height: 800,
    // frame: false,
    titleBarStyle: 'hidden',
    titleBarOverlay: true,
})

titleBarStyle 配合 titleBarOverlay 在 windows 下會在應用右上方顯示三個系統按鈕:最小、最大、關閉。

electron-win-state

使用 electron-win-state 可以儲存並恢復 electron 視窗的大小和位置。下面我們嘗試一下:

首先安裝:

$ npm install electron-win-state
npm WARN [email protected] No description
npm WARN [email protected] No repository field.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: [email protected] (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for [email protected]: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})

+ [email protected]
added 28 packages from 10 contributors and audited 325 packages in 23.356s

37 packages are looking for funding
  run `npm fund` for details

found 1 moderate severity vulnerability
  run `npm audit fix` to fix them, or `npm audit` for details

修改入口檔案,核心程式碼如下:

// main.js
const WinState = require('electron-win-state').default
function createWindow() {
  const winState = new WinState({ 
      defaultWidth: 1000,
      defaultHeight: 800,
      // other winState options, see below
  })
  let mainWindow = new BrowserWindow({
      ...winState.winOptions,
      // 取消 width 和 height 的註釋會導致 electron-win-state 失效
      // width: 1000,
      // height: 800,
      webPreferences: {
          preload: path.join(app.getAppPath(), './preload.js')
      },
  })

  winState.manage(mainWindow)
  ...
}

現在我們嘗試調整 electron 的視窗大小以及位置,然後關閉,再次開啟應用,發現視窗位置和大小仍舊是關閉之前的狀態。

Tip:npmjs 中引入方式是 import,筆者這裡得使用 require,通過列印 require('electron-win-state') 輸出 { default: [class WinState] },於是知道得這麼寫:require('electron-win-state').default

:不要在 BrowserWindow 中設定 width、height,否則 electron-win-state 會失效。

webContents

webContents 是 BrowserWindow 物件的一個屬性,可以對視窗中的網頁做一些事情。

did-finish-load&dom-ready

比如在主程序中監聽視窗中網頁內容是否載入完畢。

我們通過主程序載入 article.html,在該 html 頁面載入一個較大(2000*2000)的圖片資源。再次啟動應用發現 did-finish-load 在終端很快輸出,但需要在過一段時間,發現圖片顯示的時候 did-finish-load 同時在終端輸出。

// main.js
mainWindow.loadFile('article.html')
mainWindow.webContents.on('did-finish-load', () => {
    console.log('did-finish-load')
})
mainWindow.webContents.on('dom-ready', () => {
    console.log('dom-ready - dom準備好了,可以選擇介面上的元素')
})
// article.html
<img src='http://placekitten.com/2000/2000'/>

Tip: PlaceKitten 是一個快速而簡單的服務,用於獲取小貓的照片,以便在設計或程式碼中用作預留位置。只要把你的圖片大小(寬度和高度)放在我們的URL之後,你就會得到一個預留位置。

右鍵上下文資訊

當你在頁面中右鍵時會觸發 context-menu 事件,並傳入兩個引數 event 和 params。

比如我們新增如下程式碼:

// main.js
mainWindow.webContents.on('context-menu', (event, params) => {
    console.log('event', event)
    console.log('params', params)
})

當我們點選圖片,其中 params.srcURL 就是圖片的資源路勁,我們可以在此基礎上實現右鍵儲存圖片到本地。

params {
  x: 284,
  y: 237,
  linkURL: '',
  linkText: '',
  pageURL: 'file:///E:/lirufen/small%20project/electron/electron-demo/article.html',
  frameURL: '',
  srcURL: 'http://placekitten.com/2000/2000',
  ...
}

Tip: 你自己通過瀏覽器存取百度(https://www.baidu.com/),右鍵該網頁中的圖片可以儲存,但如果通過 electron 方式開啟卻不能右鍵儲存圖片。就像這樣:

// main.js
let secondWindow = new BrowserWindow({
    width: 600,
    height: 400,
})
secondWindow.loadURL('https://www.baidu.com')

右鍵裡面中的圖片,卻不能儲存圖片到本地,需要自己去實現:

secondWindow.loadURL('https://www.baidu.com')
secondWindow.webContents.on('context-menu', (event, params) => {
    console.log('儲存圖片...')
})

executeJavaScript

可以通過 webContents.executeJavaScript 執行 js 程式碼。

需求:選中文字,右鍵後,alert 彈出選中的文字。

實現如下:

// main.js
mainWindow.webContents.on('context-menu', (event, params) => {
    mainWindow.webContents.executeJavaScript(`alert('【選中的文字】:${params.selectionText}')`)
})

dialog

dialog - 顯示用於開啟和儲存檔案、警報等的本機系統對話方塊

上文我們已經使用 dialog 建立開啟和儲存檔案,這裡在稍微補充一下。

例如 showOpenDialogSync 中的 defaultPath 可以指定開啟檔案的預設路勁,比如指定到桌面:

dialog.showOpenDialogSync({ defaultPath: app.getPath('desktop') })

Tip: app.getPath 中還有許多其他的名字,比如 temp(臨時資料夾)、downloads(使用者下載目錄的路徑)、pictures(使用者圖片目錄的路徑)等等。

比如顯示一個顯示錯誤訊息的模態對話方塊。可以這樣:

dialog.showErrorBox('title', 'content')

File

File 物件 - 在檔案系統中,使用HTML5 File 原生API操作檔案

範例:獲取拖拽到app上的檔案的真實路徑

// from 官網
<div id="holder">
  Drag your file here
</div>

<script>
  document.addEventListener('drop', (e) => {
    e.preventDefault();
    e.stopPropagation();

    for (const f of e.dataTransfer.files) {
      console.log('File(s) you dragged here: ', f.path)
    }
  });
  document.addEventListener('dragover', (e) => {
    e.preventDefault();
    e.stopPropagation();
  });
</script>

選單

上文我們已經初步使用過選單。這裡進一步展開。

選單快捷鍵

需求:點選某選單觸發一動作,註冊一快捷鍵也同樣觸發該動作。

使用 accelerator 直接定義快捷鍵即可。就像這樣:

const template = [
    {
        id: '1', label: 'one',
        submenu: [
            {
                label: '新開視窗',
                click: () => { 
                    ...
                    ArticlePage.loadFile('article.html')
                },
             +  accelerator: 'CommandOrControl+E+D'
            },
           
        ]
    },
]
const menu = Menu.buildFromTemplate(template)

當按下 CommandOrControl+E+D 就會開啟新視窗。

選單與主程序通訊

選單如果很多,我們會將其抽取成一個單獨檔案。這種情況,如果讓選單與主程序通訊?

需求:選單中的資料來自主程序,觸發選單點選事件後將資訊傳給主程序。

核心就是將 menu 改造成一個方法,主程序呼叫時傳遞資料和一個回撥。實現如下:

// main.js
const CreateMenu = require('./custom-menu')
Menu.setApplicationMenu(CreateMenu('新開視窗', (msg) => {
    console.log('主程序接收選單資訊:', msg)
}))

// custom-menu.js
const { Menu, BrowserWindow } = require('electron')
const CreateMenu = (title, cb) => {
    const template = [
        {
            id: '1', label: 'one',
            submenu: [
                {
                    label: title,
                    click: () => { 
                        const ArticlePage = new BrowserWindow({
                            width: 500,
                            height: 500,
                        })
                        ArticlePage.loadFile('article.html')
                        cb('視窗已開啟')
                    },
                    accelerator: 'Alt+A'
                },
            ]
        },
        { id: '2', label: 'two' },
    ]
    return Menu.buildFromTemplate(template)
}
module.exports = CreateMenu

右鍵選單

需求:右鍵時顯示選單

右鍵時顯示選單和建立主選單類似,首先建立一個選單,然後右鍵時將選單顯示出來即可。實現如下:

const template = [
    { id: '1', label: 'one' },
    { id: '2', label: 'two' },
]
const contextMenu = Menu.buildFromTemplate(template)
mainWindow.webContents.on('context-menu', (event, params) => {
    contextMenu.popup()
})

托盤

Tray(系統托盤) - 新增圖示和上下文選單到系統通知區。

Tip系統托盤是個特殊區域,通常在桌面的底部,在那裡,使用者可以隨時存取正在執行中的那些程式

// mian.js
const createTray = require('./tray')
function createWindow() {
    createTray()
    ...
}
// tray.js
const { Tray } = require('electron')

let tray = null
const createTray = () => {
    // 注:路徑有問題則應用出不來
    tray = new Tray('./images/cat.jpg')
    tray.setToolTip('This is my application.')
}

module.exports = createTray

效果如下圖所示:

需求:點選托盤顯示或隱藏應用

// tray.js
const createTray = (win) => {
  tray = new Tray('./images/cat.jpg')
  tray.setToolTip('This is my application.')
  tray.on('click', () => {
    win.isVisible() ? win.hide() : win.show();
  })
}

// main.js
let mainWindow = new BrowserWindow({
    ...
})

createTray(mainWindow)

:測試發現有時不那麼靈敏。比如桌面微信,點選托盤,只會顯示,也將我們的改為總是顯示,測試通過。

郵件托盤還可以使用選單,就像這樣:

// tray.js
const contextMenu = Menu.buildFromTemplate([
{ label: '顯示', click: () => {}},
{ label: '隱藏',},
])

tray.setContextMenu(contextMenu)

具體做什麼就按照 Menu 來實現即可,這裡不展開。

nativeImage

托盤建構函式(new Tray(image, [guid]))的第一個引數:image (NativeImage | string)

nativeImage - 使用 PNG 或 JPG 檔案建立托盤、dock和應用程式圖示。

在 nativeImage 中提到高解析度,說:在具有高 DPI 支援的平臺 (如 Apple 視網膜顯示器) 上, 可以在影象的基本檔名之後追加 @ 2x 以將其標記為高解析度影象。

簡單來說我可以定義1倍圖、2倍圖、3倍圖,就像這樣:

images/
├── cat.jpg
├── [email protected]
└── [email protected]

使用者根據自己的 DPR 來自動選擇對應的圖片。

const createTray = () => {
  tray = new Tray('./images/cat.jpg')
}

可以在瀏覽器控制檯輸入 devicePixelRatio 檢視原生的 DPR,筆者win7 這裡是1,所以托盤圖示會選擇1倍圖,如果你DPR 是 2,則會匹配2倍圖。

Tip: Window 介面的 devicePixelRatio 返回當前顯示裝置的物理畫素解析度與CSS 畫素解析度之比。 此值也可以解釋為畫素大小的比率:一個 CSS 畫素的大小與一個物理畫素的大小。 簡單來說,它告訴瀏覽器應使用多少螢幕實際畫素來繪製單個 CSS 畫素。

clipboard

clipboard - 在系統剪貼簿上執行復制和貼上操作。

:官網說這個 api 屬於主程序和渲染程序,但筆者在 preload.js 中根本就引入不到 clipboard,直接列印 require('electron') 確實沒有這個 api。

const {clipboard} = require('electron')
console.log('clipboard:', clipboard)
// require('electron') 輸出:
Object
    contextBridge: (...)
    crashReporter: (...)
    ipcRenderer: (...)
    nativeImage: (...)
    webFrame: (...)
    deprecate: (...)

暫不深究,現在就在主程序中使用一下。

需求:主程序中通過 clipboard 向剪下板寫入文案並從剪下板讀取文案並返回給前端 js。

主程序定義方法:

// main.js
const {clipboard} = require('electron')
ipcMain.handle('clipboardWriteAndRead', (evt, ...args) => {
    clipboard.writeText('hello i am a bit of text2!')
    const text = clipboard.readText()
    console.log('text main:', text)
    return text
})

在 preload.js 中將主程序的方法匯出給前端js:

// preload.js
contextBridge.exposeInMainWorld(
  'electron',
  {
    platform: process.platform,
    clipboardWriteAndRead: async () => {
      const text = await ipcRenderer.invoke('clipboardWriteAndRead')
      console.log('text pre:', text)
      return text
    }
  }
)

前端呼叫:

setTimeout(async () => {
    const text = await window.electron.clipboardWriteAndRead()
    console.log('article.js :', text)
}, 1000)

達到預期。測試結果如下:

// 終端:
text main: hello i am a bit of text!

// 瀏覽器:
text pre: hello i am a bit of text!
article.js : hello i am a bit of text!

:如果通過 ipcMain.on 註冊,ipcRenderer.send 觸發,就像下面這麼寫,結果卻是主程序中的方法正確執行了,但是 preload 和 article 中的 text 都是 undefined

// main.js
const {clipboard} = require('electron')
ipcMain.on('clipboardWriteAndRead', (evt, ...args) => {
    clipboard.writeText('hello i am a bit of text!')
    const text = clipboard.readText()
    console.log('text main:', text)
    return text
})

// preload.js
contextBridge.exposeInMainWorld(
  'electron',
  {
    clipboardWriteAndRead: () => {
      const text = ipcRenderer.send('clipboardWriteAndRead')
      console.log('text pre:', text)
      return text
    }
  }
)

ipcRenderer.send('icp-eventA', 'pjl')
// article.js
setTimeout( () => {
    const text = window.electron.clipboardWriteAndRead()
    console.log('article.js :', text)
}, 1000)

上面我們使用了clipboard 的 readTextwriteText 讀取文字和寫入文字,還有其他方法讀取HTML、圖片等,請自行查閱。

desktopCapturer

desktopCapturer(桌面捕獲者) - 存取關於使用navigator.mediaDevices.getUserMedia API 獲取的可以用來從桌面捕獲音訊和視訊的媒體源的資訊。

直接從官網來看一下這個東西到底是什麼:

// 官網範例
const { desktopCapturer } = require('electron')

desktopCapturer.getSources({ types: ['window', 'screen'] }).then(async sources => {
  // 看一下 sources 到底是什麼
})

Tip:官網提到在 preload 指令碼中使用,然而 preload(console.log(require('electron'))) 中根本沒有 desktopCapturer。

直接將其放在主執行緒中會導致應用起不來,就像這樣:

function testDesktopCapturer(){
    desktopCapturer.getSources({ types: ['window', 'screen'] }).then(async sources => {
        console.log('sources', sources)
    })
}
testDesktopCapturer()

如果把方法放入 setTimeout 中等一秒執行卻可以。但是終端看起來不方便,筆者決定在 preload 中呼叫,直接在使用者端中看。

setTimeout(() => {
    testDesktopCapturer()
}, 1000)

程式碼如下:
在主程序中註冊方法:

// main.js
function testDesktopCapturer(){
    desktopCapturer.getSources({ types: ['window', 'screen'] }).then(async sources => {
        console.log('sources', sources)
    })
}

ipcMain.handle('testDesktopCapturer',  async (evt, ...args) => {
    const sources = await desktopCapturer.getSources({ types: ['window', 'screen'] }).then(async sources => {
        return sources
    })
    return sources
})

通過 preload.js 將方法暴露給前端js:

// preload.js
contextBridge.exposeInMainWorld(
  'electron',
  {
    platform: process.platform,
    testDesktopCapturer: async () => {
      const sources = await ipcRenderer.invoke('testDesktopCapturer')
      console.log('sources:', sources)
      return sources
    }
  }
)

前端 js 直接呼叫:

// article.js
setTimeout(async () => {
    window.electron.testDesktopCapturer()
}, 1000)

於是在使用者端很清晰的看到 sources 是一個陣列,筆者這裡有5個物件:

0: {name: '整個螢幕', id: 'screen:0:0', thumbnail: NativeImage, display_id: '', appIcon: null}
1: {name: 'main.js - electron-demo - Visual Studio Code [Administrator]', id: 'window:3343882:0', thumbnail: NativeImage, display_id: '', appIcon: null}
2: {name: 'electron3.md - Typora', id: 'window:1311574:0', thumbnail: NativeImage, display_id: '', appIcon: null}
3: {name: 'draft.txt - 寫字板', id: 'window:1245766:0', thumbnail: NativeImage, display_id: '', appIcon: null}
4: {name: 'MINGW64:/e/xx/small project/electron/electron-demo', id: 'window:1311282:0', thumbnail: NativeImage, display_id: '', appIcon: null}

我們看一下 name 屬性。第一個的整個螢幕,第二個應該是 vscode,第三個是 Typora(看markdown的軟體),第四個是寫字板。於是我們知道 sources 是一些應用。

其中第一個的thumbnail屬性是本地圖片(NativeImage),我們嘗試將整個螢幕顯示出來。請看程式碼:

// main.js 返回`整個螢幕`物件
ipcMain.handle('testDesktopCapturer',  async (evt, ...args) => {
    const sources = await desktopCapturer.getSources({ types: ['window', 'screen'] }).then(async sources => {
        if(sources[0].name === '整個螢幕'){
            return sources[0] 
        }
    })
    return sources
})
// article.js - 過5秒呼叫,可以用點選事件模擬,比如:全螢幕按鈕
setTimeout(async () => {
    window.electron.testDesktopCapturer()
}, 5000)
// article.html - 最開始有一張圖片,過5秒會被替換成截圖圖片
<body>
    Article
    <img src='http://placekitten.com/800/600' style="width:800px;height:600px;"/>
    <script src="./article.js"></script>
</body>
// preload.js - 轉為 url 並賦值給 <img> 元素
contextBridge.exposeInMainWorld(
  'electron',
  {
    testDesktopCapturer: async () => {
      const screen = await ipcRenderer.invoke('testDesktopCapturer')
      const imgUrl = screen.thumbnail.toDataURL()
      console.log('imgUrl:', imgUrl)
      document.querySelector('img').src = imgUrl
    }
  }
)

過5秒,截圖後會替換頁面原先圖片,不是很清晰。效果如下圖所示:

electron 小試

筆者剛好前段時間做了一個網頁版的 svn 搜尋工具給公司內部用,用來快速搜尋 svn 中的檔案。打算將其做成 electron,該工具是內部系統的一個網頁,基於 react。

方案有很多,比需要解決的問題也不相同。比如將專案放入 electron,那麼之前資源的參照得處理,就像這樣:

const {override, addDecoratorsLegacy, addLessLoader} = require('customize-cra');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');

const addCustomize = () => config => {
  // config.env.NODE_ENV
  if(process.env.NODE_ENV === 'production'){
    config.output.publicPath = 'http://192.168.xx.xx:8080/'
  }
  return config
}

最簡單的方法就是直接通過 url 放入 electron 中。

打包出現了不少情況。例如:

  • 用 electron-packager 打包非常慢,7kb的速度約2小時,結果還失敗了。
  • 改用 electron-builder 打包出現 cannot find module fs/promises,網上說是 nodejs 版本太低。
  • 回家, 23:00 點用筆電(win10 + electron-packager)打包,沒有設定映象源(有說設定淘寶源能提高打包速度)約2分鐘就打包成功,壓縮後是71M,解壓後212M,非常大。

Tip: electron-builder 本來是支援在windows下開發,然後一條命令打包到不同平臺的,但此命令需要使用遠端伺服器來完成打包,然後此伺服器已經停止很長時間了,而且從官方檔案可感知後續不會開啟。所以要打linux包必須到linux平臺下打包。

win7 中 Node 13.14 不能構建

筆者工作機器是 win7,只能安裝 node 13.14,不能打包electron。

根據網友介紹可以通過如下方法解決:

  • 安裝 node-v14.17.6-win-x64.zip
  • 設定環境變數。node.exe 上一層目錄即可

node -v 提示 至少得 win8 才能安裝:

$ node -v
Node.js is only supported on Windows 8.1, Windows Server 2012 R2, or higher.Setting the NODE_SKIP_PLATFORM_CHECK environment variable to 1 skips this check, but Node.js might not execute correctly. Any issues encountered on unsupported platforms will not be fixed.
  • 在設定系統變數 NODE_SKIP_PLATFORM_CHECK 值為 1 即可。

網易雲音樂 API

NeteaseCloudMusicApi - 網易雲音樂 Node.js API service

可以通過這個開源專案實現自己的聽歌軟體。