第三章-构建Markdown应用程序

本章主要内容:

  • 介绍我们将在接下来的几章中构建的应用程序
  • 配置我们的CSS样式表,使其看起来更像一个本机应用程序
  • 回顾在Electron中主进程和渲染器进程之间的关系
  • 为我们的主进程和渲染器进程实现基本功能
  • 在Electron渲染进程中访问Chrome开发者工具

我们的书签管理器是一个很好的开始,但它只触及了我们可以用Electron做什么。

在本章中,我们将更深入地探讨,并为与用户操作系统建立更紧密联系的应用程序打下基础。在接下来的几章中,我们将实现触发操作系统用户界面,对文件系统进行读写和访问剪贴板的功能。

我们正在构建一个简单的 Markdown编辑器 ,它允许我们创建新的或打开现有的Markdown文件,将它们转换为HTML,并将HTML保存到文件系统和剪贴板中。让我们把这个应用程序称为Fire Sale,因为它毕竟是一个廉价编辑器,只是稍微聪明一点而已。

在本章的最后,我们将讨论在出现问题时调试Electron应用程序的技术和工具。

定义我们的应用

让我们从为我们不起眼的小应用程序设置目标开始。

对于桌面应用程序,我们的许多特性可能看起来有些平庸,这就是重点。它们是桌面应用程序的标准配置,但完全超出了传统web应用程序的能力范围,传统web应用程序无法访问独立浏览器选项卡之外的任何内容。

我们的应用程序将由两个窗格组成,用户可以编写或编辑Markdown和一个右窗格,该窗格以HTML形式呈现用户的Markdown。在顶部有一系列按钮,允许用户从文件系统加载文本文件,并将结果写入剪贴板或文件系统。

在应用程序的第一阶段,我们构建了以下的界面。在图3.1。我们还可以向效果图(以及随后的应用程序)添加额外的用户界面元素,但这是一个很好的开始。

图3.1 我们的应用程序的线框显示,用户可以在左侧窗格中输入文本,或者从用户的文件系统的文件中加载文本。

在这一章中,我们为我们的应用奠定了基础。我们创建项目的结构,安装依赖项,设置主进程和呈现器进程,构建用户界面,并在用户向左侧窗格输入文本时实现markdown到HTML的转换。

我们将在接下来的几章中分阶段构建应用程序的其余部分。在每一章中,您将下载我们应用程序的当预期目标代码。通过这种方式,您可以切换到一个章节,其中包含您感兴趣的功能,而不必从头构建整个应用程序。

在第一阶段,我们的应用程序将能够

  • 打开并保存文件到文件系统
  • 从这些文件获取Markdown内容
  • 将Markdown内容呈现为HTML
  • 将生成的HTML保存到文件系统中
  • 将生成的HTML写入剪贴板

在后面的章节中,我们的应用程序使用本地操作系统接口跟踪最近打开的文档。我们可以将Markdown文件从Finder或Windows资源管理器拖放到应用程序上,并让应用程序立即打开该Markdown文件。当我们右键单击应用程序的不同区域时,应用程序将有自己的自定义应用程序菜单和自定义上下文菜单。

我们还利用了操作系统特有的特性,比如更新应用程序的标题栏,以显示当前打开的文件,以及自上次保存以来是否已经更改。如果计算机上的其他应用程序在打开文件时更改了文件,我们还实现了其他功能,比如更新应用程序中的内容。

奠定基础

如图3.2所示的文件结构与我们在前一章中商定并用于书签管理器的结构非常相似。

为了简化和清晰,在我们继续熟悉Electron时,我们在 app/main.js 中保存了主进程的所有代码,在 app/renderer.js 中保存了单渲染器进程的所有代码。我们将 app 文件夹存储在基于unix的操作系统上,以便能够快速生成它,如下面的清单所示。或者,您可以在GitHub上查看这个项目的主分支,网址是 https://github.com/electron-in-action/firesale。

图3.2 我们工程结构

列表3.1 生成应用文件结构

1
mkdir app  && touch app/index.html app/main.js app/renderer.js app/style.css

项目的各个部分是

  • index.html-包含所有为UI提供结构的HTML标记
  • main.js-包含我们的主进程的代码
  • renderer.js-包含UI的所有交互代码
  • style.css-包含样式的CSS
  • package.json-包含所有依赖项,并在启动主进程时将Electron指向main.js

