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

69 阅读47分钟

React 渐进式 Web 应用(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:使用 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 的重复。

总结

推送通知很棘手。在本章中,我们不得不写很多代码,但很少有基准可以在其中检查。如果你遇到问题,请确保你的所有代码与示例匹配。

一旦您的通知功能正常工作,我们将填补网络应用和本地应用之间的重要差距。现在,是时候迈向本地应用的世界,让用户可以安装我们的应用程序了。