Electron-使用 JavaScript, HTML 和 CSS 構建跨平臺的桌面應用

天府雲創發表於2019-03-07

前言

前端開發桌面程式這個概念已經出現有一段時間了,這項技術也已經走向成熟,Github上nw和光electron的star就差不多有10w顆星了,github也衍生出了很多開源的桌面專案儼然成了一個熱門專案。既然這麼熱,那就一個字:學。

Electron介紹

簡單來說,Electron就是可以讓你用Javascript、HTML、CSS來編寫執行於Windows、macOS、Linux系統之上的桌面應用的庫。

官方網站:http://electronjs.org/

傳送門:electron官方Github

開發環境安裝

安裝Node.js

點選 這裡 進入官網下載、安裝。

安裝cnpm

由於眾所周知的原因,你需要一個cnpm代替npm這裡 是官網。安裝命令(開啟系統的cmd.exe來執行命令):

npm install -g cnpm --registry=https://registry.npm.taobao.org

安裝Electron

cnpm install -g electron

安裝Electron-forge

這是一個類似於傻瓜開發包的Electron工具整合專案。具體介紹點選 這裡

cnpm install -g electron-forge

新建專案

  1. 假設專案要放到H:\Electron目錄下,專案名為notepad(字母全部小寫,多個單詞之間可以用“-”連線)。
  2. 開啟cmd.exe,一路cd到H:\Electron。(也可以在Electron資料夾下,按住Shift鍵並右鍵單擊空白處,選擇在此處開啟命令視窗來啟動cmd.exe。)
  3. 執行下面的命令來生成名為notepad的專案資料夾,同時安裝專案所需要的模組、依賴項等。
electron-forge init notepad
  1. cd到notepad目錄下,執行下面的命令來啟動app(也可以簡單的用npm start來執行)。
electron-forge start

cmd.exe

  1. 這樣就可以看到基本的app介面了。

     

    app介面

模板檔案

  1. 這裡某使用Visual Studio Code來開發app。
  2. notepad資料夾整個拖到VS Code中開啟(或者點選單檔案-開啟資料夾選擇notepad資料夾開啟專案),可以看一下專案的目錄結構:node_modules資料夾下是各種模組、類庫,src下是app的原始碼檔案,package.json是描述包的檔案。

    Catalog

  3. 看一下package.json,注意這裡預設已經將主程式入口檔案配置為index.js(而不是main.js)。

    main


    為避免後面混亂,某還是將這裡的src/index.js改成src/main.js,同時也要將檔案index.js改名為main.js

    main.js

  4. 看一下main.js,這是app主程式的入口,在這裡建立了mainWindow瀏覽器視窗,使用mainWindow.loadURL("file://${__dirname}/index.html")來載入index.html主頁;使用mainWindow.webContents.openDevTools()來開啟開發者工具用於除錯(這個操作通常在釋出app時刪除)。然後是app的事件處理:
  • ready: 當Electron完成初始化後觸發,這裡初始化後就會去建立瀏覽器視窗並載入主頁面。
  • window-all-closed: 當所有瀏覽器視窗被關閉後觸發,一般此時就退出應用了。
  • activate: 當app啟用時觸發,一般針對macOS要需要處理。
  1. 看一眼index.html,這是主頁面,除了顯示Well hey there!!!的資訊外,沒什麼具體內容。
  2. 於是,現在整個app只有二個原始碼檔案:main.jsindex.htmlmain.js是主程式入口,index.html是一個web頁面,它需要使用一個瀏覽器視窗(BrowserWindow)來載入和顯示,作為應用的UI,它處在一個獨立的渲染程式中。app啟動時執行main.js中的程式碼建立視窗,載入頁面等。主程式與渲染程式之間不能直接互相訪問,需要通過ipcMainipcRenderer進行IPC通訊(Inter-process communication),或者使用remote模組在渲染程式中使用主程式中的資源(反過來,在主程式中使用webContents.executeJavascript方法可以訪問渲染程式)。

Notepad App功能設計

