React-渐进式-Web-应用-全-

React 渐进式 Web 应用(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

使用 React 创建渐进式 Web 应用旨在为您提供关于 Web 开发未来的一切所需。渐进式 Web 应用PWA)对于希望利用 Web 所能提供的最佳功能的公司来说越来越普遍,它们由最前沿的技术驱动,弥合了 Web 应用和本地应用之间的差距。

在本书中,我们将利用流行的 JavaScript 库 React.js 的强大功能来创建快速和功能齐全的用户界面。然后,我们将使用革命性的新 Web 技术添加渐进式 Web 应用程序功能,如推送通知和即时加载。最后,我们将简化应用程序的性能,并探讨如何最好地衡量其速度。

通过本书,您将对 React 和 PWA 感到舒适,并为 Web 的未来做好准备。

本书涵盖内容

第一章,创建我们的应用结构,简要概述了您将学习构建的内容--一个具有推送通知和离线支持的实时聊天应用程序。您将了解这种应用程序所面临的挑战,并对将在本书中讨论的技术进行简要概述。在本章结束时,您将使用 HTML 和 CSS 设置聊天应用程序的应用程序结构。

第二章,使用 Webpack 入门,指出在编写任何 React 代码之前,您需要设置 webpack 构建过程。在本章中,您将介绍 webpack;您将学会安装该软件包并设置一些基本配置,以及启动开发服务器。本章将使您准备好开始学习 React。

第三章,我们应用的登录页面,向您介绍 React 时间!在本章中,您将学会编写前两个组件:一个应用程序包装器来包含应用程序和一个 LoginContainer。了解如何使用 ReactDOM 和 JSX 进行渲染,并编写一个基本表单,允许用户登录。在本章结束时,您将熟悉并熟悉 React 语法。

第四章《使用 Firebase 轻松设置后端》告诉您登录表单看起来不错,但缺乏实际功能。为了继续前进,您将需要一个后端数据库和身份验证解决方案来与之通信。本章将向您介绍 Google 的 Firebase。在 Firebase 控制台上设置应用程序,然后为表单编程登录和注册功能。

第五章《使用 React 进行路由》让您知道一旦用户登录,您希望将他们重定向到主要的聊天视图。因此,在本章中,您将学会构建主视图,然后设置 React 路由器,允许用户在页面之间移动。最后,学会添加第三个视图——个人用户视图,并探索 URL 中的参数匹配。

第六章《完成我们的应用程序》将带您完成构建基本应用程序的最后一步,为聊天和用户视图添加功能。您将学会如何从 Firebase 中写入和读取数据,并利用 React 生命周期方法来实现。一旦完成,您的 Web 应用程序将完成,但它还不够先进!

第七章《添加服务工作者》涵盖了服务工作者及其工作原理。在这里,您将了解如何注册自定义服务工作者,并了解其生命周期,然后连接到默认的 Firebase 消息服务工作者。

第八章《使用服务工作者发送推送通知》教你配置应用程序,因为我们的服务工作者已经准备好,可以发送推送通知。您将使用 Firebase Cloud Messaging 来管理发送这些通知,并添加自定义功能来控制在桌面和移动设备上何时以及如何发送它们。

第九章《使用清单使我们的应用可安装》教授清单是一个 JSON 文件,允许用户将您的应用保存到他们的主屏幕上。您将学会创建清单,并了解最佳实践以及 iOS 特定的考虑因素。您还将学会自定义闪屏和图标。

第十章,应用外壳,阐述了应用外壳模式作为 PWA 中的关键概念,但它带来了哪些优势?您将介绍渐进增强的外壳和 RAIL 系统,然后将一些应用布局移出 React 以实现最佳渲染。

第十一章,使用 Webpack 对 JavaScript 进行分块以优化性能,探讨了 PRPL 模式、其目标和方法,以及如何在应用中实现它的概述。然后,您将深入研究,根据路由将 JavaScript 分块,并延迟加载次要路由。

第十二章,准备缓存,介绍了如何利用服务工作线程实现离线功能,通过了解新的缓存 API 以及如何将其与服务工作线程一起使用来缓存 JavaScript 分块。

第十三章,审计我们的应用,现在是检查我们工作的时候了!在本章中,您将介绍 Lighthouse 并了解如何使用 Lighthouse 审计 PWA。

第十四章,结论和下一步,您的第一个 PWA 已经完成!在开发过程中,您手动构建了大部分 PWA 基础设施。在本章中,您将学习有关辅助库和快捷方式以节省时间,并探索 PWA 开发的未来。此外,您还将了解有关未来项目想法和改进的建议,作为额外的挑战。

本书所需内容

您所需的只是一台可以运行 Node.js 的计算机(nodejs.org/en/download/),一个用于编写代码的文本编辑器,以及最新版本的 Chrome 浏览器。如果您想在移动设备上测试应用程序,还需要一部 Android 或 iOS 手机。

本书适合对象

本书适用于想要开发高性能 Web 用户界面的 JavaScript 开发人员。本书需要对 HTML、CSS 和 JavaScript 有基本的了解。

约定

在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是这些样式的一些示例以及它们的含义解释。文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:在App.js中,我们首先导入LoginContainer

代码块设置如下:

import React, { Component } from 'react';
import LoginContainer from './LoginContainer';
import './app.css';

class App extends Component {
  render() {
    return <LoginContainer />
  }
}

export default App;

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

if (module.hot) {
  module.hot.accept('./components/App', () => {
 const NextApp = require('./components/App').default;
    ReactDOM.render(
      <App/>,
      document.getElementById('root')
    );
  });
}

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

 yarn add css-loader style-loader

新术语重要单词以粗体显示。例如,屏幕上看到的单词,比如菜单或对话框中的单词,以这种方式出现在文本中:返回到应用程序,您应该看到我们新组件的Hello from LoginContainer

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

第一章:创建我们的应用结构

欢迎来到使用 React 构建渐进式 Web 应用

本书将带您完成构建一个 React 应用程序,同时也作为渐进式 Web 应用程序的整个过程。我们将涵盖构建此类应用程序的“如何”,还将强调最佳实践以及如何衡量您的应用程序,以确保成功实施 PWA 功能。

渐进式 Web 应用程序有望成为 Web 应用程序的未来。它们承诺提供一系列额外功能,如推送通知和可安装性,将它们推向原生 iOS 或 Android 应用程序的领域。此外,对性能的强调(利用尖端的 Web 技术)意味着 PWAs 创建的应用程序对所有人都很快。

我们将深入讨论 PWAs 的每个方面,以及将常规 Web 应用程序转换为渐进式应用程序的过程。我们还将深入研究 React 最佳实践,使用诸如 React Router 之类的库。

要检查本章和未来章节的代码,您可以在github.com/scottdomes/chatastrophe/上查看已完成的项目。该存储库包括每个章节的分支。访问github.com/scottdomes/chatastrophe/tree/chapter1查看本章的最终代码。

在这一章中,我们将开始应用的基本结构。以下是我们将涵盖的内容:

  • 渐进式 Web 应用的用例

  • 我们希望我们的应用程序实现的基本用户故事

  • 项目结构和基本 HTML

  • 安装依赖

  • 开始使用 React

首先,让我们为我们应用的旅程设定场景。

设定场景

你的一个朋友打电话给你,兴奋地谈论他最新的创业想法(你知道的那个)。你耐心地听他的描述,但尊敬地拒绝成为其中的一部分。他很失望,但理解并承诺会随时向你更新项目的详情。你咕哝着表示同意。

几个月后,他在你的工作地点见到你,并宣布他找到了一群认真的投资者,他需要你帮助他建立他向他们承诺的软件。你再次拒绝,但在讨论报酬时,他提到了一个你无法拒绝的数字。一周后,你坐飞机去了旧金山。

在投资者面前(令你惊讶的是,他们是一个全神贯注的观众),你的朋友向你介绍了应用程序的基础知识。在充斥着流行语(“大规模互联”和“全球社区”)之间,你收集到了足够的信息,可以用一句话总结这个应用程序。

“所以,这是一个聊天室…为世界上的每个人…一次…”

你的朋友微笑着说:“是的。”

你被一百万陌生人同时在同一个应用程序中交谈的画面所困惑,但投资者们却掌声雷动。当你走向门口时,你的朋友再次宣布他们想要补偿你…提到了比之前更高的数字。你坐下来。

问题

“问题是,”你的朋友解释道,“这个聊天室必须是为每个人而设的。”

“全球社区,”你带着一个知情的点头说道。

“确切地说。每个人。即使他们在沙漠中的小屋里有糟糕的互联网。他们也应该被包括在内。”

“大规模互联”,你补充道。

“确切地说!所以它需要快速。轻巧。美观。动态。”

“所以每个人都会同时交谈?那不会是-”

“一个全球性的集体,是的。”

另一个问题

“另一个问题,”你的朋友宣布道,“是我们的用户大多会使用手机。在路上。”

“所以你想做一个 iOS 和 Android 应用?”

你的朋友挥了挥手。“不,不。没人再下载应用了。尤其是在发展中国家;那需要太多带宽。记住,全球性的集体。”

“所以是一个网页应用。”

“是的。一个网页集体。”

尽管你的直觉告诉你,这个项目很有趣。你如何设计一个网页应用程序尽可能快?如何使它在所有网络条件下工作?如何制作一个具有原生应用所有便利性的聊天应用,但是用于网页?

你叹了口气,握了握他的手。“让我们开始工作吧。”

开始工作

欢迎来到渐进式网页应用的世界。

在前面的情景中,你的朋友描述的问题正是PWA(渐进式网页应用)被设计解决的问题。

第一个问题是,许多用户将在较差的网络条件下访问你的网页。他们可能是硅谷的技术专家,在咖啡店里用 iPhone,WiFi 信号不好,或者他们可能是孟加拉国的村民在偏远地区。无论如何,如果你的网站对他们没有优化,他们就不会留下来。

您的应用程序加载速度有多快——它的性能——因此成为一个可访问性问题。PWA 通过第一次快速加载,以及之后每次更快地加载来解决这个问题。随着本书的进展,我们将更多地讨论它们是如何做到的。

其次,移动应用程序的安装过程对用户来说是一个障碍。这意味着您的用户需要更加致力于使用您的应用程序——足够多以放弃存储空间和时间,并使自己暴露于恶意和侵入性代码的可能性之中,甚至在他们有机会尝试应用程序之前!

如果我们可以在没有初始投资的情况下提供原生应用程序体验会怎样?PWA 试图弥合这一差距。同样,我们将在随后的章节中讨论它们是如何做到的,以及它们实际上有多成功。然而,这两者都是值得挑战的,并解决这两个问题将对我们的应用程序的用户体验产生巨大的影响。

为什么选择渐进式 Web 应用程序?

许多静态网页在性能方面做得非常出色。然而,当您只需要渲染一些 HTML、CSS 和少量 JavaScript 时,在各种网络条件下工作就不那么困难了。

当我们开始谈论 Web 应用程序——大型、复杂的、基于 JavaScript 的工作马——性能就成为一个重大挑战。我们的前端将有大量的代码。如果用户想要充分利用我们的应用程序,他们需要下载所有这些代码。我们如何确保他们不会在空白的加载屏幕前等待十秒,当 500KB 的 JavaScript 初始化时?

因此,我们大部分的性能增强将集中在解决 JavaScript 问题上。这在使用 React 时尤其如此。

为什么选择 React?

React正在迅速成为前端 Web 应用程序的首选解决方案。为什么?因为它快速、优雅,并且使管理大型应用程序变得容易。

换句话说,它使复杂性变得简单。当然,PWA 不一定要使用 React。PWA 可以是任何 Web 应用程序或网站。

React 确实有一个主要的好处——它的组件模式,其中 UI 被分割成不同的部分。正如我们将看到的,组件模式让我们将界面分解成小的代码块,以减轻之前的 JavaScript 下载问题。然而,除此之外,任何前端框架对于 PWA 来说都同样有效。

React 的优势在于它是构建前端应用程序的一种美丽而有趣的方式。这也是一种需求技能。如果你将对 React 的了解与 PWA 的经验相结合,你将成为快速发展的 Web 开发世界中未来准备的人。

换个名字也一样

你告诉你的朋友关于你在 PWA 和 React 上的学习,但在你结束之前,他挥了挥手打断了你。

“是的,是的。嘿,你觉得名字应该是什么?”

再一次,你被一种不安的感觉击中,觉得所有这一切都是一个错误,你永远不应该加入这个可疑的冒险,这个潜在的灾难。

“灾难性的对话”,你脱口而出。

你的朋友微笑着拍了拍你的背。“太棒了。好了,开始反应或者其他什么!”

用户故事

在我们开始构建应用程序之前,让我们更深入地了解我们到底想要实现什么。

我们可以从用户故事开始。用户故事是应用程序特定功能的描述,从我们用户的角度出发。

这是由Jon Dobrowolski建议的框架:

用户应该能够 _____。

作为用户,我希望做 ___,因为 ____。

假设我正在做 ___,我应该能够 ___ 以便 ___。

不是所有功能都需要整个框架。让我们从一些基本的例子开始:

  • 用户应该能够登录和退出应用程序

相当简单。我认为我们不需要为此添加理由,因为这是一个非常基本的功能。

让我们转向更高级的东西:

  • 用户应该能够在离线状态下查看他们的消息

  • 作为用户,我希望能够在不需要互联网连接的情况下检查我的消息,因为我可能需要在外出时阅读它们

  • 假设我在没有互联网访问的情况下启动应用程序,我应该能够查看所有过去的消息

让我们来看看应用程序的一些更基本的功能。用户应该能够实时发送和接收消息。

实时功能将是我们应用程序的关键。除非快速而流畅,否则聊天没有意义:

  • 用户应该能够查看特定作者的所有消息

  • 作为用户,我希望能够查看特定用户发送的所有消息列表,因为我可能需要查看他们在对话中的贡献,而不受其他消息的干扰

  • 假设我点击用户的电子邮件,我应该被带到一个包含他们所有消息的个人资料视图

个人资料视图是您向客户建议管理主要聊天室不可避免的混乱的特殊功能。

让我们添加几个更多的 PWA 特定用户故事:

  • 用户应该在其他用户发送消息时收到推送通知

  • 作为用户,我希望能够不断更新对话的进展,因为我不想错过任何重要的内容

  • 假设聊天室在我的屏幕上没有打开或可见,我应该收到另一个用户发送的每条消息的通知

并安装:

  • 用户应该能够在他们的移动设备上安装应用程序

  • 作为用户,我希望能够打开应用程序,而不必在浏览器中导航到 URL,因为我希望轻松访问聊天室

  • 假设我是第一次注册聊天,我应该被提示在我的设备上安装应用程序

不要担心我们将如何实现这些目标;我们将及时解决这个问题。现在,让我们继续记录我们想要做的事情。

我们的客户非常重视性能,所以让我们指定一些性能特定的目标:

  • 用户应该能够在不稳定的网络条件下在 5 秒内加载应用程序

  • 作为用户,我希望能够尽快与应用程序交互,因为我不想被困在等待加载的过程中

  • 假设我使用较差的互联网连接打开应用程序,我仍然应该在 5 秒内加载

在 5 秒内加载对于我们的应用程序来说仍然有点模糊。我们将在性能章节中更深入地重新讨论这个故事。

前面提到的用户故事涵盖了我们应用程序的基本功能。让我们谈谈这些要点所提出的具体挑战。

应用程序挑战

对于以下每一点,我鼓励您考虑如何在 Web 应用程序的背景下解决这些问题。希望这能让您更好地了解我们尝试通过 PWA 实现的目标以及我们面临的困难。

即时加载

通过渐进式 Web 应用程序,我们的目标是提供一种更接近原生应用程序(从 Apple 应用商店、Google Play 商店或其他应用商店下载的应用程序)的体验,而不是您典型的 Web 应用程序。当然,原生应用程序的一个优势是所有相关文件都是预先下载和安装的,而每次用户访问 Web 应用程序时,他们可能需要重新下载所有资产。

解决方案?当用户首次访问页面时,下载这些资产然后保存它们以备后用(也称为缓存)。然后,当用户重新打开应用程序时,我们不再通过互联网下载文件(慢),而是从用户设备中检索它们(快)。

然而,这仅适用于用户再次访问应用程序的情况。对于初始访问,我们仍然需要下载所有内容。这种情况特别危险,因为当用户首次访问 Chatastrophe 时,他们还没有意识到其价值,所以如果加载时间太长,他们很可能会离开(永远)。

我们需要确保我们的资产尽可能优化,并且在第一次访问时尽可能少地下载,以便用户留下来。

简而言之,第一次访问快速加载,随后每次访问几乎立即加载。

推送通知

没有通知的聊天应用是没有意义的!再次强调,我们正在尝试模拟传统上是原生应用功能的内容--直接向用户设备推送通知。

这个问题比看起来要棘手。推送通知只有在应用程序没有打开时才会收到(毕竟这就是整个目的)。因此,如果我们的网络应用程序没有打开和运行,我们怎么可能运行代码来显示通知呢?

答案是使用一个专门设计用于向注册设备发送通知的第三方服务。因此,设备不再接收通知提醒其用户,而是设备发送消息通知我们的通知服务,然后通知所有相关设备。

我们还需要一段代码,它始终处于“开启”状态,等待从第三方服务接收通知并显示它们。这个挑战最近才通过网络技术得以解决,也是 PWA 如此令人兴奋的原因之一。

现在,如果这种区别还没有“点亮”你,不要担心。我们稍后会更详细地讨论这个问题。现在,重点是推送通知将是我们的网络应用程序的一个有趣挑战。

离线访问

即使我们的用户没有连接到互联网,他们也应该能够查看过去的消息并在我们的应用程序中导航。

答案原来与之前关于即时加载的讨论密切相关。我们只需要缓存应用程序运行所需的所有内容,然后按需加载;当然,“简单”是关键词。

移动优先设计

多年来,Web 设计的一个大热词一直是响应式——从桌面缩放到移动尺寸时看起来一样好的网站。

PWA 本质上是对响应式设计的加强,将移动设计扩展到应用程序的各个方面,从外观到功能。

然而,归根结底,我们需要确保我们的应用在每种屏幕尺寸上都表现出色。它还需要在我们已经讨论过的限制条件下表现良好。我们不能过分依赖大背景图片或强烈的图形。我们需要一个简单而好看的 UI,既注重外观又注重性能。

渐进增强

任何 React 应用程序的性能瓶颈都在于下载和运行 JavaScript。我们整个应用程序的代码将包含在 JavaScript 文件中——直到这些文件被执行,我们的应用程序才能正常工作。这意味着我们的用户可能会被困在一个白屏上(没有任何功能),直到 JavaScript 准备就绪。

渐进增强是一种旨在解决这个问题的技术。本质上,它意味着用户的体验应该随着应用程序的下载而逐渐改善,取决于用户的浏览器。换句话说,随着时间的推移(和应用程序的下载量增加)以及用户软件的改进,应用程序体验会变得更好。

拥有最先进的浏览器、最快的互联网连接和完全下载的应用程序的用户将获得最佳体验,但使用过时的浏览器、不稳定的连接并刚刚登陆页面的用户也将获得优质的体验。

这意味着我们的React.js应用程序需要在没有任何 JavaScript 的情况下具有一些功能。这是一个有趣的挑战。

把我们的用户体验想象成一系列层次,从好到极好,随着时间的推移逐渐完善。

让我们开始吧

希望前面的概述让您对我们尝试实现这个应用程序的目标有了具体的想法,也让您了解了实现这些目标的障碍。虽然有很多挑战,但当我们逐步完成用户故事时,我们将逐个解决它们,直到我们拥有一个快速且功能齐全的渐进式 Web 应用程序。

通过上面提到的挑战,您可以看到一个普遍的趋势:在任何情况下都要有良好的性能和用户体验。这无疑是一个值得追求的目标,也正是为什么 PWA 技术适用于任何 Web 应用程序的原因;它们只是承诺为每个人提供更好的体验。

一旦我们开始构建我们的应用程序,我们还将看到解决这些问题仍然是一个挑战,但使用 React 是完全可以实现的。

下一步是为我们的应用程序设置一切,并使用 HTML 和 CSS 创建基本的文件夹结构。

我们的应用程序骨架

首先要做的事情。在我们开始构建 React 应用程序之前,让我们先使用基本的 HTML 和 CSS 进行设置-我们应用程序的骨架,我们将在其上堆叠 React 肌肉:

  1. 打开您的终端并切换到您想要存储项目的任何目录。

  2. 然后,我们将用mkdir chatastrophe创建我们的应用程序目录。让我们进入该文件夹,在其中创建另一个名为public的文件夹,在public中创建touch index.html。如果您使用 Windows,请使用type nul > index.html而不是touch

  1. 然后,在您选择的文本编辑器中打开整个chatastrophe文件夹。我将在本教程中使用Sublime Text 3。打开index.html文件,让我们写一些 HTML!

  2. 让我们从基本的 HTML 元素开始。创建一个<html>标签,在其中是<head><body>

  3. 这不会是一个编程教程,如果没有一个 hello world,在 body 中,让我们在<h1>标签中放置Hello world!

  4. 然后,在浏览器中打开index.html

本章结束时,我们的目标是显示与前面的插图完全相同的内容,但使用 React 来渲染我们的<h1>

为什么我们把我们的index.html放在 public 文件夹里?嗯,当用户访问我们的页面时,我们的 HTML 是他们将下载的第一件事。他们将完全按照我们在这里看到的方式下载它。这与我们的 React JavaScript 形成了鲜明对比,在被提供给客户端之前,它将被转译(在下一章中会详细介绍)。我们编写的 React 代码将是私有的。我们编写的 HTML 将是公开的。

这是一个在我们进入 React 世界时会更有意义的区别,但现在,只需知道惯例是将 HTML 和静态资产放在 public 文件夹中即可。

CSS 和资产

我们在初创公司的好朋友(现在被称为 Chatastrophe-你做了什么?)已经找了一位设计师为我们提供一些基本资产。这些包括用于我们聊天框的发送图标和应用程序的徽标。你不喜欢这种风格,但这就是生活

让我们去github.com/scottdomes/chatastrophe-assets下载图像文件。您可以通过单击克隆或下载按钮,然后选择下载为 Zip 来下载它们。然后,将它们解压缩到public文件夹中,一个名为assets的新文件夹中(因此所有资产文件应该在chatastrophe/public/assets中)。

在继续之前,我们可以通过在index.html中测试它们来确保我们的资产看起来还不错。在<h1>上面,让我们放一个img标签,src设置为/img/logo.png,ID 设置为test-image

<img src=”assets/icon.png” id=”test-image”/>

它应该是这个样子的:

这更加美丽。

我们需要做的最后一件事是添加我们的 CSS。幸运的是,所有的 CSS 都已经神秘地为我们准备好了,省去了我们样式化应用的繁琐任务。我们所要做的就是引入assets/app.css

我们可以通过链接标签将其包含在我们的index.html中:

我们应该立即看到页面的变化。背景应该是一个渐变,图片现在应该有一个轻微的脉动动画:

成功了!这就是我们的主要资产。让我们继续进行一些对我们的 HTML 的改进。

元标签和网站图标

我们的应用将是以移动设备为先的,正如我们已经讨论过的。为了确保我们的 HTML 完全优化,让我们添加一些更多的标记。

首先,让我们在index.html的顶部添加一个DOCTYPE声明。这告诉浏览器可以期望什么样的文档。在 HTML 5(最新版本的 HTML)中,它总是这样的:

<!DOCTYPE html>

接下来,我们需要为viewport宽度添加一个元标签。它看起来像这样:

<meta name="viewport" content="width=device-width, initial-scale=1">

这是做什么的?基本上,它告诉浏览器以与其屏幕相同的宽度显示网页。因此,如果网页看起来是 960px,而我们的设备宽度是 320px,而不是缩小并显示整个页面,它会将所有内容压缩到 320px。

正如你所期望的那样,只有当你的网站是响应式的并且能够适应较小的尺寸时,这才是一个好主意。然而,由于响应性是我们的主要目标之一,让我们从一开始就这样做。在我们文档的<head>中添加这个标记。

还有几个标签要添加!我们网页上使用的字符集可以用几种不同的方式进行编码:UnicodeISO-8859-1。您可以查阅这些编码以获取更多信息,但长话短说,我们使用 Unicode。让我们像这样添加它,就在前面的<meta>标签下面:

<meta charset="utf-8">

趁热打铁,让我们添加 HTML 所在的语言。在我们现有的<html>标签上,添加lang="en"

<html lang="en">

好的,HTML 的清理工作就到此为止。我们需要的最后一件事是一个favicon,这是显示在浏览器标签中标题旁边的小图标。这包含在我们的资产包中,所以我们只需要将其链接起来(就在我们的<meta>标签下面):

<link rel="shortcut icon" href="assets/favicon.ico" type="image/x-icon">

您的浏览器标签现在应该是这样的:

就这样,我们完成了!

接下来,我们将看看如何在我们的项目中包含 React,以及我们将需要的所有其他依赖项。

npm 是什么?

React 应用程序主要是 JavaScript。如果您有使用 JavaScript 的经验,您就会知道浏览器完全能够解析和执行 JavaScript。

在大多数基本网站中,我们会在<script>标签中链接到页面所需的 JavaScript,然后浏览器会下载并运行它。

我们将在我们的 React 应用程序中做类似的事情(有相当复杂的情况;在第二章,使用 Webpack 入门中会详细介绍)。

然而,JavaScript 不再局限于浏览器。越来越多的应用程序也在后端使用 JavaScript,JavaScript 在自己的环境中运行。

长话短说,JavaScript 现在无处不在,这种普及的推动力是Node.js,一个 JavaScript 运行时库,它让您可以在浏览器环境之外运行 JavaScript。

好的,这很令人兴奋,但为什么这对我们的 React 项目很重要呢?

Node 还引入了将包的概念引入到 JavaScript 中。包本质上是您可以安装到应用程序中的第三方代码库,然后在需要的地方导入和使用它们。即使您的应用程序不是 Node 应用程序,也可以使用包。

React 就是这样一个包。之前提到的 Webpack 是另一个包。简而言之,为了构建复杂的 Web 应用程序,我们将不可避免地依赖于许多其他人的代码,因此我们需要包,我们需要Node 的包管理器(简称npm)来安装它们。

我们还将使用npm来启动我们的应用程序并执行一些基本任务,但它的主要目的是管理包。

Node 设置

好了,说得够多了。让我们继续安装 Node,它已经捆绑了npm

  1. 前往nodejs.org并下载 Node 的最新稳定版本:

  1. 在这里,我会选择 v6.10.3,这是大多数用户推荐的版本。

  2. 安装完成后,打开终端并运行node -v以确认安装:

  1. 您还可以通过运行npm -v.来确认npm已经包含在内。

重申一下,Node 是一个 JavaScript 运行时,用于在浏览器之外执行 JavaScript,而npm是一种管理 JavaScript 代码模块的方法。在本书中,我们不会直接使用 Node,但我们会经常使用npm

npm 的黑暗面

在过去的一年里,npm因各种原因受到了批评。

  • 它可能会很慢(尝试在较差的 Wi-Fi 连接上安装大型包)

  • 它的安装过程可能会导致不同开发人员在同一项目上获得不同的结果

  • 即使您之前已经下载了包,它也无法离线工作

作为对这些问题的回应,Facebook 推出了一个名为Yarn的包管理器。Yarn 本质上是npm的一个包装器,提供了相同的基本功能以及额外的好处。让我们安装它,以便可以使用它来管理我们的包!

访问yarnpkg.com/en/docs/install获取安装说明。对于 macOS,请注意您将需要Homebrew(这类似于 macOS 软件包的npm-软件包无处不在!),您可以在brew.sh/.获取它。

项目初始化

我们需要做的下一件事是将我们的应用程序初始化为一个npm项目。让我们试一试,然后我们将讨论为什么需要这样做:

  1. 在您的project文件夹中,在终端中输入yarn init并按回车键。

  2. 它会问您一系列问题。第一个问题最重要--我们应用程序的名称。它应该只是当前文件夹的名称(chatastrophe)。如果不是,请输入chatastrophe。然后,只需按回车键跳过其余的问题,接受默认答案。如果我们打算发布自己的包,这些问题会更重要,但我们不打算,所以不用担心!

  3. 如果你在完成了 yarn init 后查看项目文件夹,你会注意到它添加了一个带有我们项目名称和版本的package.json文件。我们的package.json很重要,因为它将作为我们依赖项的列表--我们将通过yarn安装的包。

不过,足够谈论依赖关系了,让我们安装我们的第一个!有什么比安装 React 更好的选择呢?

安装 React

让我们尝试通过在你的project文件夹中运行yarn add react@15.6.1来安装它。

我们正在安装 React 的特定版本(15.6.1)以确保与其他依赖项的兼容性,并确保在发布新版本时没有意外问题。

安装完成后,你应该看到 React 添加到我们的package.json的依赖项中。你还会看到yarn生成了一个node_modules文件夹和一个yarn.lock文件。

node_modules文件夹是我们所有包的所在地。如果你打开它,你会看到已经有几个文件夹了。我们不仅安装了 React,还安装了 React 所依赖的一切--依赖的依赖。

你可以想象,node_modules文件夹可能会变得相当庞大。因此,我们不会将其检入源代码控制。当新开发人员加入团队并下载项目文件时,他们可以根据package.json独立安装依赖项;这样可以节省时间和空间。

然而,我们需要确保他们获得与其他人相同的包和相同的版本;这就是yarn.lock文件的作用。

前面提到的设置确保我们已经准备好安全地使用第三方库。我们在项目中有package.jsonyarn.locknode_modules文件夹。在继续之前,让我们确保添加 React 成功了。

使用 React

让我们通过使用它来向我们的屏幕渲染一个简单的元素来确认 React 是否在我们的项目中。这将是我们第一次尝试 React,所以要慢慢来,确保你理解每一步。

首先,我们需要将我们刚刚用yarn安装的 React 包导入到我们的index.html中,以便我们可以在那里使用它。

为了做到这一点,我们在我们的node-modules文件夹中添加一个指向主 React 文件的<script>标签。这个标签看起来像这样:

<script src="../node_modules/react/dist/react.js"></script>

将这个放在你的index.html中,放在body标签的底部(在闭合的</body>之前)。

好了,我们有了 React!让我们用它来制作一个简单的<h1>标签,就像我们在 HTML 中写的那样。

React 有一个名为createElement的函数来实现这一目的。它接受三个参数:元素类型,称为 props 的东西(稍后详细介绍),以及子元素(标记内部的内容)。

对我们来说,它看起来像这样:

React.createElement('h1', null, 'Hello from React!')

这个函数调用创建了一个如下所示的元素:

<h1>Hello from React!</h1>

为了确认它是否有效,让我们将其console.log出来:

<script src="../node_modules/react/dist/react.js"></script>
<script>
  console.log(React.createElement('h1', null, 'Hello from react!'))
</script>

重新加载index.html,然后右键单击或按住 Control 键单击并选择 Inspect 以在 Chrome 中打开 DevTools 并切换到 Console 选项卡。在那里,我们看到我们的元素……或者没有。而不是 HTML 输出,我们得到了这样的东西:

这不是我们可能期望的 HTML 元素,但我们可以看到 React 以自己的方式工作。我们有一个 JavaScript 对象,其中有一个h1类型的字段。让我们看看是否可以将其转换为屏幕上的实际 HTML 标记。

欢迎来到 ReactDOM

关于 React 的一个秘密是,它是一个用于创建 UI 的库,但不是用于渲染 UI 的库。它本身没有渲染 UI 到浏览器的机制。

幸运的是,React 的创建者还有一个名为ReactDOM的包,专门用于这个目的。让我们安装它,然后看看它是如何工作的。

首先,我们使用yarn add react-dom@15.6.1来安装它。

然后,在index.html中以与 React 类似的方式引入它:

<body>
  <img src="assets/icon.png" id="test-image"/>
  <h1>Hello world!</h1>
  <div id="root"></div>
  <script src="../node_modules/react/dist/react.js"></script>
 <script src="../node_modules/react-dom/dist/react-dom.js"></script>
  <script>
    console.log(React.createElement('h1', null, 'Hello from react!'));
  </script>
</body&gt;

ReactDOM 有一个名为render的函数,它接受两个参数:要渲染到屏幕上的 React 元素(嘿,我们已经有了!),以及它将被渲染在其中的 HTML 元素。

因此,我们有了第一个参数,但没有第二个。我们需要在我们现有的 HTML 中找到一些东西,可以抓取并连接到其中;ReactDOM 将在其中注入我们的 React 元素。

因此,在现有的<h1>标记下面,创建一个 ID 为root的空div

然后,在我们的ReactDOM.render函数中,我们将传入 React 元素,然后使用document.getElementById来获取我们的新div

我们的index.html应该如下所示:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta charset="utf-8">
    <link rel="stylesheet" href="assets/app.css">
    <link rel="shortcut icon" href="assets/favicon.ico" type="image/x-icon">
  </head>
  <body>
    <img src="assets/icon.png" id="test-image"/>
    <h1>Hello world!</h1>
    <div id="root"></div>
    <script src="../node_modules/react/dist/react.js"></script>
    <script src="../node_modules/react-dom/dist/react-dom.js"></script>
    <script>
      ReactDOM.render(React.createElement('h1', null, 'Hello from react!'), 
      document.getElementById('root'));
    </script>
  </body>
</html>

重新加载页面,你应该在屏幕中间看到'Hello from React!'的文本!

总结

成功!

在接下来的几章中,我们将深入(更深入)学习 ReactDOM 和 React。我们将学习如何以更直观的方式创建元素,以及 React 如何使构建 UI 成为一种梦想。

目前,我们已经准备好了项目的框架,这是我们未来应用的基础。干得好!

我们的下一步是完成准备的最后阶段,并深入研究我们最重要的依赖之一——一个名为 Webpack 的工具。

第二章:使用 Webpack 入门

本章主要讨论 Webpack:它是什么,如何使用它,以及为什么我们关心。然而,在我们深入研究 Webpack 之前,我有一个坦白要做。

在上一章中,我们在应用程序设置上有点作弊。我们需要添加一个文件夹结构的最后一部分--我们的 React 文件将存放的地方。

正如我们在上一章的依赖部分讨论的那样,React 的一个杀手功能是用户界面的组件化--将它们拆分成相关 HTML 和 JavaScript 的小块。例如,“保存”按钮可能是一个组件,位于表单组件内部,旁边是个人资料信息组件,依此类推。

组件结构的美妙之处在于与特定 UI 部分相关的所有内容都在一起(关注点分离),而且这些部分都在简洁易读的文件中。作为开发人员,你可以通过浏览文件夹结构轻松找到你要找的内容,而不是在一个庞大的 JavaScript 文件中滚动。

在本章中,我们将涵盖以下主题:

  • 如何组织我们的 React 项目

  • 设置 Webpack

  • 添加一个开发服务器

  • 使用 Babel 进行 JavaScript 转译入门

  • 激活热重载

  • 为生产环境构建

我们的项目结构

让我们看看实际操作中是什么样子。在我们的chatastrophe项目文件夹中,创建一个src文件夹(应该位于项目文件夹根目录中publicnode_modules文件夹旁边)。

src文件夹是我们所有 React 文件的存放地。为了说明这将是什么样子,让我们创建一些模拟文件。

src文件夹内,创建另一个名为components的文件夹。在该文件夹内,让我们创建三个 JavaScript 文件。你可以随意命名它们,但为了举例,我将称它们为Component1.jsComponent2.jsComponent3.js

想象一下,每个组件文件都包含了我们用户界面的一部分。我们需要这三个文件来构建完整的用户界面。我们如何导入它们呢?

嗯,当我们需要使用 JavaScript 文件时,我们可以像迄今为止所做的那样。我们可以为我们index.html中的每个组件创建一个script标签。这是一种蛮力的方法。

然而,随着我们应用程序的增长,这种方法很快就会变得难以管理。例如,像 Facebook 这样的应用程序将拥有成千上万个组件。我们无法为成千上万个组件编写script标签!

理想情况下,我们只有一个script标签,所有的 JavaScript 都合并在一起。我们需要一个工具,将我们的各种文件压缩在一起,给我们最好的两个世界--为开发者组织、分离的代码,以及为用户压缩、优化的代码。

“但是,斯科特,”你可能会说,“如果我们把所有的代码放在一个文件中,那不是会让浏览器下载时间更长吗?有小的、分离的文件不是一件好事吗?”

你说得对。最终我们不想回到单一的单文件,但也不想有成千上万个单独的文件。我们需要一个合适的中间地带,有一些代码文件,我们会达到这个中间地带。然而,首先,让我们看看如何使用我们的新朋友--Webpack将多个 JavaScript 文件捆绑成一个文件。

欢迎来到 Webpack

我们这一节的目标是将我们在index.html中的脚本标签中的 JavaScript(负责渲染我们的“Hello from React!”)移到src文件夹中的 JavaScript 文件中,然后由 Webpack 捆绑并注入到 HTML 中。

听起来很复杂,但由于 Webpack 的魔力,它比听起来简单。让我们开始吧:

  1. 首先,我们需要安装 Webpack:
yarn add webpack@3.5.4

如果你检查package.json,你应该会看到 Webpack 列在我们的依赖项下。在本书中,我将使用版本 3.5.4;如果你遇到任何莫名其妙的问题,尝试使用yarn add webpack@3.5.4指定这个版本:

  1. 现在,我们需要告诉 Webpack 该做什么。让我们先把我们的 React 代码移到src文件夹中。在chatastrophe/src中创建一个名为index.js的文件。

  2. 然后,输入以下代码:

console.log(‘hello from index.js!’);

我们的目标是让这个问候显示在我们的浏览器控制台中。

  1. 好的,让我们试试 Webpack。在你的终端中,输入以下内容:
node_modules/.bin/webpack src/index.js public/bundle.js

你的终端现在应该是这样的:

这样做有什么作用?嗯,它告诉 Webpack 将第一个文件复制(以及它需要的一切,也就是说,它需要的每个文件)到第二个文件中(这是 Webpack 为我们创建的,因为它不存在)。

打开新创建的public/bundle.js,你会看到很多 Webpack 样板代码...在底部是我们的console.log

好的,它可以工作;我们可以在我们的index.html中引入这个文件来看到我们的console.log,但这并没有充分利用 Webpack 的潜力。让我们试试其他的东西。

捆绑文件

让我们看看 Webpack 如何将我们的 JavaScript 文件合并在一起。按照以下步骤添加第二个 JavaScript 文件:

  1. 在我们的src文件夹中,创建另一个文件。让我们称之为index2.js,因为缺乏创造力。

  2. 在里面,添加第二个console.log

console.log(‘Hello from index2.js!’);
  1. 然后,在index.js(第一个)中,我们将按如下方式需要另一个文件:
require('./index2.js');
console.log('Hello from index.js!');

这基本上意味着index.js现在告诉 Webpack,“嘿,我需要另一个 index!”

  1. 好的,让我们重新运行与之前相同的 Webpack 命令:
node_modules/.bin/webpack src/index.js public/bundle.js

再次,我们只会指定src/index.js,但是如果你查看控制台输出,你会看到 Webpack 现在也获取了另一个文件:

  1. 打开public/bundle.js,滚动到底部,你会看到两个控制台日志。

这就是 Webpack 的美妙之处。我们现在可以扩展我们的应用程序以包含任意数量的 JavaScript 文件,并使用 Webpack 将它们合并为一个文件。

  1. 好的,让我们确保那些控制台日志能够正常工作。在我们的public/index.html中,在其他三个标签下面添加另一个脚本标签:
<script src="bundle.js"></script>
  1. 重新加载页面,打开控制台,你会看到这个:

移动我们的 React

够了,现在让我们使用 Webpack 来处理一些有用的代码:

  1. 删除我们的index2.js,并删除index.js中的所有代码。然后,将我们的 React 代码复制粘贴到index.js中,并删除index.html中的前三个脚本标签。

  2. 这样做后,你的index.html中应该只有一个脚本标签(用于bundle.js),而你的index.js应该包含这一行:

ReactDOM.render(React.createElement('h1', false, 'Hello from React!'), document.getElementById('root'))
  1. 在运行 Webpack 之前,我们有一个问题。我们删除了需要 React 和 ReactDOM 的脚本标签,但我们仍然需要一种方法在我们的index.js中访问它们。

  2. 我们可以以与需要index2.js相同的方式来做,也就是,输入require(‘../node_modules/react/dist/react.js’),但那需要大量输入。此外,我们将在我们的代码中使用许多来自node_modules的依赖项。

  3. 幸运的是,以这种方式需要模块是很常见的,所以require函数足够智能,可以根据名称单独获取依赖项,这意味着我们可以将其添加到我们的index.js的开头:

var React = require('react');
var ReactDOM = require('react-dom');

然后,我们可以像以前一样在我们的代码中使用这些包!

  1. 好的,让我们试一下。再次运行 Webpack:
node_modules/.bin/webpack src/index.js public/bundle.js

它将显示以下输出:

现在,你可以在我们的index.js中看到 Webpack 捆绑在一起的所有文件:React,它的所有依赖项和 ReactDOM。

重新加载页面,您应该看到没有任何变化。但是,我们的应用程序现在更具可扩展性,我们可以更好地组织我们的文件。当我们添加一个依赖项时,我们不再需要添加另一个<script>标签;我们只需在我们使用它的代码中要求它。

快捷方式

打出那么长的 Webpack 命令很无聊,也可能导致错误(如果我们误输入了bundle.js,最终生成了错误的文件怎么办?)。让我们简化这个过程以保持我们的理智。

首先,让我们决定我们的index.js将是我们应用程序的入口点,这意味着它将需要应用程序中的所有其他文件(或者说,它将需要一些需要其他文件的文件,这些文件需要一些其他文件,依此类推)。

相反,我们的bundle.js将是我们的输出文件,其中包含我们所有捆绑的代码。

因此,这两个文件将始终是我们在终端中给 Webpack 命令的参数。由于它们不会改变,让我们配置 Webpack 始终使用它们。

在我们的项目文件夹中(不是在src中,而是顶层文件夹),创建一个名为webpack.config.js的文件。在其中,放入以下内容:

module.exports = {
  entry:  __dirname + "/src/index.js",
  output: {
   path: __dirname + "/public",
   filename: "bundle.js",
   publicPath: "/",
  }
};

我们将我们的入口点定义为index.js的路径(__dirname是一个全局变量,它抓取当前目录,也就是说,无论我们在哪里运行webpack命令)。然后我们定义我们的输出文件。

现在,我们可以在终端中简单地运行node_modules/.bin/webpack,不带任何参数,得到相同的结果:

node_modules/.bin/webpack

一个很好的改进,但我们是开发人员,所以我们懒惰,想要更多的快捷方式。让我们缩短node_modules/.bin/webpack命令。

npm的一个很酷的功能是能够编写脚本来执行常用任务。让我们试试。在我们的package.json中,创建一个脚本部分;在其中,创建一个名为build的脚本,值为node_modules/.bin/webpack命令:

{
  "name": "chatastrophe",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
 "scripts": {
 "build": "node_modules/.bin/webpack",
 },
  "dependencies": {
    "react": "15.6.1",
    "react-dom": "15.6.1",
    "webpack": "3.5.4",
  }
}

然后,在终端中,您可以运行npm run buildyarn build。它们做的事情是一样的:运行 Webpack 命令并捆绑我们的文件!

哇,我们的生活变得越来越容易。我们还能更懒吗?

简而言之,是的。

我们的开发服务器

如果我们想要更新我们的代码(比如,将我们的h1更改为h2),我们将不得不进行更改,重新运行yarn build,然后重新加载页面以查看我们想要看到的每一个更改。这将大大减慢我们的开发过程。

理想情况下,每当我们更改 JavaScript 时,Webpack 命令将自动重新运行,并重新加载页面。这将是多么奢侈的世界啊!

幸运的是,有一个叫做webpack-dev-server的包专门用于这个目的。要安装它,只需运行yarn add webpack-dev-server

在我们深入之前,让我们简要介绍一下 Dev Server 是如何工作的。它在我们的机器后台运行一个小型的 Node 应用程序,提供我们公共文件夹中的文件,以便我们可以通过在浏览器中访问localhost:3000来查看它们。同时,它会监视bundle.js的源文件,当它们发生变化时重新打包,然后重新加载页面。

为了使其工作,我们需要指定要提供的文件夹(public),然后进行一些基本配置。

在我们的webpack.config.js中,在闭合的花括号之前添加以下内容(我们在这里有完整的代码):

devServer: {
  contentBase: "./public",
  historyApiFallback: true,
  inline: true,
}

contentBase会设置public作为要提供的文件夹,historyApiFallback让我们的单页应用看起来像多页应用,inline是自动刷新文件更改的部分:

module.exports = {
  entry: __dirname + "/src/index.js",
  output: {
   path: __dirname + "/public",
   filename: "bundle.js",
   publicPath: "/"
  },
 devServer: {
 contentBase: "./public",
 historyApiFallback: true,
 inline: true,
 }
};

好的,让我们试试。首先,我们将在我们的package.json中添加一个名为start的新脚本:

"scripts": {
  "build": "node_modules/.bin/webpack",
  "start": "node_modules/.bin/webpack-dev-server"
},

这将运行我们的 Dev Server(确保你首先运行了yarn add webpack-dev-server)。在你的终端中,输入yarn start。你会看到我们的 Webpack 编译,并且会收到一个通知,我们的应用正在端口8080上运行。让我们跳转到浏览器中的http://localhost:8080,我们应该能看到我们的应用程序。

最后的测试是将我们的index.js中的文本从Hello from React改为Hello from Webpack!。你的浏览器标签应该会自动重新加载并反映出更改,而无需重新运行 Webpack 命令。

Webpack 加载器

我们即将迈入未来。

到目前为止,在这本书中,我们一直在使用旧形式的 JavaScript。这种语言最近(2015 年)进行了一次整容,增加了一些便利和新功能。这个新版本被称为ECMAScript 2015,简称ES6。它比旧版 JavaScript(ES5)更加令人愉快,但也存在问题。

所有的互联网浏览器都能够完美运行 JavaScript,但许多用户使用的是旧版本浏览器,还不能运行 ES6。因此,作为开发者,我们想要使用 ES6,但如何才能在旧版本浏览器上使我们的网站正常工作呢?

关键在于 ES6 并没有做太多 ES5 做不到的事情,它只是让编写变得更容易。

例如,以前循环遍历数组是这样做的:

var arr = [1, 2, 3, 4];
for (var i = 0; i < arr.length; i++) {
  console.log(arr[i]);
}

现在,它是这样做的:

[1, 2, 3, 4].forEach(num => console.log(num));

一个较旧的浏览器可以理解第一个,但不能理解第二个,但代码的功能是一样的。所以,我们只需要将第二个代码片段转换成第一个。这就是 Babel 的作用。Babel是 JavaScript 的转译工具;把它想象成一个翻译器。我们把我们美丽的 ES6 代码给它,它把它转换成更丑陋但更适合浏览器的 ES5 代码。

我们将把 Babel 插入到我们的 Webpack 构建过程中,这样当我们捆绑所有的 JavaScript 文件时,我们也会对它们进行转译。

要开始,我们将安装 Babel,以及一堆插件和附加组件,使其能够与 React 很好地配合。停止你的开发服务器,然后运行以下命令:

yarn add babel-core babel-loader babel-preset-es2015 babel-preset-react babel-plugin-transform-class-properties

天啊,一次性安装了这么多的包!下一步中重要的是babel-loader。这是一个 Webpack 加载器,我们用它来获取(然后转译)我们的 JavaScript 文件,然后将它们传递给 Webpack 进行捆绑。让我们把它插入到 Webpack 中。

在我们的webpack.config.js中,创建一个带有加载器数组的模块对象:

然后,我们可以在数组内定义我们的加载器。

我们将创建一个具有四个键的对象:test、exclude、loader 和 query:

  • Test是加载器用来确定它应该转译哪些文件的内容。对于 Babel,我们希望运行所有的 JavaScript 文件,所以我们的测试将是以.js结尾的文件:
test: /\.js$/
  • Exclude是不需要运行的内容。我们可以跳过整个node_modules文件夹,因为这些包已经是 ES5 了:
exclude: /node_modules/
  • Loader就是我们的加载器的名字:
loader: ‘babel-loader’
  • 最后,我们将使用query来定义我们的预设(Babel 将用它来转译 JavaScript):
query: {
  presets: ['es2015','react'],
  plugins: ['transform-class-properties']
}

完整的文件应该是这样的:

module.exports = {
  entry: __dirname + "/src/index.js",
  output: {
   path: __dirname + "/public",
   filename: "bundle.js",
   publicPath: "/"
  },
  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        query: {
          presets: ['es2015','react'],
          plugins: ['transform-class-properties']
        }
      },
    ]
  },
  devServer: {
    contentBase: "./public",
    historyApiFallback: true,
    inline: true,
  }
};

运行yarn start并查找错误。如果没有错误,我们可以进行测试并编写一些 ES6 代码。

我们的第一个 ES6

让我们打开我们的src/index.js并看看我们如何让它更有趣。

首先,我们可以用新的import语法替换我们的require调用。它看起来像这样:

import React from ‘react’;
import ReactDOM from 'react-dom';

这样做会更清晰一些,并且让我们可以做一些很酷的东西,我们稍后会看到。

对于 React 和 ReactDOM 都要这样做,然后我们可以最终替换我们的React.createElement调用。

你可能会猜到,通过调用React.createElement来构建复杂的 UI 会非常笨拙。我们希望拥有 JavaScript 的功能和功能,但又具有 HTML 的可读性。

输入 JSX;JSX是一种类似 HTML 的语法,但实际上是 JavaScript。换句话说,它编译成React.createElement,就像我们的 ES6 JavaScript 会编译成 ES5 一样。

它也有一些陷阱,因为它不是真正的 HTML,但我们会解决的。最后要注意的是,JSX 让一些开发人员感到非常不舒服;他们说在 JavaScript 内部放置 HTML 看起来很奇怪。我个人不同意,但这是一个观点问题。无论你的审美立场如何,JSX 提供了很多便利,所以让我们试一试。

我们可以简单地将我们的代码行转换为这样:

ReactDOM.render(<h1>Hello from ES6!</h1>, document.getElementById('root'));

运行yarn start(或者,如果已经运行,它应该会自动刷新)。如果 Babel 工作正常,什么都不应该改变。我们的第一个 JSX 完成了!

当然,我们将更多地使用 JSX,看看它与 HTML 的区别,以及作为开发人员它为我们提供了什么优势。但是,现在让我们让我们的生活更加轻松。

拆分我们的应用程序

为了更好地组织我们的应用程序(并在下一节中进行一些魔术),让我们将我们的 JSX 从ReactDOM.render中移到一个单独的文件中。这将确保我们的文件结构具有良好的关注点分离。

src文件夹的index.js旁边,创建一个名为App.js的文件。在里面,我们只需创建一个名为App的函数,它返回我们的 JSX:

import React from 'react';

const App = () => {
  return <h1>Hello from React!!</h1>
};

export default App;

请注意底部的export语句;这意味着当我们导入我们的文件时,我们将自动获得此函数作为默认导入。我们将在后面看到非默认导入的示例,这将使这一点更清晰。

如果我们回到index.js,现在可以从'./App'导入App。然后,我们渲染它,如下所示:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App'

ReactDOM.render(<App />, document.getElementById('root'));

请注意,我们使用它就像 HTML(或者说 JSX)标签一样。我们将在接下来的章节中更多地讨论原因;现在,重要的是我们的应用程序更有组织性,我们的视图逻辑(JSX)与渲染逻辑(ReactDOM.render)分开。

热重载

我们已经为我们的开发过程取得了一些重大的胜利。在我们深入了解 Webpack 配置之前,我想再添加一个便利。

想象一个应用程序,它包括一个表单,当用户点击编辑按钮时,会弹出一个模态框。当你重新加载页面时,那个模态框会关闭。现在,想象一下你是开发人员,试图微调那个表单。你的开发服务器在每次微调后重新加载页面,迫使你重新打开模态框。在这种情况下,这可能有点烦人,但想象一下像浏览器游戏这样的东西,要回到之前的状态需要点击好几次。

简而言之,我们需要一种方法在保留应用程序当前状态的同时重新加载我们的 JavaScript,而不重新加载页面本身;这被称为热重载。我们使用 Webpack 来替换已更改的 UI 部分,而不重新加载所有内容。

为了这样做,我们将使用Dan Abramovreact-hot-loader包。让我们安装它并看看我们将如何配置 Webpack 以使其与之良好地配合。

要安装,输入yarn add react-hot-loader@3.0.0。在撰写本文时,版本 3 仍处于测试阶段;如果 yarn 提示您选择 3.0 的测试版本,请选择最新版本(对我来说,我选择了 beta.7):

yarn add react-hot-loader@3.0.0

为了使它工作,我们需要做四件事:

  1. 启用 Webpack 自己的热模块替换插件。

  2. 将 React Hot Loader 用作我们应用程序的入口点,以便 Webpack 查找源文件。

  3. 将 React Hot Loader 连接到 Babel。

  4. 在我们的开发服务器上启用热重载。

安装 Webpack 的HMR插件实际上非常容易。在我们的webpack.config.js中,首先在文件顶部要求 Webpack,以便我们可以访问该包:

var webpack = require('webpack');

我们的 Webpack 文件不会被 Babel 处理,所以我们仍然会使用require而不是import

然后,在我们的devServer键上面,添加一个名为plugins的新键,其值为一个数组,其中包括new webpack.HotModuleReplacementPlugin()作为唯一的项:

module: {
  loaders: [
    {
      test: /\.js$/,
      exclude: /node_modules/,
      loader: 'babel-loader',
      query: {
        presets: ['es2015','react'],
        plugins: ['transform-class-properties']
      }
    },
  ]
},
plugins: [
 new webpack.HotModuleReplacementPlugin()
],
devServer: {
  contentBase: "./public",
  historyApiFallback: true,
  inline: true,
}

重新启动服务器以检查错误,然后继续进行第二步。

现在,我们的index.js是 Webpack 的入口点;它执行该文件中的代码,并从该执行中使用的文件的捆绑文件中派生。我们想要首先执行react-hot-loader包。让我们修改我们的入口键如下:

entry: [
  'react-hot-loader/patch',
  __dirname + "/src/index.js"
 ],

为了使它与我们的开发服务器配合使用,我们需要添加一些代码:

entry: [
   'react-hot-loader/patch',
   'webpack-dev-server/client?http://localhost:8080',
   'webpack/hot/only-dev-server',
   __dirname + "/src/index.js"
 ],

这个配置意味着 Webpack 会在移动到我们的代码之前执行这些路径中的代码。

再次尝试重新启动服务器。如果有错误,请检查拼写错误;否则,继续!

接下来,我们想要添加一个 Babel 插件,以便我们的热重新加载文件使用babel-loader进行编译。只需更新我们的 Babel 配置,如下所示,使用react-hot-loader中包含的 Babel 插件:

loaders: [
  {
    test: /\.js$/,
    exclude: /node_modules/,
    loader: 'babel-loader',
    query: {
      presets: ['es2015','react'],
      plugins: ['react-hot-loader/babel', 'transform-class-properties']
    }
  },
]

我们还需要在我们的开发服务器中打开热重新加载;通过在我们的devServer配置中添加hot: true来实现:

devServer: {
  contentBase: "./public",
  historyApiFallback: true,
  inline: true,
  hot: true
},

作为最后一步,我们需要在我们的index.js中添加一些代码。在文件底部添加以下内容:

if (module.hot) {
  module.hot.accept('./App', () => {
    const NextApp = require('./App').default;
    ReactDOM.render(
     <App/>,
     document.getElementById('root')
    );
  });
}

上述代码基本上在文件更改时向ReactDOM.render发送我们应用程序的新版本。

好的,让我们试一试。重新启动服务器,然后打开localhost:8080。尝试编辑文本Hello from React!,看看 HTML 在不重新加载页面的情况下更新;很棒。

热模块替换将使我们的生活变得更加轻松,特别是当我们开始用不同的状态构建我们的应用程序时--重新加载页面将重置状态。

为生产构建

到目前为止,我们完全专注于在开发环境中使用 Webpack,但我们还需要考虑将我们的应用程序部署到生产环境中,以及可能涉及的内容。

当我们将我们的应用程序发送到全球网络时,我们不想发送任何不必要的东西(记住我们的目标是性能);我们想要部署最少的内容。

这是我们需要的:

  • 一个index.html页面(经过压缩)

  • 一个 CSS 文件(经过压缩)

  • 一个 JavaScript 文件(经过压缩)

  • 所有图像资产

  • 一个资产清单(上述静态文件的列表)

我们有一些这样的文件,但不是全部。让我们使用 Webpack 自动生成一个带有所有这些文件的build文件夹,以便稍后部署。

首先,一个经过压缩的index.html。我们希望 Webpack 获取我们的public/index.html文件,对其进行压缩,自动添加适当的脚本和 CSS 链接,然后将其添加到build文件夹中。

由于我们的生产环境中的 Webpack 流程将与开发环境不同,让我们制作一个webpack.config.js的副本,并将其命名为webpack.config.prod.js。在本章的大部分时间里,我们将使用webpack.config.prod.js,而不是webpack.config.js

首先,从webpack.config.prod.js中删除devServer键。我们不会在生产中使用开发服务器,也不会使用热重新加载。我们需要删除entry下的两行devServer特定行,以及热重新加载行,使其看起来像这样:

entry: __dirname + "/src/index.js",

此外,在我们的webpack.config.prod.js中,让我们指定我们的输出文件夹现在是chatastrophe/build,通过更改输出下面的这行:

path: __dirname + "/public",

需要更改为这样:

path: __dirname + "/build",

我们还需要添加一个publicPath,这样我们build文件夹中的index.html就知道在同一个文件夹中查找捆绑的 JavaScript:

output: {
  path: __dirname + "/build",
  filename: "bundle.js",
  publicPath: './'
},

让我们将环境设置为生产环境,这样 React 就不会显示它的(在开发中很有帮助的)警告。我们还可以移除HotModuleReplacementPlugin

plugins: [
  new webpack.DefinePlugin({
    'process.env': {
      NODE_ENV: JSON.stringify('production')
    }
  }),
],

接下来,我们将使用一个新的 Webpack 插件,称为HtmlWebpackPlugin。它做起来就像它的名字一样--为我们打包 HTML!让我们使用yarn add html-webpack-plugin来安装它,然后使用以下选项添加它:

plugins: [
  new webpack.DefinePlugin({
    'process.env': {
      NODE_ENV: JSON.stringify('production')
    }
  }),
  new HtmlWebpackPlugin({
    inject: true,
    template: __dirname + "/public/index.html",
    minify: {
      removeComments: true,
      collapseWhitespace: true,
      removeRedundantAttributes: true,
      useShortDoctype: true,
      removeEmptyAttributes: true,
      removeStyleLinkTypeAttributes: true,
      keepClosingSlash: true,
      minifyJS: true,
      minifyCSS: true,
      minifyURLs: true,
    },
  }),
],

不要忘记在webpack.config.prod.js的顶部要求它,就像我们要求 Webpack 一样:

var HtmlWebpackPlugin = require('html-webpack-plugin');

是时候来测试一下了!在你的package.json中,更新我们的构建脚本以使用我们的新配置,如下所示:

"build": "node_modules/.bin/webpack --config webpack.config.prod.js",

然后运行yarn build

您应该在项目目录中看到一个build文件夹出现。如果您打开build/index.html,您会看到它被整合在一起。但是,有一个问题;在那个压缩的代码中,您应该看到两个脚本标签,都需要bundle.js

这是我们之前指定的HtmlWebpackPlugin选项的结果。插件为我们添加了脚本标签!多么方便,除了我们已经在public/index.html中自己添加了它。

这里有一个简单的解决方案--让我们将我们的HtmlWebpackPlugin配置(和 require 语句)复制到webpack.config.js(我们的原始配置文件)中。但是,我们可以删除minify键及其所有选项,因为在开发中这是不必要的:

// webpack.config.js
plugins: [
  new webpack.HotModuleReplacementPlugin(),
  new HtmlWebpackPlugin({
    inject: true,
    template: __dirname + '/public/index.html',
  })
],

然后,从public/index.html中删除脚本标签,然后再次尝试yarn start来测试我们的开发环境是否工作正常,以及yarn build来测试我们的生产构建。

好的,我们在我们的构建中有一个被压缩的 HTML 文件,并且我们也稍微改进了我们的开发启动过程。下一个任务是确保我们的 CSS 也被压缩并复制到我们的构建文件夹中。

在我们的 webpack 配置中(生产和开发环境都是),我们使用babel-loader来加载我们的 JavaScript 文件;我们将类似的方法用于 CSS。

为此,我们将结合两个加载器:css-loaderstyle-loader

您可以在github.com/webpack-contrib/style-loader的 style-loader GitHub 页面上阅读更多关于为什么建议同时使用两者的信息。

使用以下命令安装两者:

 yarn add css-loader style-loader

让我们将它们添加到我们的webpack.config.prod.jswebpack.config.js中,通过在我们的babel-loader配置下添加以下代码:

这些插件的作用是将我们的 React 代码所需的 CSS 文件转换为注入到我们的 HTML 中的<style>标签。现在,这对我们来说没有太大作用,因为我们的 CSS 目前位于我们的public/assets文件夹中。让我们将它移到src中,然后在App.js中引入它:

import React from 'react';
import './app.css';

const App = () => {
  return <h1>Hello from React!!</h1>
};

export default App;

然后,我们可以从我们的public/index.html中删除我们的链接标签,并重新启动我们的服务器。

如果我们在浏览器中检查我们的 HTML 的头部,我们应该会看到一个包含所有 CSS 的<style>标签。很整洁!:

现在,当我们刷新页面时,你可能会注意到有一些未经样式化的内容闪烁;这是因为我们的应用现在需要 React 在添加样式之前启动。我们将在接下来的章节中解决这个问题,放心。

运行yarn build,看一下bundle.js。如果你搜索"Start initial styles",你会看到我们的 CSS 是如何捆绑在我们的 JavaScript 中的。另外,请注意我们的 JavaScript 相对于我们的 HTML 来说是相对可读的。下一步是对其进行缩小处理!

幸运的是,这样做非常容易。我们只需要在我们的production文件中添加另一个 Webpack 插件。在HtmlWebpackPlugin之后,添加以下内容:

plugins: [
  new HtmlWebpackPlugin({
    inject: true,
    template: __dirname + '/public/index.html',
    minify: {
      removeComments: true,
      collapseWhitespace: true,
      removeRedundantAttributes: true,
      useShortDoctype: true,
      removeEmptyAttributes: true,
      removeStyleLinkTypeAttributes: true,
      keepClosingSlash: true,
      minifyJS: true,
      minifyCSS: true,
      minifyURLs: true
    }
  }),
  new webpack.optimize.UglifyJsPlugin({
    compress: {
      warnings: false,
      reduce_vars: false
    },
    output: {
      comments: false
    },
    sourceMap: true
  })
]

再次运行yarn build,你会看到我们的bundle.js已经变成了一行。这对人类来说不太好,但对浏览器来说更快。

好的,我们离结束越来越近了。接下来,我们要确保所有的资产文件都被复制到我们的build文件夹中。

我们可以通过向我们的 Webpack 配置添加另一个加载器来实现,称为file-loader。我们将使用yarn add file-loader@0.11.2来安装它。让我们看看代码是什么样子的(请注意,这仅适用于我们的webpack.config.prod.js文件):

module: {
  loaders: [
    {
      test: /\.js$/,
      exclude: /node_modules/,
      loader: 'babel-loader',
      query: {
        presets: ['es2015', 'react'],
        plugins: ['react-hot-loader/babel', 'transform-class-properties']
      }
    },
    {
      test: /\.css$/,
      use: [{ loader: 'style-loader' }, { loader: 'css-loader' }]
    },
 {
 exclude: [/\.html$/, /\.(js|jsx)$/, /\.css$/, /\.json$/],
 loader: 'file-loader',
 options: {
 name: 'static/media/[name].[ext]'
 }
 }</strong>
  ]
},

请注意,我们排除了 HTML、CSS、JSON 和 JS 文件。这些都已经被我们的其他加载器覆盖了,所以我们不想重复文件。

我们还将这些资产放在一个static文件夹中,就像我们的public文件夹中的assets文件夹一样。

然而,file-loader只会应用于我们的 JavaScript 代码所需的文件。我们有我们的 favicon 和图标,目前只在我们的index.html中使用,所以 Webpack 找不到它们。

为了做到这一点,我们将使用 JavaScript 而不是 Webpack(因为 Webpack 只关注我们的src文件夹)。

创建一个自定义脚本

在你的目录根目录下新建一个名为scripts的文件夹。在里面,创建一个名为copy_assets.js的文件。

在这里,我们将把public中的所有内容复制到build中,但不包括我们的index.html

为了做到这一点(你猜对了),我们需要另一个包;运行 yarn add fs-extra

然后,在 copy_assets.js 中引入它,如下所示:

var fs = require('fs-extra');

fs-extra 是一个用于在 Node 环境中操作文件的包。它有一个叫做 copySync 的方法,我们将在这里使用它。

代码相当简单明了:

fs.copySync('public', 'build', {
 dereference: true,
 filter: file => file !== 'public/index.html'
});

这意味着复制 public 文件夹中的所有内容到 build 文件夹,除了 index.html 文件。

如果你在之前的 Webpack 配置中的 public 文件夹中有一个 bundle.js,现在可以删除它了。

现在,要在构建时运行此命令,请将其添加到 package.json 中的构建脚本中:

 "scripts": {
   "build": "node scripts/copy_assets.js && node_modules/.bin/webpack --config 
    webpack.config.prod.js",
   "start": "node_modules/.bin/webpack-dev-server"
 },

copy_assets 命令放在我们的 Webpack 命令之前是个好主意,这样可以确保我们不会在 public 中意外复制任何未经转译的 JavaScript 资源。

创建一个资产清单

作为最后一步,我们想要一个我们正在生成的所有静态资产的清单。一旦我们开始缓存它们以节省加载时间,这将会很有用。幸运的是,这是一个简单的步骤,另一个插件!

yarn add webpack-manifest-plugin 并将其添加到 webpack.config.prod.js 中的插件下,使用以下配置:

var ManifestPlugin = require('webpack-manifest-plugin');
// Then, under plugins: new ManifestPlugin({
  fileName: 'asset-manifest.json',
}),

好的,让我们一起试试。运行 yarn build,然后在浏览器中打开 index.html。它应该看起来和运行 yarn start 一样。你还应该在我们的 build 文件夹中看到一个 index.html,一个 bundle.js,一个 asset-manifest.json,和一个 assets 文件夹。

总结

哇!那是很多的配置。好消息是现在我们已经完全准备好开始编写 React 并构建我们的应用程序了。这就是我们接下来要做的!

在本章中,我们涵盖了与 Webpack 相关的一切,添加了一堆方便的功能来加快我们的开发速度。在下一章中,我们将开始开发过程,并开始构建我们的 React 应用程序。这就是乐趣开始的地方!

第三章:我们的应用程序的登录页面

在过去的几章中,我们已经完全准备好了使用 React 进行开发。现在,让我们全力以赴地构建我们的应用程序。

在本章中,我们将在 React 中创建我们应用程序的登录页面。最后,你应该对基本的 React 语法感到舒适。

我们将涵盖以下关键的 React 概念:

  • 将 UI 分成组件

  • 编写 JSX

  • 函数组件与类组件

  • 组件状态

  • 创建可重用的组件

什么是 React 组件?

React 组件,在最基本的层面上,是用户界面的一部分,更具体地说,是专门用于单一目的的 UI 部分。

在 React 中,你的 UI 被分成了各个部分,这些部分又包含在其他部分中,依此类推;你明白了吧。每个部分都是自己的组件,并且存在于单独的文件中。

这个系统的美妙之处现在可能并不明显,但一旦我们深入了解,你会发现它使我们的应用程序更易理解,也就是说,在开发过程中更容易理解和导航。我们只会构建一个包含几个组件的小应用程序。当你的应用程序增长到数百个组件时,效果会更加明显。

让我们来看一个将 UI 拆分成组件的快速示例。这是 Packt 的在线商店,也是这本书的出版商:

如果我们要在 React 中重建这个 UI,我们将首先将 UI 分成有意义的部分。哪些部分涉及不同的目的?

请注意,这个问题并没有一个正确的答案;不同的开发人员会有不同的做法,但是以下的划分对我来说是有意义的:将其分成FilterControlSearchBarResultsGrid

我的想法是——FilterControl(在顶部)与排序和分页有关,SearchSideBar 是搜索特定结果的功能,ResultsGrid 则是显示匹配结果的功能。每个组件都有非常具体和明确的目的。

然后,在这三个组件中,我们可以进行更小的划分。ResultsGrid 中的每本书可以是一个BookCard组件,其中包含BookInfoBookImage组件,依此类推。

我们想要将这些划分做得多细致,取决于我们自己。一般来说,更多数量的小组件更好,但是随着组件数量的增加,我们需要编写更多的样板代码。

React 组件化的另一个优势是可重用性。假设在我们的ResultsGrid中,我们为每个结果制作一个BookCard组件。然后,在 Packt 主页上,我们可以重用相同的组件!不再在两个地方重复编写相同的代码:

代码的可重用性也是为什么较小的组件更好。如果您构建组件以最大化可重用性(以适应最多的上下文),您可以利用现有部分构建新功能。这增加了开发速度和便利性。我们将构建一个可重用的组件作为我们登录表单的一部分,并在应用程序扩展时在其他地方使用它。

让我们跳转到我们的App.js文件,看看我们构建的第一个组件:

import React, { Component } from 'react';
import './app.css';

const App = () => {
  return <h1>Hello from React!!</h1>
};

export default App;

我们的App组件是一个返回一部分 JSX 的函数。就是这样。这是一种非常方便的思考 React 组件的方式,作为返回视图的一部分的函数。通过按照特定顺序调用某些函数,我们构建我们的 UI。

当然,情况会变得更加复杂。然而,如果你对 React 的语法和概念感到不知所措,请回到这个核心原则:React 组件只是返回 UI 的一部分的函数。

争议和关注点的分离

当 React 首次出现时,它非常具有争议性(对许多人来说,它仍然是)。许多开发人员关注的核心问题是 JSX,在 JavaScript 代码中间出现类似 HTML 的东西。

多年来,开发人员一直在不同的文件中编写他们的 HTML、CSS 和 JavaScript。React 违反了这一传统。一些开发人员指责该库违反了关注点分离SoC)的编程原则-代码应该分离到各自用于一件事的文件中。从这个意义上讲,他们认为你应该有一个 HTML 文件,一个 CSS 文件和一个 JavaScript 文件-不应该混合 HTML 和 JavaScript。

React 开发人员指出的是,根据类型(HTML 与 JavaScript)分离文件是一种技术上的分离,而不是关注点的分离。HTML 和 JavaScript 都关注于呈现功能性 UI-它们是一体的。

React 提出,如果你有一个按钮,按钮的 HTML 结构和使其功能(点击时发生的事情)应该存在于同一个文件中,因为这都是同一个关注点。

因此,记住 React 的重要事情是关注点的分离——你可以根据组件的目的划定它们之间的界限。

所有这一切的缺失部分当然是 CSS。它不应该在同一个文件中吗?很多人都这么认为,但是尚未出现成熟的解决方案。你可以在medium.freecodecamp.org/css-in-javascript-the-future-of-component-based-styling-70b161a79a32阅读更多关于 JS 中的 CSS。

类组件与函数组件

我们刚刚将 React 组件定义为返回 UI 片段的函数。这是一种有用的思考方式,对于我们的App组件来说当然是正确的。然而,还有另一种编写 React 组件的方式。

现在,我们的App组件是一个函数组件。这意味着它实际上是作为一个函数编写的,但你也可以将组件编写为 JavaScript 类。这些被称为基于类有状态组件(我们稍后会讨论有状态部分)。

JavaScript 类是 ES6 的一个新特性。它们以一种类似(但不完全相同)的方式工作于其他语言中的类。我们不会在这里深入探讨它们,但是对于我们的目的,你可以做到以下几点:

  • 让一个类扩展另一个类(并继承其属性)

  • 用 new 关键字创建一个类的实例(即实例化它)

让我们通过将我们的App组件转换为基于类的组件来看一个例子。

每个类组件必须做两件事:它必须扩展 React 库中的Component类,并且它必须有一个render方法。

让我们从 React 中导入Component类开始:

import React, { Component } from 'react';

对于那些对这种语法不熟悉的人来说,这是 ES6 中对象解构的一个例子。考虑以下内容:

const property = object.property;

对象解构将前面的代码转换为这样,这样可以节省一些输入,但是做的事情是一样的:

const { property } = object;

无论如何,既然我们已经导入了我们的Component类,让我们创建一个扩展它的类;删除我们的App函数,并编写以下内容:

class App extends Component {

}

JavaScript 类的功能很像对象。它们可以有属性,这些属性可以是值或函数(称为方法)。正如我们之前所说,我们需要一个render方法。下面是它的样子:

class App extends Component {
  render() {

  }
}

render方法做什么?实质上,当我们将我们的App作为一个函数组件编写时,它仅由一个render方法组成。整个东西只是一个大的render()。因此,render方法做了我们从 React 组件中期望的事情:它返回了一部分视图:

class App extends Component {
  render() {
    return <h1>Hello from React!!</h1>;
  }
}

如果你启动了应用程序(或者它已经在运行),你会注意到什么都没有改变。

那么,类组件和函数组件之间有什么区别呢?

一个最佳实践是尽可能在应用程序中创建尽可能多的小型功能组件。从性能上讲,它们会快一点,而且 React 团队已经表达了对优化函数组件的兴趣。它们也更容易理解。

然而,类组件给了我们很多方便的功能。它们可以有属性,然后我们在render方法中使用这些属性:

class App extends Component {
  greeting = 'Hello from React!!';

  render() {
    return <h1>{this.greeting}</h1>;
  }
}

我们可以从render方法中调用方法:

class App extends Component {
  logGreeting = () => {
    console.log('Hello!');
  }

  render() {
    this.logGreeting()
    return <h1>Hello from React!!</h1>;
  }
}

正如我们之前讨论的那样,类可以被实例化(在诸如const app = new App()的语法中)。这就是 React 在我们的ReactDOM.render调用中所做的;它实例化我们的App,然后调用render方法来获取 JSX。

因此,将 React 组件视为返回视图片段的函数仍然是有用的。类组件只是在render函数周围添加了一些额外的功能。

我们的第二个组件

我们已经制作了一个 React 组件;让我们再制作一个!

正如我们之前讨论的,本章的目标是创建我们应用程序的登录页面。首先,让我们在我们的src文件夹中创建一个名为components/的文件夹,然后在里面创建一个名为LoginContainer.js的文件。

如果你仍然有我们第二章中的文件夹,开始使用 Webpack,其中包括Component1.jsComponent2.jsComponent3.js,现在可以随意删除这些文件。

我们的LoginContainer将是另一个类组件,原因我们将在后面看。就像我们的应用程序一样,让我们设置一个基本的类组件框架:

import React, { Component } from 'react';

class LoginContainer extends Component {
  render() {

  }
}

export default LoginContainer;

让我们在深入研究之前测试一下渲染我们的组件。从我们的render方法中返回一个简单的<h1>Hello from LoginContainer</h1>;然后,让我们回到我们的App.js

我对代码组织有点挑剔,所以在继续之前,让我们将我们的App.js移动到我们的components文件夹中。这也意味着我们将不得不更改index.js中的导入语句如下:

import App from './components/App';

还有,将我们的app.css移到components文件夹中,然后在index.js中更改我们的热重载器配置:

if (module.hot) {
  module.hot.accept('./components/App', () => {
 const NextApp = require('./components/App').default;
    ReactDOM.render(
      <App/>,
      document.getElementById('root')
    );
  });
}

现在我们所有的组件都住在同一个文件夹里,这样好多了。

App.js中,我们首先导入LoginContainer

import LoginContainer from './LoginContainer';

然后,我们将其render而不是<h1>

import React, { Component } from 'react';
import LoginContainer from './LoginContainer';
import './app.css';

class App extends Component {
  render() {
    return <LoginContainer />
  }
}

export default App;

翻转回到应用程序,你应该看到我们新组件的 LoginContainer 的 Hello:

正如我们在构建更多组件时将看到的那样,我们的App将是我们主要Container组件的包装器。它将是我们容器的容器。在App.js中,让我们为了 CSS 的目的将我们的LoginContainer包装在一个div#container中:

class App extends Component {
  render() {
    return (
      <div id="container" className="inner-container">
        <LoginContainer />
      </div>
    );
  }
}

好了,回到LoginContainer.js,让我们写一些 JSX!

删除我们的<h1>标签,并用以下内容替换它:

class LoginContainer extends Component {
  render() {
    return (
      <div id="LoginContainer" className="inner-container">

      </div>
    );
  }
}

这是我非常喜欢的一种模式 - 大多数 React 组件都包裹在一个带有类名的div中;尽管这只是一种偏好(一种你必须遵循的偏好,因为我写了 CSS!)。

注意 JSX 周围的括号!这种格式使多行 JSX 更易读。

当然,我们登录表单的本质就是一个表单。这个表单将处理登录和注册。以下是基本的 JSX:

class LoginContainer extends Component {
   render() {
     return (
       <div id="LoginContainer" className="inner-container">
         <form>
           <p>Sign in or sign up by entering your email and password.</p>
           <input 
             type="text" 
             placeholder="Your email" />
           <input 
             type="password" 
             placeholder="Your password" />
           <button className="red light" type="submit">Login</button>
         </form>
       </div>
     )
  }
}

在前面的 JSX 中,你可能注意到我写了<button>className而不是 class。记住我说过 JSX 有一些注意事项吗?这就是其中之一:因为 class 是 JavaScript 中的一个受保护的关键字,我们不能使用它,所以我们使用className代替。你很快就会习惯的。

注意前面 JSX 中的IDclassName,否则你的 CSS 看起来就不会那么漂亮。

在我们的表单上面,我们将写一个带有我们的标志的基本标题:

<div id="LoginContainer" className="inner-container">
  <div id="Header">
    <img src="/assets/icon.png" alt="logo" />
    <h1>Chatastrophe</h1>
  </div>
  <form>

现在你的应用程序应该看起来像这样(如果你还没有这样做,请从index.html中删除<h1><img>标签):

看起来漂亮,但它能做什么呢?

React 中的状态

每个 React 组件都有一个叫做state的东西。你可以把它看作是组件在某个特定时间点的配置。

举个例子,当你点击它时变红的心形图标,就像 Twitter 的情况一样。按钮有两种状态:未点击已点击。点击按钮会导致它的状态,从而导致它的外观发生变化。

这就是 React 的流程;用户的操作或事件会导致组件状态的改变,从而导致组件的外观改变。

前面的陈述带有大量的“嗯,并不总是……”,但这是理解状态的一个有用的起点:

User event -> State change -> Appearance change

让我们给我们的LoginContainer添加一些state,然后从那里开始。

状态很容易定义;它是类的属性的对象。我们可以像这样定义它:

class LoginContainer extends Component {
  state = { text: ‘Hello from state!’ }

   render() {

我们总是在组件的顶部定义state

然后我们可以在render方法中访问我们的state

class LoginContainer extends Component {
  state = { text: ‘Hello from state!’ };

  render() {
    return (
      <div id="LoginContainer" className="inner-container">
        <div id="Header">
          <img src="/assets/icon.png" alt="logo" />
          <h1>{this.state.text}</h1>
        </div>

在前面的代码中,JSX 中的花括号表示我们正在插入一些 Javascript 代码。

这是我们初始化state的方式,但这个状态并不是很有用,因为没有改变它的机制。

我们需要做的是提供一种响应用户事件并根据它们修改状态的方法。

如果用户点击 Hello from state!时文本发生了变化会怎么样?

让我们给我们的h1标签添加一个onClick属性,如下所示:

<h1 onClick={this.handleClick}>{this.state.text}</h1>

它引用了我们类上的一个叫做handleClick的方法,我们可以定义如下:

class LoginContainer extends Component {
  state = { text: 'Hello from state!' };

  handleClick = () => {
    this.setState({ text: 'State changed!' });
  };

  render() {

handleClick中,我们想要改变我们的状态。我们可以通过 React 中的一个叫做this.setState的函数来实现这一点,我们将新的状态对象传递给它。

试一试!当你点击 Hello from state!时,它应该立即改变为新的文本。

那么,这是如何工作的呢?setState的作用是将传入的对象合并到当前状态中(如果状态中有多个属性,但只传入一个属性的对象给setState,它将只改变该属性,而不是覆盖其他属性)。然后,它再次调用render()方法,我们的组件在 DOM 中更新以反映新的状态。

如果这看起来令人困惑,不用担心,我们还有几个例子要讲解,所以你会对组件状态有更多的练习。

我们的LoginContainer将有两个状态,一个与每个<input>标签配对。我们将在状态中存储用户在电子邮件和密码字段中输入的内容,以便在他们提交表单时我们可以访问它们。

“等一下,斯科特,”你可能会说,“为什么我们不直接进入 DOM,当用户提交表单时抓取每个输入的值,用 jQuery 的方式呢?”

我们当然可以这样做,但这将打破 React 的流程,具体如下:

User edits input -> Update state -> Re-render input to reflect new value.

这样,我们的输入值就存储在状态中,视图与之保持同步,而不是将输入值存储为 DOM 元素的属性,并在需要时访问它。

这种方法的优势在这一点上可能并不明显,但它使我们的代码更加明确和可理解。

因此,在上述流程中,每当用户更改输入时,我们需要更新我们的状态。首先,让我们改变我们的状态初始化方式:

state = { email: '', password: '' };

然后,让我们删除handleClick并将handleEmailChangehandlePasswordChange方法添加到我们的组件中:

 handleEmailChange = (event) => {
   this.setState({ email: event.target.value });
 };

 handlePasswordChange = (event) => {
   this.setState({ password: event.target.value });
 };

上述方法接收一个事件(用户在字段中输入),从事件中获取值,然后将状态设置为该值。

再次注意,我们不必每次调用setState时都定义电子邮件和密码;它将合并到现有状态对象的更改,而不会覆盖其他值。

好的,现在是最后一步。让我们为我们的输入添加onChange属性,调用我们的 change 处理程序。另一个关键步骤是,我们的输入的value必须来源于状态。我们可以这样做:

<input
  type="text"
  onChange={this.handleEmailChange}
  value={this.state.email}
  placeholder="Your email"
/>
<input
  type="password"
  onChange={this.handlePasswordChange}
  value={this.state.password}
  placeholder="Your password"
/>

您可以将您的h1重置为<h1>Chatastrophe</h1>

如果一切顺利,您应该注意到您的输入功能没有任何变化(如果您的代码中有拼写错误,您将无法在其中一个字段中输入)。让我们通过为表单提交添加一个处理程序来确保它实际上是有效的:

<form onSubmit={this.handleSubmit}>

和我们的方法:

handleSubmit = (event) => {
  event.preventDefault();
  console.log(this.state);
};

当用户提交表单(点击按钮)时,上述方法将只为我们记录状态,并阻止表单实际提交。

尝试在两个字段中输入,然后单击提交。您应该看到一个带有state对象的控制台日志:

Object { email: "email@email.com", password: "asdfas" }

我们做到了!我们的第一个具有状态的 React 组件。

希望你已经对 React 数据流有了一定的了解。我们的应用程序具有状态(存储在不同的组件中),它会在事件(通常是用户发起的)的响应中更新,这会导致我们应用程序的部分根据新状态重新渲染:

Events -> State changes -> Re-render.

一旦你理解了这种简单的模式,就很容易追踪你的应用程序在任何时间点看起来的原因。

重用组件

在我们完成LoginContainer骨架之前,我想再做一个改变。

我们之前谈到过如何使 React 组件可重用,这样你就可以在应用程序的多个地方实现相同的代码。我们应该尽量将我们的 UI 拆分成尽可能多的小而可重用的部分,以节省时间,我在我们的LoginContainer中看到了一个很好的候选者。

LoginContainer不会是我们唯一的容器。在接下来的几章中,我们将创建具有不同内容的新页面,但我们希望它们具有相同的外观,并且我们希望 Chatastrophe 的标志和标题仍然在顶部的相同位置。

我建议我们制作一个新的Header组件,以备将来使用。

现在,我们将LoginContainer设置为类组件,因为我们需要使用状态和方法。另一方面,我们的页眉不会有任何状态或功能;它只是一个 UI 元素。最好的选择是将其设置为函数组件,因为我们可以。

类组件与函数组件的规则基本上是,尽可能将组件设置为函数组件,除非你需要状态或方法。

在我们的src/组件文件夹中,创建一个名为Header.js的新文件。然后,我们可以创建一个函数组件的框架。复制并粘贴LoginContainer中相关的div#Header,并将其添加为return语句:

import React from 'react';

const Header = () => {
  return (
    <div id="Header">
      <img src="/assets/icon.png" alt="logo" />
      <h1>Chatastrophe</h1>
    </div>
  );
};

export default Header;

现在,回到我们的LoginContainer,我们想要导入我们的页眉,如下所示:

import Header from './Header';

然后,我们可以用简单的<Header />标签替换div#Header

render() {
 return (
   <div id="LoginContainer" className="inner-container">
     <Header />
     <form onSubmit={this.handleSubmit}>

另一个 JSX 的陷阱是,所有的 JSX 标签都必须关闭。你不能只是使用<Header>

就是这样!制作一个小型、可重用的组件就是这么简单。我们的LoginContainer现在看起来更整洁了,而且我们节省了一些将来的打字时间。

我们的登录表单看起来很棒,但有一个问题。当你在 Chatastrophe 总部向团队进行演示时(尽管你是唯一的开发人员,但团队不知何故膨胀到了二十人),一名实习生举手发问:“它实际上是怎么工作的?”

总结

我们创建了我们的第一个有状态的 React 组件,一个登录表单。我们学习了关于 React 组件的所有知识,以及创建它们的最佳实践。然后我们构建了我们的登录表单,并介绍了如何处理表单的更改,更新我们的状态。

不幸的是,只记录电子邮件和密码的登录表单并不那么有用(或安全!)。我们的下一步将是设置应用程序的后端,以便用户实际上可以创建账户并登录。

第四章:使用 Firebase 轻松设置后端

我们的应用程序看起来很漂亮,但它并没有做太多事情。我们有一个登录表单,但用户实际上无法登录。

在本章中,我们将开始处理我们应用程序的后端。在我们的情况下,这意味着设置一个数据库来保存用户及其消息。在一个章节中,我们将涵盖让用户创建帐户和登录所需的一切。我们还将深入研究 React 和组件状态。我们将学到以下内容:

  • Firebase 是什么

  • 需要注意的问题和问题

  • 如何部署我们的应用程序

  • 用户认证(注册和登录)

  • React 生命周期方法

让我们开始吧!

Firebase 是什么?

构建渐进式 Web 应用程序在很大程度上是一个前端过程。PWA 对于它们如何从后端 API 获取数据并不太关心(除非它影响性能,当然)。我们希望保持我们应用程序的后端设置最小化;为此,我们转向 Firebase。

Firebase是 Google 设计的一个项目,旨在帮助开发人员构建应用程序,而不必担心后端基础设施。它采用免费模型,基于后端需要响应的请求数量以及您需要的存储量。对于我们的目的,它非常适合快速开发一个小型原型。当我们的应用扩展时,Chatastrophe 的执行委员会向我们保证,“金钱不是问题”。

Firebase 提供了什么?我们感兴趣的是数据库、托管解决方案和内置认证。除此之外,它还提供了一种称为Cloud Functions的东西,这是一些代码片段,会在特定事件的响应中自动运行。一旦我们为我们的应用程序添加推送通知,我们将使用 Cloud Functions。现在,我们想要在我们的登录表单中添加一些身份验证,以便用户可以注册并登录到 Chatastrophe。

如果您有 Google 帐户(例如通过 Google Plus 或 Gmail),您可以使用这些凭据登录 Firebase,或者创建一个新帐户;这就是我们开始所需要的一切。

Firebase 注意事项

Firebase 是一个有用的工具,但它确实有一些注意事项。

其中一个重要的卖点(尤其是对我们来说)是它的实时数据库。这意味着一个用户对数据的更改会自动推送给所有用户。我们不必检查是否已创建了新的聊天消息;应用程序的每个实例都将立即收到通知。

数据库还具有离线持久性,这意味着我们的用户甚至在离线时也可以阅读他们的消息(如果您记得的话,这满足了我们之前概述的用户故事之一)。Firebase 使用本地缓存来实现这一点。

那么,有什么缺点吗?Firebase 数据库是一个 NoSQL 数据库,具有特定的语法,对于更习惯于 SQL 数据库的开发人员可能会感到奇怪。该过程类似于 SQL 数据库(具有主要的CRUD操作--创建读取更新删除--适用于数据),但可能不太直观。

Firebase 的另一个要点是,它(在撰写本文时)并未针对像 React 这样构建的单页应用程序SPAs)进行优化。我们将不得不做一些变通方法,以使一切在我们的 React 应用程序中顺利运行。

尽管如此,Firebase 将节省我们大量时间,与设置我们自己的后端服务器/托管解决方案相比,这绝对是值得学习的。

设置

以下是我们如何开始使用 Firebase:

  1. 我们将转到 Firebase 控制台。

  2. 从那里,我们将创建一个项目。

  3. 我们将为我们可爱的小项目命名。

  4. 我们将获得将其集成到我们的应用程序中所需的代码。

  5. 我们将将该代码添加到index.html中。

  6. 我们将使 Firebase 作为全局变量可用。

如果您准备好开始,请这样做:

  1. 一旦您创建或登录到您的 Google 帐户,转到firebase.google.com/。在屏幕右上角,您应该看到一个名为 GO TO CONSOLE 的按钮:

  1. 从 Firebase 控制台,我们想要添加项目。点击图标:

  1. 对于项目名称,选择chatastrophe(全部小写),然后选择您的国家/地区。

  2. 一旦完成,Firebase 应该直接带您到项目页面。从那里,点击上面写着 Add Firebase to your web app 的链接:

  1. 复制并粘贴它给您的代码到public/index.html中,在闭合的</body>标签之前:
<body>
  <div id="root"></div> 
  <script src="https://www.gstatic.com/firebasejs/4.1.2/firebase.js"></script> 
  <script>  
    // Initialize Firebase  
    var config = {    
      apiKey: /* API KEY HERE */,    
      authDomain: "chatastrophe-77bac.firebaseapp.com",    
      databaseURL: "https://chatastrophe-77bac.firebaseio.com",    
      projectId: "chatastrophe-77bac",    
      storageBucket: "chatastrophe-77bac.appspot.com",    
      messagingSenderId: "85734589405"  
    };  
    firebase.initializeApp(config); 
  </script> 
</body>
  1. 最后,我们需要使我们的 Firebase 应用程序对我们的应用程序的其余部分可用。在脚本标签的底部,在firebase.initializeApp(config)行之前,添加以下内容:
window.firebase = firebase;

这段代码将我们的 Firebase 设置存储在window对象上,这样我们就可以在 JavaScript 的其余部分中访问它。

如果您没有使用源代码控制(例如 GitHub 或 Bitbucket),或者正在使用私有存储库来存储您的代码,您可以跳过到下一节。对于我们其他人,我们需要做一些工作,以确保我们不会向整个世界显示我们的config.apiKey(这是一种恶意使用的方法)。

隐藏我们的 API 密钥

我们需要将我们的 API 密钥和messagingSenderId移动到一个单独的文件中,然后确保该文件没有被检入 Git:

  1. 为此,在public/中创建一个名为secrets.js的文件。在该文件中,放入以下内容:
window.apiKey = "YOUR-API-KEY”
messagingSenderId = "YOUR-SENDER-ID"

同样,我们利用全局访问的 window 对象来存储密钥。对于那些对 JavaScript 新手来说,请注意滥用 window 对象并不是一个好的做法;只有在绝对必要时才使用它。

  1. 要在index.html中使用此密钥,我们可以在所有其他脚本标签之前添加以下内容:
<script src="/secrets.js"></script>
  1. 然后,在我们的 Firebase 初始化中:
 <script>  
   // Initialize Firebase
   var config = {
     apiKey: window.apiKey,
     // ...rest of config
     messagingSenderId: window.messagingSenderId
   };
  1. 作为最后一步,我们需要告诉 Git 忽略secrets.js文件。您可以通过修改我们项目基础中的.gitignore文件来实现这一点,添加以下行:
/public/secrets.js

搞定了!我们现在可以自由地提交和推送了。

部署 Firebase

正如我之前提到的,Firebase 自带了一个内置的部署解决方案。让我们在真实的网络上让我们的应用程序运行起来!以下是如何做到这一点:

  1. 为此,我们首先需要安装 Firebase 命令行工具:
npm install -g firebase-tools

不要忘记-g。这个标志会在您的机器上全局安装这些工具。

  1. 下一步是登录我们的 Firebase 工具:
firebase login
  1. 为了完成我们的 Firebase 工具设置,我们现在可以将我们的应用初始化为一个 Firebase 项目,类似于我们使用npm所做的。确保您从项目文件夹的根目录运行此命令:
firebase init

在它随后提示您的第一个问题中,使用箭头键和空格键来选择 Functions 和 Hosting。我们稍后将使用 Firebase 的 Cloud Functions。不要选择 Database,那是用于在本地配置数据库规则的;我们将依赖于 Firebase 控制台。

您的选择应该如下所示:

当它要求默认的 Firebase 项目时,请选择chatastrophe(或者您在 Firebase 控制台中命名的项目)。

对于问题“您是否要立即使用 npm 安装依赖项?”,输入 y。

接下来,它会问你要使用哪个文件夹作为你的公共目录。输入build,而不是public。Firebase 正在询问要使用哪个文件夹来部署你的项目;我们想要我们最终编译的构建,包括我们转译的 JavaScript,因此,我们想要build文件夹。

现在让我们转到下一个问题!我们想将我们的应用程序配置为单页面应用程序吗?当然。尽管拒绝覆盖index.html(但是,如果你说是,也没关系;每次运行build命令时,我们都会重新生成我们的build/index.html)。

好的,我们已经准备好部署了。让我们创建一个npm脚本,让我们的生活更轻松。

每次部署,我们都希望重新运行我们的build命令,以确保我们拥有项目的最新构建。因此,我们的npm脚本将结合这两者,添加到我们的package.json中:

"scripts": {
  "build": "node scripts/copy_assets.js && node_modules/.bin/webpack --config webpack.config.prod.js",
  "start": "node_modules/.bin/webpack-dev-server",
  "deploy": "npm run build && firebase deploy"
},

使用yarn deploy运行脚本,然后在终端中检查它显示的 URL。如果一切顺利,你的应用程序应该看起来和在开发中一样。打开控制台并检查警告;如果看到任何警告,浏览一下 Webpack 章节,看看是否错过了我们webpack.config.prod.js的一些设置(你可以在这里的最终文件中查看:github.com/scottdomes/chatastrophe/tree/chapter4):

太棒了!我们有一个部署好的应用程序可以与朋友分享。唯一的问题是我们在上一章讨论的问题;它实际上还没有做太多事情。

让我们开始使用 Firebase 添加身份验证流程。

使用 Firebase 进行身份验证

为了让用户能够登录/注册我们的应用程序,我们需要做三件事:

  1. 在 Firebase 控制台上打开电子邮件验证。

  2. 当用户点击按钮时,将电子邮件和密码提交到 Firebase 中。

  3. 根据结果注册或登录用户。

让我们打开我们的 Firebase 控制台(console.firebase.google.com)并开始处理任务#1:

  1. 从我们的 Chatastrophe 项目页面,点击身份验证。

  2. 在“登录方法”选项卡下,您可以看到 Firebase 提供的所有选项。这些身份验证解决方案对开发人员来说是巨大的福音,因为配置身份验证可能会很棘手(特别是在使用第三方 API 时,如 Twitter 或 Facebook)。提供适当的安全性需要创建大量基础设施。Firebase 为我们处理了这一切,所以我们只需要担心如何利用他们的系统。

  3. 点击电子邮件/密码,然后点击启用和保存。我们的应用现在可以使用电子邮件和密码组合进行注册和登录。如果您想稍后为我们的应用增添一些趣味性,可以尝试实现 Facebook 或 GitHub 登录。

返回应用程序,转到LoginContainer.js。目前,当用户提交我们的表单时,我们只是阻止默认提交并注销我们的状态:

handleSubmit = (event) => {
  event.preventDefault();
  console.log(this.state);
};

对于我们的流程,我们将合并注册和登录过程。首先,我们将检查电子邮件和密码字段是否已填写。如果是,我们将尝试登录用户,如果 Firebase 告诉我们该电子邮件对应的用户不存在,我们将自动创建用户并登录。

但是,如果用户存在并且我们收到密码错误的错误,我们将通过在我们的组件中实现更多状态来提醒用户。

这是计划:

handleSubmit = (event) => {
 event.preventDefault();
 // Step 1\. Check if user filled out fields
 // Step 2\. If yes, try to log them in.
 // Step 3\. If login fails, sign them up.
}

首先,检查字段是否已填写:

handleSubmit = (event) => {
  event.preventDefault();
  if (this.state.email && this.state.password) {
    // Try to log them in.
  } else {
    // Display an error reminding them to fill out fields.
  }
}

立即,我们需要一种方法向用户显示错误,告诉他们他们错过了一个字段。让我们向我们的状态添加一个错误字符串:

state = { email: '', password: '', error: ‘’ }

每次他们提交表单时,我们将将该错误重置为空字符串,但如果他们错过了一个字段,我们将显示以下文本:

handleSubmit = (event) => {
  event.preventDefault();
  this.setState({ error: '' });
  if (this.state.email && this.state.password) {
    // Try to log them in.
  } else {
    this.setState({ error: 'Please fill in both fields.' });
  }
}

最后,为了显示错误,我们将在按钮上方添加一个<p>标签,其中包含错误的className

  <input  
    type="password"  
    onChange={this.handlePasswordChange} 
    value={this.state.password} 
    placeholder="Your password" /> 
  <p className="error">{this.state.error}</p> 
  <button className="red light" type="submit">Login</button>

好的,尝试提交我们的表单,而不填写任何字段。您可以通过在本地运行应用程序(使用您的开发服务器)或重新部署更改来这样做。您应该会看到以下内容:

到目前为止看起来很不错。下一步是尝试登录用户。此时,我们的应用程序没有用户,因此 Firebase 应该返回一个错误。让我们使用我们的电子邮件和密码调用 Firebase,然后在控制台中记录结果。

我们想要使用的方法是firebase.auth().signInWithEmailAndPassword(email, password)。这个函数返回一个 JavaScript promise。对于熟悉 promise 的人,可以跳到下一节,但如果不确定的话,值得复习一下。

什么是 promise?

JavaScript 的问题在于它经常处理异步操作。这些是代码必须完成的步骤,它们不遵循时间上的线性流动。通常,代码一行一行地运行,但当我们需要调用一个需要随机秒数才能响应的 API 时会发生什么?我们不能停止我们的代码并等待,而且我们仍然有一些代码行需要在调用完成后执行,无论何时。

以前的解决方案是回调。如果我们以这种方式使用firebase.auth().signInWithEmailAndPassword,它会是这样的:

firebase.auth().signInWithEmailAndPassword(email, password, function() {
  // Do something when the sign in is complete.
});

我们会传递一个回调函数,当操作完成时调用它。这种方法很好用,但可能会导致一些丑陋的代码:具体来说,一些称为噩梦金字塔回调地狱的东西,其中嵌套的回调导致倾斜的代码:

firebase.auth().signInWithEmailAndPassword(email, password, function() {
  onLoginComplete(email, password, function() { 
    onLoginCompleteComplete('contrived example', function() {
      anotherFunction('an argument', function () {
        console.log('Help I'm in callback hell!');
      });
    });
  });
});

为了使处理异步函数更容易和更清晰,JavaScript 背后的人们实现了 promises。Promises有一个简单的语法:将一个函数传递给.then语句,当操作成功时调用它,将另一个函数传递给.catch语句,当操作失败时调用它:

firebase.auth().signInWithEmailAndPassword(email, password)
  .then(() => { // Do something on success })
  .catch(err => { // Do something on failure. })

现在,我们的代码很好读,我们知道操作完成时将运行哪些代码。

回到认证

由于我们期望返回一个错误(因为我们还没有使用任何电子邮件和密码组合进行注册),我们可以将我们的then语句留空,但在我们的catch语句中添加一个控制台日志:

handleSubmit = (event) => {
  event.preventDefault();
  this.setState({ error: '' });
  if (this.state.email && this.state.password) {
    firebase.auth().signInWithEmailAndPassword(this.state.email, this.state.password)
      .then(res => { console.log(res); })
      .catch(err => { console.log(err); })
  } else {
    this.setState({ error: 'Please fill in both fields.' });
  }
}

提交您的表单,您应该返回以下错误:

{code: "auth/user-not-found", message: "There is no user record corresponding to this identifier. The user may have been deleted."}

太好了!这正是我们想要的错误。这是我们在启动注册流程之前将检查的代码。现在,我们将假设所有其他错误都是由于密码不正确:

handleSubmit = (event) => {
  event.preventDefault();
  this.setState({ error: '' });
  if (this.state.email && this.state.password) {
    firebase.auth().signInWithEmailAndPassword(this.state.email, 
     this.state.password)
      .then(res => { console.log(res); })
      .catch(err => { 
        if (error.code === 'auth/user-not-found') { 
          // Sign up here.
        } else { 
          this.setState({ error: 'Error logging in.' }) ;
        }
      })
 } else {
   this.setState({ error: 'Please fill in both fields.' });
 }
}

代码清理

我们的handleSubmit函数变得有点长,难以跟踪。在继续之前,让我们重新组织一下。

我们将从初始的if语句之后的所有内容移到一个名为login()的单独函数中,以简化操作:

login() {
  firebase
    .auth()
    .signInWithEmailAndPassword(this.state.email, this.state.password)
    .then(res => {
      console.log(res);
    })
    .catch(err => {
      if (err.code === 'auth/user-not-found') {
        this.signup();
      } else {
        this.setState({ error: 'Error logging in.' });
      }
    });
}

然后,我们的handleSubmit变得更小:

handleSubmit = event => {
  event.preventDefault();
  this.setState({ error: '' });
  if (this.state.email && this.state.password) {
    this.login();
  } else {
    this.setState({ error: 'Please fill in both fields.' });
  }
};

现在阅读和跟踪起来更容易了。

注册

让我们开始注册流程。同样,这是一个相当简单的函数名--firebase.auth().createUserWithEmailAndPassword(email, password)。同样,它返回一个 promise。让我们添加thencatch,但现在将then作为控制台日志:

signup() {
  firebase
    .auth()
    .createUserWithEmailAndPassword(this.state.email, this.state.password)
    .then(res => {
      console.log(res);
    })
    .catch(error => {
      console.log(error);
      this.setState({ error: 'Error signing up.' });
    });
}

尝试登录我们的应用程序,你应该会在控制台看到一个复杂的用户对象。成功!我们创建了我们的第一个用户帐户。如果你尝试使用相同的帐户再次登录,你应该会在控制台看到相同的用户对象。

你可以尝试使用不同的电子邮件和密码组合再次尝试(对于我们的目的来说,它不必是真实的电子邮件),它应该可以顺利工作。

保存我们的用户

我们收到的firebase.auth().signInuser对象似乎将来会有用。可能会有很多次我们想要访问当前登录用户的电子邮件。让我们将其保存在我们的App组件的状态中,这样我们就可以将其传递给任何Container组件(一旦我们创建更多的容器)。

有两种可能的方法:我们可以通过 props 从LoginContainer将用户对象传递给App,并且App将一个handleLogin函数作为 prop 传递给LoginContainer,当用户登录时调用该函数并适当设置App的状态。

然而,Firebase 给了我们另一个选择。正如我们之前讨论的,Firebase 数据库是实时的,这意味着数据的更改会自动推送到前端。我们所需要做的就是设置适当的监听函数来等待这些更改并对其进行操作。

事件监听器

JavaScript 中的事件监听器基本上是这样工作的:我们定义一个事件和一个我们想要在该事件发生时运行的回调。因此,我们可以在代码中提前声明一个函数,然后在稍后触发它,只要指定的事件发生。

以下是监听浏览器窗口调整大小的示例:

window.addEventListener('resize', function() { // Do something about resize });

Firebase 为我们提供了一个名为firebase.auth().onAuthStateChanged的函数。这个函数以一个回调作为参数,然后用用户对象调用它;这对我们来说非常完美!

然而,挑战在于在我们的App组件中何时声明这个函数。我们希望它执行以下操作:

firebase.auth().onAuthStateChanged((user) => {
  // If there is a user, save it to state.
  // If there is no user, do nothing.
});

然而,这会导致一些限制:

  • 我们只想注册一次监听器,所以我们不能将其放在render方法中(因为 React 更新 DOM 时可能会多次调用)

  • 我们需要在注册监听器之前完全加载App组件,因为如果你尝试在不存在的组件上setState,React 会报错

换句话说,我们需要在特定时间声明onAuthStateChanged,也就是在App出现在屏幕上后尽快。

生命周期方法

幸运的是,在 React 中这样的情况很常见,所以库为我们提供了一个解决方案:一组名为生命周期方法的函数。这些方法是所有(基于类的)React 组件的标准功能,并在组件出现、更新和消失时的特定时间点被调用。

React 组件的生命周期如下:

  • 应用程序已启动,组件的render方法即将被调用

  • 组件已呈现并出现在屏幕上

  • 组件即将接收新的 props

  • 组件已收到新的 props,并将再次调用 render 以响应更新

  • 组件已根据新的 props 或状态更改进行了更新

  • 组件即将从屏幕上消失

请注意,并非所有这些方法都会在每个组件中发生,但它们在 UI 更新和更改时都很常见。

相应的生命周期方法如下:

  • componentWillMount

  • componentDidMount

  • componentWillReceiveProps

  • componentWillUpdate

  • componentDidUpdate

  • componentWillUnmount

根据上述描述,花点时间想一想我们想要使用哪个生命周期方法来注册我们的onAuthStateChanged

再次强调,我们要找的时间点是在组件首次呈现后。这使得componentDidMount成为完美的选择;让我们将其添加到我们的App组件中。我们还需要用user键初始化我们的状态,稍后我们将使用它:

class App extends Component {
 state = { user: null };

 componentDidMount() {

 }

  render() {
    return (
      <div id="container">
        <LoginContainer />
      </div>
    );
  }
}

如果您对生命周期方法不清楚,请尝试在您的应用程序中添加所有六个生命周期方法,并在每个方法中使用控制台日志(以及在render方法中使用console.log),观察您的 React 组件的生命周期。

好的,接下来我们可以添加onAuthStateChanged

componentDidMount() { 
  firebase.auth().onAuthStateChanged((user) => {      
    if (user) {        
      this.setState({ user });      
    }    
  }); 
}

this.setState({ user })感到困惑吗?这被称为ES6属性简写。基本上,当你将一个键分配给一个变量,并且键和变量应该有相同的名称时,你可以节省时间,而不是输入this.setState({ user: user })

注意if语句。onAuthStateChanged也在用户登出时被调用,此时用户参数将为 null。我们可以将this.state.user设置为 null,但让我们保持简单,让用户在状态中持续,直到下一个用户出现。

Firebase 身份验证的另一个好处是它为我们处理了持久登录。这意味着用户不必每次进入我们的应用程序时都要登录;Firebase 会自动加载他们的登录状态,直到他们点击登出(这是我们将来会添加的)。根据这一点,onAuthStateChanged将在用户访问我们的应用程序时每次被调用,无论他们是物理登录还是已经登录。因此,如果用户已登录,我们可以依赖于我们的用户对象始终保存在状态中。

你可以在onAuthStateChanged的回调中使用firebase.auth().signOut();来尝试登出用户。尝试重新登录,然后刷新页面;无论您刷新多少次,您都应该看到用户对象出现,因为您已自动登录。

总结

身份验证就是这样!现在,我们的用户可以登录我们的应用程序。下一步是在他们登录后给他们一些事情要做。为此,我们需要更多页面,这将引出我们的下一个主题:使用 React 进行路由。我们如何在 React 组件之间导航?我们如何根据 URL 更改应用程序的内容?所有这些等等都即将到来!

第五章:使用 React 进行路由

“我们已经扩展了功能列表。”

你忍住一声叹息,等待。

“我们想给我们的用户一切。他们需要的一切,他们想要的一切,他们可能永远想要的一切。”

“好吧,”你说。“但这只是一个原型…”

“一个用于分析的页面,一个用于他们的个人资料,一个用于他们朋友的分析,一个用于做笔记,一个用于天气。”

你悄悄地走出去,低声重复着,“这只是一个原型。”

计划

我们现在已经到达了技术上工作的应用程序的点(允许用户登录),但缺乏真正有用的内容。是时候改变了。

然而,为了这样做,我们需要向我们的应用程序添加额外的页面。你们中的一些人可能听说过单页应用程序SPA)这个术语,它用来指代 React 应用程序,因此可能会对更多页面的讨论感到困惑。随着我们进一步深入,我们将涵盖这个区别,然后进入使用 React Router 进行实际路由设置。

我们将学到什么:

  • 如何安装和使用 React Router v4

  • 如何为其他组件添加额外的路由

  • 如何在路由之间移动

页面上的页面

幸运的是,理智的头脑占上风,产品主设计师(公司目前雇佣的五名设计师中排名最高的)表示他们只需要原型的三个视图:登录视图(已完成!)、主要聊天视图和用户个人资料视图。

然而,显然我们需要一种强大且可扩展的方法来在我们的应用程序中在不同的屏幕之间切换。我们需要一个良好而坚实的路由解决方案。

传统上,路由一直是关于提供哪些 HTML/CSS/JavaScript 文件的问题。你在static-site.com上输入 URL,得到主index.html,然后转到static-site.com/resources并得到resources.html

在这个模型中,服务器收到对特定 URL 的请求并返回相应的文件。

然而,越来越多的情况下,路由正在转移到客户端。在 React 世界中,我们只提供我们的index.htmlbundle.js。我们的 JavaScript 从浏览器中获取 URL,然后决定渲染什么 JSX。

因此有了单页应用程序这个术语--从传统模型来看,我们的用户技术上只坐在一个页面上。然而,他们能够在其他视图之间导航,并且以更加流畅的方式进行,而无需从服务器请求更多文件。

我们的顶层容器组件(App.js)将始终被渲染,但变化的是其内部渲染的内容。

React 路由的不同之处

对于一些 React 路由解决方案,模型看起来可能是这样的。

我们将渲染我们的初始屏幕,如下所示:

<App>
  <LoginContainer />
</App>

这将适用于chatastrophe.com/login的 URL。当用户完成登录后,我们将把他们发送到chatastrophe.com/chat。在那时,我们将使用以下方式调用ReactDOM.render

<App>
  <ChatContainer />
</App>

然后,React 的协调引擎将比较旧应用程序和新应用程序,并交换具有更改的组件;在这种情况下,它将LoginContainer替换为ChatContainer,而不重新渲染App

以下是一个非常简单的示例,使用了一个名为page.js的基本路由解决方案:

page(‘/’, () => {
  ReactDOM.render(
    <App>
      <ChatContainer />
    </App>.
    document.getElementById('root')
  );
});

page(‘/login’, () => {
 ReactDOM.render(
   <App>
    <LoginContainer />
   </App>.
   document.getElementById('root')
  );
});

这个解决方案运行良好。我们能够在多个视图之间导航,而 React 的协调确保没有不必要的重新渲染未更改的组件。

然而,这个解决方案并不是非常符合 React 的特点。每次我们改变页面时,我们都将整个应用程序传递给ReactDOM.render,这导致我们的router.js文件中有大量重复的代码。我们定义了多个版本的应用程序,而不是精确选择应该在何时渲染哪些组件。

换句话说,这个解决方案强调了路由的整体方法,而不是通过组件分割的方法。

输入React Router v4,这是该库的完全重写,它曾经是一个更传统的路由解决方案。不同之处在于现在路由是基于 URL 渲染的组件。

让我们通过重新编写我们之前的示例来详细讨论这意味着什么:

ReactDOM.render(
  <Router>
    <App>
      <Route path="/" component={ChatContainer} />
      <Route path="/login" component={LoginContainer} />
    </App>
  </Router>,
  document.getElementById('root')
);

现在,我们只调用一次ReactDOM.render。我们渲染我们的应用程序,并在其中渲染两个包裹我们两个容器的Route组件。

每个Route都有一个path属性。如果浏览器中的 URL 与该path匹配,Route将渲染其子组件(容器);否则,它将不渲染任何内容。

我们从不尝试重新渲染我们的App。它应该保持静态。此外,我们的路由解决方案不再与我们的组件分开存放在一个router.js文件中。现在,它存在于我们的组件内部。

我们还可以在组件内进一步嵌套我们的路由。在LoginContainer内部,我们可以添加两个路由--一个用于/login,一个用于/login/new--如果我们想要有单独的登录和注册视图。

在这个模型中,每个组件都可以根据当前的 URL 做出渲染的决定。

我会诚实,这种方法有点奇怪,需要时间适应,当我开始使用它时,我一点也不喜欢。对于有经验的开发人员来说,它需要以一种不同的方式思考你的路由,而不是作为一个自上而下的、整个页面决定要渲染什么的决定,现在鼓励你在组件级别做决定,这可能会很困难。

然而,经过一段时间的使用,我认为这种范式正是 React 路由所需要的,将为开发人员提供更多的灵活性。

好了,说了这么多。让我们创建我们的第二个视图--聊天界面--用户可以在这里查看并向全世界的人发送消息(你知道,“全球互联”)。首先,我们将创建一个基本组件,然后我们可以开始使用我们的路由解决方案。

我们的 ChatContainer

创建组件现在应该是老生常谈了。我们的ChatContainer将是一个基于类的组件,因为我们将需要在后面利用一些生命周期方法(稍后会详细介绍)。

在我们的components文件夹中,创建一个名为ChatContainer.js的文件。然后,设置我们的骨架:

import React, { Component } from 'react';

export default class ChatContainer extends Component {
  render() {
    return (

   );
  }
}

让我们继续包装我们的组件,使用组件名称作为divid

import React, { Component } from 'react';

export default class ChatContainer extends Component {
  render() {
    return (
      <div id="ChatContainer">
      </div>
    );
  }
}

就像在我们的LoginContainer顶部一样,我们希望渲染我们美丽的标志和标题供用户查看。如果我们有某种可重用的组件,这样我们就不必重写那段代码了:

import React, { Component } from 'react';
import Header from './Header';

export default class ChatContainer extends Component {
  render() {
    return (
      <div id="ChatContainer">
        <Header />
      </div>
    );
  }
}

这太美妙了。好吧,让我们在Header后面添加<h1>Hello from ChatContainer</h1>,然后继续进行路由,这样我们在工作时就可以实际看到我们在做什么。现在,我们的ChatContainer是不可见的。要改变这种情况,我们需要设置 React Router。

安装 React Router

让我们从基础知识开始。从项目根目录在终端中运行以下命令。

yarn add react-router-dom@4.2.2

react-router-dom包含了我们在应用程序中为用户进行路由所需的所有 React 组件。您可以在reacttraining.com/react-router上查看完整的文档。然而,我们感兴趣的唯一组件是RouteBrowserRouter

重要的是要确保您安装的是react-router-dom而不是react-router。自从发布了第 4 版以后,该软件包已被拆分为各种分支。React-router-dom专门用于提供路由组件,这正是我们感兴趣的。请注意,它安装了react-router作为对等依赖。

Route组件相当简单;它接受一个名为path的属性,这是一个字符串,比如//login。当浏览器中的 URL 与该字符串匹配(chatastrophe.com/login),Route组件渲染通过component属性传递的组件;否则,它不渲染任何内容。

与 Web 开发中的任何内容一样,您可以使用Route组件的方式有很多额外复杂性。我们稍后会更深入地探讨这个问题。但是,现在,我们只想根据我们的路径是/还是/login有条件地渲染ChatContainerLoginContainer

BrowserRouter更复杂,但对于我们的目的,使用起来会很简单。基本上,它确保我们的Route组件与 URL 保持同步(渲染或不渲染)。它使用 HTML5 历史 API 来实现这一点。

我们的 BrowserRouter

我们需要做的第一件事是将整个应用程序包装在BrowserRouter组件中,然后我们可以添加我们的Route组件。

由于我们希望在整个应用程序周围使用路由器,最容易添加它的地方是在我们的src/index.js中。在顶部,我们要求以下组件:

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import App from './components/App';

然后,我们将我们的App作为BrowserRouter的子级进行渲染:

ReactDOM.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById('root')
);

您还应该在我们的热重新加载器配置中执行相同的操作:

if (module.hot) {
  module.hot.accept('./components/App', () => {
    const NextApp = require('./components/App').default;
    ReactDOM.render(
      <BrowserRouter>
 <App />
 </BrowserRouter>,
      document.getElementById('root')
    );
  });
}

完成!现在我们实际上可以开始添加路由了。

我们的前两个路由

在我们的App组件中,我们目前无论如何都会渲染LoginContainer

render() {
  return (
    <div id="container">
      <LoginContainer />
    </div>
  );
}

我们希望改变这个逻辑,以便只渲染LoginContainer或者渲染ChatContainer。为了做到这一点,让我们在ChatContainer中要求它。

我们还需要从react-router-dom中要求我们的Route组件:

import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import LoginContainer from './LoginContainer';
import ChatContainer from './ChatContainer';
import './app.css';

我将Route导入放在了两个Container导入的上面。最佳实践是,你应该在相对导入(从src内导入的文件)之前放置绝对导入(从node_modules导入)。这样可以保持代码整洁。

现在,我们可以用接受component属性的Route组件替换我们的容器:

render() {
  return (
    <div id="container">
      <Route component={LoginContainer} />
      <Route component={ChatContainer} />
    </div>
  );
}

我们将我们的组件属性传递为LoginContainer,而不是<LoginContainer />

我们的应用程序重新加载,我们看到...一团糟:

我们目前同时渲染两个容器!糟糕。问题在于我们没有给我们的Route一个path属性,告诉它们何时渲染(以及何时不渲染)。让我们现在来做。

我们的第一个RouteLoginContainer,应该在/login路由时渲染,因此我们添加了如下路径:

<Route path="/login" component={LoginContainer} />

当用户在根路径/(当前在localhost:8080/,或者在我们部署的应用chatastrophe-77bac.firebaseapp.com/)时,我们的另一个容器ChatContainer将被显示,因此我们添加了如下路径:

<Route path="/" component={ChatContainer} />

保存,检查应用程序,你会得到以下结果:

好了!我们的LoginContainer不再渲染。让我们前往/login,确保我们只在那里看到我们的LoginContainer

哎呀!

我们在/login处同时渲染两个容器。发生了什么?

长话短说,React Router 使用RegEx模式来匹配路由并确定要渲染的内容。我们当前的路径(/login)匹配了传递给我们登录Route的属性,但它也在技术上匹配了/。实际上,一切都匹配/,这对于你想要在每个页面上渲染一个组件是很好的,但我们希望我们的ChatContainer只在路径为/(没有其他内容)时才渲染。

换句话说,我们希望在路径精确匹配/时渲染ChatContainer路由。

好消息是,React Router 已经为这个问题做好了准备;只需在我们的Route中添加一个exact属性:

<Route exact path="/" component={ChatContainer} />

前面的内容与写作如下相同:

<Route exact={true} path="/" component={ChatContainer} />

当我们检查/login时,我们应该只看到我们的LoginContainer。太棒了!我们有了我们的前两个路由。

接下来,我们想要做的是强制路由一点;当用户登录时,我们希望将他们重定向到主要的聊天界面。让我们来做吧!

登录后重定向

在这里,事情会变得有点棘手。首先,我们要做一些准备工作。

在我们的LoginContainer中,当涉及到我们的signuplogin方法时,我们目前只是在then语句中console.log出结果。换句话说,一旦用户登录,我们实际上什么也没做:

signup() {
  firebase.auth().createUserWithEmailAndPassword(this.state.email, this.state.password)
    .then(res => {
      console.log(res);
    }).catch(error => {
      console.log(error);
      this.setState({ error: 'Error signing up.' });
    })
}

让我们改变这一点(在signuplogin中),调用另一个方法onLogin

login() {
  firebase.auth().signInWithEmailAndPassword(this.state.email, this.state.password)
    .then(res => {
      this.onLogin();
    }).catch((error) => {
      if (error.code === 'auth/user-not-found') {
        this.signup();
      } else {
        this.setState({ error: 'Error logging in.' });
      }
    });
}

然后,我们可以定义我们的onLogin方法:

onLogin() {
  // redirect to '/'
}

那么,我们如何重定向到根路径?

我们知道我们的Route组件将根据浏览器中的 URL 进行渲染。我们可以确信,如果我们正确修改 URL,我们的应用程序将重新渲染以显示适当的组件。诀窍是从LoginContainer内部修改 URL。

正如我们之前提到的,React Router 使用 HTML5 历史 API 在 URL 之间移动。在这个模型中,有一个叫做history的对象,其中有一些方法,允许你将一个新的 URL 推入应用程序的当前状态。

所以,如果我们在/login,想要去/

history.pushState(null, null, ‘/’)

React Router 让我们以更简洁的方式与 HTML5 历史对象交互(例如避免空参数)。它的工作方式很简单:通过Route(通过component属性)传递给的每个组件都会接收到一个叫做history的 prop,其中包含一个叫做push的方法。

如果这听起来让人困惑,不用担心,一会儿就会清楚了。我们只需要这样做:

onLogin() {
  this.props.history.push(‘/’);
}

试着去/login并登录。你将被重定向到ChatContainer。神奇!

当调用push时,history prop 正在更新浏览器的 URL,然后导致我们的Route组件渲染它们的组件(或者不渲染):

History.push -> URL change -> Re-render

请注意,这是一个相当革命性的在网站中导航的方式。以前,它是完全不同的:

Click link/submit form -> URL change -> Download new page

欢迎来到单页面应用的路由世界。感觉不错,是吧?

登出

好的,我们已经处理了用户登录,但是当他们想要注销时怎么办?

让我们在ChatContainer的顶部建立一个按钮,让他们可以注销。它最适合在Header组件中,所以为什么不在那里建立呢?

等等。我们目前在LoginContainer/login路径上使用Header。如果我们添加一个Logout按钮,它也会出现在登录界面上,这会让人感到困惑。我们需要一种方法,只在ChatContainer上渲染Logout按钮。

我们可以利用Route history prop,并使用它来根据 URL 进行 Logout 按钮的条件渲染(如果路径是/,则渲染按钮,否则不渲染!)。然而,这可能会变得混乱,对于未来的开发人员来说很难理解,因为我们添加了更多的路由。让我们在想要 Logout 按钮出现时变得非常明确。

换句话说,我们想在Header内部渲染 Logout 按钮,但只有当HeaderChatContainer内部时才这样做。这有意义吗?

这样做的方法是使用 React children。从 HTML 的角度来看,Children 实际上非常容易理解:

<div>
  <h1>I am the child of div</h1>
</div>

h1div的子元素。在 React 组件的情况下,Parent组件将接收一个名为children的属性,它等于h1标签:

<Parent>
  <h1>I am the child of Parent</h1>
</Parent>

要在Parent中渲染它,我们只需要这样做:

<div id=”Parent”>
  {this.props.children}
</div>

让我们看看这在实际中是如何运作的,希望这样会更有意义(并给你一个它的强大的想法)。

ChatContainer中,让我们用一个开放和关闭的标签替换我们的<Header />标签:

<Header>
</Header>

在其中,我们将定义我们的按钮:

<Header>
  <button className="red">Logout</button>
</Header>

检查我们的页面,我们会发现没有任何变化。这是因为我们还没有告诉Header实际渲染它的children。让我们跳到Header.js并改变这一点。

在我们的h1下面,添加以下内容:

import React from 'react';

const Header = (props) => {
  return (
    <div id="Header">
      <img src="/assets/icon.png" alt="logo" />
      <h1>Chatastrophe</h1>
      {props.children}
    </div>
  );
};

export default Header;

我们在这里做什么?首先,我们将props定义为我们函数组件的参数:

const Header = (props) => {

所有功能性的 React 组件都将props对象作为它们的第一个参数。

然后,在该对象内,我们正在访问children属性,它等于我们的按钮。现在,我们的Logout按钮应该出现:

太棒了!如果你检查/login路径,你会注意到我们的按钮没有出现。那是因为在LoginContainer中,Header没有children,所以没有东西被渲染。

Children 使 React 组件非常可组合和可重用。

好的,让我们让我们的按钮真正起作用。我们想要调用一个名为firebase.auth().signOut的方法。让我们为我们的按钮创建一个调用这个函数的点击处理程序:

export default class ChatContainer extends Component {
  handleLogout = () => {
    firebase.auth().signOut();
  };

  render() {
    return (
      <div id="ChatContainer">
        <Header>
          <button className="red" onClick={this.handleLogout}>
            Logout
          </button>
        </Header>
        <h1>Hello from ChatContainer</h1>
      </div>
    );
  }
}

现在,当我们按下按钮时,什么也不会发生,但我们已经被登出了。我们缺少登录谜题的最后一块。

当我们的用户注销时,我们希望将他们重定向到登录界面。如果我们有某种方式来告诉 Firebase 授权的状态就好了:

这很完美。当我们点击注销按钮后,当我们的用户注销时,Firebase 将使用空参数调用firebase.auth().onAuthStateChanged

换句话说,我们已经拥有了我们需要的一切;我们只需要在我们的if语句中添加一个else来处理没有找到用户的情况。

流程将是这样的:

  1. 当用户点击注销按钮时,Firebase 将登出他们。

  2. 然后它将使用空参数调用onAuthStateChanged方法。

  3. 如果onAuthStateChanged被调用时用户为空,我们将使用history属性将用户重定向到登录页面。

让我们通过跳转到 App.js 来实现这一点。

我们的 App 不是 Route 的子组件,所以它无法访问我们在 LoginContainer 中使用的 history 属性,但是我们可以使用一个小技巧。

App.js 的顶部,添加以下内容到我们的 react-router-dom 导入:

import { Route, withRouter } from 'react-router-dom';

然后,在底部,用这个替换我们的 export default 语句:

export default withRouter(App);

这里发生了什么?基本上,withRouter 是一个接受组件作为参数并返回该组件的函数,除了现在它可以访问 history 属性。随着我们的学习,我们会更多地涉及到这一点,但让我们先完成这个注销流程。

最后,我们可以填写 componentDidMount

componentDidMount() {
  firebase.auth().onAuthStateChanged((user) => {
    if (user) {
      this.setState({ user });
    } else {
      this.props.history.push('/login')
    }
  });
}

尝试再次登录并点击注销按钮。你应该直接进入登录界面。神奇!

绕道 - 高阶组件

在前面的代码中,我们使用了 withRouter 函数(从 react-router-dom 导入)来让我们的 App 组件访问 history 属性。让我们花点时间来谈谈它是如何工作的,因为这是你可以学到的最强大的 React 模式之一。

withRouter 是一个高阶组件HOC)的例子。这个略显夸张的名字比我最喜欢的解释更好:构建函数的函数(感谢 Tom Coleman)。让我们看一个例子。

假设你有一个 Button 组件,如下所示:

const Button = (props) => {
  return (
    <button style={props.style}>{props.text}</button>
  );
};

还有,假设我们有这样一种情况,我们希望它有白色文本和红色背景:

<Button style={{ backgroundColor: 'red', color: 'white' }} text="I am red!" />

随着你的应用程序的发展,你发现你经常使用这种特定的样式来制作按钮。你需要很多红色按钮,带有不同的文本,每次都输入 backgroundColor 很烦人。

不仅如此;你还有另一个组件,一个带有相同样式的警报框:

<AlertBox style={{ backgroundColor: 'red', color: 'white' }} warning="ALERT!" />

在这里,你有两个选择。你想要两个新的组件(RedAlertBoxRedButton),你可以在任何地方使用。你可以按照下面的示例定义它们:

const RedButton = (props) => {
  return (
    <Button style={{ backgroundColor: 'red', color: 'white' }} text={props.text} />
  );
};

还有:

const RedAlertBox = (props) => {
  return (
    <AlertBox style={{ backgroundColor: 'red', color: 'white' }} warning={props.text} />
  );
};

然而,有一种更简单、更可组合的方法,那就是创建一个高阶组件。

我们想要实现的是一种方法,可以给一个组件添加红色背景和白色文本的样式。就是这样。我们想要将这些属性注入到任何给定的组件中。

让我们先看看最终结果,然后看看我们的 HOC 会是什么样子。如果我们成功地创建了一个名为 makeRed 的 HOC,我们可以像下面这样使用它来创建我们的 RedButtonRedAlertBox

// RedButton.js
import Button from './Button'
import makeRed from './makeRed'

export default makeRed(Button)
// RedAlertBox.js
import AlertBox from './AlertBox'
import makeRed from './makeRed'

export default makeRed(AlertBox)

这样做要容易得多,而且更容易重复使用。我们现在可以重复使用makeRed来将任何组件转换为漂亮的红色背景和白色文本。这就是力量。

好了,那么我们如何创建一个makeRed函数呢?我们希望将一个组件作为参数,并返回具有其所有分配的 props 和正确样式 prop 的组件:

import React from 'react';

const makeRed = (Component) => {
  const wrappedComponent = (props) => {
    return (
      <Component style={{ backgroundColor: 'red', color: 'white' }} {...props} />
    );
  };
  return wrappedComponent;
}

export default makeRed;

以下是相同的代码,带有注释:

import React from 'react';

// We receive a component constructor as an argument
const makeRed = (Component) => {
  // We make a new component constructor that takes props, just as any component
  const wrappedComponent = (props) => {
    // This new component returns the original component, but with the style applied
    return (
      // But we also use the ES6 spread operator to apply the regular props passed in.
      // The spread operator applies props like the text in <RedButton text="hello" /> 
       to our new component
      // It will "spread" any and all props across our component
      <Component style={{ backgroundColor: 'red', color: 'white' }} {...props} />
    );
  };
  // We return the new constructor, so it can be called as <RedButton /> or <RedAlertBox />
  return wrappedComponent;
}

export default makeRed;

最令人困惑的可能是{...props}的扩展运算符。扩展运算符是一个有用但令人困惑的 ES6 工具。它允许您获取一个对象(这里是props对象)并将其所有键和值应用于一个新对象(组件):

const obj1 = { 1: 'one', 2: 'two' };
const obj2 = { 3: 'three', ...obj1 };
console.log(obj2);
// { 1: 'one', 2: 'two', 3: 'three' }

高阶组件是使您的 React 组件更容易重用的下一级工具。我们在这里只是浅尝辄止。有关更多信息,请查看Tom ColemanUnderstanding Higher Order Components,网址为medium.freecodecamp.org/understanding-higher-order-components-6ce359d761b

我们的第三个路由

正如本章开头所讨论的,Chatastrophe 团队决定要有一个用户个人资料视图。让我们为此做骨架和基本路由。

src/components中,创建一个名为UserContainer.js的新文件。在里面,做基本的组件框架:

import React, { Component } from 'react';
import Header from './Header';

export default class UserContainer extends Component {
  render() {
    return (
      <div id="UserContainer">
        <Header />
        <h1>Hello from UserContainer</h1>
      </div>
    );
  }
}

回到App.js,让我们导入我们的新容器并添加Route组件:

import UserContainer from './UserContainer';

// Inside render, underneath ChatContainer Route
<Route path="/users" component={UserContainer} />

等一下!前面的代码为我们的UserContainer创建了一个在/users的路由,但我们不只有一个用户视图。我们为我们应用程序的每个用户都有一个用户视图。我们需要在chatastrophe.com/users/1为用户 1 创建一个路由,在chatastrophe.com/users/2为用户 2 创建一个路由,依此类推。

我们需要一种方法来将变量值传递给我们的path属性,等于用户的id。幸运的是,这样做很容易:

<Route path="/users/:id" component={UserContainer} />

最棒的部分?现在,在我们的UserContainer中,我们将收到一个props.params.match对象,等于{ id: 1 }或者id是什么,然后我们可以使用它来获取该用户的消息。

让我们通过更改UserContainer.js中的h1来测试一下:

<h1>Hello from UserContainer for User {this.props.match.params.id}</h1>

然后,前往localhost:8080/users/1

如果在嵌套路由中遇到找不到bundle.js的问题,请确保您在webpack.config.js中的输出如下所示:

output: {
 path: __dirname + "/public",
 filename: "bundle.js",
 publicPath: "/"
},

很好。现在,还有最后一步。让我们为用户从UserContainer返回到主聊天屏幕添加一种方式。

我们可以通过充分利用Header的子组件来以一种非常简单的方式做到这一点;只是,在这种情况下,我们可以添加另一个 React Router 组件,使我们的生活变得非常简单。它被称为Link,就像 HTML 中的标签一样,但经过了 React Router 的优化。

UserContainer.js中:

import { Link } from 'react-router-dom';
<Header>
  <Link to="/">
    <button className="red">
      Back To Chat
    </button>
  </Link>
</Header>

当您单击按钮时,应该转到根路由/

总结

就是这样!在本章中,我们涵盖了很多内容,以便让我们的应用程序的路由解决方案能够正常运行。如果有任何困惑,我建议您查看 React Router 文档reacttraining.com/react-router/。接下来,我们将深入学习 React,完成我们的基本应用程序,然后开始将其转换为渐进式 Web 应用程序。

第六章:完成我们的应用

是时候完成我们应用的原型了,哦,我们有很多工作要做。

框架已经搭好,所有的路由都设置好了,我们的登录界面也完全完成了。然而,我们的聊天和用户视图目前还是空白的,这就是 Chatastrophe 的核心功能所在。因此,在向董事会展示我们的原型之前,让我们确保它实际上能够工作。

本章我们将涵盖的内容如下:

  • 加载和显示聊天消息

  • 发送和接收新消息

  • 仅在用户个人资料页面上显示特定的聊天消息

  • React 状态管理

用户故事进展

让我们简要地检查一下我们在第一章“创建我们的应用结构”中定义的用户故事,看看我们已经完成了哪些。

我们已经完成了以下内容:

用户应该能够登录和退出应用。

以下内容尚未完成,但是它们是我们稍后将构建的 PWA 功能的一部分:

  • 用户应该能够在离线时查看他们的消息

  • 用户应该在其他用户发送消息时收到推送通知

  • 用户应该能够将应用安装到他们的移动设备上

  • 用户应该能够在不稳定的网络条件下在五秒内加载应用

这给我们留下了一系列故事,我们需要在我们的原型完成之前完成:

  • 用户应该能够实时发送和接收消息

  • 用户应该能够查看特定作者的所有消息

这些故事中的每一个都与特定的视图(聊天视图和用户视图)相匹配。让我们从ChatContainer开始,开始构建我们的聊天框。

ChatContainer 框架

我们的聊天视图将有两个主要部分:

  • 一个消息显示,列出所有的聊天

  • 一个聊天框,用户可以在其中输入新消息

我们可以先添加适当的div标签:

render() {
  return (
    <div id="ChatContainer">
      <Header>
        <button className="red" onClick={this.handleLogout}>
          Logout
        </button>
      </Header>
      <div id="message-container">

 </div>
 <div id="chat-input">

 </div>
     </div>
   );
}

提醒确保你的 ID 和 classNames 与我的相同,以免你的 CSS 不同(甚至更糟)。

我们首先填写输入框。在div#chat-input内,让我们放置一个textarea,并设置占位符为“添加你的消息…”:

<textarea placeholder="Add your message..." />

我们将配置它,以允许用户按“Enter”键发送消息,但最好也有一个发送按钮。在textarea下面,添加一个button,在其中,我们将添加一个SVG图标:

<div id="chat-input">
  <textarea placeholder="Add your message..." />
  <button>
 <svg viewBox="0 0 24 24">
 <path fill="#424242" d="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
 </svg>
 </button>
</div>

确保你的path fillsvg viewBox属性与提到的相同。

SVG 是一种可以缩放(放大)而不会失真的图像类型。在这种情况下,我们基本上创建了一个框(svg标签),然后在path标签内绘制一条线。浏览器进行实际绘制,所以永远不会有像素化。

为了 CSS 的目的,让我们也给我们的div#ChatContainer添加inner-container类:

<div id="ChatContainer" className="inner-container">

如果一切顺利,你的应用现在应该是这个样子的:

这就是我们聊天视图的基本结构。现在,我们可以开始讨论如何管理我们的数据--来自 Firebase 的消息列表。

管理数据流

React 的一个重要原则是所谓的单向数据流

在原型 React 应用中,数据存储在最高级组件的状态中,并通过props传递给较低级的组件。当用户与应用程序交互时,交互事件通过 props 通过组件树传递,直到到达最高级组件,然后根据操作修改状态。

应用程序形成一个大循环--数据下传,事件上传,新数据下传。你也可以把它想象成一部电梯,从充满数据的顶层出发,然后再满载事件返回。

这种方法的优势在于很容易跟踪数据的流动。你可以看到数据流向哪里(传递给哪些子组件),以及为什么会改变(作为对哪些事件的反应)。

现在,这种模式在具有数百个组件的复杂应用程序中会遇到问题。在顶层组件中存储所有状态,并通过 props 传递所有数据和事件变得难以控制。

想象一条从顶层组件(App.js)到低层组件(比如一个button)的大链条。如果有数十个嵌套组件,并且button需要一个从App状态派生的 prop,你将不得不通过每个链条中的每个组件传递这个 prop。谢谢,我不要。

解决这个状态管理问题有很多方法,但大多数都是基于在组件树中创建容器组件的想法;这些组件有状态,并将其传递给有限数量的子组件。现在我们有多部电梯,一些服务于一楼到三楼,另一些服务于五楼到十二楼,依此类推。

我们不会在我们的应用程序中处理任何状态管理,因为我们只有四个组件,但是在你的 React 应用程序扩展时,记住这一点是很好的。

前两个 React 状态管理库是 Redux(github.com/reactjs/redux)和 MobX(github.com/mobxjs/mobx)。我对两者都有深入的了解,它们都有各自的优势和权衡。简而言之,MobX 对开发者的生产力更好,而 Redux 对于保持大型应用程序有组织性更好。

为了我们的目的,我们可以将所有状态存储在我们的App组件中,并将其传递给子组件。与其将我们的消息存储在ChatContainer中,不如将它们存储在App中并传递给ChatContainer。这立即给了我们一个优势,也可以将它们传递给UserContainer

换句话说,我们的消息存储在App的状态中,并通过propsUserContainerChatContainer共享。

状态是你的应用程序中的唯一真相,并且不应该重复。在ChatContainerUserContainer中存储两个消息数组是没有意义的。相反,将状态保持在必要的高度,并将其传递下去。

长话短说,我们需要在App中加载我们的消息,然后将它们传递给ChatContainer。将App负责发送消息也是有道理的,这样我们所有的消息功能都在一个地方。

让我们从发送我们的第一条消息开始!

创建一条消息

与我们的LoginContainer一样,我们需要在状态中存储textarea的值随着其变化。

我们使用LoginContainer的状态来存储该值。让我们在ChatContainer中也这样做。

在前面的讨论之后,你可能会想:为什么我们不把所有状态都保存在App中呢?有人会主张这种方法,把所有东西都放在一个地方;然而,这将使我们的App组件变得臃肿,并要求我们在组件之间传递多个props。最好将状态保持在必要的高度,而不是更高;在聊天输入中的新消息只有在完成并提交后才与App相关,而在此之前并不相关。

让我们开始设置它。

将此添加到ChatContainer.js

state = { newMessage: '' };

还要添加一个处理它的方法:

handleInputChange = e => {
  this.setState({ newMessage: e.target.value });
};

现在,修改我们的textarea

<textarea
    placeholder="Add your message..."
    onChange={this.handleInputChange}
    value={this.state.newMessage} 
/>

最佳实践说,当 JSX 元素具有两个以上的props(或props特别长)时,应该将其多行化。

当用户点击发送时,我们希望将消息发送给App,然后App会将其发送到 Firebase。之后,我们重置字段:

handleSubmit = () => {
   this.props.onSubmit(this.state.newMessage);
   this.setState({ newMessage: ‘’ });
};

我们还没有在App中添加这个onSubmit属性函数,但我们很快就可以做到:

<button onClick={this.handleSubmit}>
  <svg viewBox="0 0 24 24">
    <path fill="#424242" d="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
  </svg>
</button>

然而,我们也希望让用户通过按下Enter来提交。我们该怎么做呢?

目前,我们监听textarea上的更改事件,然后调用handleInputChange方法。在textarea上监听其值的更改的属性是onChange,但还有另一个事件,即按键按下事件,每当用户按下键时都会发生。

我们可以监听该事件,然后检查按下了什么键;如果是Enter,我们就发送我们的消息!

让我们看看它的效果:

<textarea
    placeholder="Add your message..."
    onChange={this.handleInputChange}
    onKeyDown={this.handleKeyDown}
    value={this.state.newMessage} />

以下是这个事件的处理程序:

handleKeyDown = e => {
  if (e.key === 'Enter') {
    e.preventDefault();
    this.handleSubmit();
  }
}

事件处理程序(handleKeyDown)会自动传入一个事件作为第一个参数。这个事件有一个名为key的属性,它是一个指示按键值的字符串。在提交消息之前,我们还需要阻止默认行为(在textarea中创建新行)。

你可以使用这种类型的事件监听器来监听各种用户输入,从悬停在元素上到按住 Shift 键点击某物。

在我们转到App.js之前,这是ChatContainer的当前状态:

import React, { Component } from 'react';
import Header from './Header';

export default class ChatContainer extends Component {
  state = { newMessage: '' };

  handleLogout = () => {
    firebase.auth().signOut();
  };

  handleInputChange = e => {
    this.setState({ newMessage: e.target.value });
  };

  handleSubmit = () => {
    this.props.onSubmit(this.state.newMessage);
    this.setState({ newMessage: '' });
  };

  handleKeyDown = e => {
    if (e.key === 'Enter') {
      e.preventDefault();
      this.handleSubmit();
    }
  };

  render() {
    return (
      <div id="ChatContainer" className="inner-container">
        <Header>
          <button className="red" onClick={this.handleLogout}>
            Logout
          </button>
        </Header>
        <div id="message-container" />
        <div id="chat-input">
          <textarea
            placeholder="Add your message..."
            onChange={this.handleInputChange}
            onKeyDown={this.handleKeyDown}
            value={this.state.newMessage}
          />
          <button onClick={this.handleSubmit}>
            <svg viewBox="0 0 24 24">
              <path fill="#424242" d="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
            </svg>
          </button>
        </div>
      </div>
    );
  }
}

好的,让我们添加最后一个链接来创建一条消息。在App.js中,我们需要为onSubmit事件添加一个处理程序,然后将其作为属性传递给ChatContainer

// in App.js
handleSubmitMessage = msg => {
  // Send to database
  console.log(msg);
};

我们想要将一个等于这个方法的onSubmit属性传递给ChatContainer,但等一下,我们当前渲染的ChatContainer如下:

<Route exact path="/" component={ChatContainer} />

ChatContainer本身是我们Route上的一个属性。我们怎么能给ChatContainer任何props呢?

事实证明,React Router 提供了三种在Route内部渲染组件的不同方法。最简单的方法是我们之前选择的路由(哈哈),将其作为名为component的属性传递进去。

对于我们的目的来说,还有另一种更好的方法——一个名为render的属性,我们通过它传递一个返回我们组件的函数。

Route内部渲染组件的第三种方法是通过一个名为children的属性,它接受一个带有match参数的函数,该参数根据path属性是否与浏览器的 URL 匹配而定义或为 null。函数返回的 JSX 始终被渲染,但您可以根据match参数进行修改。

让我们将我们的Route切换到这种方法:

<Route
  exact
  path="/"
  render={() => <ChatContainer onSubmit={this.handleSubmitMessage} />}
/>

前面的例子使用了一个带有隐式返回的 ES6 箭头函数。这与写() => { return <ChatContainer onSubmit={this.handleSubmitMessage} /> }或者在 ES5 中写function() { return <ChatContainer onSubmit={this.handleSubmitMessage} /> }是一样的。

现在,我们可以将所有我们喜欢的 props 传递给ChatContainer

让我们确保它有效。尝试发送一条消息,并确保你在App.jshandleSubmit中添加的console.log

如果是这样,太好了!是时候进入好部分了--实际发送消息。

向 Firebase 发送消息

要写入 Firebase 数据库,首先我们要获取一个实例,使用firebase.database()。类似于firebase.auth(),这个实例带有一些内置方法可以使用。

在本书中,我们将处理的是firebase.database().ref(refName)Ref代表引用,但更好地理解它可能是我们数据的一个类别(在 SQL 数据库中,可能构成一个表)。

如果我们想要获取对我们用户的引用,我们使用firebase.database().ref(‘/users’)。对于消息,就是firebase.database().ref(‘/messages’)...等等。现在,我们可以以各种方式对这个引用进行操作,比如监听变化(稍后在本章中介绍),或者推送新数据(我们现在要处理)。

要向引用添加新数据,可以使用firebase.database().ref(‘/messages’).push(data)。在这个上下文中,可以将ref看作一个简单的 JavaScript 数组,我们向其中推送新数据。

Firebase 会接管,将数据保存到 NoSQL 数据库,并向应用程序的所有实例推送一个“value”事件,稍后我们将利用这一点。

我们的消息数据

当然,我们希望将消息文本保存到数据库,但我们也希望保存更多的信息。

我们的用户需要能够看到谁发送了消息(最好是电子邮件地址),并能够导航到他们的users/:id页面。因此,我们需要保存消息作者的电子邮件地址以及唯一的用户 ID。让我们再加上一个timestamp以确保万无一失:

// App.js
handleSubmitMessage = msg => {
  const data = {
    msg,
    author: this.state.user.email,
    user_id: this.state.user.uid,
    timestamp: Date.now()
  };
  // Send to database
}

前面的例子使用了 ES6 的属性简写来表示消息字段。我们可以简单地写{ msg },而不是{ msg: msg }

在这里,我们利用了将当前用户保存到App组件状态中的事实,并从中获取电子邮件和 uid(唯一 ID)。然后,我们使用Date.now()创建一个timestamp

好的,让我们发送出去!:

handleSubmitMessage = (msg) => {
  const data = {
    msg,
    author: this.state.user.email,
    user_id: this.state.user.uid,
    timestamp: Date.now()
  };
  firebase
      .database()
      .ref('messages/')
      .push(data);
}

在我们测试之前,让我们打开 Firebase 控制台console.firebase.google.com并转到数据库选项卡。在这里,我们可以实时查看我们的数据库数据的表示,以便检查我们的消息是否被正确创建。

现在,它应该是这样的:

让我们在聊天输入框中输入一条消息,然后按Enter

你应该立即在 Firebase 控制台上看到以下内容:

太棒了!我们发送了我们的第一条聊天消息,但是在我们的应用中没有显示任何内容。让我们来解决这个问题。

从 Firebase 加载数据

正如我们之前所描述的,我们可以监听数据库中特定引用的更改。换句话说,我们可以定义一个函数,以便在firebase.database().ref(‘/messages’)发生更改时运行,就像新消息进来一样。

在我们继续之前,我鼓励你考虑两件事情:我们应该在哪里定义这个监听器,以及这个函数应该做什么。

看看你能否想出一个可能的实现!在你构思了一个想法之后,让我们来实现它。

事实上:我们的应用程序中已经有一个非常相似的情况。我们的App#componentDidMount中的firebase.auth().onAuthStateChanged监听当前用户的更改,并更新我们Appstate.user

我们将用我们的消息引用做同样的事情,尽管语法有点不同:

class App extends Component {
  state = { user: null, messages: [] }

  componentDidMount() {
    firebase.auth().onAuthStateChanged((user) => {
      if (user) {
        this.setState({ user });
      } else {
       this.props.history.push('/login')
      }
    });
    firebase
 .database()
 .ref('/messages')
 .on('value', snapshot => {
 console.log(snapshot);
 });
  }

我们使用.on函数来监听数据库中的'value'事件。然后我们的回调被称为一个叫做snapshot的参数。让我们把这个插入进去,然后发送另一条消息,看看我们的快照是什么样子的:

啊,这不太友好开发者。

快照是数据库结构/messages的一个图像。我们可以通过调用val()来访问一个更可读的形式:

firebase.database().ref('/messages').on('value', snapshot => {
  console.log(snapshot.val());
});

现在,我们可以得到一个包含每条消息的对象,其中消息 ID 是键。

在这里,我们需要做一些技巧。我们想用消息数组更新我们的state.messages,但我们想要将消息 ID 添加到消息对象中(因为消息 ID 目前是snapshot.val()中的键)。

如果这听起来让人困惑,希望当我们看到它实际运行时会更清楚。我们将创建一个名为messages的新数组,并遍历我们的对象(使用一个叫做Object.keys的方法),然后将带有 ID 的消息推入新数组中。

让我们将这个提取到一个新的函数中:

class App extends Component {
  state = { user: null, messages: [] }

  componentDidMount() {
    firebase.auth().onAuthStateChanged((user) => {
      if (user) {
        this.setState({ user });
      } else {
       this.props.history.push('/login')
      }
    });
    firebase
      .database()
      .ref('/messages')
      .on('value', snapshot => {
        this.onMessage(snapshot);
      });
  }

还有新的方法:

  onMessage = snapshot => {
    const messages = Object.keys(snapshot.val()).map(key => {
      const msg = snapshot.val()[key];
      msg.id = key;
      return msg;
    });
    console.log(messages);
  };

在我们的 console.log 中,我们最终得到了一个带有 ID 的消息数组:

最后一步是将其保存到状态中:

onMessage = (snapshot) => {
  const messages = Object.keys(snapshot.val()).map(key => {
    const msg = snapshot.val()[key]
    msg.id = key
    return msg
  });
  this.setState({ messages });
}

现在,我们可以将消息传递给 ChatContainer,并开始显示它们:

<Route
  exact
  path="/"
  render={() => (
    <ChatContainer
      onSubmit={this.handleSubmitMessage}
      messages={this.state.messages}
    />
  )}
/>

我们对 App.js 进行了许多更改。以下是当前的代码:

import React, { Component } from 'react';
import { Route, withRouter } from 'react-router-dom';
import LoginContainer from './LoginContainer';
import ChatContainer from './ChatContainer';
import UserContainer from './UserContainer';
import './app.css';

class App extends Component {
  state = { user: null, messages: [] };

  componentDidMount() {
    firebase.auth().onAuthStateChanged(user => {
      if (user) {
        this.setState({ user });
      } else {
        this.props.history.push('/login');
      }
    });
    firebase
      .database()
      .ref('/messages')
      .on('value', snapshot => {
        this.onMessage(snapshot);
      });
  }

  onMessage = snapshot => {
    const messages = Object.keys(snapshot.val()).map(key => {
      const msg = snapshot.val()[key];
      msg.id = key;
      return msg;
    });
    this.setState({ messages });
  };

  handleSubmitMessage = msg => {
    const data = {
      msg,
      author: this.state.user.email,
      user_id: this.state.user.uid,
      timestamp: Date.now()
    };
    firebase
      .database()
      .ref('messages/')
      .push(data);
  };

  render() {
    return (
      <div id="container">
        <Route path="/login" component={LoginContainer} />
        <Route
          exact
          path="/"
          render={() => (
            <ChatContainer
              onSubmit={this.handleSubmitMessage}
              messages={this.state.messages}
            />
          )}
        />
        <Route path="/users/:id" component={UserContainer} />
      </div>
    );
  }
}

export default withRouter(App);

显示我们的消息

我们将使用 Array.map() 函数来遍历我们的消息数组,并创建一个 div 数组来显示数据。

Array.map() 自动返回一个数组,这意味着我们可以将该功能嵌入到我们的 JSX 中。这是 React 中的一个常见模式(通常用于显示这样的数据集合),因此值得密切关注。

在我们的 message-container 中,我们创建了开头和结尾的花括号:

<div id="message-container">
  {

  }
</div>

然后,我们在消息数组上调用 map,并传入一个函数来创建新的消息 div

<div id="message-container">
  {this.props.messages.map(msg => (
    <div key={msg.id} className="message">
      <p>{msg.msg}</p>
    </div>
  ))}
</div>

如果一切顺利,你应该看到以下内容,包括你发送的所有消息:

你甚至可以尝试写一条新消息,然后立即看到它出现在消息容器中。神奇!

关于前面的代码,有几点需要注意:

  • map 函数遍历消息数组中的每个元素,并根据其数据创建一个 div。当迭代完成时,它会返回一个 div 数组,然后作为 JSX 的一部分显示出来。

  • React 的一个怪癖是,屏幕上的每个元素都需要一个唯一的标识符,以便 React 可以正确地更新它。当处理一组相同的元素时,这对 React 来说很困难,就像我们在这里创建的一样。因此,我们必须给每个消息 div 一个保证是唯一的 key 属性。

有关列表和键的更多信息,请访问 facebook.github.io/react/docs/lists-and-keys.html

让我们增加一些功能,并在消息下方显示作者姓名,并附带到他们的用户页面的链接。我们可以使用 React Router 的 Link 组件来实现;它类似于锚标签(<a>),但针对 React Router 进行了优化:

import { Link } from 'react-router-dom';

然后,在下面添加它:

<div id="message-container">
  {this.props.messages.map(msg => (
    <div key={msg.id} className="message">
      <p>{msg.msg}</p>
      <p className="author">
 <Link to={`/users/${msg.user_id}`}>{msg.author}</Link>
 </p>
    </div>
  ))}
</div>

Link 上的 to 属性使用了 ES6 字符串插值。如果你用反引号包裹你的字符串(`)而不是引号,您还可以使用${VARIABLE}将变量直接嵌入其中。

现在,我们将使我们的消息看起来更好!

消息显示改进

在我们转向用户资料页之前,让我们花点时间对消息显示进行一些快速的UI改进。

多个用户

如果你尝试注销并使用新用户登录,所有用户的消息都会显示出来,如下所示:

我的消息和其他用户的消息之间没有区分。经典的聊天应用程序模式是将一个用户的消息放在一侧,另一个用户的消息放在另一侧。我们的CSS已经准备好处理这一点——我们只需要为与当前用户匹配的消息分配“mine”类。

由于我们在msg.author中可以访问消息作者的电子邮件,我们可以将其与App状态中存储的用户进行比较。让我们将它作为道具传递给ChatContainer

<Route
  exact
  path="/"
  render={() => (
    <ChatContainer
      onSubmit={this.handleSubmitMessage}
      user={this.state.user}
      messages={this.state.messages}
    />
  )}
/>

然后,我们可以在我们的className属性中添加一个条件:

<div id="message-container">
  {this.props.messages.map(msg => (
    <div
      key={msg.id}
      className={`message ${this.props.user.email === msg.author &&
 'mine'}`}>
      <p>{msg.msg}</p>
      <p className="author">
        <Link to={`/users/${msg.user_id}`}>{msg.author}</Link>
      </p>
    </div>
  ))}
</div>

这使用了ES6字符串插值以及短路评估来创建我们想要的效果。这些是花哨的术语,归结为这一点:如果消息作者与state中的用户电子邮件匹配,将className设置为message mine;否则,将其设置为message

它最终应该看起来像这样:

批量显示用户消息

在前面的截图中,你会注意到我们甚至在连续两条消息由同一作者发送时也显示了作者电子邮件。让我们变得狡猾,使得我们将同一作者的消息分组在一起。

换句话说,我们只希望在下一个消息不是由同一作者发送时显示作者电子邮件:

<div id="message-container">
  {this.props.messages.map(msg => (
    <div
      key={msg.id}
      className={`message ${this.props.user.email === msg.author &&
        'mine'}`}>
      <p>{msg.msg}</p>
 // Only if the next message's author is NOT the same as this message's    author, return the following:      <p className="author">
        <Link to={`/users/${msg.user_id}`}>{msg.author}</Link>
      </p>
    </div>
  ))}
</div>

我们如何做到这一点?我们需要一种方法来检查数组中当前消息之后的下一个消息。

幸运的是,Array.map()函数将索引作为第二个元素传递给我们的回调函数。我们可以像这样使用它:

<div id="message-container">
  {this.props.messages.map((msg, i) => (
    <div
      key={msg.id}
      className={`message ${this.props.user.email === msg.author &&
        'mine'}`}>
      <p>{msg.msg}</p>
      {(!this.props.messages[i + 1] ||
 this.props.messages[i + 1].author !== msg.author) && (
 <p className="author">
 <Link to={`/users/${msg.user_id}`}>{msg.author}</Link>
 </p>
 )}
    </div>
  ))}
</div>

现在,我们说的是:“如果有下一个消息,并且下一个消息的作者与当前消息的作者不同,显示这个消息的作者。”

然而,在我们的render方法中有大量复杂的逻辑。让我们将其提取到一个方法中:

<div id="message-container">
  {this.props.messages.map((msg, i) => (
    <div
      key={msg.id}
      className={`message ${this.props.user.email === msg.author &&
        'mine'}`}>
      <p>{msg.msg}</p>
      {this.getAuthor(msg, this.props.messages[i + 1])}
    </div>
  ))}
</div>

还有,方法本身:

  getAuthor = (msg, nextMsg) => {
    if (!nextMsg || nextMsg.author !== msg.author) {
      return (
        <p className="author">
          <Link to={`/users/${msg.user_id}`}>{msg.author}</Link>
        </p>
      );
    }
  };

我们的消息现在这样分组:

向下滚动

尝试缩小你的浏览器,使消息列表几乎被截断;然后,提交另一条消息。请注意,如果消息超出了消息容器的截断位置,你必须滚动才能看到它。这是糟糕的用户体验。让我们改进它,使得当新消息到达时,我们自动滚动到底部。

在本节中,我们将深入探讨两个强大的React概念:componentDidUpdate方法和refs。

让我们先讨论我们想要实现的目标。我们希望消息容器始终滚动到底部,以便最新消息始终可见(除非用户决定向上滚动查看旧消息)。这意味着我们需要在两种情况下使消息容器向下滚动:

  • 当第一个组件被渲染时

  • 当新消息到达时

让我们从第一个用例开始。我们需要一个我们已经使用过的React生命周期方法。我们将在我们的ChatContainer中添加一个componentDidMount方法,就像我们在App中所做的那样。

让我们来定义它,以及一个scrollToBottom方法:

export default class ChatContainer extends Component {
  state = { newMessage: '' };

  componentDidMount() {
    this.scrollToBottom();
  }

  scrollToBottom = () => {

  };

我们还希望每当新消息到达并出现在屏幕上时触发scrollToBottom方法。React为我们提供了另一种处理这种情况的方法——componentDidUpdate。每当您的React组件因新的props或状态而更新时,都会调用此方法。最好的部分是该方法将前一个props作为第一个参数传递,因此我们可以比较它们并找出差异,如下所示:

componentDidUpdate(previousProps) {
  if (previousProps.messages.length !== this.props.messages.length) {
    this.scrollToBottom();
  }
}

我们查看前一个props中的消息数组长度,并与当前props中的消息数组长度进行比较。如果它发生了变化,我们就滚动到底部。

好的,看起来都不错。让我们继续让我们的scrollToBottom方法工作起来。

React refs

React中的refs是一种获取特定DOM元素的方式。对于熟悉jQuery的人来说,refs弥合了React通过props创建元素的方法与jQuery从DOM中获取元素并操作它们的方法之间的差距。

我们可以在任何我们想要稍后使用的JSX元素上添加一个ref(我们想要稍后引用的元素)。让我们在我们的消息容器上添加一个。ref属性总是一个函数,该函数被调用时带有相关元素,然后用于将该元素分配给组件的属性,如下所示:

<div
  id="message-container"
  ref={element => {
    this.messageContainer = element;
  }}>

在我们的scrollToBottom方法内部,我们使用ReactDOM.findDOMNode来获取相关元素(别忘了导入react-dom!):

import ReactDOM from 'react-dom';

scrollToBottom = () => {
  const messageContainer = ReactDOM.findDOMNode(this.messageContainer);
}

在下一节中,我们将使得只有在消息加载时才显示我们的消息容器。为此,我们需要一个if语句来检查我们的messageContainer DOM节点当前是否存在。一旦完成这一步,我们就可以将messageContainer.scrollTop(当前滚动到底部的距离)设置为其高度,以便它位于底部:

scrollToBottom = () => {
  const messageContainer = ReactDOM.findDOMNode(this.messageContainer);
  if (messageContainer) {
    messageContainer.scrollTop = messageContainer.scrollHeight;
  }
}

现在,如果你尝试缩小浏览器窗口并发送一条消息,你应该总是被带到消息容器的底部,以便它自动进入视图。太棒了!

加载指示器

Firebase加载速度相当快,但如果我们的用户连接速度较慢,他们将看到一个空白屏幕,直到他们的消息加载完毕,并会想:“我所有的精彩聊天都去哪儿了?”让我们给他们一个加载指示器。

在我们的ChatContainer内部,我们只希望在名为messagesLoaded的prop为true时显示消息(我们稍后会定义它)。我们将根据该prop的条件来渲染我们的消息容器。我们可以使用一个三元运算符来实现这一点。

JavaScript中的三元运算符是一种简短的if-else写法。我们可以写成true ? // 这段代码 : // 那段代码,而不是if (true) { // 这段代码 } else { // 那段代码 },这样既简洁又明了。

代码如下所示:

// Beginning of ChatContainer
<Header>
  <button className="red" onClick={this.handleLogout}>
    Logout
  </button>
</Header>
{this.props.messagesLoaded ? (
  <div
    id="message-container"
    ref={element => {
      this.messageContainer = element;
    }}>
    {this.props.messages.map((msg, i) => (
      <div
        key={msg.id}
        className={`message ${this.props.user.email === msg.author &&
          'mine'}`}>
        <p>{msg.msg}</p>
        {this.getAuthor(msg, this.props.messages[i + 1])}
      </div>
    ))}
  </div>
) : (
 <div id="loading-container">
 <img src="img/icon.png" alt="logo" id="loader" />
 </div>
)}
<div id="chat-input">
// Rest of ChatContainer

花点时间仔细阅读这个,确保你完全理解正在发生的事情。条件语句在React中很常见,因为它们使得条件渲染JSX变得容易。如果一切正确,你应该看到以下内容,带有到标志的脉冲动画:

下一步是在消息加载时更新messagesLoaded属性。让我们跳到App.js

这里的逻辑很简单——当我们从Firebase数据库接收到一个消息值时,如果我们之前没有收到过值(换句话说,这是我们收到的第一条消息),我们就知道我们的消息已经首次加载:

class App extends Component {
  state = { user: null, messages: [], messagesLoaded: false };
componentDidMount() {
    firebase.auth().onAuthStateChanged(user => {
      if (user) {
        this.setState({ user });
      } else {
        this.props.history.push('/login');
      }
    });
    firebase
      .database()
      .ref('/messages')
      .on('value', snapshot => {
        this.onMessage(snapshot);
        if (!this.state.messagesLoaded) {
 this.setState({ messagesLoaded: true });
 }
      });
  }
<Route exact path="/" render={() => (
  <ChatContainer
    messagesLoaded={this.state.messagesLoaded}
    onSubmit={this.handleSubmitMessage}
    messages={this.state.messages}
    user={this.state.user} />
)} />

现在,如果你重新加载应用页面,你应该会短暂看到加载指示器(取决于你的互联网连接),然后看到消息显示出来。

这里是到目前为止ChatContainer的代码:

import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import ReactDOM from 'react-dom';
import Header from './Header';

export default class ChatContainer extends Component {
  state = { newMessage: '' };

  componentDidMount() {
    this.scrollToBottom();
  }

  componentDidUpdate(previousProps) {
    if (previousProps.messages.length !== this.props.messages.length) {
      this.scrollToBottom();
    }
  }

  scrollToBottom = () => {
    const messageContainer = ReactDOM.findDOMNode(this.messageContainer);
    if (messageContainer) {
      messageContainer.scrollTop = messageContainer.scrollHeight;
    }
  };

  handleLogout = () => {
    firebase.auth().signOut();
  };

  handleInputChange = e => {
    this.setState({ newMessage: e.target.value });
  };

  handleSubmit = () => {
    this.props.onSubmit(this.state.newMessage);
    this.setState({ newMessage: '' });
  };

  handleKeyDown = e => {
    if (e.key === 'Enter') {
      e.preventDefault();
      this.handleSubmit();
    }
  };

  getAuthor = (msg, nextMsg) => {
    if (!nextMsg || nextMsg.author !== msg.author) {
      return (
        <p className="author">
          <Link to={`/users/${msg.user_id}`}>{msg.author}</Link>
        </p>
      );
    }
  };

  render() {
    return (
      <div id="ChatContainer" className="inner-container">
        <Header>
          <button className="red" onClick={this.handleLogout}>
            Logout
          </button>
        </Header>
        {this.props.messagesLoaded ? (
          <div
            id="message-container"
            ref={element => {
              this.messageContainer = element;
            }}>
            {this.props.messages.map((msg, i) => (
              <div
                key={msg.id}
                className={`message ${this.props.user.email ===       
                                                    msg.author &&
                  'mine'}`}>
                <p>{msg.msg}</p>
                {this.getAuthor(msg, this.props.messages[i + 1])}
              </div>
            ))}
          </div>
        ) : (
          <div id="loading-container">
            <img src="img/icon.png" alt="logo" id="loader" />
          </div>
        )}
        <div id="chat-input">
          <textarea
            placeholder="Add your message..."
            onChange={this.handleInputChange}
            onKeyDown={this.handleKeyDown}
            value={this.state.newMessage}
          />
          <button onClick={this.handleSubmit}>
            <svg viewBox="0 0 24 24">
              <path fill="#424242"  
                d="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
            </svg>
          </button>
        </div>
      </div>
    );
  }
}

我们的应用已经接近完成。最后一步是用户资料页面。

个人资料页面

对于UserContainer的代码将与ChatContainer相同,有两个主要区别:

  • 我们只想显示与我们从URL参数中获取的ID匹配的消息数组中的消息

  • 我们想在页面顶部显示作者的电子邮件,在任何其他消息之前

首先,在App.js中,将UserContainer路由转换为使用render属性,与ChatContainer相同,并传递以下属性:

<Route
  path="/users/:id"
  render={({ history, match }) => (
    <UserContainer
      messages={this.state.messages}
      messagesLoaded={this.state.messagesLoaded}
      userID={match.params.id}
    />
  )}
/>

请注意,React Router自动在我们的render方法中提供了历史和匹配props,我们在这里使用它们来从URL参数中获取用户ID。

然后,在UserContainer中,让我们设置我们的加载指示器。同时,确保你给UserContainer一个classNameinner-container用于CSS目的:

<div id="UserContainer" className="inner-container">
  <Header>
    <Link to="/">
      <button className="red">Back To Chat</button>
    </Link>
  </Header>
  {this.props.messagesLoaded ? (
 <h1>Messages go here</h1>
 ) : (
 <div id="loading-container">
 <img src="img/icon.png" alt="logo" id="loader" />
 &lt;/div>
 )}
</div>

对于显示我们的消息,我们只想显示那些msg.user_id等于我们的props.userID的消息。我们可以不用Array.map()的回调,只需添加一个if语句:

{this.props.messagesLoaded ? (
 <div id="message-container">
 {this.props.messages.map(msg => {
 if (msg.user_id === this.props.userID) {
 return (
 <div key={msg.id} className="message">
 <p>{msg.msg}</p>
 </div>
 );
 }
 })}
 </div>
) : (
  <div id="loading-container">
    <img src="img/icon.png" alt="logo" id="loader" />
  </div>
)}

这应该只显示来自我们正在查看其资料的作者的消息。然而,我们现在需要在顶部显示作者的电子邮件。

挑战在于,我们不会知道用户电子邮件,直到我们已经加载了消息,并且在迭代第一个匹配ID的消息,所以我们不能像之前那样使用map()的索引,也不能使用属性。

相反,我们将添加一个class属性来跟踪我们是否已经显示了用户电子邮件。

UserContainer顶部声明它:

export default class UserContainer extends Component {
  renderedUserEmail = false;

  render() {
    return (

然后,我们将在代码中调用一个getAuthor方法:

<div id="message-container">
  {this.props.messages.map(msg => {
    if (msg.user_id === this.props.userID) {
      return (
        <div key={msg.id} className="message">
          {this.getAuthor(msg.author)}
          <p>{msg.msg}</p>
        </div>
      );
    }
  })}
</div>

这个检查是为了看看我们是否已经渲染了作者,如果没有,就返回它:

  getAuthor = author => {
    if (!this.renderedUserEmail) {
      this.renderedUserEmail = true;
      return <p className="author">{author}</p>;
    }
  };

有点绕路——对于我们的生产应用程序,我们可能想要添加更复杂的逻辑来只加载那个作者的消息。然而,这对于我们的原型来说已经足够了。

这里是UserContainer的完整代码:

import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import Header from './Header';

export default class UserContainer extends Component {
  renderedUserEmail = false;

  getAuthor = author => {
    if (!this.renderedUserEmail) {
      this.renderedUserEmail = true;
      return <p className="author">{author}</p>;
    }
  };

  render() {
    return (
      <div id="UserContainer" className="inner-container">
        <Header>
          <Link to="/">
            <button className="red">Back To Chat</button>
          </Link>
        </Header>
        {this.props.messagesLoaded ? (
          <div id="message-container">
            {this.props.messages.map(msg => {
              if (msg.user_id === this.props.userID) {
                return (
                  <div key={msg.id} className="message">
                    {this.getAuthor(msg.author)}
                    <p>{msg.msg}</p>
                  </div>
                );
              }
            })}
          </div>
        ) : (
          <div id="loading-container">
            <img src="img/icon.png" alt="logo" id="loader" />
          </div>
        )}
      </div>
    );
  }
}

总结

就是这样!我们已经建立了完整的 React 应用程序。你的朋友对最终产品感到非常高兴,但我们还远未完成。

我们已经建立了一个网络应用程序。它看起来很不错,但它还不是一个渐进式网络应用程序。还有很多工作要做,但这就是乐趣开始的地方。

我们的下一步是开始将这个应用程序转换成 PWA。我们将从研究如何使我们的网络应用程序更像本地应用程序开始,并深入研究近年来最激动人心的网络技术之一--service workers。

第七章:添加服务工作者

欢迎来到我们迈向渐进式 Web 应用程序世界的第一步。本章将致力于创建我们的第一个服务工作者,这将解锁使 PWA 如此特别的许多功能。

我们之前已经谈到过 PWA 是如何连接 Web 应用和原生应用的。它们通过服务工作者来实现这一点。服务工作者使推送通知和离线访问等功能成为可能。它们是一种令人兴奋的新技术,有许多应用(每年都有越来越多的新应用出现);如果有一种技术能在未来五年内改变 Web 开发,那就是服务工作者。

然而,足够的炒作;让我们深入了解服务工作者到底是什么。

在本章中,我们将涵盖以下主题:

  • 什么是服务工作者?

  • 服务工作者的生命周期

  • 如何在我们的页面上注册服务工作者

什么是服务工作者?

服务工作者是一小段 JavaScript 代码,位于我们的应用和网络之间。

你可以把它想象成在我们的应用程序之外运行的脚本,但我们可以在我们的代码范围内与其通信。它是我们应用的一部分,但与其余部分分开。

最简单的例子是在缓存文件的上下文中(我们将在接下来的章节中探讨)。比如说,当用户导航到chatastrophe.com时,我们的应用会获取我们的icon.png文件。

服务工作者,如果我们配置好了,将会位于我们的应用和网络之间。当我们的应用请求图标文件时,服务工作者会拦截该请求并检查本地缓存中是否有该文件。如果找到了,就返回该文件;不会进行网络请求。只有在缓存中找不到文件时,才会让网络请求通过;下载完成后,它会将文件放入缓存中。

你可以看到“工作者”这个术语是从哪里来的--我们的服务工作者就像一只忙碌的小蜜蜂。

让我们再看一个例子;推送通知(第九章的预览,使用清单使我们的应用可安装)。大多数推送通知都是这样工作的--当发生某个事件(用户发送新的聊天消息)时,消息服务会被通知(在我们的情况下,消息服务由 Firebase 管理)。消息服务会向相关注册用户发送通知(这些用户通过他们的设备进行注册),然后他们的设备创建通知(叮咚!)。

在 Web 应用程序的情况下,这种流程的问题在于,当用户不在页面上时,我们的应用程序会停止运行,因此除非他们的应用程序已经打开,否则我们将无法通知他们,这完全违背了推送通知的初衷。

Service workers 通过始终处于“开启”状态并监听消息来解决了这个问题。现在,消息服务可以提醒我们的 service worker,后者向用户显示消息。我们的应用程序代码实际上并没有参与其中,因此它是否运行并不重要。

这是令人兴奋的事情,但是对于任何新技术来说,都存在一些问题,需要注意一些事情。

service worker 的生命周期

当用户首次访问您的页面时,service worker 的生命周期就开始了。service worker 被下载并开始运行。当不需要时,它可能会空闲一段时间,但在需要时可以重新启动。

这种始终开启的功能是使 service workers 对推送通知有用的原因。它也使 service workers 有点不直观(稍后会详细介绍)。然而,让我们深入了解典型页面上 service worker 的生死。

首先,如果可能的话,service worker 会被安装。所有 service worker 的安装都将从检查用户浏览器是否支持该技术开始。截至目前,Firefox、Chrome 和 Opera 都提供了全面支持,其他浏览器则没有。例如,苹果认为 service workers 是实验性技术,这表明他们对整个事情仍然持观望态度。

如果用户的浏览器足够现代化,安装就会开始。脚本(例如sw.js)将在特定范围内安装(或者说注册)。在这种情况下,“范围”指的是它所关注的网站路径。例如,全局范围将采用'/',即网站上的所有路径,但您也可以将 service worker 限制为'/users',例如,仅缓存应用程序的某些部分。我们将在缓存章节中更多地讨论范围。

注册后,service worker 被激活。激活事件也会在需要 service worker 时发生,例如,当推送通知到来时。service worker 的激活和停用意味着您不能在 service worker 中保持状态;它只是对事件的反应而运行的一小段代码,而不是一个完整的应用程序。这是一个重要的区别需要记住,以免我们对我们的工作人员要求过多。

服务工作者将处于空闲状态,直到发生事件。目前,服务工作者对两个事件做出反应:fetch事件(也称为应用程序的网络请求)和message(也称为应用程序代码或消息服务的交互)。我们可以在服务工作者中为这些事件注册监听器,然后根据需要做出反应。

服务工作者代码将在两种情况下更新:已经过去了 24 小时(在这种情况下,它会停止并重新下载一个方法,以防止损坏的代码引起太多烦恼),或者用户访问页面并且sw.js文件已更改。每当用户访问应用程序时,服务工作者将其当前代码与站点提供的sw.js进行比较,如果有一丁点的差异,就会下载并注册新的sw.js

这是服务工作者的基本技术概述以及它们的工作原理。这可能看起来很复杂,但好消息是使用服务工作者相对直接;您可以在几分钟内启动一个简单的服务工作者,这正是我们接下来要做的!

注册我们的第一个服务工作者

记住服务工作者的区别--它们是我们网站的一部分,但在我们的应用程序代码之外运行。考虑到这一点,我们的服务工作者将位于public/文件夹中,而不是src/文件夹中。

然后,在public/文件夹中创建一个名为sw.js的文件。现在我们将保持简单;只需在其中添加一个console.log

console.log("Service worker running!");

真正的工作(注册服务工作者)将在我们的index.html中完成。对于这个过程,我们想要做以下事情:

  1. 检查浏览器是否支持服务工作者。

  2. 等待页面加载。

  3. 注册服务工作者。

  4. 登出结果。

让我们一步一步地进行。首先,在我们的 Firebase 初始化下面,在public/index.html中创建一个空的script标签:

<body>
  <div id="root"></div>
  <script src="/secrets.js"></script>
  <script src="https://www.gstatic.com/firebasejs/4.1.2/firebase.js"></script>
  <script>
    // Initialize Firebase
    var config = {
      apiKey: window.apiKey,
      authDomain: "chatastrophe-77bac.firebaseapp.com",
      databaseURL: "https://chatastrophe-77bac.firebaseio.com",
      projectId: "chatastrophe-77bac",
      storageBucket: "chatastrophe-77bac.appspot.com",
      messagingSenderId: "85734589405"
    }; 
    window.firebase = firebase;
    firebase.initializeApp(config);
  </script>
  <script>
 // Service worker code here.
 </script>

检查浏览器支持情况

检查用户的浏览器是否支持服务工作者非常容易。在我们的脚本标签中,我们将添加一个简单的if语句:

<script>
  if ('serviceWorker' in navigator) {
    // register
  } else {
    console.log('service worker is not supported');
  }
</script>

在这里,我们检查window.navigator对象是否支持任何服务工作者。导航器还可以使用(通过其userAgent属性)来检查用户使用的浏览器,尽管我们在这里不需要。

监听页面加载

在页面加载完成之前,我们不想注册我们的 service worker;这没有意义,而且可能会导致复杂性,因此我们将为窗口添加一个'load'事件的事件侦听器:

<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', function() {

    });
  } else {
    console.log('service worker is not supported');
  }
</script>

注册 service worker

正如我们之前指出的,window.navigator有一个serviceWorker属性,其存在确认了浏览器对 service worker 的支持。我们还可以使用同一个对象通过其register函数来注册我们的 service worker。我知道,这是令人震惊的事情。

我们调用navigator.serviceWorker.register,并传入我们的 service worker 文件的路径:

<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', function() {
      navigator.serviceWorker.register('sw.js')
    });
  } else {
    console.log('service worker is not supported');
  }
</script>

记录结果

最后,让我们添加一些console.logs,这样我们就知道注册的结果。幸运的是,navigator.serviceWorker.register返回一个 promise:

<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', function() {
      navigator.serviceWorker.register('sw.js').then(function(registration) {
        // Registration was successful
        console.log('Registered!');
      }, function(err) {
        // registration failed :(
        console.log('ServiceWorker registration failed: ', err);
      }).catch(function(err) {
        console.log(err);
      });
    });
  } else {
    console.log('service worker is not supported');
  }
</script>

好的,让我们测试一下!重新加载页面,如果一切正常,您应该在控制台中看到以下内容:

您还可以通过导航到 DevTools 中的应用程序选项卡,然后转到服务工作者选项卡来检查它:

我建议您此时检查重新加载按钮。这样可以确保每次刷新页面时都刷新您的 service worker(记住我们之前讨论的正常 service worker 生命周期)。为什么要采取这种预防措施?我们正在步入缓存代码的世界,浏览器可能会认为您的 service worker 没有改变,而实际上已经改变了。这个复选框只是确保您始终处理最新版本的sw.js

好的,我们已经注册了一个 worker!太棒了。让我们花点时间从我们的sw.js中了解 service worker 的生命周期。

体验 service worker 生命周期

service worker 体验的第一个事件是'install'事件。这是用户第一次启动 PWA 时发生的。标准用户只会经历一次。

要利用这个事件,我们只需要在 service worker 本身添加一个事件侦听器。要在sw.js中执行这个操作,我们使用self关键字:

self.addEventListener('install', function() {
 console.log('Install!');
});

当您重新加载页面时,您应该在控制台中看到'Install!'出现。事实上,除非您在应用程序|服务工作者下取消选中重新加载选项,否则每次重新加载页面时都应该看到它。然后,您只会在第一次看到它。

接下来是activate事件。此事件在服务工作者首次注册时触发,注册完成之前。换句话说,它应该在相同的情况下发生,只是稍后:

self.addEventListener('activate', function() {
  console.log('Activate!');
});

我们要覆盖的最后一个事件是'fetch'事件。每当应用程序发出网络请求时,都会调用此事件。它与一个具有请求 URL 的事件对象一起调用,我们可以将其记录出来:

self.addEventListener('fetch', function(event) {
  console.log('Fetch!', event.request);
});

添加后,我们应该看到一个非常混乱的控制台:

您现在可以删除服务工作者中的所有console.logs,但是我们将在将来使用这些事件监听器中的每一个。

接下来,我们将研究如何连接到 Firebase 消息服务,为推送通知奠定基础。

将 Firebase 添加到我们的服务工作者

本章的其余部分目标是将 Firebase 集成到我们的服务工作者中,以便它准备好接收推送通知并显示它们。

这是一个大项目。在下一章结束之前,我们将无法实际显示推送通知。然而,在这里,我们将看到如何将第三方服务集成到服务工作者中,并深入了解服务工作者背后的理论。

命名我们的服务工作者

我们将用于向用户设备发送推送通知的服务称为Firebase Cloud Messaging,或FCM。FCM 通过寻找服务工作者在网络上运行,然后向其发送消息(包含通知详情)。然后服务工作者显示通知。

默认情况下,FCM 会寻找一个名为firebase-messaging-sw.js的服务工作者。您可以使用firebase.messaging().useServiceWorker来更改,然后传递一个服务工作者注册对象。然而,为了我们的目的,简单地重命名我们的服务工作者会更直接。让我们这样做;在public/中更改文件名,并在index.html中更改注册:

<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', function() {
      navigator.serviceWorker.register('firebase-messaging-sw.js').then(function(registration) {
        // Registration was successful
        console.log('Registered!');
      }, function(err) {
        // registration failed :(
        console.log('ServiceWorker registration failed: ', err);
      }).catch(function(err) {
        console.log(err);
      });
    });
   } else {
     console.log('service worker is not supported');
   }
</script>

完成后,我们可以开始在服务工作者中初始化 Firebase。

让我们再说一遍;服务工作者与您的应用程序代码没有关联。这意味着它无法访问我们当前的 Firebase 初始化。但是,我们可以在服务工作者中重新初始化 Firebase,并且只保留相关的内容--messagingSenderId。您可以从 Firebase 控制台或您的secrets.js文件中获取您的messagingSenderId

如果您担心安全性,请确保将public/firebase-messaging-sw.js添加到您的.gitignore中,尽管保持您的messagingSenderId私有性并不像保持 API 密钥秘密那样重要。

// firebase-messaging-sw.js
firebase.initializeApp({
  'messagingSenderId': '85734589405'
});

我们还需要在文件顶部导入我们需要的 Firebase 部分,包括app库和messaging库:

importScripts('https://www.gstatic.com/firebasejs/3.9.0/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/3.9.0/firebase-messaging.js');

完成后,我们应该能够console.logfirebase.messaging();

importScripts('https://www.gstatic.com/firebasejs/3.9.0/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/3.9.0/firebase-messaging.js');
firebase.initializeApp({
  'messagingSenderId': '85734589405'
});console.log(firebase.messaging());

您应该看到以下内容:

这意味着我们的 Firebase 已经在我们的服务工作者中运行起来了!

如果您仍然看到来自我们旧的sw.js的日志,请转到 DevTools 的应用程序|服务工作者选项卡,并取消注册它。这是服务工作者即使未重新注册也会持续存在的一个很好的例子。

正如前面所解释的,服务工作者是一段始终运行的代码(虽然不完全准确--想想这些工作者的生命周期--这是一个很好的思考方式)。这意味着它将始终等待 FCM 告诉它有消息进来。

但是,现在我们没有收到任何消息。下一步是开始配置何时发送推送通知,以及如何显示它们!

摘要

在本章中,我们学习了服务工作者的基础知识,并使其运行起来。我们的下一步是开始使用它。具体来说,我们希望使用它来监听通知,然后将它们显示给用户。通过设置推送通知,让我们再迈出一大步,使我们的 PWA 感觉像一个原生应用程序。

第八章:使用服务工作者发送推送通知

在本章中,我们将完成我们应用程序发送推送通知的过程。这个实现有点复杂;它需要许多移动的部分来使事情正常运行(根据我的经验,这对于任何移动或网络上的推送通知实现都是真实的)。令人兴奋的部分是我们可以与许多新的知识领域互动,比如设备令牌云函数

在我们开始之前,让我们花一分钟概述设置推送通知的过程。目前,我们的消息服务工作者已经启动并运行。这个服务工作者将坐在那里等待被调用以显示新通知。一旦发生这种情况,它将处理所有与显示通知有关的事情,所以我们不必担心(至少目前是这样)。

由我们负责的是将消息发送给服务工作者。假设我们的应用程序有 1,000 个用户,每个用户都有一个唯一的设备。每个设备都有一个唯一的令牌,用于将其标识给 Firebase。我们需要跟踪所有这些令牌,因为当我们想要发送通知时,我们需要告诉 Firebase 要发送到哪些设备。

所以,这是第一步 - 设置和维护一个包含我们应用程序使用的所有设备令牌的数据库表。正如我们将看到的,这也必然涉及询问用户是否首先想要通知。

一旦我们保存了我们的令牌,我们就可以告诉 Firebase 监听数据库中的新消息,然后向所有设备(基于令牌)发送消息详细信息的通知。作为一个小的额外复杂性,我们必须确保不向创建消息的用户发送通知。

这个阶段(告诉 Firebase 发送通知)实际上是在我们的应用程序之外进行的。它发生在神秘的“云”中,我们将在那里托管一个函数来处理这个过程;稍后会详细介绍。

我们对这个相当复杂的工程方法将是慢慢来,一次一个部分。确保你仔细跟随代码示例;通知的性质意味着在实现完全之前我们将无法完全测试我们的实现,所以尽力避免途中的小错误。

在本章中,我们将涵盖以下主题:

  • 请求显示通知的权限

  • 跟踪和保存用户令牌

  • 使用云函数发送通知

好了,让我们开始吧!

请求权限

正如前面的介绍所解释的,我们在这一章中有很多功能要创建。为了将所有内容放在一个地方,而不会使我们的App.js混乱,我们将创建一个单独的 JavaScript 类来管理与通知有关的一切。这是我在 React 中非常喜欢的一种模式,可以提取与任何一个组件无关的功能。在我们的src/文件夹中,紧挨着我们的components文件夹,让我们创建一个名为resources的文件夹,在其中创建一个名为NotificationResource.js的文件。

我们的类的基本轮廓如下:

export default class NotificationResource {

}

我们创建一个 JavaScript 类并导出它。

对于那些不熟悉 JavaScript 类的人(特别是那些熟悉其他语言中的类的人),我鼓励你阅读 MDN 的文章,解释了基础知识,网址为developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes

在我们忘记之前,让我们在App.js中导入它:

import NotificationResource from '../resources/NotificationResource';

当我们的应用启动时,我们希望请求用户权限发送通知给他们。请注意,Firebase 会记住用户是否已经接受或拒绝了我们的请求,因此我们不会每次都用弹出窗口打扰他们,只有在他们之前没有被问过的情况下才会这样做。

以下是我们将如何处理这个过程:

  1. 当我们的应用挂载时,我们将创建一个NotificationResource类的新实例,将 Firebase 消息库传递给它(我们将这个传递进去是为了避免我们不得不在NotificationResource.js文件中导入它,因为我们已经在App.js中有了对它的访问)。

  2. NotificationResource类首次实例化时,我们将立即使用传递进来的 Firebase 消息库请求用户权限。

如果这些步骤对你来说很清楚,我鼓励你首先尝试自己实现它们。如果你完全困惑于我们将如何做到这一点,不要担心,我们会一一讲解。

好的,让我们从我们的 App 的componentDidMount开始。这是我们想要创建NotificationResource实例的地方:

componentDidMount() {
   this.notifications = new NotificationResource();

我们将NotificationResource实例设置为App的属性;这将允许我们在App.js中的其他地方访问它。

正如我们之前所说,我们还希望传入 Firebase 消息库:

componentDidMount() {
   this.notifications = new NotificationResource(firebase.messaging());

每个 JavaScript 类都自动具有一个constructor方法,当创建一个实例时会调用该方法。这就是当我们说new NotificationResource()时会调用的方法。我们放在括号里的任何内容都作为参数传递给构造函数。

让我们跳回到NotificationResource.js并设置它:

export default class NotificationResource {
  constructor(messaging) {
    console.log(“Instantiated!”);
  }
}

如果您启动您的应用程序,您应该在App挂载时立即在控制台中看到"Instantiated!"

下一步是使用我们的messaging库来请求用户的权限发送通知:

export default class NotificationResource {
     constructor(messaging) {
       this.messaging = messaging;
 try {
 this.messaging
 .requestPermission()
 .then(res => {
 console.log('Permission granted');
 })
 .catch(err => {
 console.log('no access', err);
 });
 } catch(err) {
 console.log('No notification support.', err);
 }
} } 

我们用messaging库在App中做了与NotificationResource相同的事情,也就是将其保存为资源的属性,以便我们可以在其他地方使用它。然后,我们进入requestPermission函数。

如果我们回到我们的应用程序,我们会看到这个:

单击允许,您应该在控制台中看到权限已被授予。

如果您之前使用localhost:8080构建了个人项目并允许通知,您将不会看到此弹出窗口。您可以通过单击前面截图中 URL 左侧的图标,并将通知重置为询问,来忘记您之前的偏好设置。

现在我们有了开始跟踪所有用户设备的权限,我们将开始跟踪他们的所有设备令牌。

跟踪令牌

令牌是用户设备的唯一标识符。它帮助 Firebase 找出应该发送推送通知的位置。为了正确发送我们的通知,我们需要在我们的数据库中保留所有当前设备令牌的记录,并确保它是最新的。

我们可以通过 Firebase 的messaging库访问用户设备的令牌。特别有用的是两种方法:onTokenRefreshgetToken。两者的名称都相当不言自明,所以我们将直接进入实现:

 export default class NotificationResource {
     constructor(messaging) {
       this.messaging = messaging;
      try {
        this.messaging
          .requestPermission()
          .then(res => {
            console.log('Permission granted');
          })
         .catch(err => {
          console.log('no access', err);
          });
      } catch(err) {
        console.log('No notification support.', err);
      }
};
   this.messaging.getToken().then(res => {
 console.log(res);
 });
 }

当您的应用程序刷新时,您会看到一长串数字和字母。这是您设备的身份。我们需要将其保存到数据库中。

每当令牌更改时,firebase.messaging().onTokenRefresh会被调用。令牌可以被我们的应用程序删除,或者当用户清除浏览器数据时,此时会生成一个新的令牌。当这种情况发生时,我们需要覆盖数据库中的旧令牌。关键部分是覆盖;如果我们不删除旧令牌,我们最终会浪费 Firebase 的时间,发送到不存在的设备。

因此,我们有四个步骤要涵盖:

  1. 当令牌更改时,获取新令牌。

  2. 在数据库中查找现有令牌。

  3. 如果存在旧令牌,则替换它。

  4. 否则,将新令牌添加到数据库中。

在完成此清单之前,我们将不得不完成一堆中间任务,但让我们先用这个粗略的计划开始。

我们将向我们的NotificationResource添加四个函数:setupTokenRefreshsaveTokenToServerfindExistingTokenregisterToken。您可以看到最后两个函数与我们清单中的最后两个步骤相符。

让我们从setupTokenRefresh开始。我们将从构造函数中调用它,因为它将负责注册令牌更改的监听器:

   export default class NotificationResource {
     constructor(messaging) {
       this.messaging = messaging;
      try {
        this.messaging
          .requestPermission()
          .then(res => {
            console.log('Permission granted');
          })
         .catch(err => {
          console.log('no access', err);
          });
      } catch(err) {
        console.log('No notification support.', err);
      }
  } 
} 

这种模式应该在我们配置了 Firebase 的所有“on”监听器后是熟悉的。

接下来,我们将创建saveTokenToServer,并从setupTokenRefresh中调用它:

 setupTokenRefresh() {
   this.messaging.onTokenRefresh(() => {
     this.saveTokenToServer();
   });
 }

 saveTokenToServer() {
   // Get token
   // Look for existing token
   // If it exists, replace
   // Otherwise, create a new one
 }

好的,现在我们可以逐条浏览这些注释了。我们已经知道如何获取令牌:

saveTokenToServer() {
   this.messaging.getToken().then(res => {
     // Look for existing token
     // If it exists, replace
     // Otherwise, create a new one
   });
 }

接下来,查找现有令牌;我们目前无法访问保存在我们的数据库中的先前令牌(好吧,目前还没有,但以后会有)。

因此,我们需要在数据库中创建一个表来保存我们的令牌。我们将其称为fcmTokens以方便。它目前还不存在,但一旦我们向其发送一些数据,它就会存在。这就是 Firebase 数据的美妙之处--您可以向一个不存在的表发送数据,它将被创建并填充。

就像我们在App.js中对消息所做的那样,让我们在NotificationResource的构造函数中为/fcmTokens表添加一个值的监听器:

export default class NotificationResource {
  allTokens = [];
 tokensLoaded = false;

  constructor(messaging, database) {
    this.database = database;
    this.messaging = messaging;
         try {
        this.messaging
          .requestPermission()
          .then(res => {
            console.log('Permission granted');
          })
         .catch(err => {
          console.log('no access', err);
          });
      } catch(err) {
        console.log('No notification support.', err);
      }};
    this.setupTokenRefresh();
    this.database.ref('/fcmTokens').on('value', snapshot => {
 this.allTokens = snapshot.val();
 this.tokensLoaded = true;
 });
  }

您会注意到我们现在期望将数据库实例传递到构造函数中。让我们回到App.js来设置它:

componentDidMount() {
   this.notifications = new NotificationResource(
      firebase.messaging(),
      firebase.database()
    );

好的,这很完美。

如果您在数据库监听器中console.logsnapshot.val(),它将为 null,因为我们的/fcmTokens表中没有值。让我们开始注册一个:

saveTokenToServer() {
   this.messaging.getToken().then(res => {
     if (this.tokensLoaded) {
       const existingToken = this.findExistingToken(res);
       if (existingToken) {
         // Replace existing toke
       } else {
         // Create a new one
       }
     }
   });
 }

如果令牌已加载,我们可以检查是否存在现有令牌。如果令牌尚未加载,则不执行任何操作。这可能看起来有点奇怪,但我们希望确保不创建重复的值。

我们如何找到现有的令牌?嗯,在我们的构造函数中,我们将从数据库中加载令牌值的结果保存到this.allTokens中。我们只需循环遍历它们,看看它们是否与从getToken生成的res变量匹配即可:

findExistingToken(tokenToSave) {
   for (let tokenKey in this.allTokens) {
     const token = this.allTokens[tokenKey].token;
     if (token === tokenToSave) {
       return tokenKey;
     }
   }
   return false;
 }

这个方法的重要部分是tokenToSave将是一个字符串(之前看到的随机数字和字母的组合),而this.allTokens将是从数据库加载的令牌对象的集合,因此是this.allTokens[tokenObject].token的业务。

findExistingToken将返回与之匹配的令牌对象的键,或 false。从那里,我们可以更新现有的令牌对象,或者创建一个新的。当我们尝试更新令牌时,我们将看到为什么返回键(而不是对象本身)很重要。

将用户附加到令牌

在继续涵盖这两种情况之前,让我们退一步,思考一下我们的推送通知将如何工作,因为我们需要解决一个重要的警告。

当用户发送消息时,我们希望通知每个用户,除了创建消息的用户(那将是令人恼火的),因此我们需要一种方法来向数据库中的每个令牌发送通知,除了属于发送消息的用户的令牌。

我们将如何能够防止这种情况发生?我们如何将用户的消息与用户的令牌匹配起来?

好吧,我们可以在消息对象中访问用户 ID(也就是说,我们总是保存 ID 和消息内容)。如果我们对令牌做类似的操作,并保存用户 ID,这样我们就可以确定哪个用户属于哪个设备了。

这似乎是一个非常简单的解决方案,但这意味着我们需要在NotificationResource中访问当前用户的 ID。让我们立即做到这一点,然后回到编写和更新令牌。

在 NotificationResource 中更改用户

我们已经有一个处理用户更改的方法在App.js中——我们的老朋友onAuthStateChanged。让我们连接到那里,并使用它来调用NotificationResource中的一个方法:

componentDidMount() {
   this.notifications = new NotificationResource(firebase.messaging(), firebase.database());
  firebase.auth().onAuthStateChanged((user) => {
     if (user) {
       this.setState({ user });
       this.listenForMessages();
       this.notifications.changeUser(user);
     } else {
       this.props.history.push('/login')
     }
   });

然后,在NotificationResource中:

changeUser(user) {
   this.user = user;
 }

顺便说一下,这有助于解决令牌的另一个问题。如前所述,当生成新令牌时会调用onTokenRefresh,要么是因为用户删除了浏览器数据,要么是因为 Web 应用程序删除了先前的令牌。但是,如果我们将用户 ID 与令牌一起保存,我们需要确保在用户更改时更新该 ID,因此我们将不得不在用户更改时调用我们的saveTokenToServer方法:

changeUser(user) {
   this.user = user;
   this.saveTokenToServer();
 }

好的,现在我们可以回到saveTokenToServer中的if-else语句,并开始保存一些令牌。

创建一个新令牌

让我们从涵盖后一种情况开始,创建一个新的令牌。我们将创建一个名为registerToken的新方法,传入getToken调用的结果:

saveTokenToServer() {
   this.messaging.getToken().then(res => {
     if (this.tokensLoaded) {
       const existingToken = this.findExistingToken(res);
       if (existingToken) {
         // Replace existing token
       } else {
         this.registerToken(res);
       }
     }
   });
 }

然后,我们的新方法:

  registerToken(token) {
    firebase
      .database()
      .ref('fcmTokens/')
      .push({
        token: token,
        user_id: this.user.uid
      });
  }

我们保存令牌,以及用户 ID。完美。

更新现有令牌

我们将类似的方法用于更新令牌,但这次我们需要访问数据库中的现有令牌。

在这里添加一个console.log以进行测试:

saveTokenToServer() {
   this.messaging.getToken().then(res => {
     if (this.tokensLoaded) {
       const existingToken = this.findExistingToken(res);
       if (existingToken) {
         console.log(existingToken);
       } else {
         this.registerToken(res);
       }
     }
   });
 }

然后,尝试使用不同的用户登录和退出应用程序。您应该每次看到相同的existingToken键:

我们可以使用这个来获取我们数据库中fcmToken表中的现有条目,并更新它:

saveTokenToServer() {
  this.messaging.getToken().then(res => {
    if (this.tokensLoaded) {
      const existingToken = this.findExistingToken(res);
      if (existingToken) {
        firebase
 .database()
 .ref(`/fcmTokens/${existingToken}`)
 .set({
 token: res,
 user_id: this.user.uid
 });
      } else {
        this.registerToken(res);
      }
    }
  });
}

好了,这是很多内容。让我们再次确认这是否正常工作。转到console.firebase.com并检查数据库选项卡。尝试使用两个不同的用户登录和退出应用程序。您应该看到匹配的令牌条目每次更新其用户 ID。然后,尝试在另一台设备上登录(在进行另一个 firebase deploy 之后),然后看到另一个令牌出现。神奇!

现在,我们为使用我们的应用程序的每个设备都有一个令牌表,以及上次与该设备关联的用户的 ID。我们现在准备进入推送通知的最佳部分--实际发送它们。

这是最终的NotificationResource.js

export default class NotificationResource {
  allTokens = [];
  tokensLoaded = false;
  user = null;

  constructor(messaging, database) {
    this.messaging = messaging;
    this.database = database;
          try {
        this.messaging
          .requestPermission()
          .then(res => {
            console.log('Permission granted');
          })
         .catch(err => {
          console.log('no access', err);
          });
      } catch(err) {
        console.log('No notification support.', err);
      };
    this.setupTokenRefresh();
    this.database.ref('/fcmTokens').on('value', snapshot => {
      this.allTokens = snapshot.val();
      this.tokensLoaded = true;
    });
  }

  setupTokenRefresh() {
    this.messaging.onTokenRefresh(() => {
      this.saveTokenToServer();
    });
  }

  saveTokenToServer() {
    this.messaging.getToken().then(res => {
      if (this.tokensLoaded) {
        const existingToken = this.findExistingToken(res);
        if (existingToken) {
          firebase
            .database()
            .ref(`/fcmTokens/${existingToken}`)
            .set({
              token: res,
              user_id: this.user.uid
            });
        } else {
          this.registerToken(res);
        }
      }
    });
  }

  registerToken(token) {
    firebase
      .database()
      .ref('fcmTokens/')
      .push({
        token: token,
        user_id: this.user.uid
      });
  }

  findExistingToken(tokenToSave) {
    for (let tokenKey in this.allTokens) {
      const token = this.allTokens[tokenKey].token;
      if (token === tokenToSave) {
        return tokenKey;
      }
    }
    return false;
  }

  changeUser(user) {
    this.user = user;
    this.saveTokenToServer();
  }
}

发送推送通知

回到本书的开头,当我们初始化 Firebase 时,我们勾选了一个 Functions 选项。这在我们的根目录中创建了一个名为functions的文件夹,到目前为止我们已经忽略了它(如果你没有这个文件夹,你可以再次运行firebase init,并确保你在第一个问题上都勾选了 Functions 和 Hosting。参考 Firebase 章节了解更多信息)。

functions文件夹允许我们使用 Firebase 云函数。这是 Google 如何定义它们的方式:

“Cloud Functions 允许开发人员访问 Firebase 和 Google Cloud 事件,以及可扩展的计算能力来运行响应这些事件的代码。”

这是最简单的定义--在事件发生时运行的代码,超出我们的应用程序之外。我们从我们的应用程序的任何特定实例中提取一些不属于任何特定实例的功能(因为它涉及我们应用程序的所有实例)到云端,并让 Firebase 自动运行它。

让我们打开functions /index.js并开始工作。

编写我们的云函数

首先,我们可以初始化我们的应用程序,如下所示:

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);

云函数=响应事件的代码,那么我们的事件是什么?

我们希望在创建新消息时通知用户。因此,事件是一个新消息,或者更具体地说,是在我们数据库的消息表中创建新条目时。

我们将定义我们的index.js的导出为一个名为sendNotifications的函数,该函数定义了/messagesonWrite事件的监听器:

exports.sendNotifications = functions.database
  .ref('/messages/{messageId}')
  .onWrite(event => {});

本节中的其他所有内容将在事件监听器中进行。

首先,我们从事件中获取快照:

 const snapshot = event.data;

现在,我们不支持编辑消息;但将来可能会支持。在这种情况下,我们不希望推送通知,因此如果onWrite由更新触发(快照具有先前值),我们将提前返回:

const snapshot = event.data;
if (snapshot.previous.val()) {
   return;
 }

然后,我们将构建我们的通知。我们定义了一个带有嵌套通知对象的对象,其中包含titlebodyiconclick_action

const payload = {
   notification: {
     title: `${snapshot.val().author}`,
     body: `${snapshot.val().msg}`,
     icon: 'assets/icon.png',
     click_action: `https://${functions.config().firebase.authDomain}`
   }
 };

title来自与消息关联的用户电子邮件。body是消息本身。这两者都包裹在模板字符串中,以确保它们作为字符串输出。这只是一个安全措施!

然后,我们使用我们的应用图标作为通知的图标。请注意路径--图标实际上并不存在于我们的functions文件夹中,但由于它将部署到我们应用的根目录(在build文件夹中),我们可以引用它。

最后,我们的click_action应该将用户带到应用程序。我们通过我们的配置获取域 URL。

下一步是向相关设备发送有效负载。准备好,这将是一大块代码。

发送到令牌

让我们写出我们需要采取的步骤:

  1. 获取我们数据库中所有令牌的列表。

  2. 筛选该列表,仅保留不属于发送消息的用户的令牌。

  3. 向设备发送通知。

  4. 如果由于无效或未注册的令牌而导致任何设备无法接收通知,则从数据库中删除它们的令牌。

最后一步是定期从我们的数据库中删除无效令牌,以保持清洁。

好的,听起来很有趣。请记住,这一切都在onWrite的事件监听器中。以下是第一步:

return admin
      .database()
      .ref('fcmTokens')
      .once('value')
      .then(allTokens => {
        if (allTokens.val()) {

        }
      });

这使用数据库的.once方法来一次性查看令牌表。从那里,如果我们实际上保存了一些令牌,我们就可以继续进行。

为了过滤我们的结果,我们将执行一个与我们的findExistingToken方法非常相似的循环:

.then(allTokens => {
  if (allTokens.val()) {
    const tokens = [];
 for (let fcmTokenKey in allTokens.val()) {
 const fcmToken = allTokens.val()[fcmTokenKey];
 if (fcmToken.user_id !== snapshot.val().user_id) {
 tokens.push(fcmToken.token);
 }
 }
  }
});

我们循环遍历所有令牌,如果user_id与消息的user_id不匹配,我们将其推送到有效令牌数组中。

到了第三步了;向每个设备发送通知,如下所示:

.then(allTokens => {
  if (allTokens.val()) {
    const tokens = [];
    for (let fcmTokenKey in allTokens.val()) {
      const fcmToken = allTokens.val()[fcmTokenKey];
      if (fcmToken.user_id !== snapshot.val().user_id) {
        tokens.push(fcmToken.token);
      }
    }
    if (tokens.length > 0) {
 return admin
 .messaging()
 .sendToDevice(tokens, payload)
 .then(response => {});
 }
  }
});

这很简单。我们向sendToDevice传递一个令牌数组和我们的有效负载对象。

最后,让我们进行清理:

if (tokens.length > 0) {
  return admin
    .messaging()
    .sendToDevice(tokens, payload)
    .then(response => {
      const tokensToRemove = [];
 response.results.forEach((result, index) => {
 const error = result.error;
 if (error) {
 console.error(
 'Failure sending notification to',
 tokens[index],
 error
 );
 if (
 error.code === 'messaging/invalid-registration-token' ||
 error.code ===
 'messaging/registration-token-not-registered'
 ) {
 tokensToRemove.push(
 allTokens.ref.child(tokens[index]).remove()
 );
 }
 }
 });
 return Promise.all(tokensToRemove);
 });
}

这段代码应该很容易查看,除了可能会返回Promise.all。原因是在每个令牌条目上调用remove()会返回一个 promise,我们只需返回所有这些 promise 的解析。

这是最终文件:

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);

exports.sendNotifications = functions.database
  .ref('/messages/{messageId}')
  .onWrite(event => {
    const snapshot = event.data;
    if (snapshot.previous.val()) {
      return;
    }
    const payload = {
      notification: {
        title: `${snapshot.val().author}`,
        body: `${snapshot.val().msg}`,
        icon: 'assets/icon.png',
        click_action: `https://${functions.config().firebase.authDomain}`
      }
    };
    return admin
      .database()
      .ref('fcmTokens')
      .once('value')
      .then(allTokens => {
        if (allTokens.val()) {
          const tokens = [];
          for (let fcmTokenKey in allTokens.val()) {
            const fcmToken = allTokens.val()[fcmTokenKey];
            if (fcmToken.user_id !== snapshot.val().user_id) {
              tokens.push(fcmToken.token);
            }
          }
          if (tokens.length > 0) {
            return admin
              .messaging()
              .sendToDevice(tokens, payload)
              .then(response => {
                const tokensToRemove = [];
                response.results.forEach((result, index) => {
                  const error = result.error;
                  if (error) {
                    console.error(
                      'Failure sending notification to',
                      tokens[index],
                      error
                    );
                    if (
                      error.code === 'messaging/invalid-registration-token' ||
                      error.code ===
                        'messaging/registration-token-not-registered'
                    ) {
                      tokensToRemove.push(
                        allTokens.ref.child(tokens[index]).remove()
                      );
                    }
                  }
                });
                return Promise.all(tokensToRemove);
              });
          }
        }
      });
  });

测试我们的推送通知

运行**yarn deploy**,然后我们可以测试我们的推送通知。

测试它的最简单方法是简单地打开我们部署的应用程序的一个标签,然后在隐身标签中打开另一个版本(使用 Chrome)。用不同的用户登录到每个标签,当你发送一条消息时,你应该看到以下内容:

请注意,你不能同时拥有两个标签;你需要打开两个标签,但切换到另一个标签,否则通知不会显示。

调试推送通知

如果你遇到任何问题,你可以尝试以下步骤。

检查云函数日志

登录到console.firebase.com后,在“函数”选项卡下,有一个显示每个函数执行的日志选项卡。任何错误都会显示在这里,还有我们配置的任何旧令牌删除。检查以确保 A)当你发送一条消息时函数实际上正在运行,B)没有干扰发送的任何错误。

检查服务工作者

正如我们之前所说,服务工作者应该在其大小的任何字节差异以及在 Chrome DevTools | Application 中检查“重新加载时更新”后更新。然而,即使有了这些步骤,我发现服务工作者经常在重新部署时实际上并没有更新。如果你遇到问题,请在 DevTools 的 Application | Service Workers 标签下的每个实例旁边点击注销。然后,点击每个服务工作者文件的名称,以确保代码与你的build文件夹中的代码匹配。

检查令牌

确保令牌在数据库中保存和更新正确。不应该有不同用户 ID 的重复。

总结

推送通知很棘手。在本章中,我们不得不写很多代码,但很少有基准可以在其中检查。如果你遇到问题,请确保你的所有代码与示例匹配。

一旦您的通知功能正常工作,我们将填补网络应用和本地应用之间的重要差距。现在,是时候迈向本地应用的世界,让用户可以安装我们的应用程序了。

第九章:使用清单使我们的应用程序可安装

我们现在开始走向渐进式 Web 应用程序领域。从现在开始,我们的唯一重点将是将我们现有的应用程序变得更快、更时尚和更用户友好。

渐进式 Web 应用程序的一个重要优势是弥合了 Web 应用程序(在浏览器中查看)和本地应用程序(作为独立应用程序启动)之间的差距。接下来的几章,特别是将专注于使我们的 Web 应用程序更像本地应用程序,而不失去 Web 应用程序的所有优势。

Web 应用程序相对于本地应用程序的第一个主要优势是没有安装障碍。如果你创建一个本地应用程序,你需要说服用户在甚至使用你的应用程序之前,投入宝贵的存储空间和带宽。他们必须愿意忍受下载和安装过程。然后他们必须保留它,即使他们并不经常使用它。

Web 应用程序没有这样的障碍。你几乎可以立即使用它们,而且最复杂的 Web 应用程序具有可以与本地应用程序媲美的功能。它们的缺点是什么?嗯,用户必须先导航到他们的浏览器,然后再导航到网页才能使用它。他们没有漂亮整洁的应用程序存在的提醒,从他们手机的主屏幕上盯着他们。

什么是双赢的最佳选择?它将是一个允许用户在安装到他们的设备之前先试用的应用程序,但一旦安装后,它会像本地应用程序一样运行,并在设备的主屏幕上显示图标。

我们如何实现这一点?我们可以通过一个 Web 应用程序清单来实现。

在本章中,我们将涵盖以下内容:

  • 什么是 Web 应用程序清单?

  • 如何使我们的应用程序可以在 Android 上安装

  • 如何使我们的应用程序可以在 iOS 上安装

  • 使用 Web 应用程序安装横幅

什么是应用程序清单?

在第二章,使用 Webpack 入门,当我们设置我们的 Webpack 构建配置时,我们确保我们的构建过程生成了一个资产清单,文件名为asset-manifest.json

这个文件包含了我们的应用程序使用的 JavaScript 文件列表。如果我们愿意,我们可以配置它来列出我们使用的 CSS 和图像文件。

这个资产清单让我们了解了清单的用途--描述应用程序的某个部分。我们的 Web 应用清单类似,但简单地描述了我们的应用程序从更高层面上的全部内容,以一种类似于应用商店对本地应用的描述的方式。

这就是它的外观,随着我们构建文件,我们将更深入地了解,但 Web 应用清单的真正魔力在于它的功能。

在某些浏览器上(本章后面会详细介绍),如果您的 Web 应用包括一个合适的 Web 应用清单,用户可以选择将网页保存到主屏幕上,它会像一个常规应用程序一样出现,并带有自己的启动图标。当他们点击图标时,它将以闪屏启动,并且(尽管是从浏览器运行)以全屏模式运行,因此看起来和感觉像一个常规应用程序。

浏览器支持

这就是 Web 应用清单的缺点--它是一种新技术。因此,很少有浏览器实际支持它。截至目前,只有较新版本的安卓 Webview 和 Chrome for Android 具有完全支持。

我预测支持很快会到来,适用于所有新版浏览器,但目前我们该怎么办呢?

简而言之,有办法在旧版浏览器上激活类似的功能。在本章中,我们将介绍如何使用 Web 应用清单(适用于新版浏览器的用户,并为未来做准备)以及 iOS 设备的polyfill

如果您有兴趣覆盖其他设备,可以使用 polyfills,比如ManUpgithub.com/boyofgreen/manUp.js/)。这些 polyfills 的作用是将不同设备的各种解决方法编译成一个清单文件。

然而,本书是关于 Web 应用的未来,所以我们将向您展示一切您需要为 Web 应用清单的世界做准备。

使我们的应用可安装-安卓

谷歌是 PWA 的最大支持者之一,因此他们的 Chrome 浏览器和安卓操作系统对 Web 应用清单最为友好。

让我们通过创建一个清单的过程,以使其与最新版本的 Chrome 兼容。在本章后面,我们将以更手动的方式进行相同的过程,以支持 iOS。

清单属性

让我们开始吧!在您的public/文件夹中,创建一个名为manifest.json的文件,然后添加一个空对象。以下每个都将是该对象的键值对。我们将快速浏览一下每个可用属性:

  • name:您的应用程序名称。简单!:
"name": "Chatastrophe",
  • short_name:您的应用程序名称的可读版本。这是在全名无法完全显示时使用,比如在用户的主屏幕上。如果您的应用程序名称是“为什么 PWA 对每个人都很棒”,您可以将其缩短为“PWAs R Great”或其他内容:
“short_name”: “Chatastrophe”,
  • icons:用户设备使用的图标列表。我们将只使用我们当前的徽标,这恰好是图标所需的最大尺寸。

Google 推荐以下一组图标:

  • 128x128 作为基本图标大小

  • 152x152 适用于 Apple 设备

  • 144x144 适用于 Microsoft 设备

  • 192x192 适用于 Chrome

  • 256x256、384x384 和 512x512 适用于不同的设备尺寸

最后两个包含在资产包中。我们需要我们的设计师为我们的生产版本创建其余部分,但目前还不需要:

"icons": [
  {
    "src":"/assets/icon.png",
    "sizes": "192x192",
    "type": "image/png"
  },
  { 
    "src": "/assets/icon-256.png", 
    "sizes": "256x256", 
    "type": "image/png" 
  }, 
  { 
    "src": "/assets/icon-384.png", 
    "sizes": "384x384", 
    "type": "image/png" 
  }, 
  { 
    "src": "/assets/icon-512.png", 
    "sizes": "512x512", 
    "type": "image/png" 
  }
],
  • start_url:启动 URL 用于分析目的,以便您可以看到有多少用户通过安装的 PWA 访问您的 Web 应用程序。这是可选的,但不会有害。
"start_url": "/?utm_source=homescreen",
  • background_color:背景颜色用于启动我们的应用程序时显示的闪屏的颜色。在这里,我们将其设置为一个漂亮的橙红色:
"background_color": "#e05a47",
  • theme_color:这类似于background_color,但在您的应用程序处于活动状态时,它会为 Android 上的工具栏设置样式。一个不错的点缀:
"theme_color": "#e05a47",
  • display:正如我们之前所说,PWA 可以像本机应用程序一样启动,即浏览器栏被隐藏;这就是这个属性的作用。如果您认为让用户能够看到地址栏更好,可以将其设置为“browser”:
"display": "standalone"

其他属性

还有一些属性需要您了解我们的应用程序:

  • related_applications:您可以提供与您的 Web 应用程序相关的本机应用程序的列表,并附带下载的 URL;将其与prefer_related_applications配对使用。

  • prefer_related_applications:一个默认值为 false 的布尔值。如果为 true,则用户将收到有关相关应用程序的通知。

  • scope:一个字符串,比如/app。如果用户导航到范围之外的页面,应用程序将返回到浏览器中常规网页的外观。

  • description:您的应用程序的描述;不是强制性的。

  • dir:类型的方向。

  • langshort_name的语言。与dir配对使用,可用于确保从右到左的语言正确显示。

链接我们的清单

就是这样!最后,您的manifest.json应该是这样的:

{
  "name": "Chatastrophe",
  "short_name": "Chatastrophe",
  "icons": [
    {
      "src":"/assets/icon.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    { 
      "src": "/assets/icon-256.png", 
      "sizes": "256x256", 
      "type": "image/png" 
    }, 
    { 
      "src": "/assets/icon-384.png", 
      "sizes": "384x384", 
      "type": "image/png" 
    }, 
    { 
      "src": "/assets/icon-512.png", 
      "sizes": "512x512", 
      "type": "image/png" 
    }
  ],
  "start_url": "/?utm_source=homescreen",
  "background_color": "#e05a47",
  "theme_color": "#e05a47",
  "display": "standalone"
}

然后,您可以像这样从您的index.html中链接它:

<link rel="manifest" href="/manifest.json">

确保您也将其复制到您的build文件夹中。

如果一切顺利,并且您使用的是最新版本的 Chrome,您可以通过转到 Chrome Dev Tools 中的“应用程序”选项卡来检查是否正常工作。确保首先重新启动服务器。您应该会看到以下内容:

现在来测试一下吧!让我们再次运行我们的部署过程,使用yarn deploy。完成后,转到您的 Android 设备上的应用程序。为了触发 Web 应用程序安装横幅,您需要访问该站点两次,每次访问之间间隔五分钟:

如果您没有看到安装横幅,您也可以通过转到选项下拉菜单并选择“添加到主屏幕”来安装它。

一旦您点击“添加到主屏幕”,您应该会看到它出现:

然后,当我们启动时,我们会得到一个漂亮的启动画面:

这很可爱。

这就是为 Android 制作可安装的 PWA 的要点。这是一个非常简洁流畅的过程,这要感谢 Google 对 PWA 的倡导,但我们的许多用户无疑会使用 iPhone,因此我们也必须确保我们也支持他们。

使我们的应用可安装- iOS

截至撰写本文时,苹果尚未支持渐进式 Web 应用程序。关于这一点有许多理论(他们的盈利能力强大的 App Store 生态系统,与谷歌的竞争,缺乏控制),但这意味着使我们的应用可安装的过程要更加手动化。

让我们明确一点-截至目前,PWA 的最佳体验将是针对使用最新版本 Chrome 的 Android 设备用户。

然而,PWA 也是关于渐进式增强的,这是我们将在后面的章节中更深入地介绍的概念。渐进式增强意味着我们为每个用户在其设备上提供最佳的体验;如果他们可以支持所有新的功能,那很好,否则,我们会尽力利用他们正在使用的工具。

因此,让我们来看看如何使我们的 UX 对于想要将我们的应用保存到主屏幕的 iPhone 用户来说是愉快的。

我们将使用大量的<meta>标签来告诉浏览器我们的应用是可安装的。让我们从图标开始:

<link rel="apple-touch-icon" href="/assets/icon.png">

将以下内容添加到public/index.html(在本节的其余部分中,将所有的meta标签分组放在link标签之上)。这定义了用户主屏幕上的图标。

接下来,我们为页面添加一个标题,这将作为主屏幕上应用程序的名称。在您的link标签之后添加这个:

<title>Chatastrophe</title>

然后,我们需要让 iOS 知道这是一个 Web 应用程序。您可以使用以下meta标签来实现:

<meta name="apple-mobile-web-app-capable" content="yes">

就像我们在 Android 部分中使用theme_color一样,我们希望样式化状态栏的外观。默认值是黑色,看起来像这样:

另一个选项是 black-translucent,它并不是非常黑,主要是半透明的:

使用以下内容添加:

<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

我们要做的最后一件事是设计启动画面;在应用程序启动时出现的内容。

在 iOS 上进行此操作有点手动--您需要提供一个静态图像。

为了完全支持,您需要为每个 iOS 屏幕尺寸提供单独的启动图像,从 iPad 到最小的 iPhone。如果您想看到多个启动图像和图标的绝佳示例,请查看gist 链接。这里包括了该 gist 中的启动图像链接:

    <!-- iPad retina portrait startup image -->
    <link href="https://placehold.it/1536x2008"
          media="(device-width: 768px) and (device-height: 1024px)
                 and (-webkit-device-pixel-ratio: 2)
                 and (orientation: portrait)"
          rel="apple-touch-startup-image">

    <!-- iPad retina landscape startup image -->
    <link href="https://placehold.it/1496x2048"
          media="(device-width: 768px) and (device-height: 1024px)
                 and (-webkit-device-pixel-ratio: 2)
                 and (orientation: landscape)"
          rel="apple-touch-startup-image">

    <!-- iPad non-retina portrait startup image -->
    <link href="https://placehold.it/768x1004"
          media="(device-width: 768px) and (device-height: 1024px)
                 and (-webkit-device-pixel-ratio: 1)
                 and (orientation: portrait)"
          rel="apple-touch-startup-image">

    <!-- iPad non-retina landscape startup image -->
    <link href="https://placehold.it/748x1024"
          media="(device-width: 768px) and (device-height: 1024px)
                 and (-webkit-device-pixel-ratio: 1)
                 and (orientation: landscape)"
          rel="apple-touch-startup-image">

    <!-- iPhone 6 Plus portrait startup image -->
    <link href="https://placehold.it/1242x2148"
          media="(device-width: 414px) and (device-height: 736px)
                 and (-webkit-device-pixel-ratio: 3)
                 and (orientation: portrait)"
          rel="apple-touch-startup-image">

    <!-- iPhone 6 Plus landscape startup image -->
    <link href="https://placehold.it/1182x2208"
          media="(device-width: 414px) and (device-height: 736px)
                 and (-webkit-device-pixel-ratio: 3)
                 and (orientation: landscape)"
          rel="apple-touch-startup-image">

    <!-- iPhone 6 startup image -->
    <link href="https://placehold.it/750x1294"
          media="(device-width: 375px) and (device-height: 667px)
                 and (-webkit-device-pixel-ratio: 2)"
          rel="apple-touch-startup-image">

    <!-- iPhone 5 startup image -->
    <link href="https://placehold.it/640x1096"
          media="(device-width: 320px) and (device-height: 568px)
                 and (-webkit-device-pixel-ratio: 2)"
          rel="apple-touch-startup-image">

    <!-- iPhone < 5 retina startup image -->
    <link href="https://placehold.it/640x920"
          media="(device-width: 320px) and (device-height: 480px)
                 and (-webkit-device-pixel-ratio: 2)"
          rel="apple-touch-startup-image">

    <!-- iPhone < 5 non-retina startup image -->
    <link href="https://placehold.it/320x460"
          media="(device-width: 320px) and (device-height: 480px)
                 and (-webkit-device-pixel-ratio: 1)"
          rel="apple-touch-startup-image">

您可能注意到这些链接不包括任何 iPhone 6 Plus 之后的 iPhone。在撰写本文时,iOS 9 对启动图像的支持有问题,iOS 10 则不支持。虽然这不会影响您的应用程序的用户体验(启动画面本来也只能看一秒钟),但这表明了苹果对 PWA 的支持不完全。希望这在不久的将来会发生改变。

总的来说,将您的应用程序制作成 iOS 可安装的 Web 应用程序并不像manifest.json那样花哨或直观,但相当简单。使用yarn deploy重新部署您的应用程序,然后在 iPhone 上的 Safari 中打开网页。然后,点击分享并添加到主屏幕:

它应该会出现在您的主屏幕上,就像普通的应用程序一样,并且在启动时会出现如下:

这非常漂亮。

最终的index.html应该是这样的:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta charset="utf-8">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
    <link rel="shortcut icon" href="assets/favicon.ico" type="image/x-icon">
    <link rel="manifest" href="/manifest.json">
    <link rel="apple-touch-icon" href="/assets/icon.png">
    <title>Chatastrophe</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="/secrets.js"></script>
    <script src="https://www.gstatic.com/firebasejs/4.3.0/firebase.js"></script>
    <script>
      // Initialize Firebase
      var config = {
        apiKey: window.apiKey,
        authDomain: "chatastrophe-draft.firebaseapp.com",
        databaseURL: "https://chatastrophe-draft.firebaseio.com",
        projectId: "chatastrophe-draft",
        storageBucket: "chatastrophe-draft.appspot.com",
        messagingSenderId: window.messagingSenderId
      };
      window.firebase = firebase;
      firebase.initializeApp(config);
    </script>
  </body>
</html>

应用安装横幅和您

能够添加到主屏幕是一个很棒的功能,但是我们的用户如何知道我们的应用程序是可安装的,特别是如果他们从未听说过 PWA 呢?

进入Web App Install Banner。 以前,应用安装横幅是一种方便的方式来宣传您的原生应用程序-请参阅 Flipboard 的以下示例:

然而,现在,谷歌正在带头推动 PWA 安装横幅,提示用户添加到主屏幕。 请参阅 Chrome Dev Summit 网站的以下示例:

该横幅具有使用户意识到您的网站是 PWA 的优势,并且对于那些不熟悉可安装的 Web 应用程序的用户,提供了进入 PWA 世界的入口点。

当您点击上一个屏幕截图中的“添加”时,您的主屏幕上会显示如下内容:

然而,就像本节中的所有内容一样,这是一项新技术。 目前,仅在安卓上的 Chrome 和 Opera for Android 上存在牢固的支持。 此外,两个浏览器上安装横幅将出现的具体标准也是牢固的:

  • 该应用程序必须具有 Web 应用程序清单

  • 该应用程序必须通过 HTTPS 提供

  • 该应用程序必须使用服务工作者

  • 该应用程序必须被访问两次,访问之间至少间隔五分钟

我们已经涵盖了前三个条件(Firebase 应用程序会自动通过 HTTPS 部署)。 最后一个标准是尽量减少用户的烦恼。

延迟应用程序安装横幅

以下部分仅适用于您拥有安卓设备进行测试,并且安装了最新版本的 Chrome 或 Opera for Android。 您还需要为您的安卓设备设置远程调试,按照以下指南进行操作:developers.google.com/web/tools/chrome-devtools/remote-debugging/

我们之前提到的 PWA 的优势之一是用户在决定是否安装之前有机会与您的应用程序进行交互。 如果 Web 应用程序安装横幅显示得太早(在用户与您的应用程序进行积极交互之前),可能会干扰该过程。

在本节中,我们将通过延迟 Web 应用程序安装横幅事件来解决这个问题,直到用户与我们的应用程序进行积极交互。

我们将向我们的App.js添加一个事件侦听器,以便在横幅显示事件准备好触发时进行监听。 然后,我们将拦截该事件,并在用户发送消息时保存它。

监听事件

Chrome 在显示 Web 应用程序安装横幅之前直接发出beforeinstallprompt事件。这就是我们要监听的事件。像我们的其他 Firebase 事件监听器一样,让我们将其添加到我们的App.jscomponentDidMount中。

我们将创建一个名为listenForInstallBanner的方法,然后从componentDidMount中调用该方法:

componentDidMount() {
  firebase.auth().onAuthStateChanged(user => {
    if (user) {
      this.setState({ user });
    } else {
      this.props.history.push('/login');
    }
  });
  firebase
    .database()
    .ref('/messages')
    .on('value', snapshot => {
      this.onMessage(snapshot);
      if (!this.state.messagesLoaded) {
        this.setState({ messagesLoaded: true });
      }
    });
  this.listenForInstallBanner();
}
listenForInstallBanner = () => {

};

listenForInstallBanner中,我们将做两件事:

  1. 为事件注册一个监听器。

  2. 当该事件触发时,取消它并将其存储以便以后使用。

将其存储以便以后我们可以在任何时候触发它,也就是当用户发送他们的第一条消息时。

代码如下:

listenForInstallBanner = () => {
  window.addEventListener('beforeinstallprompt', (e) => {
    console.log('beforeinstallprompt Event fired');
    e.preventDefault();
    // Stash the event so it can be triggered later.
    this.deferredPrompt = e;
  });
};

我们将在App实例上存储我们的deferredPrompt,以便以后可以获取它。我们将在handleSubmitMessage方法中执行这个操作:

handleSubmitMessage = msg => {
  const data = {
    msg,
    author: this.state.user.email,
    user_id: this.state.user.uid,
    timestamp: Date.now()
  };
  firebase
    .database()
    .ref('messages/')
    .push(data);
  if (this.deferredPrompt) {
 this.deferredPrompt.prompt();
 this.deferredPrompt.userChoice.then(choice => {
 console.log(choice);
 });
 this.deferredPrompt = null;
 }
};

在我们提交消息后,我们触发我们保存的事件。然后,我们记录用户的选择(无论他们是否实际安装了应用程序,我们也可以将其发送到将来选择使用的任何分析工具)。最后,我们删除事件。

好的,让我们测试一下!

将您的 Android 设备连接到计算机上,并在 DevTools 上打开远程调试。我们首先必须部署我们的应用程序,所以点击yarn deploy并等待它完成。然后,在您的设备上打开应用程序并输入一条消息;您应该会看到应用程序安装横幅弹出。

如果没有出现,请检查您的代码,或转到 DevTools 的应用程序选项卡,然后单击“添加到主屏幕”按钮。这应该会触发beforeinstallprompt事件。

总结

Web 应用程序安装横幅仍然是一项新技术,标准仍在不断变化中。有关最新信息,请参阅 Google 关于 Web 应用程序安装横幅的页面-developers.google.com/web/fundamentals/engage-and-retain/app-install-banners/。也就是说,我希望本章对横幅的可能性和当前技术状态有所帮助。

现在我们已经使我们的应用程序更大更好,是时候精简并专注于性能了。下一章见!

第十章:应用外壳

我们上一章讨论了添加主屏幕安装和推送通知,这两者都旨在通过添加功能来改善用户体验,但正如我们在书的开头描述的用户故事一样,这个应用最重要的特性之一是包容性;它是一个面向所有人的聊天应用。

从 Web 应用的角度来看,我们可以更好地重新表述为“任何连接,任何速度”。Web 应用性能的最大障碍是网络请求:在慢速连接下加载数据需要多长时间。

开发人员可能会忽视性能,仅仅因为我们通常在城市中心的空调建筑内快速连接上测试我们的网站。然而,对于像 Chatastrophe 这样的全球应用,我们必须考虑在不发达国家的用户、农村地区的用户以及只有我们十分之一网络速度的用户。我们如何让应用为他们工作?

本节重点讨论性能;具体来说,它是关于优化我们的应用,使其在最恶劣的条件下也能表现良好。如果我们做得好,我们将拥有一个强大的用户体验,适用于任何速度(或缺乏速度)。

在本章中,我们将涵盖以下内容:

  • 渐进增强是什么

  • 性能的 RAIL 模型

  • 使用 Chrome DevTools 来衡量性能

  • 将我们的应用外壳从 React 中移出

什么是渐进增强?

渐进增强是一个简单的想法,但影响深远。它源于提供出色用户体验的愿望,同时又需要性能。如果我们所有的用户都有完美、超快的连接,我们可以构建一个令人难以置信的应用。然而,如果我们所有的用户都有慢速连接,我们必须满足于更简化的体验。

渐进增强说为什么不两者兼得?为什么不两者都有?

我们的受众包括快速连接和慢速连接。我们应该为两者提供服务,并适当地为每个人提供服务,这意味着为最佳连接提供最佳体验,为较差的连接提供更简化(但仍然很棒)的体验,以及介于两者之间的一切。

简而言之,渐进增强意味着随着用户的连接改善,我们的应用会逐渐变得更好,但它始终是有用的和可用的。因此,我们的应用是一种适应连接的应用

您可以想象这正是现代网页加载的方式。首先,我们加载 HTML——内容的基本、丑陋的骨架。然后,我们添加 CSS 使其变得漂亮。最后,我们加载 JavaScript,其中包含使其生动的所有好东西。换句话说,随着网站的加载,我们的应用程序会逐渐变得更好。

渐进增强范式敦促我们重新组织网站的内容,以便重要的内容尽快加载,然后再加载其他功能。因此,如果您使用的是超快速的连接,您会立即得到所有内容;否则,您只会得到使用应用程序所需的内容,其他内容稍后再加载。

因此,在本章中,我们将优化我们的应用程序,尽快启动。我们还将介绍许多工具,您可以使用这些工具来关注性能,并不断增强性能,但是我们如何衡量性能呢?我们可以使用哪些指标来确保我们提供了一个快速的应用程序?RAIL 模型应运而生。

RAIL 模型

RAIL 是谷歌所称的“以用户为中心的性能模型”。这是一组衡量我们应用性能的指南。我们应该尽量避免偏离这些建议。

我们将使用 RAIL 的原则来加快我们的应用程序,并确保它对所有用户都表现良好。您可以在developers.google.com/web/fundamentals/performance/rail上阅读谷歌关于 RAIL 的完整文档。

RAIL 概述了应用程序生命周期中的四个特定时期。它们如下:

  • 响应

  • 动画

  • 空闲

  • 加载

就我个人而言,我认为以相反的顺序来思考它们会更容易(因为这更符合它们的实际顺序),但那样会拼成 LIAR,所以我们可以理解为什么谷歌会回避这一点。无论如何,在这里我们将以这种方式来介绍它们。

加载

首先,您的应用程序加载(让光明降临!)。

RAIL 表示,最佳加载时间为一秒(或更短)。这并不意味着您的整个应用程序在一秒内加载完成;而是意味着用户在一秒内看到内容。他们会对当前任务(加载页面)有一定的感知,而不是盯着一片空白的白屏。正如我们将看到的,这并不容易做到!

空闲

一旦您的应用程序加载完成,它就是空闲的(在操作之间也会是空闲的),直到用户执行操作。

RAIL 认为,与其让你的应用程序闲置不用(懒惰!),我们应该利用这段时间继续加载应用程序的部分。

我们将在下一章中更详细地看到这一点,但如果我们的初始加载只是我们应用程序的基本版本,我们会在空闲时间加载其他内容(渐进增强!)。

动画

动画对我们的目的来说不太相关,但我们将在这里简要介绍一下。基本上,如果动画不以 60 帧每秒的速度执行,用户会注意到动画的延迟。这将对感知性能(用户对应用程序速度的感受)产生负面影响。

请注意,RAIL 还将滚动和触摸手势定义为动画,因此即使你没有动画,如果你的滚动有延迟,你就会有问题。

响应

最终(希望非常快!),用户执行一个操作。通常,这意味着点击按钮、输入或使用手势。一旦他们这样做,你有 100 毫秒的时间来提供一个响应,以确认他们的行动;否则,用户会注意到并感到沮丧,也许会重试该操作,从而在后续造成更多问题(我们都经历过这种情况——疯狂地双击和三击)。

请注意,如果需要进行一些计算或网络请求,某些操作将需要更长的时间来完成。你不需要在 100 毫秒内完成操作,但你必须提供一些响应;否则,正如Meggin Kearney所说,“行动和反应之间的连接就断了。用户会注意到。”

时间轴

正如前面的模型所示,我们的应用程序必须在一定的时间限制内运行。这里有一个方便的参考:

  • 16 毫秒:任何动画/滚动的每帧时间。

  • 100 毫秒:对用户操作的响应。

  • 1000 毫秒以上:在网页上显示内容。

  • 1000 毫秒以上:用户失去焦点。

  • 10,000 毫秒以上:用户可能会放弃页面。

如果你的应用程序按照这些规范执行,你就处于一个良好的状态(这些并不容易做到,正如我们将看到的)。

使用时间轴进行测量

在这一部分,我们将看看如何使用 Chrome DevTools 来分析我们应用程序的性能,这是我们将使用的一些工具中的第一个,用来跟踪我们的应用程序加载和响应的方式。

一旦我们了解了它的性能,我们可以根据 RAIL 原则进行改进。

开发工具当然是一直在不断发展的,所以它们的外观可能会与给定的截图有所不同。然而,核心功能应该保持不变,因此,重要的是要密切关注工作原理。

在 Chrome 中打开部署的 Firebase 应用程序,并打开 DevTools 到性能标签(我建议通过右上角的下拉菜单将工具拖出到单独的窗口中,因为有很多内容要查看);然后,刷新页面。页面加载完成后,您应该看到类似以下内容:

这里有很多内容,让我们来分解一下。我们将从摘要标签开始,底部的圆形图表。

摘要标签

中间的数字是我们的应用程序完全加载所花费的时间。您的数字应该与我的类似,根据您的互联网速度会有一些变化。

到目前为止,这里最大的数字是脚本,几乎达到了 1000 毫秒。由于我们的应用程序使用 JavaScript 很多,这是有道理的。我们立刻就能看到我们大部分的优化应该集中在尽快启动我们的脚本上。

另一个重要的数字是空闲时间的数量(几乎与脚本时间一样多)。我们马上就会看到为什么会有这么多空闲时间,但请记住,RAIL 模型建议利用这段时间开始预加载尚未加载的应用程序部分。目前,我们一开始就加载了所有内容,然后启动所有内容,然后坐在那里一会儿。只加载我们需要的内容(从而减少脚本时间),然后在后台加载其余内容(从而减少空闲时间)将更有意义。

网络请求

我们现在将转到网络请求,因为这将有助于解释性能概况的其余部分。

在这里,您可以看到确切加载了什么数据以及何时加载。一开始,我们看到了很多设置文件:Firebase 应用和messaging库,我们的bundle.js,以及页面的实际文档。

稍后,两个重要的调用是为了用户:登录和加载用户详细信息。我们加载的最后一件事是清单。

这个顺序是有道理的。我们需要加载 Firebase 库和我们的 JavaScript 来启动我们的应用程序。一旦我们这样做,我们就开始登录过程。

接下来发生的事情是,一旦用户登录,我们就会收到来自 Firebase 的消息和数据。正如您所注意到的,这在图表上并没有显示出来,因为它是通过 WebSockets 实时完成的,所以它并不是一个网络请求。然而,它将影响到其余的性能概况,所以请记住这一点。

瀑布

在这里,我们可以详细了解 Chrome 在渲染过程中实际在做什么。

瀑布工具是详细和复杂的,所以我们只能对其进行表面浏览。然而,我们可以从中得出两个见解。首先,我们可以看到所有的空闲时间可视化。大部分是在开始时,这在我们首次加载文档时有些不可避免,但在中间有一个很大的空白,我们可以尝试填补它。

其次,您可以看到应用程序在右侧瀑布图中接收来自 Firebase 的消息。如果您将鼠标悬停在每个块上,实际上可以追踪 Firebase 接收消息并将其状态设置为消息数组的过程。

因此,虽然我们无法在网络请求中看到消息加载,但我们可以在 JavaScript 执行中看到响应。

屏幕截图

这是我最喜欢的性能工具部分,因为它生动地说明了您的应用程序是如何加载的。

正如我们之前所建立的,用户应该在加载您的应用程序后的 1000 毫秒内看到内容。在这里,我们可以看到应用程序上的内容首先出现大约在 400 毫秒左右,所以我们看起来不错,但随着我们的应用程序增长(和我们的脚本负担增加),情况可能会改变,所以现在是尽可能优化的时候了。

PageSpeed Insights

性能工具非常棒,因为它们让我们深入了解应用程序加载的细节。我们将使用它们来跟踪我们应用程序的性能,但是,如果我们想要更具体、更详细的建议,我们可以转向 Google 提供的PageSpeed Insights工具。

转到 PageSpeed Insights(developers.google.com/speed/pagespeed/insights/)并输入您部署的应用程序的 URL。几秒钟后,您将收到关于 Chatastrophe 可以改进的建议:

正如你所看到的,我们的移动性能急需帮助。大部分见解都集中在我们的阻塞渲染 JavaScript 和 CSS 上。我鼓励你阅读关于这些问题的描述,并尝试自行解决它们。在下一节中,我们将致力于根据谷歌的规范改进我们的应用程序,使用另一个渐进式 Web 应用程序的秘密武器——应用外壳模式。

应用外壳模式

我们应用程序的核心是消息列表和聊天框,用户在其中阅读和编写消息。

这个核心功能依赖于 JavaScript 来工作。我们无法绕过这样一个事实,即在用户通过 Firebase 进行身份验证并加载消息数组之前,我们无法显示消息,但是围绕这两个部分的一切大多是静态内容。在每个视图中都是相同的,并且不依赖于 JavaScript 来工作:

我们可以将这称为应用外壳——围绕功能性、由 JavaScript 驱动的核心的框架。

由于这个框架不依赖 JavaScript 来运行,实际上我们不需要等待 React 加载和启动所有 JavaScript,然后再显示它——这正是目前正在发生的事情。

现在,我们的外壳是我们的 React 代码的一部分,因此,在调用ReactDOM.render并在屏幕上显示之前,我们所有的 JavaScript 都必须加载。

然而,对于我们的应用程序,以及许多应用程序来说,UI 中有一个相当大的部分基本上只是 HTML 和 CSS。此外,如果我们的目标是减少感知加载时间(用户认为加载应用程序需要多长时间)并尽快将内容显示在屏幕上,最好将我们的外壳保持为纯粹的 HTML 和 CSS,即将其与 JavaScript 分离,这样我们就不必等待 React。

回到我们的性能工具,你可以看到加载的第一件事是文档,或者我们的index.html

如果我们可以将我们的外壳放在index.html中,它将比目前快得多,因为它不必等待捆绑包加载。

然而,在开始之前,让我们进行基准测试,看看我们目前的情况以及这将带来多大的改进。

使用你部署的应用程序,打开我们的性能工具并刷新应用程序(在 DevTools 打开时使用 Empty Cache & Hard Reload 选项,以确保没有意外的缓存发生-按住并按下重新加载按钮来访问它)。然后,看一下那个图像条,看看内容何时首次出现:

运行测试三次,以确保,并取平均值。对我来说,平均需要 600 毫秒。这是我们要超越的基准。

将 shell HTML 从 React 中移出

让我们首先定义我们想要移动到我们的index.html中的内容。

在下面的图像中,除了消息和聊天框线之外的所有内容都是我们的应用程序 shell:

这就是我们想要从 React 中移出并转换为纯 HTML 的内容,但在继续之前让我们澄清一些事情。

我们的目标是创建一个快速加载的应用程序部分的版本,这些部分不需要立即使用 JavaScript,但最终,我们的一些 shell 将需要 JavaScript。我们需要在页眉中放置我们的注销按钮,这将需要 JavaScript 来运行(尽管只有在用户经过身份验证后才需要)。

因此,当我们谈论将这些内容从 React 中移出时,我们实际上要做的是有一个纯 HTML 和 CSS 版本的 shell,然后,当 React 初始化时,我们将用 React 版本替换它。

这种方法给了我们最好的两种世界:一个快速加载基础版本,一旦 JS 准备好,我们就会替换掉它。如果这听起来很熟悉,你也可以称之为逐步增强我们的应用程序。

那么,我们如何管理这个替换呢?嗯,让我们从打开我们的index.html开始,看看我们的应用程序是如何初始化的:

关键是我们的div#root。正如我们在index.js中看到的那样,那是我们注入 React 内容的地方:

现在,我们将我们的 React 内容嵌入到一个空的div中,但让我们尝试一些东西;在里面添加一个<h1>

<div id="root">
  <h1>Hello</h1>
</div>

然后,重新加载你的应用程序:

<h1>出现直到我们的 React 准备好,此时它被替换,所以我们可以在div#root内添加内容,当 React 准备好时,它将被简单地覆盖;这就是我们的关键。

让我们逐步移动内容,从我们的App.js开始,逐渐向下工作:

我们这里唯一需要的 HTML(或 JSX,目前)是容器。让我们将它复制到div#root中:

<div id="root">
  <div id="container">
  </div>
</div>

然后,在ChatContainer(或LoginContainer,或UserContainer)内部,我们看到有一个div.inner-container,也可以移动过去:

<div id="root">
  <div id="container">
    <div class="inner-container">
    </div>
  </div>
</div>

注意从className(对于 JSX)到class(对于 HTML)的更改。

然后,我们移动Header本身:

<div id="root">
  <div id="container">
     <div class="inner-container">
       <div id="Header">
         <img src="/assets/icon.png" alt="logo" />
         <h1>Chatastrophe</h1>
       </div>
     </div>
  </div>
</div>

重新加载您的应用程序,您将看到我们的 HTML 的一个非常丑陋的版本在 React 加载之前出现:

这里发生了什么?嗯,我们的 CSS 是在我们的App.js中加载的,在我们的导入语句中,因此直到我们的 React 准备好之前它都不会准备好。下一步将是将相关的 CSS 移动到我们的index.html中。

将 CSS 移出 React

目前,我们的应用程序没有太多的 CSS,所以理论上,我们可以只是在index.html<link>整个样式表,而不是在App.js中导入它,但随着我们的应用程序和 CSS 的增长,这将不是最佳选择。

我们最好的选择是内联相关的 CSS。我们首先在<head>下方的<title>标签右侧添加一个<style>标签。

然后,打开src/app.css,并剪切(而不是复制)/* Start initial styles *//* End Initial styles */注释内的 CSS。

将其放在样式标签内并重新加载应用程序:

应用程序看起来完全一样!这是个好消息;在这个阶段,可能不会有明显的加载时间差异。然而,让我们部署然后再次运行我们的性能工具:

正如您所看到的,外壳(带有空白内部)出现在加载指示器出现之前(这表明 React 应用程序已经启动)。这是用户通常会花在空白屏幕上的时间。

移动加载指示器

让我们再向前迈进一小步,还将加载指示器添加到我们的应用程序外壳中,以让用户了解发生了什么。

复制ChatContainer中的 JSX 并将其添加到我们的index.html。然后,重新加载页面:

<div id="root">
  <div id="container">
    <div class="inner-container">
      <div id="Header">
        <img src="/assets/icon.png" alt="logo" />
        <h1>Chatastrophe</h1>
      </div>
      <div id="loading-container">
        <img src="/assets/icon.png" alt="logo" id="loader"/>
      </div>
    </div>
  </div>
</div>

现在,用户可以清楚地感觉到应用程序正在加载,并且会更宽容地对待我们应用程序的加载时间(尽管我们仍然会尽力减少它)。

这是从本章中获得的基本原则:渐进式 Web 应用程序要求我们尽可能多地改善用户体验。有时,我们无法做任何关于加载时间的事情(归根结底,我们的 JavaScript 总是需要一些时间来启动--一旦它启动,它就提供了很好的用户体验),但我们至少可以让用户感受到进展。

良好的网页设计是关于共情。渐进式 Web 应用程序是关于对每个人都持有共情,无论他们从什么条件下访问您的应用程序。

总结

在本章中,我们涵盖了性能工具和概念的基本知识,从 RAIL 到 DevTools,再到 PageSpeed Insights。我们还使用了应用程序外壳模式进行了重大的性能改进。在接下来的章节中,我们将继续完善我们应用的性能。

我们下一章将解决最大的性能障碍——我们庞大的 JavaScript 文件。我们将学习如何使用 React Router 的魔力将其拆分成较小的块,并且如何在应用程序的空闲时间加载这些块。让我们开始吧!

第十一章:使用 Webpack 对 JavaScript 进行分块以优化性能

正如我们在上一章中讨论的那样,将 React 应用程序转换为渐进式 Web 应用程序的最大问题是 React;更具体地说,它是构建现代 JavaScript 应用程序时固有的大量 JavaScript。解析和运行该 JavaScript 是 Chatastrophe 性能的最大瓶颈。

在上一章中,我们采取了一些措施来改善应用程序的感知启动时间,方法是将内容从 JavaScript 移出并放入我们的index.html中。虽然这是一种非常有效的向用户尽快显示内容的方法,但您会注意到,我们并没有做任何实际改变我们的 JavaScript 大小,或者减少初始化所有 React 功能所需的时间。

现在是时候采取行动了。在本章中,我们将探讨如何将我们的 JavaScript 捆绑分割以实现更快的加载。我们还将介绍渐进式 Web 应用程序理论的一个新部分--PRPL 模式。

在本章中,我们将涵盖以下主题:

  • 什么是 PRPL 模式?

  • 什么是代码拆分,我们如何实现它?

  • 创建我们自己的高阶组件

  • 按路由拆分代码

  • 延迟加载其他路由

PRPL 模式

在上一章中,我们介绍了一些执行应用程序的基本原则。您希望用户尽可能少地等待,这意味着尽快加载必要的内容,并将其余的应用程序加载推迟到处理器的“空闲”时间。

这两个概念构成 RAIL 指标的'I'和'L'。我们通过应用外壳的概念迈出了改善'L'的一步。现在,我们将把一些'L'(初始加载)移到'I'(应用程序的空闲时间),但在我们这样做之前,让我们介绍另一个缩写。

PRPL代表推送渲染预缓存延迟加载;这是一个理想应用程序应该如何从服务器获取所需内容的逐步过程。

然而,在我们深入讨论之前,我想警告读者,PRPL 模式在撰写时相对较新,并且随着渐进式 Web 应用程序进入主流,可能会迅速发展。就像我们在本书中讨论的许多概念一样,它依赖于实验性技术,仅适用于某些浏览器。这是尖端的东西。

这就是Addy Osmani的说法:

对于大多数现实世界的项目来说,以其最纯粹、最完整的形式实现 PRPL 愿景实际上还为时过早,但采用这种思维方式或从各个角度开始追求这一愿景绝对不为时过早。 (developers.google.com/web/fundamentals/performance/prpl-pattern/)

让我们依次解释每个字母代表的意思,以及它对我们和我们的应用程序意味着什么。

推送

Addy Osmani将 PRPL 的 PUSH 定义如下:

“推送初始 URL 路由的关键资源。”

基本上,这意味着你的首要任务是尽快加载渲染初始路由所需的内容。听起来很熟悉吗?这正是我们在应用程序外壳中遵循的原则。

推送的一个温和定义可以是“在任何其他内容之前,首先加载关键内容。”这个定义与应用程序外壳模式完全吻合,但这并不完全是Osmani的意思。

以下部分是对服务器推送技术的理论介绍。由于我们无法控制我们的服务器(又名 Firebase),我们不会实施这种方法,但了解对于未来与自己的服务器通信的 PWA 是很有好处的。

如果你看一下我们的index.html,你会发现它引用了几个资产。它请求faviconicon.pngsecrets.js。在 Webpack 构建后,它还会请求我们的主 JavaScript bundle.js

网站通常的工作方式是这样的:浏览器请求index.html。一旦得到文件,它会遍历并请求服务器上列出的所有依赖项,每个都作为单独的请求。

这里的核心低效性在于index.html已经包含了关于它的依赖项的所有信息。换句话说,当它响应index.html时,服务器已经“知道”浏览器接下来会请求什么,那么为什么不预期这些请求并发送所有这些依赖项呢?

进入 HTTP 2.0 服务器推送。这项技术允许服务器对单个请求创建多个响应。浏览器请求index.html,然后得到index.html + bundle.js + icon.png,依此类推。

正如Ilya Grigorik所说,服务器推送“使内联过时”(www.igvita.com/2013/06/12/innovating-with-http-2.0-server-push/)。我们不再需要内联我们的 CSS 来节省对服务器的请求;我们可以编写我们的服务器以在单次请求中发送我们初始路由所需的一切。这是令人兴奋的事情;有关更多信息(以及快速教程),请查看上述链接。

渲染

在(理想情况下)将所有必要的资源推送到客户端之后,我们渲染我们的初始路由。同样,由于应用程序外壳模式的快速渲染,我们已经涵盖了这一点。

预缓存

一旦我们渲染了初始路由,我们仍然需要其他路由所需的资源。预缓存意味着一旦加载了这些资源,它们将直接进入缓存,如果再次请求,我们将从缓存中加载它们。

随着我们进入缓存世界,我们将在下一章中更详细地介绍这一点。

延迟加载

这就是本章的重点所在。

我们希望首先加载我们初始路由所需的资源,以尽快完成初始渲染。这意味着不会加载其他路由所需的资源。

在实际操作中,这意味着我们希望首先加载LoginContainer(如果用户尚未登录),并推迟加载UserContainer

然而,一旦渲染了初始路由并且用户可以看到登录屏幕,我们希望为未来做好准备。如果他们随后切换到UserContainer,我们希望尽快显示它。这意味着一旦加载了初始路由,我们就会在后台加载UserContainer资源。

这个过程被称为延迟加载-加载不需要立即使用的资源,但将来可能需要。

我们用来做到这一点的工具就是代码拆分。

什么是代码拆分?

代码拆分是将我们的 JavaScript 文件分割成有意义的块,以提高性能,但为什么我们需要它呢?

嗯,当用户首次访问我们的应用程序时,我们只需要当前所在路由的 JavaScript。

这意味着当它们在/login时,我们只需要LoginContainer.js及其依赖项。我们不需要UserContainer.js,所以我们希望立即加载LoginContainer.js并延迟加载UserContainer.js。然而,我们当前的 Webpack 设置创建了一个单一的bundle.js文件。我们所有的 JavaScript 都被绑在一起,必须一起加载。代码拆分是解决这个问题的一种方法。我们不再是一个单一的庞大的 JavaScript 文件,而是得到了多个 JavaScript 文件,每个路由一个。

因此,我们将得到一个用于/login,一个用于/user/:id,一个用于/的捆绑包。此外,我们还将得到另一个包含所有依赖项的main捆绑包。

无论用户首先访问哪个路由,他们都会得到该路由的捆绑包和主要捆绑包。与此同时,我们将在后台加载其他两个路由的捆绑包。

代码拆分不一定要基于路由进行,但对于我们的应用程序来说是最合理的。此外,使用 Webpack 和 React Router 进行这种方式的代码拆分相对来说是比较简单的。

事实上,只要您提供一些基本的设置,Webpack 就会自动处理这个问题。让我们开始吧!

Webpack 配置

我们之前讨论过的策略是这样的:我们希望根据路由将我们的bundle.js拆分成单独的块。

这一部分的目的是做两件事:一是为 JavaScript 的块设置命名约定,二是为条件导入添加支持(稍后会详细介绍)。

打开webpack.config.prod.js,让我们进行第一步(这仅适用于PRODUCTION构建,因此只修改我们的生产 Webpack 配置;我们不需要在开发中进行代码拆分)。

就目前而言,我们的输出配置如下:

output: {
   path: __dirname + "/build",
   filename: "bundle.js",
   publicPath: './'
},

我们在build文件夹中创建一个名为bundle.js的单个 JavaScript 文件。

让我们将整个部分改为以下内容:

output: {
   path: __dirname + "/build",
   filename: 'static/js/[name].[hash:8].js',
   chunkFilename: 'static/js/[name].[hash:8].chunk.js',
   publicPath: './'
},

这里发生了什么?

首先,我们将我们的 JavaScript 输出移动到build/static/js,仅仅是为了组织目的。

接下来,我们在我们的命名中使用了两个变量:namehashname变量是由 Webpack 自动生成的,使用了我们的块的编号约定。我们马上就会看到这一点。

然后,我们使用一个hash变量。每次 Webpack 构建时,它都会生成一个新的哈希--一串随机字母和数字。我们使用这些来命名我们的文件,这样每次构建都会有不同的文件名。这在下一章中将很重要,因为这意味着我们的用户永远不会遇到应用程序已更新但缓存仍然保留旧文件的问题。由于新文件将具有新名称,它们将被下载,而不是缓存中的任何内容。

接下来,我们将在我们的代码拆分文件(每个路由的文件)后添加一个.chunk。这并非必需,但如果您想对块进行任何特殊缓存,建议这样做。

一旦我们的代码拆分完成,所有提到的内容将更加清晰,所以让我们尽快完成吧!然而,在继续之前,我们需要在我们的 Webpack 配置中再添加一件事。

Babel 阶段 1

正如我们在 Webpack 章节中解释的那样,Babel 是我们用来允许我们使用尖端 JavaScript 功能,然后将其转译为浏览器将理解的 JavaScript 版本的工具。

在本章中,我们将使用另一个尖端功能:条件导入。然而,在开始之前,我们需要更改我们的 Babel 配置。

JavaScript 语言不断发展。负责更新它的委员会称为 TC39,他们根据 TC39 流程开发更新。它的工作方式如下:

  • 建议一个新的 JavaScript 功能,此时它被称为“阶段 0”

  • 为其工作创建一个提案(“阶段 1”)

  • 创建一个实现(“阶段 2”)

  • 它被打磨以包含(“阶段 3”)

  • 它被添加到语言中

在任何时候,每个阶段都有多个功能。问题在于 JavaScript 开发人员很不耐烦,每当他们听说一个新功能时,即使它处于第 3 阶段、第 2 阶段甚至第 0 阶段,他们也想开始使用它。

Babel 提供了一种方法来做到这一点,即其stage预设。您可以为每个阶段安装一个预设,并获得当前处于该阶段的所有功能。

我们感兴趣的功能(条件导入)目前处于第 2 阶段。为了使用它,我们需要安装适当的 babel 预设:

yarn add --dev babel-preset-stage-2

然后,在两个 Webpack 配置中,将其添加到 module | loaders | JavaScript 测试 | query | presets 下:

module: {
  loaders: [
  {
  test: /\.js$/,
  exclude: /node_modules/,
  loader: 'babel-loader',
  query: {
         presets: ['es2015','react','stage-2'],
         plugins: ['react-hot-loader/babel', 'transform-class-properties']
       }
  },

记得将其添加到webpack.config.jswebpack.config.prod.js中。我们在生产和开发中都需要它。

条件导入

搞定了这些,现在是时候问一下条件导入是什么了。

目前,我们在每个 JavaScript 文件的顶部导入所有的依赖项,如下所示:

import React, { Component } from 'react';

我们始终需要 React,所以这个导入是有意义的。它是静态的,因为它永远不会改变,但前面的意思是 React 是这个文件的依赖项,它将始终需要被加载。

目前,在App.js中,我们对每个容器都是这样做的:

import LoginContainer from './LoginContainer';
import ChatContainer from './ChatContainer';
import UserContainer from './UserContainer';

这样做意味着这些容器是App.js的依赖,所以 Webpack 将始终将它们捆绑在一起;我们无法将它们分开。

相反,我们希望在需要时有条件地导入它们。

这样做的机制有点复杂,但本质上看起来是这样的:

If (path === ‘/login’)
  import('./LoginContainer')
} else if (path === ‘/user/:id’)
  import(‘./UserContainer)
} else {
  import(‘./ChatContainer)
}

那么,我们该如何实现呢?

高阶组件

我们在第五章中讨论了高阶组件,使用 React 进行路由,讨论了来自 React Router 的withRouter;现在,我们将构建一个,但首先,让我们快速复习一下。

高阶组件在 React 中是一个非常有用的模式。如果你学会了如何使用它们,你将打开一系列可能性,使得大型代码库易于维护和可重用,但它们并不像常规组件那样直观,所以让我们确保我们充分涵盖它们。

在最基本的层面上,高阶组件是一个返回组件的函数。

想象一下我们有一个button组件:

function Button(props) {
 return <button color={props.color}>Hello</button>
}

如果你更熟悉class语法,也可以用这种方式来写:

class Button extends Component {
 render() {
   return <button color={this.props.color}>Hello</button>
 }
}

我们使用一个颜色属性来控制文本的颜色。假设我们在整个应用程序中都使用这个按钮。通常情况下,我们发现自己将文本设置为红色--大约 50%的时间。

我们可以简单地继续将color=”red”属性传递给我们的按钮。在这个假设的例子中,这将是更好的选择,但在更复杂的用例中,我们也可以制作一个高阶组件(正如我们将看到的)。

让我们创建一个名为RedColouredComponent的函数:

function colorRed(Component) {
  return class RedColoredComppnent extends Component {
    render () {
      return <Component color="red" />
    }
  }
}

该函数接受一个组件作为参数。它所做的就是返回一个组件类,然后返回该组件并应用color=”red”属性。

然后,我们可以在另一个文件中渲染我们的按钮,如下所示:

import Button from './Button';
import RedColouredComponent from './RedColouredComponent';

const RedButton = RedColouredComponent(Button);

function App() {
 return (
   <div>
     <RedButton />
   </div>
 )
}

然后,我们可以将任何组件传递给RedColouredComponent,从而创建一个红色版本。

这样做打开了新的组合世界--通过高阶组件的组合创建组件。

这毕竟是 React 的本质——用可重用的代码片段组合 UI。高阶组件是保持我们的应用程序清晰和可维护的好方法,但是足够的人为例子,现在让我们自己来做吧!

AsyncComponent

本节的目标是创建一个帮助我们进行代码拆分的高阶组件。

这个组件只有在渲染时才会加载它的依赖项,或者当我们明确告诉它要加载它时。这意味着,如果我们传递给它LoginContainer.js,它只会在用户导航到/login时加载该文件,或者我们告诉它加载它时。

换句话说,这个组件将完全控制我们的 JavaScript 文件何时加载,并打开了懒加载的世界。然而,这也意味着每当渲染一个路由时,相关文件将自动加载。

如果这听起来抽象,让我们看看它的实际应用。

在您的components/目录中创建一个名为AsyncComponent.js的新文件,并添加基本的骨架,如下所示:

import React, { Component } from 'react'

export default function asyncComponent(getComponent) {

}

asyncComponent是一个以导入语句作为参数的函数,我们称之为getComponent。我们知道,作为一个高阶组件,它将返回一个component类:

export default function asyncComponent(getComponent) {
 return class AsyncComponent extends Component {
   render() {
     return (

     )
   }
 }
}

AsyncComponent的关键将是componentWillMount生命周期方法。这是AsyncComponent将知道何时去获取依赖文件的时候。这样,组件在需要之前等待,然后加载任何文件。

然而,当我们得到组件后,我们该怎么办呢?简单,将其存储在状态中:

  componentWillMount() {
     if (!this.state.Component) {
       getComponent().then(Component => {
         this.setState({ Component });
       });
     }
   }

如果我们还没有加载组件,就去导入它(我们假设getComponent返回一个Promise)。一旦导入完成,将状态设置为导入的组件,这意味着我们的render应该是这样的:

  render() {
     const { Component } = this.state;
     if (Component) {
       return <Component {...this.props} />;
     }
     return null;
   }

所有这些对你来说应该很熟悉,除了return语句中的{...this.props}。这是 JavaScript 的展开运算符。这是一个复杂的小东西(更多信息请参见developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_operator),但在这种情况下,它基本上意味着将this.props对象的所有键和值复制到Componentprops上。

通过这种方式,我们可以将 props 传递给asyncComponent返回的组件,并将它们传递给Component渲染。应用于AsyncComponent的每个 prop 都将应用于其render函数中的Component

供参考的完整组件如下:

import React, { Component } from 'react';

export default function asyncComponent(getComponent) {
 return class AsyncComponent extends Component {
   state = { Component: null };

   componentWillMount() {
     if (!this.state.Component) {
       getComponent().then(Component => {
         this.setState({ Component });
       });
     }
   }

   render() {
     const { Component } = this.state;
     if (Component) {
       return <Component {...this.props} />;
     }
     return null;
   }
 };
}

路由拆分

让我们回到App.js,把它全部整合起来。

首先,我们将消除 App 对这三个容器的依赖。用AsyncComponent的导入替换这些导入,使文件顶部看起来像这样:

import React, { Component } from 'react';
import { Route, withRouter } from 'react-router-dom';
import AsyncComponent from './AsyncComponent';
import NotificationResource from '../resources/NotificationResource';
import './app.css';

接下来,我们将定义三个load()函数,每个容器一个。这些是我们将传递给asyncComponent的函数。它们必须返回一个 promise:

const loadLogin = () => {
 return import('./LoginContainer').then(module => module.default);
};

const loadChat = () => {
 return import('./ChatContainer').then(module => module.default);
};

const loadUser = () => {
 return import('./UserContainer').then(module => module.default);
};

看,条件导入的魔力。当调用这些函数时,将导入三个 JavaScript 文件。然后我们从每个文件中获取默认导出,并用它来resolve() Promise

这意味着我们可以在App.js中重新定义我们的组件,如下所示,在前面的函数声明之后(这些函数声明在文件顶部的导入语句之后):

const LoginContainer = AsyncComponent(loadLogin);
const UserContainer = AsyncComponent(loadUser);
const ChatContainer = AsyncComponent(loadChat);

不需要其他更改!您可以保持应用程序的render语句完全相同。现在,当我们提到ChatContainer时,它指的是loadChat…周围的AsyncComponent包装器,它在需要时会获取ChatContainer.js

让我们看看它是否有效。运行yarn build,并查看输出:

我们有四个 JavaScript 文件而不是一个。我们有我们的main.js文件,其中包含App.js加上我们必需的node_modules。然后,我们有三个块,每个容器一个。

还要查看文件大小,您会发现我们并没有通过这种代码拆分获得太多好处,主文件减少了几千字节。然而,随着我们的应用程序增长,每个路由变得更加复杂,代码拆分的好处也会随之增加。这有多简单?

懒加载

懒加载是我们 PRPL 拼图的最后一块,它是利用应用程序的空闲时间来加载其余的 JavaScript 的过程。

如果您yarn deploy我们的应用程序并导航到 DevTools 中的网络选项卡,您将看到类似以下的内容:

我们加载我们的主文件,然后加载与当前 URL 相关的任何块,然后停止。

我们在应用程序的空闲时间内没有加载其他路由!我们需要一种方式来触发加载过程,即在初始路由渲染完成后,即App挂载后。

我想你知道这将会发生什么。在AppcomponentDidMount方法中,我们只需要调用我们的三个加载方法:

componentDidMount() {
    this.notifications = new NotificationResource(
      firebase.messaging(),
      firebase.database()
    );
    firebase.auth().onAuthStateChanged(user => {
      if (user) {
        this.setState({ user });
        this.listenForMessages();
        this.notifications.changeUser(user);
      } else {
        this.props.history.push('/login');
      }
    });
    this.listenForMessages();
    this.listenForInstallBanner();
 loadChat();
 loadLogin();
 loadUser();
  }

现在,每当我们完成渲染当前路由时,我们也会准备好其他路由。

如果您再次打开 DevTools 的性能选项卡,您将看到网络请求中反映出这一点:

在左边,底部的黄色块是我们加载的main.js文件。这意味着我们的应用程序可以开始初始化。在右边,三个黄色块对应我们的三个路由块。我们首先加载需要的块,然后很快加载其他两个块。

我们现在更多地利用了应用程序的空闲时间,分散了初始化应用程序的工作。

总结

在本章中,我们涵盖了很多内容,大步迈向了更高性能的应用程序。我们按路由拆分了我们的 JavaScript,并简化了加载过程,以便加载我们需要的内容,并将其推迟到空闲时间。

然而,所有这些实际上只是为下一节铺平了道路。我们需要我们的应用程序在所有网络条件下都能正常运行,甚至在没有任何网络的情况下。我们如何使我们的应用程序在离线状态下工作?

接下来,我们将深入研究缓存的世界,并进一步改善我们应用程序在任何网络条件下的性能,甚至在没有网络的情况下。

第十二章:准备好进行缓存

我们在应用程序的性能方面取得了巨大进展。我们的 JavaScript 现在根据应用程序的路由拆分成更小的块,并且在我们的应用程序有空闲时间时延迟加载不太重要的部分。我们还引入了渐进增强,尽快向用户展示内容,并学习了如何根据 RAIL 指标分析我们应用程序的性能。

然而,我们的 Web 应用程序仍然存在一个核心的低效问题。如果我们的用户离开我们的页面去其他地方(我知道,他们怎么敢),然后返回,我们又要重复同样的过程:下载index.html,下载不同的 JavaScript 包,下载图片等等。

我们要求用户每次访问页面时都下载完全相同的文件,一遍又一遍,而他们的设备有足够的内存来为我们存储这些文件。为什么我们不把它们保存到用户的设备上,然后根据需要检索呢?

欢迎来到缓存。在本章中,我们将涵盖以下内容:

  • 什么是缓存?

  • 缓存 API

  • 在我们的服务工作者中使用缓存 API

  • 测试我们的缓存

什么是缓存?

缓存是减少网络请求或计算的行为。后端缓存可能包括保存严格计算的结果(比如生成统计数据),这样当客户端第二次请求时,我们就不必再次进行计算。客户端缓存通常包括保存网络请求的响应,这样我们就不必再次发起请求。

正如我们之前所说,服务工作者是位于我们应用程序和网络之间的代码片段。这意味着它们非常适合缓存,因为它们可以拦截网络请求并用所请求的文件进行响应,从缓存中获取文件,而不是从服务器获取;节省了时间。

从更广泛的角度来看,你可以将缓存视为不必重复做同样的事情,使用内存来存储结果。

使用渐进式 Web 应用程序进行缓存的好处在于,由于缓存存储在设备内存中,无论网络连接如何,它都是可用的。这意味着无论设备是否连接,都可以访问缓存中存储的所有内容。突然间,我们的网站可以离线访问了。

对于在 Wi-Fi 区域之间切换的移动用户来说,便利因素可能是巨大的,使他们能够快速查看朋友的消息或一组方向(任何曾经没有漫游计划旅行的人都会有这种感觉)。这也不仅仅是纯离线用户的优势;对于网络时断时续或质量低劣的用户来说,能够在网络断断续续时继续工作而不丧失功能性是一个巨大的胜利。

因此,一举两得,我们可以提高我们的应用程序性能,使其对所有用户都可离线使用。然而,在我们开始在 Chatastrophe 中实施缓存之前(希望不会出现灾难),让我们先看一个关于缓存重要性的故事。

缓存的重要性

2013 年,美国政府推出了healthcare.gov/,这是一个供公民注册平价医疗法案(也称为奥巴马医改)的网站。从一开始,该网站就饱受严重的技术问题困扰。对于成千上万的人来说,它根本无法加载。

公平地说,该网站承受着巨大的压力,在运营的第一个月就有大约 2,000 万次访问(来源-www.bbc.com/news/world-us-canada-24613022),但这种压力是可以预料的。

如果你正在为数百万人注册医疗保健的网站(所有人同时开始),性能可能会是你首要考虑的问题,但最终,healthcare.gov/未能交付。

作为对危机的回应(这威胁到了 ACA 的信誉),政府成立了一个团队来解决问题,有点像复仇者联盟,但是软件开发人员(所以根本不是复仇者联盟)。

考虑到该网站的目标,工程师们震惊地发现healthcare.gov/没有实施基本的缓存。没有。因此,每当用户访问该网站时,服务器都必须处理网络请求并生成回复的信息。

这种缺乏缓存产生了复合效应。第一波用户堵塞了管道,所以第二波用户看到了加载屏幕。作为回应,他们刷新屏幕,发出了越来越多的网络请求,依此类推。

一旦 Devengers 实施了缓存,他们将响应时间缩短了四分之三。从那时起,该网站甚至能够处理高峰时段的流量。

Chatastrophe 可能还没有处理healthcare.gov/级别的流量(但是……),但缓存总是一个好主意。

缓存 API

我们将使用Web 缓存 API进行缓存。

请注意,Mozilla 开发者网络将缓存 API 定义为实验性技术,截至 2017 年 8 月,它仅得到 Chrome、Firefox 和最新版本的 Opera 的支持。

API 规范有一些我们需要讨论的怪癖。首先,你可以在缓存中存储多个缓存对象。这样,我们就能够存储我们的缓存的多个版本,以我们喜欢的任何字符串命名。

也就是说,浏览器对于每个站点可以存储的数据有限制。如果缓存太满,它可能会简单地删除来自该站点的所有数据,因此我们最好存储最少量的数据。

然而,还有一个额外的困难。除非明确删除,否则缓存中的项目永远不会过期,因此如果我们不断尝试将新的缓存对象放入我们的缓存中,最终它会变得太满并删除所有内容。管理、更新和删除缓存对象完全取决于我们。换句话说,我们必须清理自己的混乱。

方法

我们将使用五种方法与缓存 API 交互:openaddAllmatchkeysdelete。在接下来的内容中,Caches将指的是缓存 API 本身,而Cache指的是特定的缓存对象,以区分在单个缓存上调用的方法与 API 本身:

  • Caches.open()接受一个缓存对象名称(也称为缓存键)作为参数(可以是任何字符串),并创建一个新的缓存对象,或者打开同名的现有缓存对象。它返回一个Promise,并将缓存对象作为参数解析,然后我们可以使用它。

  • Cache.addAll()接受一个 URL 数组。然后它将从服务器获取这些 URL,并将结果文件存储在当前的缓存对象中。它的小伙伴是Cache.add,它可以用单个 URL 做同样的事情。

  • Caches.match()接受一个网络请求作为参数(我们将在接下来看到如何获取它)。它在缓存中查找与 URL 匹配的文件,并返回一个解析为该文件的Promise。然后我们可以返回该文件,从而取代向服务器发出请求的需要。它的大哥是Caches.matchAll()

  • Caches.keys()返回所有现有缓存对象的名称。然后我们可以通过将它们的键传递给Caches.delete()来删除过时的缓存对象。

缓存 API 中的最后一个方法,我们这里不会使用,但可能会感兴趣的是Caches.put。这个方法接受一个网络请求并获取它,然后将结果保存到缓存中。如果你想缓存每个请求而不必提前定义 URL,这将非常有用。

资产清单

我们的构建过程会自动生成一个asset-manifest.json文件,其中列出了我们应用程序包含的每个 JavaScript 文件。它看起来像这样:

{
  "main.js": "static/js/main.8d0d0660.js",
  "static/js/0.8d0d0660.chunk.js": "static/js/0.8d0d0660.chunk.js",
  "static/js/1.8d0d0660.chunk.js": "static/js/1.8d0d0660.chunk.js",
  "static/js/2.8d0d0660.chunk.js": "static/js/2.8d0d0660.chunk.js"
}

换句话说,我们有一个我们想要缓存的每个 JS 文件的列表。更重要的是,资产清单会使用每个文件的新哈希更新,因此我们不必担心保持其最新。

因此,我们可以使用资产清单中的 URL 以及Cache.addAll()方法一次性缓存所有我们的 JavaScript 资产。但是,我们还需要手动将我们的静态资产(图像)添加到缓存中,但是为了这样做,我们将不得不利用我们的服务工作者生命周期方法并进行一些基本设置。

设置我们的缓存

在本节中,我们将通过我们的三个主要服务工作者生命周期事件,并在每个事件中单独与我们的缓存进行交互。最终,我们将自动缓存所有静态文件。

不过,要警告一下——在开发中使用缓存,充其量是可以容忍的,最坏的情况下是令人恼火的。我们对着屏幕大喊:“为什么你不更新?”直到我们意识到我们的缓存一直在提供旧代码;这种情况发生在我们每个人身上。在本节中,我们将采取措施避免缓存我们的开发文件,并躲避这个问题,但是在未来,请记住奇怪的错误可能是由缓存引起的。

在计算机科学中只有两件难事:缓存失效和命名事物。- Phil Karlton

另一个方法:

在计算机科学中有两个难题:缓存失效、命名事物和 off-by-1 错误。- Leon Bambrick

安装事件

当我们的服务工作者安装时,我们希望立即设置我们的缓存,并开始缓存相关的资产。因此,我们的安装事件的逐步指南如下:

  1. 打开相关的缓存。

  2. 获取我们的资产清单。

  3. 解析 JSON。

  4. 将相关的 URL 添加到我们的缓存中,以及我们的静态资产。

让我们打开firebase-messaging-sw.js并开始工作!

如果你仍然有console.log事件监听器用于安装,很好!删除console.log;否则,设置如下:

self.addEventListener('install', function() {

});

就在这个函数的上面,我们还会将我们的缓存对象名称分配给一个变量:

const CACHE_NAME = ‘v1’;

这个名称可以是任何东西,但我们希望每次部署时都提高版本,以确保旧的缓存无效,并且每个人都能获得尽可能新鲜的代码。

现在,让我们按照清单来运行。

打开缓存

在我们开始正题之前,我们需要谈谈可扩展事件。

一旦我们的服务工作线程被激活和安装,它可能会立即进入“等待”模式--等待必须响应的事件发生。然而,我们不希望它在我们正在打开缓存的过程中进入等待模式,这是一个异步操作。因此,我们需要一种告诉我们的服务工作线程的方法,“嘿,直到缓存被填充,不要认为自己完全安装了。”

我们通过event.waitUntil()来实现这一点。这个方法延长了事件的生命周期(在这里是安装事件),直到其中的所有 Promise 都被解决。

它看起来如下所示:

self.addEventListener('install', event => {
 event.waitUntil(
   // Promise goes here
 );
});

现在我们可以打开我们的缓存。我们的缓存 API 在全局变量 caches 中可用,所以我们可以直接调用caches.open()

const CACHE_NAME = 'v1';
self.addEventListener('install', event => {
 event.waitUntil(
   caches.open(CACHE_NAME)
     .then(cache => {
     });
 );
});

由于当前不存在名称为'v1'的缓存对象,我们将自动创建一个。一旦获得了该缓存对象,我们就可以进行第二步。

获取资产清单

获取资产清单听起来就像它听起来的那样:

self.addEventListener('install', event => {
 event.waitUntil(
   caches.open(CACHE_NAME)
     .then(cache => {
       fetch('asset-manifest.json')
         .then(response => {
           if (response.ok) {

           }
         })
     });
 );
});

请注意,在开发中我们不应该有 asset-manifest;在继续之前,我们需要确保请求响应是正常的,以免抛出错误。

解析 JSON

我们的asset-manifest.json返回了一些 JSON,相当令人惊讶。让我们解析一下:

self.addEventListener('install', event => {
 event.waitUntil(
   caches.open(CACHE_NAME)
     .then(cache => {
       fetch('asset-manifest.json')
         .then(response => {
           if (response.ok) {
             response.json().then(manifest => {

             });
           }
         })
     });
 );
});

现在我们有一个 manifest 变量,它是一个普通的 JavaScript 对象,与asset-manifest.json的内容相匹配。

将相关的 URL 添加到缓存

由于我们有一个 JavaScript 对象来访问 URL,我们可以挑选我们想要缓存的内容,但在这种情况下,我们想要一切,所以让我们遍历对象并获得一个 URL 数组:

response.json().then(manifest => {
  const urls = Object.keys(manifest).map(key => manifest[key]);
})

我们还想缓存index.html和我们的图标,所以让我们推入//assets/icon.png

response.json().then(manifest => {
  const urls = Object.keys(manifest).map(key => manifest[key]);
  urls.push(‘/’);
  urls.push('/assets/icon.png');
})

现在,我们可以使用cache.addAll()将所有这些 URL 添加到缓存中。请注意,我们指的是我们打开的特定缓存对象,而不是一般的 caches 变量:


self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => {
      fetch('asset-manifest.json').then(response => {
        if (response.ok) {
          response.json().then(manifest => {
            const urls = Object.keys(manifest).map(key => manifest[key]);
            urls.push('/');
            urls.push('/assets/icon.png');
            cache.addAll(urls);
          });
        }
      });
    })
  );
});

完成!我们已经进行了缓存,但目前还不值得多少,因为我们还没有办法从缓存中检索项目。让我们接着做。

获取事件

当我们的应用程序从服务器请求文件时,我们希望在服务工作线程内拦截该请求,并用缓存的文件进行响应(如果存在)。

我们可以通过监听 fetch 事件来实现这一点,如下所示:

self.addEventListener('fetch', event => {

});

作为参数传递的事件有两个有趣的属性。第一个是event.request,它是目标 URL。我们将使用它来查看我们的缓存中是否有该项,但事件还有一个名为respondWith的方法,基本上意味着“停止这个网络请求的进行,并用以下内容回应它。”

这里是不直观的部分--我们实质上是在调用event.respondWith后立即取消了这个 fetch 事件。这意味着如果我们的缓存中没有该项,我们必须开始另一个 fetch 请求(幸运的是,这不会触发另一个事件监听器;这里没有递归)。这是需要记住的一点。

因此,让我们调用event.respondWith,然后使用caches.match来查看我们是否有与 URL 匹配的文件:

self.addEventListener('fetch', event => {
 event.respondWith(
   caches.match(event.request).then(response => {

   });
 );
});

在这种情况下,响应要么是问题文件,要么是空。如果是文件,我们就返回它;否则,我们发起另一个 fetch 请求并返回其结果。以下是一行版本:

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(response => {
      return response || fetch(event.request);
    })
  );
});

就是这样!现在我们资产清单中的文件的所有 fetch 请求都将首先进入缓存,只有在该文件不在缓存中时才会进行实际的网络请求。

激活事件

激活事件是我们三个 service worker 事件中发生的第一个,所以可能看起来奇怪我们最后才谈论它,但这是有原因的。

激活事件是我们进行缓存清理时发生的。我们确保清除任何过期的缓存对象,以便我们的浏览器缓存不会变得太混乱并被终止。

为此,我们基本上删除任何名称与CACHE_NAME的当前值不匹配的缓存对象。

“但是,Scott,”你说,“如果我们的 service worker 没有正确更新,并且仍然包含旧的CACHE_NAME怎么办?”这是一个有效的观点。然而,正如所说的,我们的 service worker 应该在它与上一个 service worker 之间有字节大小的差异时自动更新,所以这不应该成为一个问题。

这次我们的过程不那么密集,但我们仍然来分解一下:

  1. 获取缓存名称列表。

  2. 循环遍历它们。

  3. 删除任何键不匹配CACHE_NAME的缓存。

一个快速提醒--如果你想将你的 CSS 保存在一个单独的缓存中,你可以拥有多个缓存。这样做没有真正的好处,但你可能喜欢有组织的东西。一个可行的方法是创建一个CACHE_NAMES对象,如下所示:

const VERSION = ‘v1’
const CACHE_NAMES = {
 css: `css-${VERSION}`,
 js: `js-${VERSION}`
};

然后,在随后的步骤中,我们将不得不迭代该对象;只是要记住的一些事情。

好的,让我们开始工作。

获取缓存名称列表

同样,我们必须在完成此异步代码时使用event.waitUntil()。这意味着我们最终将不得不返回一个Promiseevent.waitUntil(),这将影响我们编写代码的方式。

首先,我们通过调用cache.keys()来获取缓存键的列表,这会返回一个 promise:

self.addEventListener('activate', event => {
 event.waitUntil(
   cache.keys().then(keyList => {

   })
 );
});

循环遍历它们

我们需要遍历每个键,并调用caches.delete(),如果它不匹配我们的CACHE_NAME。由于我们可能有多个要删除的缓存,并且多次调用caches.delete(),它本身返回一个Promise,我们将在keyList上映射,并使用Promise.all()返回一组Promise

它看起来是这样的:

self.addEventListener('activate', event => {
 event.waitUntil(
   caches.keys().then(keyList => {
     Promise.all(keyList.map(key => {

     }));
   })
 );
});

删除任何键不匹配CACHE_NAME的缓存。

一个简单的if语句,然后调用caches.delete(),我们就完成了:

self.addEventListener('activate', event => {
 event.waitUntil(
   caches.keys().then(keyList => {
     Promise.all(
       keyList.map(key => {
         if (key !== CACHE_NAME) {
           return caches.delete(key);
         }
       })
     );
   })
 );
});

现在我们的缓存将恰好是我们想要的大小(仅在缓存对象上),并且每次我们的服务工作者激活时都会被检查。

因此,我们的缓存保持更新的机制是固有的。每次更新 JavaScript 时,我们都应该更新服务工作者中的版本。这会导致我们的服务工作者更新,从而重新激活,触发对先前缓存的检查和失效;一个美丽的系统。

测试我们的缓存

使用yarn start快速在本地运行您的应用程序,以检查是否有任何明显的错误(拼写错误等),如果一切正常,请启动yarn deploy

打开您的实时应用程序和 Chrome DevTools。在应用程序|服务工作者下关闭更新后重新加载,刷新一次,然后转到网络选项卡。您应该会看到类似以下的内容:

如果这不起作用,请尝试取消注册应用程序|服务工作者下的任何服务工作者,然后重新加载两次。

关键点是(来自服务工作者)在我们的 JavaScript 文件旁边。我们的静态资产是由我们的服务工作者缓存提供的,如果您滚动到网络选项卡的顶部,您将看到这样的情况:

文档本身是由服务工作者提供的,这意味着我们可以在任何网络条件下运行我们的应用程序,甚至是离线的;让我们试试。点击网络选项卡顶部的离线复选框,然后点击重新加载。

如果一切顺利,我们的应用程序的加载时间不应该有任何区别,即使我们没有网络连接!我们的应用程序仍然可以加载,我们的聊天消息也是如此。

消息加载是 Firebase 数据库的一个好处,不是我们的功劳,但是从缓存中加载文档,这才是真正的成就!

当然,我们的用户体验并没有很好地为离线访问做准备。我们应该有一种方式来通知用户他们当前处于离线状态,也许可以通过某种对话框,但我们将其作为一个目标。

总结

我们实现了渐进式梦想——一个可以在任何网络条件下工作的应用程序,包括完全没有网络的情况。缓存是一个复杂的主题,所以为自己的成就鼓掌吧。

然而,在我们过于兴奋并将我们的原型提交给 Chatastrophe 董事会之前,让我们确保我们做对了事情。我们需要一种方式来在我们的项目上盖上一个橡皮图章,上面写着“批准!这是一个渐进式网络应用!”。

幸运的是,一个名为 Google 的小型初创公司已经给了我们一个可以做到这一点的工具。

接下来是对我们完成的渐进式网络应用进行审计,也就是胜利之旅。

第十三章:审核我们的应用程序

审核是确认我们的渐进式 Web 应用程序是否真正符合 PWA 标准的一种方式。这种审核是我们检查工作并确保我们的应用在 PWA 功能方面尽可能好的重要最后一步。

如前所述,渐进式 Web 应用程序的最大支持者是谷歌。他们的 Chrome 浏览器和 Android 操作系统不仅是所有 PWA 友好的,而且谷歌还非常努力地教育开发人员如何以及为什么构建 PWA。当您进入 PWA 的世界时(超出本书范围),您可能经常会查阅他们的文档。

然而,谷歌提供了另一种引领渐进式网络前进的方式。为了确保您的网页或应用程序的质量,他们发布了一套工具来衡量您的网站是否符合一组标准。他们用来做到这一点的主要工具称为 Lighthouse。

以下是本章将涵盖的内容:

  • Lighthouse 是什么?

  • 它遵循哪些标准?

  • DevTools 中的审核标签是什么?

  • 运行我们的第一次审核

  • 评估读数

  • 使用 Lighthouse CLI

Lighthouse 是什么?

简而言之,Lighthouse是一个工具,运行您的网站并告诉您基于一组特定标准它到底有多渐进式。

它通过尝试在各种条件下加载页面(包括 3G 网络和离线),并评估页面的响应方式来实现。它还检查一些 PWA 的常规功能,例如启动画面和服务工作者。

标准

以下标准本质上是 Lighthouse 在查看您的应用程序时遵循的一份清单。每个“测试”都是一个简单的是/否。如果您通过所有测试,您将获得 100 分。这就是我们想要的!

以下是 2017 年 8 月的标准列表:

  • 注册服务工作者:服务工作者是使您的应用能够使用许多渐进式 Web 应用程序功能的技术,例如离线、添加到主屏幕和推送通知。

  • 离线时响应 200:如果您正在构建渐进式 Web 应用程序,请考虑使用服务工作者,以便您的应用程序可以离线工作。

  • 当 JavaScript 不可用时包含一些内容:即使只是警告用户 JavaScript 是必需的,您的应用程序也应在 JavaScript 被禁用时显示一些内容。

  • 配置自定义启动画面:您的应用将构建一个默认的启动画面,但满足这些要求可以保证一个高质量的启动画面,让用户从点击主屏幕图标到应用的首次绘制有一个流畅的过渡。

  • 使用 HTTPS:所有网站都应该使用 HTTPS 进行保护,即使不处理敏感数据的网站也是如此。HTTPS 可以防止入侵者篡改或被动监听您的应用与用户之间的通信,并且是 HTTP/2 和许多新的网络平台 API 的先决条件。

  • 将 HTTP 流量重定向到 HTTPS:如果您已经设置了 HTTPS,请确保将所有 HTTP 流量重定向到 HTTPS。

  • 3G 网络下的页面加载速度足够快:如果交互时间短于 10 秒,即满足 PWA 基准检查表中的定义(来源--developers.google.com/web/progressive-web-apps/checklist),则满足此标准。需要进行网络限速(具体来说,预期的 RTT 延迟>=150 RTT)。

  • 用户可以被提示安装 Web 应用:虽然用户可以手动将您的网站添加到其主屏幕,但如果满足各种要求并且用户对您的网站有适度的参与度,提示(也称为应用安装横幅)将主动提示用户安装应用。

  • 地址栏与品牌颜色匹配:浏览器地址栏可以进行主题设置以匹配您的网站。当用户浏览网站时,theme-color元标签将升级地址栏,一旦添加到主屏幕后,清单主题颜色将在整个网站上应用相同的主题。

  • 具有带有宽度或初始缩放的标签:添加viewport元标签以优化您的应用在移动屏幕上的显示。

  • 内容在视口中正确调整大小:如果您的应用内容的宽度与视口的宽度不匹配,您的应用可能没有针对移动屏幕进行优化。

审核标签

直到 Chrome 60 发布之前,Lighthouse 只能作为 Chrome 扩展程序或命令行工具的测试版版本。然而,现在它在 Chrome DevTools 中有了自己的位置,在新的审核标签中。

在审核标签中,除了 Lighthouse PWA 审核之外,还包括一系列其他基准测试,包括性能和网络最佳实践。我们将专注于 PWA 测试和性能测试,但也可以随意运行其他测试。

审计选项卡的另一个有用功能是能够保存先前的审计,以便在改进应用程序时获得应用程序的历史记录。

好了,说够了。让我们继续进行我们的第一次审计!

我们的第一次审计

打开您的 DevTools,导航到审计选项卡,然后单击运行审计。

应该需要几秒钟,然后给您一个关于我们网站外观的简要摘要,鼓掌。我们的渐进式 Web 应用程序有多好呢?:

一点也不糟糕。事实上,在 PWA 类别中没有比这更好的了。给自己一个鼓励,也许是一个成功的高五。让我们评估读数,然后决定是否要继续前进或者争取在所有类别中达到 100%。

请注意,由于 Lighthouse 正在积极开发中,您的分数可能与上述不符合新的标准。在这种情况下,我鼓励您查看 Lighthouse 所抱怨的内容,并看看是否可以解决问题以达到“100”分。

评估读数

如果您的结果与前面的不符,有两种可能性:

  • Chrome 添加了我们的应用程序无法满足的新测试。正如我们多次提到的,PWA 是一种不断发展的技术,所以这是完全可能的。

  • 您在书中错过了一些步骤;最好的人也会发生这种情况。

无论哪种情况,我都鼓励您进行调查并尝试解决根本问题。谷歌为每个测试标准提供了文档,这是一个很好的起点。

在我们的情况下,我们唯一没有通过的测试是性能。让我们看看我们没有通过的原因:

正如我们在这里看到的,我们的第一个有意义的绘制大约需要三秒钟。请注意,我们的应用程序外壳不被视为有意义的绘制,尽管它确实改善了页面的感知性能。Chrome 足够聪明,知道只有当我们的“登录”表单或“聊天”容器出现时,我们才真正在屏幕上有有意义的内容--用户实际可以使用的东西。

尽管如此,显示有意义的内容需要超过三秒的原因是,我们需要等待我们的 JavaScript 加载,启动,然后加载我们的用户当前是否已登录,然后加载聊天消息或重定向到登录。这是很多后续步骤。

这是一个可以解决的问题吗?也许可以。我们可以设置一些方式,在 React 加载之前找出用户是否已登录(换句话说,将一些 JavaScript 移出我们的主应用程序)。我们可以将chat容器和login表单都移出 React,以确保它们可以在库加载之前呈现,然后想出一些方法在 React 初始化后替换它们(挑战在于替换输入而不擦除用户已开始输入的任何内容)。

所有提到的挑战都属于优化关键渲染路径的范畴。对于任何想深入了解性能优化的人,我鼓励你去尝试一下。然而,从商业角度来看,这对于一点收益来说是很多(可能有错误)优化。根据先前的基准测试,我们的用户已经在大约 400 毫秒内接收到内容,并且完整的应用程序在三秒多一点的时间内加载完成。请记住,由于缓存,大多数用户在随后的访问中将获得更快的加载时间。

我们较低的性能得分实际上展示了使用诸如 React 之类的庞大 JavaScript 库构建高性能应用程序的成本效益。对于那些对更轻量级替代方案感兴趣的人,在下一章节中查看关于 Preact 的部分,这可能是解决前述问题的一个可能方案。

使用 Lighthouse CLI

从审计选项卡运行测试非常简单易行,但我们如何确保在将应用程序推送到线上之前保持应用程序的质量呢?

答案是将 Lighthouse 纳入我们的部署流程,并使用它自动评估我们的构建。这类似于在我们执行yarn deploy时运行测试套件。幸运的是,谷歌为此目的提供了 Lighthouse CLI。

让我们使用以下命令进行安装:

yarn add --dev lighthouse

在这里,我们的目标是在执行yarn deploy时在我们的应用程序上运行 Lighthouse。为此,我们必须制作一个自定义部署脚本。

如果打开我们的package.json,你会在scripts下看到以下内容:

 "scripts": {
   "build": "node_modules/.bin/webpack --config webpack.config.prod.js",
   "start": "node_modules/.bin/webpack-dev-server",
   "deploy": "npm run build && firebase deploy"
 },

让我们将其更改为以下内容:

 "scripts": {
   "build": "node_modules/.bin/webpack --config webpack.config.prod.js",
   "start": "node_modules/.bin/webpack-dev-server",
   "deploy": "npm run build && node scripts/assess.js && firebase deploy"
 },

我们将使用 node 来运行一个用 JavaScript 编写的自定义构建脚本。在你的目录根目录下创建scripts/文件夹,以及assess.js文件。

我们的流程将如下:

  1. 在本地提供我们的build文件夹,以便在浏览器中运行。

  2. 使用 Lighthouse 评估提供的页面。

  3. 在控制台记录结果。

让我们添加我们需要用来提供我们的build文件夹的包:

yarn add --dev serve

请注意,鉴于我们永远不会在生产中使用它们,我们将这个和lighthouse保存为dev依赖项。

服务我们的构建文件夹

在我们的新scripts/assess.js中,要求serve包:

const serve = require('serve');

我们只想要在端口 5000 上serve我们新编译的build文件夹,看起来是这样的:

const server = serve('./build', {
 port: 5000
});

我们可以随时通过运行server.stop()来停止服务器。我们会在显示分数后这样做。

使用 Lighthouse 来评估提供的页面

现在,让我们在assess.js的顶部要求另外两个工具:

const lighthouse = require('lighthouse');
const chromeLauncher = require('lighthouse/chrome-launcher');

chromeLauncher将允许我们打开 Chrome 到目标页面,然后运行 Lighthouse。让我们创建一个名为launchChromeAndRunLighthouse的函数来做到这一点:

function launchChromeAndRunLighthouse(url, flags= {}, config = null) {

}

我们可以选择传入一些标志和配置,这里我们不会使用(标志可以用来在过程展开时打开日志记录)。

在函数内部,我们将启动 Chrome,设置 Lighthouse 运行的端口,然后运行它。最后,我们将停止 Chrome:

function launchChromeAndRunLighthouse(url, flags = {}, config = null) {
 return chromeLauncher.launch().then(chrome => {
   flags.port = chrome.port;
   return lighthouse(url, flags, config).then(results =>
     chrome.kill().then(() => results));
 });
}

顺便说一句,这个函数直接来自 Lighthouse CLI 文档。

好了,现在是最后一步了。我们将使用我们选择的 URL 运行我们的函数(将其放在文件底部,在serve命令下方):

launchChromeAndRunLighthouse('http://localhost:5000', {}).then(results => {
  server.stop();
});

一旦我们有了结果,我们就停止服务器,但我们需要正确显示我们的结果。

记录结果

结果变量以对象的形式出现。它提供了每个类别的详细分数,但我们只关心有问题的地方。在我们的函数调用之前,让我们添加一个分数截止线:

const CUTOFF = 90
launchChromeAndRunLighthouse('http://localhost:5000', {}).then(results => {

我们将使用这个来说“只显示得分低于 90/100 的结果”。

登出结果的过程并不是很令人兴奋,所以我们不会在这里深入讨论。以下是完整的文件:

const serve = require('serve');
const lighthouse = require('lighthouse');
const chromeLauncher = require('lighthouse/chrome-launcher');

function launchChromeAndRunLighthouse(url, flags = {}, config = null) {
 return chromeLauncher.launch().then(chrome => {
   flags.port = chrome.port;
   return lighthouse(url, flags, config).then(results =>
     chrome.kill().then(() => results));
 });
}

const server = serve('./build', {
 port: 5000
})

const CUTOFF = 90

launchChromeAndRunLighthouse('http://localhost:5000', {}).then(results => {
 score = results.score
 const catResults = results.reportCategories.map(cat => {
   if (cat.score < CUTOFF) {
     cat.audits.forEach(audit => {
       if (audit.score < CUTOFF) {
         const result = audit.result
         if (result.score) {
           console.warn(result.description + ': ' + result.score)
         } else {
           console.warn(result.description)
         }
         if (results.displayValue) {
           console.log('Value: ' + result.displayValue)
         }
         console.log(result.helpText)
         console.log(' ')
       }
     })
   }
   return cat
 })
 catResults.forEach(cat => {
   console.log(cat.name, cat.score)
 })
 server.stop()
});

如果您从终端运行node scripts/assess.js,您应该会看到一个问题区域的列表,以及每个类别的最终得分。通过运行yarn deploy将所有内容汇总在一起,您将在 Firebase 部署之前看到这些分数。

现在我们有了一个简单而干净的方法来随时了解我们应用程序的状态,而不必自己启动网站来测试它。

总结

完成!我们对我们的应用进行了全面审查,它在每个类别都表现出色。我们有一个可用的渐进式 Web 应用程序。在本章中,我们了解了 Lighthouse 是什么,以及为什么验证我们的 PWA 很重要。我们还将其作为部署过程的一部分,以确保我们的应用程序继续符合质量标准。现在我们可以认为我们的应用在各个方面都已经完成。

接下来,我们将讨论后续步骤以及增加对 PWA 知识的有用资源,但首先,关于将我们的应用提交给你的朋友和 Chatastrophe 委员会。

第十四章:结论和下一步

“……这就是应用程序根据谷歌的评分。正如您所看到的,它符合渐进式 Web 应用的每个标准,这将与我们的全球业务目标很好地契合——”

“是的,是的,”你的朋友挥了挥手。“很酷。很棒。干得好。但枢纽呢?”

“什么?”你问道。

“你没收到备忘录吗?我一个月前就给你公司邮箱发了一封备忘录。”

“我不知道我有公司邮箱。”

“哦。”你的朋友皱起了眉头。“我以为你对技术很在行。”

“但我不知道——”

“没关系。我可以总结一下。公司已经转变了。聊天很棒,但如果我们再进一步呢?如果我们把它变成一个社交网络呢?想象一下——Facebook 的可分享性,Netflix 的视频流和 Uber 的顺风车,所有这些都在一个区块链上……”

当你走向门口时,你的朋友继续说话。

下一步

我们已经涵盖了将 React 应用程序转变为 PWA 所需的每一步,但是,像往常一样,还有更多要学习的。

本章分为四个部分。首先,我们将列出一些有用的资源,以继续您的 PWA 之旅。然后,我们将介绍一些重要的库,这些库将帮助自动化 PWA 开发的某些方面,或将您的应用程序提升到更高的水平。第三,我将列出一些我最喜欢的关于开发渐进式 Web 应用的文章。最后,我们将看一下一些可能的扩展目标,以便您在接受挑战后扩展和改进 Chatastrophe。

以下许多资源都是通过两个优秀的存储库发现的:awesome-pwa (github.com/hemanth/awesome-pwa) 由 GitHub 用户Hemanth创建,以及awesome-progressive-web-apps (github.com/TalAter/awesome-progressive-web-apps) 由TalAter创建。

我们将看一下以下内容:

  • 扩展您知识的学习资源

  • 成功 PWA 的案例研究

  • 可以从中获得灵感的示例应用程序

  • 关于 PWA 崛起的必读文章

  • 您可以使用的工具来使未来 PWA 的构建更容易

  • Chatastrophe 的扩展目标

学习资源

学习资源如下:

  • 渐进式 Web 应用文档:谷歌关于渐进式 Web 应用的官方文档。这应该是您的第一站,以了解概念或阅读最佳实践。它还提供了关于 PWA 的好处的摘要,并链接到诸如 Lighthouse 之类的工具。

developers.google.com/web/progressive-web-apps/

  • 你的第一个渐进式 Web 应用:一个逐步教程,教你如何构建你的第一个渐进式 Web 应用,或者在你的情况下,你的第二个。如果你想看看没有 React 的情况下构建 PWA 是什么样子,可以看看这个教程。这是非常详细的,涵盖了每个概念。

developers.google.com/web/fundamentals/getting-started/codelabs/your-first-pwapp

  • 离线 Web 应用:由 Google 创建并由 Udacity 托管的免费课程,关于离线优先的 Web 应用。内容分为三个部分:为什么优先离线、Service Workers 和缓存。一些部分,比如 service worker 部分,可能会是复习,但这门课程还深入探讨了 IndexedDB 用于本地存储。

www.udacity.com/course/offline-web-applications--ud899

  • Service Worker 入门:Google 对 Service Workers 的介绍。很多代码看起来会很熟悉,因为它们在本书的 service worker 部分中出现过,但它仍然是一个方便的资源。Matt Gaunt 做了很好的工作,解释了基础知识。

developers.google.com/web/fundamentals/getting-started/primers/service-workers

  • Service Worker 101:关于 service workers 的更加生动的指南,这个可爱的资源包含一系列图表,带你了解 service worker 的生命周期等内容。如果你对 service workers 不确定,可以打印出来贴在你的桌子上。

github.com/delapuente/service-workers-101

  • 开始使用渐进式 Web 应用:Chrome 开发团队的 Addy Osmani 的一篇博客文章(我们将在这个资源部分经常看到他)。这是一个很好的高层次介绍 PWA 的好处,并介绍了一些起步的模板。

addyosmani.com/blog/getting-started-with-progressive-web-apps/

  • 使用 Push API:Mozilla 开发者网络关于 Push API 的指南。如果你想在你的 PWA 中使用推送通知,而不依赖于 Firebase Cloud Notifications,就从这里开始。

developer.mozilla.org/en-US/docs/Web/API/Push_API/Using_the_Push_API

  • 使用缓存 API:Mozilla 开发者网络对缓存 API 的指南。在这里没有太多新东西,我们在缓存章节中没有涵盖到的,但鉴于缓存 API 的“实验性”状态,回头参考一下是很好的。这项技术可以从目前的状态发展,所以把它作为一个参考。

developer.mozilla.org/en-US/docs/Web/API/Cache

  • 通过应用安装横幅增加用户参与度:应用安装横幅的如何和为什么。一个详尽的常见问题解答了你可能有的任何问题。还有一个关于推迟提示的很棒的教程,你可以用它来巩固我们在第九章中涵盖的概念,使用清单使我们的应用可安装

developers.google.com/web/updates/2015/03/increasing-engagement-with-app-install-banners-in-chrome-for-android?hl=en

  • Web 基础-性能:谷歌关于构建高性能 Web 应用的资源。值得注意的是,谷歌对性能有一个特定的哲学,属于 PWA 模型,但不一定是更好性能的唯一途径。也就是说,对于任何对速度感兴趣的人来说,这是一个很棒的(有时过于技术性的)资源。

developers.google.com/web/fundamentals/performance/

  • 引入 RAIL:面向用户的性能模型:这篇文章以“性能建议不胜枚举,是吗?”开篇。这是真实的话,尽管Paul IrishPaul Lewis的建议比大多数更好。这篇文章特别关注为什么我们应该遵循这个指标来介绍 RAIL。答案?用户应该放在第一位。

www.smashingmagazine.com/2015/10/rail-user-centric-model-performance/

  • 渐进式 Web 应用通讯简报:我的免费通讯,让你了解渐进式 Web 应用的世界,包括教程、文章、有趣的项目等。如果你想要联系我,只需点击下一期的“回复”。我会很乐意收到你的来信。

pwa-newsletter.com/

  • 网站性能优化:另一个由谷歌和 Udacity 合作的课程,这次是关于优化性能的课程。它介绍了 DevTools 并深入探讨了关键渲染路径等概念。这门课程应该需要大约一周的时间来完成。

www.udacity.com/course/website-performance-optimization--ud884

  • 浏览器渲染优化:这里还有一个!这门课程的副标题是“构建 60 FPS Web 应用”,这是一个值得追求的目标(正如我们的 RAIL 指标建议的那样)。它可以被认为是前面课程的更深入版本。在完成这门课程后,你可以称自己为 Web 性能专家。

www.udacity.com/course/browser-rendering-optimization--ud860

  • 使用 React 构建渐进式 Web 应用Addy Osmani再次出现。在这里,他带领我们使用 React 构建 PWA。请注意,这个教程更像是一个概述,而不是一个逐步指南,但在我写这本书时,这对我来说是一个非常宝贵的资源。他还提供了许多链接到其他文章和资源,以进一步扩展你的知识。

medium.com/@addyosmani/progressive-web-apps-with-react-js-part-i-introduction-50679aef2b12

  • Service Worker Cookbook:关于 service workers 的一切你想知道的东西。说真的,这是一个了不起的资源,会让你很快成为专家。如果你对这项新技术感到兴奋并想深入了解,这是一个很好的机会。

serviceworke.rs/

  • 将你的网站改造成 PWA:大多数公司不会立即从头开始构建 PWA。相反,他们会希望将 PWA 功能添加到他们现有的网站或应用中。这是一个很好的入门指南,并附有大量的截图。

www.sitepoint.com/retrofit-your-website-as-a-progressive-web-app/

案例研究

你需要说服老板尝试渐进式 Web 应用吗?看看以下大公司采用 PWA 的案例研究(Chatastrophe Inc.因破产而被移出列表)。

构建 Google I/O 2016 渐进式 Web 应用

Google I/O 2016 应用程序(昵称 IOWA)是使用 Firebase 和 Polymer 构建的。这就是他们的做法。这是一个更加技术性的指南,介绍了几个高级概念;这是一个了解下一级 PWA 的好方法。

developers.google.com/web/showcase/2016/iowa2016

AliExpress 案例研究

AliExpress 是俄罗斯访问量最大的电子商务网站。通过转换为 PWA,他们将新用户的转化率提高了 104%。他们还将在网站上花费的时间增加了 74%。这些都是很大的数字,为 PWA 提供了一个有力的商业案例。

developers.google.com/web/showcase/2016/aliexpress

eXtra Electronics 案例研究

这对于业务改进来说怎么样--销售额增加了 100%。这就是 eXtra Electronics 通过网络推送通知到达的用户所取得的成就。事实上,网络推送通知现在是 eXtra 最大的留存渠道,超过了电子邮件。更加努力!

developers.google.com/web/showcase/2016/extra

Jumia 案例研究

又一个关于网络推送通知的好消息。Jumia 的转化率增加了 9 倍。他们过去会发送电子邮件提醒顾客购物车中剩下的物品,但开启率很低。现在引入了通知。

developers.google.com/web/showcase/2016/jumia

Konga 案例研究

你的用户关心他们的数据限制;不要让他们受苦。Konga 将他们的原生应用与 PWA 进行比较,将数据使用量减少了 92%。最终,用户完成第一笔交易所需的数据减少了 84%。考虑到入门的障碍降低了。

developers.google.com/web/showcase/2016/konga

SUUMO 案例研究

通过添加服务工作者和一些其他调整,SUUMO 团队将加载时间减少了 75%。他们还利用了推送通知的热潮,开启率达到了 31%。尝试 PWA 的决定背后的故事可能听起来很熟悉;移动体验很差,所以公司将用户推向原生应用。然而,让他们下载原生应用却很困难,所以他们尝试了 PWA。一个很好的教训--如果你的问题是留存,原生应用可能不是答案。

developers.google.com/web/showcase/2016/suumo

示例应用程序

想看看真正的渐进式 Web 应用程序是什么样子吗?看看以下任何一个。其中一些还包含 GitHub 的链接,供您查看源代码。

PWA.rocks

这是一个渐进式 Web 应用程序的集合,也是以下大部分内容的来源。如果您需要灵感,可以将其作为第一站。我还鼓励您将您添加到列表中的任何 PWA 添加到其中。

pwa.rocks/

Flipboard

Flipboard 是 PWA 领域中最重要的参与者之一,他们的 PWA 应用程序体积小,速度快,而且美观。Flipboard 拥有功能齐全的原生应用程序,但也有 PWA,以便在用户偏好方面进行押注。如果内容丰富的 Flipboard 能够符合 PWA 的性能指南,那么天空就是极限。

flipboard.com/

React Hacker News

这是一个备受欢迎的开发者项目:使用 React 克隆的 Hacker News。作为一个开源项目,ReactHN 是了解如何使用渐进式 Web 应用程序基本原理来管理复杂的前端库的好方法。我们的好朋友Addy Osmani再次出马。因此,ReactHN 是一个深入了解 Chrome 开发人员如何使用 JavaScript 库构建 PWA 的内部视角。

react-hn.appspot.com/

Notes

这是一个很好的、体积小的渐进式 Web 应用程序的例子,值得初学者关注。您可以在网站上直接找到 GitHub 的链接,然后查看Simon Evans应用程序的结构。在桌面上,应用程序外壳与内容有明显的区别,这使得概念特别直观。最重要的是,该应用在 Lighthouse 上得分 94 分。

sii.im/playground/notes/

Twitter

也许你听说过这个。

Twitter 是一个真正全球化应用程序的完美例子。他们的应用程序需要能够被所有大陆的用户在各种条件下访问(只需看看 Twitter 在组织阿拉伯之春中所扮演的角色)。

为了实现全球可访问性,Twitter 团队设法将他们的应用程序减小到 1MB,并添加了本文讨论的所有 PWA 功能:主屏幕安装、推送通知和离线访问。

lite.twitter.com/

2048 Puzzle

2048 拼图游戏的 PWA 实现,最初由 Veewo Studio 创建。它只适用于移动/触摸设备,但它是一个游戏应用程序被制作成 PWA 的例子,它快速、高效且可安装。请注意-对于未经培训的人来说,这个游戏非常容易上瘾。

这个开源项目可以在 GitHub 上找到,所以你可以查看结构(特别是 JavaScript 的结构,需要十个文件来运行游戏)。然而,这个应用的不可告人的秘密是,创作者实际上从未打过这个游戏。

2048-opera-pwa.surge.sh/

阅读的文章

以下文章涵盖了宣言、教程和清单,都是关于 PWA 的崛起以及构建它们的最佳方法。

原生应用注定要失败

JavaScript 大师Eric Elliott对渐进式 Web 应用的热情宣言。这是对原生应用成本和 PWA 好处的深入探讨。这是一个很好的材料,可以说服正在辩论是否要构建原生应用的老板和同事。后续文章也很棒。

medium.com/javascript-scene/native-apps-are-doomed-ac397148a2c0

渐进式 Web 应用的一大堆技巧和窍门

Dean Hume的各种 PWA 技巧的大杂烩。看看有趣的东西,比如离线 Google Analytics 和测试服务工作者(随着我们继续前进,会有更多内容)。

deanhume.com/Home/BlogPost/a-big-list-of-progressive-web-app-tips-and-tricks/10160

测试服务工作者

服务工作者是渐进式 Web 应用功能的核心。我们希望确保它们正常工作。我们如何对它们进行单元测试?

medium.com/dev-channel/testing-service-workers-318d7b016b19

Twitter Lite 和高性能 React 渐进式 Web 应用的规模

Twitter Lite 工程师之一深入探讨了他们的构建过程、挑战,并在开发 Twitter 的 PWA 版本后提出了建议。这是关于部署大规模 PWA 的最接近的操作指南。

medium.com/@paularmstrong/twitter-lite-and-high-performance-react-progressive-web-apps-at-scale-d28a00e780a3

为什么应用安装横幅仍然存在?

这是一个关于当你不是市场领导者时坚持传统应用程序的成本以及渐进式 Web 应用程序如何解决这个问题的优秀总结。阅读到最后,了解一些从原生应用程序转换为 PWA 的公司的统计数据。

medium.com/dev-channel/why-are-app-install-banners-still-a-thing-18f3952d349a

使用 Vue JS 创建渐进式 Web 应用程序

Charles Bochet结合了 VueJS,Webpack 和 Material Design 的元素来构建 PWA。这是一个很好的机会,可以在一个新的库中尝试 PWA 概念。

blog.sicara.com/a-progressive-web-application-with-vue-js-webpack-material-design-part-3-service-workers-offline-ed3184264fd1

将现有的 Angular 应用程序转换为渐进式 Web 应用程序

将常规 Angular 应用程序转换为功能性渐进式 Web 应用程序需要什么?Coskun Deniz一步一步地带领我们完成了这些步骤。

medium.com/@cdeniz/transforming-an-existing-angular-application-into-a-progressive-web-app-d48869ba391f

推动 Web 前进

“实际上,任何网站都可以,也应该成为渐进式 Web 应用。”

Jeremy Keith在他的文章中提出了“推动 Web”的主要论点,他是对的。使您的应用程序(或静态站点)成为渐进式,是为所有用户提供增强体验。对于任何对跳入 PWA 世界持怀疑态度的人来说,这是一篇很好的阅读。

medium.com/@adactio/progressing-the-web-9ab55f63f9fa

设计降级-敌对环境的 UX 模式

Chipotle 餐厅如何帮助改进您的网站。本文并不特别讨论 PWA,但与渐进增强的概念完全契合,即您的网站应该适用于所有人,然后根据他们的条件(网络速度,浏览器现代性等)变得越来越好。

uxdesign.cc/designed-degradations-ux-patterns-for-hostile-environments-7f308d819e50

使用应用程序外壳架构实现即时加载 Web 应用程序

对应用程序外壳模式的深入解释。如果你正在开发 PWA,这是必读的。

medium.com/google-developers/instant-loading-web-apps-with-an-application-shell-architecture-7c0c2f10c73

欺骗用户,让他们觉得你的网站比实际更快

一篇很棒的文章,从用户的角度出发(感知时间与实际时间),然后解释了你可以利用的基本技术,来减少应用的感知加载时间。

www.creativebloq.com/features/trick-users-into-thinking-your-sites-faster-than-it-is

苹果拒绝支持渐进式网络应用对未来的网络是一个损害

一个令人耳目一新的诚实看待在这个时候开发渐进式网络应用的经历,以及与 iOS 相关的挣扎。如果你正在考虑生产 PWA,请阅读这篇文章。

m.phillydevshop.com/apples-refusal-to-support-progressive-web-apps-is-a-serious-detriment-to-future-of-the-web-e81b2be29676

工具

希望你将来会构建(许多)更多的渐进式网络应用。以下工具将使这个过程更容易、更快速。

Workbox

Workbox 是“一组用于渐进式网络应用的 JavaScript 库”。更具体地说,它“使创建最佳服务工作者代码变得容易”,并以最有效的方式维护你的缓存。它很容易集成到 Webpack 中。不幸的是,文档不是很好,定制可能会很困难。

然而,Workbox 有很大的潜力,可以自动化开发的各个方面,并隐藏可能会让新开发者望而却步的复杂性。挑战在于不要用更多的复杂性来取代这种复杂性。

github.com/GoogleChrome/workbox

Sw-precache

作为 Workbox 的子集,sw-precache 值得单独讨论。它可以用来自动生成一个服务工作者,以预缓存你的应用程序资产。你所需要做的就是将它纳入你的构建过程(有一个 Webpack 插件),并注册生成的服务工作者。

github.com/GoogleChrome/sw-precache

Sw-toolbox

不适合初学者!与前面的生成工具不同,sw-toolbox 是一组辅助方法。更令人困惑的是,还有 Google Chrome 团队的 Workbox,采用了更模块化的方法。我给你的建议是,先熟悉直接与服务工作者交互,然后,如果你有一个特定的问题可以通过这些工具之一简化,那就采用它。但是,不要去寻找解决你尚未遇到的问题的工具,但像我说的,看到出现的工具来帮助管理复杂性是令人兴奋的。

github.com/GoogleChrome/sw-toolbox

Offline-plugin

另一个使你的应用程序具有离线功能的插件。这个插件使用服务工作者,但为了更好的支持,会回退到 AppCache API。实现看起来简单而直接。

github.com/NekR/offline-plugin

Manifest-json

一个工具,可以从命令行自动生成 Web 应用程序清单。我的意思是,我觉得我的清单章节还不错,但如果你更喜欢问答式的方法,那也可以,我想。

开玩笑的,这个工具可能会在 Web App 清单发展并承担更多属性时派上用场。

www.npmjs.com/package/manifest-json

Serviceworker-rails

有一个 Ruby on Rails 项目吗?想让你的资产管道使用服务工作者来缓存资产吗?使用这个宝石。文档是 Rails 如何处理缓存和实现服务工作者方法的有趣概述。

github.com/rossta/serviceworker-rails

Sw-offline-google-analytics

前面提到的 Workbox 的一部分,但专门用于具有离线功能的应用程序的 Google Analytics。使用此软件包在连接可用时发送离线请求到 Google Analytics。

www.npmjs.com/package/sw-offline-google-analytics

Dynamic Service Workers (DSW)

使用 JSON 文件配置你的服务工作者;这是一种非常有趣的服务工作者方法,支持关键功能,如推送通知(尽管只能使用 Google Cloud Messaging)。

github.com/naschq/dsw

UpUp

在您的网站上添加两个脚本,并使其在离线状态下工作。UpUp 是服务工作者技术的美丽实现,适用于简单的用例。当然,它并不适用于高级用户,但是是向每个人介绍服务工作者技术的绝佳方式。

www.talater.com/upup/

生成渐进式 Web 应用

从命令行生成渐进式 Web 应用文件结构!这仍然是一个正在进行中的工作。

github.com/hemanth/generator-pwa

渐进式 Web 应用配置

另一个来自 Addy Osmani 的样板文件。如果您要构建非 React PWA,请参考此项目结构。

github.com/PolymerLabs/progressive-webapp-config

延伸目标

Chatastrophe 已经启动,但仍然非常基础。现在我们将讨论一些挑战,您可以选择接受,以拓展您的技能并改进我们的应用。

切换到 Preact

Preact 是 React 库的 3 KB 版本。它具有类似的 API 和功能,但没有冗余。使用它而不是 React 将提高我们应用的性能。如果您选择这条路线,请考虑使用 Webpack 别名来简化转换。

github.com/developit/preact

显示在线状态

告诉其他用户另一个用户何时在线。UI 由您决定。

显示正在输入

在聊天室中常见的功能,用于向用户指示其他人正在输入。对于 Chatastrophe,挑战将是同时表示多个用户正在输入。

包括文件上传

人们想要与朋友分享(可能是表情包)。为他们提供一个文件上传系统。

创建聊天室

您的朋友曾经有一个真正全球聊天室的愿景;那个愿景很糟糕。让我们通过允许用户创建聊天室来大大提高 Chatastrophe 的可用性。是否有一种方法可以让用户在离线状态下在房间之间导航并阅读消息?

无需 React 即可交互

阻碍我们性能的一个问题是需要 React 在向用户显示交互式站点之前启动。如果我们给他们一个纯 HTML 交互式外壳,然后在加载时切换到 React 版本会怎样?这里的挑战将是避免覆盖用户输入,但您可以赢得一些巨大的性能点。

构建自己的后端

在本教程中,我们依赖 Firebase,以便将注意力集中在前端,即 React 开发上。然而,在为 Chatastrophe 设计自己的后端 API 方面,有很大的学习机会。最大的好处是可以对页面进行服务器渲染,以获得额外的性能。

结束语

编程很困难。学习也很困难。在学习全新概念的同时使用实验性技术进行编程尤其困难。如果你完成了本书中的教程,甚至只是其中的某些部分,你应该为此感到自豪。

我真诚地希望你在这里学到的东西对你的职业有所帮助。这本书对我来说也是一次激动人心的旅程。当我开始时,我对渐进式 Web 应用程序的世界感到兴奋,但绝不是专家。现在,深入研究渐进式 Web 后,我对可能性比以往任何时候都更加兴奋。我希望你也有同样的感觉。

如果你想要联系我,我会很乐意听到你的反馈、批评、问题,或者只是闲聊。你可以在 Twitter 上找到我,用户名是@scottdomes,在 LinkedIn 上找到我,也可以在 Medium 上找到我,用户名同样是@scottdomes,或者在我的网站scottdomes.com上找到我,我在那里发布关于各种主题的 Web 开发教程。

总结

我希望提到的资源对你继续 PWA 之旅有所帮助。PWA 是 Web 开发中令人兴奋的一部分,发展迅速;关注前任作者和创作者将帮助你跟上变化的步伐。

在这本书中,我们涵盖了很多内容:从零到一个 React 应用程序,再从一个 React 应用程序到一个渐进式 Web 应用程序。我们从头开始构建了一个完整的应用程序,并部署到世界上可以看到它。我们还使它快速响应,并能够处理各种类型的连接。

我希望你为最终的应用感到自豪,也希望这本书对你有所帮助。祝你未来的所有 PWA 项目好运,让我们继续推动 Web 的发展。

posted @ 2024-05-16 14:50  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报