三、事件注册器

希望你在创建表情包并与朋友分享的过程中获得了很多乐趣! 你在之前的项目中使用 HTML5 canvas 成功构建了一个 Meme Creator。 你还使用了 flexbox 来设计页面布局,并学习了一些关于 ES6 模块的东西。

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

现在我们有了模块支持,我们可以使用它来创建模块函数,这将允许我们编写可重用代码,这些代码可以跨项目的不同部分使用,也可以用于不同的项目。 在本章中,你将构建一个事件注册应用,同时学习以下概念:

  • 编写 ES6 模块
  • 使用 JavaScript 进行表单验证
  • 使用动态数据(从服务器加载的数据)
  • 使用获取进行 AJAX 请求
  • 使用 promise 处理异步函数
  • 使用 Chart.js 创建图表

Event - JS meetup

以下是我们项目的场景:

您正在本地组织一个 JavaScript 会议。 您已经邀请了来自学校、大学和办公室的人,他们都对 JavaScript 感兴趣。 你需要创建一个网站供与会者注册。 网站应具备以下功能:

  • 帮助用户注册事件的表单
  • 以图表形式显示对事件感兴趣的用户数量的统计信息的页面
  • 一个关于页面,包含事件详细信息和作为谷歌 Map 嵌入的事件位置

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

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

初始项目设置

要开始这个项目,打开 VSCode 中的第 3 章的启动器文件。 用.env.example文件中的值创建一个.env文件。 为每个环境变量赋值如下:

  • NODE_ENV=dev:生成构建时应该设置为production
  • SERVER_URL=http://localhost:3000:我们很快就会有一个服务器在这个 URL 中运行。
  • GMAP_KEY:我们将在这个项目中使用谷歌 Maps API。 您需要生成惟一的 API 键来使用谷歌 Maps。 请参阅:https://developers.google.com/maps/documentation/javascript/get-api-key以生成您的 API 密钥并将密钥添加到这个环境变量。

在之前的第 2 章Building a Meme Creator中,我提到过,当模块与 Webpack 捆绑在一起时,你无法在 HTML 中访问 JavaScript 变量。 在第一章建立待办事项列表中,我们使用 HTML 属性调用 JavaScript 函数。 这看起来可能很有用,但它也会将我们的对象结构暴露给用户(我指的是访问您的页面的其他开发人员)。 通过使用 Chrome DevTools 查看对象,用户可以清楚地了解ToDoClass类是如何构造的。 在构建大规模应用时应该防止这种情况。 因此,Webpack 不允许变量出现在全局作用域中。

一些插件将需要在全局作用域中出现变量或对象(比如我们将要使用的谷歌 Maps API)。 为此目的,Webpack 提供了一个选项,可以将一些选定的对象作为库公开给全局作用域(在 HTML 内部)。 参见启动器文件中的webpack.config.js文件。 在output部分,我添加了library: 'bundle',这意味着如果我们将export关键字添加到任何函数、变量或对象,它们将在全局作用域中的bundle对象中被访问。 我们将看到如何使用它,同时将谷歌 Maps 添加到我们的应用。

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

向页面添加样式

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

为了使表单和它的标题对齐到页面的中心,在styles.css文件(src/css/styles.css)中添加以下代码(确保 Webpack dev 服务器正在运行):

.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(通常手机属于这个类别),然后在90vw中加入min-widthvw表示视口宽度。 90vw表示视口宽度的 90%大小(这里视口是屏幕)。

More information on using media queries can be found on this w3schools page: https://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 经验水平
  • 他希望从这次事件中学到什么

因为职业和经验等级选项是预先设定的默认值,所以它们对用户来说并不是强制性的。 但是,在验证期间,我们需要将它们视为强制字段。 只有 comments 字段是可选的。

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

  • 用户填写所有表单细节并单击 Submit
  • 表单细节将被验证,如果缺少任何必需的字段,它将用红色边框突出显示字段
  • 如果表单值是有效的,那么它将继续向服务器提交表单
  • 表单提交后,用户将收到表单已成功提交的通知,表单条目将被清除