這裡將實現一個類似於Windows的記事本的App。這個App具備以下功能:

  1. 主選單:包括File, Edit, View, Help四個主選單。重點是File選單下的三個子選單:New(新建檔案)、Open(開啟檔案)、Save(儲存檔案),這三個選單需要自定義點選事件,其它的選單基本使用內建的方法處理,所以沒什麼難度。
  2. 文字框:用於文字編輯。這也是這個App上的唯一一個元件,它的寬和高自動平鋪滿整個視窗大小。當修改了文字框中的文字後,會在App標題欄上最右側新增一個*號以表示文件尚未儲存。
  3. 載入和儲存文字:可以開啟本地文字檔案,支援.txt, .js, .html, .md等文字檔案;可以將文字內容儲存為本地文字檔案。在開啟新建檔案前,如果當前文件尚未儲存,會提示使用者先儲存文件。
  4. 退出程式:退出視窗或程式時,會檢測當前文件是否需要儲存,如果尚未儲存,提示使用者儲存。
  5. 右鍵選單:支援右鍵選單,可以通過選單右鍵執行一些基本的操作,如:複製、貼上等。
    下面是這個記事本App的演示效果,原始碼下載點選 這裡

    Demo

Notepad App功能細節

由於主程式與渲染程式不能直接互相訪問,所以部分細節有必要先考慮清楚。

  1. 主選單:因為選單隻存在於主程式中,所以在執行某些涉及頁面(渲染程式)的選單命令時,比如Open(開啟檔案)命令,就需要與渲染程式進行通訊,這可以使用ipcMainipcRenderer來實現。
  2. 右鍵選單、對話方塊:所謂右鍵選單其實和主選單並無分別,只是顯示方式不同。由於選單、對話方塊等都只存在於主程式中,要在渲染程式中使用它們,就需要向主程式傳送程式間訊息,為簡化操作,Electron提供了一個remote模組,可以在渲染程式中呼叫主程式的物件和方法,而無需顯式地傳送程式間訊息,所以這一部分可以由它來實現。PS:對於從主程式訪問渲染程式(反向操作),可以使用webContents.executeJavascript方法。
  3. 退出時儲存檢測:使用者點選視窗的關閉按鈕,或者點選Exit選單就會關閉視窗退出程式。在退出時,有必要檢查文件是否需要儲存,如果尚未儲存就提示使用者儲存。要實現這一效果,首先,在主程式監測到使用者關閉視窗時,向渲染程式傳送一個特定的訊息表明視窗準備關閉,渲染程式獲得該訊息後檢視文件是否需要儲存,如果需要就彈窗提示使用者儲存,使用者儲存或取消儲存後,渲染程式再向主程式傳送一個訊息表明可以關閉程式了,主程式獲得該訊息後關閉視窗退出程式。這個過程也由ipcMainipcRenderer來實現。

Notepad App的實現

整個App功能比較簡單,最終實現後也只用到了三個主要檔案,包括:main.jsindex.htmlindex.js

main.js

這是主程式的入口,在這裡建立App視窗,生成選單,載入頁面等。下面是該檔案的完整原始碼,二個//-------之間是某根據功能需要新增的程式碼,其餘是模板自動生成的程式碼。

import { app, BrowserWindow } from 'electron';
//-----------------------------------------------------------------
import { Menu, MenuItem, dialog, ipcMain } from 'electron';
import { appMenuTemplate } from './appmenu.js';
//是否可以安全退出
let safeExit = false;
//-----------------------------------------------------------------

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow;

