第二章 你首个Electron应用 | Electron in Action(中译)

2,369 阅读32分钟

本章主要内容

  • 构建并启动Electron应用
  • 生成package.json,配置成Electron应用
  • 在你的项目中包含预先构建Electron版本
  • 配置package.json以启动主进程
  • 从主进程生成渲染进程
  • 利用Electron限制宽松的优点构建通常在浏览器无法构建的功能
  • 使用Electron的内置模块来回避一些常见的问题

​ 在第一章中,我们从高的层次上,讨论了什么是Electron。说到底这本书叫做《Electron实战》,对吧?在本章中,我们通过从头开始设置和构建一个简单的应用程序来管理书签列表,从而学习Electron的基本知识。该应用程序将利用只有在现代的浏览器中才能使用的特性。

在上一章的高层次讨论中,我提到了Electron是一个类似于Node的运行时。这仍然是正确的,但是我想回顾下这一点。Electron不是一个框架——它不提供任何框架,也没有关于如何构造应用程序或命名文件的严格规则,这些选择都留给了我们这些开发者。好的一面是,它也不强制执行任何约定,而且在入手之前,我们不需要多少概念上的样板信息去学习。

构建书签列表应用程序

让我们从构建一个简单而又有些幼稚的Electron应用程序开始,来加强我们已经介绍过的所有内容的理解。我们的应用程序接受url。当用户提供URL时,我们获取URL引用的页面的标题,并将其保存在应用程序的localStorage中。最后,显示应用程序中的所有链接。您可以在GitHub上找到本章的完整源代码(github.com/electron-in…)。

  在此过程中,我们将指出构建Electron应用程序的一些优点,例如,可以绕过对服务器的需求,使用最前沿的web api,这些web api并不广泛支持所有浏览器,因为这些APIs是在现代版本的Chromium中实现。图2.1是我们在本章构建的应用程序的效果图。

图2.1 我们在本章中构建的应用程序效果图

  当用户希望将网站URL保存并添加到输入字段下面的列表中时,应用程序向网站发送一个请求来获取标记。成功接收到标记后,应用程序获取网站的标题,并将标题和URL添加到网站列表中,该列表存储在浏览器的localStorage中。当应用程序启动时,它从localStorage读取并恢复列表。我们添加了一个带有命令的按钮来清除localStorage,以防出现错误。因为这个简单的应用程序旨在帮助您熟悉Electron,所以我们不会执行高级操作,比如从列表中删除单个网站。

搭建Electron应用

  1. npm init 生成package.json

  2. 搭建Electron目录框架

应用程序结构的定义取决于您的团队或个人处理应用程序的方式。许多开发人员采用的方法略有不同。观察学习一些更成熟的电子应用程序,我们可以辨别出共同的模式,并在本书中决定如何处理我们的应用程序。

​ 出于我们的目的,为了让本书文件结构达成一致。做出一下规定,我们有一个应用程序目录,其中存储了所有的应用程序代码。我们还有一个package.json将存储依赖项列表、关于应用程序的元数据和脚本,并声明Electron应该在何处查找主进程。在安装了依赖项之后,最终会得到一个由Electron为我们创建的node_modules目录,但是我们不会在初始设置中包含它

​ 就文件而言,让我们从应用程序中的两个文件开始:main.jsrenderer.js。它们是带有标识的文件名,因此我们可以跟踪这两种类型的进程。我们在本书中构建的所有应用程序的开始大致遵循图2.2中所示的目录结构。(如果你在运行macOS,你可以通过安装brew install tree使用tree命令。)

图2.2 我们第一个Electron应用的文件结构树

创建一个名为“bookmarker”的目录,并进入此目录。您可以通过从命令行工具运行以下两个命令来快速创建这个结构。当你使用npm init之后,你会生成一个package.json文件。

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

​ Electron本身不需要这种结构,但它受到了其他Electron应用程序建立的一些最佳实践的启发。Atom将所有应用程序代码保存在一个app目录中,将所有样式表和其他资产(如图像)保存在一个静态目录中。LevelUI在顶层有一个index.js和一个client.js,并将所有依赖文件保存在src目录中,样式表保存在styles目录中。Yoda将所有文件(包括加载应用程序其余部分的文件)保存在src目录中。app、src和lib是存放应用程序大部分代码的文件夹的常用名称,style、static和assets是存放应用程序中使用的静态资产的目录的常用名称。

package.json

package.json清单用于许多甚至说大多数Node项目。此清单包含有关项目的重要信息。它列出了元数据,比如作者的姓名以及他们的电子邮件地址、项目是在哪个许可下发布的、项目的git存储库的位置以及文件问题的位置。它还为一些常见的任务定义了脚本,比如运行测试套件或者与我们的需求相关的构建应用程序。package.json文件还列出了用于运行和开发应用程序的所有依赖项。

​ 理论上,您可能有一个没有package.json的Node项目。但是,当加载或构建应用程序时,Electron依赖于该文件及其主要属性来确定从何处开始。

npm是Node附带的包管理器,它提供了一个有用的工具帮助生成package.json。在前面创建的“bookmarker”目录中运行npm init。如果您将提示符留空,npm将冒号后面括号中的内容作为默认内容。您的内容应该类似于图2.3,当然,除了作者的名字之外。