JavaScript 最初是作为一种在 HTML 中进行表单验证的语言使用的。 随着时间的推移,它已经发展成为一种成熟的 web 应用开发语言。 使用 JavaScript 构建的 Web 应用向服务器发出大量请求,以向用户提供动态数据。 这样的网络请求总是异步的,需要正确地处理。

HTML 表单

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

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

这意味着当单击 Submit 按钮时,表单的操作是使用POST方法将数据发送到/register页面。 在进行网络请求时,GETPOST是两个常用的 HTTP 方法或动词。 GET方法没有请求体,所有的数据都通过 URL 作为查询参数进行传输。 然而,POST方法可以有一个请求体,其中数据可以作为表单数据或 JSON 对象发送。

There are different HTTP methods used for communicating with the server. Check out the following REST API Tutorial page for more information on HTTP methods: http://www.restapitutorial.com/lessons/httpmethods.html.

目前,表单使用POST方法以表单数据的形式发送数据。 在你的index.html文件中,将 form method 属性更改为get并重新加载页面(Webpack 开发服务器不会自动重新加载更改到 HTML 文件)。 现在,单击 submit。 您应该会看到一个类似的空白页面,但是表单细节现在通过 URL 本身发送。 URL 现在看起来如下:

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

所有的领域都是空的,除了专业和经验,因为他们是预选的。 表单值添加在路由/register的末尾,后面跟着一个?符号,它指定下一个文本是查询参数,表单值使用&符号分隔。 因为一个GET请求在 URL 本身发送数据,它不适合发送机密数据,比如我们将在这个表单中发送的登录细节或用户细节。 因此,选择POST方法来完成表单提交。 将方法更改为在index.html文件中发布。

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

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

Visit the following Google Developers page: https://developers.google.com/web/tools/chrome-devtools/ to learn more on using Chrome DevTools.

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

用 JavaScript 读取表单数据

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

class Home {
  constructor() {

  }

}

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

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

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

 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触发了 submit 事件,该事件也属于整个表单,即$form变量。 因此,我们需要在我们的类中添加一个事件监听器this.$form

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

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

这将在表单上附加一个事件监听器,并在以表单 submit 事件作为参数提交表单时调用类的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();
}

尝试在 Chrome DevTools 控制台使用console.log(formValues)打印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的文件。 这样,通过最大限度地重用所有模块,您的代码将易于维护和可伸缩。

Don't worry about long filenames! Maintainable code is more important, but if the filename is long it makes it easy to understand the purpose of that file. But if you are working in a team, stick to the rules of the lint tools used by your team.

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

export default function validateRegistrationForm(formValues) {
}

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

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

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

我们使用这个函数来检查用户名是否至少有3个字符长。 如果长度大于3,则使用条件操作符返回true;如果长度小于false,则返回false

We have used conditional operator ()?: once before in the ToDo list app. If you are still having problems understanding this operator, visit the following MDN page: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Conditional_Operator.

我们可以让这个函数事件更短:

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

这样,JavaScript 将自动评估长度是否大于 3,并根据结果赋值 true 或 false。 现在,要验证电子邮件地址,我们需要使用正则表达式。 我们在 Meme Creator 应用中使用了一个正则表达式来改变图像的 mime 类型。这次,我们将研究正则表达式是如何工作的。

使用 JavaScript 中的正则表达式

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

const pattern = /^a+/

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

  • ^的意思是在开始
  • +表示至少有一个

这个正则表达式将匹配以字母开头的字符串。 您可以在:https://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);
}

不要被 RegExp 压得喘不过气来,它在互联网上很常见。 当您需要正则表达式用于常见格式时,例如电子邮件地址或电话号码,您可以在互联网上找到它们。 要验证移动电话号码,请执行以下操作:

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

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

You will have to write your own regular expressions if your requirement is very specific. At that time, refer to the following page: https://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。 通过在数组中查找 profession 的索引,并检查该索引是否大于-1,我们可以使用该函数来检查 profession 的值是否为已接受值之一。

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

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