为了简单起见,除了Electron之外,我们还从两个依赖项开始作为运行时。我们使用一个名为 marked 的库来处理Markdown到HTML转换的繁重工作。

对于这个项目,通过运行npm init –yes生成一个 package.json 。–yes标记允许您跳过前一章中的提示。生成package.json之后,运行以下命令安装必要的依赖项:

1
npm install electron marked --save

图3.3 Electron首先寻找我们的主进程,它负责生成一个或多个渲染器进程,其负责显示我们的UI。

引导程序

在我们package.json的main条目被配置为加载index.js作为应用程序的主进程。如图3.3所示,我们需要将其调整为 app/main.js 。我们还需要一个渲染器进程,为用户提供应用程序的界面。在app/main.js中,让我们添加如下代码。

列表3.2 引导主进程: ./app/main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const{ app, BrowserWindow } = require('electron')

//在顶层声明mainWindow,以便在“ready”事件完成后不会将其回收为垃圾
let mainWindow = null;

app.on('ready', () => {
//使用默认属性创建一个新的BrowserWindow
mainWindow = new BrowserWindow({
webPreferences: {
// webPreferences中的nodeIntegrationInWorker选项设置为true,Electron5.x以后,缺省为false
nodeIntegration: true
}
})

//在刚才创建的BrowserWindow实例中加载app/index.html
mainWindow.loadFile('app/index.html');

mainWindow.on('closed', () => {
//在窗口关闭时将进程设置为null
mainWindow = null;
});
});

这足以启动我们的应用程序。也就是说,由于我们的主进程目前在渲染器进程中加载了一个空文件,所以没有发生太多事情。

实现用户界面

在Electron中要获得图3.1中效果图的可行版本,实现必要的HTML和CSS是相当容易的。因为我们只需要支持一个浏览器,而这个浏览器支持web平台提供的最新和最强大的特性,如图3.4所示。

图3.4 主进程将创建一个渲染器程序进程并告诉它加载index.html。然后,它将像在浏览器中一样加载CSS和JavaScript。

在index.html,我们添加清单3.3中的标记来创建图3.5中的浏览器窗口。

图3.5 开始我们第一个未样式化的Electron应用

列表3.3 我们应用的标记:./app/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fire Sale</title>
<link rel="stylesheet" href="style.css" type="text/css">
</head>
<body>

<section class="controls">
<button id="new-file">New File</button>
<button id="open-file">Open File</button>
<button id="save-markdown" disabled>Save File</button>
<button id="revert" disabled>Revert</button>
<button id="save-html">Save HTML</button>
<button id="show-file" disabled>Show File</button>
<button id="open-in-default" disabled>Open in Default Application</button>
</section>

<section class="content">
<!--
<label for="markdown" hidden>Markdown Content</label>
<textarea class="raw-markdown" id="markdown"></textarea>
<div class="rendered-html" id="html"></div>
</section>
</body>

<script>
require('./renderer');
</script>
</html>

我们的应用程序目前还没有太多需要查看的地方。

如果您和我一样,您对我在效果图中引入的两列接口有点怀疑。在讨论如何使用HTML和CSS实现列时,很少使用easy这个词。

幸运的是,我们可以自信地使用添加到CSS3的名为Flexbox的新布局模式来快速定义应用程序的两列布局。Flexbox使创建页面布局变得很容易,可以在各种屏幕大小范围内进行可预测的操作,如清单3.4所示。它对CSS来说是相对较新的,直到最近才得到Internet Explorer的支持。

正如我们在第1章和第2章中讨论的,我们的应用程序总是跟上Chrome的最新版本,所以我们可以放心地使用Flexbox布局模式,而不用担心跨浏览器兼容性。

使用Flexbox创建页面布局:./app/style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/*选择一个更新的CSS框模型,它将正确地设置元素的宽度和高度*/
html {
box-sizing: border-box;
}

/* 将此设置传递给页面上的所有其他元素和伪元素*/
*, *:before, *:after {
box-sizing: inherit;
}

html, body {
height: 100%;
width: 100%;
overflow: hidden;
}

body {
margin: 0;
padding: 0;
position: absolute;
}

/* 在整个应用程序中使用操作系统的默认字体 */
body, input {
font: menu;
}

/*移除浏览器围绕活动输入字段的默认突出显示*/
textarea, input, div, button {
outline: none;
margin: 0;
}

.controls {
background-color: rgb(217, 241, 238);
padding: 10px 10px 10px 10px;
}

