Electron: 从零开始写一个记事本app

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容