在package.json中,值得注意的是main条目。这里,你可以看到我将它设置为"./app/main.js"。基于我们如何设置应用程序。你可以指向任何你想要的文件。我们要用的主文件恰好叫做main.js。但是它可以被命名为任何东西(例如,sandwich.js、index.js、app.js)。

图2.3 npm init 提供一系列提示并设置一个package.json文件

下载和安装Electron在我们的项目

我们已经建立了应用程序的基本结构,但是却找不到Electron。从源代码编译Electron需要一段时间,而且可能很乏味。因此我们根据每个平台(macOS、Windows和Linux)以及两种体系结构(32位和64位)预先构建了electronic版本。我们通过npm安装Electron。

下载和安装电子很容易。在您运行npm init之前,在你的项目目录中运行以下命令:

npm install electron --save-dev

此命令将在你的项目node_modules目录下下载并安装Electron(如果您还没有目录,它还会创建目录)。--save-dev标志将其添加到package.json的依赖项列表中。这意味着如果有人下载了这个项目并运行npm install,他们将默认获得Electron。

漫谈electron-prebuilt

假如您了解Electron的历史,您可能会看到博客文章、文档,甚至本书的早期版本,其中提到的是electron-prebuilt,而不是electron。在过去,前者是为操作系统安装预编译版Electron的首选方法。后者是新的首选方法。从2017年初开始,不再支持electron-prebuilt

npm还允许您定义在package.json中运行公共脚本的快捷方式。当您运行package.json定义的脚本时。npm自动添加node_modules到这个路径。这意味着它将默认使用本地安装的Electron版本。让我们向package.json添加一个start脚本。

列表2.1  向package.json添加一个启动脚本