const createWindow = () => {
  // Create the browser window.
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
  });

  // and load the index.html of the app.
  mainWindow.loadURL(`file://${__dirname}/index.html`);

  // Open the DevTools.
  //mainWindow.webContents.openDevTools();

  //-----------------------------------------------------------------
  //增加主選單(在開發測試時會有一個預設選單,但打包後這個選單是沒有的,需要自己增加)
  const menu=Menu.buildFromTemplate(appMenuTemplate); //從模板建立主選單
  //在File選單下新增名為New的子選單
  menu.items[0].submenu.append(new MenuItem({ //menu.items獲取是的主選單一級選單的選單陣列,menu.items[0]在這裡就是第1個File選單物件,在其子選單submenu中新增新的子選單
    label: "New",
    click(){
      mainWindow.webContents.send('action', 'new'); //點選後向主頁渲染程式傳送“新建檔案”的命令
    },
    accelerator: 'CmdOrCtrl+N' //快捷鍵:Ctrl+N
  }));
  //在New選單後面新增名為Open的同級選單
  menu.items[0].submenu.append(new MenuItem({
    label: "Open",
    click(){
      mainWindow.webContents.send('action', 'open'); //點選後向主頁渲染程式傳送“開啟檔案”的命令
    },
    accelerator: 'CmdOrCtrl+O' //快捷鍵:Ctrl+O
  })); 
  //再新增一個名為Save的同級選單
  menu.items[0].submenu.append(new MenuItem({
    label: "Save",
    click(){
      mainWindow.webContents.send('action', 'save'); //點選後向主頁渲染程式傳送“儲存檔案”的命令
    },
    accelerator: 'CmdOrCtrl+S' //快捷鍵:Ctrl+S
  }));
  //新增一個分隔符
  menu.items[0].submenu.append(new MenuItem({
    type: 'separator'
  }));
  //再新增一個名為Exit的同級選單
  menu.items[0].submenu.append(new MenuItem({
    role: 'quit'
  }));
  Menu.setApplicationMenu(menu); //注意:這個程式碼要放到選單新增完成之後,否則會造成新增選單的快捷鍵無效

  mainWindow.on('close', (e) => {
    if(!safeExit){
      e.preventDefault();
      mainWindow.webContents.send('action', 'exiting');
    }
  });
  //-----------------------------------------------------------------

  // Emitted when the window is closed.
  mainWindow.on('closed', () => {
    // Dereference the window object, usually you would store windows
    // in an array if your app supports multi windows, this is the time
    // when you should delete the corresponding element.
    mainWindow = null;
  });
};

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow);

// Quit when all windows are closed.
app.on('window-all-closed', () => {
  // On OS X it is common for applications and their menu bar
  // to stay active until the user quits explicitly with Cmd + Q
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  // On OS X 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 (mainWindow === null) {
    createWindow();
  }
});

// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.

//-----------------------------------------------------------------
//監聽與渲染程式的通訊
ipcMain.on('reqaction', (event, arg) => {
  switch(arg){
    case 'exit':
      //做點其它操作:比如記錄視窗大小、位置等,下次啟動時自動使用這些設定;不過因為這裡(主程式)無法訪問localStorage,這些資料需要使用其它的方式來儲存和載入,這裡就不作演示了。這裡推薦一個相關的工具類庫,可以使用它在主程式中儲存載入配置資料:https://github.com/sindresorhus/electron-store
      //...
      safeExit=true;
      app.quit();//退出程式
      break;
  }
});
//-----------------------------------------------------------------

首先,app.on('ready', createWindow)也就是當Electron完成初始化後,就呼叫createWindow方法來建立瀏覽器視窗mainWindow(與主程式只能有1個不同,可以根據需要適時建立更多個瀏覽器視窗,這些視窗由主程式負責建立和管理,每個瀏覽器視窗使用一個獨立的渲染程式;本文只需使用一個瀏覽器視窗,即mainWindow)。同時,使用Menu.buildFromTemplate(appMenuTemplate)通過一個選單模板來建立app應用主選單,模板程式碼存放在appmenu.js檔案中(這個檔案包含在本文的原始碼中,也可以點選這裡檢視),這個模板的寫法可以參考官方的 Electron API Demos
Customize Menus的例子。模板的第一個選單是File選單,它的子選單被設計成空的,在這裡使用menu.items[0].submenu.append方法向這個File選單新增四個子選單,分別是:New(新建文件),Open(開啟文件),Save(儲存文件),Exit(退出程式)。其中,前三個選單在點選後都會向渲染程式傳送資訊,通知渲染程式執行相關處理。如對於New選單,使用mainWindow.webContents.send('action', 'new')的方式,通知渲染程式要新建一個文件。渲染程式會使用ipcRenderer.on方法來執行監聽,監聽到訊息後就會執行相應處理(這部分在index.js中實現)。最後使用Menu.setApplicationMenu(menu)將主選單安裝到瀏覽器窗體中(所有窗體會共享主選單)。