因为 comments 字段是可选的,所以我们不需要对这个字段使用验证逻辑。 现在,在我们最初创建的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变量的值进行 and(&&):

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

通常,您可以使用点符号(.)访问对象的属性。 然而,由于我们使用的是for/in循环,属性名存储在变量field中。 在这种情况下,我们需要使用括号符号result[field]访问属性,如果field包含值age; 这相当于点记数法中的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';

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

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);
  }
}

上述代码执行以下操作:

  • 它调用我们之前用formValues作为参数创建的validateRegistrationForm()模块,并将返回值存储在formStatus对象中。
  • 首先,它使用formStatus.isValid值检查整个表单是否有效。
  • 如果是true,它调用一个方法clearErrors()来清除 UI(我们的 HTML 表单)中的所有错误高亮部分,然后调用另一个方法submitForm()来提交表单。
  • 如果是false(表单无效),它调用clearErrors()方法清除表单,然后调用formStatus.resulthighlightErrors()的方法,其中包含验证单个字段作为参数的细节来突出显示的字段错误。

我们需要创建在前面的代码中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 开发服务器还在运行)。 尝试在输入字段中输入一些值,然后单击 Submit。 如果您输入了有效的输入值,它不应该执行任何操作。 如果您输入了无效的输入项(按照我们的验证逻辑),输入字段将用红色边框突出显示,因为我们在字段的父元素中添加了.has-errorBootstrap 类。 如果你用一个有效值更正这个字段并再次点击提交,这个错误应该会消失,因为我们使用了clearErrors()方法来清除所有旧的错误高亮显示。

使用 AJAX 提交表单

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

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

The name Asynchronous JavaScript And XML might sound confusing but, initially, XML was widely used to exchange data with the server. We can also use JSON/normal text to exchange data with the server.

为了将表单提交到服务器,我创建了一个小的 Node.js 服务器(使用快速框架构建),它假装保存表单细节并返回一个成功消息。 该服务器在代码文件的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 的一个新概念,即承诺,来处理这个 API 调用。
  • 我们将在下一节中有另一个 API 调用。 最好将 API 调用创建为类似于模块的表单验证模块。
  • 我们必须使用服务器的响应来验证注册是否成功。
  • 由于整个 API 调用将花费一些时间,我们还应该在过程中向用户显示加载指示符。
  • 最后,如果注册成功,我们应该立即通知用户并清除表单。

使用 JavaScript 进行网络请求

JavaScript 有XMLHttpRequest用于 AJAX 网络请求。 ES6 中引入了一个名为 fetch 的新规范,通过 promise 支持,它使得网络请求的处理更加现代和高效。 除了这两个方法外,jQuery 还有$.ajax()方法,它被广泛用于发出网络请求。 Axios.js是另一个npm包,也广泛用于发出网络请求。

我们将在应用中使用 fetch 来发出网络请求。

Fetch does not work with Internet Explorer and requires polyfills. Check out: https://caniuse.com/ for the browser compatibility of any new HTML/CSS/Javascript components you'd like to use.

什么是承诺?

你现在可能想知道我把什么叫做承诺? 一个承诺,就像它听起来的那样,是一个由 JavaScript 做出的承诺,异步函数将在某个时间点完成执行。

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

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

你可能想知道,如果我只需要在事件完成后执行一些代码? 以下是FileReader的处理方式:

  • 一旦阅读完成,FileReader触发一个load事件
  • 它还有一个监听load事件的onload()方法,当load事件被触发时,onload()方法将开始执行
  • 因此,我们需要将所需的代码放在onload()方法中,只有在FileReader读取完文件内容后,它才会执行

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

回调函数经常用于处理异步事件。 但如果你有很多异步函数一个接一个地发生,你的代码会像这样:

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

在写了很多回调函数之后,你的右括号会像金字塔一样排列。 这被称为回叫地狱。 回调地狱是混乱的,应该避免在构建应用。 因此,回调函数在这里没有用。

进入 promise,一种处理异步事件的新方法。 这是 JavaScriptPromise的工作原理:

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

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