{                                                        +
"name": "bookmarker",                                    |当我们运行npm start
"version": "1.0.0",                                      |npm将会运行什么脚本
"description": "Our very first Electron application",    |
"main": "./app/main.js",                                 |
"scripts": {                                             |
"start": "electron .",                            <------+
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Steve Kinney",
"license": "ISC",
"dependencies": {
"electron": "^2.0.4"
  }
}

现在,当我们运行npm start时,npm使用我们本地安装的版本Electron去启动Electron应用程序。你会注意到似乎没有什么事情发生。在你的终端中,它实际运行以下程式码:

>bookmarker@1.0.0  start /Users/stevekinney/Projects/bookmarker
>electron .

您还将在dock或任务栏中看到一个新应用程序(我们刚刚设置的Electron应用程序),如图2.4所示。它被简称为“Electron”,并使用Electron的默认应用程序图标。在后面的章节中,我们将看到如何定制这些属性,但是目前默认值已经足够好了。我们所有的代码文件都是完全空白的。因此,这个应用程序还有很多操作需要去做,但是它确实存在并正确启动。我们认为这是一场暂时的胜利。在windows上关闭应用程序的所有窗口或选择退出应用程序菜单终止进程。或者,您可以在Windows命令提示符或终端中按Control-C退出应用程序。按下Command-Period将终止macOS上的进程。

图2.4 dock上的应用程序就是我们刚建立的电子应用

处理主进程

现在我们有了一个Electron应用,如果我们真的能让它做点什么,那就太好了。如果你还记得第一章,我们从可以创建一个或多个渲染器进程的主进程开始。我们首先通过编写main.js代码,迈出我们应用程序的第一步。

​ 要处理Electron,我们需要导入electron库。Electron附带了许多有用的模块,我们在本书中使用了这些模块。第一个—也可以说是最重要的——是app模块。

列表2.2 添加一个基本的主进程: ./app/main.js


const {app} = require('electron');     +
app.on('ready', () => {            <---+ 在应用程序完全
 console.log('Hello from Electron');   + 启后立即调用
});

app是一个处理应用程序生命周期和配置的模块。我们可以使用它退出、隐藏和显示应用程序,以及获取和设置应用程序的属性。app模块还可以运行事件-包括before-quit, window -all-closed,

browser-window-blur, 和browser-window-focus-当应用程序进入不同状态时。

​ 在应用程序完全启动并准备就绪之前,我们无法处理它。幸运的是,app触发了一个ready事件。这意味着在做任何事之前,我们需要耐心等待并监听应用程序启动ready事件。在前面的代码中,我们在控制台打印日志,这是一件无需Electron就可以轻松完成的事情,但是这段代码强调了如何侦听ready事件。

创建渲染器进程

我们的主进程与其他Node进程非常相似。它可以访问Node的所有内置库以及由Electron提供的一组特殊模块,我们将在本书中对此进行探讨。但是,与任何其他Node进程一样,我们的主进程没有DOM(文档对象模型),也不能呈现UI。主进程负责与操作系统交互,管理状态,并与应用程序中的所有其他流程进行协调。它不负责呈现HTML和CSS。这就是渲染器进程的工作。参与整个Electron主要功能之一是为Node进程创建一个GUI。

​ 主进程可以使用BrowserWindow创建多个渲染器进程。每个BrowserWindow都是一个单独的、惟一的渲染器器进程,包括一个DOM,访问Chromium web APIs,以及Node内置模块。访问BrowserWindow模块的方式与访问app模块的方式相同。

列表2.3 引用BrowserWindow模块: ./app/main.js

const {app, BrowserWindow} = require('electron');

您可能已经注意到BrowserWindow模块以大写字母开头。根据标准JavaScript约定,这通常意味着我们用new关键字将其调用为构造函数。我们可以使用这个构造函数创建尽可能多的渲染器进程,只要我们喜欢,或者我们的计算机可以处理。当应用程序就绪时,我们创建一个BrowserWindow实例。让我们按照以下方式更新代码。

列表2.4 生成一个BrowserWindow: ./app/main.js

                                                    +
const {app, BrowserWindow} = require('electron');   |在我们的应用程序中创建一个
let mainWindow = null;                         <----+window对象的全局引用
app.on('ready', () => {                  +          +
 console.log('Hello from Electron.');    |当应用程序准备好时,
 mainWindow = new BrowserWindow();  <----+创建一个浏览器窗口
});                                      +并将其分配给全局变量

我们在ready事件监听器外声明了mainWindow。JavaScript使用函数作用域。如果我们在事件监听器中声明mainWindow, mainWindow将进行垃圾回收,因为分配给ready事件的函数已经运行完毕。如果被垃圾回收,我们的窗户就会神秘地消失。如果我们运行这段代码,我们会在屏幕中央看到一个不起眼的小窗口,如图2.5所示。

一个没有加载HTML文档的空BrowserWindow

这是一扇窗口,并什么好看的。下一步是将HTML页面加载到我们创建的BrowserWindow实例中。所有BrowserWindow实例都有一个web content属性,该属性具有几个有用的特性,比如将HTML文件加载到渲染器进程的窗口中、从主进程向渲染器进程发送消息、将页面打印为PDF或打印机等等。现在,我们最关心的是将内容加载到我们刚刚创建的那个无聊的窗口中。

  我们需要加载一个HTML页面,因此在您项目的app目录中创建index.html。让我们将以下内容添加到HTML页面,使其成为一个有效的文档。

列表2.5 创建index.html: ./app/index.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
	content="
	 default-src 'self';
	 script-src 'self' 'unsafe-inline';
	 connect-src *
	"
>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Bookmarker</title>
</head>
<body>
<h1>Hello from Electron</h1>
</body>
</html>

这很简单,但它完成了工作,并为构建打下了良好的基础。我们将以下代码添加到app/main.js中,以告诉渲染器进程在我们之前创建的窗口中加载这个HTML文档。

列表2.6 将HTML文档加载到主窗口: ./app/main.js

我们使用file://protocol_dirname变量,该变量在Node中全局可用。_dirname是Node进程正在执行的目录的完整路径。在我的例子中,_dirname扩展为**/Users/stevekinney/Projects/bookmarker/app**。

​ 现在,我们可以使用npm start启动应用程序,并观察它加载新的HTML文件。如果一切顺利,您应该会看到类似于图2.6的内容。

从渲染进程加载代码

从渲染器进程加载的HTML文件中,我们可以像在传统的基于浏览器的web应用程序中一样加载可能需要的任何其他文件-即<script><link>标签。

​ Electron与我们习惯的浏览器不同之处在于我们可以访问所有Node——甚至是我们通常认为的“客户端”。这意味着,我们可以使用require甚至Node-only对象和变量,比如_dirnameprocess模块。同时,我们还有所有可用的浏览器APIs。只能在客户端的工作和只能在服务端做的工作的分工开始消失不见。

图2.6 一个带有简单HTML文档的浏览器窗口

让我们来看看实际情况。在传统的浏览器环境中_dirname不可用,在Node中documentalert是不可用的。但在Electron,我们可以无缝地将它们结合在一起。让我们在页面上添加一个按钮。

列表2.7 添加一个按钮到HTML文档: ./app/index. html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF+8">
<meta http+equiv="Content+Security+Policy" content=" default+src 'self'; script+src 'self' 'unsafe+inline';connect+src *">
<meta name="viewport" content="width=device+width,initial+scale=1">
<title>Bookmarker</title>
</head>
<body>
<h1>Hello from Electron</h1>
<p>
<button class="alert">Current Directory</button>     <---+
</p>                                                     |这是我们
</body>                                                  |的新按钮
</html>                                                  +

现在,我们已经有了按钮,让我们添加一个事件监听器,它将提醒我们运行应用程序的当前目录。

<script>
  const button = document.querySelector('.alert');
  button.addEventListener('click', () =^ {
  alert(__dirname);             <------+单击按钮时,
  });                                  |使用浏览器警告显示
</script>                              |Node全局变量
                                       +

alert()仅在浏览器中可用。_dirname仅在Node中可用。当我们点击按钮时,我们被处理成Node和Chromium在一起工作,甜美和谐,如图2.7所示。

图2.7 在渲染器进程的上下文中,BrowserWindow执行JavaScript。

在渲染器进程中引用文件

在HTML文件中编写代码显然有效,但是不难想象,我们的代码量可能会增长到这种方法不再可行的地步。我们可以添加带有src属性的脚本标记来引用其他文件,但是这很快就会变得很麻烦。

​ 这就是web开发变得棘手的地方。虽然模块被添加到ECMAScript规范中,目前没有浏览器具有模块系统的工作实现。在客户端上,我们可以考虑使用一些构建工具,如Browserify (browserify.org)或模块bundlerwebpack,也可以使用任务运行器,如GulpGrunt

​ 我们可以使用Node的模块系统,而不需要额外的配置。让我们移除<script>标签中的所有代码到-现在是空的-app/renderer.js文件中。现在我们可以用一个<script> 标记去引用renderer.js文件去替代之前的内容。

列表2.9 从renderer.js加载JavaScript: ./app/index.html

                            +
<script>                    |使用Node的require函数
  require('./renderer'); <--+将额外的JavaScript模块
</script>                   |加载到渲染器进程中
                            +

如果我们启动应用程序,您将看到它的功能没有改变。一切都照常进行。这在软件开发中很少发生。在继续之前,让我们先体验一下这种感觉。

在渲染器进程中添加样式

当我们在Electron应用程序中引用样式表时,很少会发生意外。稍后,我们将讨论如何使用Sass而不是Electron。 在电子应用程序中添加样式表与在传统web应用程序中添加样式表没有多大不同。尽管如此,一些细微差别还是值得讨论的。

​ 让我们从将style.css文件添加到应用程序目录开始。我们将以下内容添加到style.css中。

列表2.10 添加基础样式: ./app/style.css

html {
  box+sizing: border+box;
}
*, *:before, *:after {
  box+sizing: inherit;        +使用页面所运行
}                             |的操作系统的
body, input {                 |默认系统字体
  font: menu;          <------+
}

最后一项声明可能看起来有点陌生。它是Chromium独有的,允许我们在CSS中使用系统字体。这种能力对于使我们的应用程序与其原生本机程序相适应非常重要。在macOS上,这是使用San Francisco的唯一方法,该系统字体附带El Capitan 10.11及以后版本。

​ 在Electron应用程序中使用CSS,这是我们应该考虑的另一个重要的区别。我们的应用程序将只在应用程序附带的Chromium版本中运行。我们不必担心跨浏览器支持或兼容性考虑。正如在第1章中提到的,电子与相对较新版本的Chromium一起发布。这意味着我们可以自由地使用flexbox和CSS变量等技术。

​ 我们像在传统浏览器环境中一样引用新样式表,然后将以下内容添加到index.html<head>部分。 我将包含链接到样式表的HTML标记—因为,在我作为web开发人员的20年里,我仍然不记得如何第一次尝试就做到这一点。

列表2.11 在HTML文档中引用样式表: ./app/index.html

<link rel="stylesheet" href="style.css" type="text/css">

实现用户界面

我们首先使用UI所需的标记更新index.html。

列表2.12 为应用程序的UI添加标记: ./app/index.html

<h1>Bookmarker</h1>
<div class="error-message"></div>
<section class="add-new-link">
  <form class="new-link-form">
    <input type="url" class="new-link-url" placeholder="URL"size="100"
     required>
    <input type="submit" class="new-link-submit" value="Submit" disabled>
  </form>
</section>

<section class="links"></section>
<section class="controls">
  <button class="clear-storage">Clear Storage</button>
</section>

我们有一个用于添加新链接的部分,一个用于显示所有精彩链接的部分,以及一个用于清除所有链接并重新开始的按钮。你的应用程序中的<script>标签应该和我们在本章早些时候讨论时一样,但是以防万一,我在下方给出代码:

<script>
&emsp;&emsp;require('./renderer');
</script>

标记就绪后,我们现在可以将注意力转向功能。让我们清除app/renderer.js中的所有内容,重新开始。在我们一起学习的过程中,我们将需要处理添加到标记中的一些元素,所以让我们首先查询这些选择器并将它们缓存到变量中。将以下内容添加到app/renderer.js

列表2.13 缓存DOM元素选择器: ./app/renderer.js

const  linksSection = document.querySelector('.links');
const	errorMessage = document.querySelector('.error-message');
const	newLinkForm = document.querySelector('.new-link-form');
const	newLinkUrl = document.querySelector('.new-link-url');
const	newLinkSubmit = document.querySelector('.new-link-submit');
const	clearStorageButton = document.querySelector('.clear-storage');

回顾清单2.12,您会注意到在标记中我们将input元素的type属性设置“url”。如果内容不匹配有效的URL模式,Chromium将把该字段标记为无效。不幸的是,我们无法访问Chrome或Firefox中内置的错误消息弹出框。这些弹出窗口不是Chromium web模块的一部分,因此也不是Electron的一部分。现在,我们在默认情况下禁用start按钮,然后在每次用户在URL输入框内中键入字母时检查是否有一个有效的URL语法。

​ 如果用户提供了一个有效的URL,那么我们将打开submit按钮并允许他们提交URL。让我们将这段代码添加到app/renderer.js中。

列表2.14 添加事件监听器以启用submit按钮

newLinkUrl.addEventListener('keyup', () => {
  newLinkSubmit.disabled = !newLinkUrl.validity.valid;    <------+
});                        当用户在输入字段中敲入url时               |
                           通过使用Chromium ValidityState API     |
                           来确定输入是不是有效,如果是这样,从         +
&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;submit按钮中移除disable属性

现在也是添加一个协助函数来清除URL字段内容的好时机。在理想的情况下,只要成功存储了链接,就会调用这个函数。

列表2.15 添加帮助函数来清除输入框: ./app/renderer.js

                                   +
const clearForm= () => {           |通过设置新连接输入框为空
  newLinkUrl.value = null;    <----+来清除该字段
};                                 |
                                   +

当用户提交一个链接,我们希望浏览器请求URL,然后把获取回复体,解析它,找到title元素,得到标题的文本元素,存储书签的标题和URL在localStorage,和then-finally-update书签的页面。

在Electron实现跨域请求

你可能感觉到,也可能没有感觉到,你脖子后面的一些毛发开始竖起来。你甚至可能对自己说:“这个计划不可能行得通。您不能向第三方服务器发出请求。浏览器不允许这样做。”

​ 通常来说,你是对的。在传统的基于浏览器的应用程序中,不允许客户端代码向其他服务器发出请求。通常,客户端代码向服务器发出请求,然后将请求代理给第三方服务器。当它返回时,它将响应代理回客户机。我们在第一章中讨论了这背后的一些原因。

​ Electron具有Node服务器的所有功能,以及浏览器的所有功能。这意味着我们可以自由地发出跨源请求,而不需要服务器。

​ 在Electron中编写应用程序的另一个好处是我们可以使用正在兴起的Fetch API来向远程服务器发出请求。Fetch API免去了手工设置XMLHttpRequest的麻烦,并为处理我们的请求提供了一个良好的、基于承诺的接口。在撰写本文时,主要浏览器对Fetch的支持有限。也就是说,它在当前版本的Chromium中有完整的支持,这意味着我们可以使用它。

​ 我们向表单添加一个事件侦听器,以便在表单有动作时,立即执行提交。我们没有服务器,所以需要确保避免发出请求的默认操作。我们通过防止默认操作来做到这一点。我们还缓存URL输入字段的值,以便将来使用。

列表2.16 向submit按钮添加事件侦听器: ./app/renderer.js

newLinkForm.addEventListener('submit', (event) => {
  event.preventDefault();              <-----+告诉Chromium不要触发HTTP请求,
                                             |这是表单提交的默认操作
  const url = newLinkUrl.value;  <--+        |
                                    |        +
// More code to come...             |获取新链接输入框中的URL字段,
});                                 +我们很块就会用到这个值。

Fetch API作为全局可用的fetch变量。抓取的URL返回一个promise对象,该对象将在浏览器完成时被实现 获取远程资源。使用这个promise对象,我们可以根据是否获取网页、图像或其他类型的内容来处理不同的响应。在本例中,我们正在获取一个网页,因此我们将响应转换为文本。我们从事件监听器中的以下代码开始。

列表2.17 使用Fetch API请求远程资源./app/renderer.js

fetch(url)	//使用Fetch API获取提供的URL的内容
.then(response => response.text());	//将响应解析为纯文本

Promises是链式的,我们可以使用先前承诺的返回值,并将另一个调用附加到then。此外,response.text()本身返回一个promise。我们的下一步将是获取接收到的大块标记,并解析它来遍历它并找到title元素。

解析回复报文

Chromium提供了一个解析器,它将为我们做这件事,但是我们需要实例化它。在app/renderer的顶部。我们创建了一个DOMParser实例,并将其存储起来供以后使用。

列表2.18 实例化一个DOMParser: ./app/renderer.js

const parser = new DOMParser(); //创建一个DOMParser实例。我们将在获取所提供URL的文本内容后使用此方法。

让我们设置一对帮助函数来解析响应并为我们找到标题。

列表2.19 添加用于解析响应和查找标题的函数: ./app/renderer.js

const parseResponse = (text) => {
	return parser.parseFromString(text, 'text/html');&emsp;//从URL获取HTML字符串并将其解析为DOM树。
}
const findTitle = (nodes) =>{
	return nodes.querySelector('title').innerText;	//遍历DOM树以找到标题节点。
}

现在我们可以将这两个步骤添加到我们的处理链中。

列表2.20 解析响应并在获取页面时查找标题: ./app/renderer.js

fetch(url)
	.then(response => response.text())
	.then(parseResponse)
	.then(findTitle);

此时,app/renderer.js中的代码看起来是这样的。

const parser = new DOMParser();
const linksSection = document.querySelector('.links');
const errorMessage = document.querySelector('.error-message');
const newLinkForm = document.querySelector('.new-link-form');
const newLinkUrl = document.querySelector('.new-link-url');
const newLinkSubmit = document.querySelector('.new-link-submit');
const clearStorageButton = document.querySelector('.clear-storage');

newLinkUrl.addEventListener('keyup', () => {
	newLinkSubmit.disabled = !newLinkUrl.validity.valid;
});

newLinkForm.addEventListener('submit', (event) => {
	event.preventDefault();
	const url = newLinkUrl.value;
	fetch(url)
		.then(response => response.text())
	.then(parseResponse)
	.then(findTitle)
});

const clearForm = () => {
	newLinkUrl.value = null;
}

const parseResponse = (text) => {
	return parser.parseFromString(text, 'text/html');
}

const findTitle = (nodes) => {
	return nodes.querySelector('title').innerText;
}

使用web storage APIs存储响应

localStorage是一个简单的键/值存储,内置在浏览器中并持久保存之间的会话。您可以在任意键下存储简单的数据类型,如字符串和数字。让我们设置另一个帮助函数,它将从标题和URL生成一个简单的对象,使用内置的JSON库将其转换为字符串,然后使用URL作为键存储它。

图2.22 创建一个函数来在本地存储中保存链接: ./app/renderer.js

const storeLink = (title, url) => {
		localStorage.setItem(url, JSON.stringify({ title: title, url: url }));
};

我们的新storeLink函数需要标题和URL来完成它的工作,但是前面的处理只返回标题。我们使用一个箭头函数将对storeLink的调用封装在一个匿名函数中,该匿名函数可以访问作用域中的url变量。如果成功,我们也清除表单。

图2.23 存储链接并在获取远程资源时清除表单: ./app/renderer.js

                                            
fetch(url)                                  
 .then(response => response.text())         
 .then(parseResponse)                       |
 .then(findTitle)                           |将标题和URL存储到localStorage
 .then(title => storeLink(title, url))  <---+
 .then(clearForm);

显示请求结果

存储链接是不够的。我们还希望将它们显示给用户。这意味着我们需要创建功能来遍历存储的所有链接,将它们转换为DOM节点,然后将它们添加到页面中。

让我们从从localStorage获取所有链接的能力开始。如果你还记得,localStorage是一个键/值存储。我们可以使用对象。获取对象的所有键。我们必须为自己提供另一个帮助函数来将所有链接从localStorage中取出。这没什么大不了的,因为我们需要将它们从字符串转换回实际对象。让我们定义一个getLinks函数。

图2.24 创建用于从本地存储中获取链接的函数: ./app/renderer.js

                                       
                                       
const getLinks = () => {               |
                                       |获取当前存储在localStorage中的所有键的数组
  return Object.keys(localStorage) <---+
    .map(key => JSON.parse(localStorage.getItem(key)));   <----+
}                                                              |对于每个键,获取其值
                                                               |并将其从JSON解析为JavaScript对象
                                                               

接下来,我们将这些简单的对象转换成标记,以便稍后将它们添加到DOM中。我们创建了一个简单的convertToElement 帮助函数,它也可以处理这个问题。需要指出的是,我们的convertToElement函数有点幼稚,并且不尝试清除用户输入。理论上,您的应用程序很容易受到脚本注入攻击。这有点超出了本章的范围,所以我们只做了最低限度的渲染这些链接到页面上。我将把它作为练习留给读者来确保这个特性的安全性。

列表2.25 创建一个从链接数据创建DOM节点的函数: ./app/renderer.js

const convertToElement = (link) => {
	return `
<div class="link">
<h3>${link.title}</h3>
<p>
<a href="${link.url}">${link.url}</a>
</p>
</div>
`;
};

最后,我们创建一个renderLinks()函数,它调用getLinks,连接它们,使用convertToElement()转换集合,然后替换页面上的linksSection元素。

列表2.26 创建一个函数来呈现所有链接并将它们添加到DOM中: ./app/renderer.js

const renderLinks = () => {
	const linkElements = getLinks().map(convertToElement).join('');	//将所有链接转换为HTML元素并组合它们
	linksSection.innerHTML = linkElements;	//用组合的链接元素替换links部分的内容
};

现在我们可以往处理链上添加最后一步。

列表2.27 获取远程资源后呈现链接: ./app/renderer.js

fetch(url)
	.then(response => response.text())
	.then(parseResponse)
	.then(findTitle)
	.then(title => storeLink(title, url))
	.then(clearForm)
	.then(renderLinks);

当页面初始加载时,我们还通过在顶层范围内调用renderLinks()来呈现所有链接。

列表2.28 加载和渲染链接: ./app/renderer.js

renderLinks();	//一旦页面加载,就调用我们之前创建的renderLinks()函数

使用promise与将功能分解为命名的帮助函数相协调的一个优点是,我们的代码通过获取外部页面、解析它、存储结果和重新对链接列表进行排序的过程非常清楚。

最后一件事,我们需要完成我们的简单应用程序的所有功能安装的方法是连接“清除存储”按钮。我们在localStorage上调用clear方法,然后在linksSection中清空列表。

列表2.29 编写清除存储按钮: ./app/renderer.js

clearStorageButton.addEventListener('click', () => {
	localStorage.clear();	//清空localStorage中的所有链接
	linksSection.innerHTML = '';    //从UI上移除所有链接
});

有了Clear Storage按钮,似乎我们已经具备了大部分功能。我们的应用程序现在看起来如图2.8所示。此时,呈现器过程的代码应该如清单2.30所示。

列表2.30 获取、存储和呈现链接的渲染器进程: ./app/renderer.js

const parser = new DOMParser();
const linksSection = document.querySelector('.links');
const errorMessage = document.querySelector('.error-message');
const newLinkForm = document.querySelector('.new-link-form');
const newLinkUrl = document.querySelector('.new-link-url');
const newLinkSubmit = document.querySelector('.new-link-submit');
const clearStorageButton = document.querySelector('.clear-storage');
const newLinkUrl.addEventListener('keyup', () => {
const newLinkSubmit.disabled = !newLinkUrl.validity.valid;
});
newLinkForm.addEventListener('submit', (event) => {
	event.preventDefault();
    
const url = newLinkUrl.value;
    
fetch(url)
	.then(response => response.text())
	.then(parseResponse)
	.then(findTitle)
	.then(title => storeLink(title, url))
	.then(clearForm)
	.then(renderLinks);
});

clearStorageButton.addEventListener('click', () => {
	localStorage.clear();
	linksSection.innerHTML = '';
});

const clearForm = () => {
	newLinkUrl.value = null;
}

const parseResponse = (text) => {
	return parser.parseFromString(text, 'text/html');
}

const findTitle = (nodes) => {
	return nodes.querySelector('title').innerText;
}

const storeLink = (title, url) => {
	localStorage.setItem(url, JSON.stringify({ title: title, url: url }));
}

const getLinks = () => {
	return Object.keys(localStorage)
		.map(key => JSON.parse(localStorage.getItem(key)));
}

const convertToElement = (link) => {
	return `<div class="link"><h3>${link.title}</h3>
		<p><a href="${link.url}">${link.url}</a></p></div>`;
}

const renderLinks = () => {
	const linkElements = getLinks().map(convertToElement).join('');
	linksSection.innerHTML = linkElements;
}

renderLinks();

错误的请求路径

到目前为止,一切似乎都运转良好。我们的应用程序从外部页面获取标题,在本地存储链接,在页面上呈现链接,并在需要时从页面中清除它们。

但是如果出了什么问题呢?如果我们给它一个无效链接会发生什么?如果请求超时会发生什么?我们将处理两种最可能的情况:当用户提供一个URL,该URL通过了输入字段的验证检查,但实际上并不有效;当URL有效,但服务器返回400或500级错误时。

我们添加的第一件事是处理任何错误的能力。我们需要提供一个捕获异常的方法,当出现错误的时候,进行调用。我们在这个事件中定义了另一个帮助方法。

图2.31 显示错误消息: ./app/renderer.js

const handleError = (error, url) => {                     +如果获取链接失败,
&emsp;&emsp;errorMessage.innerHTML = `                             |则设置错误消息元素的内容
&emsp;&emsp;There was an issue adding "${url}": ${error.message}   |         +
&emsp;&emsp;`.trim();                                         <----+         |
&emsp;&emsp;setTimeout(() => errorMessage.innerText = null, 5000);      <----+5秒后清除错误消息
}                                                                   +

我们可以把它加到链上。我们使用另一个匿名函数传递带有错误消息的URL。这主要是为了提供更好的错误消息。如果不希望在错误消息中包含URL,则没有必要这样做。

图2.32 在获取、解析和呈现链接时捕获错误: ./app/renderer.js

fetch(url)
&emsp;.then(response => response.text())
&emsp;.then(parseResponse)                          +
&emsp;.then(findTitle)                              |
&emsp;.then(title => storeLink(title, url))         |如果此处理链中的任何错误拒绝或抛出错误
&emsp;.then(clearForm)                              |则捕获错误并将其显示在UI中
&emsp;.then(renderLinks)                            |
&emsp;.catch(error => handleError(error, url));  <--+

我们还在前面添加了一个步骤,用于检查请求是否成功。如果是,它将请求传递给处理链中的下一个操作。如果没有成功,那么我们将抛出一个错误,这将绕过处理链中的其余操作,并直接跳到handleError()步骤。这里有一个我没有处理的异常情况:如果Fetch API不能建立网络连接,那么它返回的承诺将被完全拒绝。我把它作为练习留给读者来处理,因为我们在这本书中有很多内容要讲,而且页数有限。响应。如果状态码在400或500范围内,response.ok将为false。

图2.33 验证来自远程服务器的响应: ./app/renderer.js

                                                    +
                                                    |如果响应成功,则将其
const validateResponse = (response) => {            |传递给下一个处理链
&emsp;if (response.ok) { return response; }        <-----+
&emsp;throw new Error(`Status code of ${response.status} +
&emsp;&emsp;${response.statusText}`);           <-----+
}                                           &emsp;|如果请求收到400500系列响应
                                            &emsp;+则引发错误。

如果没有错误,此代码将传递响应对象。但是,如果出现错误,它会抛出一个错误,handleError()会捕捉到这个错误并相应地进行处理。

图2.34 在处理链中添加validateResponse(): ./app/renderer.js

fetch(url)
	.then(validateResponse)
	.then(response => response.text())
	.then(parseResponse)
	.then(findTitle)
    .then(title => storeLink(title, url))
	.then(clearForm)
	.then(renderLinks)
	.catch(error => handleError(error, url));

一个意想不到的错误

我们还没有走出困境——如果一切顺利的话,我们还有一个问题。如果单击应用程序中的一个链接会发生什么?也许并不奇怪,它指向了那个链接。我们的Electron应用程序的Chromium部分认为它是一个web浏览器,所以它做了web浏览器最擅长的事情—它进入页面。

​ 只是我们的应用程序并不是真正的web浏览器。它缺少后退按钮或位置栏等重要功能。如果我们点击应用程序中的任何链接,我们就会几乎被困在那里。我们唯一的选择是关闭应用程序,重新开始。

​ 解决方案是在真正的浏览器中打开链接。但这引出了一个问题,哪个浏览器?我们如何知道用户将什么设置为默认浏览器?我们当然不想做任何侥幸的猜测,因为我们不知道用户安装了什么浏览器,而且没有人喜欢看到错误的应用程序仅仅因为他们点击了一个链接就开始打开。 ​ Electron随shell模块一起载运,shell模块提供了一些与之相关的功能,高级桌面集成。shell模块可以询问用户的操作系统他们更喜欢哪个浏览器,并将URL传递给要打开的浏览器。让我们从引入Electron开始,并在app/renderer.js的顶部存储对其shell模块的引用。

列表2.35 引用Electron的shell 模块: ./app/renderer.js

const {shell} = require('electron');

我们可以使用JavaScript来确定我们希望在应用程序中处理哪些url,以及我们希望将哪些url传递给默认浏览器。在我们的简单应用程序中,区别很简单。我们希望所有的链接都在默认浏览器中打开。这个应用程序中正在添加和删除链接,因此我们在linksSection元素上设置了一个事件监听器,并允许单击事件弹出。如果目标元素具有href属性,我们将阻止默认操作并将URL传递给默认浏览器。

列表2.36 在默认浏览器中打开链接: ./app/renderer.js

                                                     +
                                                     |通过查找href属性
                                                     |检查被单击的元素是否为链接
linksSection.addEventListener('click', (event) => {  |
  if (event.target.href) {                       <---+
    event.preventDefault();                    <----+
    shell.openExternal(event.target.href); <--+     |如果它不是一个连接,
  }                                           |     |不打开
Uses Electrons shell module                  |     +
});                 在默认浏览器中使用Electorn   |
                     打开链接                   +

通过相对简单的更改,我们的代码的行为就像预期的那样。单击链接将在用户的默认浏览器中打开该页。我们有一个简单但功能齐全的桌面应用程序了。

​ 我们完成的代码应该如下面的代码示例所示。你可能以不同的顺序使用您的功能。

列表2.37 完成的应用程序: ./app/renderer.js

const {shell} = require('electron');

const parser = new DOMParser();

const linksSection = document.querySelector('.links');
const errorMessage = document.querySelector('.error-message');
const newLinkForm = document.querySelector('.new-link-form');
const newLinkUrl = document.querySelector('.new-link-url');
const newLinkSubmit = document.querySelector('.new-link-submit');
const clearStorageButton = document.querySelector('.clear-storage');

newLinkUrl.addEventListener('keyup', () => {
  newLinkSubmit.disabled = !newLinkUrl.validity.valid;
});

newLinkForm.addEventListener('submit', (event) => {
  event.preventDefault();

  const url = newLinkUrl.value;

  fetch(url)
    .then(response => response.text())
    .then(parseResponse)
    .then(findTitle)
    .then(title => storeLink(title, url))
    .then(clearForm)
    .then(renderLinks)
    .catch(error => handleError(error, url));
});

clearStorageButton.addEventListener('click', () => {
  localStorage.clear();
  linksSection.innerHTML = '';
});

linksSection.addEventListener('click', (event) => {
  if (event.target.href) {
    event.preventDefault();
    shell.openExternal(event.target.href);
  }
});


const clearForm = () => {
  newLinkUrl.value = null;
}

const parseResponse = (text) => {
  return parser.parseFromString(text, 'text/html');
}

const findTitle = (nodes) => {
  return nodes.querySelector('title').innerText;
}

const storeLink = (title, url) => {
  localStorage.setItem(url, JSON.stringify({ title: title, url: url }));
}

const getLinks = () => {
  return Object.keys(localStorage)
               .map(key => JSON.parse(localStorage.getItem(key)));
}

const convertToElement = (link) => {
  return `<div class="link"><h3>${link.title}</h3>
          <p><a href="${link.url}">${link.url}</a></p></div>`;
}

const renderLinks = () => {
  const linkElements = getLinks().map(convertToElement).join('');
  linksSection.innerHTML = linkElements;
}

const handleError = (error, url) => {
  errorMessage.innerHTML = `
    There was an issue adding "${url}": ${error.message}
  `.trim();
  setTimeout(() => errorMessage.innerText = null, 5000);
}

const validateResponse = (response) => {
  if (response.ok) { return response; }
  throw new Error(`Status code of ${response.status} ${response.statusText}`);
}

renderLinks();

总结

  • Electron不推荐或强制执行特定的项目结构。
  • Electron使用npm的package.json清单来决定那个文件被加载作为主进程
  • 我们通过使用npm init从样板文件中生产package.json
  • 我们通常在每个项目中都在本地安装Electron。这允许我们有特定项目版本的Electron。
  • 我们可以在Electron应用程序中使用require('electron')来访问Electron特定的模块和功能。
  • app模块管理电子应用的生命周期。
  • 主进程无法呈现UI。
  • 我们可以使用Browser-window模块从主进程创建渲染进程
  • Electron允许我们直接从第三方服务器发出请求,并不需要中间服务器的代理。传统的web应用程序则不允许这样做。
  • 在localStorage中存储数据将允许它在我们退出并重新打开时保持。