index.html

這是App的文字編輯頁面。這個頁面很簡單,整個頁面就只有一個TextArea控制元件(id為txtEditor),平鋪滿整個視窗。該頁面使用require('./index.js')載入index.js

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Notepad</title>
  <style type="text/css">
    body,html{
        margin:0px;
        height:100%;
    }
    
    #txtEditor{
        width:100%;
        height:99.535%;
        padding:0px;
        margin:0px;
        border:0px;
        font-size: 18px;
    }
  </style>
  </head>
  <body>
  <textarea id="txtEditor"></textarea>
</body>
  <script>
    require('./index.js');
  </script>
</html>

index.js

所有主頁面index.html涉及到的頁面處理、與主程式互動等的操作都會放到該js檔案中。該檔案完整程式碼:

import { ipcRenderer, remote } from 'electron';
const { Menu, MenuItem, dialog } = remote;

let currentFile = null; //當前文件儲存的路徑
let isSaved = true;     //當前文件是否已儲存
let txtEditor = document.getElementById('txtEditor'); //獲得TextArea文字框的引用

document.title = "Notepad - Untitled"; //設定文件標題,影響視窗標題欄名稱

//給文字框增加右鍵選單
const contextMenuTemplate=[
    { role: 'undo' },       //Undo選單項
    { role: 'redo' },       //Redo選單項
    { type: 'separator' },  //分隔線
    { role: 'cut' },        //Cut選單項
    { role: 'copy' },       //Copy選單項
    { role: 'paste' },      //Paste選單項
    { role: 'delete' },     //Delete選單項
    { type: 'separator' },  //分隔線
    { role: 'selectall' }   //Select All選單項
];
const contextMenu=Menu.buildFromTemplate(contextMenuTemplate);
txtEditor.addEventListener('contextmenu', (e)=>{
    e.preventDefault();
    contextMenu.popup(remote.getCurrentWindow());
});

//監控文字框內容是否改變
txtEditor.oninput=(e)=>{
    if(isSaved) document.title += " *";
    isSaved=false;
};

//監聽與主程式的通訊
ipcRenderer.on('action', (event, arg) => {
    switch(arg){        
    case 'new': //新建檔案
        askSaveIfNeed();
        currentFile=null;
        txtEditor.value='';   
        document.title = "Notepad - Untitled";
        //remote.getCurrentWindow().setTitle("Notepad - Untitled *");
        isSaved=true;
        break;
    case 'open': //開啟檔案
        askSaveIfNeed();
        const files = remote.dialog.showOpenDialog(remote.getCurrentWindow(), {
            filters: [
                { name: "Text Files", extensions: ['txt', 'js', 'html', 'md'] }, 
                { name: 'All Files', extensions: ['*'] } ],
            properties: ['openFile']
        });
        if(files){
            currentFile=files[0];
            const txtRead=readText(currentFile);
            txtEditor.value=txtRead;
            document.title = "Notepad - " + currentFile;
            isSaved=true;
        }
        break;
    case 'save': //儲存檔案
        saveCurrentDoc();
        break;
    case 'exiting':
        askSaveIfNeed();
        ipcRenderer.sendSync('reqaction', 'exit');
        break;
    }
});

//讀取文字檔案
function readText(file){
    const fs = require('fs');
    return fs.readFileSync(file, 'utf8');
}
//儲存文字內容到檔案
function saveText(text, file){
    const fs = require('fs');
    fs.writeFileSync(file, text);
}

//儲存當前文件
function saveCurrentDoc(){
    if(!currentFile){
        const file = remote.dialog.showSaveDialog(remote.getCurrentWindow(), {
            filters: [
                { name: "Text Files", extensions: ['txt', 'js', 'html', 'md'] }, 
                { name: 'All Files', extensions: ['*'] } ]
        });
        if(file) currentFile=file;
    }
    if(currentFile){
        const txtSave=txtEditor.value;
        saveText(txtSave, currentFile);
        isSaved=true;
        document.title = "Notepad - " + currentFile;
    }
}