假设你有一个叫做theAsyncCode()的函数,它做一些异步的事情。 还有另一个函数onlyAfterAsync(),它只需要在theAsyncCode()之后运行,并使用theAsyncCode()返回的值。

下面是如何使用 promise 处理这两个函数:

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);

这将得到相同的结果。 我已经在:https://jsfiddle.net/jjq60Ly6/4/设置了一个小的 JS 小提琴,在那里你可以体验到在行动中的承诺的工作。 访问 JS 小提琴,打开 Chrome DevTools 控制台,并单击运行在 JS 小提琴页面的左上角。 您应该看到三个函数依次打印出的console.log语句。 请随意编辑小提琴和实验的承诺。

Shortly after finishing this chapter, ES8 was announced, which confirmed the async functions to be part of the JavaScript language. ES8's async and await keywords provide an even simpler way to resolve Promises instead of the .then().catch() chain we used in ES6. To learn using the async functions, go to the following MDN page: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function.

创建 API 调用模块

我们将使用 POST API 调用来注册用户。 但是在 app 的 status 部分,我们需要使用一个GET请求来显示对事件感兴趣的人的统计数据。 我们将构建一个通用 API 调用模块。

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

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

apiCall.js file内部,创建以下函数:

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

在前面的函数中,route 是必需的参数,而 body 和 method 都定义了默认值。 这意味着它们是可选的。 如果你只使用一个参数调用函数,其他两个参数将使用它们的默认值:

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 中的查询参数发送。

我们已经在默认表单的 Submit 部分看到了GETPOST请求的工作原理。 让我们构造一个可以同时处理GETPOST请求的apiCall函数:

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

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功能不会出现任何问题。

Detailed information on CORS requests can be found in the following MDN page: https://developer.mozilla.org/en/docs/Web/HTTP/Access_control_CORS.

我们的requestDetails对象还应该包括请求的主体。 然而,主体只应包括为POST请求。 因此,它可以写在requestDetails对象声明下面,如下所示:

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

这将为POST请求添加 body 属性。 要执行获取请求,我们需要构造请求 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())。 否则,它将抛出一个错误。

我们可以使用前面讨论过的方法进一步简化承诺链:

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

Fetch 有一个小问题。 它不能自己处理超时。 假设服务器面临一个问题,并且无法返回请求。 在这种情况下,fetch 将永远不会解析。 为了避免这种情况,我们需要做一个变通方案。 在request承诺下面,创建另一个Promise,称为timeout:

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

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

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

const timeoutDuration = 5000;

这个常量被放置在文件的顶部,以便我们可以在将来很容易地更改超时时间(更易于代码维护性)。 timeout是一个简单的承诺,在 5 秒后自动拒绝(从timeoutDuration常量)。 我已经创建了一个服务器,它在 3 秒后响应。

现在,JavaScript 有一个很酷的方法来解决多个承诺,Promise.race()方法。 顾名思义,这将使两个 promise 同时运行,并接受首先解析/拒绝的那个的值。 这样,如果服务器在 3 秒内没有响应,将在 5 秒内发生超时,apiCall将被拒绝超时! 为此,在requesttimeoutpromise 后面添加以下代码:

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

apiCall()函数作为一个整体返回一个 Promise,它是requesttimeoutPromise 的解析值(取决于其中哪个执行得更快)。 就是这样! 我们的apiCall模块现在可以在事件注册应用中使用了。

If you find the apiCall function difficult to understand and follow, read it again with the apiCall.js file from the Chapter03 completed code files as reference. It will make the explanation much simpler. To learn Promises in detail with more examples, read the following Google Developers page: https://developers.google.com/web/fundamentals/getting-started/primers/promises and MDN page: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise.

其他网络请求方法

点击下面的链接,了解其他插件/ api 在 JavaScript 中进行网络请求:

要使获取工作与 Internet Explorer,请阅读以下页面如何添加polyfill获取:https://github.com/github/fetch/

回到表单

