JavaScript-示例-全-

JavaScript 示例(全)

原文:zh.annas-archive.org/md5/7B2D5876FA8197B4A2F4F8B32190F638

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

JavaScript 是一种快速发展的语言,每年都会添加新功能。本书旨在通过使用 JavaScript 构建各种应用程序来让您动手实践。这将帮助您在 JavaScript 上建立坚实的基础,从而有助于您适应其未来的新功能,以及学习其他现代框架和库。

本书涵盖内容

第一章,构建 ToDo 列表,从简单的 DOM 操作开始,使用 JavaScript 和事件监听器,这将让您对 JavaScript 如何与网站中的 HTML 进行交互有一个很好的理解。您将设置基本的开发环境并构建您的第一个 ToDo 列表应用程序。

第二章,构建 Meme Creator,帮助您构建一个有趣的应用程序 Meme Creator。通过这个,您将了解画布元素,使用 ES6 类,并介绍使用 CSS3 flexbox 进行布局。本章还向您介绍了 Webpack,并使用它设置自己的自动化开发环境。

第三章,事件注册应用程序,专注于开发具有适当表单验证的响应式事件注册表单,允许用户注册您即将举办的活动,并通过图表直观地显示注册数据。本章将帮助您了解执行 AJAX 请求的不同方法以及如何处理动态数据。

第四章,使用 WebRTC 构建实时视频通话应用程序,使用 WebRTC 在 JavaScript 中构建实时视频通话和聊天应用程序。本章重点介绍了在浏览器中使用 JavaScript 可用的强大 Web API。

第五章,开发天气小部件,帮助您使用 HTML5 自定义元素为应用程序构建天气小部件。您将了解 Web 组件及其在 Web 应用程序开发中的重要性。

第六章,使用 React 构建博客,讨论了由 Facebook 创建的用于在 JavaScript 中构建用户界面的库 React。然后,您将使用 React 和诸如create-react-appreact-router之类的工具构建博客。

第七章,Redux,将深入探讨使用 Redux 管理 React 组件之间的数据,从而使您的博客更易于维护和扩展,并提供更好的用户体验。

您需要为本书做好准备

为了在本书中构建项目时获得最佳体验,您将需要以下内容:

  • 至少 4GB RAM 内存的 Windows 或 Linux 机器,或 Mac

  • iPhone 或 Android 移动设备

  • 快速的互联网连接

本书适合谁

本书适用于具有 HTML、CSS 和 JavaScript 基础知识的 Web 开发人员,他们希望提高自己的技能并构建强大的 Web 应用程序。

具有 JavaScript 或任何其他编程语言的基础知识将会很有帮助。但是,如果您完全不了解 JavaScript 和编程,那么您可以阅读以下简单的教程之一,这将帮助您开始学习 JavaScript 的基础知识,然后您就可以立即阅读本书了:

惯例

在本书中,您会发现一些区分不同类型信息的文本样式。以下是一些示例以及它们的含义解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:“在我们的index.html文件中,我们的<body>元素被分成一个导航栏和包含网站内容的div。”

代码块设置如下:

loadTasks() {
  let tasksHtml = this.tasks.reduce((html, task, index) => html +=  
  this.generateTaskHtml(task, index), '');
  document.getElementById('taskList').innerHTML = tasksHtml;
 }

当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:

function mapStateToProps() {
  return {
    // No states needed by App Component
  };
}

任何命令行输入或输出都以以下方式编写:

npm install -g http-server

新术语重要单词以粗体显示。

屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:“单击主页上的“阅读更多”按钮将立即带您到帖子详情页面。”

警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。

第一章:构建待办事项清单

Hi there!

在本书中,我们将使用 JavaScript 构建一些非常有趣的应用程序。JavaScript 已经从在浏览器中用于表单验证的简单脚本语言发展为一种强大的编程语言,几乎在任何地方都有应用。请查看以下用例:

  • 想要设置一个服务器来处理数百万请求和大量 I/O 操作?您可以使用 Node.js 的单线程非阻塞 I/O 模型轻松处理重负载。使用 Node.js 框架(如ExpressSails)在服务器上编写 JavaScript。

  • 想要构建大规模的 Web 应用程序?现在是成为前端开发人员的激动人心的时刻,因为有很多新的 JavaScript 框架,如ReactAngular 2Vue.js等,可用于加快开发流程并轻松构建大规模应用程序。

  • 想要构建移动应用程序?选择React NativeNativeScript,您可以使用 JavaScript 编写的单个代码库构建跨 iOS 和 Android 的真正本地移动应用程序。还不够?使用PhoneGapIonic简单地使用 HTML、CSS 和 JavaScript 创建移动应用程序。就像 Web 应用程序一样!

  • 想要构建桌面应用程序?使用Electron使用 HTML、CSS 和 JavaScript 构建跨平台本地桌面应用程序。

  • JavaScript 在构建虚拟现实VR)和增强现实AR)应用程序中也扮演着重要角色。查看React VRA-Frame用于构建 WebVR 体验以及Argon.jsAR.js用于向 Web 应用程序添加 AR。

JavaScript 也在迅速发展。随着ECMAScript 2015ES6)的引入,语言中引入了许多新的功能,简化了开发人员的许多工作,为他们提供了以前只能使用 TypeScript 和 CoffeeScript 实现的功能。甚至在 JavaScript 的新规范(ES7 及更高版本)中还添加了更多功能。现在是成为 JavaScript 开发人员的激动人心的时刻,本书旨在建立坚实的基础,以便您将来可以适应前面提到的任何 JavaScript 平台/框架。

本章面向那些了解 HTML、CSS 和 JavaScript 基本概念,但尚未学习新主题(如 ES6、Node 等)的读者。本章将涵盖以下主题:

  • 文档对象模型DOM)操作和事件监听器

  • 介绍 ES6 JavaScript 的实际用法

  • 使用 Node 和 npm 进行前端开发

  • 使用 Babel 将 ES6 转译为 ES5

  • 使用 npm 脚本设置自动化开发服务器

如果您觉得对这些主题感到舒适,可以跳到下一章,我们将在那里处理一些高级工具和概念。

系统要求

JavaScript 是网络的语言。因此,您可以使用带有网络浏览器和文本编辑器的任何系统构建 Web 应用程序。但是,我们确实需要一些工具来构建现代复杂的 Web 应用程序。为了获得更好的开发体验,建议使用具有至少 4GB RAM 的 Linux 或 Windows 机器或 Mac 机器。在开始之前,您可能希望在系统中设置以下一些应用程序。

文本编辑器

首先,您需要一个友好的 JavaScript 文本编辑器。文本编辑器在编写代码时非常重要。根据它们提供的功能,您可以节省大量的开发时间。有一些非常好的文本编辑器支持多种语言。在本书中,我们将使用 JavaScript,因此我建议获取其中一个开源的 JavaScript 友好的文本编辑器:

您也可以尝试 Sublime Text:www.sublimetext.com/,这是一个很棒的文本编辑器,但与前面提到的不同,Sublime Text 是商业软件,您需要付费才能继续使用。还有另一个商业产品 WebStorm:www.jetbrains.com/webstorm/,它是一个专门为 JavaScript 打造的全功能集成开发环境IDE)。它配备了各种用于调试和与 JavaScript 框架集成的工具。您可能想试试看。

我建议在本书的项目中使用Visual Studio CodeVSCode)。

Node.js

这是本书中我们将一直使用的另一个重要工具,Node.js。Node.js 是建立在 Chrome 的 V8 引擎上的 JavaScript 运行时。它让您可以在浏览器之外运行 JavaScript。Node.js 变得非常流行,因为它让您可以在服务器上运行 JavaScript,并且由于其非阻塞 I/O 方法,它非常快速。

Node.js 的另一个优点是它有助于创建命令行工具,可用于各种用途,如自动化、代码脚手架等,本书中我们将使用其中许多。在撰写本书时,Node.js 的最新长期支持LTS)版本是 6.10.2。我将在本书中一直使用这个版本。您可以在阅读本书时安装最新的 LTS 版本。

对于 Windows 用户

在 Windows 上安装非常简单;只需下载并安装最新的 LTS 版本:nodejs.org/en/

对于 Linux 用户

最简单的方法是通过遵循提供的说明,通过软件包管理器安装最新的 LTS 版本:nodejs.org/en/download/package-manager/

对于 Mac 用户

使用 Homebrew 安装 Node.js:

  • 从以下网址安装 Homebrew:brew.sh/

  • 在终端中运行以下命令:brew install node

安装了 Node.js 之后,在终端(Windows 用户的命令提示符)中运行node -v,以检查是否已正确安装。这应该会打印出您已安装的当前版本。

谷歌浏览器

最后,在您的系统中安装最新版本的谷歌浏览器:www.google.com/chrome/。您可以使用 Firefox 或其他浏览器,但我将使用 Chrome,所以如果您使用 Chrome,跟随起来会更容易。

既然我们的系统中已经安装了所有必要的工具,让我们开始构建我们的第一个应用程序!

待办事项应用

让我们来看看我们即将构建的应用程序:

我们将构建这个简单的待办事项应用,它允许我们创建任务列表,标记已完成的任务,并从列表中删除任务。

让我们从本书的代码文件中使用第一章的起始代码开始。起始代码将包含三个文件:index.htmlscripts.jsstyles.css。在 Web 浏览器中打开index.html文件,以查看待办事项应用的基本设计,如前面的屏幕截图所示。

JavaScript 文件将是空的,我们将在其中编写脚本来创建应用程序。让我们来看看 HTML 文件。在<head>部分中,包含了对styles.css文件和 BootstrapCDN 的引用,在<body>标签的末尾,包括了 jQuery 和 Bootstrap 的 JS 文件以及我们的scripts.js文件:

  • Bootstrap 是一个 UI 开发框架,可以帮助我们更快地构建响应式 HTML 设计。Bootstrap 带有一组需要 jQuery 运行的 JavaScript 代码。

  • jQuery 是一个简化 DOM 遍历、DOM 操作、事件处理等 JavaScript 函数的 JavaScript 库。

Bootstrap 和 jQuery 通常一起用于构建 Web 应用程序。在本书中,我们将更多地专注于使用 JavaScript。因此,它们两者都不会被详细介绍。但是,你可以在 w3school 的网站上详细学习 Bootstrap:www.w3schools.com/bootstrap/default.asp 和 jQuery:www.w3schools.com/jquery/default.asp

在我们的 HTML 文件中,最后包含的 CSS 文件中的样式将覆盖之前文件中的样式。因此,如果我们打算重写框架的默认 CSS 属性,最好的做法是在默认框架的 CSS 文件(在我们的情况下是 Bootstrap)之后包含我们自己的 CSS 文件。在本章中我们不需要担心 CSS,因为我们不打算编辑 Bootstrap 的默认样式。我们只需要专注于我们的 JS 文件。JavaScript 文件必须按照起始代码中给定的顺序包含:

<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<script src="scripts.js"></script>

我们首先包含 jQuery 代码,然后包含 Bootstrap JS 文件。这是因为 Bootstrap 的 JS 文件需要 jQuery 来运行。如果我们先包含 Bootstrap JS,它将在控制台中打印一个错误,说 Bootstrap 需要 jQuery 来运行。尝试将 Bootstrap 代码移动到 jQuery 代码上方,并打开浏览器的控制台。对于谷歌浏览器,在 Windows 或 Linux 上是Ctrl+Shift+J,在 Mac 上是command+option+J。你将收到类似于这样的错误:

因此,我们目前通过按正确的顺序包含 JS 文件来管理依赖关系。然而,在更大的项目中,这可能会非常困难。在下一章中,我们将看一种更好的方式来管理我们的 JS 文件。现在,让我们继续构建我们的应用程序。

我们的 HTML 文件的 body 部分分为两个部分:

  • 导航栏

  • 容器

通常我们使用导航栏来为我们的 Web 应用程序的不同部分添加链接。由于在这个应用程序中我们只处理单个页面,所以我们只会在导航栏中包含页面标题。

我已经在 HTML 元素中包含了许多类,比如navbarnavbar-inversenavbar-fixed-topcontainercol-md-2col-xs-2等等。它们用于使用 Bootstrap 对元素进行样式设置。我们将在后面的章节中讨论它们。现在,让我们只专注于功能部分。

Chrome DevTools

在 body 部分,我们有一个输入字段和一个按钮来添加新任务,以及一个无序列表来列出任务。无序列表将有一个复选框来标记任务已完成,以及一个删除图标来从列表中删除任务。你可能会注意到列表中的第一项使用删除线标记为已完成。如果你使用 Chrome DevTools 检查元素,你会注意到它有一个额外的类complete,它使用 CSS 在文本上添加了删除线,这在我们的styles.css文件中定义。

使用 Chrome DevTools 检查元素,右键单击该元素并选择检查。你也可以在 Windows 或 Linux 上点击Ctrl+Shift+C,或者在 Mac 上点击command+shift+C,然后将鼠标悬停在元素上以查看其详细信息。你也可以直接编辑元素的 HTML 或 CSS 以查看页面上的变化。从列表中的第一项的div中删除complete类。你会发现删除线已经消失了。在 DevTools 中直接进行的更改是临时的,在刷新页面时会被清除。查看以下图片,了解在 Chrome 中检查元素的工具列表:

  • A:右键单击检查元素

  • B:点击光标图标,通过将鼠标悬停在元素上选择不同的元素

  • C:直接编辑页面的 HTML

  • D:直接编辑与元素相关的 CSS

Chrome DevTools 的另一个不错的功能是,你可以在你的 JavaScript 代码中的任何地方写入debugger,Google Chrome 会在调用debugger的地方暂停脚本的执行。一旦执行暂停,你可以将光标悬停在源代码中的变量上,它会显示弹出窗口中包含的变量的值。你也可以在控制台选项卡中输入变量的名称来查看其值。

这是 Google Chrome 调试器的截图:

随意探索 Chrome 开发者工具的不同部分,以更多地了解它为开发人员提供的工具。

开始使用 ES6

现在你对开发者工具有了一个很好的了解,让我们开始编码部分。你应该已经熟悉了 JavaScript ES5 语法。因此,在本章中,让我们探索 JavaScript 的 ES6 语法。ES6(ECMAScript 2015)是 ECMAScript 语言规范的第六个主要版本。JavaScript 是 ECMAScript 语言规范的一种实现。

在撰写本书时,ES8 是 JavaScript 语言的最新版本。然而,为了简单和易于理解,本书仅关注 ES6。一旦掌握了 ES6 的知识,你可以轻松地在互联网上了解 ES7 及更高版本引入的最新功能。

在撰写本书时,所有现代浏览器都支持大部分 ES6 功能。然而,旧版浏览器不了解新的 JavaScript 语法,因此它们会抛出错误。为了解决这种向后兼容性问题,我们需要在部署应用程序之前将 ES6 代码转译为 ES5。让我们在本章末尾详细了解这一点。最新版本的 Chrome 支持 ES6;因此,现在我们将直接使用 ES6 语法创建我们的 ToDo List。

我将详细解释新的 ES6 语法。如果你在理解普通 JavaScript 语法和数据类型方面遇到困难,请参考以下 w3schools 页面中的相应部分:www.w3schools.com/js/default.asp.

在文本编辑器中打开scripts.js文件。首先,我们将创建一个包含我们的 ToDo List 应用程序方法的类,是的!在 ES6 中,类是 JavaScript 的一个新添加。使用类在 JavaScript 中创建对象很简单。它让我们将代码组织为模块。在脚本文件中创建一个名为ToDoClass的类,并刷新浏览器:

class ToDoClass {
  constructor() {
    alert('Hello World!');
  }
}
window.addEventListener("load", function() {
  var toDo = new ToDoClass();
});

你的浏览器现在会弹出一个警报,显示“Hello World!”。这是代码的作用。首先,window.addEventListener将在窗口上附加一个事件监听器,并等待窗口完成加载所有所需的资源。一旦加载完成,将触发load事件,调用我们事件监听器的回调函数,初始化ToDoClass并将其赋值给变量toDo。在初始化ToDoClass时,它会自动调用构造函数,创建一个显示“Hello World!”的警报。我们可以进一步修改我们的代码以利用 ES6。在window.addEventListener部分,你可以将其重写为:

let toDo;
window.addEventListener("load", () => {
  toDo = new ToDoClass();
});

首先,我们用新的箭头函数() => {}替换匿名回调函数function () {}。其次,我们用let而不是var定义变量。

箭头函数

箭头函数是在 JavaScript 中定义函数的更清晰和更简洁的方式,它们简单地继承其父级的this对象,而不是绑定自己的。我们很快会看到更多关于this绑定的内容。让我们先看看使用新语法。考虑以下函数:

let a = function(x) {
}
let b = function(x, y) {
}

等效的箭头函数可以写成:

let a = x => {}
let b = (x,y) => {}

你可以看到,当我们必须将唯一的单个参数传递给函数时,()是可选的。

有时,我们在函数中只需在一行中返回一个值,例如:

let sum = function(x, y) {
  return x + y;
}

如果我们想在箭头函数中直接在一行中返回一个值,我们可以直接忽略return关键字和{}花括号,并将其写为:

let sum = (x, y) => x+y;

就是这样!它将自动返回xy的和。但是,这只能在您想要立即在一行中返回值时使用。

let、var 和 const

接下来,我们有let关键字。ES6 有两个用于声明变量的新关键字,letconst。使用它们声明的变量的作用域有所不同。使用var声明的变量的作用域在定义它的函数内部,并且如果没有在任何函数内部定义,则为全局,而let的作用域仅限于声明它的封闭块内,并且如果没有在任何封闭块内定义,则为全局。看看以下代码:

var toDo;
window.addEventListener("load", () => {
  var toDo = new ToDoClass();
});

如果您在代码中的其他地方意外重新声明toDo,如下所示,您的类对象将被覆盖:

var toDo = "some value";

这种行为对于大型应用程序来说很令人困惑,也很难维护变量。因此,在 ES6 中引入了let。它只限制了变量的作用域在声明它的封闭块内。在 ES6 中,鼓励使用let而不是var来声明变量。看看以下代码:

let toDo;
window.addEventListener("load", () => {
 toDo = new ToDoClass();
});

现在,即使您在代码的其他地方意外重新声明toDo,JavaScript 也会抛出错误,使您免受运行时异常。封闭块是两个花括号{}之间的代码块,花括号可能属于函数,也可能不属于函数。

我们需要一个toDo变量在整个应用程序中都可以访问。因此,我们在事件侦听器上方声明toDo,并在回调函数内将其分配给类对象。这样,toDo变量将在整个页面中都可以访问。

let非常有用于定义for循环中的变量。您可以创建一个for循环,例如for(let i=0; i<3; i++) {},并且变量i的作用域将仅在for循环内。您可以轻松地在代码的其他地方使用相同的变量名。

让我们来看看另一个关键字constconst的工作方式与let相同,只是使用const声明的变量不能更改(重新分配)。因此,const用于常量。但是,整个常量不能被重新分配,但它们的属性可以被更改。例如:

const a = 5;
a = 7; // this will not work
const b = {
  a: 1,
  b: 2
};
b = { a: 2, b: 2 }; // this will not work
b.a = 2; // this will work since only a property of b is changed

在 ES6 中编写代码时,始终使用const来声明变量。只有在需要对变量进行任何更改(重新分配)时才使用let,完全避免使用var

toDo对象包含类变量和函数作为对象的属性和方法。如果您需要了解 JavaScript 中对象的结构,请参阅:www.w3schools.com/js/js_objects.asp

从数据加载任务

我们应用程序中要做的第一件事是从一组数据动态加载任务。让我们声明一个包含任务数据以及预填充任务所需方法的类变量。ES6 没有直接提供声明类变量的方法。我们需要使用构造函数声明变量。我们还需要一个函数将任务加载到 HTML 元素中。因此,我们将创建一个loadTasks()方法:

class ToDoClass {
  constructor() {
    this.tasks = [
        {task: 'Go to Dentist', isComplete: false},
         {task: 'Do Gardening', isComplete: true},
         {task: 'Renew Library Account', isComplete: false},
    ];
    this.loadTasks();
  }

  loadTasks() {
  }
}

tasks变量在构造函数内部声明为this.tasks,这意味着 tasks 变量属于thisToDoClass)。该变量是一个包含任务详情和完成状态的对象数组。第二个任务被设置为已完成。现在,我们需要为数据生成 HTML 代码。我们将重用 HTML 中<li>元素的代码来动态生成任务:

  <li class="list-group-item checkbox">
  <div class="row">
    <div class="col-md-1 col-xs-1 col-lg-1 col-sm-1 checkbox">
     <label><input type="checkbox" value="" class="" checked></label>
    </div>
    <div class="col-md-10 col-xs-10 col-lg-10 col-sm-10 task-text complete">
      First item
    </div>
     <div class="col-md-1 col-xs-1 col-lg-1 col-sm-1 delete-icon-area">
      <a class="" href="/"><i class="delete-icon glyphicon glyphicon-trash"></i></a>
     </div>
   </div>
 </li>

在 JavaScript 中,类的实例被称为类对象或简单对象。类对象的结构类似于 JSON 对象中的键值对。与类对象关联的函数称为其方法,与类对象关联的变量/值称为其属性。

模板文字

传统上,在 JavaScript 中,我们使用+运算符来连接字符串。然而,如果我们想要连接多行字符串,那么我们必须使用转义码\来转义换行,例如:

let a = '<div> \
    <li>' + myVariable+ '</li> \
</div>'

当我们必须编写包含大量 HTML 的字符串时,这可能会非常令人困惑。在这种情况下,我们可以使用 ES6 模板字符串。模板字符串是用反引号 而不是单引号' '括起来的字符串。通过使用这种方式,我们可以更轻松地创建多行字符串:

let a = `
<div>
   <li> ${myVariable} </li>
</div>
`

正如你所看到的,我们可以以类似的方式创建 DOM 元素;我们在 HTML 中输入它们,而不用担心空格或多行。因为模板字符串中存在的任何格式,例如制表符或换行符,都直接记录在变量中。我们可以使用${}在字符串中声明变量。因此,在我们的情况下,我们需要为每个任务生成一个项目列表。首先,我们将创建一个函数来循环遍历数组并生成 HTML。在我们的loadTasks()方法中,编写以下代码:

loadTasks() {
  let tasksHtml = this.tasks.reduce((html, task, index) => html +=  
  this.generateTaskHtml(task, index), '');
  document.getElementById('taskList').innerHTML = tasksHtml;
 }

之后,在ToDoClass内部创建一个generateTaskHtml()函数,代码如下:

generateTaskHtml(task, index) {
 return `
  <li class="list-group-item checkbox">
   <div class="row">
    <div class="col-md-1 col-xs-1 col-lg-1 col-sm-1 checkbox">
     <label><input id="toggleTaskStatus" type="checkbox"  
     onchange="toDo.toggleTaskStatus(${index})" value="" class="" 
     ${task.isComplete?'checked':''}></label>
    </div>
    <div class="col-md-10 col-xs-10 col-lg-10 col-sm-10 task-text ${task.isComplete?'complete':''}">
     ${task.task}
   </div>
   <div class="col-md-1 col-xs-1 col-lg-1 col-sm-1 delete-icon-area">
     <a class="" href="/" onClick="toDo.deleteTask(event, ${index})"><i 
     id="deleteTask" data-id="${index}" class="delete-icon glyphicon 
     glyphicon-trash"></i></a>
    </div>
   </div>
  </li>
`;
}

现在,刷新页面,哇!我们的应用程序已经加载了来自tasks变量的任务。一开始可能看起来像是很多代码,但让我们逐行来看。

如果刷新页面时更改没有反映出来,那是因为 Chrome 已经缓存了 JavaScript 文件,并且没有检索到最新的文件。要使其检索最新的代码,您需要通过在 Windows 或 Linux 上按下Ctrl+Shift+R,或在 Mac 上按下command+Shift+R来进行强制重新加载。

loadTasks()函数中,我们声明一个名为tasksHtml的变量,其值是由tasks变量的数组reduce()方法的回调函数返回的。JavaScript 中的每个数组对象都有一些与之关联的方法。reduce是 JS 数组的一种方法,它将一个函数应用于数组的每个元素,从左到右应用值到累加器,以便将数组减少为单个值,然后返回该最终值。reduce方法接受两个参数;第一个是应用于数组每个元素的回调函数,第二个是累加器的初始值。让我们看看我们的函数在普通的 ES5 语法中是什么样子的:

let tasksHtml = this.tasks.reduce(function(html, task, index, tasks) { 
  return html += this.generateTaskHtml(task, index)
}.bind(this), '');
  • 第一个参数是回调函数,它的四个参数是html,这是我们的累加器,task,这是任务数组中的一个元素,索引,它给出了迭代中数组元素的当前索引,以及tasks,它包含了 reduce 方法应用的整个数组(对于我们的用例,我们不需要在回调函数中使用整个数组,所以忽略了第四个参数)。

  • 第二个参数是可选的,包含累加器的初始值。在我们的情况下,初始 HTML 字符串是一个空字符串''

  • 另外,请注意我们必须使用bind将回调函数与this(即我们的类)对象绑定在一起,以便在回调函数中可以访问ToDoClass的方法和变量。这是因为,否则,每个函数都将定义自己的this对象,并且父级的this对象将无法在该函数内部访问。

回调函数的作用是首先取空的html字符串(累加器),然后将其与ToDoClassgenerateTaskHtml()方法返回的值连接起来,该方法的参数是数组的第一个元素及其索引。返回的值当然应该是一个字符串,否则会抛出错误。然后,它对数组的每个元素重复执行操作,累加器的更新值最终在迭代结束时返回。最终的减少值包含作为字符串填充任务的整个 HTML 代码。

通过应用 ES6 箭头函数,整个操作可以在一行中完成:

let tasksHtml = this.tasks.reduce((html, task, index) => html += this.generateTaskHtml(task, index), '');

这不是很简单吗!由于我们只是在一行中返回值,我们可以忽略{}大括号和return关键字。此外,箭头函数不定义自己的this对象;它们只是继承其父级的this对象。因此,我们也可以忽略.bind(this)方法。现在,我们使用箭头函数使我们的代码更清晰,更容易理解。

在我们继续loadTasks()方法的下一行之前,让我们看一下generateTaskHtml()方法的工作原理。这个函数接受两个参数--任务数据中的数组元素任务和它的索引,并返回一个包含用于填充任务的 HTML 代码的字符串。请注意,我们在代码中包含了复选框的变量:

<input id="toggleTaskStatus" type="checkbox" onchange="toDo.toggleTaskStatus(${index})" value="" class="" ${task.isComplete?'checked':''}>

它说“在复选框状态改变时”,调用toDo对象的toggleTaskStatus()方法,参数是被改变的任务的索引。我们还没有定义toggleTaskStatus()方法,所以当您现在在网站上点击复选框时,它会在 Chrome 的控制台中抛出错误,并且在浏览器窗口中没有任何特殊的情况发生。此外,我们添加了一个条件运算符()?:,如果任务状态已完成,则返回输入标签的已选属性。如果任务已经完成,这对于渲染带有预选复选框的列表非常有用。

同样,我们在包含任务文本的div中包含了${task.isComplete?'complete':''},这样如果任务已完成,任务就会添加一个额外的类,而且在styles.css文件中为该类编写了 CSS,以在文本上渲染删除线。

最后,在锚点标签中,我们包含了onClick="toDo.deleteTask(event, ${index})"来调用toDo对象的deleteTask()方法,参数是点击事件本身和任务的索引。我们还没有定义deleteTask()方法,所以点击删除图标会将您带到文件系统的根目录!

onclickonchange是一些 HTML 属性,用于在父元素上发生指定事件时调用 JavaScript 函数。由于这些属性属于 HTML,它们不区分大小写。

现在,让我们看一下loadTasks()方法的第二行:

document.getElementById('taskList').innerHTML = tasksHtml;

我们刚刚用新生成的字符串tasksHTML替换了具有 IDtaskList的 DOM 元素的 HTML 代码。现在,待办事项列表已经填充。是时候定义toDo对象的两个新方法了,这些方法包含在我们生成的 HTML 代码中。

管理任务状态

ToDoClass中,包括两个新方法:

 toggleTaskStatus(index) {
  this.tasks[index].isComplete = !this.tasks[index].isComplete;
   this.loadTasks();
 }
 deleteTask(event, taskIndex) {
   event.preventDefault();
   this.tasks.splice(taskIndex, 1);
   this.loadTasks();
 }

第一个方法toggleTaskStatus()用于标记任务为已完成或未完成。当复选框被点击(onChange)时,会调用该方法,并将被点击的任务的索引作为参数:

  • 使用任务的索引,我们将任务的isComplete状态分配为其当前状态的否定,而不使用(!)运算符。因此,可以在此函数中切换任务的完成状态。

  • 一旦tasks变量使用新数据更新,就会调用this.loadTasks()来重新渲染所有任务的更新值。

第二种方法deleteTask()用于从列表中删除任务。当前,单击删除图标将带您转到文件系统的根目录。但是,在将您导航到文件系统的根目录之前,将使用单击event和任务的index作为参数调用toDo.deleteTask()

  • 第一个参数event包含关于刚刚发生的点击事件的各种属性和方法的整个事件对象(在deleteTask()函数内尝试console.log(event)以查看 Chrome 控制台中包含的所有详细信息)。

  • 为了防止任何默认操作(打开 URL)在单击删除图标(<a>标签)后发生,我们需要指定event.preventDefault()

  • 然后,我们需要从tasks变量中删除已删除的数组的任务元素。为此,我们使用splice()方法,该方法从指定的索引处删除数组中指定数量的元素。在我们的情况下,从需要删除的任务的索引处仅删除一个元素。这将从tasks变量中删除要删除的任务。

  • 调用this.loadTasks()以重新呈现所有具有更新值的任务。

刷新页面(如果需要,进行硬刷新)以查看我们的当前应用程序如何使用新代码。您现在可以将任务标记为已完成,并且可以从列表中删除任务。

向列表添加新任务

现在我们有了切换任务状态和删除任务的选项。但是我们需要向列表中添加更多任务。为此,我们需要使用 HTML 文件中提供的文本框,以允许用户输入新任务。第一步将是向添加任务的<button>添加onclick属性:

<button class="btn btn-primary" onclick="toDo.addTaskClick()">Add</button>

现在,每次单击按钮都将调用toDo对象的addTaskClick()方法,该对象尚未定义。因此,让我们在ToDoClass内定义它:

addTaskClick() {
  let target = document.getElementById('addTask');
  this.addTask(target.value);
  target.value = ""
}
addTask(task) {
  let newTask = {
   task,
   isComplete: false,
  };
  let parentDiv = document.getElementById('addTask').parentElement;
  if(task === '') {
   parentDiv.classList.add('has-error');
  } else {
   parentDiv.classList.remove('has-error');
   this.tasks.push(newTask);
   this.loadTasks();
  }
}

重新加载 Chrome 并尝试通过单击“添加”按钮添加新任务。如果一切正常,您应该看到新任务被追加到列表中。此外,当您单击“添加”按钮而不在输入字段中键入任何内容时,它将使用红色边框突出显示输入字段,指示用户应在输入字段中输入文本。

看看我是如何将我们的添加任务操作分成两个函数的?我对loadTask()函数也做了类似的事情。在编程中,最佳实践是将所有任务组织成更小、更通用的函数,这将允许您在将来重用这些函数。

让我们看看addTaskClick()方法是如何工作的:

  • addTaskClick()函数没有任何请求参数。首先,为了读取新任务的文本,我们获取 ID 为addTask<input>元素,其中包含任务所需的文本。使用document.getElementById('addTask'),并将其分配给target变量。现在,target变量包含<input>元素的所有属性和方法,可以读取和修改(尝试console.log(target)以查看变量中包含的所有详细信息)。

  • value属性包含所需的文本。因此,我们将target.value传递给addTask()函数,该函数负责将新任务添加到列表中。

  • 最后,我们通过将target.value设置为空字符串''来将输入字段重置为空状态。

这是点击事件的事件处理部分。让我们看看任务如何在addTask()方法中追加到列表中。task变量包含新任务的文本:

  • 理想情况下,此函数的第一步是构造定义我们任务的 JSON 数据:
let newTask = {
  task: task,
  isComplete: false
}
  • 这里是另一个 ES6 特性对象文字属性值简写;在我们的 JSON 对象中,我们可以简单地写{task}而不是{task: task}。变量名将成为键,存储在变量中的值将成为值。如果变量未定义,这将引发错误。

  • 我们还需要创建另一个变量parentDiv来存储目标<input>元素的父<div>元素的对象。这很有用,因为当任务为空字符串时,我们可以向父元素parentDiv.classList.add('has-error')添加has-error类,这样通过 Bootstrap 的 CSS,就会在我们的<input>元素上呈现红色边框。这就是我们如何告诉用户他们需要在单击添加按钮之前输入文本的方式。

  • 然而,如果输入文本不为空,我们应该从父元素中删除has-error类,以确保红色边框不会显示给用户,然后简单地将我们的newTask变量推送到我们类的tasks变量中。此外,我们需要再次调用loadTasks(),以便新任务得到渲染。

通过按 Enter 键添加任务

这是一种添加任务的方式,但是一些用户更喜欢直接按下Enter按钮来添加任务。为此,让我们使用事件监听器来检测<input>元素中的Enter键按下。我们也可以使用我们的<input>元素的onchange属性,但让我们尝试一下事件监听器。向类添加事件监听器的最佳方式是在构造函数中调用它们,以便在初始化类时设置事件监听器。

因此,在我们的类中,创建一个新的函数addEventListeners()并在我们的构造函数中调用它。我们将在此函数内添加事件监听器:

constructor() {
  ...
  this.addEventListeners();
}
 addEventListeners() {
  document.getElementById('addTask').addEventListener('keypress', event => {
     if(event.keyCode === 13) {
       this.addTask(event.target.value);
       event.target.value = '';
     }
   });
 }

就是这样!重新加载 Chrome,输入文本,然后按Enter。这应该像添加按钮一样将任务添加到我们的列表中。让我们来看看我们的新事件监听器:

  • 对于发生在具有 IDaddTask<input>元素中的每个按键按下,我们运行回调函数,参数为event对象。

  • 此事件对象包含按下的键的键码。对于Enter键,键码为 13。如果键码等于 13,我们只需调用this.addTask()函数,参数为任务的文本event.target.value

  • 现在,addTask()函数处理将任务添加到列表中。我们可以简单地将<input>重置为空字符串。这是将每个操作组织成函数的一个很大的优势。我们可以在需要的地方简单地重用这些函数。

在浏览器中持久保存数据

现在,就功能而言,我们的待办事项列表已经准备好了。但是,刷新页面后,数据将会丢失。让我们看看如何在浏览器中持久保存数据。通常,Web 应用程序会与服务器端的 API 连接,以动态加载数据。在这里,我们不会研究服务器端的实现。因此,我们需要寻找一种在浏览器中存储数据的替代方式。在浏览器中有三种存储数据的方式。它们如下:

  • cookiecookie是由服务器存储在客户端(浏览器)上的小信息,带有到期日期。它对于从客户端读取信息非常有用,例如登录令牌、用户偏好等。Cookie 主要用于服务器端,可以存储在 cookie 中的数据量限制为 4093 字节。在 JavaScript 中,可以使用document.cookie对象来管理 cookie。

  • localStorage:HTML5 的localStorage存储信息没有到期日期,数据将在关闭和打开网页后仍然存在。它为每个域提供 5MB 的存储空间。

  • sessionStoragesessionStoragelocalStorage相当,只是数据仅在会话期间有效(用户正在使用的当前选项卡)。当网站关闭时,数据将过期。

对于我们的用例,localStorage是持久化任务数据的最佳选择。localStorage将数据存储为键值对,而值需要是一个字符串。让我们来看看实现部分。在构造函数中,不要直接将值分配给this.tasks,而是更改为以下内容:

constructor() {
  this.tasks = JSON.parse(localStorage.getItem('TASKS'));
   if(!this.tasks) {
    this.tasks = [
       {task: 'Go to Dentist', isComplete: false},
       {task: 'Do Gardening', isComplete: true},
       {task: 'Renew Library Account', isComplete: false},
    ];
  } 
... 
}

我们将把任务保存在localStorage中,以字符串形式存储,其键为'TASKS'。因此,当用户第一次打开网站时,我们需要检查localStorage中是否存在以'TASKS'为键的数据。如果没有数据,它将返回null,这意味着这是用户第一次访问网站。我们需要使用JSON.parse()将从localStorage中检索到的数据从字符串转换为对象:

  • 如果localStorage中没有数据(用户第一次访问网站),我们将使用tasks变量为他们预填一些数据。将代码添加到我们应用程序中持久保存任务数据的最佳位置将是loadTasks()函数,因为每次对tasks进行更改时都会调用它。在loadTasks()函数中,添加一行额外的代码:
 localStorage.setItem('TASKS', JSON.stringify(this.tasks));
  • 这将把我们的tasks变量转换为字符串并存储在localStorage中。现在,您可以添加任务并刷新页面,数据将在您的浏览器中持久保存。

  • 如果您想出于开发目的清空localStorage,可以使用localStorage.removeItem('TASKS')来删除键,或者可以使用localStorage.clear()来完全删除localStorage中存储的所有数据。

JavaScript 中的所有内容都有固有的布尔值,可以称为真值或假值。以下值始终为假值-null""(空字符串)、false0(零)、NaN(不是数字)和undefined。其他值被视为真值。因此,它们可以直接用于条件语句,就像我们在代码中使用if(!this.tasks) {}一样。

现在我们的应用程序已经完成,您可以删除index.html文件中<ul>元素的内容。内容现在将直接从我们的 JavaScript 代码中填充。否则,当页面加载或刷新时,您将看到默认的 HTML 代码在页面中闪烁。这是因为我们的 JavaScript 代码只有在所有资源加载完成后才会执行,这是由以下代码造成的:

window.addEventListener("load", function() {
  toDo = new ToDoClass();
});

如果一切正常,那么恭喜您!您已成功构建了您的第一个 JavaScript 应用程序,并了解了 JavaScript 的新 ES6 功能。哦等等!看起来我们忘记了一些重要的东西!

所有在这里讨论的存储选项都是未加密的,因此不应该用于存储敏感信息,比如密码、API 密钥、认证令牌等。

与旧浏览器的兼容性

虽然 ES6 可以在几乎所有现代浏览器中使用,但仍然有许多用户使用较旧版本的 Internet Explorer 或 Firefox。那么,我们要如何让我们的应用程序对他们起作用呢?ES6 的好处在于,它的所有新功能都可以使用 ES5 规范来实现。这意味着我们可以轻松地将我们的代码转译为 ES5,在所有现代浏览器上都可以运行。为此,我们将使用 Babel:babeljs.io/,作为将 ES6 转换为 ES5 的编译器。

还记得我们在本章的开头在系统中安装 Node.js 吗?现在终于可以使用它了。在我们开始将代码编译为 ES5 之前,我们需要了解 Node 和 npm。

Node.js 和 npm

Node.js 是建立在 Chrome 的 V8 引擎上的 JavaScript 运行时。它允许开发人员在浏览器之外运行 JavaScript。由于 Node.js 的非阻塞 I/O 模型,它被广泛用于构建数据密集型的实时应用程序。您可以使用它来构建 JavaScript 的 Web 应用程序后端,就像 PHP、Ruby 或其他服务器端语言一样。

Node.js 的一个很大的优势是它允许你将代码组织成模块。模块是用于执行特定功能的一组代码。到目前为止,我们在浏览器的<script>标签中一个接一个地包含 JavaScript 代码。但是在 Node.js 中,我们可以通过创建对模块的引用来在代码中简单地调用依赖项。例如,如果我们需要 jQuery,我们可以简单地写如下代码:

const $ = require('jquery');

或者,我们可以写如下内容:

import $ from 'jquery';

jQuery 模块将被包含在我们的代码中。jQuery 的所有属性和方法将在$对象内部可访问。$的范围将仅限于调用它的文件。因此,在每个文件中,我们可以单独指定依赖项,并且在编译期间它们将被捆绑在一起。

但等等!为了包含jquery,我们需要下载包含所需模块的jquery包并将其保存在一个文件夹中。然后,我们需要将$分配给包含模块的文件夹中的文件的引用。随着项目的增长,我们将添加许多软件包并在我们的代码中引用这些模块。那么,我们将如何管理所有这些软件包。好吧,我们有一个随 Node.js 一起安装的小工具,叫做Node Package Managernpm):

  • 对于 Linux 和 Mac 用户,npm 类似于这些之一:apt-getyumdnfHomebrew

  • 对于 Windows 用户,您可能还不熟悉软件包管理的概念。所以,假设您需要 jQuery。但是您不知道 jQuery 运行所需的依赖关系。这就是软件包管理器发挥作用的地方。您可以简单地运行一个命令来安装一个包(npm install jquery)。软件包管理器将读取目标软件包的所有依赖项,并安装目标及其依赖项。它还管理一个文件以跟踪已安装的软件包。这用于将来轻松卸载软件包。

尽管 Node.js 允许直接将模块导入/导入到代码中,但浏览器不支持直接导入模块的 require 或 import 功能。但是有许多可用的工具可以轻松模仿这种功能,以便我们可以在浏览器中使用 import/require。我们将在下一章中为我们的项目使用它们。

npm 维护一个package.json文件,用于存储有关软件包的信息,例如其名称、脚本、依赖项、开发依赖项、存储库、作者、许可证等。软件包是一个包含一个或多个文件夹或文件的文件夹,其根文件夹中有一个package.json文件。npm 中有成千上万的开源软件包可用。访问www.npmjs.com/来探索可用的软件包。这些软件包可以是用于服务器端或浏览器端的模块,也可以是用于执行各种操作的命令行工具。

npm 软件包可以在本地(每个项目)或全局(整个系统)安装。我们可以使用不同的标志来指定我们想要如何安装它,如下所示:

  • 如果我们想要全局安装一个包,我们应该使用--global-g标志。

  • 如果软件包应该在本地为特定项目安装,请使用--save-S标志。

  • 如果软件包应该在本地安装,并且仅用于开发目的,请使用--save-dev-D标志。

  • 如果您运行npm install <package-name>而没有任何标志,它将在本地安装软件包,但不会更新package.json文件。不建议在没有-S-D标志的情况下安装软件包。

让我们使用 npm 安装一个命令行工具叫做http-serverwww.npmjs.com/package/http-server。这是一个简单的工具,可以用来像 Apache 或 Nginx 一样通过http-server提供静态文件。这对于测试和开发我们的 Web 应用程序非常有用,因为我们可以看到我们的应用程序在通过 Web 服务器提供时的行为。

如果命令行工具只会被我们自己使用,而不会被任何其他开发人员使用,那么通常建议全局安装。在我们的情况下,我们只会使用http-server包。所以,让我们全局安装它。打开你的终端/命令提示符并运行以下命令:

npm install -g http-server

如果您使用的是 Linux,有时可能会遇到权限被拒绝或无法访问文件等错误。尝试以管理员身份运行相同的命令(前面加上sudo)以全局安装该软件包。

一旦安装完成,就在终端中导航到我们的待办事项列表应用程序的根文件夹,并运行以下命令:

http-server

您将收到两个 URL,并且服务器将开始运行,如下所示:

  • 要在本地设备上查看待办事项列表应用程序,请在浏览器中打开以127开头的 URL

  • 要在连接到您本地网络的其他设备上查看待办事项列表应用程序,请在设备的浏览器中打开以192开头的 URL

每次打开应用程序时,http-server都会在终端中打印已提供的文件。http-server有各种选项可用,例如-p标志,可用于更改默认端口号8080(尝试http-server -p 8085)。访问 http-server:www.npmjs.com/package/http-server,npm 页面以获取所有可用选项的文档。现在我们对npm包有了一个大致的了解,让我们安装 Babel 将我们的 ES6 代码转译为 ES5。

我们将在接下来的章节中经常使用终端。如果您使用的是 VSCode,它有一个内置的终端,可以通过在 Mac、Linux 和 Windows 上按Ctrl+`来打开。它还支持同时打开多个终端会话。这可以节省您在窗口之间切换的时间。

使用 Node 和 Babel 设置我们的开发环境

Babel 是一个 JavaScript 编译器,用于将 JavaScript 代码从 ES6+转译为普通的 ES5 规范。让我们在项目中设置 Babel,以便它自动编译我们的代码。

在设置 Babel 后,我们的项目中将有两个不同的 JS 文件。一个是 ES6,我们用来开发我们的应用程序,另一个是编译后的 ES5 代码,将被浏览器使用。因此,我们需要在项目的根目录中创建两个不同的文件夹,即srcdist。将scripts.js文件移动到src目录中。我们将使用 Babel 来编译src目录中的脚本,并将结果存储在dist目录中。因此,在index.html中,将scripts.js的引用更改为<script src="dist/scripts.js"></script>,以便浏览器始终读取编译后的代码:

  1. 要使用 npm,我们需要在项目的根目录中创建package.json。在终端中导航到项目的根目录,并键入:
npm init
  1. 首先,它会询问您的项目名称,请输入名称。对于其他问题,要么输入一些值,要么只需按Enter接受默认值。这些值将填充到package.json文件中,稍后可以更改。

  2. 通过在终端中运行以下命令来安装我们的开发依赖项:

npm install -D http-server babel-cli babel-preset-es2015 concurrently
  1. 此命令将创建一个node_modules文件夹,并在其中安装包。现在,您的package.json文件将在其devDependencies参数中具有前述包,并且您当前的文件夹结构应如下所示:
.
├── dist
├── index.html
├── node_modules
├── package.json
├── src
└── styles.css

如果您在项目中使用 git 或任何其他版本控制系统,请将node_modulesdist文件夹添加到.gitignore或类似的文件中。这些文件夹不需要提交到版本控制,并且在需要时必须生成。

是时候编写脚本来编译我们的代码了。在package.json文件中,将有一个名为scripts的参数。默认情况下,它将是以下内容:

 "scripts": {
   "test": "echo \"Error: no test specified\" && exit 1"
 },

test是 npm 的默认命令之一。当您在终端中运行npm test时,它将自动在终端中执行 test 键值内的脚本。顾名思义,test用于执行自动化测试用例。其他一些默认命令包括startstoprestartshrinkwrap等。这些命令在使用 Node.js 开发服务器端应用程序时非常有用。

然而,在前端开发过程中,我们可能需要更多的命令,像默认的命令一样。npm也允许我们创建自己的命令来执行任意脚本。但是,与默认命令(如npm start)不同,我们不能通过运行npm <command-name>来执行我们自己的命令;我们必须在终端中执行npm run <command-name>

我们将设置 npm 脚本,这样运行npm run build将会生成一个包含编译后的 ES5 代码的应用程序工作构建,运行npm run watch将会启动一个开发服务器,我们将用于开发。

将脚本部分的内容更改为以下内容:

"scripts": {
  "watch": "babel src -d dist --presets=es2015 -ws",
  "build": "rm -rf dist && babel src -d dist --presets=es2015",
  "serve": "http-server"
},

好吧,看起来有很多脚本!让我们逐个查看它们。

首先,让我们来看看watch脚本:

  • 这个脚本的功能是启动babel进入监视模式,这样每当我们在src目录中的 ES6 代码中进行任何更改时,它都会自动转译为dist目录中的 ES5 代码,同时生成源映射,这对于调试编译后的代码非常有用。监视模式将在终端中持续进行,直到执行被终止(按下Ctrl+C)。

  • 在终端中从项目的根目录执行npm run watch。你会看到 Babel 已经开始编译代码,并且一个新的scripts.js文件将被创建在dist文件夹中。

  • scripts.js文件将包含我们的代码以 ES5 格式。在 Chrome 中打开index.html,你应该能看到我们的应用程序正常运行。

它的工作原理是这样的。尝试直接在终端中运行babel src -d dist --presets=es2015 -ws。它会抛出一个错误,说babel未安装(错误消息可能因操作系统而异)。这是因为我们还没有全局安装 Babel。我们只在项目中安装了它。所以,当我们运行npm run watch时,npm 将在项目的node_modules文件夹中查找 Babel 的二进制文件,并使用这些二进制文件执行命令。

删除dist目录,并在package.json中创建一个新的脚本--"babel": "babel src -d dist"。我们将使用这个脚本来学习 Babel 的工作原理。

  • 这个脚本告诉 Babel编译src目录中的所有 JS 文件,并将生成的文件保存在dist目录中。如果dist目录不存在,它将被创建。这里,使用-d标志告诉 Babel 需要编译整个目录中的文件。

  • 在终端中运行npm run babel,并打开dist目录中的新scripts.js文件。好吧,文件已经编译了,但不幸的是,结果也是 ES6 语法,所以新的scripts.js文件是我们原始文件的精确副本!

  • 我们的目标是将我们的代码编译为 ES5。为此,我们需要在编译过程中指示 Babel 使用一些预设。看看我们的npm install命令,我们已经安装了一个名为babel-preset-es2015的包来实现这个目的。

  • 在我们的 Babel 脚本中,添加选项--presets=es2015,然后再次执行npm run babel。这次代码将被编译为 ES5 语法。

  • 在浏览器中打开我们的应用程序,在构造函数中添加debugger,然后重新加载。我们有一个新问题;源代码现在将包含 ES5 语法的代码,这使得调试我们的原始代码变得更加困难。

  • 为此,我们需要使用-s标志启用源映射,它会创建一个.map文件,用于将编译后的代码映射回原始源代码。还要使用-w标志将 Babel 置于监视模式。

现在我们的脚本将与watch命令中使用的脚本相同。使用调试器重新加载应用程序,你会看到源代码将包含我们的原始代码,即使它使用的是编译后的源代码。

如果运行单个命令也可以启动我们的开发服务器,那不是很好吗?我们不能使用&&来连接两个同时运行的命令。因为&&将在第一个命令完成后才执行第二个命令。

我们为此安装了另一个名为concurrently的包。它用于同时执行多个命令。使用concurrently的语法如下:

concurrently "command1" "command2" 

当我们执行npm run watch时,我们需要同时运行当前的watch脚本和serve脚本。将watch脚本更改为以下内容:

"watch": "concurrently \"npm run serve\"  \"babel src -d dist --presets=es2015 -ws\"",

尝试再次运行npm run watch。现在,您拥有一个完全功能的开发环境,它将在您对 JS 代码进行更改时自动提供文件并编译代码。

发布代码

开发完成后,如果您使用版本控制来发布代码,请将node_modulesdist文件夹添加到忽略列表中。否则,发送您的代码时不包括node_modulesdist文件夹。其他开发人员可以简单地运行npm install来安装依赖项,并在需要时读取package.json文件中的脚本来构建项目。

我们的npm run build命令将删除项目文件夹中的dist文件夹,并使用最新的 JS 代码构建一个新的dist文件夹。

总结

恭喜!您已经用新的 ES6 语法构建了您的第一个 JavaScript 应用程序。在本章中,您学到了以下概念:

  • JavaScript 中的 DOM 操作和事件监听器

  • JavaScript 的 ECMAScript 2015(ES6)语法

  • Chrome 开发者工具

  • Node 和 npm 的工作原理

  • 使用 Babel 将 ES6 代码转译为 ES5 代码

在我们当前的 npm 设置中,我们只是创建了一个编译脚本来将我们的代码转换为 ES5。还有许多其他可用于自动化更多任务的工具,例如缩小、linting、图像压缩等。我们将在下一章中使用一个名为 Webpack 的工具。

第二章:构建一个 Meme Creator

正如章节名称所示,我们将在本章构建一个有趣的应用程序--一个Meme Creator。每个人都喜欢表情包!但我们构建 Meme Creator 的原因不仅仅是因为这个。我们将探索一些新的东西,这些东西将改变您构建 Web 应用程序的方式。让我们看看有什么:

  • 介绍CSS3 flexbox。在网络上创建响应式布局的新方法。

  • 使用Webpack模块打包工具将所有依赖项和代码转换为静态资源。

  • 使用HTML5 画布在 JavaScript 中实时绘制图形。

  • 创建一个完全优化、缩小和版本化的稳定生产版本。

之前,您成功地构建了一个 ToDo List 应用程序,同时学习了 JavaScript 的新 ES6 特性。在本章结束时,您学会了如何使用 Node 和 npm 进行 Web 开发。我们只涵盖了基础知识。我们还没有意识到在我们的项目中使用 npm 的全部潜力。这就是为什么在这个项目中,我们将尝试使用一个称为 Webpack 的强大模块打包工具。在我们开始实验构建一个完全自动化的开发环境之前,让我们先设置一些东西。

初始项目设置

为我们的 Meme Creator 应用创建一个新文件夹。在 VSCode 或您用于此项目的任何其他文本编辑器中打开该文件夹。在终端中导航到该文件夹并运行npm init。就像我们在上一章中所做的那样,在终端中填写所有要求的细节,然后在 Windows 上按Enter或在 Mac 上按return,您将在项目根目录中得到package.json文件。

从您为本书下载的代码文件中,打开第二章的起始文件夹。您会看到一个index.html文件。将其复制粘贴到您的新项目文件夹中。这就是本章提供的起始文件的全部内容,因为不会有默认的 CSS 文件。我们将从头开始构建 UI!

创建我们将在本章中使用的文件和文件夹。文件夹结构应该如下所示:

.
├── index.html
├── package.json
└── src
    ├── css
    │   └── styles.css
    └── js
         ├── general.js
         └── memes.js

现在,将 JS 文件保留为空。我们将在styles.css文件上进行工作。在浏览器中打开index.html(尝试使用我们在上一章全局安装的http-server包)。您应该会看到一个奇怪的页面,应用了一些默认的 Bootstrap 样式。我们将把该页面变成一个 Meme Creator 应用,如下所示:

这个网络应用也将是响应式的。因此,在您的移动设备上,它应该如下所示:

那个空白框将是我们的画布,它将预览使用该应用程序创建的表情包。现在您已经对应用程序的外观有了一个想法,我们将开始在我们的styles.css文件上工作。

使用 flexbox 进行响应式设计

如果您查看我们上一章的index.html文件,您会看到有一些类,比如col-md-2col-xs-2col-lg-2col-sm-2等。它们是 Bootstrap 的网格类。上一章的布局是使用 Bootstrap 网格系统设计的。该系统将页面分成行和 12 列,并根据屏幕尺寸为每个div分配特定数量的列。

有四种不同的屏幕尺寸:

  • 桌面(md)

  • 平板电脑(sm)

  • 手机(xs)

  • 大型桌面电脑(lg)

但是,在本章中我们不会使用 Bootstrap 网格。我们将使用 CSS3 中引入的新布局模式,称为 flexbox。Flexbox 或灵活盒模型提供了一个用于创建布局的盒模型。

Flexbox 是一个新的布局系统,正在被浏览器供应商积极实施。支持几乎已经完成;是时候在项目中采用这个标准了。仍然存在一些问题,比如 IE 11 只有部分 flexbox 支持,较旧版本的 IE 不支持 flexbox。访问caniuse.com/查看 flexbox 的浏览器支持详情。

Flexbox - 一个快速介绍

在 flexbox 布局系统中,您声明一个带有 CSS 属性display: flex的父div,这允许您控制如何定位其子元素。

一旦声明了display: flexdiv元素就成为一个具有两个轴的 flexbox。主轴与内容一起放置在交叉轴上,该轴与主轴垂直。您可以在父 flexbox 中使用以下 CSS 属性来更改子元素(flex 项目)的位置:

  • flex-direction:创建主轴,可以是水平(行)或垂直(列)

  • justify-content:指定如何在主轴上放置 flex 项目

  • align-items:指定如何在交叉轴上放置 flex 项目

  • flex-wrap:指定当没有足够空间在单行中显示 flex 项目时如何处理它们

您还可以将一些 flex 属性应用于 flex 项目,例如:

  • align-self:指定如何在交叉轴上放置特定的 flex 项目

  • flex:相对于其他 flex 项目的大小(如果您有两个项目分别为flex: 2flex: 1,第一个将是第二个的两倍大小)

所有这些听起来可能令人困惑,但理解 flexbox 的最简单方法是使用在线 flexbox 游乐场。搜索一些在线可用的 flexbox 游乐场,体验 flexbox 的不同属性如何工作。其中一个游乐场可以在flexboxplayground.catchmyfame.com/找到。

要学习 flexbox,请参考以下页面:

在撰写本书时,Safari 浏览器的最新版本 10.1 存在flex-wrap属性的问题,这在夜间构建中已经修复。如果您使用相同或更早版本的 Safari 浏览器,我建议在本章中使用 Chrome。

设计模因创作者

在我们的index.html文件中,我们的<body>元素被分成一个导航栏和包含网站内容的divdiv.body元素进一步分为div.canvas-areadiv.input-area

导航栏

我们文档正文的第一部分是导航栏<nav>。导航栏通常包含网站导航的主要链接集。由于在本章中我们只构建一个单页面,因此我们可以将导航栏仅包含我们的页面标题。

导航栏使用 Bootstrap 进行样式设置。类.navbar将相应元素样式为页面的主导航栏。.navbar-inverse类为导航栏添加了深色,.navbar-fixed-top类使用固定位置将导航栏附加到屏幕顶部。导航栏的内容包裹在 Bootstrap 容器(div.container)中。页面标题写在div.navbar-header中,作为带有类.navbar-brand的锚标签,这告诉 Bootstrap 这是应用程序的品牌名称/标题。

Bootstrap 导航栏是高度可定制的。要了解更多关于这个主题的内容,请参考 W3Schools 的 Bootstrap 教程:www.w3schools.com/bootstrap/或 Bootstrap 的官方文档:getbootstrap.com/getting-started/

内容区域

导航栏占据屏幕顶部的固定位置。因此,它将与页面的内容重叠。打开styles.css并添加以下代码:

body {
  padding-top: 65px;
}

这将为整个 body 部分添加填充,以便导航栏不会与我们的内容重叠。现在,我们需要将我们的主要内容区域div.body转换为 flexbox:

.body {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  justify-content: space-around;
}

这将把我们的div.body元素转换为一个 flexbox,将其内容组织为一行(flex-direction),如果没有足够的空间来容纳整行,则将内容换行到新行(flex-wrap)。此外,内容将在水平方向被等距间隔包围(justify-content)。

猜猜看?我们完成了!我们的主要布局已经完成了!切换到 Chrome,进行硬刷新,看到内容现在水平对齐了。打开响应式设计模式;对于移动设备,你会看到行自动换行成两行来显示内容。没有 flexbox,要实现相同的布局需要三倍的代码量。Flexbox 大大简化了布局过程。

现在我们的主要布局已经完成,让我们为各个元素添加一些样式,比如:

  • 使.canvas-area的大小是.input-area的两倍

  • 给画布元素添加黑色边框

  • 将画布和表单输入在各自的区域居中对齐

  • 此外,我们需要为.canvas-area.input-area添加一些边距,这样当行被换行时它们之间会有空间

为了实现这些样式,将以下 CSS 添加到你的styles.css文件中:

.canvas-area {
   flex: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 10px;
}
.img-canvas {
  border: 1px solid #000000;
}
.input-area {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  margin: 10px;
}

画布区域仍然很小,但我们将从 JavaScript 代码中处理它的大小。所以,现在我们不需要担心画布的大小。

我们的样式几乎完成了,只是表单输入现在大小不同。这是因为 Bootstrap 的.form-input样式告诉相应的div占据其父div的整个宽度。然而,当我们在样式中添加align-items: center时,我们告诉父div分配有限的宽度,以便内容不重叠,并在 flexbox 内居中。因此,现在每个元素的宽度根据其内容而异。

为了解决这个问题,我们只需要为.form-input类指定一个固定的宽度。此外,让我们为下载按钮添加一些额外的顶部边距。在你的styles.css文件的末尾添加以下行:

.form-group {
  width: 90%;
}
.download-button {
  margin-top: 10px;
}

现在我们使用 flexbox 构建了我们的 Meme Creator 的 UI。是时候转向本章中最重要的主题了。

由于其易用性和大量功能,flexbox 布局系统也被移动应用开发所采用。React Native 使用 flexbox 为 Android 和 iOS 应用创建 UI。Facebook 还发布了开源库,如yogalitho,用于在原生 Android 和 iOS 应用中使用 flexbox。

Webpack 模块打包工具

终于是时候建立我们功能齐全的开发环境了。你可能会想知道 Webpack 是什么,它与开发环境有什么关系。或者,你可能熟悉诸如 gulp 或 grunt 之类的工具,想知道 Webpack 与它们有何不同。

如果你以前使用过 gulp 或 grunt,它们是任务运行器。它们执行一组特定的任务来编译、转换和压缩你的代码。还有一个名为Browserify的工具,它允许你在浏览器中使用require()。通常,使用 gulp/grunt 的开发环境涉及使用不同的工具集(如 Babel、Browserify 等)按特定顺序执行各种命令来生成我们期望的输出代码。但 Webpack 不同。与任务运行器不同,Webpack 不运行一组命令来构建代码。相反,它充当模块打包工具。

Webpack 通过您的 JavaScript 代码并查找importrequire等来查找依赖于它的文件。然后,它将文件加载到依赖图中,并依次找到这些文件的依赖关系。这个过程会一直持续下去,直到没有更多的依赖关系。最后,它使用构建的依赖图将依赖文件与初始文件捆绑在一起成为一个单一的文件。这个功能在现代 JavaScript 开发中非常有用,因为所有东西都被写成一个模块:

Webpack 正在被广泛采用作为流行的现代框架(如 React、Angular 和 Vue)的捆绑工具。这也是您简历上很好的技能。

JavaScript 模块

记得我们在上一章中构建的 ToDo List 应用程序吗?我们使用 npm 安装 Babel 将我们的 ES6 代码转换为 ES5。导航到ToDo List文件夹并打开node_modules文件夹。您会发现一个包含各种包的大型文件夹列表!即使您只安装了四个包,npm 也已经跟踪了所需包的所有依赖关系,并将它们与实际包一起安装。

我们只使用这些包作为开发依赖项来编译我们的代码。因此,我们不知道这些包是如何构建的。这些包被构建为模块。模块是一个独立的可重用代码片段,返回一个值。该值可以是对象、函数、stringint等。模块被广泛用于构建大型应用程序。Node.js 支持导出和导入 JavaScript 模块,而这在浏览器中目前是不可用的。

让我们看看如何在 JavaScript 中创建一个简单的模块:

function sum (a, b) {
  return a+b;
}

考虑前面提到的返回两个数字之和的函数。我们将把该函数转换为一个模块。创建一个新文件sum.js,并按如下方式编写函数:

export function sum (a, b) {
  return a+b;
}

就是这样!您只需要在您想要导出的变量或对象之前添加一个export关键字,它将成为一个可以在不同文件中使用的模块。想象一下,您有一个名为add.js的文件,您需要找到两个数字的和。您可以按如下方式导入sum模块:

// In file add.js at the same directory as sum.js
import { sum } from './sum.js';

let a = 5, b = 6, total;
total = sum(a, b);

如果您正在导入一个 JavaScript 文件,可以忽略扩展名.js并使用import { sum } from './sum'。您也可以使用以下方式:

let sum = (a, b) => return a+b;
module.exports = { sum };

然后,按如下方式导入它:

const sum = require('./sum');

module.exportsrequire关键字自 Node.js 用于导入和导出 JavaScript 模块,甚至在 ES6 之前就已经使用了。然而,ES6 有一个新的模块语法,使用importexport关键字。Webpack 支持所有类型的导入和导出。对于我们的项目,我们将坚持使用 ES6 模块。

考虑以下文件sides.js,其中包含多个几何图形的边数:

export default TRIANGLE = 3;
export const SQUARE = 4;
export const PENTAGON = 5;
export const HEXAGON = 6;

要将它们全部导入到我们的文件中,您可以使用以下方式:

import * as sides from './sides.js';

现在,sides.js文件中导出的所有变量/对象将可以在sides对象中访问。要获取TRIANGLE的值,只需使用sides.LINE。还要注意,TRIANGLE被标记为默认。当同一文件中有多个模块时,默认导出是有用的。输入以下内容:

import side from './sides.js';

现在,side将包含默认导出TRIANGLE的值。所以,现在side = 3。要导入默认模块以及其他模块,您可以使用以下方式:

import TRIANGLE, { SQUARE, PENTAGON, HEXAGON } from './sides.js';

现在,如果您想要导入一个存在于node_modules文件夹中的模块,您可以完全忽略相对文件路径(./部分)并只需输入import jquery from 'jquery';。Node.js 或 Webpack 将自动从文件的父目录中找到最近的node_modules文件夹,并自动搜索所需的包。只需确保您已经使用npm install安装了该包。

这基本上涵盖了在 JavaScript 中使用模块的基础知识。现在是时候了解 Webpack 在我们的项目中的作用了。

在 Webpack 中捆绑模块

要开始使用 Webpack,让我们首先编写一些 JavaScript 代码。打开你的memes.js文件和general.js文件。在两个文件中写入以下代码,它只是在控制台中打印出相应的文件名:

// inside memes.js file
console.log('Memes JS file');
// inside general.js file
console.log('General JS File');

通常,在构建具有大量 HTML 文件的多页面 Web 应用程序时,通常会有一个单个的 JavaScript 文件,其中包含需要在所有 HTML 文件上运行的代码。我们将使用general.js文件来实现这个目的。即使我们的 Meme Creator 只有一个 HTML 文件,我们也将使用general.js文件来包含一些通用代码,并在memes.js文件中包含 Meme Creator 的代码。

为什么我们不尝试在我们的memes.js文件中导入general.js文件呢?由于general.js没有导出任何模块,所以只需在你的memes.js文件中输入以下代码:

import './general';

在你的index.html文件的<body>元素末尾包含一个引用memes.js文件的script标签,并在 Chrome 中查看结果。如果一切顺利,你应该在 Chrome 的控制台中看到一个错误,说:Unexpected token import。这意味着 Chrome 出了一些问题。是的!Chrome 不知道如何使用import关键字。要使用import,我们需要 Webpack 将general.jsmeme.js文件捆绑在一起,并将其作为单个文件提供给 Chrome。

让我们将 Webpack 作为项目的开发依赖项进行安装。在终端中运行以下命令:

npm install -D webpack

Webpack 现在作为我们项目的开发依赖项安装完成。Webpack 也是一个类似 Babel 的命令行工具。要运行 Webpack,我们需要使用npm脚本。在你的package.json文件中,在测试脚本下面,创建以下脚本:

"webpack": "webpack src/js/memes.js --output-filename dist/memes.js",

现在在你的终端中运行以下命令:

npm run webpack

memes.js文件创建在dist/js/目录下。该文件包含了general.jsmemes.js文件的捆绑在一起。在 VSCode 中打开新的 JavaScript 代码;你应该会看到大量的代码。在这个阶段不需要惊慌;这是 Webpack 用来管理捆绑文件的范围和属性的代码。这是我们目前不必担心的东西。如果你滚动到文件的末尾,你会看到我们在原始文件中写的console.log语句。编辑index.html中的脚本标签以包含新文件,如下所示:

<script  src="./dist/memes.js"></script>

现在,在 Chrome 中重新加载页面,你应该看到来自两个文件的控制台语句都在memes.js文件中执行。我们已经成功地在我们的代码中导入了一个 JavaScript 文件。在我们以前的项目中,我们设置了开发环境,以便在源文件中进行更改时,代码将自动编译和提供服务。要进行 ES6 到 ES5 的编译和其他任务,我们需要安装许多包,并且必须给 Webpack 提供许多指令。为此,在你的项目根目录中创建webpack.config.js并编写以下代码:

const webpack = require('webpack');

module.exports = {
  context: __dirname,
  entry: {
    general: './src/js/general.js',
    memes: './src/js/memes.js',
  },
  output: {
    path: __dirname + "/dist",
    filename: '[name].js',
  },
}

删除package.json中传递给 Webpack 的所有选项。现在,package.json中的脚本应该如下所示:

"webpack": "webpack"

由于我们没有向 Webpack 传递任何参数,它将在执行的目录中查找webpack.config.js文件。它现在将从我们刚刚创建的文件中读取配置。我们配置文件中的第一行是使用require('webpack')导入 Webpack。我们仍然使用 Node.js 来执行我们的代码,所以我们应该在 Webpack 配置文件中使用require。我们只需要将我们的配置作为 JSON 对象导出到这个文件中。在module.exports对象中,每个属性的用途如下:

  • context:用于指定需要解析入口部分文件路径的绝对路径。在这里,__dirname是一个常量,将自动包含当前目录的绝对路径。

  • entry:用于指定需要使用 Webpack 捆绑的所有文件。它接受字符串、数组和 JSON 对象。如果需要 Webpack 捆绑单个入口文件,只需将文件路径指定为字符串。否则,使用数组或对象。

  • 在我们的情况下,我们以[name]: [path_of_the_file]的形式指定输入文件为对象。

  • 这个[name]将用于命名每个文件的输出捆绑包。

  • output:在输出中,我们需要指定输出目录的绝对路径,在我们的情况下是dist,以及文件名,即我们在入口部分指定的[name],后跟文件扩展名[name].js

在终端中运行npm run webpack。您应该会在dist目录中看到两个新文件被创建:general.jsmemes.js,它们包含了各自源文件的捆绑代码。memes.js文件将包括general.js文件中的代码,因此在 HTML 中只需要包含memes.js文件即可。

现在我们已经编写了捆绑我们代码的配置,我们将使用这个配置文件来将 ES6 语法转换为 ES5。在 Webpack 中,当文件被导入时,转换会被应用。要应用转换,我们需要使用加载器。

Webpack 中的加载器

加载器用于在导入和捆绑文件之前对文件应用转换。在 Webpack 中,使用不同的第三方加载器,我们可以转换任何文件并将其导入到我们的代码中。这适用于用其他语言编写的文件,如 TypeScript、Dart 等。我们甚至可以将 CSS 和图像导入到我们的 JS 代码中。首先,我们将使用加载器将 ES6 转换为 ES5。

memes.js文件中,添加以下代码:

class Memes {
  constructor() {
    console.log('Inside Memes class');
  }
}

new Memes();

这是一个使用 ES6 的简单类,构造函数中有一个console.log语句。我们将使用 Webpack 和babel-loader将这个 ES6 代码转换为 ES5 形式。为此,安装以下软件包:

npm install -D babel-core babel-loader babel-preset-env babel-preset-es2015

在您的webpack.config.js文件中,在输出属性下面添加以下代码:

module: {
  rules: [
    {
      test: /\.js$/,
      exclude: /(node_modules)/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['env', 'es2015'],
        }
      }
    }
  ],
},

这就是我们在 Webpack 中添加加载器的方法。我们需要在模块部分内创建一组规则的数组。规则包含加载器的配置对象数组。在我们的配置中,它将测试文件,看它是否与正则表达式.js$匹配,也就是说,检查文件是否是 JavaScript 文件,使用它的扩展名。我们已经排除了node_modules目录,这样只有我们的代码会被评估进行转换。

如果导入的文件是 JavaScript 文件,Webpack 将使用提供的选项使用babel-loader。在这里,在options中,我们指示 Babel 使用enves2015预设。es2015预设将把 ES6 代码转译成 ES5 格式。

env预设更为特殊。它用于将任何 ES 版本的 JavaScript 转译为特定环境支持的版本(如特定版本的 Chrome 和 Firefox)。如果没有提供配置,就像我们之前提到的代码一样,那么它将使 JavaScript 代码(甚至是 ES8)在几乎所有环境中工作。有关此预设的更多信息,请访问github.com/babel/babel-preset-env

由于我们只会在本书中使用 ES6,所以es2015预设对所有项目来说已经足够了。但是,如果您将来想要学习 ES7 及更高版本,那么请学习env预设的工作。

同样,让我们使用 Webpack 捆绑我们的 CSS 代码。使用 Webpack 捆绑 CSS 代码有许多优点。其中一些如下:

  • 通过在各自的 JavaScript 文件中导入所需的 CSS 代码,只使用每个网页所需的 CSS 代码。这将导致更容易和更好的依赖管理,并减少每个页面的文件大小。

  • CSS 文件的最小化。

  • 使用 autoprefixer 轻松自动添加特定供应商前缀。

  • 轻松编译使用 Sass、Less、Stylus 等编写的样式表为普通的 CSS。

使用 Webpack 捆绑 CSS 代码还有更多优势。因此,让我们从捆绑我们的styles.css文件开始,然后是 Bootstrap 的文件。安装以下依赖项来实现我们的 CSS 加载器:

npm install -D css-loader style-loader

在我们的 Webpack 配置中,将以下对象添加到 rules 数组中:

{
  test: /\.css$/,
  use: [ 'style-loader', 'css-loader' ]
},

我们正在安装两个加载器来捆绑 CSS 文件:

  1. 第一个是css-loader。它使用 Webpack 解析所有的导入和url()。然后返回完整的 CSS 文件。

  2. style-loader将把 CSS 添加到页面中,以便样式在页面上生效。

  3. 我们需要先运行css-loader,然后是style-loader,它使用css-loader返回的输出。为此,我们编写了以下内容:

  • 对于 CSS 文件:test: /\.css$/

  • 使用以下加载器:use: ['style-loader', 'css-loader']。Webpack 按照从后到前的顺序执行加载器。因此,首先执行css-loader,然后将其输出传递给style-loader

  1. 打开你的general.js文件,并在文件开头添加以下行:
import  '../css/styles.css';

还要删除在你的index.html页面中用于包含 CSS 文件的<link>属性。这是一个技巧:CSS 文件将被导入到general.js文件中,然后被导入到memes.js文件中,这是你需要在index.html中包含的唯一文件。

我们将创建一个大的webpack.config.js文件。如果遇到任何问题,请参考我们在以下位置创建的最终webpack.config.js文件:goo.gl/Q8P4ta或本书代码文件中的chapter02\webpack-dev-server目录。

现在是时候看看我们的应用程序了。在终端中执行npm run webpack,然后在 Chrome 中打开只包含一个memes.js文件的网站。你应该看到完全相同的页面,没有任何变化。所有的依赖项都被捆绑到一个单一的文件中——除了 Bootstrap!

在 Webpack 中捆绑 Bootstrap

是时候将我们最终的依赖项捆绑到 Webpack 中了。Bootstrap 由三个部分组成。首先是 Bootstrap 的 CSS 文件,然后是 jQuery 和依赖于 jQuery 的 Bootstrap 的 JavaScript 文件。这两个文件在本章的index.html文件中被忽略了,因为我们没有使用它们。但是,由于我们正在使用 Webpack 捆绑我们的依赖项,让我们把它们都放在一起。首先,安装我们的依赖项(这些不是开发依赖项;因此,使用-S而不是-D):

npm install -S jquery bootstrap@3

Bootstrap 是使用Less而不是 CSS 编写的。Less是一个 CSS 预处理器,它通过添加更多功能(如变量、混合和函数)来扩展 CSS。为了使用 Webpack 导入 Bootstrap 的 less 文件,我们需要另一个加载器:

npm install -D less less-loader

这将把 less 编译器和加载器安装到我们的node_modules中。现在,在我们的 rules 中,将 CSS 规则修改为:

{
  test: /\.(less|css)$/,
  use: [ 'style-loader', 'css-loader', 'less-loader' ]
},

这将在 Webpack 检测到 CSS 或 less 文件时将less-loader作为第一个选项添加为加载器。现在,尝试npm run webpack。这次,你将在终端中收到一个错误,指出"您可能需要一个适当的加载器来处理此文件类型",这是由 Bootstrap 使用的字体引起的。由于 Bootstrap 依赖于许多字体,我们需要创建一个单独的加载器将它们包含在我们的捆绑文件中。为此目的,安装以下内容:

npm install -D file-loader url-loader

然后在你的 rules 数组中包含以下对象:

{
  test: /\.(svg|eot|ttf|woff|woff2)$/,
  loader: 'url-loader',
  options: {
    limit: 10000,
    name: 'fonts/[name].[ext]'
  }
},

这将告诉 Webpack,如果文件大小小于 10KB,则将文件作为数据 URL 内联到 JavaScript 中。否则,将文件移动到字体文件夹并在 JavaScript 中创建一个引用。如果文件大小小于 10KB,则这对于减少网络开销很有用。url-loader需要安装file-loader作为依赖项。再次执行npm run webpack,这次你的 Bootstrap less 文件将成功捆绑,并且你将能够在浏览器中查看你的网站。

对于一些 CSS 和 JS 文件来说,这可能看起来是很多工作。但是,当您在大型应用程序上工作时,这些配置可以节省数小时的开发工作。Webpack 的最大优势是您可以为一个项目编写配置,并将其用于其他项目。因此,我们在这里做的大部分工作只需要做一次。我们只需复制并在其他项目中使用我们的webpack.config.js文件。

如我之前提到的,我们没有在应用程序中使用 Bootstrap 的 JS 文件。但是,我们可能需要在将来的应用程序中使用它们。Bootstrap 需要全局范围内可用的 jQuery,以便执行其 JavaScript 文件。但是,Webpack 不会暴露它捆绑的 JavaScript 变量,除非明确指定要暴露它们。

为了使 jQuery 在整个 Web 应用程序中的全局范围内可用,我们需要使用 Webpack 插件。插件与加载程序不同。我们稍后会详细了解插件。现在,请在 Webpack 的 module 属性之后添加以下代码:

module: {
  rules: [...],
},
plugins: [
  new webpack.ProvidePlugin({
    jQuery: 'jquery',
    $: 'jquery',
    jquery: 'jquery'
  }),
],

在我们的general.js文件中,包含以下行以将所有 Bootstrap JavaScript 文件导入到我们的 Web 应用程序中:

import  'bootstrap';

这行将从node_modules文件夹中导入 Bootstrap 的 JavaScript 文件。您现在已成功使用 Webpack 捆绑了 Bootstrap。还有一个常用的加载程序- img-loader。有时我们会在 CSS 和 JavaScript 中包含图像。使用 Webpack,我们可以在捆绑时自动捆绑图像,同时压缩较大图像的大小。

要捆绑图像,我们需要一起使用img-loaderurl-loader。首先,安装img-loader

npm install -D img-loader

将以下对象添加到您的规则列表中:

{
  test: /\.(png|jpg|gif)$/,
  loaders: [
    {
      loader: 'url-loader',
      options: {
        limit: 10000,
        name: 'images/[name].[ext]'
      }
    },
  'img-loader'
  ],
},

现在,执行npm run webpack,然后再次打开网站。您的所有依赖项都已捆绑在一个名为memes.js的 JavaScript 文件中,准备就绪。

有时,img-loader二进制文件在构建过程中可能会因您的操作系统而失败。在 Ubuntu 的最新版本中,这是由于缺少的软件包,可以从packages.debian.org/jessie/amd64/libpng12-0/download下载并安装。在其他操作系统中,您必须手动找出构建失败的原因。如果您无法解决img-loader问题,请尝试使用不同的加载程序,或者只使用url-loader来处理图像。

Webpack 中的插件

与加载程序不同,插件用于自定义 Webpack 构建过程。Webpack 内置了许多插件。它们可以通过webpack.[plugin-name]访问。我们还可以编写自己的函数作为插件。

有关 webpack 的插件系统的更多信息,请参阅webpack.js.org/configuration/plugins/

Webpack 开发服务器

到目前为止,我们已经创建了 Webpack 配置来编译我们的代码,但如果我们可以像使用http-server一样提供代码,那将更容易。webpack-dev-server是一个使用 Node.js 和 Express 编写的小型服务器,用于提供 Webpack 捆绑包。要使用webpack-dev-server,我们需要安装它的依赖项并更新我们的 npm 脚本:

npm install -D webpack-dev-server

将以下行添加到 npm 脚本中:

  "watch": "webpack-dev-server"

使用npm run watch,我们现在可以在本地主机上的服务器上提供文件。webpack-dev-server不会将捆绑的文件写入磁盘。相反,它将自动从内存中提供它们。webpack-dev-server的一个很棒的功能是它能够进行HotModuleReplacement,这将替换已更改的代码部分,甚至无需重新加载页面。要使用HotModuleReplacement,请将以下配置添加到您的 Webpack 配置文件中:

entry: {...},
output: {...},
devServer: {
  compress: true,
  port: 8080,
  hot: true,
},
module: {..},
plugins: [
  ...,
  new webpack.HotModuleReplacementPlugin(),
],

目前,webpack-dev-server正在从根目录提供文件。但是我们需要从dist目录提供文件。为此,我们需要在输出配置中设置publicPath

output: {
  ...,
  publicPath: '/dist/',
},

删除你的dist文件夹并运行npm run watch命令。你的 Web 应用现在将在控制台中打印一些额外的消息。这些消息来自webpack-dev-server,它正在监听任何文件更改。尝试在 CSS 文件中更改几行。你的更改将立即反映出来,无需重新加载页面!这对于在代码保存后立即查看样式更改非常有用。HotModuleReplacement广泛用于现代 JavaScript 框架,如 React、Angular 等。

我们的代码中仍然缺少用于调试的source-maps。为了启用source-maps,Webpack 提供了一个简单的配置选项:

devtool: 'source-map',

Webpack 可以生成不同类型的源映射,具体取决于生成它们所需的时间和质量。请参考此页面以获取更多信息:webpack.js.org/configuration/devtool/

这将只向 JS 文件添加源映射。要向包含 Bootstrap 的 less 文件的 CSS 文件添加source-maps,请将 CSS 规则更改为以下内容:

{
  test: /\.(less|css)$/,
  use: [
    {
      loader: "style-loader"
    },
    {
      loader: "css-loader",
      options: {
        sourceMap: true
      }
    },
    {
      loader: "less-loader",
      options: {
        sourceMap: true
      }
    }
  ]
},

这条规则将告诉less-loader向其编译的文件添加source-maps并将其传递给css-loader,后者也将源映射传递给style-loader。现在,你的 JS 和 CSS 文件都将有源映射,这样就可以轻松在 Chrome 中调试应用程序。

如果你一直在跟进,你的 Webpack 配置文件现在应该看起来像以下 URL 中的代码:goo.gl/Q8P4ta。你的package.json文件应该是这样的:goo.gl/m4Ib97。这些文件也包含在书中代码的chapter02\webpack-dev-server目录中。

我们在 Webpack 中使用了许多不同的加载器,每个加载器都有自己的配置选项,其中许多我们在这里没有讨论。请访问这些包的 npm 或 GitHub 页面,了解更多关于它们的配置并根据您的需求进行自定义。

即将到来的部分是可选的。如果你想构建 Meme Creator 应用程序,你可以跳过下一部分并开始开发。你现在拥有的 Webpack 配置完全没问题。然而,下一部分对于更多了解 Webpack 并在生产中使用它非常重要,所以请稍后回来阅读!

为不同环境优化 Webpack 构建

在开发大型应用程序时,通常会为应用程序创建不同类型的环境,例如开发、测试、暂存、生产等。每个环境都有不同的应用程序配置,对于团队中的不同人员进行开发和测试非常有用。

例如,假设你的应用程序有一个用于支付的 API。在开发过程中,你将拥有沙盒凭据,而在测试过程中,你将拥有不同的凭据,最后,在生产环境中,你将拥有支付网关所需的实际凭据。因此,应用程序需要在三种不同的环境中使用三种不同的凭据。同样重要的是不要将敏感信息提交到版本控制系统中。

那么,我们如何在不在代码中写入凭据的情况下将凭据传递给应用程序?这就是环境变量的概念发挥作用的地方。操作系统将在编译时提供值,以便可以使用不同环境变量中的值在不同环境中生成构建。

创建环境变量的过程对于每个操作系统都是不同的,对于每个项目来说维护这些环境变量是一项繁琐的任务。因此,让我们通过使用npm包从项目根目录的.env文件中加载我们的环境变量来简化这个过程。在 Node.js 中,您可以在process.env对象中访问环境变量。以下是如何从.env文件中读取变量的方法:

  1. 第一步是安装以下包:
npm install -D dotenv
  1. 完成后,在您的项目根目录中创建一个.env文件,并添加以下行:
NODE_ENV=production
CONSTANT_VALUE=1234567
  1. 这个.env文件包含三个环境变量及其值。如果您使用 Git,则应将.env文件添加到.gitignore文件中,或将其包含在您的版本控制系统的忽略列表中。创建.env.example文件也是一个好习惯,它告诉其他开发人员应用程序需要哪些环境变量。您可以将.env.example文件提交到您的版本控制系统。我们的.env.example文件应如下所示:
NODE_ENV=
CONSTANT_VALUE=

这些环境变量可以被 Node.js 读取,但不能被我们的 JavaScript 代码读取。因此,我们需要 Webpack 读取这些变量,并将它们作为全局变量提供给 JavaScript 代码。建议将环境变量名称的字母保持大写,以便您可以轻松识别它们。

我们将使用NODE_ENV来检测环境类型,并告诉 Webpack 为该环境生成适当的构建,我们需要在我们的 JS 代码中使用其他两个环境变量。在您的webpack.config.js文件中,第一行包含以下代码:

require('dotenv').config()

这将使用我们刚刚安装的dotenv包,并从我们项目的根目录中的.env文件中加载环境变量。现在,环境变量可以在 Webpack 配置文件中的process.env对象中访问。首先,让我们设置一个标志,检查当前环境是否为 production。在require('webpack')行之后包含以下代码:

const  isProduction = (process.env.NODE_ENV === 'production');

现在,当NODE_ENV设置为 production 时,isProduction将被设置为 true。要在我们的 JavaScript 代码中包含另外两个变量,我们需要在 Webpack 中使用DefinePlugin。在插件数组中,添加以下配置对象:

new webpack.DefinePlugin({
  ENVIRONMENT: JSON.stringify(process.env.NODE_ENV),
  CONSTANT_VALUE: JSON.stringify(process.env.CONSTANT_VALUE),
}),

DefinePlugin将在编译时定义常量,因此您可以根据您的环境更改您的环境变量,并且它将反映在代码中。确保您对传递给DefinePlugin的任何值进行字符串化。有关此插件的更多信息,请访问:webpack.js.org/plugins/define-plugin/

现在,在您的memes.js文件的构造函数中,尝试console.log(ENVIRONMENT, CONSTANT_VALUE);并重新加载 Chrome。您应该在控制台中看到它们的值被打印出来。

由于我们使用isProduction变量设置了一个标志,所以只有在环境为 production 时才能对构建进行各种优化。一些常用于生产构建优化的插件如下。

在 Windows 中创建.env 文件

Windows 不允许您直接从 Windows 资源管理器创建.env文件,因为它不允许以点开头的文件名。但是,您可以轻松地从 VSCode 中创建它。首先,使用菜单选项文件|打开文件夹...[Ctrl+K Ctrl+O]打开 VSCode 中的项目文件夹,如下截图所示:

一旦您打开了文件夹,点击 VSCode 左上角的资源管理器图标(或按Ctrl+Shift+E)打开资源管理器面板。在资源管理器面板中,点击“新建文件”按钮,如下截图所示:

然后,如下截图所示,简单地输入新文件名.env

Enter创建.env文件并开始编辑它。

当 Webpack-dev-server 启动时,.env文件是只读的。因此,如果您对.env文件进行任何更改,您将需要在终端中终止运行的 Webpack-dev-server 实例,并重新启动它,以便它将读取.env文件中的新值。

UglifyJsPlugin

这是一个用于压缩和缩小 JavaScript 文件的插件。这将大大减小 JavaScript 代码的大小,并增加最终用户的加载速度。但是,在开发过程中使用此插件会导致 Webpack 变慢,因为它会为构建过程添加额外的步骤(昂贵的任务)。因此,UglifyJsPlugin通常仅在生产环境中使用。要这样做,请在 Webpack 配置的末尾添加以下行:

if(isProduction) {
  module.exports.plugins.push(
    new webpack.optimize.UglifyJsPlugin({sourceMap: true})
  );
}

如果环境设置为生产环境,这将将UglifyJSPlugin推送到插件数组中。有关UglifyJsPlugin的更多信息,请访问:webpack.js.org/plugins/uglifyjs-webpack-plugin/

PurifyCSSPlugin

构建 Web 应用程序时,将会有很多在 CSS 中定义但在 HTML 中从未使用的样式。PurifyCSSPlugin将遍历所有 HTML 文件,并在捆绑代码之前删除我们之前定义的任何不必要的 CSS 样式。要使用PurifyCSSPlugin,我们需要安装purifycss-webpack包:

npm install -D purifycss-webpack

之后,将插件导入到您的 Webpack 配置文件中,并按以下代码中指定的方式使用它:

const  PurifyCSSPlugin = require('purifycss-webpack'); constglob = require('glob');
 module.exports = {
  ...
  plugins: [
    ...
    new PurifyCSSPlugin({
      paths: glob.sync(__dirname + '/*.html'),
      minimize: true,
    }),
  ],
}

glob是 Node.js 中的内置模块。我们使用glob.sync指定 HTML 的路径,它将正则表达式解析为指定目录中的所有 HTML 文件。PurifyCSSPlugin现在将使用这些 HTML 文件来净化我们的样式。minimize选项将与净化一起最小化 CSS。有关PurifyCSSPlugin的更多信息,请访问:github.com/webpack-contrib/purifycss-webpack

PurifyCSSplugin很有用,但可能会导致 Bootstrap 动画和其他一些插件出现问题。在使用之前,请务必进行充分测试。

ExtractTextPlugin

在生产环境中,建议将所有 CSS 代码提取到单独的文件中。这是因为 CSS 文件需要在页面开头包含,以便在加载 HTML 时应用页面样式。但是,由于我们将 CSS 与 JavaScript 捆绑在一起,因此我们将其包含在页面末尾。当页面加载时,它将看起来像一个普通文档,直到 CSS 文件加载完成。

ExtractTextPlugin用于解决这个问题。它将把所有 CSS 文件从 JS 代码中提取到与其捆绑在一起的具有相同名称的单独文件中。现在我们可以在 HTML 文件的顶部包含该 CSS 文件,这样样式将首先加载。和往常一样,第一步是安装包:

npm install -D extract-text-webpack-plugin

在此之后,我们需要创建一个新的ExtractTextPlugin实例,我们将与我们的 CSS 文件一起使用。由于我们还在使用 Bootstrap 的 less,我们的配置文件应该如下所示:

...
const extractLess = new ExtractTextPlugin({
  filename: "[name].css",
});

module.exports = {
  ...
  module: {
    rules: [
      ...
      {
        test: /\.(less|css)$/,
        use: extractLess.extract({
          use: [
            {
              loader: 'css-loader',
              options: {
                sourceMap: true
              }
            },
            {
              loader: 'less-loader',
              options: {
                sourceMap: true
              }
            }
          ],
          fallback: 'style-loader',
        })
     },
    ]
  },
  ...
  plugins: [
    ...
    extractLess,
    new PurifyCSSPlugin({
      paths: glob.sync(__dirname + '/*.html'),
      minimize: true,
    }),
    ...
  ]
}

我们已经创建了一个名为extractLessExtractTextPlugin实例。由于我们使用了PurifyCSSPlugin,请确保在我们在插件数组中创建PurifyCSSPlugin实例之前包含extractLess对象。

有关PurifyCSSPlugin的更多信息,请访问:github.com/webpack-contrib/purifycss-webpack

一旦您添加了ExtractTextPlugin,Webpack 将为每个 JavaScript 文件生成两个文件,如果 JavaScript 文件导入 CSS,则需要在 HTML 中单独包含 CSS 文件。在我们的情况下,对于memes.js,它将在dist目录中生成memes.jsmemes.css,这需要单独包含在 HTML 文件中。

ExtractTextPlugin与 Webpack 的HotModuleReplacement不适用于 CSS 文件。因此,最好只在生产环境中包含ExtractTextPlugin

缓存破坏

为了使用 Webpack 生成的静态资源进行缓存,最好的做法是将静态资源的文件名附加哈希。[chunkhash]将生成一个内容相关的哈希,应该附加到充当缓存破坏器的文件名上。每当文件内容发生变化时,哈希值都会改变,这将导致新的文件名,因此重新生成缓存。

只有生产构建需要缓存破坏逻辑。开发构建不需要这些配置。因此,我们只需要在生产环境中生成带哈希的文件名。此外,我们必须生成一个包含生成资源的新文件名的manifest.json文件,这些资源必须内联到 HTML 文件中。缓存破坏的配置如下:

const fileNamePrefix = isProduction? '[chunkhash].' : '';

module.exports = {
  ...
  output: {
    ...
    filename: fileNamePrefix + '[name].js',
    ...
  }
}

这将在生产环境中为文件名添加哈希前缀。但是,webpack.HotModuleReplacementPlugin()[chunkhash]不兼容,因此在我们的生产环境中不应使用HotModuleReplacementPlugin。要生成manifest.json文件,请将以下函数作为元素添加到插件数组中:

function() {
  this.plugin("done", function(status) {
    require("fs").writeFileSync(
      __dirname + "/dist/manifest.json",
      JSON.stringify(status.toJson().assetsByChunkName)
    );
  });
}

或者最好将其添加到UglifyJSPlugin旁边,该插件仅在生产环境中执行。此函数将使用 Node.js 中的fs模块将生成的文件写入 JSON 文件。有关此主题的更多信息,请参阅:webpack.js.org/guides/caching/

在生成新构建之前清理dist文件夹。

由于我们生成了许多带有不同哈希文件名的构建,最好的做法是在运行每个构建之前删除dist目录。clean-webpack-plugin就是这样做的。它会在新文件捆绑之前清理dist目录。要使用clean-webpack-plugin,请在项目根目录内运行以下命令来安装插件:

npm install -D clean-webpack-plugin

然后,将以下变量添加到您的 Webpack 配置文件中:

const CleanWebpackPlugin = require('clean-webpack-plugin');
const pathsToClean = [
 'dist'
];
const cleanOptions = {
 root: __dirname,
 verbose: true,
 dry: false,
 exclude: [],
};

最后,将new CleanWebpackPlugin(pathsToClean, cleanOptions)添加到您的生产插件中。现在,每次生成生产构建时,旧的dist文件夹将被删除,并将创建一个包含最新捆绑文件的新文件夹。有关此插件的更多信息,请参阅:github.com/johnagan/clean-webpack-plugin

生产环境中的源映射

源映射为我们提供了一种轻松调试编译后的代码的方法。直到打开开发工具之前,浏览器不会加载源映射。因此,就性能而言,源映射不会造成任何伤害。但是,如果需要保护原始源代码,则删除源映射是一个好主意。您还可以通过在每个捆绑文件的末尾设置sourceMappingURL来使用私有源映射,以将哈希前缀添加到生产环境中的文件名。这样,源映射只能由受信任的源(例如,源映射只能由公司域内的开发人员访问)使用:

//# sourceMappingURL: http://protected.domain/dist/general.js.map

包含了所有前面提到的优化的完整 Webpack 配置文件如下:goo.gl/UDuUBu。此配置中使用的依赖项可以在此处找到:goo.gl/PcHpZf。这些文件也包含在本书的代码文件中,位于Chapter02\webpack production optimized目录下。

我们刚刚尝试了许多由社区创建的 Webpack 插件和加载器。请记住,执行这些任务的方法不止一种。因此,请务必随时查看许多新的插件/加载器。此存储库包含了 Webpack 资源的精选列表:github.com/webpack-contrib/awesome-webpack

由于我们在 Meme Creator 中使用了 flexbox,一些旧的浏览器支持带有vendor-prefixes的 flexbox。尝试使用postcss/autoprefixer向您的 CSS 添加供应商前缀:github.com/postcss/autoprefixer

构建 Meme Creator

我们刚刚使用 Webpack 构建了一个不错的开发环境。现在是时候投入使用了。如果您已经进行了生产优化,请确保在项目根文件夹中创建了.env文件,并且该文件中的NODE_ENV环境变量不是production。在我们工作在应用程序时,简单地将NODE_ENV=dev的值设置为NODE_ENV。我们现在要构建 Meme Creator。确保您已经在index.html文件中包含了dist目录中的memes.jsmemes.css文件(如果您使用了ExtractTextPlugin)。

在文本编辑器中打开memes.js文件并保持webpack-dev-server运行(npm run watch)。我们的第一步是在我们的类中创建对所有所需 DOM 元素的引用变量。然后,我们可以使用这些引用来稍后从类内部修改元素。此外,每当我们创建对 DOM 元素的引用时,最好将变量名称以$开头。这样,我们可以轻松知道哪些变量包含值,哪些包含对 DOM 元素的引用。

webpack-dev-server 将在控制台中打印 URL,您应该使用 Chrome 打开以查看您的应用程序。URL 将是 http://localhost:8080/

还记得在上一章中,我们使用document.getElementById()来搜索 DOM 元素吗?JavaScript 还有一个更好的替代方法,使查询 DOM 元素更简单:document.querySelector()方法。前者只允许我们使用Id搜索文档,但querySelector允许我们使用id、类甚至元素名称查询文档。例如,如果您需要选择以下内容:

<input id="target" class="target-input" type="text"/>

您可以使用以下之一:

document.querySelector('#target');
document.querySelector('.target-input');
document.querySelector('input#target.target-input');

所有这些都将返回与查询条件匹配的第一个元素。如果要选择多个元素,可以使用document.querySelectorAll(),它将返回对所有匹配的 DOM 元素的引用数组。在我们的构造函数中,编写以下代码:

this.$canvas = document.querySelector('#imgCanvas');
this.$topTextInput = document.querySelector('#topText');
this.$bottomTextInput = document.querySelector('#bottomText');
this.$imageInput = document.querySelector('#image');
this.$downloadButton = document.querySelector('#downloadMeme');

现在我们的类中有对所有所需 DOM 元素的引用。目前,我们的画布很小;我们没有使用 CSS 指定其大小,因为我们需要页面具有响应性。如果用户从移动设备访问页面,我们不希望显示水平滚动条,因为画布由于其大小而超出了屏幕。因此,我们将使用 JavaScript 根据屏幕大小创建画布高度和宽度。首先需要计算设备宽度。在Memes类之前添加以下代码(不要放在类内部):

const  deviceWidth = window.innerWidth;

这将计算设备的宽度并将其存储在常量deviceWidth中。在类内部,创建以下函数:

createCanvas() {
  let canvasHeight = Math.min(480, deviceWidth-30);
  let canvasWidth = Math.min(640, deviceWidth-30);
  this.$canvas.height = canvasHeight;
  this.$canvas.width = canvasWidth;
}

对 DOM 元素的引用包含整个目标元素作为 JavaScript 对象。它可以像处理普通类对象一样使用。对引用的修改将反映在 DOM 中。

如果设备屏幕足够大,这将创建一个高度为480,宽度为640的矩形画布。否则,它将创建一个宽度为deviceWidth-30的正方形画布。参考您之前看到的 Meme Creator 的图像。桌面上的画布将是矩形的,而移动设备上将成为带有边距的正方形区域。

Math.min(x, y)将返回两个数字xy中较小的那个。我们将宽度减小了30,因为我们需要为边距留出空间。在构造函数中添加this.createCanvas(),并在 Chrome 中查看页面(Webpack 将为您重新加载页面)。尝试响应式设计模式,查看画布在移动设备上的显示方式。高度和宽度仅在首次加载页面时应用;因此,在检查不同设备时,请刷新页面。

我们的画布区域已经准备好了;让我们来看看 HTML 中新的<canvas>元素的一些内容。Canvas 是一个图形容器。我们可以使用 JavaScript 在 canvas 元素上绘制图形。Canvas 有几种绘图方法,如路径、形状、文本和图像。此外,在 canvas 中渲染图形比使用 DOM 元素更快。Canvas 的另一个优势是我们可以将画布内容转换为图像。在现实世界的应用程序中,当你有服务器端 API 时,你可以使用服务器来渲染表情包的图像和文本。但是,由于本章节中我们不打算使用服务器端,canvas 是我们最好的选择。

访问Mozilla 开发者网络MDN)页面:developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial 以获取有关 canvas 元素的更多信息。

以下是表情包创建器的策略:

  • 画布元素只在被指示时将图形渲染到其位图上。我们无法检测到先前在其上绘制的任何图形。这使我们别无选择,只能在每次输入新文本或图像到表情包创建器时清除画布,并再次渲染整个画布。

  • 我们需要事件监听器在用户在顶部文本框或底部文本框中输入文本时向表情包添加文本。

  • 底部文本是一个必填字段。用户只有在填写了底部文本后才能下载表情包。

  • 用户可以选择任意大小的图像。如果他选择了一个巨大的图像,它不应该破坏我们的页面布局。

  • 下载按钮应该像一个下载按钮一样工作!

事件处理

我们现在有了一个构建表情包创建器的想法。我们的第一步是创建一个将表情包渲染到画布上的函数。在Memes类中,创建一个名为createMeme()的函数,它将包含我们的主要画布渲染器。现在,让函数保持一个简单的控制台语句:

createMeme() {
  console.log('rendered');
}

记住,每次发生变化时我们需要渲染整个画布。因此,我们需要为所有输入元素附加事件监听器。你也可以使用 HTML 事件属性,比如我们在之前的 ToDo List 应用程序中使用的onchange。但是事件监听器让我们可以处理一个元素的多个事件。因此,它们被广泛地使用。此外,由于我们使用 Webpack 来捆绑代码,我们无法直接在 HTML 中访问 JavaScript 变量或对象!这需要一些 Webpack 配置更改,而且可能根本不需要。我们将在下一章节中详细讨论这个话题。

首先,我们需要在TopTextInputBottomTextInput区域输入文本时调用createMeme。因此,我们需要在这些输入框上附加一个监听keyup事件的事件监听器。创建事件监听器函数:

addEventListeners() {
  this.$topTextInput.addEventListener('keyup', this.createMeme);
  this.$bottomTextInput.addEventListener('keyup', this.createMeme);
}

打开 Chrome 并尝试在保持控制台打开的情况下在文本框中输入。每次输入一个单词时,你应该在控制台中看到rendered被打印出来。实际上,如果你想将相同的事件监听器附加到多个元素,有一个更好的方法来附加事件监听器。只需使用以下方法:

addEventListeners() {
  let inputNodes = [this.$topTextInput, this.$bottomTextInput, this.$imageInput];

  inputNodes.forEach(element => element.addEventListener('keyup', this.createMeme));
}

这段代码做了以下几件事:

  • 它创建了一个对所有目标输入元素(inputNodes)的引用对象数组

  • 使用forEach()方法循环遍历数组中的每个元素,并为其附加一个事件监听器

  • 通过使用 ES6 的箭头函数,我们在一行代码中实现了它,而不必担心将this对象绑定到回调函数。

我们还在inputNodes中添加了$imageInput。这个元素不会受到keyup事件的影响,但是当用户上传新图片时,我们需要监控它。此外,如果用户在不按键盘按钮的情况下复制和粘贴文本到文本输入框中,我们需要处理这种变化。这两种情况都可以使用change事件来处理。在addEventListeners()函数中添加以下行:

inputNodes.forEach(element  =>  element.addEventListener('change', this.createMeme));

每当用户输入一些文本或上传新图像时,this.createMeme()方法将自动调用。

在画布中渲染图像

向画布渲染一些东西的第一步是使用CanvasRenderingContext2D接口获取目标<canvas>元素的 2D 渲染上下文。在我们的createMeme()函数内部,为画布元素创建一个上下文:

let context = this.$canvas.getContext('2d');

context变量现在将保存CanvasRenderingContext2D接口的对象。为了使渲染更加高效,我们将添加一个条件,仅在用户选择了图像时才进行渲染。我们可以通过检查图像输入的引用是否包含任何文件来实现这一点。只有在输入中选择了文件时,我们才应该开始渲染过程。为此,检查输入元素是否包含任何文件对象:

if (this.$imageInput.files && this.$imageInput.files[0]) {
  console.log('rendering');
}

现在,尝试在输入字段中输入一些文本。您应该在控制台中收到一个错误,说:无法读取未定义的属性'getContext'。

此时,您应该问以下问题:

  • 我们不是在构造函数中定义this.$canvas来保存对画布元素的引用吗?

  • 我们从画布引用this.$canvas获取上下文对象。但是this.$canvas怎么会是未定义的呢?

  • 我们难道没有做对吗?

要找到答案,我们需要使用 Chrome DevTools 来找出代码中出了什么问题。在引起错误的行之前(我们定义上下文变量的行)添加debugger;关键字。现在,重新加载 Chrome 并开始输入。Chrome 的调试器现在已经暂停了页面执行,并且源选项卡将突出显示 Chrome 调试器暂停执行的行:

现在,代码的执行已经暂停。这意味着在执行期间,所有变量现在都将包含它们的值。将光标悬停在debugger;旁边的行上的this关键字上。令人惊讶的是,将光标放在此对象上将突出显示您网站中顶部输入文本字段!此外,信息弹出窗口还将显示包含对input#topText.form-control的引用的此对象。问题在这里:this对象不再具有对类的引用,而是具有对 DOM 元素的引用。我们在类内部定义了$canvas变量;因此,this.$canvas现在未定义。我们在上一个项目中遇到了类似的绑定this对象的问题。您能猜到我们哪里出错了吗?

这是我们在addEventListeners()函数中附加事件侦听器到输入元素的行。由于我们在这里使用了 ES6 的箭头函数,您可能会想知道为什么this没有自动从父级继承其值。这是因为,这一次,我们将this.createMeme作为参数发送到目标元素的addEventListener()方法。因此,该输入元素成为继承this对象的新父级。为了解决这个问题,在addEventListeners()函数的第一行添加以下代码:

this.createMeme = this.createMeme.bind(this);

现在,this.createMeme可以在addEventListeners()函数的任何地方正常使用。尝试在输入框中输入一些文本。这次不应该有任何错误。现在,从源图像输入中选择一个图像。尝试输入一些文本。这次,您应该在控制台中看到rendering文本。我们将在此if条件中编写渲染代码,以便当选择图像时才渲染表情。

还有一件事!如果您点击图像输入,它会显示磁盘中的所有文件。我们只需要用户选择图像文件。在这种情况下,在index.html中的输入元素中添加accept属性,以允许用户选择的扩展名。新的输入元素应该是以下内容:

<input type="file" id="image" class="form-control" accept=".png,.jpg,.jpeg">

使用 JavaScript 读取文件

为了读取所选的图像,我们将使用FileReader,它允许 JavaScript异步读取文件的内容(无论是来自文件还是原始数据)。请注意术语异步;这意味着 JavaScript 不会等待FileReader代码完成执行。JavaScript 将在FileReader仍在读取文件时开始执行下一行。这是因为 JavaScript 是单线程语言。这意味着所有操作、事件监听器、函数等都在单个线程中执行。如果 JS 必须等待FileReader的完成,那么整个 JavaScript 代码将被暂停(就像调试器暂停脚本的执行一样),因为一切都在单个线程中运行。

为了避免这种情况发生,JavaScript 不仅仅等待事件完成,而是在执行下一行代码的同时运行事件。我们可以处理异步事件的不同方式。通常,异步事件会给定一个回调函数(需要在事件完成后执行的一些代码行)或者异步代码在执行完成时会触发一个事件,我们可以编写一个函数在触发该事件时执行。ES6 有一种新的处理异步事件的方式,称为 Promises。

我们将在下一章中了解更多关于使用 Promises 的内容。FileReader在完成读取文件时会触发load事件。FileReader还带有onload事件处理程序来处理load事件。在if语句内部,创建一个新的FileReader对象,并使用FileReader()构造函数将其分配给变量 reader。这就是我们将如何处理异步FileReader逻辑的方式:将以下代码写入if语句中(删除之前的console.log语句):

let reader = new FileReader();

reader.onload = () => {
  console.log('file completly read');
};

reader.readAsDataURL(this.$imageInput.files[0]);
console.log('This will get printed first!');

现在,在 Chrome 中尝试选择一张图片。你应该在控制台中看到两个语句打印出来。这是我们在之前的代码中所做的事情:

  • 我们在 reader 变量中创建了一个FileReader的新实例。

  • 然后,我们在onload事件处理程序中指定了读取器应该做什么

  • 然后,我们将所选图像的文件对象传递给了读取器对象

正如你可能已经猜到的那样,JavaScript 将首先执行reader.readAsDataURL,并发现它是一个异步事件。因此,在FileReader运行时,它将执行下一个console.log()语句。

一旦FileReader完成读取文件,它将触发load事件,这将调用相应的reader.onload事件处理程序。现在,reader.onload方法内的console.log()语句将被执行。reader.result现在将包含图像数据。

我们需要使用FileReader的结果创建一个Image对象。使用Image()构造函数创建一个新的图像实例(现在我们应该在reader.onload方法内编写代码):

reader.onload = () => {
  let image = new Image();

  image.onload = () => {

  };

  image.src = reader.result;
}

正如你所看到的,动态加载图像源也是一个异步事件,我们需要使用Image对象提供的onload事件处理程序。

一旦图像加载完成,我们需要将画布调整为图像的大小。为此,请在image.onload方法中写入以下代码:

image.onload = () => {
  this.$canvas.height = image.height;
  this.$canvas.width = image.width;
}

现在将画布调整为图像的大小。一旦我们调整了画布的大小,我们的第一步是擦除画布。画布对象有clearRect()方法,可以用来清除画布中的矩形区域。在我们的情况下,矩形区域是整个画布。要清除整个画布,我们需要使用clearRect()和我们画布的上下文对象,也就是我们之前创建的context变量。之后,我们需要将图像加载到画布中。在分配了画布尺寸后,将以下代码写入image.onload方法中:

context.clearRect(0, 0, this.$canvas.height, this.$canvas.width);
context.drawImage(image,0,0);

现在,尝试选择一张图片。图片应该显示在画布上。这是之前的代码所做的事情:

  • 清除从左上坐标(0,0)开始的画布上的矩形区域,即clearRect()方法的前两个参数,然后创建一个高度和宽度等于画布的矩形,即clearRect()方法的最后两个参数。这将有效地清除整个画布。

  • 使用存储在image对象中的图像在画布上绘制图像,从坐标(0,0)开始。由于画布的尺寸与图像相同,图像将覆盖整个画布。

在画布上呈现文本

现在我们有了一张图片,但是我们还缺少顶部文本和底部文本。以下是我们作为文本属性需要的一些东西:

  • 字体大小应该根据图像的大小进行响应

  • 文本应该是居中对齐的

  • 文本应该在图像的顶部和底部有边距空间

  • 文本应该有黑色描边,以便清晰地显示在图像上

对于我们的第一步,我们需要字体大小是响应式的。如果用户选择了大图像或小图像,我们需要有一个相对的字体大小。由于我们有画布的高度和宽度,我们可以使用它来获得一个字体大小,即图像高度和宽度的平均值的4%。我们可以使用textAlign属性使文本居中对齐。

此外,我们需要使用textBaseline属性指定基线。它用于将文本定位到指定位置。首先,画布在我们为文本指定的位置创建一个基线。然后,根据textBaseline提供的值,它将在基线的上方、下方或上方写入文本。在image.onload方法中写入以下代码:

 let fontSize = ((this.$canvas.width+this.$canvas.height)/2)*4/100;
 context.font = `${fontSize}pt sans-serif`;
 context.textAlign = 'center';
 context.textBaseline = 'top';

我们已经指定了字体为画布高度和宽度的平均值的4%,并将字体样式设置为sans-serif。此外,通过将textBaseline设置为top,基线将位于文本顶部,也就是说,文本将在基线下方呈现。

画布没有选项来对文本应用描边。因此,为了创建带有黑色描边的白色文本,我们需要创建两种不同的文本,一个是黑色描边文本,一个是白色填充文本,描边文本的线宽略大于填充文本,并将它们放在一起。这听起来可能是一个复杂的任务,但实际上很简单。

这就是描边文本的样子:

这就是填充文本的样子(在灰色背景下):

为描边文本和填充文本创建样式:

// for stroke text
context.lineWidth = fontSize/5;
context.strokeStyle = 'black';

// for fill text
context.fillStyle = 'white';

从输入字段获取顶部文本和底部文本的值:

const topText = this.$topTextInput.value.toUpperCase();
const bottomText = this.$bottomTextInput.value.toUpperCase();

这将从输入字段获取值,并自动将文本转换为大写字母。最后,要在画布的顶部和底部呈现文本,我们需要做以下操作:

// Top Text
context.strokeText(topText, this.$canvas.width/2, this.$canvas.height*(5/100));
context.fillText(topText, this.$canvas.width/2, this.$canvas.height*(5/100));

// Bottom Text
context.strokeText(bottomText, this.$canvas.width/2, this.$canvas.height*(90/100));
context.fillText(bottomText, this.$canvas.width/2, this.$canvas.height*(90/100));

考虑context.strokeText()。这就是文本的呈现方式:

  • strokeText方法的第一个参数topText包含要呈现的文本。

  • 第二个和第三个参数包含文本应该开始呈现的位置。沿着x轴,文本应该从画布的中间开始呈现(this.$canvas.width/2)。文本将居中对齐,沿着y轴从距离画布顶部5%的高度开始(this.$canvas.height*(5/100))。文本将被呈现。

这正是我们需要 Meme 的顶部文本的地方。对于底部文本,将高度增加到距离顶部90%的高度。带有黑色描边的文本将位于填充文本下方。有时,“M”会在文本上有额外的笔画。这是因为两条线相交的地方没有正确圆角。为此,在指定fillStyle之后添加以下行:

context.lineJoin = 'round';

现在,快速切换到 Chrome,选择一张图片,然后输入一些文本!你就有了自己的 Meme 创作者!作为参考,它应该像这样工作:

现在,要下载 meme,我们需要将画布转换为图像,并将图像作为属性附加到下载按钮。在Memes类中创建一个新的函数downloadMeme()。在addEventListeners()函数中,添加以下行:

this.$downloadButton.addEventListener('click', this.downloadMeme.bind(this));

现在,在downloadMeme()函数中,添加以下代码:

const imageSource = this.$canvas.toDataURL('image/png');
let att = document.createAttribute('href');
att.value = imageSource.replace(/^data:image\/[^;]/, 'data:application/octet-stream');
this.$downloadButton.setAttributeNode(att);

现在,单击下载按钮将会将画布转换为图像,并让浏览器下载它。这就是先前的代码的工作原理:

  • 首先,使用toDataURL('image/png')方法将画布转换为 64 位编码的 png URL,并存储在imageSource常量中。

  • 创建另一个包含 HTML 'href'属性对象的常量att

  • 现在,将att对象的值更改为存储在imageSource中的图像 URL,同时将 mime 类型从data:image更改为data:application/octet-stream。这一步是必要的,因为大多数浏览器会直接显示图像而不是下载它们。通过将 mime 类型更改为octet-stream(用于二进制文件),我们可以欺骗浏览器,使其认为文件不是图像,因此下载文件而不是查看文件。

  • 最后,将att对象分配为$downloadButton的属性,它是一个带有download属性的锚标签。download属性的值将是下载图像的默认名称。

imageSource.replace()方法中,使用正则表达式来更改图像的 mime 类型。我们将在下一章中更多地讨论使用正则表达式。要了解更多关于正则表达式的信息,请访问以下 MDN 页面:developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions

在从 Meme Creator 下载 meme 之前,我们需要验证表单,以便必须选择图像,并且至少底部文本框已填写才能下载 meme。我们需要在上面的代码中添加downloadMeme()函数中的表单验证代码:

if(!this.$imageInput.files[0]) {
  this.$imageInput.parentElement.classList.add('has-error');
  return;
}
if(this.$bottomTextInput.value === '') {
  this.$imageInput.parentElement.classList.remove('has-error');
  this.$bottomTextInput.parentElement.classList.add('has-error');
  return;
}
this.$imageInput.parentElement.classList.remove('has-error');
this.$bottomTextInput.parentElement.classList.remove('has-error');

先前的代码将检查底部文本输入框中的图像和文本,并使用return关键字停止downloadMeme()的执行。一旦发现空字段,它将向输入的父div添加.has-error类,根据 Bootstrap 的定义,这将突出显示红色边框的输入(我们之前在 ToDo 列表应用程序中使用过它)。

您可能无法获得突出显示,因为我们正在使用PurifyCSSPlugin与 Webpack,它通过引用index.html过滤掉所有不需要的样式。由于.has-error类最初不在index.html中,因此其样式定义也从捆绑的 CSS 中删除。为了解决这个问题,将您想要动态添加的所有类添加到页面中的隐藏div元素中。在我们的index.html文件中,在<script>标签的上面添加以下行:

<div class="has-error" style="display: none;"></div>

现在,.has-error的样式定义将包含在 bundle 中,并且表单验证将向空字段添加红色边框。

使画布响应以显示大图像

如果用户选择了大图像(例如,屏幕大小的图像),它将导致布局破坏。为了防止这种情况发生,当选择大图像时,我们需要缩小画布。我们可以通过控制 CSS 中的高度和宽度来放大或缩小画布元素。在Memes类中,创建以下函数:

resizeCanvas(canvasHeight, canvasWidth) {
  let height = canvasHeight;
  let width = canvasWidth;
  this.$canvas.style.height = `${height}px`;
  this.$canvas.style.width = `${width}px`;
  while(height > Math.min(1000, deviceWidth-30) && width > Math.min(1000, deviceWidth-30)) {
    height /= 2;
    width /= 2;
    this.$canvas.style.height = `${height}px`;
    this.$canvas.style.width = `${width}px`;
  }
}

这就是resizeCanvas()的工作原理:

  • 此函数最初将 CSS 中画布的高度和宽度应用于其实际高度和宽度(以便不记住先前图像的缩放级别)。

  • 然后,它将检查高度和宽度是否大于最小值 1000px 或deviceWidth-30(我们已经定义了deviceWidth常量)。

  • 如果画布大小大于给定条件,我们将高度和宽度减半,然后将新值分配给画布的 CSS(这将缩小画布)。

  • 由于这是一个 while 循环,操作会重复进行,直到画布大小小于条件,从而有效地缩小画布并保持页面布局。

image.onload方法中,在渲染画布中的文本代码之后,简单地调用this.resizeCanvas(this.$canvas.height, this.$canvas.width)

height /= 2height = height / 2的简写。这适用于其他算术运算符,如+-*%

摘要

干得好!您已经建立了一个模因创作者,现在可以将您的图像转换成模因。更重要的是,您拥有一个很棒的开发环境,将使 JavaScript 应用程序开发变得更加容易。让我们回顾一下您在本章学到的东西:

  • CSS 中 flexbox 布局系统的简介

  • JavaScript 模块介绍

  • 使用 Webpack 进行模块捆绑

  • 优化生产以提高用户性能

  • 使用 HTML5 画布和 JavaScript 在网站上绘制图形

我们在本章学到了很多东西。特别是关于 Webpack。这可能看起来有点令人不知所措,但从长远来看非常有用。在下一章中,我们将看到如何编写模块化的代码并在整个应用程序中重用它,这现在由于 Webpack 是可能的。

第三章:活动注册应用程序

希望您在创建表情并与朋友分享时玩得很开心!您在上一个项目中成功使用 HTML5 画布构建了一个表情创作器。您还使用了 flexbox 来设计页面布局,并学习了有关 ES6 模块的一些知识。

上一章最重要的部分是我们使用 Webpack 创建的开发环境。它让我们可以使用HotModuleReplacement更快地开发应用程序,创建具有单个文件资产和减小代码大小的优化生产构建,并且还可以隐藏原始源代码,同时我们可以使用源映射来调试原始代码。

现在我们有了模块支持,我们可以使用它来创建模块化函数,这将允许我们编写可重用的代码,可以在项目的不同部分之间使用,也可以在不同的项目中使用。在本章中,您将构建一个活动注册应用程序,同时学习以下概念:

  • 编写 ES6 模块

  • 使用 JavaScript 进行表单验证

  • 使用动态数据(从服务器加载的数据)

  • 使用 fetch 进行 AJAX 请求

  • 使用 Promises 处理异步函数

  • 使用 Chart.js 创建图表

活动 - JS 聚会

以下是我们项目的情景:

您正在本地组织一个 JavaScript 聚会。您邀请了来自学校、大学和办公室的对 JavaScript 感兴趣的人。您需要为与会者创建一个注册活动的网站。该网站应具有以下功能:

  • 帮助用户注册活动的表单

  • 显示对活动感兴趣的用户数量的统计数据页面

  • 关于页面,包括活动详情和活动位置的 Google 地图嵌入

此外,大多数人将使用手机注册活动。因此,应用程序应完全响应。

这是应用程序在手机上的样子:

初始项目设置

要开始项目,请在 VSCode 中打开第三章的起始文件。创建一个.env文件,并使用.env.example文件中的值。为每个环境变量分配以下值:

  • NODE_ENV=dev:在生成构建时应设置为production

  • SERVER_URL=http://localhost:3000:我们很快将在此 URL 上运行服务器。

  • GMAP_KEY:我们将在此项目中使用 Google Maps API。您需要生成自己的唯一 API 密钥以使用 Google Maps。请参阅:developers.google.com/maps/documentation/javascript/get-api-key 生成您的 API 密钥,并将密钥添加到此环境变量中。

在第二章中,构建表情创作器,我提到当模块与 Webpack 捆绑在一起时,您无法在 HTML 中访问 JavaScript 变量。在第一章中,构建待办事项列表,我们使用 HTML 属性调用 JavaScript 函数。这看起来可能很有用,但它也会向用户(我指的是访问您页面的其他开发人员)公开我们的对象结构。用户可以通过检查 Chrome DevTools 来清楚地了解ToDoClass类的结构。在构建大型应用程序时应该防止这种情况发生。因此,Webpack 不允许变量存在于全局范围内。

一些插件需要全局范围内存在变量或对象(比如我们将要使用的 Google Maps API)。为此,Webpack 提供了一个选项,可以将一些选定的对象作为库暴露到全局范围内(在 HTML 内)。查看起始文件中的webpack.config.js文件。在output部分,我已经添加了library: 'bundle',这意味着如果我们向任何函数、变量或对象添加export关键字,它们将在全局范围内的bundle对象中可访问。我们将看到如何在向我们的应用程序添加 Google Maps 时使用它。

现在我们已经准备好环境变量,打开项目根文件夹中的终端并运行npm install来安装所有依赖项。一旦依赖项安装完成,在终端中输入npm run watch来启动 Webpack 开发服务器。您现在可以在控制台中由 Webpack 打印的本地主机 URL(http://localhost:8080/)上查看页面。查看所有页面。

向页面添加样式

目前,页面是响应式的,因为它是使用 Bootstrap 构建的。然而,我们仍然需要对表单进行一些样式更改。在桌面屏幕上,它目前非常大。此外,我们需要将标题对齐到页面中央。让我们为index.html页面添加样式。

将表单及其标题居中对齐到页面中央,在styles.css文件(src/css/styles.css)中添加以下代码(确保 Webpack 开发服务器正在运行):

.form-area {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

由于 Webpack 中启用了HotModuleReplacement,样式将立即反映在页面上(不再重新加载!)。现在,给标题添加一些边距,并为表单设置最小宽度:

.title {
  margin: 20px;
}
.form-group {
  min-width: 500px;
}

现在表单的最小宽度将为500px。然而,我们面临另一个问题!由于表单将始终为500px,在移动设备上(移动用户是我们的主要受众)将超出屏幕。我们需要使用媒体查询来解决这个问题。媒体查询允许我们根据页面所在的媒介类型添加 CSS。在我们的情况下,我们需要在移动设备上更改min-width。要查询移动设备,请在先前的样式下方添加以下样式:

@media only screen and (max-width: 736px) {
  .form-group {
    min-width: 90vw;
  }
}

这将检查设备宽度是否小于736px(通常,移动设备属于此类别),然后添加90vwmin-widthvw代表视口宽度。90vw表示视口宽度的大小的 90%(这里,视口是屏幕)。

有关使用媒体查询的更多信息,请访问 w3schools 页面:www.w3schools.com/css/css_rwd_mediaqueries.asp

我在index.htmlstatus.html页面上使用了加载指示器图像。要指定图像的大小而不破坏其原始宽高比,使用max-widthmax-height如下:

.loading-indicator {
  max-height: 50px;
  max-width: 50px;
}

查看状态页面。加载指示器的大小将被减小。我们已经为我们的应用程序添加了必要的样式。现在,是时候使用 JavaScript 使其工作了。

使用 JavaScript 验证和提交表单

HTML 表单是 Web 应用程序中最重要的部分,用户输入会被记录下来。在我们的 JS Meetup 应用程序中,我们使用 Bootstrap 构建了一个漂亮的表单。让我们使用index.html文件来探索表单包含的内容。表单包含四个必填字段:

  • 姓名

  • 电子邮件地址

  • 电话号码

  • 年龄

它还包含三个可选字段(其中两个的值已经预先选择):

  • 用户的职业

  • 他在 JavaScript 方面的经验水平

  • 对他对这次活动期望学到的内容进行评论

由于职业和经验水平选项已预先选择了默认值,因此它们不会被标记为用户必填。但是,在验证期间,我们需要将它们视为必填字段。只有评论字段是可选的。

这是我们的表单应该如何工作的:

  • 用户填写所有表单细节并点击提交

  • 表单详细信息将被验证,如果缺少任何必填字段,它将用红色边框突出显示这些字段

  • 如果表单值有效,它将继续将表单提交到服务器

  • 提交表单后,用户将收到通知表单已成功提交,并且表单条目将被清除

JavaScript 最初用作在 HTML 中进行表单验证的语言。随着时间的推移,它已经发展成为一个完整的 Web 应用程序开发语言。使用 JavaScript 构建的 Web 应用程序会向服务器发出许多请求,以向用户提供动态数据。这些网络请求始终是异步的,需要正确处理。

HTML 表单

在我们实现表单验证逻辑之前,让我们先了解表单的正常工作方式。单击当前表单中的提交。您应该会看到一个空白页面,并显示消息“无法 POST /register”。这是 Webpack 开发服务器的消息,表示没有为/register配置POST方法的路由。这是因为在index.html中,表单是使用以下属性创建的:

<form action="/register" method="post" id="registrationForm">

这意味着当单击提交按钮发送数据到/register页面时,使用POST方法。在进行网络请求时,GETPOST是两种常用的 HTTP 方法或动词。GET方法不能有请求正文,因此所有数据都通过 URL 作为查询参数传输。但是,POST方法可以有请求正文,其中数据可以作为表单数据或 JSON 对象发送。

有不同的 HTTP 方法用于与服务器通信。查看以下 REST API 教程页面,了解有关 HTTP 方法的更多信息:www.restapitutorial.com/lessons/httpmethods.html

当前,表单以POST方法使用表单数据发送数据。在您的index.html文件中,将表单方法属性更改为get并重新加载页面(Webpack 开发服务器不会自动重新加载 HTML 文件的更改)。现在,单击提交。您应该看到类似的空白页面,但是现在表单详细信息正在发送到 URL 本身。现在 URL 将如下所示:

http://localhost:8080/register?username=&email=&phone=&age=&profession=school&experience=1&comment=

所有字段都为空,除了职业和经验,因为它们是预先选择的。表单值添加在路由/register的末尾,后跟一个?符号,指定下一个文本是查询参数,表单值使用&符号分隔。由于GET请求会将数据发送到 URL 本身,因此不适合发送机密数据,例如登录详细信息或我们将在此表单中发送的用户详细信息。因此,选择POST方法进行表单提交。在您的index.html文件中将方法更改为 post。

让我们看看如何检查使用POST请求发送的数据。打开 Chrome DevTools 并选择网络选项卡。现在在表单中输入一些详细信息,然后单击提交。您应该在网络请求列表中看到一个名为register的新条目。如果单击它,它将打开一个新面板,其中包含请求详细信息。请求数据将出现在表单数据部分的标头选项卡中。请参考以下屏幕截图:

Chrome DevTools 具有许多用于处理网络请求的工具。我们只使用它来检查我们发送的数据。但是您还可以做更多的事情。根据上图,您可以在标头选项卡的表单数据部分中看到我在表单中输入的表单值。

访问以下 Google 开发者页面:developers.google.com/web/tools/chrome-devtools/ 以了解更多关于使用 Chrome DevTools 的信息。

现在你对提交表单的工作原理有了一个很好的了解。我们在/register路由中没有创建任何页面,并且通过将表单重定向到单独的页面进行提交不再是一个好的用户体验(我们处于单页应用程序SPA)的时代)。考虑到这一点,我创建了一个小的 Node.js 服务器应用程序,可以接收表单请求。我们将禁用默认的表单提交操作,并将使用 JavaScript 作为 AJAX 请求提交表单。

在 JavaScript 中读取表单数据

是时候编码了!使用npm run watch命令保持 Webpack 开发服务器运行(NODE_ENV变量应为dev)。在 VSCode 中打开项目文件夹,并从src/js/目录中打开home.js文件。我已经在index.html文件中添加了对dist/home.js的引用。我还将在home.js中添加代码来导入general.js文件。现在,在导入语句下面添加以下代码:

class Home {
  constructor() {

  }

}

window.addEventListener("load", () => {
 new Home();
});

这将创建一个新的Home类,并在页面加载完成时创建一个新的实例。我们不需要将实例对象分配给任何变量,因为我们不会像在 ToDo 列表应用程序中那样在 HTML 文件中使用它。一切都将从 JavaScript 本身处理。

我们的第一步是创建对表单中所有输入字段和表单本身的引用。这包括表单本身和当前在页面中使用.hidden Bootstrap 类隐藏的加载指示器。将以下代码添加到类的构造函数中:

 this.$form = document.querySelector('#registrationForm');
 this.$username = document.querySelector('#username');
 this.$email = document.querySelector('#email');
 this.$phone = document.querySelector('#phone');
 this.$age = document.querySelector('#age');
 this.$profession = document.querySelector('#profession');
 this.$experience = document.querySelector('#experience');
 this.$comment = document.querySelector('#comment');
 this.$submit = document.querySelector('#submit');
 this.$loadingIndicator = document.querySelector('#loadingIndicator');

就像我在构建 Meme Creator 时提到的,最好将对 DOM 元素的引用存储在以$符号为前缀的变量中。现在,我们可以轻松地从其他变量中识别具有对 DOM 元素的引用的变量。这纯粹是为了开发效率,不是你需要遵循的严格规则。在前面的代码中,对于体验单选按钮,只存储了第一个单选按钮的引用。这是为了重置单选按钮;要读取所选单选按钮的值,需要使用不同的方法。

现在我们可以在Home类中访问所有的 DOM 元素。触发整个表单验证过程的事件是表单提交时发生的。表单提交事件发生在<form>元素内部带有属性type="submit"的 DOM 元素被点击时。在我们的情况下,<button>元素包含这个属性,并且被引用为$submit变量。尽管$submit触发了提交事件,但事件属于整个表单,也就是$form变量。因此,我们需要在我们的类中为this.$form添加一个事件监听器。

我们只会有一个事件监听器。因此,在声明前面的变量之后,只需将以下代码添加到构造函数中:

this.$form.addEventListener('submit', event => {
  this.onFormSubmit(event);
});

这将为表单附加一个事件监听器,并在表单提交时调用类的onFormSubmit()方法,以表单提交事件作为其参数。因此,让我们在Home类中创建onFormSubmit()方法:

onFormSubmit(event) {
  event.preventDefault();
}

event.preventDefault()将阻止默认事件动作发生。在我们的情况下,它将阻止表单的提交。在 Chrome 中打开页面(http://localhost:8080/)并尝试点击提交。如果没有任何动作发生,那太好了!我们的 JavaScript 代码正在阻止表单提交。

我们可以使用这个函数来启动表单验证。表单验证的第一步是读取表单中所有输入元素的值。在Home类中创建一个新的方法getFormValues(),它将以 JSON 对象的形式返回表单字段的值:

getFormValues() {
  return {
    username: this.$username.value,
    email: this.$email.value,
    phone: this.$phone.value,
    age: this.$age.value,
    profession: this.$profession.value,
    experience: parseInt(document.querySelector('input[name="experience"]:checked').value),
    comment: this.$comment.value,
  };
}

看到我如何使用document.querySelector()来读取选中的单选按钮的值了吗?该函数本身就是不言自明的。我添加了parseInt(),因为该值将作为字符串返回,并且需要转换为 Int 以进行验证。在onFormSubmit()方法中创建一个变量来存储表单中所有字段的值。您的onFormSubmit()方法现在将如下所示:

onFormSubmit(event) {
  event.preventDefault();
  const formValues = this.getFormValues();
}

尝试使用console.log(formValues)在 Chrome DevTools 控制台中打印formValues变量。您应该看到一个 JSON 对象中的所有字段及其相应的值。现在我们有了所需的值,下一步是验证数据。

在我们的 JS Meetup 应用程序中,我们只有一个表单。但在更大的应用程序中,您可能会在应用程序的不同部分中有多个表单执行相同的操作。但是,由于设计目的,表单将具有不同的 HTML 类和 ID,但表单值将保持不变。在这种情况下,验证逻辑可以在整个应用程序中重复使用。这是构建您的第一个可重用 JavaScript 模块的绝佳机会。

表单验证模块

通过使用 Webpack,我们现在有能力创建单独的模块并在 JavaScript 中导入它们。但是,我们需要某种方法来组织我们创建的模块。随着应用程序的规模增长,您可能会有数十甚至数百个模块。以便能够轻松识别它们的方式来组织它们将极大地帮助您的团队,因为他们将能够在需要时轻松找到模块,而不是重新创建具有相同功能的模块。

在我们的应用程序中,让我们在src/js/目录中创建一个名为services的新文件夹。该目录将包含所有可重用的模块。现在,在services目录中,创建另一个名为formValidation的目录,在其中我们将创建validateRegistrationForm.js文件。您的项目src/js/目录现在将如下所示:

.
├── about.js
├── general.js
├── home.js
├── services
│   └── formValidation
│       └── validateRegistrationForm.js
└── status.js

现在,想象自己是一个第一次看到这段代码的不同开发人员。在js目录中,有另一个名为services的目录。在其中,formValidation作为一个服务可用。您现在知道有一个用于表单验证的服务。如果您查看此目录,它将具有validateRegistrationForm.js文件,该文件仅凭其文件名就告诉您此模块的目的。

如果您想为登录表单创建一个验证模块(只是一个想象的场景),只需在formValidation目录中创建另一个名为validateLoginForm.js的文件。这样,您的代码将易于维护,并通过最大程度地重用所有模块来扩展。

不要担心文件名太长!可维护的代码更重要,但如果文件名太长,它会更容易理解该文件的目的。但是,如果您在团队中工作,请遵守团队使用的 lint 工具的规则。

是时候构建模块了!在您刚刚创建的validateRegistrationForm.js文件中,添加以下代码:

export default function validateRegistrationForm(formValues) {
}

使用模块文件和其默认导出项相同的名称将使导入语句看起来更容易理解。当您将此模块导入到您的home.js文件中时,您将看到这一点。前面的函数将接受formValues(我们从上一节中的表单中读取的)JSON 对象作为参数。

在编写此函数之前,我们需要为每个输入字段设置验证逻辑为单独的函数。当输入满足验证条件时,这些函数将返回 true。让我们从验证用户名开始。在validateRegistrationForm()下面,创建一个名为validateUserName()的新函数,如下所示:

function validateUserName(name) {
  return name.length > 3 ? true: false;
}

我们使用此函数来检查用户名是否至少为3个字符长。我们使用条件运算符,如果长度大于3则返回true,如果长度小于3则返回false

我们之前在 ToDo 列表应用程序中使用了条件运算符()?:。如果您仍然对这个运算符有困难,可以访问以下 MDN 页面进行了解:developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Conditional_Operator

我们可以使这个函数更加简洁:

function validateUserName(name) {
  return name.length > 3;
}

这样,JavaScript 将自动评估长度是否大于三,并根据结果分配 true 或 false。现在,要验证电子邮件地址,我们需要使用正则表达式。我们曾经使用正则表达式来更改 Meme Creator 应用程序中图像的 MIME 类型。这一次,我们将研究正则表达式的工作原理。

在 JavaScript 中使用正则表达式

正则表达式(RegExp)基本上是一个模式的定义(例如一系列字符、数字等),可以在其他文本中进行搜索。例如,假设您需要找到段落中以字母a开头的所有单词。然后,在 JavaScript 中,您将模式定义为:

const pattern = /^a+/

正则表达式总是在/ /内定义。在前面的代码片段中,我们有以下内容:

  • ^表示在开头

  • +表示至少有一个

这个正则表达式将匹配以字母a开头的字符串。您可以在以下网址测试这些语句:jsfiddle.net/。要使用这个正则表达式验证一个字符串,请执行以下操作:

pattern.test('alpha') // this will return true
pattern.test('beta') // this will return false

要验证电子邮件地址,请使用以下函数,其中包含一个用于验证电子邮件地址的正则表达式:

function validateEmail(email) {
  const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\ [\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
  return emailRegex.test(email);
}

不要被正则表达式所压倒,它是互联网上常见的东西。每当您需要常见格式的正则表达式,比如电子邮件地址或电话号码,您都可以在互联网上找到它们。要验证手机号码,请执行以下操作:

function validatePhone(phone) {
  const phoneRegex = /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/;
  return phoneRegex.test(phone);
}

这将验证电话号码是否符合XXX-XXX-XXXX的格式(此格式在表单的占位符中给出)。

如果您的要求非常具体,您将不得不编写自己的正则表达式。那时,请参考以下页面:developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions

电子邮件地址在表单中默认验证,因为电子邮件输入字段的类型属性设置为电子邮件。但是,有必要在 JavaScript 中验证它,因为并非所有浏览器都可能支持此属性,而且 HTML 可以很容易地从 Chrome DevTools 进行编辑。其他字段也是一样。

要验证年龄,假设用户应该在 10-25 岁的年龄组中:

function validateAge(age) {
  return age >= 10 && age <= 25;
}

要验证职业,职业的接受值为schoolcollegetraineeemployee。它们是index.html文件中职业选择字段的<option>元素的值。要验证profession,请执行以下操作:

function validateProfession(profession) {
  const acceptedValues = ['school','college','trainee','employee'];
  return acceptedValues.indexOf(profession) > -1;
}

JavaScript 数组有一个名为indexOf()的方法。它接受一个数组元素作为参数,并返回该元素在数组中的索引。但是,如果数组中不存在该元素,则返回-1。我们可以使用这个函数来检查职业的值是否是接受的值之一,方法是找到它在数组中的索引,并检查索引是否大于-1

最后,要验证经验,经验单选按钮的值为 1、2 和 3。因此,经验应该是 0-4 之间的数字:

function validateExperience(experience) {
  return experience > 0 && experience < 4;
}

由于评论字段是可选的,我们不需要为该字段编写验证逻辑。现在,在我们最初创建的validateRegistrationForm()函数中,添加以下代码:

export default function validateRegistrationForm(formValues) {

  const result = {
    username: validateUserName(formValues.username),
    email: validateEmail(formValues.email),
    phone: validatePhone(formValues.phone),
    age: validateAge(formValues.age),
    profession: validateProfession(formValues.profession),
    experience: validateExperience(formValues.experience),
  };

}

现在,结果对象包含每个表单输入的验证状态(true/false)。检查整个表单是否有效。只有当结果对象的所有属性都为true时,表单才有效。要检查结果对象的所有属性是否都为true,我们需要使用for/in循环。

for/in循环遍历对象的属性。由于result对象的所有属性都需要为true,因此创建一个初始值为true的变量isValid。现在,遍历result对象的所有属性,并将值与isValid变量进行逻辑与(&&)操作:

let field, isValid = true;
for(field in result) {
  isValid = isValid && result[field];
}

通常,您可以使用点符号(.)访问对象的属性。但是,由于我们使用了for/in循环,属性名称存储在变量field中。在这种情况下,如果field包含值age,我们需要使用方括号表示法result[field]来访问属性;这相当于点表示法中的result.age

只有当结果对象的所有属性都为true时,isValid变量才为true。这样,我们既有表单的验证状态,又有各个字段的状态。validateRegistrationForm()函数将作为另一个对象的属性返回isValid变量和result对象:

export default function validateRegistrationForm(formValues) {
  ...
  ...
  return { isValid, result };
}

我们在这里使用了 ES6 的对象字面量属性值简写特性。我们的表单验证模块已经准备好了!我们可以将这个模块导入到我们的home.js文件中,并在事件注册应用程序中使用它。

在你的home.js文件中,在Home类之前,添加以下行:

import validateRegistrationForm from './services/formValidation/validateRegistrationForm';

然后,在Home类的onFormSubmit()方法中,添加以下代码:

onFormSubmit(event) {
  event.preventDefault();

  const formValues = this.getFormValues();
  const formStatus = validateRegistrationForm(formValues);

  if(formStatus.isValid) {
    this.clearErrors();
    this.submitForm(formValues);
  } else {
    this.clearErrors();
    this.highlightErrors(formStatus.result);
  }
}

上述代码执行以下操作:

  • 它调用我们之前创建的validateRegistrationForm()模块,并将formValues作为其参数,并将返回的值存储在formStatus对象中。

  • 首先,它检查整个表单是否有效,使用formStatus.isValid的值。

  • 如果为true,则调用clearErrors()方法清除 UI(我们的 HTML 表单)中的所有错误高亮,并调用另一个方法submitForm()提交表单。

  • 如果为false(表单无效),则调用clearErrors()方法清除表单,然后使用formStatus.result调用highlightErrors()方法,该方法作为参数包含各个字段的验证详细信息,以突出显示具有错误的字段。

我们需要在Home类中创建在上述代码中调用的方法,因为它们是Home类的方法。clearErrors()highlightErrors()方法的工作很简单。clearErrors只是从输入字段的父<div>中移除.has-error类。而highlightError如果输入字段未通过验证(字段的结果为false),则将.has-error类添加到父<div>中。

clearErrors()方法的代码如下:

clearErrors() {
  this.$username.parentElement.classList.remove('has-error');
  this.$phone.parentElement.classList.remove('has-error');
  this.$email.parentElement.classList.remove('has-error');
  this.$age.parentElement.classList.remove('has-error');
  this.$profession.parentElement.classList.remove('has-error');
  this.$experience.parentElement.classList.remove('has-error');
}

highlightErrors()方法的代码如下:

highlightErrors(result) {
  if(!result.username) {
    this.$username.parentElement.classList.add('has-error');
  }
  if(!result.phone) {
    this.$phone.parentElement.classList.add('has-error');
  }
  if(!result.email) {
    this.$email.parentElement.classList.add('has-error');
  }
  if(!result.age) {
    this.$age.parentElement.classList.add('has-error');
  }
  if(!result.profession) {
    this.$profession.parentElement.classList.add('has-error');
  }
  if(!result.experience) {
    this.$experience.parentElement.classList.add('has-error');
  }
}

目前,将submitForm()方法留空:

submitForm(formValues) {
}

在浏览器中打开表单(希望您保持 Webpack 开发服务器运行)。尝试在输入字段中输入一些值,然后单击提交。如果输入了有效的输入值,它不应执行任何操作。如果输入了无效的输入条目(根据我们的验证逻辑),则输入字段将以红色边框突出显示,因为我们向字段的父元素添加了.has-error Bootstrap 类。如果您更正了具有有效值的字段,然后再次单击提交,错误应该消失,因为我们使用了clearErrors()方法来清除所有旧的错误高亮。

使用 AJAX 提交表单

现在我们进入表单部分的第二部分,提交表单。我们已经禁用了表单的默认提交行为,现在需要实现一个用于提交逻辑的 AJAX 表单。

AJAX 是异步 JavaScript 和 XMLAJAX)的缩写。它不是一个编程工具,而是一个概念,通过它你可以发出网络请求,从服务器获取数据,并更新网站的某些部分,而无需重新加载整个页面。

异步 JavaScript 和 XML 这个名字可能听起来有点困惑,但最初 XML 被广泛用于与服务器交换数据。我们也可以使用 JSON/普通文本与服务器交换数据。

为了将表单提交到服务器,我创建了一个小的 Node.js 服务器(使用 express 框架构建),假装保存你的表单详情并返回一个成功消息。服务器在代码文件的Chapter03文件夹中。要启动服务器,只需在服务器目录中运行npm install,然后运行npm start命令。这将在http://localhost:3000/URL 上启动服务器。如果你在浏览器中打开这个 URL,你会看到一个空白页面,上面显示着消息 Cannot GET /;这意味着服务器正常运行。

服务器有两个 API 端点,我们需要与其中一个通信以发送用户的详情。这就是注册 API 端点的工作方式:

Route: /registration,
Method: POST,
Body: the form data in JSON format
{
  "username":"Test User",
  "email":"mail@test.com",
  "phone":"123-456-7890",
  "age":"16",
  "profession":"school",
  "experience":"1",
  "comment":"Some comment from user"
} If registration is success:
status code: 200
response: { "message": "Test User is Registered Successfully" }

在真实的 JavaScript 应用中,你将不得不处理很多像这样的网络请求。大部分用户操作都会触发需要服务器处理的 API 调用。在我们的场景中,我们需要调用前面的 API 来注册用户。

让我们来规划一下 API 调用应该如何工作:

  • 正如其名称所示,这个事件将是异步的。我们需要使用 ES6 的一个新概念,叫做 Promises,来处理这个 API 调用。

  • 在下一节中,我们将有另一个 API 调用。最好将 API 调用创建为类似模块验证模块的形式。

  • 我们必须验证服务器是否成功注册了用户。

  • 由于整个 API 调用会花费一些时间,我们应该在过程中向用户显示一个加载指示器。

  • 最后,如果注册成功,我们应该立即通知用户并清空表单。

在 JavaScript 中进行网络请求

JavaScript 有XMLHttpRequest用于进行 AJAX 网络请求。ES6 引入了一个叫做 fetch 的新规范,它通过 Promises 支持使得处理网络请求更加现代和高效。除了这两种方法,jQuery 还有$.ajax()方法,广泛用于进行网络请求。Axios.js是另一个广泛用于进行网络请求的npm包。

我们将在我们的应用中使用 fetch 进行网络请求。

Fetch 在 Internet Explorer 中不起作用,需要使用 polyfills。查看:caniuse.com/来了解任何你想使用的新的HTML/CSS/Javascript组件的浏览器兼容性。

什么是 Promise?

到现在为止,你可能会想知道我所说的 Promise 是什么?嗯,Promise,顾名思义,是 JavaScript 做出的一个承诺,即异步函数将在某个时刻完成执行。

在上一章中,我们遇到了一个异步事件:使用FileReader读取文件内容。这就是FileReader的工作方式:

  • 它开始读取文件。由于读取是一个异步事件,其他 JavaScript 代码在读取仍在进行时会继续执行。

你可能会想,如果我需要在事件完成后执行一些代码怎么办?这就是FileReader处理的方式:

  • 一旦读取完成,FileReader会触发一个load事件。

  • 它还有一个onload()方法来监听load事件,当load事件被触发时,onload()方法将开始执行。

  • 因此,我们需要将我们需要的代码放在onload()方法中,它只会在FileReader完成读取文件内容后执行。

这可能看起来是处理异步事件的更简单方式,但想象一下如果有多个需要依次发生的异步事件!你将不得不触发多少事件,需要跟踪多少事件监听器?这将导致非常难以理解的代码。此外,JavaScript 中的事件监听器是昂贵的资源(它们消耗大量内存),必须尽量减少。

回调函数经常用于处理异步事件。但是,如果有很多异步函数依次发生,您的代码将看起来像这样:

asyncOne('one', () => {
  ...
  asyncTwo('two', () => {
    ...
    asyncThree('three', () => {
      ...
      asyncFour('four', () => {
      });
    });
  });
});

在编写了很多回调之后,您的闭合括号将被排列成金字塔形。这被称为回调地狱。回调地狱很混乱,构建应用程序时应该避免。因此,回调在这里没有用处。

进入 Promises,一种处理异步事件的新方法。这是 JavaScript Promise的工作方式:

new Promise((resolve, reject) => {
  // Some asynchronous logic
  resolve(5);
});

Promise构造函数创建一个具有两个参数的函数,resolve 和 reject,它们都是函数。然后,Promise只有在调用 resolve 或 reject 时才会返回值。当异步代码成功执行时,调用 resolve,当发生错误时调用 reject。在这里,Promise在异步逻辑执行时返回一个值5

假设您有一个名为theAsyncCode()的函数,它执行一些异步操作。您还有另一个函数onlyAfterAsync(),它需要严格在theAsyncCode()之后运行,并使用theAsyncCode()返回的值。

以下是如何使用 Promises 处理这两个函数:

function theAsyncCode() {
  return new Promise((resolve, reject) => {
    console.log('The Async Code executed!');
    resolve(5);
  });
}

首先,theAsyncCode()应该返回一个Promise而不是一个值。您的异步代码应该写在那个Promise里。然后,您编写onlyAfterAsync()函数:

function onlyAfterAsync(result) {
  console.log('Now onlyAfterAsync is executing...');
  console.log(`Final result of execution - ${result}`);
}

要依次执行前面的函数,我们需要使用Promise.then().catch()语句将它们链接起来。在这里,PromisetheAsyncCode()函数返回。因此,代码应该是:

theAsyncCode()
.then(result => onlyAfterAsync(result))
.catch(error => console.error(error))

theAsyncCode()执行resolve(5)时,then方法会自动以解析值作为其参数调用。现在我们可以在then方法中执行onlyAfterAsync()方法。如果theAsyncCode()执行的是reject('an error')而不是resolve(5),它将触发catch方法而不是then

如果您有另一个函数theAsyncCode2(),它使用theAsyncCode()返回的数据,那么它应该在onlyAfterAsync()函数之前执行:

function theAsyncCode2(data) {
  return new Promise((resolve, reject) => {
    console.log('The Async Code 2 executed');
    resolve(data);
  });
}

您只需要更新您的.then().catch()链,如下所示:

theAsyncCode()
.then(data => theAsyncCode2(data))
.then(result => onlyAfterAsync(result))
.catch(error => console.error(error));

这样,所有三个函数将依次执行。如果theAsyncCode()theAsyncCode2()中的任何一个返回reject(),那么将调用catch语句。

如果我们只需要使用链中前一个函数的解析值作为参数调用函数,我们可以进一步简化链:

theAsyncCode()
.then(theAsyncCode2)
.then(onlyAfterAsync)
.catch(console.error);

这将得到相同的结果。我在jsfiddle.net/jjq60Ly6/4/上设置了一个小的 JS fiddle,您可以在那里体验 Promises 的工作。访问 JS fiddle,打开 Chrome DevTools 控制台,然后单击 JS fiddle 页面左上角的 Run。您应该看到按顺序从三个函数中打印出console.log语句。随意编辑 fiddle 并尝试使用 Promises 进行实验。

在完成本章后不久,ES8 被宣布,确认了async函数是 JavaScript 语言的一部分。ES8 的asyncawait关键字提供了一种更简单的方式来解决 Promise,而不是 ES6 中使用的.then().catch()链。要学习使用async函数,请访问以下 MDN 页面:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function

创建 API 调用模块

我们将使用 POST API 调用来注册我们的用户。但是,在应用程序的状态部分,我们需要使用GET请求来显示对活动感兴趣的人的统计数据。因此,我们将构建一个通用的 API 调用模块。

要创建 API 调用模块,在services目录内,创建另一个名为api的目录,并在其中创建apiCall.js。您的services目录的结构应如下所示:

.
├── api
│   └── apiCall.js
└── formValidation
    └── validateRegistrationForm.js

apiCall.js文件中创建以下函数:

export default function apiCall(route, body = {}, method='GET') {
}

在前面的函数中,路由是一个必需的参数,而bodymethod有其默认值。这意味着它们是可选的。如果您只使用一个参数调用该函数,则另外两个参数将使用它们的默认值:

apiCall('/registration) // values of body = {} and method = 'GET' 

如果您使用所有三个参数调用该函数,它将像普通函数一样工作:

apiCall('/registration', {'a': 5}, 'POST'); // values of body = {'a': 5} and method = 'POST'

默认参数仅在 ES6 中引入。我们使用默认参数是因为GET请求不需要body属性。它只将数据作为查询参数发送到 URL 中。

我们已经在默认表单的提交部分看到了GETPOST请求的工作原理。让我们构建一个apiCall函数,可以执行GETPOST请求:

apiCall函数内,创建一个名为request的新Promise对象:

export default function apiCall(route, body = {}, method='GET') {

  const request = new Promise((resolve, reject) => {
    // Code for fetch will be written here
  });

}

fetch API 接受两个参数作为输入,并返回Promise,当网络请求完成时解析。第一个参数是请求 URL,第二个参数包含有关请求的信息的对象,如headerscorsmethodbody等。

构建请求详细信息

将以下代码写入请求Promise内。首先,因为我们正在处理 JSON 数据,我们需要创建一个带有内容类型application/json的标头。我们可以使用Headers构造函数来实现这一目的:

const headers = new Headers({
  'Content-Type': 'application/json',
});

现在,使用之前创建的headers和参数中的method变量,我们创建requestDetails对象:

const requestDetails = {
  method,
  mode: 'cors',
  headers,
};

请注意,我已在requestDetails中包含了mode: 'cors'跨域资源共享CORS)允许服务器安全地进行跨域数据传输。假设您有一个运行在www.mysite.org上的网站。您需要向在www.anothersite.org上运行的另一个服务器发出 API 调用(网络请求)。

然后,这是一个跨域请求。要进行跨域请求,www.anothersite.org上的服务器必须设置Access-Control-Allow-Origin标头以允许www.mysite.org。否则,浏览器将阻止跨域请求,以防止未经授权访问另一个服务器。来自www.mysite.org的请求还应在其请求详细信息中包含mode: 'cors'

在我们的事件注册应用程序中,Webpack 开发服务器正在http://localhost:8080/上运行,而 Node.js 服务器正在http://localhost:3000/上运行。因此,这是一个跨域请求。我已经启用了Access-Control-Allow-Origin并设置了Access-Control-Allow-Headers,以便它不会对apiCall函数造成任何问题。

有关 CORS 请求的详细信息可以在以下 MDN 页面找到:developer.mozilla.org/en/docs/Web/HTTP/Access_control_CORS

我们的requestDetails对象还应包括请求的body。但是,body应仅包括在POST请求中。因此,可以在requestDetails对象声明下面编写,如下所示:

if(method !== 'GET') requestDetails.body = JSON.stringify(body);

这将为POST请求添加body属性。要进行 fetch 请求,我们需要构建请求 URL。我们已经设置了环境变量SERVER_URL=http://localhost:3000,Webpack 将其转换为全局变量SERVER_URL,可在 JavaScript 代码的任何地方访问。路由传递给apiCall()函数的参数。fetch 请求可以构建如下:

function handleErrors(response) {
  if(response.ok) {
    return response.json();
  } else {
    throw Error(response.statusText);
  }
}

fetch(`${SERVER_URL}/${route}`, requestDetails)
  .then(response => handleErrors(response))
  .then(data => resolve(data))
  .catch(err => reject(err));

handleErrors 函数的作用是什么?它将检查服务器返回的响应是否成功(response.ok)。如果是,它将解码响应并返回它(response.json())。否则,它将抛出一个错误。

我们可以使用我们之前讨论的方法进一步简化 Promise 链:

fetch(`${SERVER_URL}/${route}`, requestDetails)
  .then(handleErrors)
  .then(resolve)
  .catch(reject);

Fetch 有一个小问题。它无法自行处理超时。想象一下服务器遇到问题,无法返回请求。在这种情况下,fetch 将永远不会解决。为了避免这种情况,我们需要做一些变通。在 request Promise 之后,创建另一个名为 timeoutPromise

const request = new Promise((resolve, reject) => {
....
});

const timeout = new Promise((request, reject) => {
  setTimeout(reject, timeoutDuration, `Request timed out!`);
});

apiCall.js 文件的 apicall() 函数之外创建一个名为 timeoutDuration 的常量,如下所示:

const  timeoutDuration = 5000;

将此常量放在文件顶部,以便我们可以在将来轻松更改超时持续时间(更易于代码维护)。timeout 是一个简单的 Promise,它在 5 秒后自动拒绝(来自 timeoutDuration 常量)。我已经创建了服务器,以便在 3 秒后响应。

现在,JavaScript 有一种很酷的方法来解决多个 Promises,即 Promise.race() 方法。正如其名字所示,这将使两个 Promises 同时运行,并接受首先解决/拒绝的那个的值。这样,如果服务器在 3 秒内没有响应,5 秒后就会发生超时,apiCall 将被拒绝并显示超时!为此,在 apiCall() 函数中的 requesttimeout Promises 之后添加以下代码:

return new Promise((resolve, reject) => {
  Promise.race([request, timeout])
    .then(resolve)
    .catch(reject);
});

apiCall() 函数作为一个整体返回一个 Promise,该 Promise 是 requesttimeout Promise 的解决值(取决于它们中哪一个更快执行)。就是这样!我们的 apiCall 模块现在已经准备好在我们的事件注册应用程序中使用。

如果您觉得 apiCall 函数难以理解和跟踪,请再次阅读 Chapter03 完整代码文件中的 apiCall.js 文件,以便更简单地解释。要详细了解 Promise 并带有更多示例,请阅读以下 Google Developers 页面:developers.google.com/web/fundamentals/getting-started/primers/promises 和 MDN 页面:developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise

其他网络请求方法

点击这些链接了解 JavaScript 中进行网络请求的其他插件/API:

要使 fetch 在 Internet Explorer 中工作,请阅读以下页面,了解如何为 fetch 添加 polyfillgithub.com/github/fetch/

回到表单

开始提交的第一步是隐藏提交按钮并用加载指示器替换它。这样,用户就不会意外地点击两次提交。此外,加载指示器还表示后台正在进行某个过程。在 home.js 文件的 submitForm() 方法中,添加以下代码:

submitForm(formValues) {
  this.$submit.classList.add('hidden');
  this.$loadingIndicator.classList.remove('hidden');
}

这将隐藏提交按钮并显示加载指示器。要进行 apiCall,我们需要导入 apiCall 函数并通知用户请求已完成。我在 package.json 文件中添加了一个名为 toastr 的包。当您运行 npm install 命令时,它应该已经安装。

home.js 文件的顶部,添加以下导入语句:

import apiCall from './services/api/apiCall';
import toastr from 'toastr';
import '../../node_modules/toastr/toastr.less';

这将导入toastr及其样式文件(toastr.less),以及最近创建的apiCall模块。现在,在submitForm()方法中,添加以下代码:

apiCall('registration', formValues, 'POST')
  .then(response => {
    this.$submit.classList.remove('hidden');
    this.$loadingIndicator.classList.add('hidden');
    toastr.success(response.message);
    this.resetForm(); // For clearing the form
  })
  .catch(() => {
    this.$submit.classList.remove('hidden');
    this.$loadingIndicator.classList.add('hidden');
    toastr.error('Error!');
  });

由于apiCall()返回一个 Promise,我们在这里使用Promise.then().catch()链。当注册成功时,toastr将在页面的右上角显示一个成功的提示,其中包含服务器发送的消息。如果出现问题,它将简单地显示一个错误提示。此外,我们需要使用this.resetForm()方法清除表单。在Home类中添加resetForm()方法,代码如下:

resetForm() {
  this.$username.value = '';
  this.$email.value = '';
  this.$phone.value = '';
  this.$age.value = '';
  this.$profession.value = 'school';
  this.$experience.checked = true;
  this.$comment.value = '';
}

在 Chrome 中返回到活动注册页面,尝试提交表单。如果所有值都有效,它应该成功提交表单并显示成功的提示消息,表单值将被重置为初始值。在现实世界的应用中,服务器将向用户发送确认邮件。然而,服务器端编码超出了本书的范围。但我想在下一章中稍微解释一下这个。

尝试关闭 Node.js 服务器并提交表单。它应该会抛出一个错误。在学习 JavaScript 的一些高级概念的同时,您已经成功完成了构建您的活动注册表单。现在,让我们继续进行我们应用程序的第二页——状态页面,我们需要显示一个注册用户统计图表。

使用 Chart.js 向网站添加图表

我们刚刚为用户创建了一个不错的注册表单。现在是时候处理我们活动注册应用程序的第二部分了。状态页面显示了一个图表,显示了对活动感兴趣的人数,根据经验、职业和年龄。如果现在打开状态页面,它应该显示一个数据加载中...的消息和加载指示器图像。但我已经在status.html文件中构建了所有这个页面所需的组件。它们都使用 Bootstrap 的.hidden类当前隐藏。

让我们看看status.html文件中有什么。尝试从以下每个部分中删除.hidden类,看看它们在 Web 应用程序中的外观。

首先是加载指示器部分,它目前显示在页面上:

<div id="loadingIndicator">
  <p>Data loading...</p>
  <image src="./src/assets/images/loading.gif" class="loading-indicator"></image>
</div>

接下来是一个包含错误消息的部分,当 API 调用失败时显示:

<div id="loadingError" class="hidden">
  <h3>Unable to load data...Try refreshing the page.</h3>
</div>

在前面的部分之后,我们有一个选项卡部分,它将为用户提供在不同图表之间切换的选项。代码如下所示:

<ul class="nav nav-tabs hidden" id="tabArea">
  <li role="presentation" class="active"><a href="" id="experienceTab">Experience</a></li>
  <li role="presentation"><a href="" id="professionTab">Profession</a></li>
  <li role="presentation"><a href="" id="ageTab">Age</a></li>
</ul>

选项卡只是一个带有.nav.nav-tabs类的无序列表,由 Bootstrap 样式为选项卡。选项卡部分是带有.active类的列表项,用于突出显示所选的选项卡部分(role="presentation"用于辅助选项)。在列表项内,有一个空的href属性的锚标签。

最后,我们有我们的图表区域,有三个画布元素,用于显示前面选项卡中提到的三个不同类别的图表:

<div class="chart-area hidden" id="chartArea">
  <canvas id="experienceChart"></canvas>
  <canvas id="professionChart"></canvas>
  <canvas id="ageChart"></canvas>
</div>

正如我们在上一章中看到的,画布元素最适合在网页上显示图形,因为编辑 DOM 元素是一项昂贵的操作。Chart.js 使用画布元素来显示给定数据的图表。让我们制定状态页面应该如何工作的策略:

  • 在从服务器获取统计数据的 API 调用时,应该显示加载指示器

  • 如果数据成功检索,则加载指示器应该被隐藏,选项卡部分和图表区域应该变得可见

  • 只有与所选选项卡对应的画布应该可见;其他画布元素应该被隐藏

  • 应该使用 Chart.js 插件向画布添加饼图

  • 如果数据检索失败,则所有部分应该被隐藏,错误部分应该被显示

好了!让我们开始工作。打开我已经在status.html中添加为参考的status.js文件。创建一个Status类,并在其构造函数中引用所有所需的 DOM 元素,如下所示:

class Status {
  constructor() {
    this.$experienceTab = document.querySelector('#experienceTab');
    this.$professionTab = document.querySelector('#professionTab');
    this.$ageTab = document.querySelector('#ageTab');

    this.$ageCanvas = document.querySelector('#ageChart');
    this.$professionCanvas = document.querySelector('#professionChart');
    this.$experienceCanvas = document.querySelector('#experienceChart');

    this.$loadingIndicator = document.querySelector('#loadingIndicator');
    this.$tabArea = document.querySelector('#tabArea');
    this.$chartArea = document.querySelector('#chartArea');

    this.$errorMessage = document.querySelector('#loadingError');

    this.statisticData; // variable to store data from the server
 }

}

我还创建了一个类变量statisticData,用于存储从 API 调用中检索到的数据。此外,在页面加载时添加创建类的实例的代码:

window.addEventListener("load", () => {
  new Status();
});

我们状态页面的第一步是向服务器发出网络请求,以获取所需的数据。我已在 Node.js 服务器中创建了以下 API 端点:

Route: /statistics,
Method: GET,  Server Response on Success:
status code: 200
response: {"experience":[35,40,25],"profession":[30,40,20,10],"age":[30,60,10]}

服务器将以适合与 Chart.js 一起使用的格式返回包含基于其经验、职业和年龄感兴趣的人数的数据。让我们使用之前构建的apiCall模块来进行网络请求。在您的status.js文件中,首先在Status类上面添加以下导入语句:

import apiCall from './services/api/apiCall';

之后,在Status类中添加以下方法:

loadData() {
  apiCall('statistics')
    .then(response => {
      this.statisticData = response;

      this.$loadingIndicator.classList.add('hidden');
      this.$tabArea.classList.remove('hidden');
      this.$chartArea.classList.remove('hidden');
    })
    .catch(() => {
      this.$loadingIndicator.classList.add('hidden');
      this.$errorMessage.classList.remove('hidden');
    });
}

这次,我们可以只使用一个参数调用apiCall()函数,因为我们正在进行GET请求,并且我们已经将apiCall()函数的默认参数定义为body = {}method = 'GET'。这样,我们在进行GET请求时就不必指定 body 和 method 参数。在您的构造函数中,添加this.loadData()方法,这样当页面加载时它将自动进行网络请求:

constructor() {
  ...
  this.loadData();
}

现在,在 Chrome 中查看网页。三秒后,应该显示选项卡。目前,单击选项卡只会重新加载页面。我们将在创建图表后处理这个问题。

将图表添加到画布元素

我们的类变量statisticData中有所需的数据,应该用它来渲染图表。我已经在package.json文件中添加了 Chart.js 作为项目依赖项,当您执行npm install命令时,它应该已经安装。让我们通过在status.js文件顶部添加以下代码来将 Chart.js 导入我们的项目中:

import Chart from 'chart.js';

不一定要在文件顶部添加import语句。但是,在顶部添加import语句可以清晰地看到当前文件中模块的所有依赖关系。

Chart.js 提供了一个构造函数,我们可以使用它来创建一个新的图表。Chart构造函数的语法如下:

new Chart($canvas, {type: 'pie', data});

Chart构造函数的第一个参数应该是对 canvas 元素的引用,第二个参数是具有两个属性的 JSON 对象:

  • type属性应该包含我们在项目中需要使用的图表类型。我们需要在项目中使用饼图。

  • data属性应该包含作为基于图表类型的格式的对象所需的数据集。在我们的情况下,对于饼图,所需的格式在 Chart.js 文档的以下页面上指定:www.chartjs.org/docs/latest/charts/doughnut.html

数据对象将具有以下格式:

{
  datasets: [{
    data: [],
    backgroundColor: [],
    borderColor: [],
  }],
  labels: []
}

数据对象具有以下属性:

  • 一个datasets属性,其中包含另一个对象的数组,该对象具有databackgroundColorborderColor作为数组

  • labels属性是一个标签数组,顺序与数据数组相同

创建的图表将自动占据其父元素提供的整个空间。在Status类内部创建以下函数,将Chart加载到状态页面中:

您可以根据经验创建一个图表,如下所示:

loadExperience() {
  const data = {
    datasets: [{
      data: this.statisticData.experience,
      backgroundColor:[
        'rgba(255, 99, 132, 0.6)',
        'rgba(54, 162, 235, 0.6)',
        'rgba(255, 206, 86, 0.6)',
      ],
      borderColor: [
        'white',
        'white',
        'white',
      ]
    }],
    labels: [
      'Beginner',
      'Intermediate',
      'Advanced'
    ]
  };
  new Chart(this.$experienceCanvas,{
    type: 'pie',
    data,
  });
}

您可以根据职业创建一个图表,如下所示:

loadProfession() {
  const data = {
    datasets: [{
      data: this.statisticData.profession,
      backgroundColor:[
        'rgba(255, 99, 132, 0.6)',
        'rgba(54, 162, 235, 0.6)',
        'rgba(255, 206, 86, 0.6)',
        'rgba(75, 192, 192, 0.6)',
      ],
      borderColor: [
        'white',
        'white',
        'white',
        'white',
      ]
    }],
    labels: [
      'School Students',
      'College Students',
      'Trainees',
      'Employees'
    ]
  };
  new Chart(this.$professionCanvas,{
    type: 'pie',
    data,
  });
}

您可以根据年龄创建一个图表,如下所示:

loadAge() {
  const data = {
    datasets: [{
      data: this.statisticData.age,
      backgroundColor:[
        'rgba(255, 99, 132, 0.6)',
        'rgba(54, 162, 235, 0.6)',
        'rgba(255, 206, 86, 0.6)',
      ],
      borderColor: [
        'white',
        'white',
        'white',
      ]
    }],
    labels: [
      '10-15 years',
      '15-20 years',
      '20-25 years'
    ]
  };
  new Chart(this.$ageCanvas,{
    type: 'pie',
    data,
  });
}

这些函数应在数据加载到statisticData变量中时调用。因此,在 API 调用成功后调用它们的最佳位置是在loadData()方法中添加以下代码,如下所示:

loadData() {
  apiCall('statistics')
    .then(response => {
      ...
      this.loadAge();
      this.loadExperience();
      this.loadProfession();
     })
...
}

现在,在 Chrome 中打开状态页面。您应该看到页面上呈现了三个图表。图表已经占据了其父元素的整个宽度。要减小它们的大小,请在您的styles.css文件中添加以下样式:

.chart-area {
  margin: 25px;
  max-width: 600px;
}

这将减小图表的尺寸。Chart.js 最好的部分是它默认是响应式的。尝试在 Chrome 的响应式设计模式下调整页面大小。当页面的高度和宽度改变时,你应该看到图表被重新调整大小。我们现在在我们的状态页面上添加了三个图表。

对于我们的最后一步,我们需要选项卡来切换图表的外观,以便一次只有一个图表可见。

设置选项卡部分

选项卡应该工作,以便在任何给定时间只有一个图表可见。此外,所选选项卡应使用 .active 类标记为活动状态。这个问题的一个简单解决方案是隐藏所有图表,从所有选项卡项目中移除 .active,然后只向点击的选项卡项目添加 .active 并显示所需的图表。这样,我们可以轻松获得所需的选项卡功能。

首先,在 Status 类中创建一个方法来清除选定的选项卡并隐藏所有图表:

hideCharts() {
  this.$experienceTab.parentElement.classList.remove('active');
  this.$professionTab.parentElement.classList.remove('active');
  this.$ageTab.parentElement.classList.remove('active');
  this.$ageCanvas.classList.add('hidden');
  this.$professionCanvas.classList.add('hidden');
  this.$experienceCanvas.classList.add('hidden');
}

创建一个方法来为点击的选项卡项目添加事件监听器:

addEventListeners() {
  this.$experienceTab.addEventListener('click', this.loadExperience.bind(this));
  this.$professionTab.addEventListener('click', this.loadProfession.bind(this));
  this.$ageTab.addEventListener('click', this.loadAge.bind(this));
}

还要在 constructor 中使用 this.addEventListeners(); 调用前面的方法,以便在页面加载时附加事件监听器。

每当我们点击选项卡项目中的一个时,它将调用相应的加载图表函数。比如我们点击了 Experience 选项卡。这将使用 event 作为参数调用 loadExperience() 方法。但是我们可能希望在 API 调用后调用此函数以加载图表,而不带有事件参数。为了使 loadExperience() 在两种情况下都能工作,修改该方法如下:

loadExperience(event = null) {
  if(event) event.preventDefault();
  this.hideCharts();
  this.$experienceCanvas.classList.remove('hidden');
  this.$experienceTab.parentElement.classList.add('active');

  const data = {...}
  ...
}

在前面的函数中:

  • 事件参数被定义为默认值 null。如果使用事件参数调用 loadExperience()(当用户点击选项卡时),if(event) 条件将通过,event.preventDefault() 将停止锚标签的默认点击操作。这将防止页面重新加载。

  • 如果从 apiCall 的 promise 链中调用 this.loadExperience(),它将不具有 event 参数,事件的值默认为 nullif(event) 条件将失败(因为 null 是一个假值),event.preventDefault() 将不会被执行。这将防止异常,因为在这种情况下 event 未定义。

  • 之后,调用 this.hideCharts(),这将隐藏所有图表并从所有选项卡中移除 .active

  • 接下来的两行将从经验图表的画布中移除 .hidden 并向 Experience 选项卡添加 .active 类。

apiCall 函数的 then 链中,移除 this.loadAge()this.loadProfession(),这样只有经验图表会首先加载(因为它是第一个选项卡)。

如果你在 Google Chrome 中打开并点击 Experience 选项卡,它应该重新渲染图表而不刷新页面。这是因为我们在 loadExperience() 方法中添加了 event.preventDefault() 来阻止默认操作,并使用 Chart.js 在点击选项卡时渲染图表。

通过在 loadAge()loadProfession() 中使用相同的逻辑,我们现在可以轻松使选项卡按预期工作。在你的 loadAge() 方法中添加以下事件处理代码:

loadAge(event = null) {
  if(event) event.preventDefault();
  this.hideCharts();
  this.$ageCanvas.classList.remove('hidden');
  this.$ageTab.parentElement.classList.add('active');

  const data = {...}
  ...
}

同样,在 loadProfession() 方法中添加以下代码:

loadProfession(event = null) {
  if(event) event.preventDefault();
  this.hideCharts();
  this.$professionCanvas.classList.remove('hidden');
  this.$professionTab.parentElement.classList.add('active');

  const data = {...}
  ...
}

打开 Chrome。点击选项卡以检查它们是否都正常工作。如果是,你已成功完成了状态页面!Chart.js 默认是响应式的;因此,如果你调整页面大小,它将自动调整饼图的大小。现在,还有最后一页,你需要添加谷歌地图来显示事件位置。在普通的 JavaScript 中,添加谷歌地图很简单。但是,在我们的情况下,因为我们使用 Webpack 来捆绑我们的 JavaScript 代码,我们需要在正常流程中添加一个小步骤(谷歌地图需要在 HTML 中访问 JavaScript 变量!)。

Chart.js 有八种类型的图表。请尝试访问:www.chartjs.org/,如果你正在寻找更高级的图表和图形库,请查看D3.js数据驱动文档):d3js.org/

在网页中添加谷歌地图

在 VSCode 或文本编辑器中打开about.html文件。它将有两个<p>标签,你可以在其中添加有关活动的一些信息。之后,将会有一个 ID 为#map<div>元素,它应该显示活动在地图中的位置。

我之前已经要求你生成一个 API 密钥来使用谷歌地图。如果你还没有生成,请从以下网址获取:developers.google.com/maps/documentation/javascript/get-api-key,并将其添加到你的.env文件的GMAP_KEY变量中。根据谷歌地图的文档,要在网页上添加一个带有标记的地图,你必须在页面上包含以下脚本:

<script  async  defer src="https://maps.googleapis.com/maps/api/js?key=API_KEY&callback=**initMap**">

在这里,<script>标签的asyncdefer属性将异步加载脚本,并确保它仅在文档加载后执行。

要了解有关asyncdefer的工作原理的更多信息,请参考以下 w3schools 页面。有关 Async: www.w3schools.com/tags/att_script_async.asp,有关 Defer: www.w3schools.com/tags/att_script_defer.asp

让我们来看看src属性。在这里,有一个 URL,后面跟着两个查询参数,key 和 callback。Key 是你需要包含你的谷歌地图 API 密钥的地方,callback 应该是一个需要在脚本加载完成后执行的函数(脚本是异步加载的)。挑战在于脚本需要包含在我们的 JavaScript 变量不可访问的 HTML 中(我们现在是 Webpack 用户!)。

但是,正如我之前解释的,在webpack.config.js文件中,我已经添加了output.library属性,它将通过将它们的作用域从constlet更改为var,将使用export关键字标记的对象、函数或变量暴露给 HTML(但它们不能直接通过它们的名称访问)。我给出的output.library的值是bundle。因此,使用export关键字标记的东西将作为bundle对象的属性可用。

在 Chrome 中打开事件注册应用程序,并打开 Chrome DevTools 控制台。如果你在控制台中输入bundle,你会发现它打印出一个空对象。这是因为我们还没有从Webpack 的入口文件中进行任何导出(我们在apiCall.jsregistrationForm.js中进行了一些导出,但这些文件不在webpack.config.js的入口属性中)。因此,目前我们只有一个空的 bundle 对象。

让我们想一种成功将谷歌地图脚本包含在我们的 Web 应用程序中的方法:

  • API 密钥当前在我们的 JavaScript 代码中作为全局变量GMAP_KEY可用。因此,最好是在页面加载完成后从 JavaScript 创建脚本元素并将其附加到 HTML 中。这样,我们就不必导出 API 密钥。

  • 对于回调函数,我们将创建一个 JavaScript 函数并导出它。

在 VSCode 中打开about.js文件并添加以下代码:

export function initMap() {
}

window.addEventListener("load", () => {
  const $script = document.createElement('script');
  $script.src = `https://maps.googleapis.com/maps/api/js?key=${GMAP_KEY}&callback=bundle.initMap`;
  document.querySelector('body').appendChild($script);
});

上述代码执行以下操作:

  • 当页面加载完成时,它将创建一个新的脚本元素document.createElement('script')并将其存储在$script常量对象中。

  • 现在,我们将src属性添加到$script对象中,并将值设置为所需的脚本 URL。请注意,我已经在密钥中包含了GMAP_KEY变量,并将bundle.initMap作为回调函数(因为我们在about.js中导出了initMap)。

  • 最后,它将把脚本作为子元素附加到 body 元素。这将使 Google Maps 脚本按预期工作。

  • 我们这里不需要asyncdefer,因为只有在页面加载完成后才加载脚本。

在你的 Chrome DevTools 控制台上,当你在 about 页面上时,尝试再次输入bundle。这一次,你应该看到一个打印出initMap作为其属性之一的对象。

在我们的 ToDo List 应用中,我们通过直接在模板字符串中编写 HTML 代码来创建 HTML 元素。这对于构建大量 HTML 元素非常有效。然而,对于较小的元素,最好使用document.createElement()方法,因为当该元素有很多需要动态值的属性时,这样做会使代码更易读和易懂。

添加带有标记的 Google 地图

我们已经成功在页面上包含了 Google Maps 脚本。当 Google Maps 脚本加载完成时,它将调用我们在about.js文件中声明的initMap函数。现在,我们将使用该函数来创建一个指向 JS Meetup 活动位置的地图标记。

添加 Google 地图标记和更多功能的过程在 Google 地图文档中有很好的解释,可在以下链接找到:developers.google.com/maps/documentation/javascript/adding-a-google-map

我们之前包含的 Google Maps 脚本为我们提供了一些构造函数,可以创建mapMarkerinfowindow。要添加一个带有marker的简单 Google 地图,请在initMap()函数内添加以下代码:

export function initMap() {
  const map = new google.maps.Map(document.getElementById('map'), {
    zoom: 13,
    center: {lat: 59.325, lng: 18.070}
  });

  const marker = new google.maps.Marker({
    map,
    draggable: true,
    animation: google.maps.Animation.DROP,
    position: {lat: 59.325, lng: 18.070}
  });

  marker.addListener('click', () => {
    infowindow.open(map,marker);
  });

  const infowindow = new google.maps.InfoWindow({
    content: `<h3>Event Location</h3><p>Event Address with all the contact details</p>`
  });

  infowindow.open(map,marker);
}

用你的活动地点的纬度和经度替换上述代码中的latlng值,并将infowindow对象的内容更改为活动地点的地址和联系方式。现在,在 Google Chrome 上打开about.html页面;你应该看到地图上有一个标记指向你的活动地点。信息窗口将默认打开。

恭喜!你已经成功构建了你的 Event Registration 应用!但是,在我们开始邀请人们参加活动之前,你的应用还有一件事情需要做。

生成生产构建

你可能已经注意到了关于 Meme Creator 和 Event Registration 应用的一些问题。这些应用首先加载纯 HTML;之后加载样式。这使得应用在一段时间内看起来很普通。在 ToDo List 应用中不存在这个问题,因为我们首先加载了 CSS。在 Meme Creator 应用中,有一个名为为不同环境优化 Webpack 构建的可选部分。现在可能是阅读它的好时机。如果你还没有阅读过,请回去,阅读一下那部分内容,然后回来生成生产构建。

到目前为止,我们的应用一直在开发环境中运行。记得吗?在.env文件中,我告诉你要设置NODE_ENV=dev。这是因为,当你按照我创建的webpack.config.js文件设置NODE_ENV=production时,Webpack 将进入生产模式。npm run watch命令用于运行 Webpack 开发服务器,为我们提供一个开发服务器。在你的package.json文件中,应该有另一个名为webpack的命令。这个命令用于生成生产构建。

这个项目中包含的webpack.config.js文件有很多插件,用于优化代码,使应用加载时间更快。只有当NODE_ENV为 production 时,npm run watch才能正常工作,因为有很多插件用于进行生产优化。要为你的 Event Registration 应用生成生产构建,请按照以下步骤进行:

  1. .env文件中NODE_ENV变量的值更改为production

  2. 在终端中从项目根文件夹运行以下命令npm run webpack

命令执行需要一段时间,但一旦完成,你应该在项目的/dist文件夹中看到许多文件。那里会有 JS 文件、CSS 文件和包含生成的 CSS 和 JS 文件的.map文件的源映射信息。JS 文件将被压缩和精简,以便加载和执行时间非常快。还会有一个包含 Bootstrap 使用的字体的字体目录。

到目前为止,我们只在 HTML 中包含了 JS 文件,因为它也包含了 CSS 代码。然而,这就是为什么页面在开始加载时显示空白的 HTML 而没有 CSS 的原因。CSS 文件应该在<body>元素之前包含,这样它将首先加载,页面样式在加载时将是统一的(看看我们在第一章中如何包含 CSS 文件,构建一个待办事项列表应用)。对于生产构建,我们需要删除对旧 JS 文件的引用,并包含新生成的 CSS 和 JS 文件。

在你的dist/目录中,会有一个manifest.json文件,其中包含了 Webpack 每个入口生成的文件列表。manifest.json应该看起来像这样:

{
  "status": [
    "16f9901e75ba0ce6ed9c.status.js",
    "16f9901e75ba0ce6ed9c.status.css",
    "16f9901e75ba0ce6ed9c.status.js.map",
    "16f9901e75ba0ce6ed9c.status.css.map"
  ],
  "home": [
    "756fc66292dc44426e28.home.js",
    "756fc66292dc44426e28.home.css",
    "756fc66292dc44426e28.home.js.map",
    "756fc66292dc44426e28.home.css.map"
  ],
  "about": [
    "1b4af260a87818dfb51f.about.js",
    "1b4af260a87818dfb51f.about.css",
    "1b4af260a87818dfb51f.about.js.map",
    "1b4af260a87818dfb51f.about.css.map"
  ]
}

前缀数字只是哈希值,它们可能对你来说是不同的;不用担心。现在,为每个 HTML 文件包含 CSS 和 JS 文件。例如,取status.html文件,并在前面的manifest.json文件的 status 属性中添加 CSS 和 JS 文件,如下所示:

...
<head>
  ...
  <link rel="stylesheet" href="dist/16f9901e75ba0ce6ed9c.status.css">
</head>
<body>
  ...
  <script src="dist/16f9901e75ba0ce6ed9c.status.js"></script>
</body>
...

对其他 HTML 文件重复相同的过程,然后你的生产构建就准备好了!现在不能使用 Webpack 开发服务器,所以你可以使用http-server工具打开网页,或者直接用 Chrome 打开 HTML 文件(我建议使用http-server)。这一次,在页面加载时,你不会看到没有样式的 HTML 页面,因为 CSS 会在 body 元素之前加载。

发布代码

现在你已经学会了如何生成生产构建,如果你想把这段代码发送给其他人呢?比如 DevOps 团队或服务器管理员。在这种情况下,如果你正在使用版本控制,将dist/目录、node_modules/目录和.env文件添加到你的忽略列表中。发送代码时不包括这两个目录和.env文件。其他人应该能够使用.env.example文件找出要使用的环境变量,创建.env文件,并使用npm installnpm run webpack命令生成node_modules/dist/目录。

对于所有其他步骤,将过程整齐地记录在项目根目录的README.md文件中,并将其与其他文件一起发送。

共享.env文件应该避免的主要原因是环境变量可能包含敏感信息,不应以明文形式在版本控制中传输或存储。

你现在已经学会了如何为使用 Webpack 构建的应用生成生产构建。现在,Meme Creator 应用还没有生产构建!我会让你使用本章中使用的webpack.config.js文件作为参考。所以,继续为你的 Meme Creator 创建一个生产构建。

摘要

干得好!你刚刚构建了一个非常有用的活动注册应用。在这个过程中,你学到了一些 JavaScript 的高级概念,比如构建可重用代码的 ES6 模块,使用 fetch 进行异步 AJAX 调用,并使用 Promises 处理异步代码。你还使用 Chart.js 库构建图表来直观显示数据,最后使用 Webpack 创建了一个生产就绪的构建。

学会了所有这些概念,你不再是 JavaScript 的初学者;你可以自豪地称自己为专家!但是,除了这些概念,现代 JavaScript 还有很多其他内容。正如我之前告诉过你的,JavaScript 不再仅仅是用于浏览器表单验证的脚本语言。在下一章中,我们将使用 JavaScript 构建一个点对点视频通话应用程序。

`# 使用 WebRTC 进行实时视频通话应用

嘿!只是想告诉你,JS Meetup 在找到后端开发人员完成应用程序的服务器端之后取得了巨大成功。但是你很棒,完成了整个应用程序的前端。你创建了一个完整的活动注册网站,让用户报名参加活动,同时学习了一些非常重要的概念,比如构建可重用的 ES6 模块,使用 Promises 处理异步代码进行 AJAX 请求,从数据创建美丽的图表,当然还有经典的表单验证与验证服务。

后端代码也是用 JavaScript(Node.js)编写的,所以你可能真的对编写服务器端代码感兴趣。但遗憾的是,正如我之前提到的,Node.js 超出了本书的范围。实际上,你可以用纯 JavaScript 做一些非常酷的事情,尽管很多人认为,“它需要大量的服务器端代码!”因为你已经读过本章的标题 - 是的!我们将在本章中构建一个真正的视频通话应用程序,几乎没有服务器端代码。最好的部分是,就像我们的其他应用程序一样,这个应用程序也将是响应式的,并且将与大多数移动浏览器兼容。

让我们首先看一下我们将在本章学习的概念清单:

  • WebRTC 介绍

  • JavaScript 中的 WebRTC API

  • 使用 SimpleWebRTC 框架进行工作

  • 构建视频通话应用程序

除了这些主要概念,本章还有很多东西要学习。因此,在我们开始之前,请确保你有以下硬件:

  • 带有网络摄像头和麦克风的台式机或笔记本电脑(你可能想使用另一台计算机来体验视频通话的实际效果)

  • 安卓或 iPhone 设备(可选)

  • 局域网连接,以便所有设备都在同一局域网上进行开发应用程序的测试(可以是 Wi-Fi 或有线以太网)

这个项目中使用的一个依赖项要求你的系统中安装了 Python 2.7.x。Linux 和 Mac 用户已经预装了 Python。Windows 用户可以从www.python.org/downloads/下载 Python 2.7.x 版本。

第四章:WebRTC 介绍

在我们开始构建应用程序之前,最好先了解一些关于 WebRTC 的知识,以便你对应用程序的工作原理有一个很好的了解。

WebRTC 的历史

实时通信能力已经成为我们现在使用的许多应用程序的常见功能。比如你想和朋友聊天或者观看现场足球比赛。这些应用程序必须具备实时通信功能。然而,在过去在浏览器上进行实时视频通话对用户来说是一项相当困难的任务,因为他们必须为不同的应用程序在 Web 浏览器上使用视频通话安装插件,而插件会带来漏洞,因此需要定期更新。

为了解决这个问题,谷歌于 2011 年 5 月发布了一个开源项目,用于基于浏览器的实时通信标准,名为 WebRTC。WebRTC 的概念很简单。它定义了一套标准,应该在所有应用程序中使用,以便应用程序可以直接相互通信(点对点通信)。通过实现 WebRTC,将不再需要插件,因为通信平台是标准化的。

目前,WebRTC 正在由万维网联盟W3C)和互联网工程任务组IETF)进行标准化。WebRTC 正在被大多数浏览器供应商积极实施,并且它也将与原生的 Android 和 iOS 应用程序一起工作。如果你想知道你的浏览器是否准备支持 WebRTC,你可以访问:iswebrtcreadyyet.com/

在撰写本书时,浏览器支持状态如下:

尽管大多数常用的浏览器都支持 WebRTC,除了 Safari,但实现中仍然存在许多问题和错误,因此建议使用适配器(如adapter.js)(github.com/webrtc/adapter),以便应用程序在规范或供应商前缀发生变化时不会遇到任何问题。当我们研究 WebRTC 的 JavaScript API 时,我们将更多地了解这一点。

WebRTC 也支持 Chrome 和 Firefox 的移动版本;因此,即使在没有插件的移动浏览器中,你也可以进行视频通话。

对于 iPhone 用户,iPhone 上的 Safari 移动浏览器或 Chrome 尚不支持 WebRTC。因此,你必须安装 Firefox 或来自应用商店的 Bowser 应用。Bowser 的链接:itunes.apple.com/app/bowser/id560478358?mt=8

JavaScript WebAPIs

到目前为止,我们已经使用了一些 WebAPIs,比如FileReader,文档(在document.querySelector()方法中使用),HTMLImageElement(我们在 Meme Creator 中使用的new Image()构造函数),等等。它们不是 JavaScript 语言的一部分,但它们是 WebAPIs 的一部分。在浏览器中运行 JavaScript 时,将提供一个包含所有 WebAPIs 方法的window对象。window对象的范围是全局的,window对象的属性和方法也是全局的。这意味着,如果你想使用 navigator WebAPI,你可以这样做:

window.navigator.getUserMedia()

或者,你可以简单地这样做:

navigator.getUserMedia();

两者都可以正常工作并实现相同的方法。但是请注意,WebAPI(window对象)仅在浏览器中运行 JavaScript 时才可用。如果你在其他平台上使用 JavaScript,比如 Node.js 或 React Native,你将无法使用 WebAPIs。

现在 WebAPIs 变得越来越强大,为 JavaScript 提供了更多的功能,比如直接从浏览器录制视频和音频。渐进式 Web 应用程序就是这样的一个例子,由ServiceWorker WebAPI 提供支持。

本章和接下来的章节中,我们将使用大量的 WebAPIs。有关 JavaScript 可用的 WebAPIs 的完整列表,请访问以下 MDN 页面:developer.mozilla.org/en-US/docs/Web/API

JavaScript WebRTC API

由于浏览器原生支持 WebRTC,因此浏览器供应商创建了 JavaScript WebAPIs,以便开发人员可以轻松构建应用程序。目前,WebRTC 实现了以下三个 JavaScript 使用的 API:

  • MediaStream

  • RTCPeerConnection

  • RTCDataChannel

MediaStream

MediaStream API 用于获取用户的视频和音频设备的访问权限。通常,浏览器会提示用户是否允许网站访问他/她设备的摄像头和麦克风。尽管 MediaStream API 的基本概念是相同的,但不同的浏览器供应商对 API 的实现方式有所不同。

在使用getUserMedia()方法时,使用{audio: true}来访问你自己的麦克风时,要么将扬声器静音,要么将 HTML 视频元素静音。否则,可能会导致反馈,损坏你的扬声器

例如,在 Chrome 中,要使用 MediaStream API,你需要使用navigator.getUserMedia()方法。此外,Chrome 只允许 MediaStream 在 localhost 或 HTTPS URL 中工作。

navigator.getUserMedia()接受三个参数。第一个是配置对象,告诉浏览器网站需要访问什么。另外两个是成功或失败响应的回调函数。

创建一个简单的 HTML 文件,比如chrome.html,放在一个空目录中。在 HTML 文件中,添加以下代码:

<video></video>
<script>
const $video = document.querySelector('video');
if (navigator.getUserMedia) {
  navigator.getUserMedia(
    {audio: true, video: true},
    stream => {
      $video.srcObject = stream;
      $video.muted = true; // Video muted to avoid feedback
      $video.onloadedmetadata = () => {
        $video.play();
      };
    },
    error => console.error(error)
  );
}
</script>

这段代码做了以下几件事:

  • 它将在$video对象中创建对<video>元素的引用。

  • 然后,它检查navigator.getUserMedia是否可用。这样做是为了避免在使用不兼容 WebRTC 的浏览器时出现错误。

  • 然后,它使用以下三个参数调用navigator.getUserMedia()方法:

  • 第一个参数指定网站对浏览器的需求。在我们的例子中,需要音频和视频。因此,我们应该传递{audio: true, video: true}

  • 第二个参数是成功的回调函数。用户接收的视频和音频流在传递给此函数的stream对象中可用。它将srcObject属性添加到<video>元素,其值为从用户输入设备接收的视频和音频的stream对象。当流加载时,将调用$video.onloadedmetadata,并且它将开始播放视频,因为我们在其回调函数中添加了$video.play()

  • 第三个参数是当用户拒绝网站访问摄像头或麦克风,或者发生其他错误且无法检索媒体流时调用的函数。此函数的参数是一个error对象,其中包含错误详细信息。

现在,使用http-server在本地主机中的 Chrome 中打开文件。首先,Chrome 将提示您允许访问设备的摄像头和麦克风。它应该如下所示:

如果您点击允许,您应该看到通过前置摄像头传输的视频。我已经在以下网址设置了一个 JS fiddle:jsfiddle.net/1odpck45/,您可以在其中玩弄视频流。

一旦您点击允许或阻止,Chrome 将记住网站的偏好设置。要更改网站的权限,您必须点击地址栏左侧的锁定或信息图标,它将显示一个菜单,如下所示,您可以再次更改权限:

由于我们使用 http-server 或 Webpack 开发服务器进行开发,这些服务器在本地主机上运行,因此我们可以在 Chrome 中开发 WebRTC 应用程序。但是,如果要在生产环境中部署应用程序,则需要使用 HTTPS URL 进行部署。否则,应用程序将无法在 Chrome 上运行。

我们在 Chrome 上创建的视频在 Chrome 上运行得很好,但是如果您尝试在不同的浏览器 Firefox 上运行此代码,它将无法运行。这是因为 Firefox 对 MediaStream API 有不同的实现。

在 Firefox 中,您需要使用navigator.mediaDevices.getUserMedia()方法,该方法返回一个 Promise。可以使用.then().catch()链使用stream对象。

Firefox 的代码如下:

<video></video>
<script>
const $video = document.querySelector('video');
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then(stream => {
  $video.srcObject = stream;
  $video.muted = true;
  $video.onloadedmetadata = function(e) {
    $video.play();
  };
})
.catch(console.error);
</script>

您可以在 Firefox 中运行此代码,方法是在与您创建chrome.html文件相同的目录中创建一个firefox.html文件,或者在您的 Firefox 浏览器中打开以下 JS fiddle:jsfiddle.net/hc39mL5g/

为了生产环境设置 HTTPS 服务器超出了本书的范围。但是,根据您想要使用的服务器类型,可以很容易地在互联网上找到说明。

使用 Adapter.js 库

由于 WebRTC 在不同浏览器之间的实现不同,建议使用适配器(例如adapter.js库(github.com/webrtc/adapter))来隔离代码与浏览器实现的差异。通过包含adapter.js库,您可以在 Firefox 浏览器中运行为 Chrome 编写的 WebRTC 代码。尝试在 Firefox 中运行以下 JS fiddle,其中包含适用于 Chrome 的 WebRTC 代码,但包括adapter.jsjsfiddle.net/1ydwr4tt/

如果您想了解<video>元素,它是在 HTML5 中引入的。要了解有关使用视频元素的更多信息,请访问 w3schools 页面:www.w3schools.com/html/html5_video.asp或 MDN 页面:developer.mozilla.org/en/docs/Web/HTML/Element/video

RTCPeerConnection 和 RTCDataChannel

虽然 MediaStream API 用于从用户设备检索视频和音频流,但 RTCPeerConnection 和 RTCDataChannel API 用于建立对等连接并在它们之间传输数据。在我们的视频通话应用程序中,我们将使用 SimpleWebRTC 框架,它将抽象这些 API 并为我们提供一个更简单的对象来与其他设备建立连接。因此,我们不打算深入研究这两个 API。

然而,在使用 WebRTC 时有一件重要的事情要知道。尽管 WebRTC 是为了使设备直接连接而无需任何服务器而创建的,但目前不可能实现这一点,因为要连接到设备,您需要知道设备在互联网上的位置,即设备在互联网上的 IP 地址。但是,一般来说,设备只会知道它们的本地 IP 地址(类似于 192.168.1.x)。公共 IP 地址由防火墙或路由器管理。为了克服这个问题并将确切的 IP 地址发送给其他设备,我们需要信令服务器,例如STUNTURN

设备将向 STUN 服务器发送请求,以检索其公共 IP 地址,并将该信息发送给其他设备。这是广泛使用的,并适用于大多数情况。但是,如果路由器或防火墙的 NAT 服务为设备的每个连接分配不同的端口号,或者设备的本地地址不断变化,那么从 STUN 服务器接收的数据可能不足,因此必须使用 TURN 服务器。TURN 服务器充当两个设备之间的中继,即设备将数据发送到 TURN 服务器,然后 TURN 服务器将数据中继到其他设备。但是,TURN 服务器不像 STUN 服务器那样高效,因为它消耗了大量服务器端资源。

通常会使用ICE实现,它确定两台设备之间是否需要 STUN 或 TURN 服务器(在大多数情况下会选择 STUN,而使用 TURN 作为最后的手段),从而保持连接更有效和稳定。

使用 WebRTC 进行实时通信是一个很大的主题,但如果您有兴趣了解更多关于 WebRTC 的信息,可以访问 WebRTC 的官方网站webrtc.org/,查看一些可用于开始使用 WebRTC 的各种资源。

构建视频通话应用程序

我们将在本章中构建的应用程序是一个简单的视频会议应用程序,您可以在其中创建一个房间,然后将房间 URL 分享给其他人。谁点击 URL 将能够加入通话。对于 UI 部分,我们可以将参与者的视频排列在小框中,当您点击参与者时,我们可以放大视频。这种类型的视频通话应用程序现在广泛使用。以下是应用程序在桌面浏览器上的外观:

蓝色框将显示您的视频,而其他框应显示其他参与者的视频。当参与者数量增加时,行将自动换行到新行(flex-wrap)。在移动设备上,我们可以将视频显示为列而不是行,因为对于较小的屏幕来说,这样会更有效。因此,对于手机,应用程序应如下所示:

这些框只是占位符。对于真实的视频,我们可以使用 margin/padding 在每个视频之间留出间距。此外,为了分享链接,我们可以使用一个点击复制按钮,这将非常用户友好。现在你已经很好地理解了我们要构建的内容,让我们开始吧!

初始项目设置

初始设置与我们在之前的活动注册应用程序中所做的并没有太大的不同。在 VSCode 的Chapter04文件夹中打开起始文件并创建一个.env文件。从.env.example文件中,你应该知道,对于这个应用程序,我们只需要一个环境变量NODE_ENV,其值只在生产环境下为production。对于开发,我们可以简单地为其分配其他值,比如dev

创建了.env文件后,在 VSCode 的终端或本机终端(导航到项目根文件夹)中运行npm install来安装项目的所有依赖项。之后,在终端中运行npm run webpack,这应该会启动 Webpack 开发服务器。

为页面添加样式

你知道如何使用 Webpack 开发服务器。所以,让我们继续添加样式到我们的页面。首先,浏览index.html文件,了解页面的基本结构。

页面的主体分为两个部分:

  • 导航栏

  • 容器

容器进一步分为三个部分:

  1. 首先是create-room-area,其中包含创建具有房间名称的新房间所需的输入字段。

  2. 其次是info-area,其中包含有关房间的信息(房间名称和房间 URL)。它还有两个按钮,用于复制房间 URL(当前使用.hidden Bootstrap 样式类进行隐藏)。

  3. 最后是video-area,用于显示所有参与者的视频。

首先,在src/css/styles.css文件中添加以下代码,以防止容器部分与导航栏重叠:

body {
  padding-top: 65px;
}

启用 Webpack 热重载后,你应该立即看到 CSS 的更改。create-room-area使用默认的 Bootstrap 样式看起来很好。所以,让我们继续进行第二部分,info-area。要处理info-area,暂时从 HTML 中删除.hidden类。还要从两个按钮中删除.hidden,并在段落元素中添加一些文本,其中包含房间 URL。如果房间 URL 和按钮在同一行对齐会很好。为了对齐它们,在styles.css文件中添加以下 CSS:

.room-text {
  display: flex;
  flex-direction: row;
  padding: 10px;
  justify-content: flex-start;
  align-items: center;
  align-content: center;
}
.room-url {
  padding: 10px;
}
.copy {
  margin-left: 10px;
}
.copied {
  margin-left: 10px;
}

对于video-area,视频需要在移动设备上以列的形式排列,而在桌面上应以行的形式排列。因此,我们可以使用媒体查询为其分配不同的样式。此外,对于视频元素(.video-player)的大小,我们可以将max-widthmax-height设置为 25 视口宽度,以使其在所有设备上具有响应性的尺寸。在你的styles.css文件中,添加以下样式:

@media only screen and (max-width: 736px) {
  .video-area {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
  }
}
@media only screen and (min-width: 736px) {
  .video-area {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
  }
}
.video-player {
  max-height: 25vh;
  max-width: 25vh;
  margin: 20px;
}

现在所需的样式就是这些。所以,让我们开始编写应用程序的 JavaScript。

构建视频通话应用程序

一切就绪,让我们开始编码。像以前的应用程序一样,打开你的home.js文件并创建你的Home类和构造函数:

class Home {
  constructor() {

  }
}

之后,创建Home类的一个实例,并将其分配给一个对象home,如下所示:

const home = new Home();

我们以后会用到 home 对象。现在,通过在项目根文件夹的终端中运行以下命令,将SimpleWebRTC包添加到我们的项目中:

npm install -S simplewebrtc

并在你的home.js文件顶部添加以下导入语句:

import SimpleWebRTC from 'simplewebrtc';

根据SimpleWebRTC文档,我们需要创建一个SimpleWebRTC类的实例,并进行一些配置以在我们的应用程序中使用它。在你的home.js文件中,在Home类之前,添加以下代码:

const webrtc = new SimpleWebRTC({
  localVideoEl: 'localVideo',
  remoteVideosEl: '',
  autoRequestMedia: true,
  debug: false,
});

您的应用程序现在应该请求权限来访问摄像头和麦克风。这是因为在幕后,SimpleWebRTC已经开始设置一切需要启动视频通话的工作。如果您点击“允许”,您应该会看到您的视频出现在一个小矩形框中。这就是您在之前的代码中添加的对象中的配置所做的事情:

  • localVideoEl:包含应该包含您本地视频的元素的 ID。在这里,我们的index.html文件中的video#localVideo元素将显示我们自己的视频,因此选择它作为其值。

  • remoteVideosEl:包含需要添加远程视频的容器的 ID。我们还没有创建该元素,最好稍后再添加视频,所以将其留空。

  • autoRequestMedia:用于提示用户允许访问摄像头和麦克风的权限,需要设置为true

  • debug:如果为 true,它将在控制台中打印所有的webrtc事件。我已将其设置为false,但在您的系统上将其设置为 true 以查看事件发生。

默认情况下,SimpleWebRTC使用由 Google 提供的免费 STUN 服务器,即stun.l.google.com:19302。在大多数情况下,这个 STUN 服务器就足够了,除非您身处一些具有复杂路由协议的企业防火墙之后。否则,您可以设置自己的 ICE 配置,包括 STUN 和 TURN 服务器。为此,您需要安装 signalmaster(github.com/andyet/signalmaster),并将 ICE 配置详细信息添加到前面提到的构造函数中。然而,这超出了本书的范围。我们将简单地继续使用默认配置。

对于我们的第一步,我们将在构造函数中创建类变量和对 DOM 元素的引用:

constructor() {
  this.roomName = '';

  this.$createRoomSection = document.querySelector('#createRoomSection');
  this.$createRoomButton = document.querySelector('#createRoom');
  this.$roomNameInput = document.querySelector('#roomNameInput');

  this.$infoSection = document.querySelector('#infoSection');
  this.$roomName = document.querySelector('#roomNameText');
  this.$roomUrl = document.querySelector('#roomUrl');
  this.$buttonArea = document.querySelector('.room-text');
  this.$copy = document.querySelector('.copy');
  this.$copied = document.querySelector('.copied');

  this.$remotes = document.querySelector('.video-area');
  this.$localVideo = document.querySelector('#localVideo');
}

这很多,但它们都是我们应用程序不同步骤所需的。我们在这里创建的唯一变量是roomName,正如其名称所示,它包含了房间的名称。其他的都是对 DOM 元素的引用。

创建房间

该应用的第一步是创建一个房间,以便其他成员可以加入房间进行通话。根据当前的 UI 设计,当用户点击“创建房间”按钮时,我们需要创建房间。因此,让我们在该按钮上注册一个点击事件处理程序。

到目前为止,我们一直在使用不同的方法来处理事件:

  • 在我们的待办事项列表应用程序中,我们在 HTML 中添加了onclick属性来调用 JavaScript 函数的onclick事件。

  • 在 Meme Creator 中,我们为每个元素附加了事件侦听器,我们希望监听特定事件的发生(keyup、change 和 click 事件)。在事件注册表单中也是如此,我们添加了一个事件侦听器来监听表单提交操作。

  • 还有另一种方法,即将回调函数添加到 DOM 元素的引用的事件属性中。在我们的情况下,我们需要检测“创建房间”按钮的点击事件。我们可以这样处理:

this.$createRoomButton.onclick  = () => { }

因此,每当单击“创建房间”按钮时,它将执行前述函数中编写的代码。完全取决于您和您的要求来决定使用哪种事件处理程序。通常,第一种方法会被避免,因为它会将您的 JavaScript 代码暴露在 HTML 中,并且在大型项目中难以跟踪 HTML 中调用的所有 JavaScript 函数。

如果您有大量的元素,比如表格中的 100 行,为每一行附加 100 个事件侦听器是低效的。您可以使用第三种方法,通过将函数附加到每行 DOM 元素的引用的onclick方法,或者您可以将单个事件侦听器附加到行的父元素,并使用该事件侦听器来监听其子元素的事件。

有关所有 DOM 事件的列表,请访问 W3Schools 页面:www.w3schools.com/jsref/dom_obj_event.asp

在我们的应用程序中,我们需要处理很多点击事件。因此,让我们在Home类中创建一个方法来注册所有的点击事件:

registerClicks() {
}

并在构造函数中调用此方法:

constructor() {
  ...
  this.registerClicks();
}

registerClicks()方法中,添加以下代码:

this.$createRoomButton.onclick  = () => { }

当用户单击“创建房间”按钮时,需要执行一些操作:

  • 获取房间名称。但是房间名称不能包含任何会导致 URL 出现问题的特殊字符

  • 使用SimpleWebRTC创建一个房间

  • 将用户重定向到为房间创建的 URL(带有房间名称作为查询字符串的 URL)

  • 显示他/她可以与其他需要参与通话的人分享的 URL

您应该在您在上述代码中创建的onclick方法中编写以下代码:

this.roomName  =  this.$roomNameInput.value.toLowerCase().replace(/\s/g, '-').replace(/[^A-Za-z0-9_\-]/g, '');

这将获取在输入字段中键入的房间名称,并使用正则表达式将其转换为 URL 友好的字符。如果房间名称不为空,我们可以继续在SimpleWebRTC中创建房间:

if(this.roomName) {  webrtc.createRoom(this.roomName, (err, name) => {
    if(!err) {
      // room created
    } else {
      // unable to create room
      console.error(err);
    }
  });
}

上述代码执行以下操作:

  • if条件将检查房间名称是否不为空(空字符串为假)。

  • webrtc.createRoom()将创建房间。它接受两个参数:第一个是房间名称字符串,第二个是在创建房间时执行的回调函数。

  • 回调函数具有参数errname。通常,我们应该检查过程是否成功。因此,if(!err) {}将包含在过程成功时执行的代码。name是由SimpleWebRTC创建的房间名称。

if(!err)条件中,添加以下代码:

const  newUrl  =  location.pathname  +  '?'  +  name; history.replaceState({}, '', newUrl);
this.roomName = name; this.roomCreated();

location对象包含有关当前 URL 的信息。location.pathname用于设置或获取网页的当前 URL。因此,我们可以通过将房间名称附加到其中来构造 URL。因此,如果您当前的 URL 是http://localhost:8080/,那么在创建房间后,您的 URL 应该变为http://localhost:8080/?roomName

要替换 URL 而不影响当前页面,我们可以使用 History Web API 提供的history对象。history对象用于操作浏览器的历史记录。如果要执行用户单击浏览器后退按钮时发生的后退操作,可以按照以下步骤进行:

history.back();

同样,要前进,可以按照以下步骤进行:

history.forward();

但是我们在应用程序中需要做的是在不影响浏览器历史记录的情况下更改当前的 URL。也就是说,我们需要将 URL 从http://localhost:8080/更改为http://localhost:8080/?roomName,而不影响浏览器的后退或前进按钮。

对于这样复杂的操作,您可以使用 HTML5 中引入的pushState()replaceState()方法来处理历史对象。pushState()在浏览器上创建一个新的历史记录条目,并更改页面的 URL,而不影响当前页面。replaceState()也是一样,但是它替换当前条目,非常适合我们的目的。

pushState()replaceState()方法都接受三个参数。第一个是state(一个 JSON 对象),第二个是title(字符串),第三个是新的 URL。这就是pushState()replaceState()的工作原理:

  • 每次调用pushState()replaceState()时,都会触发window对象中的popstate事件。第一个参数,状态对象,由该事件的回调函数使用。我们现在用不到它,所以将其设置为空对象。

  • 目前,大多数浏览器都会忽略第二个参数,所以我们将其设置为空字符串。

  • 第三个参数 URL 是我们真正需要的。它将浏览器的 URL 更改为提供的 URL 字符串。

由于房间已创建并且 URL 已更改,我们需要隐藏.create-room-area div 并显示.info-area div。这就是为什么我添加了this.roomCreated()方法。在Home类中,创建新方法:

roomCreated() {
  this.$infoSection.classList.remove('hidden');
  this.$createRoomSection.classList.add('hidden');
  this.$roomName.textContent = `Room Name: ${this.roomName}`;
  this.$roomUrl.textContent = window.location.href;
}

这个方法将显示信息部分,同时隐藏创建房间部分。此外,它将使用textContent()方法更改房间名称和 URL,该方法更改了相应 DOM 元素中的文本。

有关位置对象的更多信息可以在 w3schools 页面上找到:www.w3schools.com/jsref/obj_location.asp。有关历史对象的更多信息可以在 MDN 页面上找到:developer.mozilla.org/en-US/docs/Web/API/History。此外,如果你想学习如何操纵浏览器历史记录,可以访问developer.mozilla.org/en-US/docs/Web/API/History_API

向你的房间添加参与者

你有一个活跃的房间和房间 URL,你需要邀请其他人。但是如果有一个点击复制功能来复制 URL,那不是更方便吗?这实际上是一个非常好的功能。因此,在我们向房间添加参与者之前,让我们构建一个点击复制功能。

点击复制文本

目前,信息区的外观是这样的:

对于点击复制功能,如果你将鼠标悬停在房间 URL 上,它应该显示一个复制按钮:

如果你点击复制按钮,它应该复制文本并变成已复制按钮:

对于这个功能,我们需要添加一些事件监听器。因此,在你的 home 类中,创建一个新的方法addEventListeners(),并在构造函数中调用它:

class Home {
  constructor() {
    ...
    this.addEventListeners();
  }

  addEventListeners() {
  }
}

包含复制按钮的 div 的引用存储在this.$buttonArea变量中。每当鼠标进入 div 时,它将触发一个mouseenter事件。当这个事件发生在$buttonArea中时,我们需要从复制按钮中移除.hidden类。

在你的addEventListeners()方法中,添加以下代码:

this.$buttonArea.addEventListener('mouseenter', () => {
  this.$copy.classList.remove('hidden');
});

页面将重新加载,你将不得不再次创建一个房间。如果你现在将鼠标指针悬停在房间 URL 上,它应该会显示复制按钮。当指针离开div时,我们还需要隐藏按钮。类似于mouseenter,当指针离开div时,div将触发一个mouseout事件。因此,再次在前面的代码旁边添加以下代码:

this.$buttonArea.addEventListener('mouseout', event => {
  this.$copy.classList.add('hidden');
  this.$copied.classList.add('hidden');
});

现在,再次尝试将鼠标指针悬停在房间 URL 上。令人惊讶的是,它并没有按预期工作。它应该有作用,但它没有。这是因为mouseout事件,当你的指针进入$buttonArea的子元素时,它也会触发。它将子元素视为div外部。为了解决这个问题,我们需要过滤传递给回调函数的event对象,这样如果指针通过进入子元素而移动到外部,就不会发生任何操作。

这个有点棘手,但是如果你在控制台中打印事件对象,你会看到有很多属性和方法包含了事件的所有细节。toElement属性或relatedTarget属性将包含指针移动到的元素,具体取决于浏览器。因此,我们需要检查该元素的父元素是否是$buttonArea。如果是,我们应该阻止任何操作发生。为了做到这一点,将前面的代码更改为以下内容:

this.$buttonArea.addEventListener('mouseout', event => {
  const e = event.toElement || event.relatedTarget;
  if(e) {
    if (e.parentNode == this.$buttonArea || e == this.$buttonArea) {
      return;
    }
  }
  this.$copy.classList.add('hidden');
  this.$copied.classList.add('hidden');
});

注意这一行:

const e = event.toElement || event.relatedTarget;

这是一个短路评估。它的作用是,如果第一个值为真,它将把它赋给常量e。如果它为假,或运算符将评估第二个值,并将其值赋给e。你可以声明任意数量的值。比如:

const fun = false || '' || true || 'test';

在这里,fun 的值将是列表中第一个真值语句,因此它的值将为 true。'test'也是一个真值,但它不会被评估,因为在它之前有一个真值。这种类型的赋值通常被使用,对于某些任务来说非常方便。

现在,e对象包含目标元素。所以,我们只需要检查e是否存在(以防止异常),如果存在,是否其父元素或元素本身是$buttonArea。如果是真的,我们只需返回。这样,回调函数在不隐藏复制和已复制按钮的情况下停止执行。我们也隐藏已复制按钮,因为当用户点击复制按钮时,我们将使其可见。

尝试在应用程序中再次悬停在房间 URL 上,应该按预期工作。最后一步是在用户点击复制按钮时复制 URL。因此,让我们在我们的Home类中早期创建的registerClicks()方法中注册点击。在registerClicks()方法中,添加处理点击复制和已复制按钮的代码,并在Home类中创建一个新方法copyUrl()来执行复制操作:

registerClicks() {
  ...
  this.$copy.onclick = () => {
    this.copyUrl();
  };
  this.$copied.onclick = () => {
    this.copyUrl();
  };
}

copyUrl() {

}

在前面的代码中,在registerClicks()方法中,点击复制按钮和已复制按钮都将调用类的copyUrl()方法。我们需要在copyUrl()方法中添加复制文本的代码。

要复制文本,首先,我们需要从中复制文本的节点(DOM 元素)的范围。为此,创建一个范围对象并选择包含房间 URL 文本的this.$roomUrl节点。在copyUrl()方法中,添加以下代码:

const range = document.createRange();
range.selectNode(this.$roomUrl);

现在,范围对象包含元素$roomUrl作为所选节点。然后,我们需要选择节点中的文本,就像用户通常使用光标选择文本一样。window对象有getSelection()方法,我们可以用于此目的。我们必须删除所有范围以清除先前的选择,然后选择一个新范围(即我们之前创建的范围对象)。在前面的代码中添加以下代码:

window.getSelection().removeAllRanges();
window.getSelection().addRange(range);

最后,我们不知道用户的浏览器是否支持执行复制命令,所以我们在try{} catch(err){}语句中进行复制,以便如果发生任何错误,可以在 catch 语句中处理。document.execCommand('copy')方法将复制所选范围内的文本并将其作为字符串返回。此外,我们需要在复制成功时隐藏复制按钮并显示已复制按钮。复制的代码如下:

try {
  const successful = document.execCommand('copy');
  const msg = successful ? 'successful' : 'unsuccessful';
  console.log('Copying text command was ' + msg);
  this.$copy.classList.add('hidden');
  this.$copied.classList.remove('hidden');
} catch(err) {
  console.error(err);
}

在添加了前面的代码之后,在应用程序中创建一个房间,然后尝试再次点击复制。它应该变成已复制按钮,并且房间 URL 文本将被突出显示,因为我们选择了文本,就像我们用 JavaScript 和光标选择文本一样。但是,一旦复制完成,清除选择会更好。因此,在copyUrl()方法的末尾添加这行:

window.getSelection().removeAllRanges();

这将清除选择,所以下次点击复制时,房间 URL 文本将不会被突出显示。然后,您可以简单地粘贴所选的 URL 到任何您想要分享的地方。

加入房间

现在您有了一个链接,我们需要让用户使用该链接加入房间。这个过程很简单:当用户打开链接时,他会加入房间,并且所有参与者的视频都会显示给他。要让用户加入房间,SimpleWebRTCjoinRoom('roomName')方法,其中房间名称字符串作为参数传递。一旦用户在房间里,它将寻找房间中连接的其他用户的视频,并为它找到的每个视频触发videoAdded事件,以及一个回调函数,其中包含视频对象和该用户的对等对象。

让我们制定一下过程应该如何工作:

  • 首先,我们需要检查用户输入的 URL 是否在其查询字符串中包含房间名称。也就是说,如果以'?roomName'结尾。

  • 如果房间名称存在,那么我们应该让用户加入房间,同时隐藏.create-room-area div,并显示.info-area div 以显示房间详情。

  • 然后,我们需要监听videoAdded事件,如果触发了事件,我们将视频添加到.video-area div中。

SimpleWebRTC在加载完成后会触发readyToCall事件。它还有on()方法来监听触发的事件。我们可以使用readyToCall事件来检查 URL 中的房间名称。这段代码应该在Home类之外。因此,在调用Home类构造函数的那一行之后,添加以下代码:

webrtc.on('readyToCall', () => {
  if(location.search) {
    const locationArray = location.search.split('?');
    const room = locationArray[1];
  }
});

我们使用 location 对象来获取 URL。首先,我们需要检查 URL 是否包含查询字符串,使用location.search。因此,我们在 if 条件中使用它,如果它包含查询字符串,我们可以继续进行处理。

split()方法将字符串拆分为由传递给它的值分隔的子字符串数组。URL 将如下所示:

http://localhost:8080/?myRoom

location.search将返回 URL 的查询字符串部分:

'?myRoom'

因此,location.search.split('?')将把字符串转换为以下数组:

[ '', 'myRoom']

我们在数组的索引 1 处有房间名称。像这样写是可以的,但是在这里我们可以使用短路评估。我们之前使用了 OR 运算符进行评估,它将获取第一个真值。在这种情况下,我们可以使用 AND 运算符,它将获取第一个假值,或者如果没有假值,则获取最后一个真值。上述代码将简化为以下形式:

webrtc.on('readyToCall', () => {
  const room = location.search && location.search.split('?')[1];
});

如果 URL 不包含查询字符串,location.search将是一个空字符串(""),这是一个假值。因此,房间的值将是一个空字符串。

如果 URL 包含带有房间名称的查询字符串,那么location.search将返回'?roomName',这是一个真值,所以下一个语句location.search.split('?')[1]将被评估,它执行分割并返回数组中的第一个索引(房间名称)。由于它是最后一个真值,room 常量现在将包含房间名称字符串!我们使用短路评估将三行代码简化为一行代码。

关于短路评估的详细信息可以在以下网址找到:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_Operators#Short-circuit_evaluation

设置器和获取器

我们只需要添加一行代码来让用户加入房间:

webrtc.joinRoom(room);

这将使用户加入房间,但是一旦用户进入房间,我们需要隐藏.create-room-area div 并显示.info-area div。这些都在Home类的roomCreated()方法中。但是该方法依赖于this.roomName类变量,该变量应该包含房间名称。因此,我们需要更新一个类变量并从类外部调用class方法。

尽管我们可以使用之前创建的home对象来做到这一点,但如果我们只能更新类的 room 属性并且它将自动执行操作,那将更有意义。为此,我们可以使用设置器。设置器是用于为对象的属性分配新值的特殊方法。我们以前已经多次使用过获取器和设置器。还记得我们如何获取输入字段的值吗?

const inputValue = this.$roomNameInput.value

在这里,值属性是一个获取器。它从$roomNameInput对象返回一个值。但是,如果我们这样做:

$roomNameInput.value = 'New Room Name'

然后,它将把输入字段的值更改为'New Room Name'。这是因为值现在充当设置器,并更新了$roomNameInput对象内的属性。

我们将为我们的Home类创建一个设置器来加入一个房间。创建一个设置器很简单;我们只需创建一个以set关键字为前缀的方法,该方法应该有正好一个参数。在您的Home类中,添加以下代码:

set room(room) {
  webrtc.joinRoom(room);
  this.roomName = room;
  this.roomCreated();
}

现在,在您的readyToCall事件处理程序中使用设置器(仅当房间不是空字符串时):

const home = new Home();

webrtc.on('readyToCall', () => {
  const room = location.search && location.search.split('?')[1];
  if(room) home.room = room;
});

添加代码后,在视频通话应用中创建一个房间,然后复制 URL 并粘贴到新标签中。它应该会自动从 URL 获取房间名称并加入房间。如果您能看到房间信息,那么您就可以开始了。我们正接近应用程序的最后阶段--添加和删除视频。

如果和 else 条件后面只有一个语句,不需要{}大括号。也就是说,if (true) console.log('true'); else console.log('false');将正常工作!但应该避免这样做,因为最好始终使用带有{}大括号的if else条件。

要创建一个 getter,您只需在方法前面加上get而不是set,但是该方法不应包含参数,并且应返回一个值。比如,在您的Home类中,您需要使用 getter 知道房间名称。然后,您可以添加以下方法:

get room() {
  return this.roomName;
}

如果您尝试在类外部使用console.log(home.room),您应该会得到存储在roomName类变量中的值。

添加和删除视频

类似于readyToCall事件,SimpleWebRTC将为在房间中找到的每个视频触发videoAdded事件,具有具有视频对象和包含 ID(该用户的唯一 ID)的对等对象的回调函数。

为了测试多个视频,我们将在同一系统的同一浏览器中打开两个标签。这可能会导致反馈损坏您的音频设备,所以保持音量静音!

Home类中创建一个新方法addRemoteVideo($video, peer),如下所示:

class Home {
  ...
  addRemoteVideo($video, peer) {
  }
}

让我们为videoAdded事件添加另一个事件处理程序,就像我们为readToCall事件所做的那样:

webrtc.on('videoAdded', ($video, peer) =>  home.addRemoteVideo($video, peer)); 

每当添加视频时,它将调用Home类的addRemoteVideo方法,并传入视频对象和对等对象。我们有一个.video-area的 div,它应该包含所有的视频。因此,我们需要构建一个类似于用于本地视频的新 div 元素,例如:

<div class="video-container" id="container_peerid">
  <video class="video-player"></video>
</div>

然后,我们应该将此元素附加到.video-area div,它当前由this.$remotes变量引用。这很简单,就像我们在上一章中添加script元素一样。在您的addRemoteVideo()方法中,添加以下代码:

addRemoteVideo($video, peer) {
  const $container = document.createElement('div');
  $container.className = 'video-container';
  $container.id = 'container_' + webrtc.getDomId(peer);

  $video.className = 'video-player';

  $container.appendChild($video);

  this.$remotes.appendChild($container);
}

上述代码执行以下操作:

  • 首先,我们使用document.createElement('div')方法创建一个div元素,并将其分配给$container对象。

  • 然后,我们将$container的类名设置为'video-container',ID 设置为'container_peerid'。我们可以使用webrtc.getDomId()方法从我们收到的对等对象中获取对等 ID。

  • 我们收到的$video对象是一个 HTML 元素,就像$container一样。因此,我们将其分配为类名'video-player'

  • 然后,作为最后一步,我们将$video作为子元素附加到$container中,最后将$container作为子元素附加到this.$remotes中。

这将使用类和 ID 构造我们需要的 HTML。当用户离开房间时,将触发videoRemoved事件,这类似于videoAdded事件。每当用户离开房间时,我们需要使用对等 ID 来删除包含 ID'container_peerid'的 div,其中peerid是离开的用户的 ID。为此,请添加以下代码:

class Home {
  ...

  removeRemoteVideo(peer) {
    const $removedVideo = document.getElementById(peer ? 'container_' + webrtc.getDomId(peer) : 'no-video-found');
    if ($removedVideo) {
      this.$remotes.removeChild($removedVideo);
    }
  }

}
...

webrtc.on('videoRemoved', ($video, peer) => home.removeRemoteVideo(peer));

removeRemoteVideo()方法将使用对等 ID 查找包含远程视频的 div,并使用removeChild()方法从this.$remotes对象中删除它。

是时候测试我们的视频通话应用了。在 Chrome 中打开应用程序并创建房间。复制房间 URL 并粘贴到新标签中(保持音量静音!)。可能需要几秒钟,但除非 STUN 对您不起作用,否则您应该在每个标签中看到两个视频。您正在在标签之间传输视频。

第一个视频是你的视频。如果你关闭其中一个标签,你会看到第二个视频会从另一个标签中移除。在我们在其他设备上测试这个应用之前,还有一个功能会让这个应用看起来更棒。那就是增加所选视频的大小。

选择视频

目前,所有的视频都很小。因此,我们需要一个功能来放大视频,比如:

  • 在桌面上,点击视频将增加视频的大小,并将其移动到视频列表的第一个位置

  • 在手机上,点击视频只会增加视频的大小

这听起来不错。为了实现这一点,让我们在我们的styles.css文件中添加一些样式:

@media only screen and (max-width: 736px) {
  .video-selected {
    max-height: 70vw;
    max-width: 70vw;
  }
}
@media only screen and (min-width: 736px) {
  .video-selected {
    max-height: 50vh;
    max-width: 50vh;
  }
  .container-selected {
    order: -1;
  }
}

我们使用媒体查询添加了两组样式。一组用于手机(max-width: 736px),另一组用于桌面(min-width: 736px)。

对于每次点击视频,我们应该为该视频添加.video-selected类,并为该视频的父 div 添加.container-selected类:

  • 在手机上,它将把视频的大小增加到视口宽度的 70%。

  • 在桌面上,它将把大小增加到视口宽度的 50%,并且还会给其父 div 分配order: -1。这样,由于父 div 是 flex 的一部分,它将成为 flex 元素的第一个项目(但其他元素不应该在其样式中包含 order)。

在你的Home类中,添加以下方法:

clearSelected() {
  let $selectedVideo = document.querySelector('.video-selected');
  if($selectedVideo) {
    $selectedVideo.classList.remove('video-selected');
    $selectedVideo.parentElement.classList.remove('container-selected');
  }
}

这将找到包含.video-selected类的视频,并从该视频和该视频的父 div 中移除.video-selected类和.container-selected类。这很有用,因为我们可以在选择另一个视频之前调用它来清除已选择的视频。

我们可以在registerClicks()方法中为本地视频注册点击事件。在registerClicks()方法中,添加以下代码:

this.$localVideo.onclick = () => {
  this.clearSelected();
  this.$localVideo.parentElement.classList.add('container-selected');
  this.$localVideo.classList.add('video-selected');
};

这将为视频元素及其父级 div 添加所需的类。对于远程视频,我们不能在这里注册点击,因为我们动态创建这些元素。因此,我们要么创建一个事件监听器,要么在创建远程视频元素时注册点击事件。

在这里为每个视频创建一个事件监听器并不太有效,因为当用户离开时,视频将被移除,所以我们将有不需要的事件监听器运行在每个视频上。我们将不得不使用removeEventListener()方法来移除这些事件监听器,或者通过在父 div.video-area上创建一个事件监听器来避免这种情况。不过,这意味着我们需要筛选.video-area内的每次点击,以检查该点击是否是在视频上进行的。

显然,当视频元素被创建时,使用onclick()方法注册点击更简单。这样可以避免处理事件监听器的麻烦。在你的addRemoteVideo()方法中,在现有代码之后添加以下代码:

$video.onclick = () => {
  this.clearSelected();
  $container.classList.add('container-selected');
  $video.classList.add('video-selected');
};

现在尝试在 Chrome 中点击视频。你应该看到视频会增大并移动到列表的第一个位置。恭喜!你已经成功构建了你的视频通话应用!是时候测试视频通话了。

视频通话

你已经准备好应用程序了,所以让我们在本地测试一下。首先,为你的应用生成生产构建。你之前在事件注册应用中已经做过这个了。你需要在你的.env文件中设置NODE_ENV=production

之后,在你的项目根目录中,关闭 Webpack 开发服务器,运行npm run webpack命令。它应该会为你的 JS 和 CSS 文件生成生产构建。文件名将在dist/manifest.json文件中。在你的index.html页面中包含这些 CSS 和 JS 文件。

现在,在您的项目根文件夹中运行http-server。它应该打印出两个 IP 地址。在浏览器中打开以 192 开头的那个。这个 IP 地址对您局域网中的所有设备都是可访问的,除非您使用防火墙阻止了端口。然而,Chrome 将无法显示您的视频!这是因为getUserMedia()方法只能在本地主机和 HTTPS URL 中工作。由于我们的本地地址只使用 HTTP,视频将无法工作。

我们可以通过在公共服务器上部署我们的 WebRTC 应用程序并使用来自证书颁发机构的 SSL 证书来添加 HTTPS。然而,对于我们的本地开发环境,我们可以使用自签名证书。来自证书颁发机构的 SSL 证书将受到所有浏览器的信任,但自签名证书将不受信任,因此会显示警告,我们应该在浏览器上手动选择信任该网站的选项。因此,自签名证书不适用于生产,只应用于开发目的。

创建自签名证书是一个复杂的过程,但幸运的是,有一个npm包可以在一行命令中完成这个过程。我们需要全局安装这个包,因为它像http-server一样是一个命令行工具。在您的终端中运行以下命令:

npm install -g local-ssl-proxy

Linux 用户可能需要在他们的命令中添加sudo以全局安装软件包。默认情况下,http-server将从端口号8080提供您的文件。比如,如果您当前的 URL 如下:

http://192.168.1.8:8080

然后,打开另一个终端并运行以下命令:

local-ssl-proxy --source 8081 --target 8080

在这里,源是新的端口号,目标是 http-server 正在运行的端口号。然后,您应该在 Chrome 中使用新的端口号和https://前缀打开相同的 IP 地址,如下面的代码块所示:

https://192.168.1.8:8081

如果您在 Chrome 中打开此页面,您应该会收到类似以下截图的警告。在这种情况下,请选择高级,如下图所示:

点击高级后,您将看到一个类似以下图像的页面,您应该点击继续链接:

您现在可以使用这个 HTTPS URL 在连接到您的局域网的任何设备上打开应用程序。确保设备之间有足够的距离,以免造成反馈。

总结

希望您在构建视频通话应用程序时度过了愉快的时光。在本章中,我们使用 JavaScript 做了一些新的事情,并学习了一些新概念,如 JavaScript WebRTC API 和 SimpleWebRTC 框架。在这个过程中,我们做了很多很酷的事情,比如操纵浏览器历史记录,使用 JavaScript 选择文本,以及处理 URL。此外,我们使用短路评估缩短了一些代码,并学习了在 JavaScript 中操纵类变量的设置器和获取器。

SimpleWebRTC还带有许多其他事件和操作,允许您在应用程序中执行更多操作,例如静音麦克风,静音其他人的音频等。如果您感兴趣,可以查看 SimpleWebRTC 主页获取更多示例。

我们知道如何创建可重用的 JavaScript 模块,这是我们在上一章中做的。在下一章中,我们将进一步迈出一步,使用 Web 组件构建我们自己的可重用的 HTML 元素。

第五章:开发天气小部件

嘿!视频通话应用做得不错。希望你已经给你的朋友打了一些电话。在上一章中,我们使用了 SimpleWebRTC 框架构建了一个视频通话应用。知道你可以用 JavaScript 构建所有这些酷炫的应用程序真是太棒了,你可以直接从浏览器访问用户设备的硬件。

到目前为止,你一直在独自构建整个应用程序,所以你对应用程序的结构有完整的了解,比如在 HTML 和 CSS 中使用的类和 ID,以及在 JavaScript 中使用的类、函数和服务。但在现实世界中,你很少是独自工作。如果有的话,你会在由几名成员到数百名开发人员组成的团队中工作。在这种情况下,你将不会对整个 Web 应用程序有完整的了解。

你能构建一个天气小部件吗?

所以,你的项目有大约 40 名开发人员在 Web 应用程序的不同部分工作,突然出现了一个新的需求。他们需要在网站的某些区域显示一个天气小部件。天气小部件需要是响应式的,这样它就可以适应 Web 应用程序的任何部分中的任何可用空间。

我们当然可以构建一个天气小部件,但有一个问题。我们对 Web 应用程序的其余部分一无所知!例如,它的 HTML 中使用了哪些类和 ID,因为 CSS 创建的样式总是全局的。如果我们不小心使用了在 Web 应用程序的其他部分中已经使用的类,我们的小部件将继承该 DOM 元素的样式,这是我们真的需要避免的!

另一个问题是我们将创建<div>。例如:

<div class="weather-container">
  <div class="temperature-area">
  ....
  </div>
  <div>...</div>
  <div>...</div>
  <!-- 10 more divs -->
</div>

除了 CSS 文件和一些 JS 文件,我们还需要所有必要的逻辑来使我们的小部件工作。但是我们要如何将它交付给团队的其他成员呢(假设我们没有希望在小部件中重用任何其他 Web 应用程序中使用的类名或 ID)?

如果它是一个简单的 JavaScript 模块,我们只需构建一个 ES6 模块,团队可以导入和使用,因为 ES6 模块中的变量作用域不会泄漏(你应该只使用letconst;你真的不想意外地使用var创建全局变量)。但对于 HTML 和 CSS 来说情况就不同了。它们的作用域总是全局的,它们总是需要小心处理(你不希望团队中的其他人意外地篡改你的小部件)!

所以,让我们开始吧!我们将考虑一些真正随机(而且酷!)的类名和 ID,用于 DOM 元素,你的团队中没有人能想到,然后编写一个 10 页的readme文件,记录天气小部件的工作原理,包括所有的注意事项,然后在我们对小部件进行一些增强和错误修复时,花时间仔细更新readme文件。还要记住所有的类名和 ID!

关于最后一段,不!我们绝对不会这样做!我已经开始想象了!相反,我们将学习 web 组件,并编写一个简单的 ES6 模块,应该由你的团队成员导入和使用,然后他们应该在他们的 HTML 文件中简单地添加以下 DOM 元素:

<x-weather></x-weather>

就是这样!你需要构建一个 DOM 元素(比如<input><p><div>元素),它将显示一个天气小部件。x-weather是一个新的 HTML5 自定义元素,我们将在本章中构建它。它将克服我们在之前方法中可能遇到的所有问题。

介绍 web 组件

Web 组件是一组可以一起或分开使用的四种不同技术,用于构建可重用的用户界面小部件。就像我们可以使用 JavaScript 创建可重用模块一样,我们可以使用 Web 组件技术创建可重用的 DOM 元素。构成 Web 组件的四种技术是:

  • 自定义元素

  • HTML 模板

  • 影子 DOM

  • HTML 导入

Web 组件是为开发人员提供简单 API 以构建高度可重用 DOM 元素而创建的。有许多 JavaScript 库和框架专注于通过将整个 Web 应用程序组织成更简单的组件来提供可重用性,例如 React、Angular、Vue、Polymer 等。在下一章中,我们将通过组合多个独立的 React 组件来构建整个 Web 应用程序。然而,尽管所有可用的框架和库,Web 组件具有很大的优势,因为它们受到浏览器的本地支持,这意味着不需要额外的库来增加小部件的大小。

对于我们的小部件,我们将使用自定义元素和影子 DOM。在开始构建小部件之前,让我们快速了解其他两个,这两个在本章中不会使用。

Web 组件是一个新的标准,所有浏览器供应商都在积极实施。然而,在撰写本书时,只有 Chrome 支持 Web 组件的所有功能。如果要检查浏览器是否支持 Web 组件,请访问:jonrimmer.github.io/are-we-componentized-yet/

在本章的项目中,您应该只使用 Chrome,因为其他浏览器尚未完全支持 Web 组件。在本章结束时,我们将讨论如何添加 polyfill 以使 Web 组件在所有浏览器中工作。

HTML 模板

HTML 模板是一个简单的<template>标签,我们可以将其添加到我们的 DOM 中。但是,即使将其添加到我们的 HTML 中,<template>元素的内容也不会被呈现。如果它包含任何外部资源,例如图像、CSS 和 JS 文件,它们也不会加载到我们的应用程序中。

因此,模板元素只包含一些 HTML 内容,稍后可以由 JavaScript 使用。例如,假设您有以下模板元素:

<template id="image-template">
  <div>
    <h2>Javascript</h2>
    <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/4621/javascript.png" alt="js-logo" style="height: 50px; width: 50px;">
  </div>
</template>

此元素包含浏览器不会呈现的div。但是,我们可以使用 JavaScript 创建该div的引用,如下所示:

const $template = document.querySelector('#image-template');

现在,我们可以对此引用进行任何更改,并将其添加到我们的 DOM 中。更好的是,我们可以对此元素进行深层复制,以便我们可以在多个地方使用它。深层复制是对象的副本,对副本的更改不会反映在原始对象中。默认情况下,当我们使用=运算符进行赋值时,JavaScript 总是对对象进行浅层复制。$template是 DOM 元素的浅层复制,我们称之为对 DOM 元素的引用。因此,对$template的任何更改都会反映在 DOM 中。但是,如果我们对$template进行深层复制,那么对该深层复制的更改将不会反映在 DOM 中,因为它不会影响$template

要对 DOM 元素进行深层克隆,我们可以使用document.importNode()方法。它接受两个参数:第一个是它需要克隆的 DOM 元素,第二个是一个布尔值,用于指定是否需要进行深层复制。如果第二个参数为 true,则它将对元素进行深层复制。请参阅以下代码:

const $javascript = document.importNode($template.content, true);
$body.appendChild($javascript);

在这里,我对模板元素($template.content)的内容进行了深层复制,并将$javascript添加到了 DOM 元素。对$javascript的任何修改都不会影响$template

有关更详细的示例,我在 JSFiddle 上设置了一个示例:jsfiddle.net/tgf5Lc0v/。请查看它,以查看模板元素的工作方式。

HTML 导入

HTML 导入很简单。它们允许你以与包含 CSS 和 JS 文件相同的方式在一个 HTML 文档中导入另一个 HTML 文档。导入语句如下所示:

<link rel="import" href="file.html">

在我们不使用 Webpack 等构建工具的环境中,HTML 导入有很多好处;例如,为跨 Web 应用程序使用 Web 组件提供了便利。

有关使用 HTML 导入功能的更多信息,请参考 html5rocks 教程:www.html5rocks.com/en/tutorials/webcomponents/imports/

我们不使用 HTML 模板和 HTML 导入在我们的天气小部件中的主要原因是它们更专注于与 HTML 文件一起使用。我们将在本章中使用的构建系统(Webpack)更适合 JavaScript 文件。因此,我们将继续学习本章的其余部分,了解自定义元素和影子 DOM。

构建天气小部件

在本章中,我们需要一个服务器来获取给定位置的天气信息。在浏览器中,我们可以使用 navigator 对象来检索用户的确切地理位置(纬度经度)。然后,使用这些坐标,我们需要找到该地区的名称和其天气信息。为此,我们需要使用第三方天气提供商和我们在第三章,事件注册应用中使用的谷歌地图 API。我们在这个项目中将使用的天气提供商是Dark Sky

让我们为天气小部件设置服务器。打开书中代码的Chapter05\Server目录。在服务器目录内,首先运行npm install来安装所有依赖项。你需要获取 Dark Sky 和谷歌地图的 API 密钥。你可能已经有了谷歌地图 API 密钥,因为我们最近使用过它。为了为这两项服务生成 API 密钥,执行以下操作:

一旦你获得了这两个密钥,就在Server根目录内创建一个.env文件,并以以下格式将密钥添加到其中:

DARK_SKY_KEY=DarkSkySecretKey
GMAP_KEY=GoogleMapAPIKey

添加完密钥后,从Server根目录在终端中运行npm start来启动服务器。服务器将在http://localhost:3000/ URL 上运行。

我们已经准备好服务器。让我们为项目设置起始文件。打开Chapter05\Starter文件夹,然后在该目录内运行npm install来安装所有依赖项。在项目根目录中创建一个.env文件,并在其中添加以下行:

NODE_ENV=dev
SERVER_URL=http://localhost:3000

就像我们在上一章中所做的那样,我们应该设置NODE_ENV=production来生成生产构建。SERVER_URL将包含我们刚刚设置的项目服务器的 URL。NODE_ENVSERVER_URL将作为全局变量在我们应用程序的 JavaScript 代码中可用(我已经在webpack.config.js中使用了 Webpack 定义的插件)。

最后,在终端中执行npm run watch来启动 Webpack 开发服务器。你的项目将在http://localhost:8080/上运行(项目 URL 将在终端中打印出来)。目前,Web 应用将显示三个文本:大、中、小。它有三个不同大小的容器,将容纳天气小部件。项目结束时,天气小部件将如下所示:

天气小部件的工作

让我们规划一下我们的天气小部件的工作。由于我们的天气小部件是一个 HTML 自定义元素,它应该像其他原生 HTML 元素一样工作。例如,考虑<input>元素:

<input type="text" name="username">

这将呈现一个普通的文本输入。但是,我们可以使用相同的<input>元素,具有不同的属性,如下所示:

<input type="password" name="password">

它将呈现一个密码字段,而不是将所有输入文本内容隐藏的文本字段。同样,对于我们的天气小部件,我们需要显示给定位置的当前天气状况。确定用户位置的最佳方法是使用 HTML5 地理位置,它将直接从浏览器中获取用户当前的纬度和经度信息。

但是,我们应该使我们的小部件可定制给其他开发人员。他们可能希望手动为我们的天气小部件设置位置。因此,我们将把检索位置的逻辑留给其他开发人员。相反,我们可以手动接受纬度经度作为天气小部件的属性。我们的天气元素将如下所示:

<x-weather latitude="40.7128" longitude="74.0059" />

现在,我们可以从各自的属性中读取纬度经度,并在我们的小部件中设置天气信息,其他开发人员可以通过简单地更改纬度经度属性的值来轻松定制位置。

检索地理位置

在开始构建小部件之前,让我们看一下检索用户地理位置的步骤。在您的src/js/home.js文件中,您应该看到一个导入语句,该语句将 CSS 导入 Web 应用程序。在该导入语句下面,添加以下代码:

window.addEventListener('load', () => {
  getLocation();
});

function getLocation() {
}

当页面加载完成时,这将调用getLocation()函数。在此函数内部,我们必须首先检查浏览器中是否可用navigator.geolocation方法。如果可用,我们可以使用navigator.geolocation.getCurrentPosition()方法来检索用户的地理位置。此方法接受两个函数作为参数。当成功检索位置时,将调用第一个函数,如果无法检索位置,则调用第二个函数。

在您的home.js文件中,添加以下函数:

function getLocation() {
  if (navigator.geolocation) {
    navigator.geolocation.getCurrentPosition(showPosition, errorPosition);
  } else {
    console.error("Geolocation is not supported by this browser.");
  }
}

function showPosition(position) {
  const latitude = position.coords.latitude;
  const longitude = position.coords.longitude;

  console.log(latitude);
  console.log(longitude);
}

function errorPosition(error) {
  console.error(error);
}

在 Chrome 中打开应用程序。页面应该要求您允许访问您的位置,就像在上一章中访问摄像头和麦克风一样。如果单击“允许”,您应该在 Chrome 的控制台中看到您当前的纬度经度

上述代码执行以下操作:

  • 首先,getLocation()函数将使用navigator.getlocation.getCurrentPosition(showPosition, errorPosition)方法获取用户的位置。

  • 如果页面请求权限时单击“允许”,则会调用showPosition函数,并将position对象作为参数。

  • 如果您单击“Block”,则会调用errorPosition函数,并将error对象作为参数。

  • position对象包含用户的纬度和经度,位于position.coords属性中。此函数将在控制台中打印纬度和经度。

有关使用地理位置的更多信息,请参阅 MDN 页面:developer.mozilla.org/en-US/docs/Web/API/Geolocation/Using_geolocation

创建天气自定义元素

我们已经获得了地理位置。因此,让我们开始创建自定义元素。当前,您的文件夹结构将如下所示:

.
├── index.html
├── package.json
├── src
│   ├── css
│   │   └── styles.css
│   └── js
│       └── home.js
└── webpack.config.js

我们希望保持我们的自定义元素独立于其他 JavaScript 模块。在src/js目录中,创建一个文件,路径为CustomElements/Weather/Weather.js。请注意,我在文件夹和文件名(PascalCase)中使用了大写字母。您可以对文件和文件夹使用 PascalCase,这将导出整个类。这仅用于在项目文件夹中轻松识别类,并不需要严格遵循规则。

现在,您的文件夹结构将变为:

.
├── index.html
├── package.json
├── src
│   ├── css
│   │   └── styles.css
│   └── js
│       ├── CustomElements
│       │   └── Weather
│       │       └── Weather.js
│       └── home.js
└── webpack.config.js

在 VSCode 中打开Weather.js文件。所有原生 HTML 元素都是使用HTMLElement类(接口)直接实现的,或者通过继承它的接口实现的。对于我们的自定义天气元素,我们需要创建一个扩展HTMLElement的类。通过扩展一个类,我们可以继承父类的属性和方法。在您的Weather.js文件中,编写以下代码:

class Weather extends HTMLElement {

}

根据自定义元素 v1 规范,自定义元素应该直接从HTMLElement扩展,只使用一个类。然而,我们正在使用带有env预设的babel-loader,它将所有类转换为函数。这将导致自定义元素出现问题,因为它们需要是类。但是有一个插件可以用来解决这个问题:transform-custom-element-classes。我已经在您的webpack.config.js文件中添加了这个插件,这样您在本章中就不会遇到任何问题。您可以在 Webpack 配置文件的.js规则部分找到它。

让我们在Weather类的构造函数中声明初始类变量:

Class Weather extends HTMLElement {

  constructor() {
    super();

    this.latitude = this.getAttribute('latitude');
    this.longitude = this.getAttribute('longitude');
  }

}

请注意,在构造函数的第一行,我调用了super()方法。这将调用父类HTMLElement的构造函数。每当您的类扩展另一个类时,始终在您的类构造函数中添加super()。这样,父类在您的类方法开始工作之前也会被初始化。

两个类变量(属性)this.latitudethis.longitude将使用this.getAttribute()方法从自定义天气元素的latlong属性中获取值。

我们还需要为我们的自定义元素添加 HTML。由于Weather类类似于我们之前使用的 DOM 元素的引用,this.innerHTML可用于为天气元素添加 HTML。在构造函数中,添加以下行:

this.innerHTML = ` `;

现在,this.innerHTML是一个空的模板字符串。我已经创建了自定义元素所需的 HTML 和 CSS。您可以在书籍代码的Chapter 05\WeatherTemplate目录中找到它。复制weather-template.html文件的内容,并将其粘贴到模板字符串中。

测试自定义元素

我们的自定义元素现在包含了显示内容所需的 HTML。让我们来测试一下。在您的Weather.js文件末尾,添加以下行:

export default Weather;

这将导出整个Weather类,并使其可用于在其他模块中使用。我们需要将其导入到我们的home.js文件中。在您的home.js文件中,在顶部添加以下代码:

import Weather from './CustomElements/Weather/Weather';

接下来,我们需要定义自定义元素,也就是将自定义元素与标签名称关联起来。理想情况下,我们想要称呼我们的元素为<weather>。这样会很好!但根据自定义元素规范,我们应该给元素命名,使其在名称中有一个破折号-。因此,为了简单起见,我们将我们的元素称为<x-weather>。这样,每当我们看到一个以x-为前缀的元素,我们立刻知道它是一个自定义元素。

customElements.define()方法用于定义自定义元素。customElements可用于全局window对象。它接受两个参数:

  • 第一个参数是一个字符串,应该包含自定义元素名称

  • 第二个参数应该包含实现自定义元素的类

home.js中添加的用于获取地理位置的窗口加载事件侦听器的回调函数中,添加customElements.define('x-weather', Weather)window.addEventListener现在将如下所示:

window.addEventListener('load', () => {
  customElements.define('x-weather', Weather);
  getLocation();
});

让我们将自定义元素添加到我们的index.html文件中。在您的index.html文件中,在div.large-container元素内添加以下行:

<x-weather />

由于这是 HTML 文件的更改,您必须在 Chrome 中手动重新加载页面。现在,您应该会得到一个显示加载消息的天气小部件,如下所示:

如果您使用 Chrome DevTools 检查元素,它应该结构如下:

如您所见,您的 HTML 现在附加在自定义元素内部,以及样式。但是,我们在这里面临一个严重的问题。样式的范围始终是全局的。这意味着,如果有人在页面的 CSS 中为.title类添加样式,比如color: red;,它也会影响我们的天气小部件!或者,如果我们在小部件内部添加样式到页面中使用的任何类,比如.large-container,它将影响整个页面!我们真的不希望发生这种情况。为了解决这个问题,让我们学习 Web 组件的最后一个剩下的主题。

附加影子 DOM

影子 DOM 提供了 DOM 和 CSS 之间的封装。影子 DOM 可以附加到任何元素,附加影子 DOM 的元素称为影子根。影子 DOM 被视为与 DOM 树的其余部分分开;因此,影子根外部的样式不会影响影子 DOM,反之亦然。

要将影子 DOM 附加到元素,我们只需要在该元素上使用attachShadow()方法。看下面的例子:

const $shadowDom = $element.attachShadow({mode: 'open'});
$shadowDom.innerHTML = `<h2>A shadow Element</h2>`;

在这里,首先,我将一个名为$shadowDom的影子 DOM 附加到$element。之后,我向$shadowDom添加了一些 HTML。请注意,我在attachShadow()方法中使用了参数{mode: 'open'}。如果使用{mode: 'closed'},则无法从 JavaScript 中的影子根访问影子 DOM,其他开发人员将无法使用 JavaScript 从 DOM 中操作我们的元素。

我们需要开发人员使用 JavaScript 来操作我们的元素,以便他们可以为天气小部件设置地理位置。通常,广泛使用开放模式。仅当您希望完全阻止其他人对您的元素进行更改时,才使用关闭模式。

要将影子 DOM 添加到我们的自定义天气元素,请执行以下步骤:

  1. 将影子 DOM 附加到我们的自定义元素。这可以通过在构造函数中添加以下行来完成:
this.$shadowRoot = this.attachShadow({mode: 'open'});
  1. this.innerHTML替换为this.$shadowRoot.innerHTML,您的代码现在应如下所示:
this.$shadowRoot.innerHTML = ` <!--Weather template> `;
  1. 在 Chrome 中打开页面。您应该看到相同的天气小部件;但是,如果使用 Chrome DevTools 检查元素,则 DOM 树将结构如下:

你可以看到<x-weather>元素的内容将通过将x-weather指定为影子根与 DOM 的其余部分分离。此外,天气元素内部定义的样式不会泄漏到 DOM 的其余部分,而影子 DOM 外部的样式也不会影响我们的天气元素。

通常,要访问元素的影子 DOM,可以使用该元素的shadowRoot属性。例如:

const $weather = document.querySelector('x-weather');
console.log($weather.shadowRoot);

这将在控制台中打印附加到影子根的整个影子 DOM。但是,如果您的影子根是closed,那么它将简单地打印null

使用自定义元素

我们现在已经准备好了天气小部件的 UI。我们的下一步是从服务器检索数据并在天气小部件中显示它。通常,小部件,例如我们的天气小部件,不会直接出现在 HTML 中。就像我们第一章中的任务构建待办事项列表一样,开发人员通常会从 JavaScript 中创建元素,附加属性,并将其附加到 DOM 中。此外,如果他们需要进行任何更改,例如更改地理位置,他们将使用 JavaScript 中对元素的引用来修改其属性。

这是非常常见的,我们在所有项目中都以这种方式修改了许多 DOM 元素。现在,我们的自定义天气元素也将期望同样的行为。我们从中扩展了我们的Weather类的HTMLElement接口为我们的Weather类提供了称为生命周期回调的特殊方法。生命周期回调是在发生某个事件时调用的方法。

对于自定义元素,有四个生命周期回调方法可用:

  • connectedCallback(): 当元素插入 DOM 或影子 DOM 时调用此方法。

  • attributeChangedCallback(attributeName, oldValue, newValue, namespace): 当元素的观察属性被修改时调用此方法。

  • disconnectedCallback(): 当元素从 DOM 或影子 DOM 中移除时调用此方法。

  • adoptedCallback(oldDocument, newDocument): 当元素被采用到新的 DOM 中时调用此方法。

对于我们的自定义元素,我们将使用前三个回调方法。您的index.html文件中删除<x-weather />元素。我们将从我们的 JavaScript 代码中添加它。

在您的home.js文件中,在showPosition()函数内,创建一个名为:createWeatherElement()的新函数。此函数应接受一个类名(HTML 类属性)作为参数,并创建一个具有该类名的天气元素。我们已经在latitudelongitude常量中有地理位置信息。showPosition()函数的代码如下:

function showPosition() {
  ...
  function createWeatherElement(className) {
    const $weather = document.createElement('x-weather');
    $weather.setAttribute('latitude', latitude);
    $weather.setAttribute('longitude', longitude);
    $weather.setAttribute('class', className);
    return $weather;
  };
}

此函数将返回一个具有三个属性的天气元素,在 DOM 中看起来如下片段:

<x-weather latitude="13.0827" longitude="80.2707" class="small-widget"></x-weather>

要在所有大、中、小容器中添加天气小部件,请在前面的函数之后添加以下代码:

const $largeContainer = document.querySelector('.large-container');
const $mediumContainer = document.querySelector('.medium-container');
const $smallContainer = document.querySelector('.small-container');

$largeContainer.appendChild(createWeatherElement('large'));
$mediumContainer.appendChild(createWeatherElement('medium'));
$smallContainer.appendChild(createWeatherElement('small'));

您应该看到天气小部件附加到所有三个不同大小的容器上。我们的最终小部件应该如下所示:

天气小部件包含以下详细信息:

  • 城市名

  • 天气图标

  • 温度

  • 时间(小时:分钟:

  • 天气状态摘要(阴天

添加依赖模块

我们的天气小部件需要向服务器发出 HTTP 请求。为此,我们可以重用我们之前在第三章中构建的 APICall 模块。此外,由于我们将使用 Dark Sky 服务来显示天气信息,我们可以使用他们的图标库 Skycons 来显示天气图标。目前,Skycons 在 npm 中不可用。您可以从书中的Chapter05\weatherdependencies目录或完成的代码文件中获取这两个文件。

目前,您的 JS 文件夹结构如下:

.
├── CustomElements
│   └── Weather
│       └── Weather.js
└── home.js

您应该在CustomElements/Weather/services/api/apiCall.js路径下添加apiCall.js文件,并在CustomElements/Weather/lib/skycons.js路径下添加skycons.js文件。您的 JS 文件夹现在应该如下所示:

.
├── CustomElements
│   └── Weather
│       ├── lib
│       │   └── skycons.js
│       ├── services
│       │   └── api
│       │       └── apiCall.js
│       └── Weather.js
└── home.js

检索和显示天气信息

在您的weather.js文件中,在顶部添加以下导入语句:

import apiCall from './services/api/apiCall';
import './lib/skycons';

Skycons 库将向 window 对象添加一个全局变量Skycons。它用于在画布元素中显示一个动画可伸缩矢量图形SVG)图标。目前,所有的类变量,比如latitudelongitude,都是在构造函数中创建的。但是,最好只在天气小部件添加到 DOM 时才创建它们。让我们将变量移到connectedCallback()方法中,这样变量只有在小部件添加到 DOM 时才会被创建。您的Weather类现在应该如下所示:

class Weather extends HTMLElement {
  constructor() {
    this.$shadowRoot = this.attachShadow({mode: 'open'});
    this.$shadowRoot.innerHTML = ` <!-- Weather widget HTML --> `;
  }

  connectedCallback() {
    this.latitude = this.getAttribute('latitude');
    this.longitude = this.getAttribute('longitude');
  }
}

就像我们在之前的章节中在 DOM 中创建元素的引用一样,让我们在天气小部件的影子 DOM 中创建对元素的引用。在connectedCallback()方法内部,添加以下代码:

this.$icon = this.$shadowRoot.querySelector('#dayIcon');
this.$city = this.$shadowRoot.querySelector('#city');
this.$temperature = this.$shadowRoot.querySelector('#temperature');
this.$summary = this.$shadowRoot.querySelector('#summary');

启动本章附带的服务器,并让它在http://localhost:3000/ URL 上运行。用于检索天气信息的 API 端点如下:

http://localhost:3000/getWeather/:lat,long

这里,latlong是纬度和经度值。如果您的(latlong)值为(13.135885480.286841),那么您的请求 URL 将如下所示:

http://localhost:3000/getWeather/13.1358854,80.286841

API 端点的响应格式如下:

{
  "latitude": 13.1358854,
  "longitude": 80.286841,
  "timezone": "Asia/Kolkata",
  "offset": 5.5,
  "currently": {
    "summary": "Overcast",
    "icon": "cloudy",
    "temperature": 88.97,
    // More information about current weather
    ...
  },
  "city": "Chennai"
}

要在天气小部件中设置天气信息,创建一个新的方法在Weather类内部setWeather(),并添加以下代码:

setWeather() {
  if(this.latitude && this.longitude) {
    apiCall(`getWeather/${this.latitude},${this.longitude}`, {}, 'GET')
      .then(response => {
        this.$city.textContent = response.city;
        this.$temperature.textContent = `${response.currently.temperature}° F`;
        this.$summary.textContent = response.currently.summary;

        const skycons = new Skycons({"color": "black"});
        skycons.add(this.$icon, Skycons[response.currently.icon.toUpperCase().replace(/-/g,"_")]);
        skycons.play();
      })
      .catch(console.error);
    }
  }

还要在connectedCallback()方法的末尾添加this.setWeather()来调用前面的方法。在 Chrome 中打开页面,您应该看到天气小部件按预期工作!您将能够看到城市名称、天气信息和天气图标。setWeather()方法的工作方式很简单,如下所示:

  • 首先,它将检查纬度和经度是否都可用。否则,将无法进行 HTTP 请求。

  • 使用apiCall模块,进行 GET 请求并在Promise.then()链中获得response

  • 从 HTTP 请求的response中,所需的数据,如城市名称、温度和摘要,都包含在相应的 DOM 元素中。

  • 对于天气图标,全局Skycons变量是一个构造函数,它创建一个具有特定颜色的所有图标的对象。在我们的情况下,是黑色。构造函数的实例存储在skycons对象中。

  • 为了添加动画图标,我们使用add方法,将 canvas 元素(this.$icon)作为第一个参数,将图标名称作为第二个参数以所需的格式传入。例如,如果 API 中的图标值是cloudy-day,则相应的图标是Skycons['CLOUDY_DAY']。为此,我们首先将整个字符串转换为大写,并使用正则表达式.replace(/-/g, "_")-替换为_

将当前时间添加到小部件中

我们的小部件中仍然缺少时间。与其他值不同,时间不依赖于 HTTP 请求,但需要每秒自动更新。在您的天气类中,添加以下方法:

displayTime() {
  const date = new Date();
  const displayTime = `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
  const $time = this.$shadowRoot.querySelector('#time');
  $time.textContent = displayTime;
}

displayTime()方法执行以下操作:

  • 使用new Date()构造函数创建一个日期对象。new Date()构造函数使用传递的日期和时间的所有详细信息创建一个date对象。如果没有传递参数,它将创建一个包含有关当前日期和时间的所有信息(直到毫秒)的对象。在我们的情况下,因为我们没有传递任何参数,它包含了初始化时刻的所有日期和时间的详细信息。

  • 我们从日期对象中获取小时、分钟和秒。通过使用模板字符串,我们简单地按照所需的格式构建了时间,并将其存储在displayTime常量中。

  • 最后,将时间设置为阴影 DOM 中p#time$time)元素的文本内容。

日期对象是一个重要的概念,是 JavaScript 中日常软件开发的一部分。要了解有关日期对象的更多信息,请参考 w3schools 页面:www.w3schools.com/js/js_dates.asp

这个方法用于设置时间一次,但我们需要每秒执行一次这个方法,这样用户就可以在小部件中看到确切的时间。JavaScript 有一个叫做setInterval()的方法。它用于在特定的时间间隔内重复执行一个函数。setInterval()方法接受两个参数:

  • 第一个是需要在特定时间间隔内执行的函数

  • 第二个是以毫秒为单位的时间间隔

然而,setInterval()会重复执行函数,即使 DOM 元素由于某种原因被从 DOM 中移除。为了克服这一点,您应该将setInterval()存储在一个变量中,然后使用disconnectedCallback()方法来执行clearInterval(intervalVariable),这将清除间隔函数。

为了实现这一点,使用以下代码:

connectedCallback() {
  ...
  this.ticker = setInterval(this.displayTime.bind(this), 1000);
}

disconnectedCallback() {
  clearInterval(this.ticker);
}

在 Chrome 中打开天气小部件,您应该看到小部件中的当前时间每秒更新一次,这对用户来说看起来很正常。

响应元素属性的更改

我们有一个完全工作的天气小部件,但是只有在第一次将小部件添加到 DOM 时才会加载天气信息。如果您尝试从 Chrome DevTools 或 JavaScript 更改latitudelongitude属性的值,值会更改,但是天气小部件不会得到更新。为了使天气元素响应latitudelongitude的更改,我们需要将它们声明为观察属性。为此,请在您的Weather类中添加以下行:

static get observedAttributes() { return ['latitude', 'longitude']; }

这将创建一个静态getter observedAttributes(),它将返回一个数组,其中包含天气小部件应监听更改的所有属性名称。静态方法是Class的特殊方法,可以在不创建类实例对象的情况下访问。对于所有其他方法,我们需要创建类的新实例(对象);否则,我们将无法访问它们。由于静态方法不需要实例,这些方法内部的this对象将在这些方法内部为undefined

静态方法用于保存与类相关的常见(独立的类变量和方法)函数,可以在类外的其他地方使用。

由于我们将latitudelongitude标记为观察属性,因此每当它们使用任何方法进行修改时,它都会触发attributeChangedCallback(),并将修改后的属性名称以及该属性的旧值和新值作为参数。因此,让我们在Weather类中添加attributeChangedCallback()

attributeChangedCallback(attr, oldValue, newValue) {
  if (attr === 'latitude' && oldValue !== newValue) {
    this.latitude = newValue;
    this.setWeather();
  }
  if(attr === 'longitude' && oldValue !== newValue) {
    this.longitude = newValue;
    this.setWeather();
  }
}

这种方法很简单。每当latitudelongitude属性的值发生变化时,它都会更新相应的类变量并调用this.setWeather()来将天气更新到新的地理位置。您可以通过直接在 Chrome DevTools 的 DOM 树中编辑天气小部件的属性来测试这一点。

使用settersgetters

我们在创建对 DOM 元素的引用时经常使用settersgetters。如果我们有一个对天气自定义元素的引用,我们只需按如下方式设置或获取latitudelongitude

currentLatitude = $weather.lat;
$weather.lat = newLatitude;

在这种情况下,如果我们设置了新的latitudelongitude,我们需要小部件进行更新。为此,请将以下settersgetters添加到您的Weather类中:

get long() {
  return this.longitude;
}

set long(long) {
  this.longitude = long;
  this.setWeather();
}

get lat() {
  return this.latitude;
}

set lat(lat) {
  this.latitude = lat;
  this.setWeather();
}

为了测试settersgetters是否正常工作,让我们删除(或注释掉)将天气小部件附加到$smallContainer的行。而是添加以下代码:

const  $small  =  createWeatherElement('small'); $smallContainer.appendChild($small); setTimeout(() => { console.log($small.lat, $small.long);
 $small.lat  =  51.5074;
 $small.long  =  0.1278;
 console.log($small.lat, $small.long); }, 10000);

您应该看到在 10 秒后,小容器中的天气会自动更改为伦敦。旧的和新的地理位置也将打印在 Chrome DevTools 控制台中。

您已成功完成了天气小部件!在将其用于您的项目之前,您需要添加 polyfills,因为在撰写本书时,只有 Chrome 支持 Web 组件的所有功能。

修复浏览器兼容性

为了改进我们的天气小部件的浏览器兼容性,我们需要webcomponents.js库提供的一组 polyfills,位于:github.com/webcomponents/webcomponentsjs 存储库中。这些 polyfills 使我们的小部件与大多数现代浏览器兼容。要将这些 polyfills 添加到我们的项目中,首先从项目根文件夹中的终端运行以下命令:

npm install -S webcomponents.js

这将安装并将webcomponents.js添加到我们的项目依赖项中。之后,在您的home.js文件中导入它:

import  'webcomponents.js';

目前,我们正在监听窗口加载事件后初始化项目。Webcomponents.js异步加载 polyfills,并且一旦准备就绪,它将触发'WebComponentsReady'事件。因此,我们现在应该监听这个新事件,而不是加载事件:

window.addEventListener('WebComponentsReady', () => {
  customElements.define('x-weather', Weather);
  getLocation();
});

现在,对于最后一部分,您需要记录如何在readme文件中使用天气自定义元素和 Web 组件 polyfill,以便团队的其余成员知道如何将其添加到项目中。但这次,readme文档将不到一页,并且应该简单易于维护!我会把readme部分留给您。我打赌您已经在庆祝第五章的完成了。

需要了解的基本事项

这些是一些在使用自定义元素时会派上用场的事情。就像我们扩展了一般的HTMLElement接口一样,我们也可以扩展内置元素,比如段落元素<p>,按钮元素<button>等等。这样,我们可以继承父元素中可用的所有属性和方法。例如,要扩展按钮元素,可以按照以下步骤进行:

class PlasticButton extends HTMLButtonElement {
  constructor() {
    super();

    this.addEventListener("click", () => {
      // Draw some fancy animation effects!
    });
  }
}

在这里,我们扩展了HTMLButtonElement接口,而不是HTMLElement接口。同样,就像内置元素可以被扩展一样,自定义元素也可以被扩展,这意味着我们可以通过扩展我们的天气小部件类来创建另一种类型的小部件。

尽管 JavaScript 现在支持类和扩展类,但它还不支持私有或受保护的类变量和方法,就像其他面向对象的语言一样。目前,所有的类变量和方法都是公共的。一些开发人员在需要私有变量和方法时在变量和方法前面添加下划线'_'前缀,以防止在扩展类中意外使用它们。

如果您对更多地使用 Web 组件感兴趣,您可能应该查看以下库,这些库旨在改进使用内置 polyfills 的 Web 组件的可用性和工作流程:

要了解有关扩展内置 HTML 元素的更多信息,请参考 Google 开发者页面上的以下教程:developers.google.com/web/fundamentals/getting-started/primers/customelements

总结

在本章中,您为团队构建了一个天气小部件,同时学习了有关 Web 组件的知识。您创建了一个可重用的 HTML 自定义元素,它使用影子 DOM 来将 CSS 与文档的其余部分分离,使小部件可以轻松地插入到项目的其余部分中。您还学习了一些方法,比如地理位置和设置间隔。但在本章中,您学到的最重要的事情是在团队环境中创建独立组件的优势。通过创建可重用的天气组件,您为自己和团队的其余成员简化了工作。

到目前为止,我们一直在使用纯 JavaScript。然而,今天有许多现代框架和库,使得使用 JavaScript 进行编程更加简单,高效,并且可扩展到很大程度。大多数框架都集中于将整个应用程序组织成更小、独立和可重用的组件,这正如我们在本章中体验到的 Web 组件一样。在下一章中,我们将使用 Facebook 创建的强大 UI 库React.js来构建整个应用程序。

第六章:使用 React 构建博客

嘿!做到了书的最后一节,你将学习 Facebook 的 React 库。在我们开始本章之前,让我们回顾一下你在书中的学习之旅:

  • 你首先使用 JavaScript 的 ES6 语法构建了一个简单的待办事项应用,然后创建了一个构建脚本将其编译为 ES5,以便与旧版浏览器兼容。

  • 然后,在设置自己的自动化开发环境的同时,你构建了一个 Meme Creator,学习了许多新概念和工具。

  • 接下来,你使用开发环境构建了一个活动注册应用程序,在其中构建了你的第一个可重用的 JavaScript 模块,用于 API 调用和表单验证。

  • 然后,你利用 JavaScript WebAPI 的强大功能构建了一个使用 WebRTC 的点对点视频通话应用程序。

  • 最后,你构建了自己的 HTML5 自定义元素,它将显示一个天气小部件,并可以轻松导入和在其他项目中使用。

从初学者级别开始,你构建了一些非常棒的应用程序,现在你熟悉了现代 JavaScript 的许多重要概念。现在,是时候利用这些技能学习 JavaScript 框架了,这将加速你的开发过程。本章将重点帮助你开始使用 React。

为什么使用框架?

现代应用程序开发都是关于速度、可维护性和可扩展性的。鉴于 Web 是许多应用程序的主要平台,对于任何 Web 应用程序都会有相同的期望。JavaScript 可能是一种很棒的语言,但在团队环境中处理大型应用程序时,编写纯 JavaScript 有时可能是一个繁琐的过程。

在这样的应用程序中,你将不得不操作大量的 DOM 元素。每当你更改 DOM 元素的 CSS 时,它被称为重绘。这将影响元素在浏览器上的显示。每当你在 DOM 中删除、更改或添加一个元素时,这被称为回流。父元素的回流也会导致其所有子元素的回流。重绘和回流是昂贵的操作,因为它们是同步的。这意味着当重绘或回流发生时,JavaScript 将无法在那个时候运行。这将导致 Web 应用程序的延迟或缓慢执行(特别是在较小的设备上,如低端智能手机)。到目前为止,我们一直在构建非常小的应用程序;因此,我们还没有注意到任何性能问题,但对于像 Facebook 这样的应用程序来说,这是至关重要的(有成千上万的 DOM 元素)。

此外,编写大量的 JavaScript 代码意味着增加代码文件的大小。对于依赖 3G 或更低连接的移动用户来说,这意味着你的应用程序加载时间会更长。这会导致糟糕的用户体验。

最后,前端 JavaScript 代码需要处理大量的副作用(例如点击、滚动、悬停和网络请求等事件)。在团队环境中工作时,每个开发人员都应该知道你的代码处理的是什么类型的副作用。当 Web 应用程序增长时,每个副作用都需要被正确跟踪。在纯 JavaScript 中,在这样的环境中编写可维护的代码也是困难的。

幸运的是,JavaScript 社区对所有这些情况都有很好的认识,因此有许多开源的 JavaScript 库和框架被创建并积极维护,以解决上述问题并提高开发人员的生产力。

选择一个框架

在 2017 年选择 JavaScript 框架比学习 JavaScript 本身更困难(是的,这是真的!)因为几乎每周都会发布一个新的框架。但除非你的需求非常具体,否则大多数情况下你不需要担心它们。目前,有一些框架在开发者中非常受欢迎,比如 React、Vue.js、Angular、Ember 等。

这些框架非常受欢迎,因为它们可以让你几乎立即启动应用程序,并得到来自使用这些框架的庞大开发人员社区的出色支持。这些框架还配备了它们自己的构建工具,这将为你节省设置自己的开发环境的麻烦。

React

在这一章中,我们将学习使用 React 构建 Web 应用程序的基础知识。React 是由 Facebook 开发并广泛使用的。许多其他知名应用程序,如 Instagram、Airbnb、Uber、Pinterest、Periscope 等,也在它们的 Web 应用程序中使用 React,这有助于将 React 发展成为一个成熟且经过实战考验的 JavaScript 库。在撰写本书时,React 是 GitHub 上最受欢迎的前端 JavaScript 框架,拥有超过 70,000 名活跃开发人员的社区。

与大多数其他 JavaScript 框架不同,React 不认为自己是一个框架,而是一个用于构建用户界面的库。它通过将应用程序的每个部分组合成更小的功能组件来完美处理应用程序的视图层。

函数是执行任务的简单 JavaScript 代码。我们从本书的一开始就一直在使用函数。React 使用函数的概念来构建 Web 应用程序的每个组件。例如,看一下以下元素:

<h1 class="hello">Hello World!</h1>

假设你想用一个动态变量,比如某人的名字,来替换单词world。React 通过将元素转换为函数的结果来实现这一点:

const hello = (name) => React.createElement("h1", { className: "hello"}, "Hello ", name, "!")

现在,函数hello包含所需的元素作为其结果。如果你尝试hello('Rahul'),你将得到以下结果:

<h1 class="hello">Hello Rahul!</h1>

但等等!那个React.createElement()方法是什么?忘了告诉你。这就是 React 创建 HTML 元素的方式。但是对我们来说,应用这个方法来构建应用程序是不可能的!想象一下,为了创建一个包含大量 DOM 元素的应用程序,你将不得不输入多少个这样的方法。

为此,React 引入了JavaScript inside XMLJSX)。这是在 JavaScript 中编写 XML 样式的标记的过程,它被编译成React.createElement()方法。简而言之,你也可以将hello函数写成如下形式:

const hello = (name) => <h1 className="hello">Hello {name}!</h1>

这将更有意义,因为我们只是在 JavaScript 的返回语句中写 HTML。这样做的酷之处在于元素的内容直接取决于函数的参数。在使用 JSX 时,你需要注意一些事项:

  • JSX 元素的属性不能包含 JavaScript 关键字。注意,class 属性被替换为className,因为 class 是 JavaScript 中的保留关键字。同样,对于 for 属性,它变成了htmlFor

  • 要在 JSX 中包含变量或表达式,你应该将它们包裹在花括号{}中。这类似于我们在模板字符串中使用的${}

  • JSX 需要 Babel React 预设来编译成 JavaScript。

  • JSX 中的所有 HTML 元素应该只使用小写字母。

  • 例如:<p></p><div></div><a></a>

  • 在 HTML 中使用大写字母是无效的。

  • 例如:<Div></Div><Input></Input>都是无效的。

  • 我们创建的自定义组件应该以大写字母开头。

  • 例如:考虑我们之前创建的hello函数,它是一个无状态的 React 组件。要在 JSX 中包含它,你应该将它命名为Hello,并将其包含为<Hello></Hello>

上述函数是一个简单的无状态React 组件。一个无状态的 React 组件根据作为参数传递给函数的变量直接输出元素。它的输出不依赖于任何其他因素。

有关 JSX 的详细信息,请参阅:facebook.github.io/react/docs/jsx-in-depth.html

这种表示适用于较小的元素,但许多 DOM 元素带有各种副作用,例如 DOM 事件和会导致 DOM 元素修改的 AJAX 调用,这些副作用来自于函数范围之外的因素(或变量)。为了解决这个问题,React 提出了有状态组件的概念。

一个有状态的组件有一个特殊的变量叫做statestate变量包含一个 JavaScript 对象,它应该是不可变的。我们稍后会看不可变性。现在,看看以下代码:

class Counter extends React.Component {
  constructor() {
    super();
    this.state = {
      count: 0,
    }
  }

  render() {
    return ( <h1>{this.state.count}</h1> );
  }
}

这是一个简单的有状态 React 组件。正如你所看到的,我们正在从React.Component接口扩展一个类,类似于我们如何从HTMLElement扩展它来创建我们在上一章中的自定义元素,就像自定义元素一样,React 组件也有生命周期方法。

React 生命周期方法在组件被插入到 DOM 中或更新时的不同阶段被调用。以下生命周期方法在组件被插入到 DOM 中时被调用(按照确切的顺序):

  1. constructor()

  2. componentWillMount()

  3. render()

  4. componentDidMount()

以下生命周期方法在组件状态或属性改变导致更新时被调用。

  1. componentWillReceiveProps()

  2. shouldComponentUpdate()

  3. componentWillUpdate()

  4. render()

  5. componentDidUpdate()

还有一个生命周期方法在组件从 DOM 中移除时被调用:

  • componentWillUnmount()

有关 React 中每个生命周期方法如何工作的详细解释,请参考 React 文档中的以下页面:facebook.github.io/react/docs/react-component.html#the-component-lifecycle

在前面的Counter类中的render方法是 React 组件的生命周期方法之一。顾名思义,render()方法用于在 DOM 中渲染元素。每当组件被挂载和更新时,都会调用render方法。

在 React 组件中,当stateprops发生变化时会发生更新。我们还没有看过 props。为了检测状态变量的变化,React 要求状态是不可变对象。

不可变状态

不可变对象是一旦设置就无法更改的对象!是的,没错。一旦你创建了那个对象,就无法回头了。这让你想知道“如果我需要修改该对象的属性怎么办?”好吧,很简单;你只需从旧对象创建一个新对象,但这次带有新属性。

现在,这可能看起来是很多工作,但相信我,创建一个新对象实际上更好。因为大多数时候,React 只需要知道对象是否改变以更新视图。例如:

this.state = { a: 'Tree', b: 'Flower', c: 'Fruit' };
this.state.a = 'Plant';

这是改变 JavaScript 对象属性的标准方式。在这里,我们称之为可变方式。太棒了!你刚刚修改了状态。但是 React 如何知道状态已经修改并且应该调用它的生命周期方法来更新 DOM 元素呢?现在这是一个问题。

为了克服这一点,React 组件有一个特殊的方法叫做setState(),它可以以不可变的方式更新状态并调用所需的生命周期方法(包括render,它将更新 DOM 元素)。让我们看看如何以不可变的方式更新状态:

this.state = { a: 'Tree', b: 'Flower', c: 'Fruit' };
this.setState({ a: 'Plant' });

这将通过创建一个新的状态对象而不是旧的状态对象来更新你的状态。现在,旧状态和新状态是两个不同的对象:

oldState = { a: 'Tree', b: 'Flower', c: 'Fruit' }
newState = { a: 'Plant', b: 'Flower', c: 'Fruit' }

React 现在可以通过简单比较两个对象oldState !== newState来轻松检查状态是否改变,如果状态改变则返回 true,因此在视图中进行快速更新。以这种方式比较对象比迭代每个对象的属性并检查是否有任何属性改变要快得多和更有效率。

使用setState()的目标是调用render方法,这将更新视图。因此,不应该在render方法内部使用setState(),否则将导致无限循环。

JavaScript 数据类型不是不可变的;然而,使用不可变数据类型非常重要,您很快就会了解更多相关知识。

Props

Props 是从父组件传递给 React 组件的数据。Props 类似于状态,只是 props 是只读的。您不应该在组件内部更改组件的 props。例如,考虑以下组件:

class ParentComponent extends Component {
  render() {
    return (
      <ChildrenComponent name={'World'} />
    )
  }
}

class ChildrenComponent extends Component {
  render() {
    return (
      <h1>Hello {this.props.name}!</h1>
    )
  }
}

在这里,传递给ParentComponentChildrenComponent元素的 name 属性已成为ChildrenComponent的 prop。这个 prop 不应该由ChildrenComponent更改。但是,如果从ParentComponent更改了值,ChildrenComponent也将使用新的 props 重新渲染。

要了解更多关于组件和 props 的信息,请访问 react 文档中的以下页面:facebook.github.io/react/docs/components-and-props.html

构建计数器

看一下我们之前创建的Counter类。顾名思义,它应该呈现一个每秒增加 1 次的计数器。为此,我们需要使用setInterval来增加计数器状态对象的 count 属性。我们可以使用componentWillMountcomponentDidMount生命周期方法来添加setInterval。由于这个过程不需要任何对 DOM 元素的引用,我们可以使用componentWillMount

Counter类内部,我们需要添加以下代码行:

increaseCount() {
  this.setState({ count: this.state.count+1 })  
}
componentWillMount() {
  setInterval(this.increaseCount.bind(this), 1000);  
}

这将自动每秒执行一次增量,render方法将更新所需的 DOM 元素。要查看计数器的实际效果,请访问以下 JSFiddle 页面:jsfiddle.net/reb5ohgk/

现在,在 JSFiddle 页面上,看一下左上角的外部资源部分。您应该会看到其中包括三个资源,如下面的截图所示:

除此之外,在 JavaScript 代码块中,我已经选择了 Babel+JSX 作为语言。如果您点击 JavaScript 部分右上角的设置图标,您将能够看到一组选项,如下面的截图所示:

以下是配置的内容:

  • 我包含的第一个 JavaScript 文件是react.js库。React 库是负责创建 DOM 元素作为组件的核心。但是,React 将组件呈现在虚拟 DOM中,而不是真实的 DOM 中。

  • 我包含的第二个库是ReactDOM。它用于为 React 组件提供包装器,以便它们可以在 DOM 中呈现。考虑以下行:

ReactDOM.render( <Counter />,  document.querySelector("app"));
  • 这将使用ReactDOM.render()方法将Counter组件呈现到 DOM 中的<app></app>元素中。

  • 第三个库是 Bootstrap;我只是为了样式添加了它。那么,让我们看看配置的下一步。

  • 在 JavaScript 代码块中,我已经选择了 Babel + JSX 作为语言。这是因为浏览器只认识 JavaScript。它们对 JSX 一无所知,就像旧版浏览器对 ES6 一无所知一样。

  • 因此,我刚刚指示 JSFiddle 使用浏览器内置的 Babel 转换器将 ES6 和 JSX 代码编译回普通的 JavaScript,以便它可以在所有浏览器中运行。

  • 在实际应用中,我们将使用 Webpack 和 React 预设的 Babel 加载器来编译 JSX,就像我们为 ES6 所做的那样。

到目前为止,您应该对 React 有了一个很好的了解,那么让我们开始构建您的第一个 React 应用程序-一个待办事项列表-在下一节中。

React 速成课程

在本节中,我们将花费 10 分钟构建你的第一个 React 应用程序。在本节中,你不需要任何文本编辑器,因为你将在 JSFiddle 中构建应用程序!

通过访问 JSFiddle 页面jsfiddle.net/uhxvgcqe/开始。我已经在这个页面中设置了构建 React 应用程序所需的所有库和配置。你应该在这个页面中为 React 速成课程部分编写代码。

这个页面有 React 和ReactDOM作为 window 对象(全局范围)的属性可用,因为我已经在外部资源中包含了这些库。我们还将从 React 对象创建一个组件对象。在 ES6 中,有一个技巧可以将对象的属性或方法获取为独立的变量。看下面的例子:

const vehicles = { fourWheeler: 'Car', twoWheeler: 'Bike' };
const { fourWheeler, twoWheeler } = vehicles;

现在将从车辆对象的相应属性中创建两个新的常量fourWheelertwoWheeler。这被称为解构赋值,它适用于对象和数组。遵循相同的原则,在你的 JSFiddle 的第一行中,添加以下代码:

const { Component } = React;

这将从 React 对象的组件属性创建组件对象。在 HTML 部分中,我已经包含了一个<app></app>元素,这是我们将渲染我们的 React 组件的地方。因此,使用以下代码创建对<app>元素的引用:

const $app = document.querySelector('app');

让我们创建一个有状态的应用组件,它将渲染我们的待办事项列表。在 JSFiddle 中,输入以下代码:

class App extends Component {
  render() {
    return(
    <div className="container">      
      <h1>To Do List</h1>      
      <input type="text" name="newTask"/>      
      <div className="container">        
        <ul className="list-group">          
          <li>Do Gardening</li>          
          <li>Return books to library</li>          
          <li>Go to the Dentist</li>        
        </ul>      
      </div>    
    </div> 
    ); 
  }
}

在类外部,添加以下代码块,它将在 DOM 中渲染 React 组件:

ReactDOM.render( <App/>,  $app);

现在,点击 JSFiddle 页面左上角的运行。你的应用程序现在应该看起来像这样:jsfiddle.net/uhxvgcqe/1/

有关解构赋值的更多信息和用法详情,请访问以下 MDN 页面:developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment

添加和管理状态

一个有状态的 React 组件最重要的部分是它的状态,它提供了渲染 DOM 元素所需的数据。对于我们的应用程序,我们需要两个状态变量:一个包含任务数组,另一个包含文本字段的输入值。作为一个完全功能的表示,我们总是需要为每个视图更改维护一个状态,包括输入字段的值。

在你的App类中,添加以下代码行:

constructor() {  
  super();        
  this.state = {    
    tasks: [],      
    inputValue: "",    
  }  
}

这将向类添加一个构造函数,在构造函数中,我们应该首先调用super(),因为我们的类是一个扩展类。super()将调用Component接口的构造函数。在下一行,我们创建了状态变量tasksinputValuetasks是一个数组,它将包含一个包含任务名称的字符串数组。

管理输入字段的状态

首先,我们将把inputValue状态与输入字段关联起来。在你的render()方法中,添加输入 JSX 元素的 value 属性,如下所示:

<input type="text" name="newTask" value={this.state.inputValue} />

我们已经明确地将输入字段的值与状态变量绑定在一起。现在,尝试点击运行并编辑输入字段。你不应该能够编辑它。

这是因为无论你在这个字段中输入什么,render()方法都只会渲染我们在return()语句中指定的内容,即一个带有空inputValue的输入字段。那么,我们如何改变输入字段的值呢?通过向输入字段添加一个onChange属性。让我向你展示如何做。

App类中,在我指定的位置添加以下代码行:

class App extends Component { 
  constructor() {
    ...
    this.handleChange = this.handleChange.bind(this);
  }

  handleChange(event) {  
    this.setState({inputValue: event.target.value});  
  }

  ...
}    

这个handleChange方法将接收我们的输入事件,并根据事件目标的值更新状态,事件目标应该是输入字段。请注意,在构造函数中,我已经将this对象与handleChange方法绑定。这样我们就不必在 JSX 元素内使用this.handleChange.bind(this)了。

现在,我们需要将handleChange方法添加到输入元素的onChange属性中。在您的 JSX 中,将onChange属性添加到输入元素,如下所示:

<input type="text" name="newTask" value={this.state.inputValue} onChange={this.handleChange} />

点击运行,您应该能够再次在输入字段中输入。但是这次,每当您编辑输入字段时,您的inputValue状态都会得到更新。您的 JSFiddle 现在应该看起来像这样:jsfiddle.net/uhxvgcqe/2/

这是 React 的单向数据流(或单向数据绑定),其中数据只从状态流向render方法。渲染组件中的任何事件都必须触发对状态的更新以更新视图。此外,状态应该只以不可变的方式使用this.setState()方法进行更新。

管理任务的状态

我们应用中需要维护的第二个状态是tasks数组。目前,我们有一个示例任务的无序列表。将这些任务作为字符串添加到tasks数组中。您构造函数中的state对象现在应该如下所示:

this.state = {          
  tasks: [      
    'Do Gardening',        
    'Return books to library',        
    'Go to the Dentist',      
  ],            
  inputValue: "",        
};

现在,让我们从状态中填充任务。在您的render方法中,在<ul>元素内,删除所有<li>元素,并用以下内容替换它们:

<ul className="list-group">            
  {            
    this.state.tasks.map((task, index) => <li key={index}>{ task }</li>)            
  }          
</ul>

在 JSX 中的花括号{}只接受返回直接值的表达式,就像模板文字中的${}一样。因此,我们可以使用数组的 map 方法返回 JSX 元素的数组。每当我们将 JSX 元素作为数组返回时,我们应该添加一个带有唯一值的key属性,React 用它来识别数组中的元素。

因此,在上述代码中,我们需要执行以下步骤:

  1. 我们遍历statetasks数组,并使用数组的map()方法将列表项作为 JSX 元素的数组返回。

  2. 对于key属性的唯一值,我们使用数组中每个元素的index

点击运行,您的代码应该产生与之前相同的输出,只是任务现在是从状态中填充的。您的代码现在应该看起来像这样:jsfiddle.net/uhxvgcqe/3/

添加新任务

我们应用的最后一步是允许用户添加一个新任务。通过在键盘上按Enterreturn来简化。要检测Enter按钮,我们需要在输入字段上使用一个类似于onChange的属性,但它应该发生在onChange事件之前。onKeyUp就是这样一个属性,当用户在键盘上按下并释放键时会调用它。它也会在onChange事件之前发生。首先创建处理键盘按键过程的方法:

class App extends Component {
  constructor() {
    ...
    this.handleKeyUp = this.handleKeyUp.bind(this);
  }

  handleKeyUp(event) {
    if(event.keyCode === 13) {    
      if(this.state.inputValue) {        
        const newTasks = [...this.state.tasks, this.state.inputValue];
        this.setState({tasks: newTasks, inputValue: ""});      
      } else {      
        alert('Please add a Task!');      
      }    
    }
  }

  ...
}

handleKeyUp方法的工作原理如下:

  1. 首先,它将检查事件的keyCode是否为13,这是EnterkeyCode(对于 Windows)和return(对于 Mac)键。然后,它将检查this.state.inputValue是否可用。否则,它将抛出一个警报,显示'请添加一个任务'。

  2. 第二个也是最重要的部分是更新数组而不改变状态。在这里,我使用了扩展语法来创建一个新的任务数组并更新状态。

在您的render方法中,再次修改输入 JSX 元素为以下内容:

<input type="text" name="newTask" value={this.state.inputValue} onChange={this.handleChange} onKeyUp={this.handleKeyUp}/>

现在,点击运行,输入一个新任务,然后按Enter。您会看到一个新任务被添加到待办事项列表中。您的代码现在应该看起来像jsfiddle.net/uhxvgcqe/4/,这是待办事项列表的完成代码。在我们讨论在这里使用 React 的优势之前,让我们看一下我们用于添加任务的扩展语法。

使用扩展语法防止突变

在 JavaScript 中,数组和对象在赋值过程中是按引用传递的。例如,打开一个新的 JSFiddle 窗口,尝试以下代码:

const a = [1,2,3,4];
const b = a;
b.push(5);
console.log('Value of a = ', a);
console.log('Value of b = ', b);

我们从数组a创建一个新数组b。然后我们向数组b中推入一个新值5。如果您查看控制台,输出将如下所示:

令人惊讶的是,两个数组都已更新。这就是我所说的按引用传递。ab都持有对同一数组的引用,这意味着更新它们中的任何一个都会更新两者。这对数组和对象都成立。这意味着如果使用普通赋值,我们显然会改变状态

然而,ES6 提供了用于数组和对象的扩展语法。我在handleKeyUp方法中使用了这个语法,其中我从this.state.tasks数组创建了一个newTask数组。在您尝试了上述代码的 JSFiddle 窗口中,将代码更改为以下内容:

const a = [1,2,3,4];
const b = [...a, 5];
console.log('Value of a = ', a);
console.log('Value of b = ', b);

看看这次我是如何创建一个新数组b的。三个点...(称为扩展运算符)用于展开数组a中的所有元素。除此之外,还添加了一个新元素5,并创建了一个新数组并将其分配给b。这种语法起初可能会令人困惑,但这是我们在 React 中更新数组值的方式,因为这将以不可变的方式创建一个新数组。

同样,对于对象,您应该执行以下操作:

const obj1 = { a: 'Tree', b: 'Flower', c: 'Fruit' };
const obj2 = { ...obj1, a: 'plant' };
const obj3 = { ...obj1, d: 'seed' };

console.log('Value of obj1 = ', obj1);
console.log('Value of obj2 = ', obj2);
console.log('Value of obj3 = ', obj3);

我在jsfiddle.net/bLo4wpx1/中创建了一个带有扩展运算符的小玩意。随时玩玩它,以了解扩展语法的工作方式,我们将在本章和下一章中经常使用它。

要了解更多使用扩展语法的实际示例,请访问 MDN 页面developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Spread_operator

使用 React 的优势

我们在 10 分钟内使用 React 构建了一个待办事项列表应用。在本章的开头,我们讨论了为什么需要 JavaScript 框架以及使用纯 JavaScript 的缺点。在本节中,让我们看看 React 是如何克服这些因素的。

性能

DOM 更新是昂贵的。重绘和回流是同步事件,因此需要尽量减少。React 通过维护虚拟 DOM 来处理这种情况,使得 React 应用程序非常快速。

每当我们对render方法中的 JSX 元素进行修改时,React 将更新虚拟 DOM 而不是真实 DOM。更新虚拟 DOM 是快速、高效的,比更新真实 DOM 要便宜得多,只有虚拟 DOM 中更改的元素才会在实际 DOM 中被修改。React 通过使用智能差异算法来实现这一点,我们大多数时候不必担心。

要详细了解 React 的工作原理和性能,您可以阅读 React 文档中的以下文章:

可维护性

React 在这一部分表现出色,因为它将应用程序整齐地组织为状态和相应的 JSX 元素分组为组件。在待办事项列表应用中,我们只使用了一个有状态的组件。但是我们也可以将其 JSX 分成较小的无状态子组件。这意味着对子组件的任何修改都不会影响父组件。因此,即使我们修改列表的外观,核心功能也不会受到影响。

查看 JSFiddle:jsfiddle.net/7s28bdLe/,在那里我将待办事项列表项组织为较小的子组件。

这在团队环境中非常有用,每个人都可以创建自己的组件,并且可以很容易地被其他人重用,这将提高开发人员的生产力。

大小

React 很小。整个 React 库在最小化时只有大约 23 KB,而react-dom大约为 130 KB。这意味着即使在 2G/3G 连接缓慢的情况下,它也不会对页面加载时间造成严重问题。

使用 React 构建博客

本节的目标是通过构建一个简单的博客应用程序来学习 React 的基础知识以及它在 Web 应用程序中的使用方式。到目前为止,我们一直在学习 React,但现在是时候看看它在真实 Web 应用程序中的使用方式了。React 将在我们迄今为止在本书中使用的开发环境中正常工作,只是我们需要向babel-loader添加一个额外的react预设。

react-community提出了一个更好的解决方案,即create-react-app命令行工具。基本上,这个工具会使用所有必要的开发工具、Babel 编译器和插件为您创建项目,这样您就可以专注于编写代码,而不必担心 Webpack 配置。

create-react-app建议在使用 React 时使用 yarn 而不是 npm,但由于我们对 npm 非常熟悉,所以在本章中我们不会使用 yarn。如果您想了解有关 yarn 的信息,请访问:yarnpkg.com/en/

要了解create-react-app的工作原理,首先让我们使用 npm 全局安装该工具。打开终端并输入以下命令(由于这是全局安装,它将从任何目录中工作):

npm i -g create-react-app

Linux 用户可能需要添加sudo前缀。安装完成后,您可以通过运行简单的命令为您的 React 项目创建一个样板:

create-react-app my-react-project

这个命令会花一些时间,因为它必须创建一个my-react-project目录,并为您的 React 开发环境安装所有 npm 依赖项。命令完成后,您可以在终端中使用以下命令运行应用程序:

cd my-react-project
npm start

这将启动 React 开发服务器,并打开浏览器显示一个用 React 构建的欢迎页面,如下面的屏幕截图所示:

让我们看看项目中文件是如何组织的。项目根目录将按以下结构排列文件:

.
├── node_modules
├── package.json
├── public
├── README.md
├── src
└── yarn.lock

公共文件夹将包含index.html文件,其中包含我们的 React 组件将呈现到的div#root元素。此外,它还包含faviconmanifest.json文件,当网页添加到主屏幕时向 Android 设备提供信息(在渐进式 Web 应用程序中常用)。

src目录包含我们的 React 应用程序的源文件。src目录的文件结构将如下所示:

.
├── App.css
├── App.js
├── App.test.js
├── index.css
├── index.js
├── logo.svg
└── registerServiceWorker.js

index.js文件是应用程序的入口点,它简单地在公共目录中的index.html文件中呈现App.js文件中的App组件。我们在App.js文件中编写我们的主要App组件。应用程序中的所有其他组件都将是App组件的子组件。

到目前为止,我们一直在使用 JavaScript 构建多页面应用程序。但现在,我们将使用 React 构建单页面应用程序。单页面应用程序SPA)是指应用程序的所有资产最初都会加载,然后在用户浏览器上像普通应用程序一样工作。SPA 现在是趋势,因为它们为用户在各种设备上提供了良好的用户体验。

要在 React 中构建 SPA,我们需要一个库来管理应用程序中页面(组件)之间的导航。react-router就是这样一个库,它将帮助我们管理应用程序中页面(路由)之间的导航。

就像其他章节一样,我们的博客在移动设备上也是响应式的。让我们来看看我们即将构建的博客应用程序:

对于这个应用程序,我们将不得不编写大量的代码。因此,我已经为您准备好了起始文件供您使用。您应该从书中代码的Chapter06文件夹中的起始文件开始,而不是从create-react-app工具开始。

除了 React 和react-dom之外,起始文件还包含以下库:

为博客提供 API 的服务器位于书中代码Chapter06\Server目录中。在构建应用程序时,您应该保持此服务器运行。我强烈建议您在开始构建博客之前先查看已完成的应用程序。

create-react-app支持直接从.env文件中读取环境变量;但是,有一个条件,即所有的环境变量都应该以REACT_APP_关键字为前缀。更多信息,请阅读:github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#adding-custom-environment-variables

要运行已完成的应用程序,请执行以下步骤:

  1. 首先通过在服务器目录中运行npm install,然后运行npm start来启动服务器。

  2. 它将在控制台中打印应该添加到Chapter 6\completedCode文件的.env文件中的 URL。

  3. Chapter 6\CompletedCode文件夹中,使用.env.example文件创建.env文件,并将控制台输出的第一行中打印的 URL 作为REACT_APP_SERVER_URL的值粘贴进去。

  4. 在终端中导航到书中代码Chapter 6\CompletedCode文件夹,并运行相同的npm installnpm start命令。

  5. 应该在浏览器中打开博客。如果没有打开博客,那么请手动在浏览器中打开http://localhost:3000/

我还为服务器创建了一个使用 swagger 的 API 文档。要访问 API 文档,当服务器正在运行时,它将在控制台输出的第二行中打印文档 URL。只需在浏览器中打开 URL。在文档页面上,点击默认组,您应该会看到一个 API 端点列表,如下面的截图所示:

您可以在这里看到关于 API 端点的所有信息,甚至可以通过点击 API 然后点击 Try it out 来尝试它们:

慢慢来。访问已完成的博客的所有部分,尝试在 swagger 文档中尝试所有的 API,并学习它是如何工作的。一旦你完成了它们,我们将继续下一节,开始构建应用程序。

创建导航栏

希望您尝试了这个应用程序。目前,我已经设置服务器在 3 秒后才响应;因此,在尝试在页面之间导航时,您应该会看到一个加载指示器。

这个应用程序中所有页面共同的一件事是顶部导航栏:

在前几章中,我们使用 Bootstrap 轻松创建了导航栏。然而,在这里我们不能使用 Bootstrap,因为在 React 中,所有的 DOM 元素都是通过组件动态渲染的。然而,Bootstrap 需要 jQuery,而 jQuery 只能在普通的 DOM 上工作,这样它才能在移动设备上查看导航栏时点击汉堡菜单时显示动画,如下面的截图所示:

然而,有几个库可以让您在 React 中使用 Bootstrap,它们为每个 Bootstrap 样式的元素提供了等效的 React 组件。在本项目中,我们将使用一个名为 reactstrap 的库。它需要与之一起安装 Bootstrap 4(alpha 6);因此,我还在项目的起始文件中安装了 Bootstrap 4。

现在,转到书中代码Chapter06\Starter files目录,并在项目根目录中创建.env文件。.env文件应该与REACT_APP_SERVER_URL的完成代码文件中的值相同,这是服务器在控制台中打印的 URL。

从您的终端中的起始文件目录中运行npm install,然后运行npm start。它应该启动起始文件的开发服务器。它将打开浏览器,显示消息“应用程序在这里...”。在 VSCode 中打开文件夹并查看src/App.js文件。它应该在render方法中包含该消息。

起始文件将被编译,会出现很多警告,说没有使用的变量。这是因为我已经在所有文件中包含了导入语句,但还没有使用它们。因此,它告诉您有很多未使用的变量。只需忽略这些警告。

在您的App.js文件顶部,您应该看到我已经从 reactstrap 库导入了一些模块。它们都是 React 组件:

import { Collapse, Navbar, NavbarToggler, Nav, NavItem } from  'reactstrap';

在这里解释每个组件并不重要,因为本章重点是学习 React 而不是样式化 React 组件。因此,要了解 reactstrap,请访问项目主页:reactstrap.github.io/

在您的App类中,在App.js文件中,用以下内容替换render方法的return语句:

    return (
      <div className="App">
        <Navbar color="faded" light toggleable>
          <NavbarToggler right onClick={() => {}} />
          <a className="navbar-brand" href="home">Blog</a>
          <Collapse isOpen={false} navbar>
            <Nav className="ml-auto" navbar>
              <NavItem>
                <a className="nav-link" href="home">Home</a>
              </NavItem>
              <NavItem>
                <a className="nav-link" href="authors">Authors</a>
              </NavItem>
              <NavItem>
                <a className="nav-link" href="new-post">New Post</a>
              </NavItem>
            </Nav>
          </Collapse>
        </Navbar>
      </div>
    );

上述代码将使用 reactstrap 组件,并为博客创建一个顶部导航栏,就像在完成的项目中一样。在 Chrome 的响应式设计模式下查看页面,以查看其在移动设备上的外观。在响应式设计模式下,汉堡菜单将无法使用。

这是因为我们还没有创建任何状态和方法来管理展开和折叠导航栏。在您的App类中,添加以下构造函数和方法:

constructor(props) {
    super(props);
    this.state = {
      isOpen: false,
    };
    this.toggle = this.toggle.bind(this);
}

toggle() {
    this.setState({
      isOpen: !this.state.isOpen
    });
}

这将添加状态变量isOpen,用于识别汉堡菜单的打开/关闭状态,同时切换方法用于通过将isOpen状态的值更改为truefalse来展开或折叠汉堡菜单。

要在导航栏中绑定这些内容,在render方法中执行以下步骤:

  1. 将包含在<Collapse isOpen={false} navbar>组件中isOpen属性的false值替换为this.state.isOpen。该行现在应如下所示:
  <Collapse  isOpen={this.state.isOpen} navbar>
  1. 将包含<NavbarToggler right onClick={()=>{}}的行中onClick属性的空函数()=>{}值替换为this.toggle。该行现在应如下所示:
<NavbarToggler  right  onClick={this.toggle} />

一旦添加了这些行并保存文件,导航栏中的汉堡按钮将在浏览器中正常工作。但是,单击导航栏中的链接将只重新加载页面。在单页面应用程序中,我们无法使用锚标签进行常规导航,因为应用程序只会显示单个页面。在下一节中,我们将看到如何使用 React Router 库在页面之间实现导航。

使用 React Router 实现路由和导航

React Router 通过根据用户在 Web 应用程序中访问的 URL 显示组件来实现路由。React Router 可以在 React.js 和 React Native 中使用。但是,由于我们只关注 React.js,我们应该使用特定的 React Router 库react-router-dom,它处理浏览器上的路由和导航。

实现 React Router 的第一步是将整个App组件包装在react-router-dom<BrowserRouter>组件中。要包装整个应用程序,请在 VSCode 中打开项目目录中的src/index.js文件。

index.js文件的顶部,添加以下导入语句:

import {BrowserRouter  as  Router} from  'react-router-dom';

这将使用名称为 router 的BrowserRouter组件进行导入。一旦您添加了导入语句,请用以下代码替换ReactDOM.render()行:

ReactDOM.render(
  <Router>
    <App />
  </Router>
  ,
  document.getElementById('root')
);

这只是将<App />组件包装在<Router>组件中,这将允许我们在App组件的子组件中使用 React Router。

路由文件

在起始文件中,我在src/routes.js路径中包含了一个routes.js文件。该文件包含了我们在博客中要使用的所有路由的 JSON 对象形式:

const routes = {
  home: '/home',
  authors: '/authors',
  author: '/author/:authorname',
  newPost: '/new-post',
  post: '/post/:id',
};

export default routes;

查看已完成的博客应用程序的主页。URL 将指向'/home'路由。同样,每个页面都有其各自的路由。但是,一些路由具有动态值。例如,如果您在博客文章中单击“阅读更多”,它将带您到具有以下 URL 的页面:

http://localhost:3000/post/487929f5-47bc-47af-864a-f570d2523f3e

在这里,URL 的第三部分是帖子的 ID。为了表示这样的 URL,我在路由文件中使用了'/post/:id',其中 ID 表示 React Router 将理解 ID 将是一个动态值。

您实际上不必在单个路由文件中管理所有路由。我创建了一个路由文件,这样在构建应用程序时更容易添加路由。

在应用程序组件中添加路由

React Router 所做的事情非常简单;它只是根据地址栏中的 URL 呈现一个组件。它为此目的使用历史和位置 Web API,但为我们提供了简单、易于使用的基于组件的 API,以便我们可以快速设置我们的路由逻辑。

要在App.js文件中的组件之间添加导航,请在<Navbar></Navbar>组件之后的render方法中添加以下代码:

  render() {
    return (
      <div className="App">
        <Navbar color="faded" light toggleable>
          ....
        </Navbar>

        <Route exact path={routes.home} component={Home} />
        <Route exact path={routes.post} component={Post} />
        <Route exact path={routes.authors} component={AuthorList} />
        <Route exact path={routes.author} component={AuthorPosts} />
        <Route exact path={routes.newPost} component={NewPost} />
      </div>
    );
  }

此外,如果在添加代码文件后遇到任何问题,请参考已完成的代码文件。我已经在App.js文件中添加了所有的导入语句。路由组件是从react-router-dom包中导入的。前面的路由组件所做的就是:

  • 路由组件将检查当前页面的 URL,并渲染与给定路径匹配的组件。看一下以下路由:
        <Route exact path={routes.home} component={Home} />
  • 当您的 URL 具有路径'/home'(来自路由文件的routes.home的值)时,React Router 将呈现Home组件。

  • 这是每个属性的含义:

  • exact:仅当路径完全匹配时。如果它不在'/home'中,这是可选的:它也将对'/home/otherpaths'保持真实。我们需要精确匹配;因此,我已经包含了它。

  • path:必须与 URL 匹配的路径。在我们的情况下,它是来自路由文件的routes.home变量的'/home'

  • component:当路径与 URL 匹配时必须呈现的组件。

一旦您添加了路由组件,请返回 Chrome 中的应用程序。如果您的应用程序在http://localhost:3000/中运行,您将只看到一个空白页面。但是,如果您单击导航栏中的菜单项,您应该看到相应的组件呈现在页面上!

通过在路由组件之外添加导航栏,我们可以在整个应用程序中轻松重用相同的导航栏。

但是,我们应该让我们的应用程序在第一次加载时自动导航到主页'/home',而不是显示空白页面。为此,我们应该以编程方式替换 URL 为所需的'/home'路径,就像我们在第四章中所做的那样,使用历史对象实时视频通话应用程序

但是我们有一个问题。React Router 为导航维护了自己的历史对象。这意味着我们需要修改 React Router 的历史对象。

使用 withRouter 管理历史记录

React Router 有一个名为withRouter的高阶组件,我们可以使用它将 React Router 的历史、位置和匹配对象作为 props 传递给我们的 React 组件。要使用withRouter,您应该将App组件包装在withRouter()内作为参数。目前,这是我们在App.js文件的最后一行导出App组件的方式:

export default App;

您应该将此行更改为以下内容:

export  default  withRouter(App);

这将向我们的App组件提供三个 props,historylocationmatch对象。对于我们最初的目标,默认情况下显示主页组件,将以下componentWillMount()方法添加到App类中:

  componentWillMount() {
    if(this.props.location.pathname === '/') {
      this.props.history.replace(routes.home);
    }
  }

前面的代码做了什么:

  1. 由于它是写在componentWillMount中,它将在App组件呈现之前执行。

  2. 它将使用location.pathname属性检查 URL 的路径。

  3. 如果路径是'/',即默认的http://localhost:3000/,它将自动用http://localhost:3000/home替换历史记录和 URL。

  4. 这样,每当用户导航到网页的根 URL 时,home组件就会自动呈现。

现在,在浏览器中打开http://localhost:3000/,它将显示主页。但是,我们在这里还有另一个问题。每次单击导航栏中的链接时,都会导致页面重新加载。由于我们的博客是单页面应用程序,应该避免重新加载,因为所有资产和组件已经下载。在导航期间每次单击重新加载应用程序只会导致不必要地多次下载整个应用程序。

Proptype 验证

每当我们向我们的 React 组件传递 props 时,建议进行 proptype 验证。proptype 验证是 React 开发构建中发生的简单类型检查,用于检查是否正确地向我们的 React 组件提供了所有 props。如果没有,它将显示一个警告消息,这对于调试非常有帮助。

可以在'prop-types'包中定义可以传递给我们的 React 组件的所有类型的 props,该包将与create-react-app一起安装。您可以看到我已经在文件顶部包含了以下导入语句:

import  PropTypes  from  'prop-types';

要对我们的App组件进行 proptype 验证,在App类内部,在构造函数之前添加以下静态属性(在顶部声明 proptypes 将使得知道 React 组件依赖的 props 更容易):

  static propTypes = {
    history: PropTypes.object.isRequired,
    location: PropTypes.object.isRequired,
    match: PropTypes.object.isRequired,
  }

如果您对在哪里包含前面的代码片段感到困惑,请参考已完成的代码文件。这就是 proptype 验证的工作原理。

考虑前面代码的第二行history: PropTypes.object.isRequired。这意味着:

  • history应该是App组件的一个 prop

  • history的类型应该是对象

  • history prop 是必需的(isRequired是可选的,对于可选的 props 可以删除)

有关 proptype 验证的详细信息,请参阅 React 文档页面facebook.github.io/react/docs/typechecking-with-proptypes.html

使用 NavLink 进行无缝导航

React Router 有一个完美的解决方案来解决导航期间的重新加载问题。React Router 提供了LinkNavLink组件,您应该使用它们来代替传统的锚标签。NavLinklink组件具有更多功能,例如在链接处于活动状态时指定活动类名。因此,我们将在我们的应用程序中使用NavLink

例如,考虑我们在App.js文件中用于导航到作者页面的以下锚标签:

<a className="nav-link" href="authors">Authors</a>

我们可以将其替换为 React Router 的NavLink组件,如下所示:

  <NavLink  className={'nav-link'} activeClassName={'active'} to={routes.authors}>Authors</NavLink>

以下是NavLink JSX 组件的属性的作用:

  • className:当NavLink在 DOM 中呈现为锚标签时给元素的类名。

  • activeClassName:当链接是当前活动页面时给元素的类名。

  • to:链接将导航到的路径。

请参考完成的代码文件中的App.js文件,并将App.js文件中的所有锚点标签替换为NavLink组件。一旦完成这个更改,每当你点击导航栏中的菜单项时,你的应用程序将无缝导航,无需任何页面重新加载。

此外,由于.active类被添加到活动链接中,Bootstrap 样式将在导航栏中的菜单项上突出显示略深的黑色,当相应的导航栏菜单项处于活动状态时。

我们已经成功为我们的应用程序创建了导航栏并实现了一些基本的路由。从我们的路由文件中,你可以看到我们的博客有五个页面。我们将在下一节构建首页。

博客首页

通过探索完成的代码文件中的应用程序,你应该已经对博客的首页是什么样子有了一个概念。我们的博客有一个简单的首页,列出了所有的帖子。你可以点击帖子中的“阅读更多”按钮来详细阅读帖子。由于这个博客是一个学习目的的项目,这个简单的首页现在已经足够了。

理想情况下,你应该从头开始创建每个 React 组件。然而,为了加快开发过程,我已经创建了所有无状态组件和有状态父组件的样板。所有的组件都在src/Components目录中。由于 React 组件的名称应该以大写字母开头,我已经创建了所有组件目录名称以大写字母开头,以表示它们包含 React 组件。这是Components目录的文件结构:

.
├── Author
│   ├── AuthorList.js
│   └── AuthorPosts.js
├── Common
│   ├── ErrorMessage.js
│   ├── LoadingIndicator.js
│   ├── PostSummary.js
│   └── SuccessMessage.js
├── Home
│   └── Home.js
├── NewPost
│   ├── Components
│   │   └── PostInputField.js
│   └── NewPost.js
└── Post
    └── Post.js

我们博客的首页是src/Components/Home/Home.js文件中的Home组件。目前,Home组件的render方法只呈现了一个Home文本。我们需要在首页显示帖子列表。我们将如何实现这一点:

  1. 服务器有/posts端点,它以GET请求返回一个帖子数组。因此,我们可以使用这个 API 来检索帖子数据。

  2. 由于Home是一个有状态的组件,我们需要为Home组件中的每个操作维护状态。

  3. Home组件从服务器检索数据时,我们应该有一个状态--loading,它应该是一个布尔值,用于显示加载指示器。

  4. 如果网络请求成功,我们应该将帖子存储在一个状态--帖子中,然后可以用它来呈现所有的博客帖子。

  5. 如果网络请求失败,我们应该简单地使用另一个状态--hasError,它应该是一个布尔值,用于显示错误消息。

让我们开始吧!首先,在你的Home类中,添加以下构造函数来定义组件的状态变量:

  constructor() {
    super();

    this.state = {
      posts: [],
      loading: false,
      hasError: false,
    };
  }

一旦定义了状态,让我们进行网络请求。由于网络请求是异步的,我们可以在componentWillMount中进行,但如果你想进行同步操作,那将延迟渲染。最好是在componentDidMount中添加它。

为了进行网络请求,我已经添加了apiCall服务,我们在src/services/api/apiCall.js文件中使用了它,并在Home.js文件中包含了导入语句。以下是componentWillMount方法的代码:

  componentWillMount() {
    this.setState({loading: true});
    apiCall('posts', {}, 'GET')
    .then(posts => {
      this.setState({posts, loading: false});
    })
    .catch(error => {
      this.setState({hasError: true, loading: false});
      console.error(error);
    });
  }

前面的函数做了什么:

  1. 首先,它将把状态变量 loading 设置为true

  2. 调用apiCall函数来进行网络请求。

  3. 由于网络请求是一个异步函数,render方法将被执行,组件将被渲染。

  4. 渲染完成后,网络请求将在 3 秒内完成(我在服务器上设置了这么长的延迟)。

  5. 如果apiCall成功并且数据被检索到,它将使用从服务器返回的帖子数组更新帖子的状态,并将加载状态设置为false

  6. 否则,它将把hasError状态设置为true,并将加载状态设置为false

为了测试前面的代码,让我们添加渲染帖子所需的 JSX。由于 JSX 部分需要大量代码,我已经在src/Components/Common目录中创建了用于此页面的无状态组件,并在Home.js文件的顶部包含了导入语句。用以下代码替换render方法的return语句:

    return (
      <div className={`posts-container container`}>
        {
          this.state.loading
          ?
            <LoadingIndicator />
          :
            null
        }
        {
          this.state.hasError
          ?
            <ErrorMessage title={'Error!'} message={'Unable to retrieve posts!'} />
          :
            null
        }
        {
          this.state.posts.map(post => <PostSummary key={post.id} post={post}>Post</PostSummary>)
        }
      </div>
    );

一旦你添加了前面的代码片段,请保持服务器运行,并访问博客的主页。它应该列出所有帖子,如下面的截图所示:

然而,如果你关闭服务器并重新加载页面,它将显示错误消息,如下面的截图所示:

一旦你了解了状态和生命周期方法如何与 React 一起工作,实现过程就非常简单。然而,在这一部分,我们仍然需要涵盖一个重要的主题,那就是我之前为你创建的子组件,供你使用。

使用子组件

让我们来看看ErrorMessage组件,我已经创建了它,用于在无法从服务器检索帖子时显示错误消息。这是ErrorMessage组件包含在render方法中的方式:

<ErrorMessage title={'Error!'} message={'Unable to retrieve posts!'} />

如果ErrorMessage是通过扩展Component接口创建的有状态组件。ErrorMessage JSX 元素的属性 title 和 message 将成为子ErrorMessage组件的 props。然而,如果你看一下ErrorMessage元素的实现,你会发现它是一个无状态功能组件:

const ErrorMessage = ({title, message}) => (
  <div className="alert alert-danger">
    <strong>{title}</strong> {message}
  </div>
);

因此,以下是功能组件的属性工作方式:

  • 由于功能组件不支持状态或属性,属性成为函数调用的参数。考虑以下 JSX 元素:
<ErrorMessage title={'Error!'} message={'Unable to retrieve posts!'} />
  • 这将相当于一个带有对象作为参数的函数调用:
ErrorMessage({
  title: 'Error!',
  message: 'Unable to retrieve posts!',
})
  • 通过之前学到的解构赋值,你可以在我们的函数中使用参数,如下所示:
const ErrorMessage = ({title, message}) => {}; // title and message retrieved as normal variables
  • 我们也可以对功能组件使用propType验证,但在这里,propTypes用于验证函数的参数。

每当你在功能组件中输入 JSX 代码时,请确保在文件中包含import React from 'react'语句。否则,Babel 编译器将不知道如何将 JSX 编译回 JavaScript。

PostSummary组件带有一个“阅读更多”按钮,通过它你可以在页面上查看整个帖子的详情。目前,如果你点击这个链接,它只会显示“帖子详情”文本。因此,让我们通过创建帖子详情页面来完成我们的博客主页。

显示帖子详情

博客中的每篇帖子都有一个与之关联的唯一 ID。我们需要使用这个 ID 从服务器检索帖子的详细信息。当你点击“阅读更多”按钮时,我已经创建了PostSummary组件,以便它将带你到路由'/post/:id',其中:id包含帖子的 ID。帖子 URL 将如下所示:

http://localhost:3000/post/487929f5-47bc-47af-864a-f570d2523f3e

这里,第三部分是帖子 ID。在 VSCode 中从src/Components/Post/Post.js路径打开Post.js文件。我们需要访问 URL 中存在的 ID,以在我们的Post组件中访问 ID。为了访问 URL 参数,我们需要使用 React Router 的 match 对象。对于这个过程,我们将不得不像我们为App组件做的那样,将我们的Post组件包装在withRouter()中。

在你的Post.js文件中,将导出语句更改为以下内容:

export default withRouter(Post);

此外,由于这将为Post组件提供historylocationmatch props,我们还应该向Post类添加原型验证:

  static propTypes = {
    history: PropTypes.object.isRequired,
    location: PropTypes.object.isRequired,
    match: PropTypes.object.isRequired,
  }

我们必须为我们的Post组件创建状态。这些状态与Home组件的状态相同;但是,这里我们将有一个帖子状态(对象),而不是帖子状态(数组),因为这个页面只需要一个帖子。在Post类中,添加以下构造函数:

  constructor() {
    super();

    this.state = {
      post: {},
      loading: false,
      hasError: false,
    };
  }

在服务器的 swagger 文档中,你应该看到一个 API 端点,GET /post/{id},我们将在本章中使用它来从服务器检索Post。我们将在这个组件中使用的componentWillMount方法与之前的Home组件非常相似,只是我们将不得不从 URL 中检索id参数。这可以通过以下代码行来完成:

const postId = this.props.match.params.id;

在这里,this.props.match是由 React Router 的withRouter()组件提供给Post组件的一个 prop。因此,你的componentWillMount方法应该如下:

  componentWillMount() {
    this.setState({loading: true});
    const postId = this.props.match.params.id;
    apiCall(`post/${postId}`, {}, 'GET')
    .then(post => {
      this.setState({post, loading: false});
    })
    .catch(error => {
      this.setState({hasError: true, loading: false});
      console.error(error);
    });
  }

最后,在render方法中,添加以下代码:

    return(
      <div className={`post-container container`}>
        {
          this.state.loading
          ?
            <LoadingIndicator />
          :
            null
        }
        {
          this.state.hasError
          ?
            <ErrorMessage title={'Error!'} message={`Unable to retrieve post!`} />
          :
            null
        }
        <h2>{this.state.post.title}</h2>
        <p>{this.state.post.author}</p>
        <p>{this.state.post.content}</p>
      </div>
    );

这将创建帖子页面。现在,你应该能够通过点击“阅读更多”按钮来查看整篇帖子。这个页面将以与主页相同的方式工作。通过使用可重用组件,你可以看到我们已经大大减少了代码量。

添加新的博客帖子

我们已经成功为我们的博客建立了主页。下一个任务是构建作者列表页面。然而,我会把作者列表的构建留给你。你可以参考已完成的代码文件并构建作者列表页面。这将是一个很好的练习。

因此,我们还剩下最后一个页面,即新帖子页面。我们将要使用的 API 是POST /post,你可以在 swagger 文档中看到。帖子请求的主体将以以下形式出现:

{
  "id": "string",
  "title": "string",
  "content": "string",
  "datetime": "string",
  "author": "string"
}

在这里,id是博客帖子的唯一 ID,datetime是作为字符串的时代时间戳。通常,这两个属性是由服务器生成的,但由于我们只是在我们的项目中使用模拟服务器,所以我们需要在客户端生成它们。

src/Components/NewPost/NewPost.js路径中打开NewPost.js文件。这个组件需要三个输入字段:

  • 作者名称

  • 帖子标题

  • 帖子文本

我们需要维护这三个字段的状态。博客帖子将需要textarea,它将根据输入的博客帖子动态增加其大小(行数)。因此,我们需要维护一个用于管理行数的状态。

除此之外,我们还需要在前一个组件中使用的加载和网络请求的hasError状态。我们还需要一个成功状态,用于向用户指示帖子已成功提交。

在你的NewPost类中,创建一个带有所有必需状态变量的constructor,如下所示:

  constructor() {
    super();

    this.state = {
      author: '',
      title: '',
      content: '',
      noOfLines: 0,
      loading: false,
      success: false,
      hasError: false,
    };
  }

与之前的组件不同,我们不仅仅是从服务器上显示检索到的数据,而是需要在这个组件中从输入字段发送数据到服务器。每当涉及到输入字段时,这意味着我们需要很多方法来编辑输入字段的状态。

用已完成的代码文件中的NewPost.js文件的render方法替换你的NewPost.js文件的render方法。由于作者名称和标题都使用相同的输入字段,我为它们创建了一个简单的PostInputField组件。这是PostInputField组件的样子:

        <PostInputField
          className={'author-name-input'}
          id={'author'}
          title={'Author Name:'}
          value={this.state.author}
          onChange={this.editAuthorName}
        />

这是相应的PostInputField函数的样子:

const PostInputField = ({className, title, id, value, onChange}) => (
  <div className={`form-group ${className}`}>
    <label htmlFor={id}>{title}</label>
    <input type="text" className="form-control" id={id} value={value} onChange={onChange}/>
  </div>
); 

你可以看到,我基本上是在返回的 JSX 元素中使classNamelabelidvalueonChange属性动态化。这将让我在同一个表单中为多个输入元素重用整个输入字段。由于最终呈现的 DOM 元素将具有不同的类和 ID,但共享相同的代码,你所需要做的就是导入并在你的组件中使用它。这将节省许多长时间的开发工作,并且在许多情况下,它比你在上一章学到的自定义元素更有效。

让我们看看textarea是如何工作的。

render方法中,您应该看到以下行,我们正在使用状态变量创建一个noOfLines常量:

  const  noOfLines  =  this.state.noOfLines  <  5  ?  5  :  this.state.noOfLines;

this.state.noOfLines 将包含博客文章中的行数。使用这个值,如果行数少于5,那么我们将把行属性的值设为5。否则,我们可以将行属性增加到博客文章中的行数。

这是文本输入的 JSX 的样子:

<div className="form-group content-text-area">
  <label htmlFor="content">Post:</label>
  <textarea className="form-control" rows={noOfLines} id="content" value={this.state.content} onChange={this.editContent}></textarea>
</div>

您可以看到rows属性的值是在render方法中创建的noOfLines常量。在文本区域字段之后,我们有以下部分:

  • 加载部分,我们可以根据网络请求状态(this.state.loading)显示<LoadingIndicator />或提交按钮

  • hasError 和成功部分,我们可以根据来自服务器的响应显示成功或错误消息

让我们创建用于更新其值的输入字段使用的方法。在您的NewPost类中,添加以下方法:

  editAuthorName(event) {
    this.setState({author: event.target.value});
  }

  editTitle(event) {
    this.setState({title: event.target.value});
  }

  editContent(event) {
    const linesArray = event.target.value.split('\n');
    this.setState({content: event.target.value, noOfLines: linesArray.length});
  }

在这里,editContenttextinput字段使用的方法。您可以看到我使用了 split('\n')将行根据换行符分成数组。然后我们可以使用数组的长度来计算帖子中的行数。还要记得在构造函数中为所有方法添加this绑定。否则,从 JSX 调用的方法将无法使用类的this变量:

constructor() {
  ...

  this.editAuthorName = this.editAuthorName.bind(this);
  this.editContent = this.editContent.bind(this);
  this.editTitle = this.editTitle.bind(this);
}

提交文章

添加文章部分的最后一部分是提交文章。在这里,我们需要做两件事:为文章生成 UUID,并以 epoch 时间戳格式获取当前日期和时间:

  • 为了生成用于帖子 ID 的 UUID,我已经包含了uuid库。您只需调用uuidv4(),它将返回您要使用的 UUID。

  • 要以epoch时间戳格式创建日期和时间,您可以使用以下代码:

  const  date  =  new  Date();
  const  epoch  = (date.getTime()/1000).toFixed(0).toString();

JSX 中的提交按钮已经设置为在单击时调用this.submit()方法。因此,让我们创建AddPost类的submit方法,使用以下代码:

  submit() {
    if(this.state.author && this.state.content && this.state.title) {
      this.setState({loading: true});

      const date = new Date();
      const epoch = (date.getTime()/1000).toFixed(0).toString();
      const body = {
        id: uuidv4(),
        author: this.state.author,
        title: this.state.title,
        content: this.state.content,
        datetime: epoch,
      };

      apiCall(`post`, body)
      .then(() => {
        this.setState({
          author: '',
          title: '',
          content: '',
          noOfLines: 0,
          loading: false,
          success: true,
        });
      })
      .catch(error => {
        this.setState({hasError: true, loading: false});
        console.error(error);
      });

    } else {
      alert('Please Fill in all the fields');
    }
  }

此外,为了将 this 与提交按钮绑定,还要添加以下代码到您的构造函数中:

this.submit = this.submit.bind(this)

这就是前面的提交方法所做的事情:

  1. 它构造了网络请求的主体,这是我们需要添加的帖子,然后向 POST/post 服务器端点发出请求。

  2. 如果请求成功,它将使用状态变量将输入字段重置为空字符串。

  3. 如果请求失败,它将简单地将hasError状态设置为 true,这将显示给我们一个错误消息。

如果一切正常,然后点击主页,你应该看到你的新文章添加到博客中。恭喜!你成功地使用 React 构建了自己的博客应用程序!

尝试自己构建作者列表页面,并在构建时遇到任何问题时,通过参考已完成的文件来获得帮助。

生成生产构建

我们在每一章中一直在做的一件事就是生成生产构建。我们通过在.env文件中将NODE_ENV变量设置为production,然后在终端中运行npm run webpack来实现这一点。然而,对于本章,由于我们使用的是create-react-app,我们不必担心设置环境变量。我们只需要在项目根目录的终端中运行以下命令:

npm run build

运行此命令后,您将获得已完成所有优化的生产构建,并准备在项目的构建目录中使用。使用create-react-app生成构建就是这么简单!

生成生产构建后,在项目的构建目录中运行http-server,并通过访问http-server在控制台上打印的 URL 来查看应用程序的运行情况。

React 有一个浏览器扩展,可以让你调试组件层次结构,包括组件的状态和属性。由于本章中我们只是在使用基本应用程序,所以我们没有使用那个工具。但是,如果你正在使用 React 构建应用程序,你可以自己试一试,网址是github.com/facebook/react-devtools

总结

这本书旨在帮助你了解 React 的基础知识。由于我们在本章中只构建了一个简单的应用程序,所以我们没有探索 React 的许多很酷的功能。在本章中,你从一个简单的计数器开始,然后在 React 速成课程中构建了一个待办事项列表,最后,使用create-react-app工具和一些库,如react-router和 reactstrap,构建了一个简单的博客应用程序。

作为应用程序的简单视图层,React 确实需要一些库一起使用,才能使其像一个完整的框架一样工作。React 并不是唯一的 JavaScript 框架,但它绝对是一种革新现代 UI 开发的独特库。

关于 React 和我们刚刚构建的博客应用程序,一切都很棒,除了博客中的每个页面加载都要花费令人讨厌的 3 秒钟。嗯,我们可以通过使用浏览器的 localStorage API 离线存储帖子详情并使用它们来更新状态来解决这个问题。但是,再一次地,我们的应用程序对服务器进行了太多的网络请求,以检索在先前的请求中已经检索到的数据。

在你开始考虑一些复杂的方法来离线存储并重复使用数据之前,我们在这本书中还需要学习一件事,那就是正在引领现代前端开发风潮的新库-Redux。

第七章:Redux

嗨!在前一章中博客的工作做得很好,欢迎来到本书的最后一章,这是前一章中构建的博客的续集。在本章中,我们将通过学习使用 Redux 进行集中状态管理来解决博客中令人讨厌的 3 秒加载问题。

就像我们在前一章中只涵盖了 React 的基础知识一样,本章很简单,将涵盖 Redux 的基本概念,这将永远改变构建 Web 应用程序的方式。这让我们只剩下一个简单的问题:Redux 是什么?

Redux 是什么?

根据 Redux 文档:redux.js.org/,Redux 是“JavaScript 应用程序的可预测状态容器”。为了详细解释 redux,让我们来看看 Facebook 构建的应用程序架构flux的故事。

Flux

对于像 ToDo 列表或我们在前一章中构建的博客这样的小型应用,React 都很好,但对于像 Facebook 这样的应用就不行了。Facebook 有数百个有状态的 React 组件用于渲染 Web 应用程序。在我们的博客中,每个 React 组件都有自己的状态,并且每个有状态的组件都会发出网络请求来填充这些状态数据。

一旦父组件获取数据,它将作为 props 传递给子组件。但是,子组件也可以有自己的状态。同样,在同一级别可能有两个或更多需要相同数据状态的父组件。React 的单向数据流在这里存在严重问题。如果将数据作为 props 传递给子组件,子组件无法更改 props,因为这将导致数据的变异。因此,子组件将不得不调用父组件中的一个方法,该方法也应该作为 props 传递以进行简单的更改。想象一下,您有数十甚至数百个父子嵌套组件,其中控制总是必须传递回父组件,并且必须在父子组件之间正确管理数据流。

Facebook 需要一个简单且可维护的解决方案来管理所有这些组件之间的数据。他们提出的理想解决方案是将状态从 React 组件中取出,并在一个称为stores的独立位置进行管理。计划很简单 - 我们将状态(数据)从 React 组件中取出,并将其保存在单独的 stores 中。然后所有的 React 组件都将依赖于 stores 来获取它们的数据。因此,您必须将 stores 中所需的数据作为 props 传递给所有必要的组件。

stores 中的任何更改都将导致所有依赖组件中的 props 发生变化,每当 props 发生变化时,React 将自动重新渲染 DOM。他们提出了称为actionsdispatchers的特殊函数,它们是唯一能够更新 stores 的函数。因此,如果任何组件需要更新 stores,它将使用所需的数据调用这些函数,它们将更新 stores。由于 stores 被更新,所有组件将接收新的 props,并且它们将使用新数据重新渲染。

这解释了 flux 的架构。flux 架构不仅为 React 创建,还为所有 JavaScript 框架的一般使用创建。然而,尽管 flux 的概念很简单,但实现起来相当复杂,后来通过一种新的状态管理库 Redux 来克服了这一问题。由于本章重点介绍 Redux,我们不会讨论 flux;但是,如果您对了解更多关于 flux 感兴趣,可以访问其官方页面:facebook.github.io/flux/

Redux 简介

使用 flux 的开发人员面临的主要问题是应用程序状态不够可预测。这可能是 Redux 自称为 JavaScript 应用程序的可预测状态容器的原因。Redux 被创建为一个可以与任何 JavaScript 应用程序一起使用的独立库。要在 React 中使用 Redux,我们将需要另一个名为react-redux的库,这是由 React 社区提供的,可在github.com/reactjs/react-redux上找到。

Redux 拥有最好的开源库文档之一。它甚至附带了由库的创建者Dan Abramov提供的两个免费视频课程,这些课程可以在文档的主页上找到。在我们开始向博客应用程序添加 Redux 之前,让我们看看 Redux 的工作原理以及它将如何帮助改进我们的 React 应用程序。

考虑我们在前一章创建的博客应用。我们有一个App 组件作为父组件,所有其他组件都是App 组件的子组件。在我们的情况下,每个组件都有自己的状态,如下所示:

如果我们使用 flux,它将具有多个存储,并且我们可以将Post 组件列表的状态和Author 组件列表的状态作为两个存储,并让整个应用程序共享这些存储。但是,如果我们使用 Redux,它将维护一个单一存储,其中将保存整个应用程序的状态。您的应用程序结构将如下所示:

如前面的图像所示,Redux 将创建一个单一存储,保存状态,然后将其作为 props 提供给所需的组件。由于整个应用程序具有单一状态,因此更容易维护,并且应用程序状态对开发人员更加可预测。

那么,让我们来看看 Redux 是如何管理它的存储的。Redux 的实现有三个重要部分:

  • Store

  • Actions

  • Reducers

Store

存储是包含整个应用程序状态的集中状态。与普通状态一样,存储也是一个简单的 JavaScript 对象,只包含纯数据(存储对象不应包含任何方法)。此外,状态是只读的,这意味着应用程序的其他部分不能直接更改状态。修改状态的唯一方法是发出一个动作。

Actions

Actions 是设计执行任务的函数。每当组件需要修改状态时,它将调用一个 action。Actions 作为 props 提供给组件。动作函数的返回类型应该是一个普通对象。动作返回的对象被提供给 reducers。

Reducers

Reducers 是简单的方法,其功能是更新存储。由于存储是一个以键值对组织的 JavaScript 对象,每个键都有自己的 reducer。reducer 函数接受两个参数,从动作返回的对象和当前状态,并返回一个新状态。

在博客中实现 Redux

现在您已经对为什么使用 Redux 有了一个很好的理解,让我们开始在我们的博客应用程序中实现 Redux。本章使用与前一章相同的服务器,因此在本章工作时,您还需要保持服务器运行。

本章的起始文件与前一章的完成代码文件相同,只是package.json文件中包含以下新库作为依赖项:

  • redux

  • react-redux

  • redux-thunk

  • redux-persist

  • localforage

在构建我们的应用程序时,我们将看到这些库各自的作用。我们将使用与前一章相同的.env文件,其中包含REACT_APP_SERVER_URL环境变量,其值是运行服务器的 URL。在终端中导航到项目根文件夹,并执行npm install,然后执行npm start以启动应用程序的开发服务器。

文件夹结构

在我们开始使用 Redux 之前,我们需要为 Redux 组件定义一个适当的文件夹结构。目前,我们的src/目录看起来是这样的:

.
├── App.css
├── App.js
├── App.test.js
├── assets
├── Components
├── index.css
├── index.js
├── logo.svg
├── registerServiceWorker.js
├── routes.js
└── services

我们需要创建一个名为redux的新目录,其中将保存我们的storeactionsreducers。现在,目录结构将如下所示:

.
├── App.css
├── App.js
├── App.test.js
├── assets
├── Components
├── index.css
├── index.js
├── logo.svg
├── redux
├── registerServiceWorker.js
├── routes.js
└── services

redux目录中,您需要创建四个不同的目录,即actionsactionTypesreducersstore。您的redux目录现在看起来是这样的:

.
├── actions
├── actionTypes
├── reducers
└── store

您可能会想到actionTypes目录。在 Redux 中,所有操作都应该是预定义的。您不希望发生未知的操作。因此,我们将创建actionTypes文件夹,其中将保存应用程序可以执行的所有操作的常量。

既然我们有了所需的文件夹结构,让我们开始创建我们的初始状态。

初始状态

我们总是在构造函数中为我们的 React 组件定义初始状态,我们在那里创建状态变量。同样,我们也需要为我们的 Redux 创建一个初始状态。唯一的区别是 Redux 状态将需要保存整个应用程序的状态。

让我们制定初始状态的外观:

  • 我们在博客主页上使用的数据也是帖子的数组,因此我们需要一个帖子数组

  • 用于显示作者列表的数据也是一个数组

  • 我们还需要维护 AJAX 调用及其成功或错误状态的状态

在您的store目录中,创建一个新文件--initialState.js--并添加包含initialState对象的以下代码:

const initialState = {
  posts: [

  ],
  authors: [

  ],
  ajaxCalls: {
    getAllPosts: {
      loading: false,
      hasError: false,
    },
    getAuthors: {
      loading: false,
      hasError: false,
    },
    addPost: {
      loading: false,
      hasError: false,
    }
  }
};

export default initialState;

正如您所看到的,initialState常量包含了一个空数组用于帖子和作者,以及一个包含了三个网络请求(AJAX 调用)的状态信息的对象,这些我们将在这个应用程序中使用。

一旦我们添加了 Redux,我们的应用程序将只需要进行三个网络请求--一个用于获取所有帖子,一个用于获取所有作者,第三个用于添加新帖子。如果我们想在帖子详情页面看到一个帖子,我们可以轻松地使用我们在第一个网络请求中得到的帖子数组。

您的redux文件夹现在应该是这样的:

.
├── actions
├── actionTypes
├── reducers
└── store
    └── initialState.js

操作类型

现在我们已经准备好了初始状态,让我们定义我们的博客应用程序可以执行的所有操作。在我们的博客中,操作实际上就是我们进行检索数据的网络请求。每个网络请求将与四个操作相关联。

考虑一下我们从服务器获取所有博客帖子的请求。与这个网络请求相关的操作将如下所示:

  • 开始 AJAX 调用

  • 网络请求成功

  • 网络请求失败

  • 获取帖子数据

因此,在您的redux/actionTypes目录中,创建一个actionTypes.js文件,其中将保存应用程序中将发生的所有操作的常量值。在actionTypes.js文件中,添加以下代码:

const actions = {

  GET_POSTS_AJAX_CALL_START : 'GET_POSTS_AJAX_CALL_START',
  GET_POSTS_AJAX_CALL_SUCCESS: 'GET_POSTS_AJAX_CALL_SUCCESS',
  GET_POSTS_AJAX_CALL_FAILURE: 'GET_POSTS_AJAX_CALL_FAILURE',
  GET_POSTS: 'GET_POSTS',

  GET_AUTHORS_AJAX_CALL_START: 'GET_AUTHORS_AJAX_CALL_START',
  GET_AUTHORS_AJAX_CALL_SUCCESS: 'GET_AUTHORS_AJAX_CALL_SUCCESS',
  GET_AUTHORS_AJAX_CALL_FAILURE: 'GET_AUTHORS_AJAX_CALL_FAILURE',
  GET_AUTHORS: 'GET_AUTHORS',

  ADD_POST_AJAX_CALL_START: 'ADD_POST_AJAX_CALL_START',
  ADD_POST_AJAX_CALL_SUCCESS: 'ADD_POST_AJAX_CALL_SUCCESS',
  ADD_POST_AJAX_CALL_FAILURE: 'ADD_POST_AJAX_CALL_FAILURE',
  ADD_POST: 'ADD_POST',

};

export default actions;

您的redux文件夹现在应该有以下结构:

.
├── actions
├── actionTypes
│   └── actionTypes.js
├── reducers
└── store
    └── initialState.js

我们已经创建了actionTypes,我们可以在整个应用程序中使用它,因此让我们创建应用程序应该使用的操作来更新状态。

操作

当 React 组件需要修改应用程序的状态时,会从 React 组件中分派操作。我们的应用程序需要两个操作,一个用于帖子页面,一个用于作者页面。然而,就像前一章一样,我只会专注于帖子页面;完成本章后,您可以继续处理作者页面。已完成的代码文件也包含了作者页面的操作,因此您可以将其用作参考。

让我们开始吧。在actions目录中,创建两个文件,authorActions.jspostActions.js。您的redux文件夹应该是这样的:

.
├── actions
│   ├── authorActions.js
│   └── postActions.js
├── actionTypes
│   └── actionTypes.js
├── reducers
└── store
    └── initialState.js

在这里,将authorActions.js文件保留为空,我们将在postActions.js文件上工作。标准操作函数应该如下所示:

const sumAction = (a, b) => {
  return {
    type: 'SUM_TWO_NUMBERS',
    payload: { answer: a+b }
  }
};

正如你所看到的,该操作返回一个带有两个属性的对象,即typepayloadtype属性被reducers用来识别发生的操作类型,而payload传递了该操作的结果。payload是可选的,因为有些操作不会产生直接的结果,但是所有操作返回的对象中应该包含type属性。

这对于简单的操作非常有效,比如我们在前面示例代码中看到的两个数字的和,这是同步的。然而,大多数情况下,我们在应用程序中执行的操作是异步的,我们不能简单地从这些操作中返回一个 JSON 对象。

为了解决这个问题并执行异步操作,Redux 有一个叫做中间件的概念。中间件是可以影响 Redux 工作方式的库,特别是在有异步函数的操作中。我们将在这个应用程序中用于此目的的中间件是redux-thunk库。这个库已经包含在本章起始文件的package.json文件中,并且在你执行npm install时应该已经安装了。

因此,这就是redux-thunk的工作原理。redux-thunk允许操作分派其他操作,而不是返回一个普通的 JavaScript 对象。这很有用,因为在异步事件运行时,我们可以调用任意数量的操作。返回其他操作的操作具有以下奇怪的语法:

const ajaxRequestAction = () => {         // Action
  return dispatch => {                    // dispatcher
    makeAjaxRequest()                     // asynchronous code
    .then(response => {
      dispatch(successAction(response));  // dispatch successAction
    })
    .catch(error => {
      dispatch(errorAction(error));       // dispatch errorAction
    });
  }
}

const successAction = (response) => {
  return {
    type: 'REQUEST_SUCCESS',
    payload: { response },
  };
}

const errorAction = (error) => {
  return {
    type: 'REQUEST_FAILURE',
    payload: { error },
  };
}

前面的语法一开始很难理解,但是如果你仔细看,ajaxRequestAction将返回另一个函数而不是返回一个对象。该函数将以dispatch作为其参数。

让我们将ajaxRequestAction返回的函数称为调度程序仅供参考)。一旦我们进入调度程序,我们可以执行任何需要的异步操作。调度程序不需要返回任何值。但是,调度程序有能力分派其他操作。

让我们在postActions.js文件中为我们博客的帖子创建操作。在你的postActions.js文件中,你首先需要添加两个导入语句:

import actions from '../actionTypes/actionTypes';
import apiCall from '../../services/api/apiCall';

第一个是我们在actionTypes文件夹中创建的操作对象。这包含了我们应用程序中可以执行的所有操作。第二个是apiCall服务,它将发出网络请求。

在我们的博客中需要执行的两种类型的操作:

  • 获取所有帖子

  • 添加新帖子

获取所有帖子

通常,我们的 React 组件只需要触发一个操作--getAllPosts()--它将发出网络请求并返回帖子数据。这个操作将是我们的调度程序。这个操作将开始网络请求并根据网络请求的结果分派所有其他操作。在你的postActions.js文件中,添加以下代码:

export const getAllPosts = () => {

  return dispatch => {                   // Create the dispatcher

    dispatch(postsApiCallStart());       // Dispatch - api call started

    apiCall('posts', {}, 'GET')
      .then(posts => {
        dispatch(postsApiCallSuccess()); // Dispatch - api call success 
        dispatch(getPosts(posts));       // Dispatch - received posts array
      })
      .catch(error => {
        dispatch(postsApiCallFailure()); // Dispatch - api call failed
        console.error(error);
      });

  };

};

注意在getAllPosts函数之前的export关键字。这是因为所有操作将从 React 组件内部使用,因此我们在它们前面加上export关键字,以便以后可以导入它们。

我们的调度程序getAllPosts将发出网络请求并分派所有其他正常操作,这些操作将被我们应用程序的 reducers 使用。将以下代码添加到你的postActions.js文件中,其中包含了getAllPosts操作分派的所有操作的代码:

export const postsApiCallStart = () => {
  return {
    type: actions.GET_POSTS_AJAX_CALL_START,
  };
};

export const postsApiCallSuccess = () => {
  return {
    type: actions.GET_POSTS_AJAX_CALL_SUCCESS,
  };
};

export const postsApiCallFailure = () => {
  return {
    type: actions.GET_POSTS_AJAX_CALL_FAILURE,
  };
};

export const getPosts = (posts) => {
  return {
    type: actions.GET_POSTS,
    payload: { posts },
  };
};

跟踪 API 调用状态的操作不需要返回payload。由于其状态将是一个布尔值,因此它只返回操作的类型。然而,getPosts操作应返回帖子详情,除了操作类型之外还返回payload,即帖子数组。

对于简单的网络请求来说,这看起来是很多代码,但是相信我,一旦你的应用程序扩展起来,这些是你在需要获取所有帖子时唯一需要的操作。

您应该始终使用在actionTypes文件中创建的动作对象来指定动作的类型。这样,您可以防止团队中的其他开发人员意外创建意外的动作。

添加新的帖子

由于添加帖子是与帖子相关的动作,我们将在同一个postActions.js文件中添加动作。在您的postActions.js文件中添加以下代码,用于addNewPost动作,它还充当添加新帖子的分发器:

export const addNewPost = (body) => {
  return dispatch => {

    dispatch(addPostApiCallStart());

    apiCall(`post`, body)
    .then(() => {

      dispatch(addPostApiCallSuccess());
      dispatch(getAllPosts());             // Dispatch - getAllPosts action

    })
    .catch(error => {

      dispatch(addPostApiCallFailure());

    });
  };
};

addNewPost动作与我们之前的getAllPosts动作非常相似。但是,它需要一个body参数,其中包含添加帖子到服务器所需的帖子详情。您还应该注意,一旦接收到来自服务器的成功响应,即帖子已添加,addNewPost动作将分发getAllPosts动作,该动作将检索所有帖子,包括新创建的帖子。这样可以避免我们的 React 组件分发多个动作。

剩余动作的代码,由addNewPost动作分发,如下所示:

export const addPostApiCallStart = () => {
  return {
    type: actions.ADD_POST_AJAX_CALL_START
  };
};

export const addPostApiCallSuccess = () => {
  return {
    type: actions.ADD_POST_AJAX_CALL_SUCCESS
  };
};

export const addPostApiCallFailure = () => {
  return {
    type: actions.ADD_POST_AJAX_CALL_FAILURE
  };
};

这就是动作部分的全部内容。我们当前的博客应用程序中有以下三个部分:

然而,它们目前是相互连接的。我们的下一步是创建 reducers,这些 reducers 提供了更新应用程序状态的能力。

Reducers

Reducers 是简单的函数,它们接收来自动作的动作对象,然后使用它们更新状态。通常,由于我们的应用程序状态表示为键值对的对象,我们将需要为每个键(或属性)创建一个 reducer。这是我们在初始状态部分创建的应用程序状态的结构:

{
  posts: [],
  authors: [],
  ajaxCalls: {
    ...
  },
}

我们的状态中有三个属性,因此我们需要三个 reducers,分别是postsReducer.jsauthorsReducer.jsajaxCallsReducer.js。这些 reducers 将代表我们应用程序状态在存储中。我们还需要另一个 reducer,用于将这三个 reducers 组合成一个单一对象,该对象将用作我们的状态。

在您的redux目录中,创建以下结构中突出显示的四个文件;您的redux文件夹结构现在应如下所示:

.
├── actions
│   ├── authorActions.js
│   └── postActions.js
├── actionTypes
│   └── actionTypes.js
├── reducers
│   ├── ajaxCallsReducer.js
│   ├── authorsReducer.js
│   ├── postsReducer.js
│   └── rootReducer.js
└── store
    └── initialState.js

这就是 reducer 函数的工作方式:

  • reducer 函数接受两个参数;第一个是旧状态,第二个是由动作返回的动作对象。

  • 它将返回一个全新的状态,完全覆盖旧状态。这是因为,就像在 React 组件中更新状态一样,在 Redux 中更新状态也应该是不可变的。

考虑以下示例。这是 Redux 存储状态的方式;状态的值是 reducers 的结果:

{
  posts: postsReducer(oldPosts, action),
}

如果发生一个接收新帖子的动作,reducer 将返回所有新帖子,这将更新帖子的状态而不会改变它。请记住,所有 reducers 都将监听所有动作。因此,我们需要在 reducer 内部正确过滤所需的动作,如果没有任何动作影响 reducer,它应该简单地返回旧状态。

打开你的postsReducer.js文件,并添加以下导入语句:

import initialState from '../store/initialState';
import actions from '../actionTypes/actionTypes';

一旦您添加了这些import语句,添加以下代码用于帖子 reducer:

const postsReducer = (state = initialState.posts, action) => {
  switch(action.type) {
    case actions.GET_POSTS:
      return action.payload.posts;

    default:
      return state;
  }
};

export default postsReducer;

postsReducer函数将接受两个参数,如前所述:

  • state:它包含了posts状态的旧值。然而,在第一次加载时,旧状态将为 null,因此initialState.posts被传递为状态的默认参数。

  • action:它是由动作返回的动作对象。

由于 reducer 会为每个动作调用,我们只需要添加一个 switch case 语句,通过它我们可以确定动作的类型以及它是否会影响我们的状态。在我们的 switch case 语句中,我们为以下条件添加了两个情况:

  • 如果操作的类型是GET_POSTS,我们知道它包含所有帖子,因此我们可以简单地从操作的payload中返回帖子。

  • 如果不是,则将执行default情况,它将简单地返回旧状态。

authorsReducer.js文件是供您尝试的,但不能留空。在此文件中,添加以下代码:

import initialState from '../store/initialState';
import actions from '../actionTypes/actionTypes';

const authorsReducer = (state = initialState.authors, action) => {
  switch(action.type) {

    default:
      return state;
  }

};

export default authorsReducer;

它将简单地返回所有操作的initialState。您可以在此 reducer 上工作,以在作者列表页面中尝试 Redux。

对于ajaxCallsReducer.js,代码太长无法在书中指定,因此您应该从已完成的代码文件中复制文件的内容。确切的代码将正常工作。ajaxCallsReducer的工作非常简单。它根据网络请求的结果切换loadinghasError属性的值为truefalse。由于状态不能被改变,它使用扩展运算符(...state)来执行此操作。

考虑GET_POSTS_AJAX_CALL_START发生的情况:

    case actions.GET_POSTS_AJAX_CALL_START:
      return {
        ...state,
        getAllPosts: {
          loading: true,
          hasError: false,
        },
      };

在这里,创建了一个新的状态对象,其中getAllPosts属性内部的loading属性设置为true。这种状态对于在应用程序中显示加载指示器非常有用。

根 reducer

在 reducers 部分中我们剩下的最后一项是根 reducer。在此文件中,所有 reducer 都被组合在一起,以用作应用程序的状态。Redux 提供了一个名为combineReducers的方法,可以用于此目的。在您的rootReducer.js文件中,添加以下导入语句:

import { combineReducers } from 'redux';
import postsReducer from './postsReducer';
import authorsReducer from './authorsReducer';
import ajaxCallsReducer from './ajaxCallsReducer';

这将导入combineReducers函数以及其他 reducer。要将所有 reducer 组合成单个根 reducer,只需添加以下代码:

const rootReducer = combineReducers({
  posts: postsReducer,
  authors: authorsReducer,
  ajaxCalls: ajaxCallsReducer,
});

export default rootReducer;

我们将在下一节中导入此 reducer 以创建我们的 store。目前,操作和 reducer 之间的数据流动方式如下:

Store

使用根 reducer 创建 store 对象是 Redux 部分工作的最后阶段。在redux/store目录中,创建configureStore.js文件,该文件将创建我们的 store 对象。我们还需要在此文件中应用我们的redux-thunk中间件,这将允许我们使用将分派其他操作的操作。

Redux 提供了createStore函数来创建 store 对象和applyMiddleware函数来添加中间件。在您的configureStore.js文件中,添加以下代码:

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from '../reducers/rootReducer';

要创建一个 store,只需使用createReducer函数调用rootReducer,前面的状态和applyMiddleware方法作为参数。第一个参数是必需的,而其他参数是可选的。在configureStore.js文件中,在import语句之后添加以下代码:

const configureStore = (preloadedState) => {
  return createStore(
    rootReducer,
    preloadedState,
    applyMiddleware(thunk)
  );
};

export default configureStore;

configureStore函数将用于为我们的 React 组件创建 store 对象。我们的redux目录的最终文件夹结构如下:

.
├── actions
│   ├── authorActions.js
│   └── postActions.js
├── actionTypes
│   └── actionTypes.js
├── reducers
│   ├── ajaxCallsReducer.js
│   ├── authorsReducer.js
│   ├── postsReducer.js
│   └── rootReducer.js
└── store
    ├── configureStore.js
    └── initialState.js

到目前为止,在redux部分就是这些了。我们现在将使用react-redux库在我们的博客的 React 组件中连接 Redux 和 React。现在,这是redux部分中数据流动的方式:

将 Redux 与 React 组件连接起来

我们将我们博客的整个App组件包装在 React 路由器的BrowserRouter组件中,以在index.js文件中实现路由。Redux 遵循类似的方法。我们需要将App组件(已经包装在路由器内部)包装在react-redux库的Provider组件中。

打开您的src/index.js文件,并在文件中已经存在的import语句之后添加以下导入语句:

import { Provider } from 'react-redux';
import configureStore from './redux/store/configureStore';

这将导入react-reduxProvider组件和我们在前一节中创建的configureStore函数。我们需要从configureStore函数创建一个store对象。在前面的import语句之后,添加以下行以创建store对象:

const  store  =  configureStore();

目前,您的ReactDOM.render()方法如下所示:

ReactDOM.render(
  <Router>
    <App />
  </Router>
  ,
  document.getElementById('root')
);

您需要将其替换为以下行:

ReactDOM.render(
  <Provider store={store}>
    <Router>
      <App />
    </Router>
  </Provider>
  ,
  document.getElementById('root')
);

我们现在已经将整个App组件包装在Provider中,它为 React 组件提供了与 Redux 连接的能力。我们现在将看到如何将单个组件与 Redux 存储中的状态和操作连接起来。

App 组件

我们将首先连接到 Redux 的组件是App组件,它充当我们应用程序中所有其他组件的父组件。这意味着无论我们在应用程序中访问的 URL 是什么,App组件都将被执行。这使得App组件成为执行操作的最佳位置,例如getAllPosts,它将检索帖子数组。

这部分是 Redux 中最令人困惑的部分,因此,您将需要密切关注我们如何将 Redux 存储和操作作为 props 传递给 React 组件。此外,如果在此阶段遇到任何错误,请确保参考已完成的代码文件。

您需要在您的App.js文件中添加一些import语句。您需要导入的第一件事是react-redux库提供的connect组件:

import { connect } from 'react-redux';

这将为您的 React 组件提供一个与 Redux 连接的包装器。这与 React 路由器的withRouter组件的工作方式相同,它为 React 组件提供了 history、location 和 match props。

您还需要导入 Redux 的bindActionCreators函数,它将操作函数转换为可以被 React 组件使用的简单对象:

import { bindActionCreators } from 'redux';

我们还需要导入的另一件重要的事情是postActions,它将被我们的App组件使用。由于postActions包含许多单独导出的函数,我们可以使用以下import语句将它们全部作为单个对象一起导入:

import * as postActions from './redux/actions/postActions';

我们现在已经将所有必需的import语句放在了适当的位置。我们的下一步是实际的实现部分。目前,App组件的导出语句如下所示:

export  default  withRouter(App);

我们的App组件被包装在withRouter中。要将其与 Redux 连接,我们需要将App组件包装在我们从react-redux导入的connect函数中,并且结果应该在withRouter组件内。

但是,connect函数本身需要两个函数--mapStateToPropsmapDispatchToProps--作为参数。在这两个函数中,mapStateToProps将从存储中转换状态,而mapDispatchToProps将将操作转换为可以被 React 组件使用的 props。现在,请密切关注,因为我们很快将看到另一种奇怪的语法。

将您的App组件的导出代码替换为以下代码行:

function mapStateToProps() {
  return {
    // No states needed by App Component
  };
}

function mapDispatchToProps(dispatch) {
  return {
    postActions: bindActionCreators(postActions, dispatch),
  };
}

export default withRouter(
  connect(
    mapStateToProps,
    mapDispatchToProps
  )(App)
);

仔细查看前面的代码片段。如果导出语句对您来说毫无意义,不用担心,我们会解决这个问题。让我们看看connect的作用。connect函数将接受两个参数--mapStateToPropsmapDispatchToProps--它们是函数,并且它将返回一个函数:

connectFunction = connect(mapStateToProps, mapDispatchToProps);

App组件被包装在connectFunction中作为connectFunction(App)。整个组件然后被包装在withRouter()函数中。因此,基本上,这就是导出语句的工作方式:

export default withRouter(connectFunction(App));

这是我们已经合并在一起并写成的:

export default withRouter(
  connect(
    mapStateToProps,
    mapDispatchToProps
  )(App)
);

App组件不使用任何状态,因此mapStateToProps函数将返回一个空对象。但是,mapDispatchToProps函数将使用bindActionCreators函数将postActions作为对象返回,然后将其作为 prop 提供给App组件。

现在,我们将让App组件通过在componentWillMount()方法中添加以下代码行来进行 API 调用以获取所有帖子:

  this.props.postActions.getAllPosts();

此外,由于postActions作为一个 prop 传递给我们的App组件,因此请将以下属性添加到我们在App组件中添加的propType验证中:

postActions:  PropTypes.object.isRequired

如果你在将上述代码片段包含到App.js文件中时遇到任何问题,请参考已完成的代码文件。完成此步骤后,从Chapter06\Server目录保持服务器运行,并在 Chrome 中打开你的应用程序。

当我们点击导航栏图标中的菜单项或帖子中的“阅读更多”按钮时,你应该看到博客以相同的 3 秒加载时间运行。我们将在下一节中修复这个问题。

主页组件

在前面的部分中,我们使用App组件从服务器检索数据并将其存储在 Redux 存储中。这意味着我们不再需要在我们的主页组件中进行任何网络请求。我们只需要从 Redux 存储中检索数据。

主页组件不会触发任何 Redux 动作,因此,我们只需要从react-redux中导入 connect 组件。在你的Home.js文件中,添加以下import语句:

import { connect } from  'react-redux';

用以下代码替换我们的Home.js文件中的export语句:

function mapStateToProps(state) {
  return {
    posts: state.posts,
    loading: state.ajaxCalls.getAllPosts.loading,
    hasError: state.ajaxCalls.getAllPosts.hasError,
  };
}

export default connect(mapStateToProps)(Home);

由于主页组件不会执行任何操作,我们可以安全地忽略 connect 中的mapDispatchToProps函数。但是,我们需要为mapStateToProps函数做一些工作,在前一章中它只是简单地返回了一个空对象。

mapStateToProps函数有一个参数,即应用程序的整个 Redux 状态的状态。在 return 语句中,我们只需要提到我们需要将状态的哪一部分作为 props 传递给 React 组件。connect 的最好部分是,每当 reducers 更新状态时,它将使用mapStateToProps函数更新这些 props。

现在我们为主页组件得到了一些新的 props。因此,在你的主页组件中,添加以下propType验证:

  static propTypes = {
    posts: PropTypes.array.isRequired,
    loading: PropTypes.bool.isRequired,
    hasError: PropTypes.bool.isRequired,
  }

此外,我们的主页组件中不再需要任何状态或 API 调用,因此,你可以删除constructorcomponentWillMount方法。而是在你的 render 方法的 JSX 中,用this.props.posts替换this.state.posts。对于loadinghasError状态也是一样。现在我们的主页组件直接依赖于 Redux 存储。如果你遇到任何问题,请参考已完成的代码文件。

这里的酷部分是-如果你点击导航栏中的任何其他部分并返回到主页,你会发现帖子会立即加载。这是因为所有帖子都存储在 Redux 存储中,准备供我们使用。如果你在主页帖子列表中点击“阅读更多”按钮,你应该再次看到加载指示器,因为它正在从服务器检索帖子详情。让我们也将该组件与 Redux 连接起来。

帖子组件

在 VSCode 中打开你的src/Components/Post.js文件。我们的第一步是添加所需的import语句:

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';

让我们制定一下我们将如何将该组件与 Redux 连接起来的策略:

  • 我们需要获取帖子 ID,它存在于 URL 中

  • 一旦我们有了 ID,我们应该使用Array.find()方法在我们存储的帖子数组中找到具有该 ID 的帖子

  • 最后,我们可以将所需的帖子作为 props 发送。

现在,在Post.js中用以下代码替换你的export语句:

function mapStateToProps(state, ownProps) {

  return {
    post: state.posts.find(post => post.id === ownProps.match.params.id),
    loading: state.ajaxCalls.getAllPosts.loading,
    hasError: state.ajaxCalls.getAllPosts.hasError,
  };
}

export default withRouter(
  connect(mapStateToProps)(Post)
);

mapStateToProps函数有一个第二个参数,即ownProps。它包含了 Post 组件的所有 props。从ownProps中,我们可以获取帖子 ID,它存在于 React 路由器的withRouter组件提供的 match 对象中。然后我们将使用 find 方法找到帖子并在 return 语句中返回所需的数据。

你的帖子组件内的propType验证应如下所示:

  static propTypes = {
    history: PropTypes.object.isRequired,
    location: PropTypes.object.isRequired,
    match: PropTypes.object.isRequired,
    post: PropTypes.object,
    loading: PropTypes.bool.isRequired,
    hasError: PropTypes.bool.isRequired,
  }

你可以删除构造函数和componentWillMount方法,就像我们为我们的主页组件所做的那样,然后在你的 render 方法中,用this.props.loading替换this.state.loading,用this.props.hasError替换this.state.hasError

但是,在将this.state.post替换为this.props.post之前,我们应该确保this.props.post有一个值,因为在加载过程中,帖子数组将为空,并且this.props.post的值将为 undefined。在 render 方法中,用以下代码替换您使用this.state.post的三行代码:

{
  this.props.post
  ?
    <div>
      <h2>{this.props.post.title}</h2>
      <p>{this.props.post.author}</p>
      <p>{this.props.post.content}</p>
    </div>
  :
    null
}

现在尝试重新加载页面。第一次加载需要三秒钟,但一旦数据加载完成,您将看到导航到其他页面(除了作者页面)将变得轻而易举。在主页上点击“阅读更多”按钮将立即带您到帖子详情页面。

现在轮到您在AuthorListAuthorPosts组件中尝试这一点。我们需要连接 Redux 的最后一个组件是 NewPost 组件。

NewPost 组件

NewPost 组件需要 Redux 的状态和操作。它需要来自状态的加载和hasError数据,并且必须使用postActions将帖子提交到服务器。因此,让我们从在src/Components/NewPost/NewPost.js文件中包含所需的import语句开始:

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as postActions from '../../redux/actions/postActions';

现在,请用以下代码替换NewPost.js文件中的export语句:

function mapStateToProps(state) {
  return {
    loading: state.ajaxCalls.addPost.loading,
    hasError: state.ajaxCalls.addPost.hasError,
  };
}

function mapDispatchToProps(dispatch) {
  return {
    postActions: bindActionCreators(postActions, dispatch),
  };
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(NewPost);

由于我们在NewPost组件中有了 props,请在NewPost类中添加以下propType验证代码:

  static propTypes = {
    postActions: PropTypes.object.isRequired,
    loading: PropTypes.bool.isRequired,
    hasError: PropTypes.bool.isRequired,
  }

HomePost组件不同,NewPost组件需要状态和 props 来渲染 JSX 元素。我们可以删除加载和hasError状态,并用 props 替换它们。您应该参考已完成的代码文件(如果需要),并将 render 方法中的加载和hasError状态替换为 props。

然后,将 submit 方法中的整个apiCall().then().catch()链替换为以下单行代码:

this.props.postActions.addNewPost(body);

您的submit方法现在将如下所示:

  submit() {
    if(this.state.author && this.state.content && this.state.title) {
      this.setState({loading: true});

      const date = new Date();
      const epoch = (date.getTime()/1000).toFixed(0).toString();
      const body = {
        id: uuidv4(),
        author: this.state.author,
        title: this.state.title,
        content: this.state.content,
        datetime: epoch,
      };

      this.props.postActions.addNewPost(body);

    } else {
      alert('Please Fill in all the fields');
    }
  }

submit方法现在将触发一个包含所需网络请求的addNewPost操作。但是,我们需要在网络请求完成后显示成功消息。为了检测网络请求的完成,由于我们对存储的所有更新都是不可变的,如果 Redux 状态中的ajaxCalls属性中的加载或hasError属性的状态发生变化,将导致创建一个新对象,该对象将自动通过react-redux传递给NewPost组件。

这意味着NewPost React 组件将在网络请求结束时接收到新的 props。在这种情况下,我们可以使用 React 的componentWillReceiveProps生命周期方法来显示成功消息,并在提交帖子后清除输入字段。将以下代码添加到NewPost类的componentWillReceiveProps中:

  componentWillReceiveProps(nextProps) {
    if(this.props !== nextProps) {
      if(nextProps.loading === false && nextProps.hasError === false) {
        this.setState({
          success: true,
          author: '',
          title: '',
          content: '',
        });
      } else if(nextProps.loading === false && nextProps.hasError === true) {
        this.setState({success: false});
      }
    }
  }

componentWillReceiveProps将接收到的新 props(在我们的情况下,来自react-redux)作为其参数,我们将称之为nextProps。在componentWillReceiveProps方法中,进行简单的this.props !== nextProps检查以确保当前 props 和新 props 不是相同的对象。如果它们都持有相同的对象,我们可以跳过操作。然后我们只需要使用 if else 语句检查加载是否完成以及是否存在任何错误,就像在前面的代码片段中使用的那样。

一旦您包含了上述代码片段,请尝试添加一个帖子(确保服务器正在运行)。它应该添加帖子并显示成功消息。现在,点击主页菜单选项。您将看到您添加的新帖子立即出现,无需等待加载时间。这其中的秘密是addNewPost操作将自动调用getAllPosts操作,后者将在后台更新您的 Redux 存储。使用新帖子更新存储后,您的Home组件可以直接从 Redux 获取更新后的帖子状态,使事情立即出现。

这为用户提供了很好的用户体验,因为他们会发现每次更新都是即时的,而不必等待加载指示器。

Redux 数据流

连接 Redux 代码与 React 组件后,你会发现 Redux 遵循与 React 相同的单向数据流。这就是 Redux 的数据流:

这就是 React 组件中数据流的方式:

此外,React 组件中的状态和 Redux 存储中的状态都应该是不可变的。这种不可变性对于 React 和 Redux 的正常工作是必不可少的。然而,由于 JavaScript 目前没有严格实现任何不可变的数据类型,我们需要小心不要改变状态。在 React 组件中,我们将使用this.setState()方法,在 Redux 的 reducer 中使用扩展运算符(...)来更新状态而不是改变它们。

对于大型项目和大量数据可能会有麻烦。Facebook 引入了一个叫做Immutable.js的库,可以解决这个问题,它可以在 JavaScript 中创建不可变的数据类型。这个库超出了本书的范围,但是确保你以后试一试。

持久化 Redux 存储

我们的博客加载速度很快,因为我们已经将 Redux 集成到其中,但是我们的用户仍然需要等待三秒钟进行初始加载。如果我们可以将 Redux 存储持久化到离线,并在加载新数据时向用户展示它呢?

听起来不错,而且也很简单!我已经为此目的将两个库添加到了依赖列表中:

redux-persist提供了一种简单的方法来持久化你的 Redux 存储,并在需要时重新填充它。这样当用户第二次访问你的页面时,你的存储就可以离线使用了。

localForage是一个简单的存储库,可以让你使用类似localStorage的 API 来使用indexDBredux-persistlocalStorage配合良好,但它建议在 web 浏览器中使用localForage作为默认存储引擎。

现在,持久化 Redux 存储并不那么复杂;你只需要在 Redux 存储中添加几行代码来持久化它,并让 reducers 监听rehydration动作以从持久化存储中重新填充数据。只需要更改以下三个文件:

第一个文件:打开你的configureStore.js文件,并添加以下导入语句:

import { autoRehydrate } from 'redux-persist';

然后,将configureStore方法中的return语句更改为以下内容:

  return createStore(
    rootReducer,
    preloadedState,
    applyMiddleware(thunk),
    autoRehydrate()
  );

现在,在创建存储时添加autoRehydrate()函数,它将发出 rehydrate 动作。

第二个文件:打开你的index.js文件,并添加以下import语句:

import { persistStore } from 'redux-persist';
import localForage from 'localforage';

这将导入persistStore()函数,它可以持久化你的存储,以及将被用作存储引擎的localForage库。现在,你需要在创建存储的代码行之后添加一行代码:

const store = configureStore();              // Store gets created here
persistStore(store, {storage: localForage}); // next line which will persist your store

第三个文件:打开你的postsReducer.js文件。在这个 posts reducer 中,我们将监听另一个动作,即在重新填充持久化的 Redux 存储时发出的 rehydrate 动作。Redux Persist 维护了一组常量,其中定义了 rehydrate 动作,类似于我们在actionTypes.js文件中定义动作的方式。

在 reducers 文件中,添加以下import语句:

import * as constants from 'redux-persist/constants';

这将从redux-persist中导入常量。然后在postsReducer函数内添加一个额外的 case 语句,用于填充 Redux 存储:

    case constants.REHYDRATE:
      if(action.payload.posts) {
        return action.payload.posts;
      }
      return state;

这个案例将检查 rehydrate 动作是否发生,然后使用if条件来检查 rehydrate 动作是否在动作的载荷中包含posts属性。如果遇到任何问题,请参考已完成的代码文件。

现在,一旦完成,打开 Chrome 中的应用程序并尝试重新加载页面。您应该看到即使数据正在从服务器加载,帖子也是可用的,就像下面的图片一样:

这使用户可以在加载帖子的同时离线使用应用程序。我们已经完全解决了博客的 3 秒加载问题。

Redux 是一个很好的用于管理状态的库,它将状态集中管理在一个单独的状态容器中。它与 React 的集中状态管理被证明非常有用和高效,以至于许多库也为其他框架创建了集中状态管理,比如@ngrx/store用于 Angular 和vuex用于 Vue.js。在这一章中,我们只涵盖了 Redux 的基础知识--请参考 Redux 文档和其教程视频,以深入学习 Redux。此外,查看Redux DevTools,它提供了一些很酷的功能,比如热重新加载和时间旅行调试,适用于你的 Redux 应用程序。

作者页面尚未连接到 Redux。所以,试一试并完成博客。

总结

恭喜!你已成功完成了 Redux 章节,也完成了这本书。在这一章中,我们介绍了 Redux 是什么,以及我们如何使用它来改进状态管理。然后,我们创建了一个 Redux 存储,其中包括管理存储数据所需的动作和减速器。我们使用react-redux库将我们的 Redux 代码与 React 组件连接起来,并使用 props 而不是状态来渲染 JSX 元素。

最后,我们使用redux-persistlocalforage作为存储引擎来持久化我们的 Redux 存储,并使我们的应用程序能够离线工作。这一章使博客对用户更快速、更用户友好。

你已经完成了本书的旅程,但你刚刚开始探索 JavaScript 世界的旅程。还有很多东西要学,还有更多的东西要来。所以,无论你想做什么,都要做好学习和探索的准备。

posted @ 2024-05-22 12:07  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报