//如果需要儲存,彈出儲存對話方塊詢問使用者是否儲存當前文件
function askSaveIfNeed(){
    if(isSaved) return;
    const response=dialog.showMessageBox(remote.getCurrentWindow(), {
        message: 'Do you want to save the current document?',
        type: 'question',
        buttons: [ 'Yes', 'No' ]
    });
    if(response==0) saveCurrentDoc(); //點選Yes按鈕後儲存當前文件
}

首先,前面說了,在渲染程式中不能直接訪問選單,對話方塊等,它們只存在於主程式中,但可以通過remote來使用這些資源。

import { remote } from 'electron';
const { Menu, MenuItem, dialog } = remote;

然後,const contextMenu=Menu.buildFromTemplate(contextMenuTemplate)即使用contextMenuTemplate模板來建立編輯器的右鍵選單(雖然建立過程在渲染程式中進行,但實際上使用remote來建立的選單、對話方塊等,仍然只存在於主程式內),由於這裡涉及到的選單都只需要使用系統的內建功能,不需要自定義,所以這裡比較簡單。使用txtEditor.addEventListener('contextmenu')來監聽右鍵選單請求,使用contextMenu.popup(remote.getCurrentWindow())來彈出右鍵選單。
  txtEditor.oninput用於監控文字框內容變化,如果有改變,則將文件標記為尚未儲存,並在標題欄最右側顯示一個*號作為提示。
  PS:在Win7上如果沒有啟用Aero效果,使用document.title = xxxremote.getCurrentWindow().setTitle(xxx)都看不到程式標題欄的標題變化,只當你比如縮放一下視窗後這個修改才會被重新整理。
  ipcRenderer.on用於監聽由主程式發來的訊息。前面說過,主程式使用mainWindow.webContents.send('action', 'new')的方式向渲染程式傳送特定訊息,渲染程式監聽到訊息後,根據訊息內容做出相應處理。比如,這裡,當主程式發來new的訊息後,渲染程式就開始著手新建一個文件,在新建前會使用askSaveIfNeed方法檢測文件是否需要儲存,並提示使用者儲存;對於open的訊息就會呼叫remote.dialog.showOpenDialog來顯示一個檔案開啟對話方塊,由使用者選擇要開啟的文件然後載入文字資料;而對於save訊息就會對當前文件進行儲存操作。

退出時儲存檢測的實現過程

正如前面在App功能細節中討論的一樣,在關閉程式前,友好的做法是檢測文件是否需要儲存,如果尚未儲存,通知使用者儲存。要實現這一功能,需要在主程式和渲染程式間進行相互通訊,以獲得窗體關閉和文件儲存的確認,實現安全退出。

主程式端

首先在main.js中,使用mainWindow.on('close')來監控mainWindow視窗的關閉。

mainWindow.on('close', (e) => {
    if(!safeExit){
      e.preventDefault();
      mainWindow.webContents.send('action', 'exiting');
    }
  });

這裡safeExit開關用於標記渲染程式是否已經向主程式反饋它已經完成所有操作了。如果尚未反饋,則使用e.preventDefault()阻止視窗關閉,並使用mainWindow.webContents.send('action', 'exiting')向渲染程式傳送一個exiting訊息,告訴渲染程式:嘿,我要關掉視窗了,你趕緊看看還要什麼沒做完的,做完後通知我。
  既然主程式要等渲染程式的反饋,就需要監聽渲染程式發回的訊息,所以主程式使用ipcMain.on來執行監聽。如果渲染程式傳送一個exit訊息過來,就表示可以安全退出了。

ipcMain.on('reqaction', (event, arg) => {
  switch(arg){
    case 'exit':
      safeExit=true;
      app.quit();
      break;
  }
});

渲染程式端

在渲染程式這邊的index.js中,在ipcRenderer.on監聽方法中,相應的有一個訊息處理是針對主程式發來的exiting訊息的,當獲知主程式準備關閉視窗,渲染程式就先去檢查文件是否儲存過了,如果尚未儲存就通知使用者儲存,使用者儲存或取消儲存後,使用ipcRenderer.sendSync('reqaction', 'exit')來向主程式傳送一個exit訊息,表示:我要做的都做完了,你想退就退吧。