开始提交的第一步是隐藏提交按钮,并用加载指示器替换它。 这样,用户就不会意外地两次点击 Submit。 另外,加载指示器还可以作为进程正在后台运行的指示。 在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 的事件注册页面,并尝试提交表单。 如果所有的值都是有效的,它应该成功地提交表单并发送成功 toast 消息,表单值将被重置为初始值。 在实际应用中,服务器将向用户发送确认邮件。 然而,服务器端编码超出了本书的范围。 但我想在下一章稍微解释一下。

尝试关闭 Node.js 服务器并提交表单。 它应该抛出一个错误。 您已经成功地构建了事件注册表单,同时学习了 JavaScript 中的一些高级概念。 现在,让我们转到应用的第二个页面—状态页面,在这里我们需要显示注册用户统计数据的图表。

使用 Chart.js 添加图表到网站

我们刚刚为我们的用户创建了一个很好的注册表单。 现在是时候使用我们的活动注册应用的第二部分了。状态页面显示了一个基于经验、职业和年龄对活动感兴趣的人数图表。 如果你现在打开状态页,它应该显示数据加载… 带有加载指示器图像的消息。 但是我已经在status.html文件中建立了这个页面所需的所有必要组件。 它们目前都是使用 Bootstrap 的.hidden类隐藏的。

让我们看看在status.html文件中有什么。 试着将.hidden类从下面的每个部分中删除,看看它们在 web 应用中的样子。

首先是当前正在页面上显示的加载指示器部分:

<div id="loadingIndicator">
  <p>Data loading...</p>
  <image src="./src/iimg/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属性的锚标记。

最后,我们的图表区域有三个 canvas 元素,用于显示前面提到的三个不同类别的图表:

<div class="chart-area hidden" id="chartArea">
  <canvas id="experienceChart"></canvas>
  <canvas id="professionChart"></canvas>
  <canvas id="ageChart"></canvas>
</div>

正如我们在上一章所看到的,canvas 元素是在网页上显示图形的最佳选择,因为编辑 DOM 元素是一项开销很大的操作。 js 使用 canvas 元素将给定数据显示为图表。 让我们来规划一下状态页面应该如何工作:

  • 当调用 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请求时指定主体和方法参数。 在你的构造函数中,添加this.loadData()方法,这样它将在页面加载时自动发出网络请求:

constructor() {
  ...
  this.loadData();
}

现在,看看 Chrome 中的网页。 三秒钟后,它应该显示选项卡。 目前,点击选项卡只会重新加载页面。 我们将在创建图表之后处理这个问题。

向画布元素添加图表

我们在类变量statisticData中有所需的数据,应该用这些数据来呈现图表。 我已经在package.json文件中添加了 Chart.js 到项目依赖项中,当你执行npm install命令时,应该已经安装了它。 让我们通过在status.js文件的顶部添加以下代码,将 Chart.js 导入到我们的项目中:

import Chart from 'chart.js';

It is not compulsory to add the import statements only on top of the file. However, adding the import statements on top gives us a clear view of all the dependencies of the module in the current file.

js 提供了一个构造函数,我们可以用它来创建一个新图表。 Chart构造函数有以下语法:

new Chart($canvas, {type: 'pie', data});

Chart构造函数的第一个参数应该是 canvas 元素的引用,第二个参数是具有两个属性的 JSON 对象:

  • type属性应该包含我们需要在项目中使用的图形类型。 我们需要在项目中使用饼图。
  • 属性应该包含创建图形所需的数据集,作为基于图形类型的格式的对象。 在我们的例子中,对于饼图,所需的格式在下面的 chart .js 文档中指定:http://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();调用前面的方法,以便在页面加载时附加事件监听器。

每当我们点击其中一个选项卡项目,它将调用各自的加载图功能。 假设我们点击了体验标签。 这将使用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承诺链内调用this.loadExperience(),则没有event参数,事件的值默认为nullif(event)条件将失败(因为null是一个假值),event.preventDefault()将不会被执行。 这将防止异常,因为event在这个场景中没有定义。
  • 之后,调用this.hideCharts(),将隐藏所有的图表,并从所有的选项卡中删除.active
  • 接下来的两行将从体验图的画布上删除.hidden,并将.active类添加到体验选项卡。