button {
font-size: 14px;
background-color: rgb(181, 220, 216);
border: none;
padding: 0.5em 1em;

}

button:hover {
background-color: rgb(156, 198, 192);
}

button:active {
background-color: rgb(144, 182, 177);
}

button:disabled {
background-color: rgb(196, 204, 202);
}

.container {
display: flex;
flex-direction: column;
min-height: 100vh;
min-width: 100vw;
position: relative;
}

/* 使用Flexbox对齐应用程序的两个窗格*/
.content {
height: 100vh;
display: flex;
}

/* 使用Flexbox将两个窗格设置为相同的宽度 */
.raw-markdown, .rendered-html {
min-height: 100%;
max-width: 50%;
flex-grow: 1;
padding: 1em;
overflow: scroll;
font-size: 16px;
}

.raw-markdown {
border: 5px solid rgb(238, 252, 250);;
background-color: rgb(238, 252, 250);
font-family: monospace;
}

样式表有两个主要目标。首先,我们想利用像Flexbox这样的现代CSS特性来设计我们的UI。其次,我们希望采取一些小步骤,使应用程序的外观和感觉更像一个真实的web应用程序(参见图3.6)。

图3.6 我们的应用程序已经使用CSS的现代特性给出了一些基本的样式。

box-sizing 属性在CSS中处理一个历史上的奇怪现象,在一个宽度为200像素的元素中添加50个像素的填充将导致它的宽度为300像素(每边添加50个像素的填充),对于边框也是一样。

box-sizing 被设置为 border-box 时,我们的元素会考虑到我们设置它们的高度和宽度。总的来说,这是一件好事。在这个CSS规则中,我们还让所有其他元素和伪元素都尊重我们通过将box-sizing设置为border-box所做的艰苦工作。

我们希望我们的应用程序能够适应本地应用程序。朝着这个方向迈出的重要一步是使用所有其他应用程序都使用的系统字体。例如,尽管macOS在整个操作系统中使用San Francisco作为默认字体,但它不能作为常规字体使用。我们将font属性设置为menu,它依赖于操作系统来使用它的默认字体——即使我们无法访问它。

浏览器在当前活动的UI元素周围设置一个边框。在macOS中,这个边框是蓝色的辉光。您可能从未过多地考虑过它,因为我们已经习惯了在web上使用它,但是当我们开发桌面应用程序时,它看起来并不合适。在我们的应用程序中,它看起来尤其糟糕,其中一半的UI实际上是一个大型文本输入。通过将 outline 设置为 none ,我们删除了活动元素周围的非自然辉光。

.content.raw-markdown.rendered-html 规则中,我们实现了一个简单的Flexbox布局,这将使我们的应用程序看起来更像我们在图3.1中介绍的效果。content类的元素将包含我们的两列。我们将display属性设置为flex,以使用前面讨论的Flexbox技术。下一步,我们设置flex- growth,它指定flex项的增长因子,

当然可以。把它看作元素的尺度相对于它的兄弟元素可能是有帮助的。在本例中,我们使用Flexbox将两列设置为相等的比例。

优雅地显示浏览器窗口

如果你仔细观察你的应用程序的启动,您将注意到,在Electron加载index.html并在窗口中呈现DOM之前,窗口完全为空。用户不习惯在本地应用程序中看到这种情况,我们可以通过重新思考如何启动窗口来避免这种情况。

如果您认为应用程序第一次启动时的虚无闪光是无意义的,考虑主进程中的代码:它创建一个窗口,然后在其中加载内容。如果我们隐藏窗口直到内容被加载呢?然后,当UI准备好时,我们显示窗口,并避免短暂地暴露一个空窗口。

列表3.5 当DOM就绪时优雅地显示窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
app.on('ready', () => {
//使用默认属性创建一个新的BrowserWindow
mainWindow = new BrowserWindow({
show: false,
webPreferences: {
// webPreferences中的nodeIntegrationInWorker选项设置为true,Electron5.x以后,缺省为false
nodeIntegration: true
}
})

//在刚才创建的BrowserWindow实例中加载app/index.html
mainWindow.loadFile('app/index.html');

mainWindow.once('ready-to-show', () => {
//当DOM就绪时显示窗口。
mainWindow.show();
});

mainWindow.on('closed', () => {
//在窗口关闭时将进程设置为null
mainWindow = null;
});
});

