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

Electron介紹

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

開發環境安裝

安裝Node.js

點擊 這裏 進入官網下載、安裝。php

安裝cnpm

因爲衆所周知的緣由,你須要一個cnpm代替npm這裏 是官網。安裝命令(打開系統的cmd.exe來執行命令):css

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

安裝Electron

cnpm install -g electron

安裝Electron-forge

這是一個相似於傻瓜開發包的Electron工具整合項目。具體介紹點擊 這裏html

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界面了。java


     
    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具有如下功能:node

  1. 主菜單:包括File, Edit, View, Help四個主菜單。重點是File菜單下的三個子菜單:New(新建文件)、Open(打開文件)、Save(保存文件),這三個菜單須要自定義點擊事件,其它的菜單基本使用內建的方法處理,因此沒什麼難度。
  2. 文本框:用於文本編輯。這也是這個App上的惟一一個組件,它的寬和高自動平鋪滿整個窗口大小。當修改了文本框中的文字後,會在App標題欄上最右側添加一個*號以表示文檔還沒有保存。
  3. 加載和保存文本:能夠打開本地文本文件,支持.txt, .js, .html, .md等文本文件;能夠將文本內容保存爲本地文本文件。在打開新建文件前,若是當前文檔還沒有保存,會提示用戶先保存文檔。
  4. 退出程序:退出窗口或程序時,會檢測當前文檔是否須要保存,若是還沒有保存,提示用戶保存。
  5. 右鍵菜單:支持右鍵菜單,能夠經過菜單右鍵執行一些基本的操做,如:複製、粘貼等。
    下面是這個記事本App的演示效果,源碼下載點擊 這裏
     
    Demo

Notepad App功能細節

因爲主進程與渲染進程不能直接互相訪問,因此部分細節有必要先考慮清楚。git

  1. 主菜單:由於菜單隻存在於主進程中,因此在執行某些涉及頁面(渲染進程)的菜單命令時,好比Open(打開文件)命令,就須要與渲染進程進行通訊,這可使用ipcMainipcRenderer來實現。
  2. 右鍵菜單、對話框:所謂右鍵菜單其實和主菜單並沒有分別,只是顯示方式不一樣。因爲菜單、對話框等都只存在於主進程中,要在渲染進程中使用它們,就須要向主進程發送進程間消息,爲簡化操做,Electron提供了一個remote模塊,能夠在渲染進程中調用主進程的對象和方法,而無需顯式地發送進程間消息,因此這一部分能夠由它來實現。PS:對於從主進程訪問渲染進程(反向操做),可使用webContents.executeJavascript方法。
  3. 退出時保存檢測:用戶點擊窗口的關閉按鈕,或者點擊Exit菜單就會關閉窗口退出程序。在退出時,有必要檢查文檔是否須要保存,若是還沒有保存就提示用戶保存。要實現這一效果,首先,在主進程監測到用戶關閉窗口時,向渲染進程發送一個特定的消息代表窗口準備關閉,渲染進程得到該消息後查看文檔是否須要保存,若是須要就彈窗提示用戶保存,用戶保存或取消保存後,渲染進程再向主進程發送一個消息代表能夠關閉程序了,主進程得到該消息後關閉窗口退出程序。這個過程也由ipcMainipcRenderer來實現。

Notepad App的實現

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

main.js

這是主進程的入口,在這裏建立App窗口,生成菜單,載入頁面等。下面是該文件的完整源碼,二個//-------之間是某根據功能須要添加的代碼,其他是模板自動生成的代碼。web

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)將主菜單安裝到瀏覽器窗體中(全部窗體會共享主菜單)。npm

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啓動程序。
相關文章
相關標籤/搜索