apiCall功能的then链中,删除this.loadAge()this.loadProfession(),以便只加载体验图(因为它是第一个标签)。

如果你打开谷歌 Chrome 并点击体验选项卡,它应该重新渲染图形而不刷新页面。 这是因为我们添加了event.preventDefault()来停止loadExperience()方法中的默认动作,并在点击选项卡时使用 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 是响应的; 因此,如果您调整页面的大小,它将自动调整饼图的大小。 现在,还剩下最后一个页面,您需要在其中添加谷歌 Maps 来显示事件位置。 在普通 JavaScript 中,添加谷歌 Maps 非常简单。 但是,在我们的例子中,由于我们使用 Webpack 打包 JavaScript 代码,我们需要在正常的过程中添加一个小步骤(谷歌 Maps 需要访问 HTML 中的 JavaScript 变量!)

Chart.js has eight types of charts. Do try each of them at: http://www.chartjs.org/, and if you are looking for a more advanced Charting and Graphics library, check out D3.js (Data-Driven Documents) at: https://d3js.org/.

添加谷歌地图到 web 页面

在 VSCode 或文本编辑器中打开about.html文件。 它将有两个段落<p>标签,你可以在其中添加一些关于你的事件的信息。 在此之后,将有一个 ID 为#map<div>元素,用于在地图中显示事件的位置。

我之前已经要求您生成一个 API 密钥来使用谷歌 Maps。 如果您还没有生成它,请从:https://developers.google.com/maps/documentation/javascript/get-api-key中获取一个,并将其添加到您的.env文件的GMAP_KEY变量中。 根据谷歌 Maps 文档,要添加一个带有标记的地图到您的网页,您必须在页面上包括以下脚本:

<script async defer src="https://maps.googleapis.com/maps/api/js?key=API_KEY&callback=initMap">

在这里,<script>标签的asyncdefer属性将异步加载脚本,并确保仅在加载文档后才执行脚本。

To know more about the workings of async and defer, refer to the following w3schools pages. For Async: https://www.w3schools.com/tags/att_script_async.asp and for Defer: https://www.w3schools.com/tags/att_script_defer.asp.

让我们看看src属性。 这里有一个 URL,后面跟着两个查询参数,键和回调。 Key 是需要包含谷歌 Maps API 键的地方,callback 应该是一个需要在脚本加载完成后执行的函数(脚本是异步加载的)。 挑战在于,脚本需要包含在 HTML 中,而我们的 JavaScript 变量是不可访问的(我们现在是 Webpack 用户!)

但正如我之前解释说,在webpack.config.js文件中,我已经添加了output.library属性,将揭露对象,函数,或变量中标注关键字export的入口文件 Webpack HTML 通过其全球范围(改变他们的范围从constlet``var)。 但是,它们不能通过名称直接访问。 output.library我给的价值是bundle。 因此,用export关键字标记的东西将作为bundle对象的属性可用。

打开事件注册应用在 Chrome 和打开你的 Chrome DevTools 控制台。 如果您在控制台中键入bundle,您可以看到它打印出一个空对象。 这是因为我们还没有从Webpack 的入口文件中导出任何文件(我们在apiCall.jsregistrationForm.js中导出了一些文件,但这些文件不在webpack.config.js入口属性中)。 因此,我们目前只有一个空的 bundle 对象。

让我们想一个成功地将谷歌 Maps 脚本包含在我们的 web 应用中的方法:

  • 在我们的 JavaScript 代码中,API 键目前作为全局变量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 元素。 这将使谷歌 Maps 脚本按照预期工作。
  • 我们这里不需要asyncdefer,因为只有在页面加载完成后才加载脚本。

在你的 Chrome DevTools 控制台,当你在关于页面,尝试输入bundle再次。 这一次,您应该看到一个对象被打印为initMap属性之一。