我们将一个对象传递给 BrowserWindow 构造函数,默认情况下将其设置为 hidden 。当 BrowserWindow 实例触发它的“ready-to-show”事件时,我们将调用它的show()方法,这将在UI完全准备好运行后使它不再隐藏。当应用程序通过网络加载远程资源时,这种方法甚至更有用,因为初始化页面可能需要更长的时间。

实现基本功能

让我们把一些基本功能放在适当的位置上。对于初学者,我们希望在左窗格中的Markdown发生更改时更新右窗格中呈现的HTML视图(参见图3.7)。这就是我们唯一的依赖—Marked—发挥作用的地方。

图3.7 我们将在左侧窗格中添加一个事件监听器,它将以HTML的形式呈现标记并显示在右侧窗格中。

引入依赖项很容易,因为我们可以使用Node的 require 来引入 marked 。让我们在app/renderer.js中添加以下内容。

列表3.6 引入依赖: ./app/renderer.js

1
const marked = require('marked');

现在,我们可以通过变量marked使用Marked。鉴于我们在图3.7中讨论了应用程序的功能,您可能已经开始怀疑,在开发应用程序时,我们将大量使用#markdown文本区域和#html元素。让我们使用一对变量来存储对每个元素的引用,以便更容易地使用它们,如清单3.7所示。在此过程中,我们还将为UI顶部的每个按钮创建变量。

列表3.7 缓存DOM选择器: ./app/renderer.js

1
2
3
4
5
6
7
8
9
const markdownView = document.querySelector('#markdown');
const htmlView = document.querySelector('#html');
const newFileButton = document.querySelector('#new-file');
const openFileButton = document.querySelector('#open-file');
const saveMarkdownButton = document.querySelector('#save-markdown');
const revertButton = document.querySelector('#revert');
const saveHtmlButton = document.querySelector('#save-html');
const showFileButton = document.querySelector('#show-file');
const openInDefaultButton = document.querySelector('#open-in-default');

我们还相当频繁地在htmlView中呈现Markdown,所以我们想给自己一个函数,以便将来更容易实现。

列表3.8 转换markdown到HTML: ./app/renderer.js

marked将我们要呈现的Markdown内容作为第一个参数,并将选项的对象作为第二个参数。我们希望避免意外的脚本注入,因此我们传入了一个对象,并将sanitize属性设置为true。

最后,我们向markdownView添加了一个事件监听器,它将在keyup上读取它的内容(在textarea元素中,内容存储在它的value属性中),通过marked运行它们,然后将它们加载到htmlView中。结果如图3.8所示。

列表3.9 当Markdown更改时重新呈现HTML: ./app/renderer.js

1
2
3
4
markdownView.addEventListener('keyup', (event) => {
const currentContent = event.target.value;
renderMarkdownToHtml(currentContent);
});

图3.8 我们的应用程序接受用户在左窗格中键入的内容,并在右窗格中将其自动呈现为HTML。该内容由用户提供,不属于我们的应用程序。

基本功能已经就绪,我们准备开始研究只有在Electron应用程序中才可能实现的特性,首先从文件系统中读写文件开始。当所有这些都完成后,应用程序的呈现程序流程应该是这样的。

列表3.10 渲染进程: ./app/renderer.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const marked = require('marked');

const markdownView = document.querySelector('#markdown');
const htmlView = document.querySelector('#html');
const newFileButton = document.querySelector('#new-file');
const openFileButton = document.querySelector('#open-file');
const saveMarkdownButton = document.querySelector('#save-markdown');
const revertButton = document.querySelector('#revert');
const saveHtmlButton = document.querySelector('#save-html');
const showFileButton = document.querySelector('#show-file');
const openInDefaultButton = document.querySelector('#open-in-default');

const renderMarkdownToHtml = (markdown) => {
htmlView.innerHTML = marked(markdown, { sanitize: true });
};

markdownView.addEventListener('keyup', (event) => {
const currentContent = event.target.value;
renderMarkdownToHtml(currentContent);
});

调试Electron应用程序

在理想的世界中,我们在编写代码时永远不会出错。

接口和方法永远不会在不同的版本之间更改,而且您的作者不必每次发布本书中应用程序使用的依赖项的新版本时都屏住呼吸。

我们并不生活在那个世界上。因此,我们可以使用开发工具帮助我们跟踪并有望消除缺陷。

调试渲染器进程

到目前为止,一切都进行得相当顺利,但可能不久之后我们就必须调试一些棘手的情况。因为Electron应用程序是基于Chrome的,所以我们在构建Electron应用程序时可以使用Chrome开发者工具就不足为奇了(图3.9)。