case 'exiting':
        askSaveIfNeed();
        ipcRenderer.sendSync('reqaction', 'exit');
        break;

主程式監聽到這個訊息後,將safeExit標記為true,表示已經得到渲染程式的確認,然後就可以使用app.quit()安全退出了。當然,在退出前,可以再執行一些其它操作(比如儲存引數配置等)。

編譯打包

  1. 鍵入以下命令進行編譯打包:
npm run make

該命令會將檔案打包到當前專案目錄下的out資料夾下。打包後發現,原始碼直接暴露在[app專案目錄]\out\notepad-win32-x64\resources\app\src目錄下。

  1. 修改package.json,在electronPackagerConfig部分新增"asar": true
"electronPackagerConfig": {
        "asar": true
      }

重新打包後原始碼檔案會被打包進app.asar檔案中(該檔案仍然在src目錄下)。

  1. 可以直接執行打包後的notepad.exe啟動程式。

electron API

官方api(英文) 官方docs

翻譯API(版本有偏差)翻譯版docs

國內也有翻譯版的API文件,但是不能保證是最新的,使用時一定要看好自己的版本和翻譯版。使用翻譯版API。同時可以看看官方的更新日誌,看看有什麼新功能。官方社群有很多有用的工具,開始學習欠務必瞭解,涉及到專案開發除錯和專案構建。這裡推薦一個倉庫,這個倉庫收錄了一些比較常用的API,克隆後跑起來你就可以快速檢視這些常用API

git clone https://github.com/fuchao2012/zh-cn-Electron-API-Demos
 cd zh-cn-Electron-API-Demos
 npm install
 npm start

electron專案和web專案的區別

electron核心我們可以分成2個部分,主程式和渲染程式。主程式連線著作業系統和渲染程式,可以把她看做頁面和計算機溝通的橋樑。渲染程式就是我們所熟悉前端環境了。只是載體改變了,從瀏覽器變成了window。傳統的web環境我們是不能對使用者的系統就行操作的。而electron相當於node環境,我們可以在專案裡使用所有的node api 。

簡單理解:
給web專案套上一個node環境的殼。

專案結構

相比web專案,桌面專案多了一個程式

專案遷移

如果要遷移專案到web端,就需要把專案中的electron提供的API和node的API完全剝離出來,只能遺留web的程式碼,比如 node fs模組,electron提供ipc 模組,都需要剝離。

如果你一開始就打算雙端程式,在開始寫程式碼時應該對web程式碼和elecctron的程式碼進行分離,以便後期的遷移。

專案開發打包工具

這裡推薦devtron 和 electron-builder 2個開發工具,配置簡單,功能強大。這裡不詳細介紹工具的使用。官方都有非常好的文件。

傳送門: devtron

傳送門: electron-builder

社群還有很多好用的工具,可以自行查閱,選擇使用。

傳送門:community

ps: electron打包的時候需要下載一個版本庫,速度會非常慢,可以通過淘寶映象源解決

>就是在你的命令前加ELECTRON_MIRROR=https://npm.taobao.org/mirrors/electron/及空格

$ ELECTRON_MIRROR=https://npm.taobao.org/mirrors/electron/ npm run build

打包問題

Electron介紹差不多就到這裡,框架有了。然而一大堆配置頭都暈了,從0-1非常困難,我們不妨從1到0,可以先找個模版做個小demo感受一下electron的魅力,在做專案中學習electron。

傳送門: react模版     

傳送門: vue模版     

electron-vue經驗分享

官方文件中作者提供了很多對開發有用的東西,我推薦學習的同學都通讀一遍

傳送門: electron-vue文件

electron-vue,作者為我們封裝好了一個基於vue框架的腳手架,包括electron所有基本的開發構建工具 和vue配套的請求,路由以及vuex等外掛。
通過腳手架我們可以直接進入開發階段,開發的同時,去了解electron的工作機制,之後再開始深入去理解她更深層次的程式碼邏輯。 先走形,再走心。

相關文章