React-和库教程-三-

101 阅读56分钟

React 和库教程(三)

原文:React and Libraries

协议:CC BY-NC-SA 4.0

六、MERN 栈:第一部分

在前一章中,你学习了状态管理和脸书的 Flux 状态管理架构。您了解了目前最流行的状态管理工具:vanilla Redux 和 Redux 工具包。在这一章中,我们将使用反冲和 Mongo-Express-React-node . js(MERN)栈实现一个具有独占私有成员区域的登录。具体来说,我们将使用 React with 反冲在不同组件之间共享状态,作为视图层和中间件。在下一章中,我们将实现 Node.js、Express 和 MongoDB 作为后端。

Note

中间件为我们的组件提供了 React 库中没有的服务。将中间件视为“软件粘合剂”

这一章将包含大量的实践编码。当您完成本章时,您将会看到使用 React 和其他相关技术的完整周期。当您完成下一章时,您将拥有成为全栈开发人员的工具。

同样,MERN 栈分为两章。

  • 在这一章中,我们将设置前端,React 部分。

  • 在下一章,我们将设置后端。

这一章分为两部分。在第一部分中,我们将重构我们在前面章节中开始的应用,并重构状态管理以使用反冲。在本章的第二部分,我们将构建一个专属的会员区,其中包含一个我们可以在整个应用中使用的全球 toast 消息组件。

图 6-1 显示了最终的结果,用户可以登录和注销一个专属的、安全的、会员专用的区域。我们的组件将动态变化。

img/503823_1_En_6_Fig1_HTML.jpg

图 6-1

本章的最终结果

用反冲更新首选项

在这一节中,我们将重构我们的应用,使用反冲而不是 Redux 工具包 来为反冲中间件与后端的集成做好准备,我们将在下一章中设置后端。不过,首先让我们谈谈 MERN 和反冲。

什么是 MERN 栈?

MERN 栈是一个 JavaScript 栈,它使得开发过程更加容易,因为它完全基于 JavaScript 技术。MERN 包括四种开源技术: M ongoDB、 E xpress、 R eact、 N ode.js,这些技术为我们提供了从前端到后端框架完整的端到端循环。在本章中,我们将完成 MERN 栈的 React 部分。

什么是后坐力?

为了了解反冲,我们将重构我们在上一章中创建的首选项组件的状态,但是这一次我们将使用脸书反冲库取代状态管理,而不是使用 Redux 工具包。

