1. 程式人生 > >Electron: 從零開始寫一個記事本app

Electron: 從零開始寫一個記事本app

模板 發現 when 不同 return body func -a 自動生成

Electron介紹

簡單來說,Electron就是可以讓你用Javascript、HTML、CSS來編寫運行於Windows、macOS、Linux系統之上的桌面應用的庫。本文的目的是通過使用Electron開發一個完整但簡單的小應用:記事本,來體驗一下這個神器的開發過程。本文猶如Hello World一樣的存在,是個入門級筆記,但如果你之前從未接觸過Electron,而又對它有興趣,某想信這會是一篇值得一看的入門教程。
  PS:這篇文章是基於Windows的開發過程,未對macOS、Linux作測試。

開發環境安裝

安裝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啟動程序。

by Mandarava(鰻駝螺) 2017.07.12



作者:鰻駝螺
鏈接:https://www.jianshu.com/p/57d910008612
來源:簡書

Electron: 從零開始寫一個記事本app