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启动程序。
相关文章
相关标签/搜索