In our ToDo List app, we created HTML elements by writing the HTML code directly in template strings. It is very efficient for constructing a large number of HTML elements. However, for smaller elements, it is better to use the document.createElement() method, since it makes the code more readable and easy to understand when there are a lot of attributes to that element that need dynamic values.

添加带有标记的谷歌地图

我们已经成功地将谷歌 Maps 脚本包含在页面中。 当谷歌 Maps 脚本完成加载时,它将调用我们在about.js文件中声明的initMap函数。 现在,我们将使用该函数创建带有指向 JS Meetup Event 位置的标记的地图。

添加谷歌地图的标记和更多功能的过程在谷歌地图文档中有很好的解释,可在:https://developers.google.com/maps/documentation/javascript/adding-a-google-map

我们前面包含的谷歌 Maps 脚本为我们提供了一些可以创建mapMarkerinfowindow的构造函数。 要使用marker添加简单的谷歌 Maps,请在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对象的内容。 现在,在谷歌 Chrome 上打开about.html页面; 您应该在您的活动地点看到带有标记的地图。 默认情况下,信息窗口是打开的。

恭喜你! 您已经成功构建了您的活动注册应用! 但是,在我们开始邀请人们参加活动之前,在应用中还有一件事需要做。

生成生产版本

你可能已经注意到关于 Meme Creator 和事件注册应用的一些事情。 这些应用首先加载纯 HTML; 之后,加载样式。 这使得应用暂时看起来很普通。 这个问题在待办事项列表应用中不存在,因为我们首先在待办事项列表应用中加载 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文件有很多插件,用来优化代码,让最终用户的应用加载时间更快。 npm run watch将不能正常工作,只有当NODE_ENV是生产,因为有很多插件包括做生产优化。 要为你的事件注册应用生成一个产品构建,请遵循以下步骤:

  1. .env文件中的NODE_ENV变量的值更改为production
  2. 在终端的项目根目录下运行以下命令npm run webpack

这将需要一段时间来完成命令的执行,但一旦它完成了,你应该看到许多文件在您的项目/dist文件夹。 将有 JS 文件,CSS 文件,和.map文件,其中包含生成的 CSS 和 JS 文件的源映射信息。 JS 文件将被压缩并缩小,因此加载和执行时间非常快。 也会有一个字体目录包含 Bootstrap 使用的字体。

到目前为止,我们只在 HTML 中包含了 JS 文件,因为它也包含了 CSS 代码。 然而,这也是为什么页面在开始加载时显示空白 HTML 而没有 CSS 的原因。 CSS 文件应该包括<body>元素之前首先将负载和页面样式加载时将统一(看看我们包括 CSS 文件第一章,建立一个 ToDo 列表应用)。 对于生产构建,我们需要删除对旧 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文件,将 CSS 和 JS 文件添加到前面manifest.json文件的 status 属性中,如下:

...
<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文件中整洁地记录该过程,并将其与其他文件一起发送。

The main reason why sharing the .env file should be avoided is because the environment variables might contain sensitive information and should not be transported or stored in version control as plain text.

现在,您已经了解了如何为使用 Webpack 构建的应用生成产品构建。 现在,Meme Creator 应用还没有生产版本! 我将让你使用本章中使用的webpack.config.js文件作为参考。 所以,继续为你的 Meme Creator 创建一个产品构建吧。

总结

做得好! 你刚刚构建了一个非常有用的事件注册应用。在此过程中,你学习了一些非常高级的 JavaScript 概念,比如为可重用代码构建 ES6 模块,使用 fetch 进行异步 AJAX 调用,以及使用 promise 处理异步代码。 您还使用了 Chart.js 库来构建图表以可视化地显示数据,最后,使用 Webpack 创建了一个可用于生产的构建。

学习了所有这些概念后,你就不再是 JavaScript 的初学者了; 你可以自豪地称自己为专业人士! 但是,除了这些概念之外,现代 JavaScript 还有很多工作要做。 正如我之前告诉过您的,JavaScript 不再是一种仅用于浏览器上的表单验证的脚本语言。 在下一章中,我们将使用 JavaScript 构建一个点对点视频调用应用。