反冲( https://recoiljs.org/ )是脸书正在席卷 React 开发者社区的改变生活的状态管理实验。后坐力团队是这样说的:

“后坐力的工作原理和思考方式类似 React。添加一些到您的应用中,获得快速灵活的共享状态。”

为什么要用后座力?

正如我们在前一章看到的,有许多状态管理库,那么为什么我们还需要另一个状态管理来共享我们的应用状态呢?跨多个组件共享状态和设置中间件能做得更好更容易吗?快速回答是肯定的!

如果您需要做的只是全局存储值,那么您选择的任何库都可以工作。然而,当您开始做更复杂的事情,比如异步调用,或者试图让您的客户端与您的服务器状态同步,或者反向用户交互时,事情就开始变得复杂和混乱了。

理想情况下,正如在本书中多次提到的,我们希望我们的 React 组件尽可能纯净,我们希望数据管理在 React 钩子中流动,没有副作用。我们还希望“真正的”DOM 为了性能而尽可能少地改变。

保持组件松散耦合对于开发人员来说总是一个好地方,因此拥有一个与 React 很好集成的库是对 React 库的一个很好的补充,因为它将 React 与 Angular 等其他顶级 JavaScript 框架放在一起。

拥有固态管理库将更好地促进 React 应用为企业级复杂应用提供服务,并处理前端和中间层的复杂操作。反冲简化了状态管理,我们只需要创建两个成分:原子和选择器。

原子是对象,是组件可以订阅的状态单元。反冲让我们创建一个从这些原子(共享状态)流向组件的数据流图。选择器是允许同步或异步转换状态的纯函数。

与 Redux 或 Redux 工具包 不同,不需要处理复杂的中间件设置并连接您的组件或使用任何其他东西来让 React 组件很好地运行。

你知道吗?反冲库仍处于实验阶段,但已经获得了一些非凡的人气,超过了 Redux 的人气。反冲库在 GitHub 上的得分接近 10,000 星,超过了 Redux 工具包 的 4,100 星!

我和许多其他人都认为,反冲将成为状态管理的标准,并且比继续使用 Redux 工具包 作为中间件更好的投资。然而,了解 Redux 工具包 仍然是很好的,因为您可能参与了一个使用 Redux 的项目。此外,反冲仍然是一个实验,并没有达到释放阶段,因为这本书的写作,所以它不适合心脏虚弱的人。

用反冲分享用户的偏好状态

为了开始使用反冲,我将通过重构我们在上一章中构建的首选组件,从通过 Redux 工具包 共享状态到利用反冲,让您更容易理解。这将使您能够并排比较这两个库。我把这个过程分成两步。

  • 步骤 1 :实施反冲

  • 第二步:重构视图层

在项目层面,我们将从上一章停止的地方继续。

https://github.com/Apress/react-and-libraries/05/exercise-5-1

你可以在这里下载重构我们偏好的完整代码:

https://github.com/Apress/react-and-libraries/06/exercise-6-1

实施反冲

为了开始,我们通常首先需要安装反冲(yarn add recoil)。在撰写本文时,反冲的版本是 0.0.13,但在您阅读本章时,情况可能会发生变化。然而,我们的 CRA MHL 模板已经包括反冲,所以它已经设置没有任何额外的工作对你的一部分。

更新首选项对象

我们将更新的第一个文件是preferencesObject.ts,它是我们在前一章中创建的。对象为两个应用状态保存一个枚举常数。我们将向该对象添加一种保存首选项对象类型的方法和一种初始化该对象的方法。这在后座力上会派上用场。看一看:

// src/model/preferencesObject.ts
...

// eslint-disable-next-line @typescript-eslint/naming-convention
export interface preferencesObject {
  theme: ThemeEnum
}

export const initPreferencesObject = (): preferencesObject => ({
  theme: ThemeEnum.Light,
})

注意,我放置了eslint-disable代码,让 Lint 忽略签名行,因为我们的 ESLint 基于 Airbnb 风格,所以被设置为不使用小写作为命名约定。我想把界面设置成小写。另一个选项是更改 ESLint 配置文件。

另一件要注意的事情(有些人可能会有争议)是将枚举放在对象模型中以保持它“更纯粹”如果您来自 Java 世界,您应该听说过值对象(VOs)和数据传输对象(dto)。我不会讲太多细节,但是对象可以重构,枚举可以提取到枚举对象及其自己的文件夹中,如果这是您想要的设计。

为我们的对象创建一个带有类型的模型文件夹让我们可以充分利用 TypeScript,并且使用对象类型可以帮助我们编写代码和测试。

首选项原子

接下来,我们将创建我们的反冲物体,称为原子。我们将调用文件preferencesAtoms.ts,反冲希望我们为每个原子定义一个惟一的键,并设置一个默认值。我将文件原子称为复数而不是原子的原因是我可以在该文件中添加其他相关原子。这只是一个好习惯。

看一看:

// src/recoil/atoms/preferencesAtoms.ts

import { atom } from 'recoil'
import { initPreferencesObject } from '../../model'

export const preferencesState = atom({
  key: 'PreferencesState',
  default: initPreferencesObject(),
})

注意,对于默认值,我使用模型的initPreferencesObject方法用默认值(ThemeEnum.Light)初始化对象。我们稍后会用到它。

会话原子

我们还将创建一个sessionAtoms.ts文件,它将保存一个用户密钥,我们可以用它来确保用户被授权登录到我们的安全会员区。我们不会在本章中实现它,但是在您完成相关章节后,您可以随意创建逻辑并自己实现该功能。

// src/recoil/atoms/sessionAtoms.ts
import { atom } from 'recoil'

export const sessionState = atom({
  key: 'SessionState',
  default: '',
})

重构视图层

现在我们有了原子preferencesAtomssessionAtoms.ts,我们准备设置页脚和页眉,从反冲而不是 Redux 工具包 获取共享状态值,并通过反冲设置值。

UserButton 子组件

我们将从创建一个名为UserButton.tsx的新子组件开始。

UserButton将了解用户的偏好以及用户是否登录了我们的应用。该按钮将根据状态调整颜色和信息。

为了实现所有这些,我们将在界面中包含主题的状态,以及稍后可以使用的登录状态的状态。

我们的导入需要包括 Material-UI 按钮组件、一个链接组件、ThemeEnum以及一个包含应用主题的prop的接口,我们将从父组件传递该主题。看一看:

//src/components/UserButton/UserButton.tsx

import React from 'react'
import './UserButton.scss'
import Button from '@material-ui/core/Button/Button'
import { Link } from 'react-router-dom'
import { ThemeEnum } from '../../model'

export interface IUserButtonProps {
  isLoggedIn: boolean
  theme: ThemeEnum
}

接下来,我们将创建两个常量,我们可以用它们来根据用户的偏好改变菜单标签的样式。

const menuLabelsLight = {
  color: 'white',
  padding: 20,
}

const menuLabelsDark = {
  color: 'black',
  padding: 20,
}

对于类签名,我们可以使用PureComponent。当你不需要shouldComponentUpdate挂钩时,使用这个:

export class UserButton extends React.PureComponent<IUserButtonProps, {}> {
  render() {

在按钮样式中,我们可以让开关决定使用哪个样式元素。在我们的例子中,用户登录与否的唯一区别是顶部菜单上显示的“members”或“login ”,但是代码被设置为可以显示任何想要的子组件。例如,您可能希望在用户登录时显示用户名或图片。看一看:

    const { isLoggedIn } = this.props
    return isLoggedIn ? (
      <Button component={Link} style={this.props.theme === ThemeEnum.Dark ? menuLabelsLight : menuLabelsDark} to="/Members">
        Members
      </Button>
    ) : (
      <Button component={Link} style={this.props.theme === ThemeEnum.Dark ? menuLabelsLight : menuLabelsDark} to="/Members">
        Login
      </Button>
    )
  }
}

内联条件语句与用纯 JavaScript 编写时是一样的。

let value = isLoggedIn ? 'Members' : 'Login'

用户列表按钮组件

类似地,我们将创建UserButton,它将知道登录状态并在我们的抽屉组件中使用它。我们的组件将把一个单击的用户手势传递给父组件,因此我们的组件将像任何其他链接组件一样,能够在不破坏封装的情况下关闭抽屉。

export interface IUserListButtonProps {
  isLoggedIn: boolean
  onClick: MouseEventHandler
}

对于我们的导入,我从 Material-UI 中挑选了一些图标,我们可以用它们来指示用户已经登录或注销。我们还需要来自 Material-UI 的ListItemListItemText,以及路由的NavLink

import React, { MouseEventHandler } from 'react'
import { NavLink } from 'react-router-dom'
import ListItem from '@material-ui/core/ListItem/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText/ListItemText'
import VpnKeyIcon from '@material-ui/icons/VpnKey'
import CardMembershipIcon from '@material-ui/icons/CardMembership'

我们的类签名可以再次是PureComponent,内联的if / else语句将显示成员按钮或登录按钮。

export class UserListButton extends React.PureComponent<IUserListButtonProps, {}> {
  render() {
    const { isLoggedIn } = this.props
    return isLoggedIn ? (
      <NavLink to="/Members" className="NavLinkItem" key="/Members" activeClassName="NavLinkItem-selected">
        <ListItem button key="Members" onClick={this.props.onClick}>
          <ListItemIcon>
            <CardMembershipIcon />
          </ListItemIcon>
          <ListItemText primary="Members" />
        </ListItem>
      </NavLink>
    ) : (
      <NavLink to="/Members" className="NavLinkItem" key="/Members" activeClassName="NavLinkItem-selected">
        <ListItem button key="Login" onClick={this.props.onClick}>
          <ListItemIcon>
            <VpnKeyIcon />
          </ListItemIcon>
          <ListItemText primary="Login" />
        </ListItem>
      </NavLink>
    )
  }
}

HeaderTheme 组件更改

有了原子,我们可以继续前进,重构。我们将更新组件,并通过props将共享反冲原子的prop传递给Header子组件。

我们将useRecoilState以及preferencesStatesessionState添加到我们的import语句中。

// src/layout/Header/HeaderTheme.tsx

import { useRecoilState } from 'recoil'
import { preferencesState } from '../../recoil/atoms/preferencesAtoms'
import { sessionState } from '../../recoil/atoms/sessionAtoms'

接下来,我们将利用useRecoilState从反冲中获取我们的首选项和会话令牌的值。反冲的这种集成感觉很自然,并且更符合 React 组件的内置状态机制。您可以在这里看到反冲力的作用(代码更改突出显示):

export const HeaderTheme: FunctionComponent = () => {
  const [preferences] = useRecoilState(preferencesState)
  const [session] = useRecoilState(sessionState)
  ...
}

然后我们可以使用偏好的共享值,并将数据传递给Header组件。再也不需要 Redux 工具包 的store.getState()了。

<HeaderComponent theme={preferences.theme} session={session} smallBreakPoint={smallBreakPoint} />

正如您所看到的,我通过将共享状态作为props传递来保持子组件尽可能纯净的状态,这不仅使调试和测试变得容易,因为状态将从父状态传递到子组件,而且还可以将任何组件从一个应用提取到另一个应用。

标题子组件

在我们的Header子组件(HeaderHeaderTopNavHeaderDrawer)中,我们可以采用相同的主题prop方法,并通过props将其传递给其他两个子组件。由于Header组件没有 set 方法,它只读取主题状态,我们的代码是松散耦合的,我们可以很容易地将子组件从一个项目复制粘贴到另一个项目。此外,当我们需要测试一个组件不依赖于另一个父组件时,最好保持我们的组件松散耦合。

标题子组件

我们可以用Header子组件代码开始重构Header子组件。在Header props中,我们可以设置主题和会话变量。

// src/layout/Header/Header.tsx

interface IHeaderProps {
  theme: ThemeEnum
  session: string
  smallBreakPoint: boolean
}

对于渲染 JSX 方面,我们使用主题设置样式,并设置会话密钥。

请注意,我们直接在组件上检查会话状态。原因是一旦共享状态被更新,我们需要组件被更新。我们正在检查会话共享状态以及localStorage的状态。我们将两者都设置的原因是我们需要两种情况,如下所示:

  • 应用通过钩子自动更新

  • 一旦用户刷新浏览器,应用就会更新

Note

localStorage也叫 DOM storage,是 web 存储,它赋予我们的 web app 存储我们客户端数据的能力,当我们刷新浏览器时,数据会被持久化。

<HeaderTopNav theme={this.props.theme} isLoggedIn={this.props.session === 'myUniqueToken' || localStorage.getItem('accessToken') === 'myUniqueToken'} />

<HeaderDrawer theme={this.props.theme} isLoggedIn={this.props.session === 'myUniqueToken' || localStorage.getItem('accessToken') === 'myUniqueToken'} />

注意,在代码级别,我正在为会话检查创建烘焙代码myUniqueToken。这可以连接到通过加密/解密算法检查会话的逻辑。

HeaderTopNav 子组件

对于HeaderTopNav子组件重构工作,我们将导入UserButton,它知道我们创建的首选项和登录状态。

// src/layout/Header/HeaderTopNav.tsx

import { UserButton } from '../../components/UserButton/UserButton'

接下来,我们需要子组件知道用户是否登录以及主题。我们将这些信息传递给孩子。

interface IHTNavProps {
  theme: ThemeEnum
  isLoggedIn: boolean
}

我想重构的另一个项目是 GitHub 图标。GitHub 图标样式需要基于主题偏好,所以要匹配其余导航项;否则,它保持不变的颜色。看一看:

function githubIconStyle(color: string) {
  return {
    color,
    width: 120,
  }
}

<IconButton style={githubIconStyle(this.props.theme === ThemeEnum.Dark ? 'white' : 'black')}>

最后,让我们在现有的 Contact 按钮下添加UserButton组件。

<UserButton theme={this.props.theme} isLoggedIn={this.props.isLoggedIn} />

HeaderDrawer 子组件

HeaderDrawer子组件的重构工作类似于HeaderTopNav子组件。我们设置我们创建的新按钮的导入,UserListButton;更新props界面;并添加按钮。其余保持不变。看一看:

// src/layout/Header/HeaderDrawer.tsx

import { UserListButton } from '../../components/UserListButton/UserListButton'

interface IHDProps {
  theme: ThemeEnum
  isLoggedIn: boolean

<UserListButton isLoggedIn={this.props.isLoggedIn} onClick={this.handleListItemClick} />

页脚组件重构

在我们的Footer组件工作中,过程类似于我们在Header组件中所做的。然而,我们不只是阅读偏好状态;我们还让用户改变应用主题的状态。一旦用户使用了onClick函数,我们就可以使用setPreferences来设置首选项,所以我们需要重构代码并移除 Redux 工具包。

FooterTheme 主题组件

FooterTheme.tsx是一个返回FooterComponentFunctionComponent包装器。我们需要包装器的原因是我们正在使用反冲钩子,而且我们只能在纯函数中这样做。

正如我们对Header组件所做的那样,我们将通过propspreferencesAtom对象传递给其他子组件。我们不需要订阅 Redux 工具包商店。看看这些变化:

// src/layout/Footer/FooterTheme.tsx

import { useRecoilState } from 'recoil'

import { preferencesState } from '../../recoil/atoms/preferencesAtoms'

Next, we can set preferences to tie that to our state.

export const FooterTheme: FunctionComponent = () => {
  const [preferences] = useRecoilState(preferencesState)
  return <FooterComponent theme={preferences.theme} />
}

如你所见,我正在使用FooterTheme函数中的useRecoilState反冲函数来检索原子,然后我将它传递给FooterComponent

页脚子组件

我们从Footer主题组件传递了首选项状态,它与反冲共享状态相关联。当我们使用setPreferences时,状态将被使用该状态的任何组件和子组件全局共享。此外,React 将自动识别何时用任何需要更改的组件和子组件来更新真正的 DOM,我们这边不需要做任何工作。

要做到这一点,我们需要首先引入反冲。

// src/layout/Footer/Footer.tsx

import { useRecoilState } from 'recoil'

接下来,我们现有的NestedGrid函数可以保存状态并绑定使用反冲的数据。

function NestedGrid() {
  const [preferences, setPreferences] = useRecoilState(preferencesState)
  ...
}

我们需要做的另一个改变是,当用户点击更改首选项时,我们现在可以直接将setPreferences更改为组件状态,而不是使用 Redux 工具包。这样好很多,也更直观。

const updatePref = () => {
  setPreferences({
    theme: preferences.theme === ThemeEnum.Light ? ThemeEnum.Dark : ThemeEnum.Light,
  })
}

合适的组件

最后,在App组件中,我们需要将路由代码包装在RecoilRoot标签中。

什么是反冲根?

RecoilRoot提供上下文,作为所有使用反冲挂钩的组件的祖先。

悬念标签

在反冲中,我们还需要设置一个后备悬念标签。暂停标签设置将在加载期间显示的组件。

在我们的例子中,我们可以将悬念组件设置为只显示加载消息。看一下代码:

// src/AppRouter.tsx

import React, { Suspense } from 'react'
import { RecoilRoot } from 'recoil'

function AppRouter() {
  return (
    <Router>
      <RecoilRoot>
        <Suspense fallback={<span>Loading...</span>}>
          <HeaderTheme />
          <Switch>
            ...
          </Switch>
          <div className="footer">
            <FooterTheme />
          </div>
        </Suspense>
      </RecoilRoot>
    </Router>
  )
}

干得好!现在我们可以正式删除reduxPreferences文件夹,因为我们不再使用它们了。

  • src/redux/store.ts

  • src/features/Preferences/preferencesSlice.ts

和往常一样,如果你没有运行应用,使用yarn start。参见图 6-2 中的最终结果。

img/503823_1_En_6_Fig2_HTML.jpg

图 6-2

重构我们的应用,使用反冲而不是 Redux

继续尝试使用页脚按钮切换主题。

正如你所看到的,反冲的行为和感觉就像是 React 库的扩展,反冲还为我们提供了一个共享的状态管理,而没有麻烦。最重要的是,DOM 只在需要的时候更新。这是一个速度提升。假设我们正在更新一个包含数千个结果的列表,或者维护一个服务器-客户端状态。React 和反冲配合得很好,并分开我们的关注。

现在我们已经重构了我们的应用,并且我们正在使用反冲而不是 Redux 工具包。到目前为止,我们的应用功能很简单;它显示内容页面,并有一个开关来改变用户的主题偏好。

然而,假设我们想增加更多的功能。例如,应用中的一个常见功能是登录到安全的会员专用区域或提交表单,因此我们需要添加更多的逻辑。

至此,我们已经很好地了解了如何将 React 组件分解成子组件,分离关注点,以及如何使用反冲共享状态库。我们已经准备好处理更复杂的功能。

用 MERN 栈创建一个会员专用区

我们现在将学习如何使用 MERN 栈创建一个会员专用区域。

我们如何用 MERN 栈建立一个会员专用区?

为了实现逻辑以便我们能够登录到一个安全的会员专用区域,我将这个过程分为两个主要部分。

  • 创建前端

  • 创建后端

前端将包括我们的应用的中间件和视图层。后端将包括服务器、服务和数据库。

在下一节中,我们将使用反冲作为中间件,然后在第七章中,我们将集成 Express、NodeJS 和 MongoDB 作为后端。

让我们开始写我们的私人会员区的前端。

前端

您可以从这里下载该练习的完整代码:

https://github.com/Apress/react-and-libraries/06/exercise-6-2

我们将构建两个前端组件。

  • Toast 消息组件

  • 登录和会员区组件

toast 消息组件,顾名思义,会像烤面包机一样弹出消息。这些消息可以用来在整个应用中通知用户成功、失败、信息和警告消息,而不仅仅是这个特性。

登录和成员组件将包括一个表单,用户可以提交该表单以验证用户名和密码,并获得对成员专用区域的访问权限。

Toast 消息组件

为了开始我们的 toast 消息组件,我们将从定义数据开始。这是一个良好的开端,因为我们首先设置了将在组件中使用的数据。

每个 toast 消息需要有一个唯一的 ID,这样我们就知道将显示什么。toast 还需要一个消息类型,如successfailed,以及我们想要显示的消息的描述。我们将接受四种类型的消息:成功、失败、警告和信息。我们会给每条信息一个不同的风格和图标。

你可以在图 6-3 中看到最终的结果。

img/503823_1_En_6_Fig3_HTML.jpg

图 6-3

测试烤面包机组件

目标模型

一个好的起点是数据。toastObject数据对象是我们在 preference 数据对象中使用的类似架构设计。我们将包括一个接口和一个方法来启动 toast 消息。我还将包含一个方法来获取一个随机 ID 号,我们可以用它作为每个 toast 消息的唯一 ID。

设置消息类型的枚举是一个好主意,这样我们就不需要不断地键入它们。设置一个不同消息背景颜色的枚举也不会有什么坏处,这样我们就可以从一个地方很容易地改变它们。创建toastObject.ts并查看代码,如下所示:

// src/model/toastObject.ts

// eslint-disable-next-line @typescript-eslint/naming-convention
export interface toastObject {

  id: number
  type: string
  description: string
}
export const initEmptyToast = (): toastObject => ({
  id: -1,
  type: '',
  description: '',
})
export const initToast = (id: number, type: string, description: string): toastObject => ({
  id: id,
  type: type,
  description: description,
})
// eslint-disable-next-line @typescript-eslint/naming-convention
export const randomToastId = () => {
  return Math.floor(Math.random() * 101 + 1)
}
export enum notificationTypesEnums {
  Success = 'Success',
  Fail = 'Fail',
  Info = 'Info',
  Warning = 'Warning',
}
// eslint-disable-next-line @typescript-eslint/naming-convention
export enum backgroundColorEnums {
  Success = '#5bb85a',
  Fail = '#d94948',
  Info = '#55bede',
  Warning = '#f0a54b',
}

注意,我正在设置initEmptyToastinitToastinitEmptyToast将 toast 消息的 ID 设置为-1,表示 toast 消息未设置。initToast让我们决定 toast 对象的值。

记住还要将 toast 模型添加到src/model/index.ts文件中,以便于访问。

// src/model/index.ts
export * from './toastObject'

反冲:烤面包原子

现在我们有了模型数据,我们可以创建 toast 原子(toastAtoms.ts)。我们需要保存一个惟一的键和默认值,就像我们对 preference 原子所做的那样。

// src/recoil/atoms/toastAtoms.ts

import { atom } from 'recoil'
import { initEmptyToast } from '../../model'

export const toastState = atom({
  key: 'ToastState',
  default: initEmptyToast(),
})

吐司成分

在组件层次结构设计方面,我们的组件设计将有一个ToastNotification组件,它将保存我们需要显示的所有祝酒词。当我们分解功能时,这种设计是健康的,并且易于维护。为了帮助理解这个层次,请看图 6-4 。

img/503823_1_En_6_Fig4_HTML.jpg

图 6-4

Toast 组件架构设计

配备了反冲吐司原子和模型,我们可以开始和创建我们的前端组件。我们将从一个Toast组件开始。这将是我们的通知组件用来显示祝酒词的子组件。它代表了我们将要分发的每一份吐司。

让我们回顾一下Toast组件。我们的import语句包括图标、反冲、悬念和通知枚举。

// src/components/Toast/Toast.tsx
import React, { Suspense } from 'react'
import './Toast.scss'
import { useRecoilState } from 'recoil'
import { toastState } from '../../recoil/atoms/toastAtoms'
import checkIcon from '../../assets/toast/check.svg'
import errorIcon from '../../assets/toast/error.svg'
import infoIcon from '../../assets/toast/info.svg'
import warningIcon from '../../assets/toast/warning.svg'
import { backgroundColorEnums, initEmptyToast, notificationTypesEnums } from '../../model'

我们的导入图标可以从 GitHub 的/assets/toast文件夹下载,或者你可以制作自己的 SVG 自定义图标。

为了在我们的Toast组件中绑定反冲共享状态,我们将使用useRecoilState

export const Toast = () => {
  const [toast, setToast] = useRecoilState(toastState)

StyleDetails界面将保存选中的 toast 图标和背景选中的颜色代码。

  interface StyleDetails {
    background: string
    icon: string
  }

接下来,我们将创建一个方法(getToastStyle),它将保持一个开关来分配一个带有图标和背景颜色的对象。我使用了一个开关,但这也可以用一个对象文字来完成。

  const getToastStyle = (toastType: string) => {
    let retVal: StyleDetails = {
        background: backgroundColorEnums.Success,
        icon: checkIcon,
    }
    switch (toastType)  {
      case notificationTypesEnums.Fail:
        retVal = {
            background: backgroundColorEnums.Fail,
            icon: errorIcon,
        }
        break;
      case notificationTypesEnums.Warning:
        retVal = {
            background: backgroundColorEnums.Warning,
            icon: warningIcon,
        }
        break;
      case notificationTypesEnums.Info:
        retVal = {
            background: backgroundColorEnums.Info,
            icon: infoIcon,
        }
        break;
    }
    return retVal;
  }

在渲染 JSX 方面,我们将忽略任何 ID 为-1 的 toast(记住,这是初始值)。这样,我们可以清除屏幕上显示的未设置或需要删除的 toast。

  return (

      {toast.id !== -1 && (

我使用的是悬念 API,它允许我们在组件加载时显示加载消息。如果您愿意,可以将装载消息更改为 spinner。

        <Suspense fallback={<span>Loading</span>}>
          <div className="notification-container top-right">

每个 toast 都会被分配一个随机的、唯一的 toast ID,这种设计允许我们删除一个 toast,以防我们希望实现多个 toast 同时显示,或者我们将来可能需要的任何其他功能。

            <div
              key={toast.id}
              className={`notification toast top-right`}

对于背景和图标的样式,我们将使用我们创建的getToastStyle。我们通过toast.type得到背景颜色。看一看:

              style={{ backgroundColor: getToastStyle(toast.type).background }}
            >

一旦用户点击关闭 toast,我们使用我们在模型中创建的initEmptyToast方法将 toast 的 ID 设置为-1 来清除 toast。这将删除祝酒词。看一看:

              <button type="button" onClick={() => setToast(initEmptyToast())}>X</button>
              <div className="notification-image">
                <img src={getToastStyle(toast.type).icon} alt="" />
              </div>
              <div>
                <p className="notification-title">{toast.type}</p>
                <p className="notification-message">{toast.description}</p>
              </div>
            </div>
          </div>
        </Suspense>
      )}

  )
}

烤面包。SCS

至于Toast.scss,我不会在这里显示完整的代码,但是你可以从 GitHub 的资源库下载。但是我想指出一个特点。

在我们的代码中,我们将 toast 设置在用户屏幕的右上角,并设置了一个动画来将 toast 从右向左移动,但是您可以更改代码来从屏幕的任何角落显示 toast 的动画。看一下处理位置的代码的Toast.scss部分:

.top-right {
  top: 12px;
  right: 12px;
  transition: transform 0.6s ease-in-out;
  animation: toast-in-right 0.7s;
}

@keyframes toast-in-right {
  from {
    transform: translateX(100%);
  }
  to {
    transform: translateX(0);
  }
}

ToastNotification 子组件

现在我们已经准备好了 toast 子组件,我们将创建父组件ToastNotification.tsx。反冲使用钩子,所以为了能够使用 React 组件而不是纯函数,我们需要将 React 组件包装在纯函数中,并通过props传递反冲状态。这是怎么做到的?看一看。

我们需要导入 SCSS 文件、反冲状态和值、toast 模型、toast 原子以及Toast组件。

// src/components/Toast/ToastNotification.tsx
import React from 'react'
import './ToastNotification.scss'
import { SetterOrUpdater, useRecoilValue, useSetRecoilState } from 'recoil'
import { initEmptyToast, toastObject } from '../../model'
import { toastState } from '../../recoil/atoms/toastAtoms'
import { Toast } from './Toast'

我们检索 toast 状态的纯函数以及创建使用getToastStateuseSetRecoilStateAPI 设置 toast 的方法需要在ToastNotification包装器中设置。这些将通过props传递给我们正在包装的ToastNotificationInner组件。

// wrapper
export default function ToastNotification() {
  const setToastState = useSetRecoilState(toastState)
  const getToastState: toastObject = useRecoilValue(toastState)
  return <ToastNotInner setToastState={setToastState} getToastState={getToastState} />
}

现在我们的ToastNotification包装已经准备好了。ToastNotificationInner React 组件实现了setToastStategetToastState接口。

interface IToastNotProps {
  setToastState: SetterOrUpdater<toastObject>
  getToastState: toastObject
}

interface IToastNotState {}

class ToastNotInner extends React.PureComponent<IToastNotProps, IToastNotState> {

我们希望即使用户没有点击关闭 toast,我们的 toast 也会自动消失,所以我们可以用 5000 毫秒(半秒)的计时器在componentDidUpdate方法上设置一个间隔。

componentDidUpdate()方法是放置我们代码的好地方,因为它是在最近提交的输出被提交之后被调用的。

Tip

主要区别在于getDerivedStateFromProps是在调用 render 方法之前被调用的。getSnapshotBeforeUpdate在提交最近渲染的输出之前运行。componentDidUpdate穷追不舍。

componentDidUpdate() {
  const interval = setInterval(() => {
    if (this.props.getToastState.id !== -1) {
      this.props.setToastState(initEmptyToast())
    }
  }, 5000)
  return () => {
    clearInterval(interval)
  }
}

如果你想让面包烤得更久,你可以把时间从半秒增加到一秒(10,000 毫秒)。在 JSX 渲染端,我们返回我们创建的Toast子组件。

  render() {
    return (
    <>

        <Toast />

    </>
    )
  }
}

toastnotification . scss

最后,对于我们的ToastNotification.scss文件,我们需要设置 toast 按钮设计属性。

.toast-buttons {
  display: flex;
}

.toast-buttons button {
  color: white;
  font-size: 14px;
  font-weight: bold;
  width: 100px;
  height: 50px;
  margin: 0 10px;
  border: none;
  outline: none;
}

.select {
  display: flex;
  width: 30%;
  margin-top: 10px;
}

.position-select {

  background-color: inherit;
  color: #fff;
  width: 100%;
  height: 30px;
  font-size: 16px;
}

适当的组件重构

既然我们已经创建了 toast 子组件和 notification 父组件,我们可以向我们的AppRouter组件添加逻辑来包含ToastNotification。出于两个原因,最好将这段代码放在全局位置。

  • 我们需要RecoilRoot,因为我们正在使用反冲 API。

  • 我们可以在任何页面中使用 toast 组件。

<RecoilRoot>标签内添加<ToastNotification>AppRouter.tsx

<RecoilRoot>
  <ToastNotification />
  ..
  ..
</RecoilRoot>

我们都准备好了。这种架构设计允许我们的代码在全局级别上接受来自任何组件的 toast 通知。反冲为我们提供了一个共享状态,我们可以随时使用它来分配一个祝酒词。

让我们试驾一下 toast 组件。例如,为了在App.tsx中发送一个 toast,我们设置 toast 状态,并且我们模仿我们喜欢的任何类型的 toast。当我们刷新页面时,看看吐司是什么样子,如图 6-3 所示。

// src/App.tsx

import { useSetRecoilState } from 'recoil'
import { toastState } from './recoil/atoms/toastAtoms'
import { initToast, notificationTypesEnums, randomToastId } from './model'

function App() {

  const setToastState = useSetRecoilState(toastState)
  setToastState(initToast(randomToastId(), notificationTypesEnums.Success, 'Hello World'))
  ...
}

专属会员区

在本章的最后一个练习中,我们将创建我们谈到的受密码保护的会员专用区域。会员区需要一个登录,将有一个电子邮件和密码输入框,用户可以填写,以便他们可以登录到一个专属的会员专用区。

您可以从这里下载该练习的完整代码:

 https://github.com/Apress/react-and-libraries/06/exercise-6-3

使用者物件模型

和往常一样,一个好的起点是定义我们的用户对象模型文件(userObject.ts)。用户对象保存电子邮件和密码以及初始化对象的方法,就像我们在前面添加的模型中所做的一样。

// src/model/userObject.ts

export interface userObject {
  email: string
  password: string
}

export const initUser = (): userObject => ({
  email: '',
  password: '',
})

向索引文件添加导出;

// src/model/index.ts
export * from './userObject'

登录反冲逻辑

我们来看看登录逻辑。

用户原子

接下来,正如我们在 preference 和 toast 组件中所做的那样,我们需要设置一个反冲原子。创建userAtoms.ts。该文件将保存用户状态,默认值设置为模型对象。

// src/recoil/atoms/userAtoms.ts
import { atom } from 'recoil'
import { initUser } from '../../model'

export const userState = atom({
  key: 'UserState',
  default: initUser(),
})

除了用户原子,我们还需要一个会话原子。如果您还记得在本章前面我们设置首选项时,我们还创建了sessionAtoms.ts来保存用户密钥,我们可以用它来确保用户被授权登录到我们的安全会员区。

反冲用户选择器

对于用户组件,我们需要一些中间件来进行服务调用。这将通过使用反冲选择器 API 来实现。

反冲选择器共享状态,因此我们所有的 React 组件都可以订阅数据。选择器是纯函数,允许我们同步或异步地转换状态。

在我们的例子中,我们使用服务调用,因此调用将是异步的。看看显示选择器纯函数userSelectors.ts的代码级别:

// src/recoil/selectors/userSelectors.ts

import { selector } from 'recoil'
import { userState } from '../atoms/userAtoms'

对于服务调用,我将简单地使用一个名为axios ( https://github.com/axios/axios )的有用库来调用代码。确保将该库添加到项目中($ yarn add axios)。

import axios from 'axios'

在反冲中,我们可以使用一个选择器或者一个selectorFamily来传递用户信息。selectorFamily是一种类似于selector的模式,但是它允许向选择器的 get 和 set 回调传递参数。因此,您可以修改代码,设计一个selectorFamily来传递用户电子邮件,而不是将其存储在 atom 中。我将它存储起来,以防其他组件需要知道所选择的用户电子邮件,例如,一个时事通讯组件。

反冲选择器就像反冲原子一样,需要一个唯一的键来设置。我使用带有get方法的async来检索userState。看一看:

export const submitUserLoginSelector = selector({
  key: 'SubmitUserLoginSelector',
  get: async ({ get }) => {
    const payload = get(userState)

我可以在这里设置另一个验证来确保用户填写了表单。

    if (payload.email === '' || payload.password === '') return `Error: Please complete form`

接下来,我将我的服务调用包装在trycatch标签中,以确保不会产生错误。

  try {
      const urlWithString = `http://localhost:8081/validate?email=${  payload.email  }&password=${  payload.password}`
      const res = await axios({
        url: urlWithString,
        method: 'get',
      })
      return res?.data?.status
    } catch (err) {
      // console.warn(err)
      return `Error: ${  err}`
    }
  },
})

Note

我使用的是可选链接(res?.data?.status)。这让我们可以编写代码,如果遇到空值,TypeScript 可以立即停止运行表达式。这是健康的,有助于在服务调用失败或变得不正常时避免错误。

登录组件

现在我们有了原子和选择器,我们可以在视图层工作了。

我们将从登录组件的表单组件开始。

我们将使用 Material-UI 风格的组件库来加速我们的开发工作。正如我们之前所做的,我们将使用一个包装器组件(LoginForm)并将我们的LoginPage包装在一个LoginForm中。LoginForm组件将有另一个内部组件来保存实际的表单。看图 6-5 。

img/503823_1_En_6_Fig5_HTML.jpg

图 6-5

登录组件层次结构

LoginForm中,我们将使用一个样式文件将样式从视图中分离出来。创建LoginForm.styles.ts来居中并设置我们的组件为一个列容器。

这种增加的复杂性是不必要的;然而,它帮助我们更好地分离组件,并保持它们的松散耦合,如果我们需要将LoginForm放在这个应用的其他地方或将其拖到另一个项目,额外的步骤是值得的。

LoginForm.styles.ts

我们的风格决定了我们将要使用的容器类型。它指定了如何将组件中的表单元素作为页面的列和中心进行布局。看一看:

// src/components/Login/LoginForm.styles.ts
import { createStyles, Theme } from '@material-ui/core/styles'

export default (theme: Theme) =>
    container: {
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
    },
  })

log in form-登入表单

接下来,让我们创建我们的LoginForm包装登录组件。LoginForm.tsx将包括来自 Material-UI、我们的模型和登录风格的表单元素。

// src/components/Login/LoginForm.tsx
import * as React from 'react'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
import { withStyles, WithStyles } from '@material-ui/core/styles'
import CircularProgress from '@material-ui/core/CircularProgress'
import { userObject } from '../../model'
import styles from './LoginForm.styles'

为了包装LoginForm.styles,我们将创建一个内部 React 函数组件,并使用withStyle API 来导出将我们的组件与样式连接起来的组件。

export const LoginForm = withStyles(styles)(LoginFormInner)

我们将要设置的props包括扩展withStyle API,从父组件传递onLoginonUpdateLoginField函数,传递用户的状态,最后传递一个标志来显示表单提交后的加载进度。

interface ILoginFormProps extends WithStyles<typeof styles> {
  onLogin: () => void
  onUpdateLoginField: (name: string, value: string) => void
  loginInfo: userObject
  loading: boolean
}

我们将使用在props中设置的onTextFieldChangeHandleronUpdateLoginField将用户更新表单的值传递给父组件。

const LoginFormInner: React.FunctionComponent<ILoginFormProps> = (props: ILoginFormProps) => {
  const onTextFieldChangeHandler = (fieldId: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
    props.onUpdateLoginField(fieldId, e.target.value)
  }

在 JSX 渲染端,我们将设置电子邮件和密码文本字段输入,将onChange绑定到onTextFieldChangeHandler,并通过props.loginInfo将值绑定到状态。看一看。

return (
  <div className={props.classes.container}>
    <TextField label="email address" margin="normal" value={props.loginInfo.email} onChange={onTextFieldChangeHandler('email')} />
    <TextField label="password" type="password" margin="normal" value={props.loginInfo.password} onChange={onTextFieldChangeHandler('password')} />

我们的提交按钮将使用来自props的回调和加载prop标志。在提交按钮里面,我们会看到一个加载动画,这要归功于 Material-UI CircularProgress组件。

      <Button variant="contained" color="primary" disabled={props.loading} onClick={props.onLogin}>
        Login
        {props.loading && <CircularProgress size={30} color="secondary" />}
      </Button>
    </div>
  )
}

太好了,我们的登录表单组件已经可以使用了。

登录页面

我们的LoginPage组件是父组件,它将包含一个内部组件(LoginPageInner)来显示表单或处理表单的提交,并显示成功或失败的逻辑。为了更好地理解我在做什么,请看图 6-6 。

img/503823_1_En_6_Fig6_HTML.jpg

图 6-6

登录页面组件和子组件

如您所见,LoginPage保存了内页包装器(LoginPageInner)。里面将是一个子组件,将我们的登录表单居中。

InnerPage要么显示我们的表单,要么使用一个名为SubmitUserFormComponent的子组件,一旦表单被提交,这个子组件就会被使用,并且根据结果设置状态并显示一条消息。此外,它将启动我们之前创建的 toast 组件。

另外,看一下图 6-7 中的活动图,更好地理解提交登录表单的流程。

img/503823_1_En_6_Fig7_HTML.jpg

图 6-7

LoginFormInner 活动图

中心内容组件

让我们从中心组件开始,它可以帮助我们调整登录表单组件。这个组件可以在其他页面中重用,所以让我们把它放在布局文件夹中重用。

为了清楚起见,我们将把组件从样式中分离出来。我们称之为样式组件Centered.styles.ts

// src/layout/Centered/Centered.styles.ts

import { createStyles, Theme } from '@material-ui/core/styles'

export default (theme: Theme) =>
  createStyles({
    '@global': {
      'body, html, #root': {
        paddingTop: 40,
        width: '100%',
      },
    },
    container: {
      maxWidth: '400px',
      margin: '0 auto',
    },
  })

接下来,我们可以创建我们的包装器组件(Centered.tsx),我们将使用它作为我们的容器类,它将使我们的子组件居中。

// src/layout/Centered/Centered.tsx

import * as React from 'react'
import { withStyles, WithStyles } from '@material-ui/core/styles'
import styles from './Centered.styles'

const CenteredViewInner: React.FunctionComponent<Props> = (props) => <div className={props.classes.container}>{props.children}</div>

interface Props extends WithStyles<typeof styles> {}

export const Centered = withStyles(styles)(CenteredViewInner)

最后,让我们创建一个index.ts文件来更直观地访问这个组件,这样我们就可以将它用作 JSX 标签,就像这样:<Centered />

// src/layout/index.ts

export { Centered } from './Centered'

登录页面组件

对于LoginPage,我们已经有了这个组件,但是它只是显示页面的名称。开始更新吧。我们的导入需要包括材质-UI 组件、反冲元素、toast 组件和我们的登录表单。

// src/components/Login/LoginPage.tsx

import './LoginPage.scss'
import { Card, CardContent, CardHeader } from '@material-ui/core'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { Centered } from '../../layout/Centered'
import { LoginForm } from '../../components/Login/LoginForm'
import { initToast, initUser, notificationTypesEnums, randomToastId } from '../../model'
import { userState } from '../../recoil/atoms/userAtoms'
import { toastState } from '../../recoil/atoms/toastAtoms'
import { submitUserLoginSelector } from '../../recoil/selectors/userSelectors'
import { sessionState } from '../../recoil/atoms/sessionAtoms'

LoginPage需要包括LoginPageInner组件,这是繁重工作发生的地方。

export default LoginPage

const LoginPage = () => {
  return <LoginPageInner />
}

LoginPageInner 和子组件

正如我提到的,LoginPageInner组件设置组件的本地登录页面状态(userLoginPageState)以及反冲页面状态(user)。

我们需要本地状态和共享状态的原因是,我们不需要在用户的任何击键更新时更新全局共享状态,而只需要在用户完成表单时更新。我们还将在这里设置加载状态标志,并将其传递给表单,这样我们就可以从父组件控制它,并保持子组件干净。

function LoginPageInner() {
  const [userLoginPageState, setUserLoginPageState] = useState(initUser)
  const [loading, setLoading] = useState(false)
  const [user, setUser] = useRecoilState(userState)

一旦用户点击onLogin按钮,我们设置表单的加载状态以及反冲用户状态值。

const onLogin = () => {
  // console.log(`LoginPage.tsx :: onLogin :: userLoginPageState :: ${JSON.stringify(userLoginPageState)}`)
  setLoading(true)
  setUser(userLoginPageState)
  // eslint-disable-next-line no-console
  console.log(JSON.stringify(user))
}
const onUpdateLoginFieldHandler = (name: string, value: string) => {
  // console.log(`LoginPage.tsx :: Update name: ${name}, value: ${value}`)
  setUserLoginPageState({
    ...userLoginPageState,
    [name]: value,
  })
  // console.log(`LoginPage.tsx :: onUpdateLoginFieldHandler :: user :: ${JSON.stringify(user)}`)
}

在 JSX 端,我们检查表单是否通过我们的州旗加载。我们使用SubmitUserFormComponent,在这里我们将根据表单是否被提交来放置进行服务调用或显示表单的逻辑。这个if / else条件逻辑的原因是我们不想显示LoginForm。如果用户提交表单,并且表单正在被处理,我们可以创建另一个页面组件来导航或者将代码分割成更多的组件。然而,这种设计现在已经足够了,因为显示结果的代码很简单。

  return (
    <Centered>
      {loading ? (
        <SubmitUserFormComponent />
      ) : (
        <Card>
          <CardHeader title="Members Login" />
          <CardContent>
            <LoginForm onLogin={onLogin} onUpdateLoginField={onUpdateLoginFieldHandler} loginInfo={userLoginPageState} loading={loading} />
          </CardContent>
        </Card>
      )}
    </Centered>
  )
}

SubmitUserForm 子组件

最后,我们的SubmitUserFormComponent是使用反冲选择器的逻辑发生的地方。我们使用useRecoilValue模式来调用我们的选择器。

function SubmitUserFormComponent() {
  const results = useRecoilValue(submitUserLoginSelector)
  const setSessionState = useSetRecoilState(sessionState)
  const setToastState = useSetRecoilState(toastState)

一旦从选择器中检索到结果,我们需要设置两个方法来处理成功和失败逻辑。在这里,我们可以设置可以在其他组件中使用的烘焙会话状态令牌。在我们的例子中,令牌是硬编码,但在现实生活中,我们需要加密和解密密钥,并根据业务逻辑的要求将其存储在数据库中。

onSuccessLogin Logic

这里是onSuccessLogin逻辑:

  const onSuccessLogin = () => {
    localStorage.setItem('accessToken', 'myUniqueToken')
    setSessionState('myUniqueToken')
  }

注意,我也使用了localStorage浏览器 API。这样,我可以让用户返回页面,而不需要再次登录,所以我在localStorage上设置了令牌。

错误逻辑

对于失败逻辑,我们可以调用我们创建的自定义 toast 组件并传递失败消息,例如当我们的服务停止时出现网络错误。

  const onFailLogin = () => {
    setToastState(initToast(randomToastId(), notificationTypesEnums.Fail, results))
    localStorage.removeItem('accessToken')
    setSessionState('')
  }

然后我们可以使用逻辑来检查从服务器端检索结果的值是成功还是失败。

  results === 'success' ? onSuccessLogin() : onFailLogin()

在 JSX 呈现级别,我们根据从服务调用中检索到的结果,返回一条成功消息或一条失败消息。如果需要,这可以扩展为它自己的子组件,而不仅仅是一条文本消息。

  return (
    {results === 'success' ? Success : We were unable to log you in please try again}
  )
}

我们已经为登录组件的视图准备好了所有的逻辑,包括一个 atom 和一个选择器,它充当我们的中间件并进行服务调用。

接下来,我们将创建一个成员组件,它可以检查会话令牌,并在用户没有登录时显示安全成员区域或登录组件。

成员页面

对于我们的会员区,我们将更新会员页面组件,使其显示登录页面或专属会员区主页。看一下图 6-8 中的活动图。

img/503823_1_En_6_Fig8_HTML.jpg

图 6-8

会员区活动图

让我们首先为成员可以登录的安全区域创建一个名为MembersHome.tsx的主页。目前,该组件只有一个注销按钮,代码被设置为清除本地存储。我还将清空会话状态。这样,一旦会话状态改变,其他组件将自动更新,如图 6-9 所示。

img/503823_1_En_6_Fig9_HTML.jpg

图 6-9

带有注销按钮的会员专属区

会员之家

在代码层面上,MembersHome组件将首先向您展示导入库。

// src/page/MembersPage/MembersHome.tsx

import Button from '@material-ui/core/Button'
import React from 'react'
import { useSetRecoilState } from 'recoil'
import { sessionState } from '../../recoil/atoms/sessionAtoms'

接下来,我们的 pure 函数利用useSetRecoilState模式来设置会话状态。我们包含了让用户注销的onClickHandler逻辑。当我们注销用户时,我们需要清除会话共享原子和localStorage

const MembersHome = () => {
  const setSessionState = useSetRecoilState(sessionState)
  const onClickHandler = (e: React.MouseEvent) => {
    e.preventDefault()
    localStorage.removeItem('accessToken')
    setSessionState('')
  }

对于渲染,我们此时只显示一个注销按钮。如果我们将来需要更复杂的视图,这可以扩展到更多的子组件。

  return (

      <Button type="submit" variant="contained" color="primary" onClick={onClickHandler}>
        Logout
      </Button>

  )
}

export default MembersHome

成员页面

最后,我们需要一个名为MembersPage.tsx的成员页面的父组件,它将持有一个开关,根据令牌状态显示成员安全区域或登录页面。

// src/page/MembersPage/MembersPage.tsx

import React from 'react'
import './MembersPage.scss'
import { useRecoilValue } from 'recoil'
import LoginPage from '../LoginPage/LoginPage'
import MembersHome from './MembersHome'
import { sessionState } from '../../recoil/atoms/sessionAtoms'

交换机的逻辑检查会话状态是否等于固定值myUniqueToken。稍后,我们可以创建逻辑来加密和解密会话,甚至将它存储在我们的数据库中。

我正在检查反冲共享状态和localStorage是否都设置为该值。我们需要这两者,以防用户返回页面或页面被刷新。

const MembersPage = () => {
  const isMemberHasAccess = useRecoilValue(sessionState) === 'myUniqueToken' || localStorage.getItem('accessToken') === 'myUniqueToken'
  return <MembersPageInner isMemberHasAccess={isMemberHasAccess} />
}

对于显示成员页面的内部组件,我们传递一个名为isMemberHasAccessprop,它可以告诉组件用户是否应该访问该组件,这将被用作显示成员或登录组件的条件。

const MembersPageInner = (props: IMembersPageInnerProps) => (

    {props.isMemberHasAccess ? <MembersHome /> : <LoginPage />}

)

interface IMembersPageInnerProps {
  isMemberHasAccess: boolean
}

export default MembersPage

我们完成了编码。太棒了。

我将在第九章中介绍 Jest 测试,但是现在删除会员页面的 Jest 测试,因为我们修改了代码,所以我们的测试不会通过:MembersPage.test.tsx

记得运行formatlinttest,保证代码质量。

$ yarn format & yarn lint & yarn test

我们终于可以运行我们的代码了,如果你还没有运行它的话($ yarn start),看看我们的首选项和登录系统的工作逻辑。见图 6-1 。

如果您尝试登录,您将收到一条错误消息“我们无法让您登录,请重试。”这是意料之中的,因为我们还没有建立我们的服务呼叫(图 6-10 )。

img/503823_1_En_6_Fig10_HTML.jpg

图 6-10

表单失败提示和消息

摘要

本章深入研究了编码。您了解了更多关于如何构建函数、类、组件和子组件的知识。您还了解了 MERN 栈。我们在本章一开始就向你介绍了反冲。我们将共享用户偏好状态从 Redux 工具包 更改为反冲,您可以看到这两个工具之间的差异。您还看到了反冲如何轻松地与 React 配合使用,以及与导入的库相比,它开箱即用的感觉如何。

您还了解了如何通过创建一个使用 React 作为 MERN 栈前端的登录系统来构建一个会员专属区域,我们甚至创建了一个自定义的 toast 消息组件来向全球用户显示消息。反冲有许多功能来维护组件生命周期中的状态,甚至是客户端和服务器端之间的状态。我们只是带着后座力接触了表面。我鼓励你访问反冲网站( https://recoiljs.org/ ),了解更多的可能性。现在我们的前端已经准备好了,在下一章中,您将学习如何使用 Node.js、MongoDB 和 Express 设置后端。

七、MERN 栈:第二部分

在前一章中,您了解了如何构建 React 函数、类、组件和子组件。你被介绍给了 MERN 栈。您了解了反冲以及如何通过创建一个以 React 作为 MERN 栈前端的登录系统来构建一个会员专属区域。我们甚至创建了一个定制的前端 toast 消息组件来显示消息。

在本章中,我们将从上一章停止的地方开始,用 Node.js、Express 和 MongoDB——MERN 栈的其他部分——创建我们的后端。在这一章中,你将看到完整的开发周期,从编写前端代码到完成后端,这样你就有了一个可以工作的应用。这个应用不仅能够包括组件,而且能够登录到一个安全的会员专用区域并与数据库交互的实际功能。它甚至提供了连接到套接字以接收实时消息的基础。

在本章的第二部分,我们将添加一个注册组件,并通过加密和解密用户密码以及更新登录选择器来完成登录周期,这样我们就可以实际注册一个用户并登录到我们的专属会员区。

我们将会建造什么

在我们应用的这一点上,如果我们试图实际登录到我们在之前的练习中构建的会员专属区域,我们将会得到一个网络错误,如图 7-1 所示。

img/503823_1_En_7_Fig1_HTML.jpg

图 7-1

尝试登录我们的应用

那不是错误。在这一点上,这是正确的行为。我们收到这条消息是因为我们还没有设置后端服务。在本章中,我们将设置我们的后端逻辑,让登录系统工作,并通过添加一个注册组件和后端 API 来完成这个循环。

您可以从这里下载我们将创建的完整后端代码:

https://github.com/Apress/react-and-libraries/07/exercise-7-1

为什么是 Node.js?

Node.js 对你来说应该不陌生。事实上,我们已经安装了 Node.js,并且从第一章开始就一直使用 Node.js 包管理器 NPM。我们一直在安装和使用 NPM 的库。

Note

Node.js 是基于 Google 's V8 JS 引擎的单线程、非阻塞框架。

Node.js 使代码执行速度超快。Node.js 是一种非阻塞、事件驱动的输入/输出方法,允许大量快速并发连接。这是通过 JavaScript 事件循环完成的。

当我说很多时,我们可以创建多少个并发连接?这实际上取决于我们运行的服务器以及我们运行的服务器的内部配置设置。

事实上,Node.js 可以在一个标准的 Ubuntu 服务器上同时运行 1000 个 Node.js 的并发连接,而不会冻结 CPU,甚至可以通过 Posix ( https://github.com/ohmu/node-posix )等库来增加。对于中小型应用来说,这通常就足够了,无需设置复杂的服务器栈、负载平衡器等。

事实上,你知道 Node.js 正在被 PayPal、LinkedIn 和 Yahoo 等公司使用吗?

为什么要快递?

我们可以使用 Node.js HTTP 模块,从头开始编写我们的 web 服务器,那么为什么我们需要在 Node.js 之上使用 Express 呢?

Express 构建在 Node.js 之上,利用 Node.js HTTP/HTTPS 模块。有许多框架都是基于 Node.js 构建的。Express 是最古老、使用最广泛的 Node.js 框架之一,因为它提供了关注点的分离。Express 被 Myspace、Twitter、Stack 和 Accenture 等公司使用。

Note

Node.js 是一种快速的低级输入/输出机制,内置了 HTTP/HTTPS 模块。Express 是一个 web 应用框架,位于 Node.js 之上。Express 是轻量级的,有助于组织您的服务器端 web 应用。

为什么应该使用 MongoDB?

MongoDB 是一个面向文档的 NoSQL 数据库,用于大容量数据存储。MongoDB 提供了集合和文档,而不是使用传统的关系数据库将数据存储在表和行中。文档由键值对组成。这里有一个有趣的事实:据报道,超过 3000 家公司在他们的技术栈中使用 MongoDB,包括优步、Lyft 和送货服务 Hero。

构建后端

对于后端服务,我们将使用 Express 通过我创建的名为rooms.js ( https://github.com/EliEladElrom/roomsjs )的库与 MongoDB 进行交互。

旨在加速我们的开发工作。rooms.js提供了一种发送和接收消息并切换到不同传输器的方式,以创建房间并在用户之间传输数据,从数据库传输数据,甚至从第三方 CDN 传输数据。

在这个过程的最后,我们将能够调用我们将在本地主机上创建的服务文件。

http://localhost:8081/validate?email=youremail@gmail.com&password=isDebug

在下一章中,我们将把项目中的前端和后端发布到远程 Ubuntu 服务器上,这样我们就可以看到应用正在实时运行。

在本章中,我将这个过程分解为以下编码步骤:

  • 数据库模式

  • 验证服务 API

  • 快速框架

  • MongoDB

您可以从这里下载后端代码的完整代码:

https://github.com/Apress/react-and-libraries/07/exercise-7-1

数据库模式

首先,如果你还记得我们上一章学习反冲的时候,我们的第一步是建立一个模型,然后是原子。我们的后端项目的第一步是相似的:在后端建立数据模型。继续创建一个database.js文件,我们将使用它来为我们的用户设置数据库模式。当我们稍后需要与 MongoDB 数据库进行交互时,这个模式将会派上用场。我们的用户对象有一个类型为String的电子邮件和密码。我们的数据库将包括其他变量,这些变量在我们想要注册用户时会很方便,比如加密哈希和 salt(本章后面会详细介绍加密)、用户上次登录的时间、注册日期、登录令牌、电话号码、用户名以及用户尝试登录但失败的次数。看一看:

// models/database.js
let usersSchema = {
    username: 'String',
    email: 'String',
    passwordHash: 'String',
    passwordSalt: 'String',
    lastLoginDate: 'String',
    attempt: 'Number',
    signDate: 'String',
    emailEachLogin: 'Boolean',
    loginToken: 'String',
    phone: 'String'
};

if (typeof exports != 'undefined' ) {
    exports.usersSchema = usersSchema;
}

验证服务 API

接下来,我们需要创建一个服务 API 来验证我们的用户。它需要输入用户数据,并输出成功或失败的身份验证响应。我在这里没有展示注册服务 API 来设置用户注册,但是为了安全起见,登录系统通常需要包括加密和解密。部分代码已经实现,在本章的第二部分会派上用场。

此时,我允许用户通过内置的密码isDebug绕过所有的加密和解密逻辑。我还建立了一个机制来检查用户尝试登录系统的次数,这样我们就可以阻止用户,以防黑客在我们的登录系统上启动攻击机器人来试图破解用户密码。安全应该永远是你的第一要务。

为了开始使用我们的服务 API,我们将创建一个服务文件,并将其命名为validate.js。看一下代码:

// src/services/validate.js

'use strict';

在文件的顶部,使用use strict来指示代码应该在严格模式下执行是一个很好的做法,这意味着我们不能,例如,使用未声明的变量。

接下来,我们将使用在上一步中为用户创建的数据库模式,并定义我们将使用的库和变量。

let usersSchema = require("../models/database").usersSchema,
logger = require('../utils/log.js').logger,
moment = require("moment"),
CryptoJS = require('crypto-js'),
async = require('async'),
connector,
users,
isUserExists = false,
params,
user;

我们的主函数validate将从我们的服务器文件中获取数据,并连接到我们设置的数据库。

function validate(data, dbconnectorCallBackToRooms) {

    connector = this.getConnector();
    params = data.query || data.params;
    params.member_id = -1;

我们将使用异步系列逻辑。Node.js 是基于事件的循环,我们无法停止 node . js;然而,在某些情况下,我们需要构建基于异步调用的逻辑,有时每个操作都依赖于之前的一个或多个操作。

这可以通过使用async.series库( https://github.com/hughsk/async-series )来完成。该库允许一个或多个操作一个接一个地执行。

在我们的例子中,我们只做了一个checkUserInfo操作,但是在未来的迭代中,我们可能会扩展这个操作并包含其他操作,比如向用户发送一封电子邮件,告诉他们已经登录到我们的系统或者其他需要的逻辑。

    let operations = ;
    operations.push(checkUserInfo);

    async.series(operations, function (err, results) {

        let retData = {
            "exist_member_id": params.member_id,
            "isUserExists": isUserExists,
            "user": user
        };

        users = null;
        user = null;
        isUserExists = false;
        params = null;

一旦异步操作的结果传入,我们就可以将它们传递回输出端进行显示。

        if (err) {
            dbconnectorCallBackToRooms(data, {status: 'error', error_message: JSON.stringify(err), params: []});
        } else {
            dbconnectorCallBackToRooms(data, {status: 'success', params: retData});
        }
    });
}

对于checkUserInfo操作,我们需要根据 MongoDB 数据库验证用户。

function checkUserInfo(callback) {

    logger.info('validate.js :: checkUserInfo');

    if (connector.isModelExists('users')) {
        users = connector.getModel('users');
    } else {
        let schema = connector.setSchema(usersSchema);
        users = connector.setModel('users', schema);
    }

    let findObject = {
        email: (params.email).toLowerCase()
    };

使用 Mongoose 库对 MongoDB 文档进行排序,搜索任何包含我们定义的对象的文档。在我们的例子中,这是用户的电子邮件地址。

    users.find(findObject)
        .then((doc) => {
            if (doc.length > 0) {
                user = doc[0]._doc;
                params.member_id = (user._id).toString();

一旦我们有了结果,我们就可以使用HashSalt来解密用户信息。

                let passwordParam = (params.password).toString(),
                    password = user.passwordHash,
                    salt = user.passwordSalt,
                    attempt = user.attempt,
                    lastLoginDate = user.lastLoginDate;

                let databaseTime = moment(lastLoginDate),
                    now = moment().format(),
                    diff = moment(now).diff(databaseTime, 'minutes');

如果有三次尝试失败,我们会将用户锁定 60 秒。

                // don't even attempt to login - 3 attempts
                if (diff < 60 && attempt >= 3) {
                    callback('three_attempts_wait_one_hour', null);
                } else {
                    let decryptedDatabasePassword = (CryptoJS.AES.decrypt(password, salt)).toString(CryptoJS.enc.Utf8),
                        decryptedURLParam = (CryptoJS.AES.decrypt(passwordParam, "SomeWordPhrase")).toString(CryptoJS.enc.Utf8),
                        loginSuccess = (decryptedDatabasePassword === decryptedURLParam && decryptedDatabasePassword !== ''),
                        new_attempt_count = (loginSuccess) ? 0 : attempt + 1;

现在,我在这里重写了整个加密和解密逻辑,所以我们可以使用密码isDebug,来测试服务,但是稍后我将扩展我们如何加密和解密。这是标准协议。我把它分成两步的原因是,首先我们只想看到我们的系统工作。然后我们实现安全性,这是确保我们的代码正常工作的良好实践。

                    if (params.password === 'isDebug’) {
                        loginSuccess = true;
                    }

                    users.updateOne({"email": params.email}, {
                        $set: {
                            attempt: new_attempt_count,
                            lastLoginDate: now
                        }
                    }).then((doc) => {
                        if (loginSuccess) {
                            isUserExists = true;
                            callback(null, null);
                        } else {
                            isUserExists = false;
                            callback('No login success', null);
                        }
                    }).catch((err) => {

                        logger.info(err);
                        callback(err.message, null);
                    });
                }

            } else {
                callback('no user found', null);
            }
        })
        .catch((err) => {
            logger.info(err);
            callback(err.message, null);
        });
}

module.exports.validate = validate;

快速框架

既然我们已经准备好了验证服务 API,我们希望能够访问逻辑、发送数据并显示结果。

为此,我们将使用 Express,借助于我自己的库roomsjs ( https://github.com/EliEladElrom/roomsjs )。该库通过 SSL 或 HTTP 简化了服务的创建,并且该库可以连接到任何数据库(包括 MongoDB)。我们将设置 MongoDB 使用默认设置,但是我们可以很容易地将数据库更改为使用 MySQL 或任何其他数据源。

在 Node.js 中,我们可以使用一个server.js文件来设置我们的数据库和服务库,从而利用 Express 服务器。我们首先导入我们需要的库。

// server.js
let os = require('os'),
    rooms = require('roomsjs'),
    roomdb = require('rooms.db'),
    bodyParser = require('body-parser'),
    port = (process.env.PORT || 8081),
    logger = require('./utils/log.js').logger,
    log = require('./utils/log.js'),
    isLocalHost = log.isLocalHost();

接下来,我们创建 Express 服务器。

let express = require('express'),
    app = express().use(express.static(__dirname + '/public'));

我们需要允许跨域的逻辑。

// will overcome the No Access-Control-Allow-Origin header error
let allowCrossDomain = function(req, res, next) {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
    next();
};
app.use(allowCrossDomain);
Parse any ‘url’ encoded ‘params’;
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

现在,我们只需要 HTTP 模块,因为我们将在本地机器上运行这个逻辑,但是在下一章,当我们发布到产品时,我们将为 HTTPS 添加逻辑。

// create server
let server = require('http').createServer(app).listen(port, function () {
    logger.info('Listening on http://' + os.hostname() + ':' + port);
});

roomdb ( https://github.com/EliEladElrom/roomsdb )是roomjs的伴库。它设置一个到任何数据库的连接器,并遍历我们设置的任何服务文件。

// services
roomdb.setServices('services/', app, 'get');

logger.info('hostname: ' + os.hostname());
let roomsSettingsJSON;

logger.info('*** Listening *** :: ' + os.hostname() + ', localhost: ' + isLocalHost);
roomsSettingsJSON = (isLocalHost) ? require('./roomsdb-local.json') : require('./roomsdb.json');

roomdb.connectToDatabase('mongodb', 'mongodb://' + roomsSettingsJSON.environment.host + '/YourSite', { useNewUrlParser: true, useUnifiedTopology: true });

该库使用套接字设置了一个传送器。在这一点上,我们不需要套接字,HTTP 在我们的逻辑中已经足够了,但是有它也很好。如果我们想创建一个需要实时消息的应用,例如聊天应用,可以使用套接字。

// set rooms
rooms = new rooms({
    isdebug : true,
    transporter : {
        type: 'engine.io',
        server : server
    },
    roomdb : roomdb
});

这超出了本章的范围,但是我想让你知道什么是可能的。

最后,我们希望捕捉任何uncaughtException并显示它们,这样我们就可以解决代码中的任何问题。

process.on('uncaughtException', function (err) {
    logger.error('uncaughtException: ' + err);
});

本地 MongoDB

难题的最后一部分是在我们的本地机器上设置 MongoDB。在下一章中,我们将在远程服务器上创建我们的 MongoDB 并发布我们的代码,但是现在我们首先需要一个工作的本地版本。

我们可以在 Mac 上使用以下命令创建一个本地 MongoDB。首先,我们可以用自制软件安装 MongoDB。

Note

家酿是开源软件包管理,以简化 macOS 和 Linux 上的软件安装。brew是家酿的核心命令。

先用 Ruby 安装 Brew,然后更新。

$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
$ brew doctor
$ brew update

接下来,使用brew命令安装 MongoDB,并设置我们正在存储的 MongoDB 数据的位置和权限。

$ brew install mongodb
$ sudo mkdir /data
$ sudo mkdir /data/db
$ sudo chown -R `id -un` /data/db
$ cd /Locally run mongodb

就这样。我们已经安装了 MongoDB,可以随时使用了。

现在,我们可以运行 Mongod 和 Mongo;见图 7-2 和图 7-3 。

img/503823_1_En_7_Fig2_HTML.jpg

图 7-2

运行在 Mac 终端的 Mongod 进程

$ mongod

$ mongo

img/503823_1_En_7_Fig3_HTML.jpg

图 7-3

mongo shell 命令

为什么是两个命令?这些命令是什么意思?

Mongod 将启动 MongoDB 进程,并在后台运行它。Mongo 会给我们一个命令行 shell,它连接到我们正在运行的 Mongod 进程。在 Mongo shell 中,我们可以执行命令。

这取决于您安装的 MongoDB 版本。一些 MongoDB 版本在后台作为服务运行,终端不需要保持打开。

Note

mongod(“Mongo 守护进程”)是 MongoDB 的守护进程。Mongod 处理 MongoDB 数据请求,管理数据访问,并运行管理操作。mongo是连接 Mongod 的命令行 shell。

现在,在 Mongo shell 终端窗口中,我们需要创建我们的数据库名称。

$ use YourDatabaseName

然后我们插入我们的用户。

db.users.insert({"username": "user", "email":"youremail@gmail.com",  password:"123456", passwordHash: "someHash", passwordSalt: "someSalt", attempt: 0, lastLoginDate: "2020-01-15T12:10:00+00:00", "signDate":"2020-01-15T11:50:12+00:00" })

您可以使用以下命令来确认这一点:

db.users.find();

该命令将返回我们插入的用户。虽然您可以在 Mongo shell 中键入所有命令,但是有许多图形用户界面(GUI)可以帮助管理 MongoDB 数据库。我推荐的两个可以提供帮助的好 GUI 是 Compass ( https://www.mongodb.com/try/download/compass )和 nosqlbooster ( https://nosqlbooster.com/downloads )。

这些工具可以帮助备份、导出、导入和运行命令。参见 MongoDB 团队提供的 MongoDB Compass 下载页面,如图 7-4 。

img/503823_1_En_7_Fig4_HTML.jpg

图 7-4

MongoDB Compass GUI 下载页面

有许多工具,付费的和免费的,所以请随意做你自己的研究(DYOR)。每个工具根据订阅提供不同的功能集,并支持不同的平台。我不是在推荐任何工具,你也不必使用任何 GUI。Mongo 命令行 shell 可以满足您的所有需求。

要连接和设置这些 GUI 工具,过程是相同的:我们在本地或远程机器上连接到 MongoDB。由于我们没有更改默认的 MongoDB,所以 MongoDB 的端口应该是 27017。

GUI Client > local localhost:27017 > connect

许多 GUI 也支持在 URI 中粘贴。没有适当安全性的本地主机的 URI 将如下所示:

uri:mongob://localhost:27017

Note

URI值表示创建 Mongo 实例的统一资源标识符(URI)。URI 描述了主机和选项。

现在我们已经设置了 MongoDB 数据库,创建了服务 API,并且有了利用 Express 的 Node.js 服务器文件,我们已经准备好运行后端服务了。在一个单独的窗口中,调用:

$ node server.js

如果一切顺利,您将在终端中得到以下输出:

{"message":"Listening on http://Computer-name-or-ip:8081","level":"info"} type: master, masterPeerId: 548e09f70356a1237594fbe489e33684, channel: roomsjs, port: 56622

这意味着服务文件遍历我们的服务文件,并为我们建立一个套接字,以备将来需要。现在,如果您使用以下代码测试服务:

http://localhost:8081/validate?email=youremail@gmail.com&password=isDebug

您将在浏览器中获得以下结果:

{"status":"success","params":{"exist_member_id":"5f58278c81cb4a742188d3cb","isUserExists":true,"user":{"_id":"5f58278c81cb4a742188d3cb","user": "user", "email":"YouEmail@gmail.com","password":"123456","passwordHash":"someHash","passwordSalt":"someSalt","attempt":0,"lastLoginDate":"2020-01-15T12:10:00+00:00","signDate":"2020-01-15T11:50:12+00:00"}}}

接下来,如果您检查您的前端代码,使用isDebug密码和您设置的电子邮件地址,您可以再次运行您的应用($yarn start)。你会发现我们可以成功登录我们的安全会员区。图 7-5 显示了会员安全区域,现在只有我们在上一章创建的注销按钮。

img/503823_1_En_7_Fig5_HTML.jpg

图 7-5

用户成功登录后保护会员区

现在让我们打开浏览器开发人员控制台。例如,在 Chrome DevTools 中,从顶部菜单选择查看➤开发者➤开发者工具。

我们可以看到我们的应用在我们的本地存储中创建了accessToken,使用我们设置的值,如图 7-6 所示。我们的应用使用这些值来确定用户是否可以访问安全区域。

img/503823_1_En_7_Fig6_HTML.jpg

图 7-6

Chrome 开发者工具本地存储值

设置 MongoDB 身份验证

我们已经在本地机器上设置了 MongoDB,所以下一步是创建 MongoDB 身份验证。没有身份验证,任何未经授权的用户都可以连接到您的数据库,为所欲为。

要进行设置,在终端中,以管理员身份连接到 Mongo shell 终端(确保 Mongod 正在运行)。一旦我们连接到数据库,我们就可以创建将要使用的用户,并设置读和写的角色。以下是命令:

$ mongo admin
$ use MyDatabase
switched to db MyDatabase

$ db.createUser({ user: "myuser", pwd: "YOUR_PASSWORD", roles: ["readWrite"] })
Successfully added user: { "user" : "myuser", "roles" : [ "readWrite" ] }

对于健全性检查,我们可以运行getUsers命令来确保我们的用户被正确添加。

$ db.getUsers()

[
     {
          "_id" : "MyDatabase.myuser",
          "user" : "myuser",
          "db" : "MyDatabase",
          "roles" : [
               {

                    "role" : "readWrite",
                    "db" : "MyDatabase"
               }
          ],
          "mechanisms" : [
               "SCRAM-SHA-1",
               "SCRAM-SHA-256"
          ]
     }
]

接下来,断开与 Mongo shell 的连接(Cmd+C)。

我们的用户有一个安全密码。接下来,我们可以在 Mongod 配置文件中启用身份验证。启用安全性,如果还没有启用的话。

$ vim /usr/local/etc/mongod.conf
security: authorization: enabled

太好了。现在用用户名和密码连接到 MongoDB。

$ mongo MyDatabase -u myuser -p YOUR_PASSWORD

太棒了。现在我们的数据库有密码保护。在 Mongo shell 或 GUI 中,如果你试图在没有凭证的情况下连接,你会得到一个错误,如图 7-7 所示。

img/503823_1_En_7_Fig7_HTML.jpg

图 7-7

MongoDB Compass 上的验证错误

为了解决这个认证错误,我们需要使用我们的凭证进行连接,如图 7-8 所示。

img/503823_1_En_7_Fig8_HTML.jpg

图 7-8

在 MongoDB Compass 中设置身份验证

最后一部分是设置我们的代码使用身份验证进行连接。如果你看一下server.js的代码级别,我们正在连接roomdb,它使用 Mongoose 库( https://github.com/Automattic/mongoose )进行连接。

roomdb.connectToDatabase('mongodb', 'mongodb://' + roomsSettingsJSON.environment.host + '/YourSite', { useNewUrlParser: true, useUnifiedTopology: true });

代码被设置为使用本地机器上的本地文件(roomsdb-local.json)和远程机器上的roomsdb.json(我们将在下一章中设置)。

如果我们在我们的roomsdb-local.json文件中设置新的认证信息,我们会得到:

{
  "name": "db-config",
  "version": "1.0",
  "environment": {
    "host":"localhost",
    "user":"myuser",
    "password":"YOUR_PASSWORD",
    "dsn": "YourSite"
  }
}

我们现在可以按照以下语法重构连接:

mongoose.connect('mongodb://username:password@host:port/database')

看一看:

let db_host = 'mongodb://' + roomsSettingsJSON.environment.user + ':' + roomsSettingsJSON.environment.password + '@' + roomsSettingsJSON.environment.host + '/' + roomsSettingsJSON.environment.dsn;

roomdb.connectToDatabase('mongodb', db_host, { useNewUrlParser: true, useUnifiedTopology: true });

来吧,试一试:

$ node server.js

这个设计为我们的生产做好了准备,因为我们有两个用于开发和生产的文件(roomsdb-local.jsonroomsdb.json),它们保存数据库信息和基于代码运行位置的代码切换。

完全登录注册系统

到目前为止,我们已经完成了登录组件从前端到后端的一个完整周期,但是我们仍然没有完成代码。我们的代码没有读写我们的 MongoDB。

原因是在现实生活中,我们需要对用户的密码进行加密和解密,而不是仅仅传入烤入的数据。为此,我们需要添加一些注册逻辑,它可以获取用户的密码字符串,加密该字符串,然后将其存储在我们的数据库中。当用户想要登录时,我们希望解密用户的密码,并将其与用户在登录输入框中提供的密码进行匹配。所有这些都是安全登录注册系统的常规安全协议。

在本章的这一部分,我们将这样做。我们将创建一个加密用户密码的注册组件和一个将数据写入 MongoDB 的服务 API。最后,我们将重构反冲登录选择器,在发送密码之前对其进行加密,这样我们就可以测试读取用户的密码并比较结果。

您可以从这里下载我们将要编写的完整前端代码:

https://github.com/Apress/react-and-libraries/07/exercise-7-2

我们开始吧。

注册模型

我们可以从模型对象开始,采用与登录组件相同的方式。用我们想要捕获的寄存器信息创建一个registerObject.ts

在我们的例子中,我们的表单将捕获用户名、电子邮件和密码,并确保在重复输入密码的情况下正确插入密码。这可以扩展到您想要捕捉的任何其他信息。我们还将设置initRegister方法来设置我们的默认值。

export interface registerObject {
  username: string
  email: string
  password: string
  repeat_password: string
}

export const initRegister = (): registerObject => ({
  username: '',
  email: '',
  password: '',
  repeat_password: '',
})

记住还要将寄存器模型添加到src/model/index.ts文件中,以便于访问。

// src/model/index.ts
export * from './registerObject'

注册原子

接下来是设置我们的反冲原子。创建一个新文件,并将其命名为registerAtoms.ts。该文件将使用initRegister来设置默认值。

// src/recoil/atoms/regsiterAtoms.ts
import { atom } from 'recoil'
import { initRegister } from '../../model'

export const registerState = atom({
  key: 'RegisterState',
  default: initRegister(),
})

现在我们已经准备好了 atom,我们可以继续创建我们的寄存器选择器。

寄存器选择器

为了加密和解密用户的密码,我们将使用一个名为crypto-js ( https://github.com/brix/crypto-js )的库。这是一个包含加密标准的 JavaScript 库。

我们将需要安装的库和类型。

$ yarn add crypto-js @types/crypto-js

我们的registerSelectors.ts将类似于我们的登录选择器。

// src/recoil/selectors/registerSelectors.ts

import { selector } from 'recoil'
import axios from 'axios'
import { registerState } from '../atoms/registerAtoms'
import * as CryptoJS from 'crypto-js'

export const registerUserSelector = selector({
  key: 'RegisterUserSelector',
  get: async ({ get }) => {
    const payload = get(registerState)
    if (
      payload.email === '' ||
      payload.password === '' ||
      payload.repeat_password === '' ||
      payload.username === '' ||
      payload.repeat_password !== payload.password
    ) {
      // eslint-disable-next-line no-console
      console.log(
        'registerSelectors.ts :: registerUserSelector :: ERROR incomplete form :: ' +
          JSON.stringify(payload)
      )
      return 'Error: Please complete form'
    }
    try {
      // console.log('registerSelectors.ts :: registerUserSelector :: start encrypt')

但是我们会添加加密。为了加密,我们可以创建一个我们可以决定的秘密密码短语,然后使用CryptoJS.AES.encrypt方法来加密我们的密码。看一看:

      const secretPassphrase = 'mySecretPassphrase'
      const passwordEncrypt = CryptoJS.AES.encrypt(payload.password, secretPassphrase)
      const passwordEncryptEncodeURI = encodeURIComponent(passwordEncrypt.toString())
      // console.log('passwordEncryptEncodeURI: ' + passwordEncryptEncodeURI)

此外,我们可以为发布产品代码做准备,这将在下一章中进行。CRA 接受添加环境变量( https://create-react-app.dev/docs/adding-custom-environment-variables/ )。事实上,process.env.NODE_ENV已经和developmentproduction一起被植入我们的应用中。我们可以用它来设置我们的服务 API URL,我们可以在我们的代码中设置它。

      const host = process.env.NODE_ENV === 'development' ? 'http://localhost:8081' : ''
      // console.log(
        `userSelectors.ts :: submitUserLoginSelector :: process.env.NODE_ENV: ${process.env.NODE_ENV}`
      )
      const urlWithString =
        host +
        '/register?name=' +
        payload.username.toLowerCase() +
        '&email=' +
        payload.email.toLowerCase() +
        '&password=' +
        passwordEncryptEncodeURI
      // eslint-disable-next-line no-console
      console.log('registerSelectors.ts :: registerUserSelector :: url: ' + urlWithString)
      const res = await axios({
        url: urlWithString,
        method: 'get',
      })
      // const status = `${res.data.status}`
      // console.log(`userSelectors.ts :: registerUserSelector :: results: ${JSON.stringify(status)}`)
      return res?.data?.status
    } catch (err) {
      // console.warn(err)
      return `Error: ${err}`
    }
  },
})

我们的寄存器选择器已经完成,可以使用了。然而,在我们继续构建我们的视图表示层之前,为了安全起见,我们还可以调整登录选择器来发送密码的加密字符串。让我们来看看。

重构登录

在这一节中,我们将重构我们的登录,这样它将调整我们的userSelectors.ts逻辑,以便发送我们用户密码的加密版本。

通过传递用户名和密码,我们得到了:

const urlWithString = `http://localhost:8081/validate?email=${payload.email}&password=${payload.password}`

要在crypto-js库(yarn add crypto-js)的帮助下加密用户名和密码,请使用:

// src/recoil/selectors/userSelectors.ts

import * as CryptoJS from 'crypto-js'

const secretPassphrase = 'mySecretPassphrase'
const passwordEncrypt = CryptoJS.AES.encrypt(payload.password, secretPassphrase)
const passwordEncryptEncodeURI = encodeURIComponent(passwordEncrypt.toString())
// console.log('passwordEncryptEncodeURI: ' + passwordEncryptEncodeURI)
const urlWithString = `${host}/validate?email=${payload.email}&password=${passwordEncryptEncodeURI}`

太好了!现在,我们的登录和注册选择器已经准备好将用户的加密密码传递给我们的服务 API。

在下一章中,我们将把我们的应用发布到产品中,并设置在 SSL 服务器上,这样数据不仅被加密,还能防止黑客窃取我们的用户信息。正如您所记得的,我们还放置了一些逻辑来检查用户尝试登录的次数,以获得额外的安全性。

注册视图层

对于注册视图层,我们将使用与登录视图层相同的方法。我们将创建以下内容:

  • RegisterForm.tsxRegisterForm.styles.ts

  • RegisterPage.tsx,包括子组件RegisterPageInnerSubmitUserFormComponentonFailRegister

看看寄存器组件的层次结构,如图 7-9 所示。

img/503823_1_En_7_Fig9_HTML.jpg

图 7-9

注册视图层组件层次线框

RegisterForm.tsx中,我们的RegisterPage将包装RegisterPageInner纯子组件,以便 React 挂钩工作。一旦用户提交了 atom,表单就会被更新,并且这些变化会反映在SubmitUserFormComponent中,就像我们对 login 组件所做的那样。onSuccessRegisteronFailRegister方法处理成功和失败的登录尝试。这与我们在前一章中用于Login视图层的过程相同。参见图 7-10 ,该图显示了该流程的活动流程图。

img/503823_1_En_7_Fig10_HTML.jpg

图 7-10

注册活动流程图

登记表

我们的注册表单是保存注册表单元素的子组件。我们将从显示导入的库开始。

// src/components/Register/RegisterForm.tsx
import * as React from 'react'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
import { withStyles, WithStyles } from '@material-ui/core/styles'
import CircularProgress from '@material-ui/core/CircularProgress'
import styles from './RegisterForm.styles'
import { registerObject } from '../../model/registerObject'

RegisterFormInner是我们包装的 React 函数组件,我们将使用它来传递样式和props

const RegisterFormInner: React.FunctionComponent<IRegisterFormProps> = (
  props: IRegisterFormProps
) => {
  const onTextFieldChangeHandler = (fieldId: any) => (e: any) => {
    props.onUpdateRegisterField(fieldId, e.target.value)
  }

为了简单起见,代码采用了与我们在前一章中创建的Login表单组件相同的方法。我们将对onUpdateRegisterFieldonRegister方法的更改传递给父组件。

  return (
    <div className={props.classes.container}>
      <TextField
        label="username"
        margin="normal"
        value={props.registerInfo.username}
        onChange={onTextFieldChangeHandler('username')}
      />
      <TextField
        label="email address"
        margin="normal"
        value={props.registerInfo.email}
        onChange={onTextFieldChangeHandler('email')}
      />
      <TextField
        label="password"
        type="password"
        margin="normal"
        value={props.registerInfo.password}
        onChange={onTextFieldChangeHandler('password')}
      />
      <TextField
        label="repeat password"
        type="password"
        margin="normal"
        value={props.registerInfo.repeat_password}
        onChange={onTextFieldChangeHandler('repeat_password')}
      />
      <Button
        variant="contained"
        color="primary"
        disabled={props.loading}
        onClick={props.onRegister}
      >
        Register
        {props.loading && <CircularProgress size={30} color="secondary" />}
      </Button>
    </div>
  )
}

我们的接口可以绑定到我们的registerObject来设置默认值。

interface IRegisterFormProps extends WithStyles<typeof styles> {
  onRegister: () => void
  onUpdateRegisterField: (name: string, value: any) => void
  registerInfo: registerObject
  loading: boolean
}

最后,我们的Form子组件绑定了样式对象。

export const RegisterForm = withStyles(styles)(RegisterFormInner)

注册表单样式

在我们的RegisterForm.styles.ts文件中,我们设置了容器样式,以 flex 列样式对齐我们的容器。

// src/components/Register/RegisterForm.styles.ts
import { createStyles, Theme } from '@material-ui/core/styles'

export default (theme: Theme) =>
  createStyles({
    container: {
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
    },
  })

现在我们已经有了带有样式集的Form子组件,我们可以创建我们的父组件,也就是注册页面。

注册页面

我们还没有创建RegisterPage组件。您可以使用generate-react-cli创建注册页面。

$ npx generate-react-cli component RegisterPage --type=page

在我们的RegisterPage.tsx组件中,让我们更新代码。首先设置import报表。

src/pages/RegisterPage/RegisterPage.tsx
import React, { useState } from 'react'
import { Card, CardContent, CardHeader } from '@material-ui/core'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { Centered } from '../../layout/Centered'
import { RegisterForm } from '../../components/Register/RegisterForm'
import { initToast, notificationTypesEnums, randomToastId } from '../../model'
import { registerState } from '../../recoil/atoms/registerAtoms'
import { toastState } from '../../recoil/atoms/toastAtoms'
import { registerUserSelector } from '../../recoil/selectors/registerSelectors'
import { sessionState } from '../../recoil/atoms/sessionAtoms'
import { initRegister } from '../../model/registerObject'

接下来,我们设置我们的RegisterPage来包装 React 挂钩的RegisterPageInner子组件。

const RegisterPage = () => {
  return <RegisterPageInner />
}

RegisterPageInner中,我们传递props并获取寄存器状态对象。

function RegisterPageInner(props: IRegisterPageProps) {
  const [userRegisterPageState, setUserRegisterPageState] = useState(initRegister)
  const [loading, setLoading] = useState(false)
  const [user, setUser] = useRecoilState(registerState)
  const onRegister = () => {
    setLoading(true)
    setUser(userRegisterPageState)
  }

一旦从表单中调用了onUpdateRegisterFieldHandler,我们就更新状态。

  const onUpdateRegisterFieldHandler = (name: string, value: string) => {
    setUserRegisterPageState({
      ...userRegisterPageState,
      [name]: value,
    })
  }

在 JSX 端,我们设置SubmitUserFormComponent以防表单被提交或显示表单。

  return (
    <Centered>
      {loading ? (
        <SubmitUserFormComponent />
      ) : (
        <Card>
          <CardHeader title="Register Form" />
          <CardContent>
            <RegisterForm
              onRegister={onRegister}
              onUpdateRegisterField={onUpdateRegisterFieldHandler}
              registerInfo={userRegisterPageState}
              loading={loading}
            />
          </CardContent>
        </Card>
      )}
    </Centered>
  )
}
interface IRegisterPageProps {
  // TODO
}
export default RegisterPage

SubmitUserFormComponent子组件中,我们使用选择器进行服务调用并显示结果。

function SubmitUserFormComponent() {
  console.log(`RegisterPage.tsx :: SubmitUserFormComponent`)
  const results = useRecoilValue(registerUserSelector)
  const setSessionState = useSetRecoilState(sessionState)
  const setToastState = useSetRecoilState(toastState)

onSuccessRegisteronFailRegister方法处理成功的登录尝试和失败的登录尝试。在这一点上,我们只是将它与我们创建的内置令牌联系起来,但稍后我们可以实现一个逻辑,让我们的后端系统生成唯一的令牌,并让这些令牌由我们的前端代码解释;例如,我们可以让它们在 24 小时内过期。

  const onSuccessRegister = () => {
    localStorage.setItem('accessToken', 'myUniqueToken')
    setSessionState('myUniqueToken')
  }
  const onFailRegister = () => {
    setToastState(initToast(randomToastId(), notificationTypesEnums.Fail, results))
    localStorage.removeItem('accessToken')
    setSessionState('')
  }
  results === 'success' ? onSuccessRegister() : onFailRegister()
  return (
    <div className="RegisterPage">
      {results === 'success' ? (
        Success
      ) : (
        We were unable to register you in please try again. Message: `{results}`
      )}

  )
}

对于样式 SCSS,我们可以在按钮上设置一些填充,这样页面的格式就很好,因为除了注销按钮,我们没有其他内容。

RegisterPage.scss
.RegisterPage {
  padding-bottom: 350px;
}

我们已经完成了注册组件的前端视图。

重构逼近器

为了显示我们创建的RegisterPage页面,我们需要在我们的AppRouter组件中包含页面组件。看一看:

// src/AppRouter.tsx

import Register from './pages/RegisterPage/RegisterPage'

function AppRouter() {
  return (
    <Router>
      <RecoilRoot>
        <Suspense fallback={<span>Loading...</span>}>
          <ToastNotification />
          <HeaderTheme />
          <Switch>
            <Route exact path="/" component={App} />
<Route exact path="/Register" component={Register} />
            ...
         </Switch>
          <div className="footer">
            <FooterTheme />

          </div>
        </Suspense>
      </RecoilRoot>
    </Router>
  )
}

注册用户服务 API

现在我们已经完成了前端代码,我们可以在 Node.js 应用中创建我们的register.js服务文件。服务类似于validate.js

让我们来看看。我们的imports语句包括crypto-js库,因此我们可以使用 React 应用中使用的相同库来解密密码。

// src/services/register.js

'use strict';

let usersSchema = require("../models/database").usersSchema,
    logger = require('../utils/log.js').logger,
    moment = require("moment"),
    async = require('async'),
    CryptoJS = require('crypto-js'),
    params,
    user,
    isUserExists = false,
    connector,
    users;

我们的主函数register包括三个操作:readUserInfoFromDBinsertUsergetUserId

function register(data, dbconnectorCallBackToRooms) {

    logger.info('---------- register ----------');
    connector = this.getConnector();
    params = data.query || data.params;
    params.member_id = -1;

    let operations = [];
    operations.push(readUserInfoFromDB);
    operations.push(insertUser);
    operations.push(getUserId);

    async.series(operations, function (err, results) {

        let retData = {

            "exist_member_id": params.member_id,
            "isUserExists": isUserExists,
            "user": user
        };

        user = null;
        users = null;
        isUserExists = false;
        params = null;

        if (err) {
            logger.info(err);
            dbconnectorCallBackToRooms(data, {status: 'error', error_message: err, params: retData});
        } else {
            dbconnectorCallBackToRooms(data, {status: 'success', params: retData});
        }
    });
}

readUserInfoFromDB操作将检查用户是否已经存在于数据库中,因为我们不希望多个用户使用相同的电子邮件地址。

function readUserInfoFromDB(callback) {
    logger.info('---------- register :: readUserInfoFromDB ----------');
    if (connector.isModelExists('users')) {
        users = connector.getModel('users');
    } else {
        let schema = connector.setSchema(usersSchema);
        users = connector.setModel('users', schema);
    }
    let findObject = {
        username: params.name,
    };
    users.find(findObject)
        .then((doc) => {
            if (doc.length > 0) {
                isUserExists = true;
                params.member_id = doc[0]._id;
                logger.info('isUserExists');
            } else {
                isUserExists = false;
            }
            callback(null, doc);
        })
        .catch((err) => {
            logger.info(err);
            params.member_id = -1;
            callback(err.message, null);
        });
}

我们的insertUser操作将使用我们在 React 前端代码上创建的相同秘密字符串来解密密码。我们添加了一个随机的salthash,以确保我们用户的密码安全地存储在我们的数据库中。

原因是我们不想对所有密码使用相同的密码秘密,而是对每个密码使用唯一的密钥。这是一个常见的安全协议,以确保我们的用户的个人信息受到保护。

function insertUser(callback) {
    logger.info('---------- register :: insertUser isUserExists :: ' + isUserExists + ', member_id: ' + params.member_id);
    if (isUserExists) {
        callback('error', 'user_exists_already');
    } else {
        let passwordEncrypt;
        let secretPassphrase = 'mySecretPassphrase';

有了所有的安全措施,我将设置一个覆盖密码isDebug,它仅用于测试目的,应该在生产构建中删除。

        if (params.password === 'isDebug') {
            passwordEncrypt = CryptoJS.AES.encrypt("123456", secretPassphrase);
            let passwordEncryptEncodeURI = encodeURIComponent(passwordEncrypt);
            logger.info('debug passwordEncryptEncodeURI: ' + passwordEncryptEncodeURI);
        } else {
            passwordEncrypt = params.password;
        }

        let user_password = (CryptoJS.AES.decrypt(passwordEncrypt, secretPassphrase)).toString(CryptoJS.enc.Utf8),
            pass_salt = Math.random().toString(36).slice(-8),
            encryptedPassword = CryptoJS.AES.encrypt(user_password, pass_salt),
            now = moment().format();

        logger.info('---------- register :: insertUser :: user_password : ' + user_password);

        let newUsers = new users({
            username: (params.name).toLowerCase(),
            email: (params.email).toLowerCase(),
            passwordHash: encryptedPassword,
            passwordSalt: pass_salt,
            lastLoginDate: now,
            attempt: 0,
            signDate: now,
            emailEachLogin: true,
            loginToken: '',
            phone: ''
        });

        newUsers.save(function (err) {
            if (err) {
                logger.info('Error' + err.message);
                callback(err.message, null);
            } else {
                callback(null, 'success');
            }
        });
    }
}

最后,getUserId操作将检索userId,这样我们可以将它传递给我们的应用,并确保我们的数据被正确插入。

function getUserId(callback) {
    if (isUserExists) {
        callback('error', 'user_exists_already');
    } else {
        logger.info('register :: getUserId');

        users.find({

            username: params.name,
        })
        .then((doc) => {
            if (doc.length > 0) {
                params.member_id = doc[0]._id;
                callback(null, doc);
            } else {
                callback('error username return no results');
            }
        })
        .catch((err) => {
            logger.info(err);
            callback(err.message, null);
        });
    }
}

module.exports.register = register;

Tip

我在代码中留下了大量日志注释,以帮助您在前端和后端代码中更好地调试和理解代码。

现在在两个终端窗口中运行 Node.js 和 Mongo。

$ node server.js
$ mongod

如果您在http://localhost:3000/Register导航到注册页面,您将看到图 7-11 中的屏幕。您现在可以注册新用户了。

img/503823_1_En_7_Fig11_HTML.jpg

图 7-11

注册页面

如果一切顺利,您可以使用 Mongo shell 或您喜欢的其他 GUI 来查看数据库,并看到我们刚刚输入的用户。参见图 7-12 。

img/503823_1_En_7_Fig12_HTML.jpg

图 7-12

进入 MongoDB 数据库的用户的 GUI 视图

摘要

在本章中,我们在 Node.js、Express 和 MongoDB 的帮助下创建了我们的后端。我们首先创建数据库模式,然后创建验证服务。我们使用了roomsjsroomdb库来加速 Express 和 Node.js 的开发。我们为后端设置了本地环境,包括创建身份验证。在本章的第二部分,我们添加了一个注册组件,并通过加密和解密用户密码以及更新登录选择器来完成登录周期。

这一章是令人兴奋的,因为所有以前的章节现在都聚集在一起,创造一个循环。我们能够创建一个完整的工作网站/应用,允许用户不仅可以查看 React 组件制作的页面,还可以拥有注册和登录等常见功能。我们甚至实施了安全措施。在这个过程中,您能够了解 React、状态管理、浏览器本地存储、数据处理以及如何构建 React 组件和子组件。

在下一章中,您将学习如何将您的工作发布到部署服务器。