调试渲染器过程相对简单。Electron的默认应用程序菜单提供了一个命令来打开应用程序中的Chrome开发工具。在第6章中,我们将学习如何创建我们自己的自定义菜单,并在您不希望将其公开给用户的情况下消除此功能。

还有另外两种访问开发人员工具的方法。

在任何时候,您都可以按 macOS 上的 Command-Option-IWindowsLinux 上的 Control-Shift-I 打开工具(图3.10)。此外,您还可以通过编程方式触发开发人员工具。

BrowserWindow实例上的webcontent属性有一个名为 openDevTools() 的方法。如清单3.11所示,这个方法将在调用它的BrowserWindow中打开开发工具。

图3.9 Chrome开发工具在渲染器过程中可用,就像在基于浏览器的应用程序中一样。

图3.10 该工具可以在Electron提供的默认菜单中开或关。您还可以使用Windows上的Control-Shift-I或macOS上的Command-Option-I来触发它们。

列表3.11 从主流程打开开发者工具: ./app/main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
app.on('ready', () => {
mainWindow = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true
}
});

mainWindow.loadFile(`app/index.html`);


mainWindow.once('ready-to-show', () => {
mainWindow.show();
mainWindow.webContents.openDevTools(); //我们可以通过编程方式在主窗口加载开发工具时立即打开它。
});

mainWindow.on('closed', () => {
mainWindow = null;
});
});

调试主进程

调试主进程并不容易。Node Inspector是调试Node.js应用程序的常用工具,为了提供一个可以调试主进程的方法,Electron 提供了 --inspect 开关。使用如下的命令行开关来调试 Electron 的主进程: --insepct=[port] 当这个开关用于 Electron 时,它将会监听 V8 引擎中有关 port 的调试器协议信息。 默认的 port5858

1
electron --inspect=5858 your/appCopy

使用VSCode进行主进程调试

Visual Studio Code 是一个免费的开放源码的IDE,适用于Windows、Linux和macOS,并且是由Microsoft在Electron之上构建的。Visual Studio Code提供了一组用于调试节点应用程序的丰富工具,这使得调试Electron应用程序比前面提到的要容易得多。

设置构建任务的一种快速方法是让Visual Studio Code在没有构建任务的情况下构建应用程序。

在Windows上按 Control-Shift-B 或在macOS上按 Command-Shift-B ,将提示您创建一个构建任务,如图3.11所示。

图3.11 在没有适当的构建任务的情况下触发构建任务,Visual Studio Code将提示为您创建一个。

列表3.12 在Windows的Visual Studio Code中设置构建任务: task.json

1
2
3
4
5
6
7
8
9
10
11
12
{
// 有关 tasks.json 格式的文档,请参见
// https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"problemMatcher": []
}
]
}

现在,当您按下Windows上的 Control-Shift-B 或macOS上的 Command-Shift-B 时,您的电子应用程序将启动。这不仅对于在Visual Studio Code中设置调试非常重要,而且通常也是启动应用程序的一种方便方法。下一步是设置Visual Studio Code来启动应用程序,并将其连接到其内置调试器(图3.12)。

要创建启动任务,请转到上面的终端选项卡,并单击配置默认生成任务。Visual Studio Code将询问您想要创建哪种配置文件。选择Node并用清单3.13替换文件的内容。

图3.12 在Debug选项卡中,单击gear, Visual Studio Code将创建一个配置文件,用于代表您启动调试器。

列表3.13 为Windows的Visual Studio代码设置启动任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd"
},
"args" : ["."],
"outputCapture": "std"
}
]
}

有了这个配置文件,您可以单击主进程中任何一行的左边缘来设置断点,然后按F5运行应用程序。

执行将在断点处暂停,允许您检查调用堆栈,确定范围内的变量,并与活动控制台进行交互。断点并不是调试代码的唯一方法。

您还可以监视特定的表达式,或者在抛出未捕获异常时将其放入调试器(图3.13)。

图3.13 内置在Visual Studio Code中的调试器允许您暂停应用程序的执行,并顺便检查bug。

您很可能没有使用Visual Studio Code。这很好。这并不是本书的先决条件,使用您最熟悉的文本编辑器或IDE几乎肯定没问题。

此外,Visual Studio Code并不是唯一支持调试主进程。例如,您可以在这里找到配置WebStorm的详细信息: http://mng.bz/Y5T6。

总结

Node Inspector