React-和-GraphQL-全栈-Web-开发第二版-三-

95 阅读45分钟

React 和 GraphQL 全栈 Web 开发第二版(三)

原文:zh.annas-archive.org/md5/218d260d064933ae511d6d90a02baf1c

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:使用 Apollo 和 React 进行身份验证

在过去的几章中,我们已经走得很远了。现在,我们已经到达了将要为我们的 React 和 GraphQL Web 应用程序实现身份验证的阶段。在本章中,你将学习到构建使用 GraphQL 进行身份验证的应用程序的一些基本概念。

本章涵盖了以下主题:

  • JWT 是什么?

  • Cookie 与 localStorage 的比较

  • 在 Node.js 和 Apollo 中实现身份验证

  • 用户注册和登录

  • 验证 GraphQL 查询和突变

  • 从请求上下文中访问用户

技术要求

本章的源代码可在以下 GitHub 仓库中找到:

github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition/tree/main/Chapter06

什么是 JSON Web Tokens?

JSON Web Tokens (JWTs) 仍然是一个相对较新的标准,用于执行身份验证;并不是每个人都了解它们,甚至更少的人使用它们。本节不会提供 JWT 的数学或加密基础理论性的探讨。

例如,在用 PHP 编写的传统 Web 应用程序中,你通常有一个会话 cookie。这个 cookie 识别服务器上的用户会话。会话必须存储在服务器上以检索初始用户。这里的问题是,保存和查询所有用户的会话可能会产生很高的开销。然而,在使用 JWT 时,服务器无需保留任何类型的会话 ID。

通常来说,JWT 包含了识别用户所需的一切。最常见的方法是存储令牌的创建时间、用户名、用户 ID,以及可能的角色,例如管理员或普通用户。出于安全原因,你不应该包含任何个人信息或关键数据。

JWT 存在的原因并不是为了以任何方式加密或保护数据。相反,为了使用服务器等资源进行身份验证,你需要发送一个由你的服务器验证的已签名的 JWT。只有当它是由你的服务器声称为可信的服务创建时,它才能验证 JWT。在大多数情况下,你的服务器将使用其公钥来签名令牌。任何可以读取你与服务器之间通信的人或服务都可以访问令牌,并且可以轻松提取有效载荷。尽管如此,他们无法编辑其内容,因为令牌是用签名签名的。

令牌需要在客户端的浏览器中安全地传输和存储。如果令牌落入错误的手中,那个人可以使用您的身份访问受影响的应用程序,以您的名义发起操作,或读取个人信息。JWT 的撤销也很困难。使用会话 cookie,您可以在服务器上删除会话,用户将不再通过 cookie 进行认证。然而,使用 JWT,我们在服务器上没有任何信息。它只能验证令牌的签名并在您的数据库中找到用户。一种常见的方法是有一个所有不允许的令牌的黑名单。或者,您可以通过指定过期日期来降低 JWT 的有效期。然而,这种解决方案需要用户频繁地重复登录过程,这会使体验变得不那么舒适。

JWT 不需要任何服务器端存储。服务器端会话的妙处在于您可以存储特定应用程序状态,例如记住用户执行的最后操作。没有服务器端存储,您要么需要在 localStorage 中实现这些功能,要么实现一个会话存储,这对于使用 JWT 认证根本不是必需的:

注意

JWT 在开发者社区中是一个重要的话题。有关 JWT 是什么、如何使用以及其技术背景的出色文档有很多。访问以下网页了解更多信息,并查看 JWT 生成演示:jwt.io/

图 6.1 – JWT 结构

图 6.1 – JWT 结构

如前图所示,JWT 由三个部分组成:

  • 标题: 标题指定了用于生成 JWT 的算法。

  • 有效载荷: 有效载荷由所有“会话”数据组成,这些数据被称为声明。前面的只是一个简单的表示,并没有展示 JWT 的全部复杂性。

  • 签名: 签名是从标题和有效载荷计算得出的。为了验证 JWT 是否被篡改,签名将与从实际有效载荷和标题中新生成的签名进行比较。

在我们的示例中,我们将使用 JWT,因为它们是一种现代且去中心化的认证方法。尽管如此,您可以在任何时候选择退出此选项,并改用常规会话,这在 Express.js 和 GraphQL 中可以快速实现。

在下一节中,我们将探讨在浏览器内部存储 JWT 的不同方法以及如何在 localStorage 和 cookies 之间传输。

localStorage 与 cookies 的比较

让我们看看另一个关键问题。了解至少认证工作原理及其安全性的基础知识至关重要。您对任何可能导致数据泄露的故障实现负有责任,所以请始终牢记这一点。我们在哪里存储从服务器收到的令牌?

无论你将令牌发送到哪个方向,你都应该始终确保你的通信是安全的。对于像我们这样的 Web 应用程序,请确保所有请求都启用了 HTTPS。一旦用户成功认证,客户端将根据 JWT 认证工作流程接收 JWT。JWT 不绑定到任何特定的存储介质,因此你可以自由选择你喜欢的任何一种。如果我们不在收到令牌时存储它,它将仅在内存中可用。当用户浏览我们的网站时,这是可以的,但当他们刷新页面时,他们需要再次登录,因为我们没有在任何地方存储令牌。

有两种标准选项:将 JWT 存储在localStorage中或存储在 cookie 中。让我们先讨论第一种选项。localStorage是教程中经常建议的选项。这是可以的,假设你正在编写一个单页 Web 应用程序,其中内容根据用户和客户端路由的动作动态更改。我们不遵循任何链接并加载新站点以查看新内容;相反,旧的内容只是被你想要显示的新页面所替换。

将令牌存储在localStorage有以下缺点:

  • localStorage不是在每次请求时都传输。当页面首次加载时,你无法在请求中发送令牌,因此需要认证的资源无法返回给你。一旦你的应用程序加载完成,你必须向服务器发送第二个请求,包括令牌以访问受保护的内容。这种行为的结果是,无法构建服务器端渲染的应用程序。

  • 客户端需要实现将令牌附加到发送到服务器的每个请求的机制。

  • 由于localStorage的性质,客户端没有内置的过期日期。如果在某个时刻,令牌达到其过期日期,它仍然存在于客户端的localStorage中。

  • localStorage通过纯 JavaScript 访问,因此容易受到 XSS 攻击。如果有人设法通过未经过滤的输入将自定义 JavaScript 集成到你的代码或网站上,他们可以从localStorage中读取令牌。

然而,使用localStorage有许多优点:

  • 由于localStorage不是在每次请求时自动发送,因此它对任何试图通过随机请求从外部站点执行操作的跨站请求伪造CSRF)攻击具有安全性。

  • localStorage在 JavaScript 中很容易读取,因为它以键值对的形式存储。

  • 它支持更大的数据大小,这对于存储应用程序状态或数据来说非常好。

将如此关键的令牌存储在 Web 存储中的主要问题是您无法保证没有不受欢迎的访问。除非您能确保每个单独的输入都经过清理,并且您不依赖于任何捆绑到您的 JavaScript 代码中的第三方工具,否则始终存在潜在的风险。即使是一个您没有构建的包也可能与创建者共享您的用户的 Web 存储,而您或用户可能从未注意到。此外,当您使用公共内容分发网络CDN)时,攻击面和您的应用程序的风险都会增加。

现在,让我们来看看 cookie。尽管由于欧盟启动的 cookie 合规性法律而受到负面报道,但它们仍然很棒。抛开 cookie 可以允许公司做的更负面的事情,比如跟踪用户,它们仍然有很多优点。与localStorage相比的一个显著区别是,cookie 会随着每个请求发送,包括您应用程序托管站点的初始请求。

Cookie 具有以下优点:

  • 由于每个请求都会发送 cookie,因此服务器端渲染根本不是问题。

  • 前端不需要实现任何额外的逻辑来发送 JWT。

  • 可以将 cookie 声明为httpOnly,这意味着 JavaScript 无法访问它们。这可以保护我们的令牌免受 XSS 攻击。

  • Cookie 有一个内置的过期日期,可以设置为在客户端浏览器中使 cookie 失效。

  • 可以配置 cookie,使其只能从特定的域或路径中读取。

  • 所有浏览器都支持 cookie。

这些优点听起来很好,但让我们考虑一下缺点:

  • Cookie 通常容易受到 CSRF 攻击,在这些攻击中,外部网站向您的 API 发送请求。他们期望您已认证,并希望他们能代表您执行操作。我们无法阻止 cookie 与每个请求一起发送到您的域。常见的预防策略是实施 CSRF 令牌。这个特殊的令牌也由您的服务器传输并保存为 cookie。由于它存储在不同的域下,外部网站无法使用 JavaScript 访问 cookie。您的服务器不会从每个请求中读取 cookie,而只从 HTTP 头中读取。这种行为保证了令牌是由托管在您的应用程序上的 JavaScript 发送的,因为只有这样才能访问令牌。然而,设置用于验证的 XSRF 令牌却需要做很多工作。

  • 由于它们以一个大型的逗号分隔的字符串形式存储,访问和解析 cookie 并不直观。

  • 它们只能存储少量的数据。

因此,我们可以看到这两种方法都有其优点和缺点。

最常见的方法是使用 localStorage,因为这是最简单的方法。在这本书中,我们将首先使用 localStorage,但稍后将在使用服务器端渲染时切换到 cookies,以便让你体验两种方法。你可能根本不需要服务器端渲染。如果是这种情况,你可以跳过这部分,以及 cookie 实现。

在下一节中,我们将实现使用 GraphQL 的身份验证。

GraphQL 身份验证

现在身份验证的基本知识应该已经清楚。现在,我们的任务是实现一种安全的方法让用户进行身份验证。如果我们查看当前的数据库,我们会看到我们缺少所需的字段。为此,请按照以下步骤操作:

  1. 让我们准备并添加一个 password 字段和一个 email 字段。正如我们在 第三章 中所学的,连接到数据库,我们必须创建一个迁移来编辑我们的用户表。如果你忘记了这些命令,可以在该章节中查找:

    sequelize migration:create --migrations-path src/server/migrations --name add-email-password-to-post
    

    上述命令为我们生成了新文件。

  2. 替换其内容,然后尝试自己编写迁移,或者你可以检查以下代码片段中的正确命令:

    'use strict';
    module.exports = {
      up: (queryInterface, Sequelize) => {
        return Promise.all([
          queryInterface.addColumn('Users',
            'email',
            {
              type: Sequelize.STRING,
              unique : true,
            }
          ),
          queryInterface.addColumn('Users',
            'password',
            {
              type: Sequelize.STRING, 
            }
          ),
        ]);
      },
      down: (queryInterface, Sequelize) => {
        return Promise.all([
          queryInterface.removeColumn('Users', 'email'),
          queryInterface.removeColumn('Users',
           'password'),
        ]);
      }
    };
    
  3. 所有字段都是简单的字符串。按照 第三章 中所述,连接到数据库 执行迁移。电子邮件地址必须是唯一的。现在我们需要更新我们为用户的老种子文件,以表示我们刚刚添加的新字段。将以下字段添加到第一个用户:

    password: '$2a$10$bE3ovf9/Tiy/d68bwNUQ0.zCjwtNFq9ukg9h4rhKiHCb6x5ncKife',
    email: 'test1@example.com',
    

    对所有用户都这样做,并更改每个用户的电子邮件地址。否则,它将不起作用。密码是哈希格式,代表明文密码 123456789。由于我们在单独的迁移中添加了新字段,我们必须将这些字段添加到模型中。

  4. 打开并添加以下新行作为字段到 model 文件夹中的 user.js 文件:

    email: DataTypes.STRING,
    password: DataTypes.STRING,
    
  5. 现在清空数据库,运行所有迁移,并再次执行种子文件。

我们首先要做的是让登录过程运行起来。目前,我们只是在模拟作为数据库中的第一个用户登录。

Apollo 登录突变

在本节中,我们将编辑我们的 GraphQL 模式并实现相应的解析函数。按照以下步骤操作:

  1. 让我们从模式开始,向我们的 schema.js 文件中的 RootMutation 对象添加一个新突变。

    login (
      email: String!
      password: String!
    ): Auth
    

    上述模式为我们提供了一个接受电子邮件地址和密码的登录突变。两者都是识别和验证用户的必要条件。然后,我们需要向客户端响应一些内容。目前,Auth 类型返回一个令牌,在我们的案例中是一个 JWT。你可能想根据你的需求添加不同的选项:

    type Auth {
      token: String
    }
    
  2. 模式现在已准备就绪。前往 resolvers 文件,并在突变对象中添加登录函数。在我们这样做之前,安装并导入两个新包:

    jsonwebtoken package handles everything that's required to sign, verify, and decode JWTs.The important part is that all the passwords for our users are not saved as plain text but are first encrypted using hashing, including a random salt. This generated hash cannot be decoded or decrypted as a plain password, but the package can verify if the password that was sent with the login attempt matches the password hash that was saved on the user. 
    
  3. resolvers 文件顶部导入这些包:

    import bcrypt from 'bcrypt';
    import JWT from 'jsonwebtoken';
    
  4. login 函数接收 emailpassword 作为参数。它应该如下所示:

    login(root, { email, password }, context) {
      return User.findAll({
        where: {
          email
        },
        raw: true
      }).then(async (users) => {
        if(users.length = 1) {
          const user = users[0];
          const passwordValid = await
            bcrypt.compare(password, user.password);
          if (!passwordValid) {
            throw new Error('Password does not match');
          }
          const token = JWT.sign({ email, id: user.id },
            JWT_SECRET, {
            expiresIn: '1d'
          });
          return {
            token
          };
        } else {
          throw new Error("User not found");
        }
      });
    },
    

    上述代码执行以下步骤:

    1. 我们查询所有邮箱地址匹配的用户。

    2. 如果找到用户,我们可以继续。由于 MySQL 唯一约束禁止这种情况,因此不可能有多个用户具有相同的地址。

    3. 接下来,我们使用用户的密码,并使用之前解释的 bcrypt 包将其与提交的密码进行比较。

    4. 如果密码正确,我们使用 jwt.sign 函数为 jwt 变量生成 JWT 令牌。它接受三个参数:负载,即用户 ID 和他们的电子邮件地址;我们用于签名 JWT 的密钥;以及 JWT 将要过期的时长。

    5. 最后,我们返回一个包含我们的 JWT 的对象。

    注意

    可能需要重新思考的是错误消息中的详细程度。例如,我们可能不想区分密码错误和不存在用户的情况。这允许可能的攻击者或数据收集者知道哪个电子邮件地址正在使用。

    login 函数尚未工作,因为我们缺少 JWT_SECRET,这是用于签名 JWT 的。在生产中,我们使用环境变量将 JWT 密钥传递到我们的后端代码中,以便我们也可以在开发中使用这种方法。

  5. 对于 Linux 或 Mac,请在终端中直接输入以下命令:

    export JWT_SECRET=
      awv4BcIzsRysXkhoSAb8t8lNENgXSqBruVlLwd45kGdYje
      JHLap9LUJ1t9DTdw36DvLcWs3qEkPyCY6vOyNljlh2Er952h2gDzYwG8
      2rs1qfTzdVIg89KTaQ4SWI1YGY
    
  6. export 函数为您设置 JWT_SECRET 环境变量。用随机生成的 JWT 替换提供的 JWT。您可以通过将字符数设置为 128 并排除任何特殊字符来使用任何密码生成器。设置环境变量允许我们在应用程序中读取密钥。您必须在进入生产环境时替换它。

  7. 在文件顶部插入以下代码:

    const { JWT_SECRET } = process.env;
    

    此代码从全局 Node.js process 对象中读取环境变量。一旦发布应用程序,请务必替换 JWT,并确保始终安全地存储密钥。在让服务器重新加载后,我们可以发送第一个登录请求。我们将在后面的 React 中学习如何做到这一点,但以下代码展示了使用 Postman 的一个示例:

    {
      "operationName":null,
      "query": "mutation login($email : String!, $password
        : String!) { 
       login(email: $email, password : $password) { token 
         }}",
      "variables":{
        "email": "test1@example.com",
        "password": "123456789"
      }
    }
    

    这个请求应该返回一个令牌:

    {
      "data": {
        "login": {
          "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e
            yJlbWFpbCI6InRlc3QxQGV4YW1wbGUuY29tIiwiaWQiOjE
            sImlhdCI6MTUzNzIwNjI0MywiZXhwIjoxNTM3MjkyNjQzf
            Q.HV4dPIBzvU1yn6REMv42N0DS0ZdgebFDXUj0MPHvlY"
        }
      }
    }
    

    如您所见,我们已经生成并返回了一个签名 JWT。我们可以在每个请求的 HTTP 认证头中继续发送此令牌。然后,我们可以为迄今为止实现的全部 GraphQL 查询或突变启动认证。

让我们继续学习如何设置 React 以与后端上的认证一起工作。

React 登录表单

我们需要处理我们应用程序的不同认证状态:

  • 第一种情况是用户未登录,无法查看任何帖子或聊天。在这种情况下,我们需要显示登录表单,以便用户可以验证自己。

  • 第二种情况是,通过登录表单发送电子邮件和密码。需要解释响应,如果结果是正确的,我们现在需要将 JWT 保存到浏览器的localStorage中。

  • 当更改localStorage时,我们还需要重新渲染我们的 React 应用程序以显示登录状态。

  • 此外,用户应该能够再次注销。

  • 我们还必须能够处理 JWT 过期且用户无法访问任何功能的情况。

登录表单将如下所示:

![Figure 6.2 – 登录表单

![Figure 6.02_B17337.jpg]

Figure 6.2 – 登录表单

要开始使用登录表单,请按照以下步骤操作:

  1. apollo文件夹内设置一个单独的登录突变文件。我们可能只需要在代码中的一个地方使用这个组件,但将 GraphQL 请求保存在单独的文件中是一个好主意。

  2. 构建登录表单组件,该组件使用登录突变发送表单数据。

  3. 创建CurrentUser查询以检索已登录的用户对象。

  4. 如果用户未认证或登录到真实应用程序(如新闻源),则条件渲染登录表单。

  5. 我们将首先在客户端组件的mutations文件夹内创建一个新的login.js文件:

    import { gql, useMutation } from '@apollo/client';
    export const LOGIN = gql'
      mutation login($email : String!, $password : 
        String!) {
        login(email : $email, password : $password) {
          token
        }
      }
    ';
    export const useLoginMutation = () => useMutation(LOGIN);
    

    与之前的突变一样,我们解析查询字符串并从useMutation钩子中导出login函数。

  6. 现在,我们必须实现使用此突变的实际登录表单。为此,我们将在components文件夹内直接创建一个loginregister.js文件。正如你所预期的,我们在一个组件中处理用户的登录和注册。首先导入依赖项:

    import React, { useState } from 'react';
    import { useLoginMutation } from '../apollo/mutations/login';
    import Loading from './loading';
    import Error from './error';
    
  7. LoginForm组件将存储表单状态,如果出现错误则显示错误消息,显示加载状态,并发送包含表单数据的登录突变。在import语句下方添加以下代码:

    const LoginForm = ({ changeLoginState }) => {
      const [email, setEmail] = useState('');
      const [password, setPassword] = useState('');
      const [login, { loading, error }] = 
        useLoginMutation();
      const onSubmit = (event) => {
        event.preventDefault();
        login({
          update(cache, { data: { login } }) {
            if(login.token) {
              localStorage.setItem('jwt', login.token);
              changeLoginState(true);
            }
          }, variables: { email, password }
        });
      }
      return (
        <div className="login">
          {!loading && (
            <form onSubmit={onSubmit}>
              <label>Email</label>
              <input type="text" onChange={(event) => 
                setEmail(event.target.value)} />
              <label>Password</label>
              <input type="password" onChange={(event) =>
                setPassword(event.target.value)} />
              <input type="submit" value="Login" />
            </form>
          )}
          {loading && (<Loading />)}
          {error && (
            <Error><p>There was an error logging in!</p>
            </Error>
          )}
        </div>
      )
    }
    

    整个 React 组件相当简单。我们只有一个表单和两个输入,并将它们的值存储在两个状态变量中。当表单提交时,会调用onSubmit函数,这将触发登录突变。突变的update函数将与其他突变有所不同。我们不在 Apollo 缓存中写入返回值;相反,我们在localStorage中存储 JWT。语法相当简单。你可以直接使用localStorage.getlocalStorage.set与 Web 存储进行交互。

    在将 JWT 保存到localStorage之后,我们调用一个changeLoginState函数,我们将在下一步实现它。这个函数的目的是有一个全局开关,用于将用户从登录状态切换到注销状态,或反之。

  8. 现在,我们需要导出一个将被我们的应用程序使用的组件。最简单的方法是设置一个包装组件,该组件为我们处理登录和注册情况。

    为包装组件插入以下代码:

    const LoginRegisterForm = ({ changeLoginState }) => {
      return (
        <div className="authModal">
          <div>
            <LoginForm changeLoginState={changeLoginState}
            />
          </div>
        </div>
      )
    }
    export default LoginRegisterForm
    

    这个组件只是渲染登录表单并传递changeLoginState函数。

    所有用于验证用户的基本功能现在都已准备就绪,但尚未导入或显示在任何地方。打开App.js文件。在那里,我们将直接显示动态内容、聊天和顶部栏。如果用户未登录,不应允许他们看到一切。继续阅读以更改此设置。

  9. 导入我们刚刚创建的新表单和从 React 导入的useEffect钩子:

    import LoginRegisterForm from './components/loginregister';
    
  10. 现在,我们必须存储用户是否已登录,以及在我们应用程序的第一次渲染中,根据localStorage检查登录状态。将以下代码添加到App组件中:

    const [loggedIn, setLoggedIn] = useState(!!localStorage.getItem('jwt'));
    

    当加载我们的页面时,我们有一个loggedIn状态变量来存储当前的登录状态。默认值是如果存在令牌则为true,如果不存在则为false

  11. 然后,在return语句中,我们可以使用条件渲染来显示登录表单,当loggedIn状态变量设置为false时,这意味着我们的localStorage中没有 JWT:

    {loggedIn && (
      <div>
        <Bar changeLoginState={setLoggedIn} />
        <Feed />
        <Chats />
      </div>
    )}
    {!loggedIn && <LoginRegisterForm changeLoginState={setLoggedIn} />}
    

    如你所见,我们将setLoggedIn函数传递给登录表单,这使得它能够触发登录状态,以便 React 可以重新渲染并显示登录区域。我们称这个属性为changeLoginState,并在登录突变的update方法中登录表单内部使用它。

  12. 从官方 GitHub 仓库添加 CSS:

    github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition

一旦我们登录,我们的应用程序将展示常见的帖子动态内容,就像之前一样。认证流程现在正在工作,但还有一个未完成的任务。在下一节中,我们将允许新用户在 Graphbook 上注册。

Apollo 注册突变

你现在应该熟悉创建新的突变。要这样做,请遵循以下步骤:

  1. 首先,编辑模式以接受新的突变:

    signup (
      username: String!
      email: String!
      password: String!
    ): Auth
    

    我们只需要usernameemailpassword属性,这些在前面代码中已提及,以接受新用户。如果你的应用程序需要性别或其他信息,你可以在这里添加。当我们尝试注册时,我们需要确保电子邮件地址和用户名尚未被占用。

  2. 将以下代码复制以实现为新用户注册的解析器:

    signup(root, { email, password, username }, context) {
      return User.findAll({
        where: {
          [Op.or]: [{email}, {username}]
        },
        raw: true,
      }).then(async (users) => {
        if(users.length) {
          throw new Error('User already exists');
        } else {
          return bcrypt.hash(password, 10).then((hash) => {
            return User.create({
              email,
              password: hash,
              username,
              activated: 1,
            }).then((newUser) => {
              const token = JWT.sign({ email, id:
                newUser.id }, JWT_SECRET, 
              {
                expiresIn: '1d'
              });
              return {
                token
              };
            });
          });
        }
      });
    },
    

    让我们一步一步地通过这段代码:

    1. 如我们之前提到的,首先,我们必须检查是否存在具有相同电子邮件或用户名的用户。如果是这样,我们抛出一个错误。我们使用 Sequelize 的Op.or运算符来实现 MySQL 的 OR 条件。

    2. 如果用户不存在,我们可以使用 bcrypt 对密码进行散列。出于安全原因,您不能保存明文密码。当运行 bcrypt.hash 函数时,会使用随机盐来确保没有人能够访问原始密码。这个命令需要相当多的计算时间,所以 bcrypt.hash 函数是异步的,我们必须在继续之前解决这个承诺。

    3. 加密的密码,包括用户发送的其他数据,随后被插入到我们的数据库中作为新用户。

    4. 在创建用户后,我们生成一个 JWT 并将其返回给客户端。JWT 允许我们在用户注册后直接登录。如果您不希望这种行为,您只需返回一条消息来指示用户已成功注册。

现在,您可以在使用 npm run server 启动后端的同时,再次使用 Postman 测试 signup 突变。这样,我们就完成了后端实现。那么,让我们开始前端的工作。

React 注册表单

注册表单没有什么特别之处。我们将遵循与登录表单相同的步骤:

  1. 复制 LoginMutation 组件,将顶部的请求替换为 signup 突变,并将 signup 方法传递给底层的子组件。

  2. 在顶部,导入所有依赖项,然后解析新的查询:

    import { gql, useMutation } from '@apollo/client';
    export const SIGNUP = gql'
      mutation signup($email : String!, $password :
        String!, $username : String!) {
        signup(email : $email, password : $password, 
          username : $username) {
          token
        }
      }
    ';
    export const useSignupMutation = () => useMutation(SIGNUP);
    

    如您所见,这里的 username 字段是新的,我们将其与每个 signup 请求一起发送。逻辑本身并没有改变,所以我们仍然在请求成功后从 signup 字段中提取 JWT 来登录用户。

很好看到 loginsignup 突变相当相似。最大的变化是我们有条件地渲染登录表单或注册表单。按照以下步骤操作:

  1. 将新的突变导入到 loginregister.js 文件中:

    import { useSignupMutation } from '../apollo/mutations/signup';
    
  2. 然后,用以下新的组件替换完整的 LoginRegisterForm 组件:

    const LoginRegisterForm = ({ changeLoginState }) => {
      const [showLogin, setShowLogin] = useState(true);
      return (
        <div className="authModal">
          {showLogin && (
            <div>
              <LoginForm 
                 changeLoginState={changeLoginState} />
              <a onClick={() => setShowLogin(false)}>
                Want to sign up? Click here</a>
            </div>
          )}
          {!showLogin && (
            <div>
              <RegisterForm 
                changeLoginState={changeLoginState} />
              <a onClick={() => setShowLogin(true)}>
                Want to login? Click here</a>
            </div>
          )}
        </div>
      )
    }
    

    您应该注意到我们在组件状态中存储了一个 showLogin 变量。这个变量决定是否显示登录或注册组件,这处理了实际的业务逻辑。

  3. 然后,在导出语句之前添加一个用于注册表单的单独组件:

    const RegisterForm = ({ changeLoginState }) => {
      const [email, setEmail] = useState('');
      const [password, setPassword] = useState('');
      const [username, setUsername] = useState('');
      const [signup, { loading, error }] =
        useSignupMutation();
      const onSubmit = (event) => {
        event.preventDefault();
        signup({
          update(cache, { data: { login } }) {
            if(login.token) {
              localStorage.setItem('jwt', login.token);
              changeLoginState(true);
            }
          }, variables: { email, password, username }
        });
      }
      return (
        <div className="login">
          {!loading && (
            <form onSubmit={onSubmit}>
              <label>Email</label>
              <input type="text" onChange={(event) =>
                setEmail(event.target.value)} />
              <label>Username</label>
              <input type="text" onChange={(event) =>
                setUsername(event.target.value)} />
              <label>Password</label>
              <input type="password" onChange={(event) =>
                setPassword(event.target.value)} />
              <input type="submit" value="Sign up" />
            </form>
          )}
          {loading && (<Loading />)}
          {error && (
            <Error><p>There was an error logging in!</p>
            </Error>
          )}
        </div>
      )
    }
    

    在前面的代码中,我添加了 username 字段,这个字段必须提供给突变。现在一切设置完毕,可以邀请新用户加入我们的社交网络,并且他们可以随时登录。

在下一节中,我们将学习如何在我们现有的 GraphQL 请求中使用身份验证。

验证 GraphQL 请求

问题是我们目前并没有在所有地方使用身份验证。我们正在验证用户是否是他们所说的那个人,但在收到聊天或消息请求时并没有重新检查这一点。为了完成这个任务,我们必须在每个 Apollo 请求中发送我们专门为此情况生成的 JWT 令牌。在后端,我们必须指定哪些请求需要身份验证,从 HTTP 授权头中读取 JWT 并验证它。按照以下步骤操作:

  1. 打开apollo文件夹中的index.js文件以获取客户端代码。我们的ApolloClient当前配置如第四章中所述,将 Apollo 集成到 React 中。在我们发送任何请求之前,我们必须从localStorage中读取 JWT 并将其添加为 HTTP 授权头。在link属性中,我们已指定了ApolloClient处理过程的链接。在我们配置 HTTP 链接之前,我们必须插入一个第三个预处理钩子,如下所示:

    const AuthLink = (operation, next) => {
      const token = localStorage.getItem('jwt');
      if(token) {
        operation.setContext(context => ({
          ...context,
          headers: {
            ...context.headers,
            Authorization: 'Bearer ${token}',
          },
        }));
      }
      return next(operation);
    };
    

    这里,我们称新的链接为AuthLink,因为它允许我们在服务器上对客户端进行身份验证。您可以将AuthLink方法复制到需要自定义 Apollo 请求头的其他情况中。在这里,我们只是从localStorage读取 JWT,如果找到,我们使用扩展运算符构建头,并将我们的令牌添加到Authorization字段作为 Bearer Token。这就是客户端需要完成的所有事情。

  2. 为了澄清问题,请查看以下link属性以了解如何使用这个新的预处理器。不需要初始化;它只是一个在每次请求时被调用的函数。将link配置复制到我们的 Apollo 客户端设置中:

    link: from([
      onError(({ graphQLErrors, networkError }) => {
        if (graphQLErrors) {
          graphQLErrors.map(({ message, locations, path })
            => 
          console.log('[GraphQL error]: Message: 
            ${message}, Location: 
          ${locations}, Path: ${path}'));
          if (networkError) {
            console.log('[Network error]:
              ${networkError}');
          }
        }
      }),
      AuthLink,
      new HttpLink({
        uri: 'http://localhost:8000/graphql',
        credentials: 'same-origin',
      }),
    ]),
    
  3. 让我们安装一个我们需要的依赖项:

    npm install --save @graphql-tools/utils
    
  4. 对于我们的后端,我们需要一个相当复杂的解决方案。在 GraphQL 的services文件夹中创建一个名为auth.js的新文件。我们希望能够在我们的模式中用所谓的指令标记特定的 GraphQL 请求。如果我们将此指令添加到我们的 GraphQL 模式中,我们可以在标记的 GraphQL 操作被请求时执行一个函数。在这个函数中,我们可以验证用户是否已登录。查看以下函数并将其保存到auth.js文件中:

    import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
    function authDirective(directiveName) {
      const typeDirectiveArgumentMaps = {};
      return {
        authDirectiveTypeDefs: 'directive 
          @${directiveName} on QUERY | FIELD_DEFINITION |
            FIELD',
        authDirectiveTransformer: (schema) =>
          mapSchema(schema, {
          [MapperKind.TYPE]: (type) => {
            const authDirective = getDirective(schema,
              type, directiveName)?.[0];
            if (authDirective) {
              typeDirectiveArgumentMaps[type.name] = 
                authDirective;
            }
            return undefined;
          },
          [MapperKind.OBJECT_FIELD]: (fieldConfig,
            _fieldName, typeName) => {
            const authDirective = getDirective(schema,
              fieldConfig, directiveName)?.[0] ?? 
                typeDirectiveArgumentMaps[typeName];
            if (authDirective) {
              const { resolve = defaultFieldResolver } = 
                fieldConfig;
              fieldConfig.resolve = function (source, 
                args, context, info) {
                if (context.user) {
                  return resolve(source, args, context, 
                    info);
                }
                throw new Error("You need to be logged  
                                 in.");
              }
              return fieldConfig;
            }
          }
        }),
      };
    }
    export default authDirective;
    

    从顶部开始,我们从@graphql/utils包中导入三样东西:

    1. mapSchema函数接受两个参数。第一个是实际的 GraphQL 模式,然后是一个可以转换模式的函数对象。

    2. getDirective函数将读取模式并尝试获取指定的directiveName。基于此,我们可以做我们想要做的任何事情。

    3. MapperKind只是一组我们可以使用的类型。我们正在使用它来只为特定类型运行函数。

    此函数或指令将读取用户从上下文,并将其传递到我们的解析器中,其中指令在我们的 GraphQL 模式中指定。

  5. 我们必须在graphqlindex.js文件中加载新的authDirective函数,该函数设置了整个 Apollo Server:

    import authDirective from './auth';
    
  6. 在我们创建可执行模式之前,我们必须从authDirective函数中提取新的模式转换器。在创建可执行模式后,我们必须将其传递给转换器,以便authDirective开始工作。用以下代码替换当前的方案创建:

    const { authDirectiveTypeDefs, authDirectiveTransformer } = authDirective('auth');
    let executableSchema = makeExecutableSchema({
        typeDefs: [authDirectiveTypeDefs, Schema],
        resolvers: Resolvers.call(utils),
    });
    executableSchema = authDirectiveTransformer(executableSchema);
    
  7. 为了验证我们刚刚所做的工作,请转到 GraphQL 模式并编辑postsFeed RootQuery,在行尾添加@auth,如下所示:

    postsFeed(page: Int, limit: Int): PostFeed @auth
    
  8. 由于我们正在使用一个新的指令,我们还需要在我们的 GraphQL 模式中定义它,以便我们的服务器了解它。将以下代码直接复制到模式的最顶部:

    directive @auth on QUERY | FIELD_DEFINITION | FIELD
    

    这段简短的内容告诉 Apollo Server,@auth 指令可以与查询、字段和字段定义一起使用,这样我们就可以在所有地方使用它。

如果您重新加载页面并通过 React Developer Tools 手动将 loggedIn 状态变量设置为 true,您将看到以下错误消息:

![Figure 6.3 – GraphQL login error]

![img/Figure_6.03_B17337.jpg]

图 6.3 – GraphQL 登录错误

由于我们之前实现了错误组件,现在如果用户未登录,我们正在正确地接收到 postsFeed 查询的无权限错误。我们如何使用 JWT 来识别用户并将其添加到请求上下文中?

注意

模式指令是一个复杂的话题,因为关于 Apollo 和 GraphQL 有许多重要的事情需要记住。我建议您在官方 Apollo 文档中详细了解指令:www.graphql-tools.com/docs/introduction

第二章 使用 Express.js 设置 GraphQL 中,我们通过提供可执行模式和上下文来设置 Apollo Server,直到现在上下文一直是请求对象。我们必须检查 JWT 是否在请求中。如果是这种情况,我们需要验证它并查询用户以查看令牌是否有效。让我们先验证授权头。在这样做之前,将新依赖项导入到 GraphQL 的 index.js 文件中:

import JWT from 'jsonwebtoken';
const { JWT_SECRET } = process.env;

ApolloServer 初始化的 context 字段必须如下所示:

context: async ({ req }) => {
  const authorization = req.headers.authorization;
  if(typeof authorization !== typeof undefined) {
      var search = "Bearer";
      var regEx = new RegExp(search, "ig");
      const token = authorization.replace(regEx,
        '').trim();
      return JWT.verify(token, JWT_SECRET, function(err,
        result) {
          if(err) {
              return req;
          } else {
              return utils.db.models.User.findByPk(
                result.id).then((user) => {
                  return Object.assign({}, req, { user });
              });
          }
      });
  } else {
      return req;
  }
},

在这里,我们将 ApolloServer 类的 context 属性扩展为一个功能齐全的函数。我们从请求的头部读取 auth 令牌。如果 auth 令牌存在,我们需要移除携带者字符串,因为它不是我们后端创建的原始令牌的一部分。携带者令牌是 JWT 身份验证的最佳方法。

注意

可用的其他身份验证方法还有基本身份验证等,但携带者方法是最佳选择。您可以在 IETF 的 RFC6750 中找到详细说明:tools.ietf.org/html/rfc6750

之后,我们必须使用 JWT.verify 函数来检查令牌是否与从环境变量中生成的密钥创建的签名匹配。下一步是验证成功后检索用户。将 verify 回调的内容替换为以下代码:

if(err) {
    return req;
} else {
    return utils.db.models.User.findByPk(result.id).then((
      user) => {
        return Object.assign({}, req, { user });
    });
}

如果前一段代码中的err对象已被填充,我们只能返回普通的请求对象,当它到达auth指令时将触发错误,因为没有附加用户。如果没有错误,我们可以使用我们已经在 Apollo Server 设置中传递的utils对象来访问数据库。如果你需要提醒,请查看第二章使用 Express.js 设置 GraphQL。在查询用户后,我们必须将其添加到请求对象中,并将合并后的用户和请求对象作为上下文返回。这导致我们的授权指令返回成功响应。

现在,让我们测试这种行为。使用npm run client启动前端,使用npm run server启动后端。别忘了,现在所有 Postman 请求都必须包含有效的 JWT,如果 GraphQL 查询中使用了auth指令。你可以运行登录突变,并将其复制到授权头中运行任何查询。我们现在能够将任何查询或突变标记为授权标志,并因此要求用户登录。

从解析函数中访问用户上下文

目前,我们 GraphQL 服务器的所有 API 函数都允许我们通过从数据库中选择可用的第一个来模拟用户。正如我们刚刚引入了完整的认证,我们现在可以从请求上下文中访问用户。本节将快速解释如何为聊天和消息实体执行此操作。我们还将实现一个名为currentUser的新查询,在我们的客户端中检索登录用户。

聊天和消息

首先,你必须将@auth指令添加到 GraphQL 的RootQuery中的聊天,以确保用户需要登录才能访问任何聊天或消息。

看一下聊天解析函数。目前,我们使用findAll方法获取所有用户,取第一个,并查询该用户的所有聊天。用以下新的解析函数替换此代码:

chats(root, args, context) {
  return Chat.findAll({
    include: [{
      model: User,
      required: true,
      through: { where: { userId: context.user.id } },
    },
    {
      model: Message,
    }],
  });
},

在这里,我们不检索用户;而是直接从上下文中插入用户 ID,如前述代码所示。这就是我们必须要做的:所有属于登录用户的聊天和消息都直接从聊天表中查询。

我们需要复制这部分代码以用于聊天、消息以及其他当前我们拥有的所有查询和突变。

CurrentUser GraphQL 查询

JWTs 允许我们查询当前登录的用户。然后,我们可以在顶部栏中显示正确的认证用户。为了请求登录用户,我们在后端需要一个名为currentUser的新查询。在模式中,你只需将以下行添加到RootQuery查询中:

currentUser: User @auth

就像postsFeedchats查询一样,我们还需要@auth指令来从请求上下文中提取用户。

类似地,在解析函数中,你只需要插入以下三行:

currentUser(root, args, context) {
  return context.user;
},

我们立即从上下文中返回用户,因为它已经是一个包含所有适当数据(由 Sequelize 返回)的用户模型实例。在客户端,我们在单独的组件和文件中创建此查询。请注意,你不需要将结果传递给所有子组件,因为这是由ApolloConsumer后来自动完成的。你可以通过查看之前的查询组件示例来了解这一点。只需在queries文件夹中创建一个名为currentUserQuery.js的文件,并包含以下内容:

import { gql, useQuery } from '@apollo/client';
export const GET_CURRENT_USER = gql'
  query currentUser {
    currentUser {
      id
      username
      avatar
    }
  }
';
export const useCurrentUserQuery = (options) => useQuery(GET_CURRENT_USER, options);

现在,你可以在App.js文件中导入新的查询,并将以下行添加到App组件中:

const { data, error, loading, refetch } = useCurrentUserQuery();
if(loading) {
    return <Loading />;
}

在这里,我们执行了useCurrentUserQuery钩子以确保查询在全局范围内对所有组件执行。此外,我们显示一个加载指示器,直到请求完成,以确保在我们做其他任何事情之前用户已经加载。

每当loggedIn状态变量为true时,我们渲染组件。为了获取响应,我们必须在上一章中实现的bar组件中使用ApolloConsumer。我们在App.js文件中运行currentUser查询,以确保所有子组件可以在渲染之前依赖 Apollo 缓存来访问用户。

而不是在ApolloConsumer内部使用硬编码的假用户,我们可以使用client.readQuery函数从ApolloClient缓存中提取数据,并将其提供给底层的子组件。用以下代码替换当前的消费者:

import React from 'react';
import { ApolloConsumer } from '@apollo/client';
import { GET_CURRENT_USER } from '../../apollo/queries/currentUserQuery';
export const UserConsumer = ({ children }) => {
  return (
    <ApolloConsumer>
      {client => {
        const result = client.readQuery({ query:
          GET_CURRENT_USER });
        return React.Children.map(children,
          function(child){
          return React.cloneElement(child, { user:
            result?.currentUser ? result.currentUser : null 
              });
        });
      }}
    </ApolloConsumer>
  )
}

在这里,我们将从client.readQuery方法中提取的currentUser结果传递给当前组件的所有包装子组件。

从现在开始显示的聊天以及顶部栏中的用户,不再是伪造的;相反,它们被与已登录用户相关的数据填充。

创建新帖子或消息的突变仍然使用静态用户 ID。我们可以通过使用context.user对象中的用户 ID,以与我们在本节之前相同的方式切换到真正的已登录用户。你现在应该能够自己做到这一点。

使用 React 注销

为了完成闭环,我们仍然需要实现注销功能。当用户可以注销时,有两种情况:

  • 用户想要注销并点击注销按钮。

  • 根据指定的 1 天后 JWT 已过期;用户不再认证,我们必须将状态设置为注销。

按照以下步骤完成此操作:

  1. 我们将首先在我们的应用程序前端顶部栏添加一个新的注销按钮。为此,在bar文件夹内创建一个新的logout.js组件。它应该看起来如下:

    import React from 'react';
    import { withApollo } from '@apollo/client/react/hoc';
    const Logout = ({ changeLoginState, client }) => {
      const logout = () => {
        localStorage.removeItem('jwt');
        changeLoginState(false);
        client.stop();
        client.resetStore();
      }
      return (
        <button className="logout" onClick={logout}>Logout
        </button>
      );
    }
    export default withApollo(Logout);
    

    如您所见,当点击登出按钮时,它将触发组件的登出方法。在logout方法内部,我们从localStorage中删除 JWT 并执行我们从父组件接收到的changeLoginState函数。请注意,我们没有向我们的服务器发送请求来登出;相反,我们从客户端删除了令牌。这是因为我们没有使用黑白名单来禁止或允许某些 JWT 在我们的服务器上进行认证。最简单的方法是在客户端删除令牌,这样服务器和客户端都没有它。

    我们还重置了客户端缓存。当用户登出时,我们必须删除所有数据。否则,同一浏览器上的其他用户将能够提取所有数据,这是我们必须防止的。为了访问底层的 Apollo Client,我们必须导入包裹在其中的withApollo Logout组件。在登出时,我们必须执行client.stopclient.resetStore函数,以便删除所有数据。

  2. 要使用我们新的Logout组件,打开bar文件夹中的index.js文件,并在顶部导入它。我们可以在顶部的div顶部栏中渲染它,位于其他内部div标签下方:

    <div className="buttons">
      <Logout changeLoginState={changeLoginState}/>
    </div>
    

    在这里,我们将changeLoginState函数传递给Logout组件。

  3. Bar组件的 props 中提取changeLoginState函数,如下所示:

    const Bar = ({ changeLoginState }) => {
    
  4. App.js文件中,你必须实现一个额外的函数来正确处理当前用户查询。如果我们未登录然后登录,我们需要获取当前用户。如果我们登出,我们需要设置或能够轻松地再次获取当前用户查询。添加以下函数:

    const handleLogin = (status) => {
        refetch().then(() => {
            setLoggedIn(status);
        }).catch(() => {
            setLoggedIn(status);
        });
    }
    
  5. 将此函数不仅传递给LoginRegisterForm,还传递给Bar组件,如下所示:

    <Bar changeLoginState={handleLogin} />
    
  6. 如果你从官方 GitHub 仓库复制完整的 CSS,当你登录时,你应该在屏幕右上角看到一个新按钮。点击它将你登出,并要求你再次登录,因为 JWT 已被删除。

  7. 我们实现登出功能的另一种情况是我们使用的 JWT 过期。在这种情况下,我们会自动登出用户,并要求他们再次登录。转到App组件,并添加以下行:

    useEffect(() => {
      const unsubscribe = client.onClearStore(
        () => {
          if(loggedIn){
            setLoggedIn(false)
          }
        }
      );
      return () => {
        unsubscribe();
      }
    }, []);
    

    在这里,我们使用的是client.onClearStore事件,该事件通过client.onClearStore函数在客户端存储被清除时捕获。

  8. 要使前面的代码正常工作,我们必须在我们的App组件中访问 Apollo Client。最简单的方法是在App.js文件中使用withApollo HoC。只需从@apollo/client包中导入它:

    import { withApollo } from '@apollo/client/react/hoc';
    
  9. 然后,通过高阶组件(HoC)导出App组件——不是直接导出,而是通过 HoC——并提取client属性。以下代码必须直接位于App组件下方:

    export default withApollo(App);
    

    现在,组件可以通过其属性访问客户端。每当客户端恢复被重置时,就会抛出clearStore事件,正如其名称所暗示的。你很快就会看到为什么我们需要这个。在 React 中监听事件时,我们必须在组件卸载时停止监听。我们在前面的代码中的useEffect Hook 中处理这个问题。现在,我们必须重置客户端存储以启动注销状态。当事件被捕获时,我们会自动执行changeLoginState函数。因此,我们可以移除最初传递给注销按钮的changeLoginState部分,因为不再需要它,但这里我们并不想这样做。

  10. App组件的 props 中提取客户端,如下所示:

    const App = ({ client }) => {
    
  11. 前往apollo文件夹中的index.js文件。在那里,我们已经捕获并遍历了从我们的 GraphQL API 返回的所有错误。我们现在必须遍历所有错误,但检查每个错误是否包含UNAUTHENTICATED错误。然后,我们必须执行client.clearStore函数。将以下代码插入到 Apollo 客户端设置中:

    onError(({ graphQLErrors, networkError }) => {
      if (graphQLErrors) {
        graphQLErrors.map(({ message, locations, path,
          extensions }) => {
          if(extensions.code === 'UNAUTHENTICATED') {
            localStorage.removeItem('jwt');
            client.clearStore()
          }
          console.log('[GraphQL error]: Message: 
            ${message}, Location: 
          ${locations}, Path: ${path}');
        });
        if (networkError) {
          console.log('[Network error]: ${networkError}');
        }
      }
    }),
    

    如你所见,我们访问了错误的extensions属性。extensions.code字段持有返回的具体错误类型。如果我们没有登录,我们会移除 JWT 然后重置存储。通过这样做,我们在App组件中触发事件,将用户送回登录表单。

进一步的扩展将是提供一个刷新令牌 API 函数。这个功能可以在我们每次成功使用 API 时运行。这个问题是用户将永远保持登录状态,只要他们使用应用程序。通常这并不是问题,但如果其他人正在访问同一台计算机,他们将作为原始用户进行认证。有不同方式实现这些功能以使用户体验更舒适,但我并不是很喜欢这些功能,出于安全原因。

摘要

到目前为止,我们应用程序的主要问题之一是我们没有进行任何认证。现在,每当用户访问我们的应用程序时,我们都可以知道谁登录了。这允许我们保护 GraphQL API,并以正确用户的身份插入新的帖子或消息。在本章中,我们讨论了 JWT、localStorage和 cookie 的基本方面。我们还探讨了散列密码验证和签名令牌的工作原理。然后,我们介绍了如何在 React 中实现 JWT 以及如何触发登录和注销的正确事件。

在下一章中,我们将使用一个可重复使用的组件实现图像上传,该组件允许用户上传新的头像图像。

第七章:处理图片上传

所有社交网络都有一个共同点:每个都允许其用户上传自定义和个人图片、视频或任何其他类型的文档。这个功能可以在聊天、帖子、群组或个人资料中实现。为了提供相同的功能,我们将在 Graphbook 中实现图片上传功能。

本章将涵盖以下主题:

  • 设置亚马逊网络服务

  • 配置 AWS S3 存储桶

  • 在服务器上接受文件上传

  • 通过 Apollo 使用 React 上传图片

  • 裁剪图片

技术要求

本章的源代码可在以下 GitHub 仓库中找到:

github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition/tree/main/Chapter07

设置亚马逊网络服务

首先,我必须提到,亚马逊——更具体地说,亚马逊网络服务AWS)——并不是唯一提供托管、存储或计算系统的提供商。有许多这样的提供商,包括以下:

  • Heroku

  • DigitalOcean

  • 谷歌云

  • 微软 Azure

AWS 提供了运行完整 Web 应用所需的一切,就像所有其他提供商一样。此外,它也被广泛使用,这就是为什么我们在这本书中专注于 AWS。

其服务范围从数据库到对象存储,再到安全服务,等等。此外,AWS 是大多数其他书籍和教程中都会找到的解决方案,许多大型公司也用它来支持其完整的基础设施。

本书使用 AWS 来托管静态文件,例如图片,运行生产数据库以及我们的应用程序的 Docker 容器。

在继续本章之前,您将需要 AWS 账户。您可以在官方网页aws.amazon.com/上创建一个账户。为此,您需要一个有效的信用卡;在阅读本书的过程中,您也可以在免费层上运行几乎所有服务,而不会遇到任何问题。

一旦您成功注册 AWS,您将看到以下仪表板。这个屏幕被称为AWS 管理控制台

图 7.1 – AWS 管理控制台

图 7.1 – AWS 管理控制台

下一个部分将介绍使用 AWS 存储文件的选择。

配置 AWS S3 存储桶

对于本章,我们需要一个存储服务来保存所有上传的图片。AWS 为各种用例提供不同的存储类型。在我们的社交网络场景中,我们将有数十人同时访问许多图片。AWS 简单存储服务S3)是我们场景的最佳选择。按照以下步骤设置 S3 存储桶:

  1. 您可以通过点击页面顶部的服务下拉菜单,然后在下拉菜单中的存储类别下查找,来访问Amazon S3屏幕。在那里,您将找到一个指向 S3 的链接。点击它后,屏幕将看起来像这样:图 7.2 – S3 管理屏幕

    图 7.2 – S3 管理屏幕

    在 S3 中,您可以在特定的 AWS 区域内部创建一个存储桶,在那里您可以存储文件。

    前一个屏幕提供了许多与您的 S3 存储桶交互的功能。您可以通过管理界面浏览所有文件,上传您的文件,并配置更多设置。

  2. 现在,我们将通过点击右上角的创建存储桶,如图 7.2所示,为我们的项目创建一个新的存储桶。您将看到一个表单,如下面的截图所示。要创建存储桶,您必须填写以下内容:

图 7.3 – S3 存储桶向导

图 7.3 – S3 存储桶向导

存储桶必须在 S3 的所有存储桶中具有唯一名称。然后,我们需要选择一个区域。对我来说,欧洲(法兰克福)eu-central-1是最好的选择,因为它是最接近的源点。选择最适合您的选项,因为存储桶的性能与其访问区域和存储桶区域之间的距离相对应。

然后,您需要取消选择阻止所有公共访问选项,并检查带有警告标志的确认。AWS 显示此警告是因为我们只有在真正需要时才应向 S3 存储桶提供公共访问。它应该看起来像这样:

图 7.4 – S3 存储桶访问

图 7.4 – S3 存储桶访问

对于我们的用例,我们可以保留在此表单向导中为所有其他选项提供的默认设置。在其他更高级的场景中,其他选项可能会有所帮助。AWS 提供了许多功能,例如完整的访问日志和版本控制。

注意

许多大型公司拥有全球用户,这需要一个高度可用的应用程序。当您达到这一点时,您可以在其他地区创建更多的 S3 存储桶,并且您可以将一个存储桶的复制设置到世界各地其他地区的存储桶。然后,可以使用 AWS CloudFront 和针对每个用户的特定路由器将正确的存储桶分发出去。这种方法为每个用户提供了最佳的可能体验。

通过点击页面底部的创建存储桶完成设置过程。您将被重定向回所有存储桶的表格视图。

生成 AWS 访问密钥

在实现上传功能之前,我们必须创建一个 AWS应用程序编程接口API)密钥,以授权我们的 AWS 后端,以便将新文件上传到 S3 存储桶。

点击 AWS 管理屏幕顶部的用户名。在那里,您将找到一个名为我的安全凭证的标签页,它导航到一个提供各种选项以保护您的 AWS 账户访问权限的屏幕。

您将看到一个类似这样的对话框:

图 7.5 – S3 身份和访问管理 (IAM) 对话框

图 7.5 – S3 身份和访问管理 (IAM) 对话框

您可以点击继续到安全凭证以继续。通常建议使用 AWS IAM,这允许您通过单独的 IAM 用户高效地管理对 AWS 资源的安全访问。在本书中,我们将以我们现在的方式使用根用户,但我建议在编写您的下一个应用程序时查看 AWS IAM。

您现在应该看到凭证页面,其中列出了存储凭证的不同方法。它应该看起来像这样:

图 7.6 – AWS 访问密钥

图 7.6 – AWS 访问密钥

在列表中,展开前一个屏幕截图中所显示的名为**访问密钥(访问密钥 ID 和秘密访问密钥)**的选项卡。在此选项卡中,您可以找到您 AWS 账户的所有访问令牌。

要生成新的访问令牌,请点击创建新访问密钥。输出应如下所示:

图 7.7 – AWS 访问密钥

图 7.7 – AWS 访问密钥

最佳实践是按照提示下载密钥文件并将其安全地保存到某个地方,以防您在任何时候丢失密钥。关闭窗口后,您无法再次检索访问密钥,因此如果您丢失了它们,您将不得不删除旧密钥并生成一个新的。

注意

这种方法可以用来解释 AWS 的基础知识。对于这样一个庞大的平台,您必须采取进一步的步骤来进一步增强您应用程序的安全性。例如,建议每 90 天更换 API 密钥。您可以在docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html上了解更多关于所有最佳实践的信息。

正如您在图 7.7中所见,AWS 给我们提供了两个令牌。两者都是访问我们的 S3 存储桶所必需的。

现在,我们可以开始编写上传机制了。

将图片上传到 Amazon S3

实现文件上传和存储文件始终是一项巨大的任务,尤其是在用户可能希望再次编辑他们的文件时的图片上传。

对于我们的前端,用户应该能够将他们的图片拖放到拖放区域,裁剪图片,然后在完成时提交。后端需要接受文件上传,这并不容易。文件必须被处理并有效地存储,以便所有用户都可以快速访问它们。

由于这是一个庞大的主题,本章仅涵盖了从 React 基本上传图像,使用多部分 POST 请求到我们的 GraphQL API,然后将图像传输到我们的 S3 存储桶。当涉及到压缩、转换和裁剪时,您应该查看有关此主题的更多教程或书籍,包括在前端和后端实现这些技术的技巧,因为有很多东西需要考虑。例如,在许多应用程序中,存储不同分辨率的图像以供用户在不同情况下查看是有意义的,这样可以节省带宽。

让我们从在后台实现上传过程开始。

GraphQL 图像上传突变

当将图像上传到 S3 时,需要使用一个 API 密钥,我们已生成。因此,我们不能直接使用 API 密钥从客户端上传文件到 S3。任何访问我们应用程序的人都可以从 JavaScript 代码中读取 API 密钥,并访问我们的存储桶,而无需我们知道。

直接从客户端将图像上传到存储桶通常是可能的。为此,您需要将文件的名称和类型发送到服务器,然后服务器会生成 统一资源定位符URL) 和签名。然后客户端可以使用签名上传图像。这种技术会导致客户端进行多次往返,并且不允许我们进行后处理图像,例如转换或压缩(如果需要)。

一个更好的解决方案是将图像上传到我们的服务器,让 GraphQL API 接收文件,然后向 S3 发送另一个请求——包括 API 密钥——以将文件存储到我们的存储桶中。

我们必须准备我们的后端以与 AWS 通信并接受文件上传。准备工作如下:

  1. 我们安装官方的 npm 包以与 AWS 交互。它提供了使用任何 AWS 功能所需的一切,而不仅仅是 S3。我们还安装了 graphql-upload,它提供了一些工具来从任何 GraphQL 请求中解析文件。以下是完成此操作的代码:

    npm install --save aws-sdk graphql-upload
    
  2. 在服务器 index.js 文件中,我们需要添加 graphql-upload 包的初始化。为此,在顶部导入 Express 依赖项,如下所示:

    import { graphqlUploadExpress } from 'graphql-upload';
    
  3. 在文件末尾的 graphql 案例中,在执行 applyMiddleware 函数之前,我们需要先初始化它,如下所示:

    case 'graphql':
      (async () => {
        await services[name].start();
        app.use(graphqlUploadExpress());
        services[name].applyMiddleware({ app });
      })();
      break;
    
  4. 接下来要做的事情是编辑 GraphQL 模式,并在其顶部添加一个 Upload 标量。该标量用于在上传文件时解析诸如 多用途互联网邮件扩展MIME) 类型和解码等细节。以下是您需要的代码:

    scalar Upload
    
  5. File 类型添加到模式中。此类型返回文件名和图像可以在浏览器中访问的结果 URL。代码如下所示:

    type File {
      filename: String!
      mimetype: String!
      encoding: String!
      url: String!
    }
    
  6. 创建一个新的uploadAvatar突变。用户需要登录才能上传头像图像,所以将@auth指令附加到突变上。突变接受之前提到的Upload标量作为输入。代码在下面的代码片段中展示:

    uploadAvatar (
      file: Upload!
    ): File @auth
    
  7. 接下来,我们将在resolvers.js文件中实现突变解析函数。为此,我们将在resolvers.js文件顶部导入和设置我们的依赖项,如下所示:

    import { GraphQLUpload } from 'graphql-upload';
    import aws from 'aws-sdk';
    const s3 = new aws.S3({
      signatureVersion: 'v4',
      region: 'eu-central-1',
    });
    

    我们将初始化s3对象,我们将在下一步上传图像时使用它。需要传递一个region属性,这是我们创建 S3 存储桶的属性。我们将signatureVersion属性设置为版本'v4',因为这被推荐使用。

    注意

    你可以在docs.aws.amazon.com/general/latest/gr/signature-version-4.html找到关于 AWS 请求签名过程的详细信息。

  8. resolvers.js文件内部,我们需要添加一个Upload解析器,如下所示:

    Upload: GraphQLUpload
    
  9. mutation属性内部,插入uploadAvatar函数,如下所示:

    async uploadAvatar(root, { file }, context) {
      const { createReadStream, filename, mimetype,
        encoding } = await file;
      const bucket = 'apollo-book';
      const params = {
          Bucket: bucket,
          Key: context.user.id + '/' + filename,
          ACL: 'public-read',
          Body: createReadStream()
      };
      const response = await s3.upload(params).promise();
      return User.update({
          avatar: response.Location
      },
      {
          where: {
              id: context.user.id
          }
      }).then(() => {
          return {
              filename: filename,
              url: response.Location
          }
      });
    },
    

在前面的代码片段中,我们首先将函数指定为async,这样我们就可以使用await方法解析文件及其详细信息。解析的await file方法的结果包括streamfilenamemimetypeencoding属性。

然后,我们在params变量中收集以下参数,以便上传我们的头像图像:

  • Bucket字段持有我们保存图像的存储桶名称。我使用了'apollo-book'这个名字,但你需要输入你在创建存储桶时输入的名字。你可以在s3对象内部直接指定这个名字,但这种方法更灵活,因为你可以为不同类型的文件拥有多个存储桶,而不需要多个s3对象。

  • Key属性是文件保存的路径和名称。请注意,我们将文件存储在一个新文件夹下,这个文件夹就是用户context变量。在未来的应用中,你可以为每个文件引入某种哈希值。那会很好,因为文件名不应该包含不允许的字符。此外,当使用哈希时,文件不能被程序性地猜测。

  • ACL字段设置了谁可以访问文件的权限。由于社交网络上的上传图像可以被互联网上的任何人公开查看,我们将属性设置为'public-read'

  • Body字段接收stream变量,这是我们通过解析文件最初获得的。stream变量不过就是图像本身作为一个流,我们可以直接将其上传到存储桶中。

params 变量被传递给 s3.upload 函数,该函数将文件保存到我们的存储桶。我们直接将 promise 函数链接到 upload 方法。在前面的代码片段中,我们使用 await 语句来解决 upload 函数返回的承诺。因此,我们将函数指定为 async。AWS S3 上传的 response 对象包括一个公共 URL,任何人都可以通过该 URL 访问图像。

最后一步是在我们的数据库中设置新的用户头像图片。我们通过设置从 response.Location 的新 URL(S3 在我们解决承诺后提供给我们)来执行 Sequelize 的 User.update 模型函数。

这里提供了一个指向 S3 图像的示例链接:

https://apollo-book.s3.eu-central-1.amazonaws.com/1/test.png

如您所见,URL 前缀是存储桶的名称,然后是区域。后缀当然是文件夹,即用户 ID 和文件名。前面的 URL 将与后端生成的 URL 不同,因为您的存储桶名称和区域将不同。

更新用户后,我们可以返回 AWS 响应以相应地更新 用户界面UI),而不需要刷新浏览器窗口。

在上一节中,我们生成了访问令牌以授权 AWS 的后端。默认情况下,AWS JWT_SECRET,我们将设置令牌,如下所示:

export AWS_ACCESS_KEY_ID=YOUR_AWS_KEY_ID
export AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_KEY

将您的 AWS 令牌插入到前面的代码中。AWS SDK 会自动检测环境变量。我们不需要在我们的代码中任何地方读取或配置它们。

现在,我们将继续在前端实现所有图像上传功能。

React 图像裁剪和上传

在像 Facebook 这样的社交网络中,有多个位置可以供您选择和上传文件。您可以在聊天中发送图片,将它们附加到帖子中,在您的个人资料中创建相册,等等。现在,我们只看看如何更改我们用户的头像图片。这是一个展示所有技术的好例子。

我们希望达到的结果看起来像这样:

图 7.8 – 裁剪对话框

图 7.8 – 裁剪对话框

图 7.8 – 裁剪对话框

用户可以选择一个文件,在模态框中直接裁剪它,并使用前面的对话框将其保存到 AWS。

我不是很喜欢使用太多的 npm 包,因为这通常会使您的应用程序变得不必要地大。截至本书编写时,我们无法为显示对话框或裁剪等一切编写自定义 React 组件,无论这可能多么容易。

要使图像上传工作,我们将安装两个新的包。为此,您可以按照以下说明操作:

  1. 使用 npm 安装包,如下所示:

    react-modal package offers various dialog options that you can use in many different situations. The react-cropper package is a wrapper package around Cropper.js. The react-dropzone package provides an easy implementation for file drop functionality.
    
  2. 当使用 react-cropper 包时,我们可以依赖其包含的 App.js 文件,直接从包本身导入,如下所示:

    import 'cropperjs/dist/cropper.css';
    

    Webpack 负责打包所有资源,就像我们用自定义 CSS 所做的那样。其余所需的 CSS 都可在本书的官方 GitHub 仓库中找到。

  3. 我们接下来要安装的包是 Apollo 客户端的扩展,这将使我们能够上传文件,如下所示:

    npm install --save apollo-upload-client
    
  4. 要运行apollo-upload-client包,我们必须编辑apollo文件夹中的index.js文件,在那里我们初始化 Apollo 客户端及其所有链接。在index.js文件顶部导入createUploadLink函数,如下所示:

    import { createUploadLink } from 'apollo-upload-client';
    
  5. 您必须用新的上传链接替换链表底部的旧HttpLink实例。现在,我们将传递createUploadLink函数,但使用相同的参数。执行时,将返回一个常规链接。链接应如下所示:

    createUploadLink({
      uri: 'http://localhost:8000/graphql',
      credentials: 'same-origin',
    }),
    

    需要注意的是,当我们使用新的上传链接并通过 GraphQL 请求发送文件时,我们不会发送标准的application/json Content-Type请求,而是发送一个多部分的FormData请求。这允许我们使用 GraphQL 上传标准file对象。

    注意

    或者,在传输图像时,可以发送一个base64字符串而不是file对象。这种方法将节省我们目前正在做的工作,因为发送和接收字符串在 GraphQL 中是没有问题的。如果您想将其保存到 AWS S3,则必须将base64字符串转换为文件。然而,这种方法仅适用于图像,并且 Web 应用程序应该能够接受任何文件类型。

  6. 现在包已经准备好了,我们可以开始为客户实现uploadAvatar突变组件。在mutations文件夹中创建一个名为uploadAvatar.js的新文件。

  7. 在文件顶部,导入所有依赖项,并以传统方式使用gql解析所有 GraphQL 请求,如下所示:

    import { gql, useMutation } from '@apollo/client';
    const UPLOAD_AVATAR = gql'
      mutation uploadAvatar($file: Upload!) {
        uploadAvatar(file : $file) {
          filename
          url
        }
      }
    ';
    export const getUploadAvatarConfig = () => ({
      update(cache, { data: { uploadAvatar } }) {
        console.log(uploadAvatar);
        if(uploadAvatar && uploadAvatar.url) {
          cache.modify({
            fields: {
              currentUser(user, { readField }) {
                cache.modify({
                  id: user,
                  fields: {
                    avatar() {
                      return uploadAvatar.url;
                    }
                  }
                })
              }
            }
          });
        }
      }
    });
    export const useUploadAvatarMutation = () => useMutation(UPLOAD_AVATAR, getUploadAvatarConfig());
    

    如您所见,我们只是通过将 GraphQL 查询包裹在useMutation钩子中导出了新的突变。我们还添加了一个update函数,该函数将首先获取当前用户的引用,然后通过引用更新此用户的新头像 URL 来更新缓存。

  8. 最后,我们需要将id属性添加到userAttributes片段中。否则,用户引用上头像 URL 的更新只会反映在顶部栏上,而不会反映在所有帖子中。代码如下所示:

    import { gql } from '@apollo/client';
    export const USER_ATTRIBUTES = gql'
      fragment userAttributes on User {
        id
        username
        avatar
      }
    ';
    

准备工作现在已完成。我们已经安装了所有必需的包,进行了配置,并实现了新的突变组件。我们可以开始编写用户界面对话框来更改头像图像。

为了这本书的目的,我们不是依赖于单独的页面或类似的东西。相反,我们给用户提供了在顶部栏点击他们的图像时更改头像的机会。为此,我们将监听头像上的点击事件,打开一个包含文件拖放区域和提交新图像按钮的对话框。

执行以下步骤以运行此逻辑:

  1. 总是让您的组件尽可能可重用是一个好主意,因此请在 components 文件夹内创建一个 avatarModal.js 文件。

  2. 与往常一样,您必须首先导入新的 react-modalreact-cropperreact-dropzone 包,然后是突变,如下所示:

    import React, { useState, useRef } from 'react';
    import Modal from 'react-modal';
    import Cropper from 'react-cropper';
    import { useDropzone } from 'react-dropzone';
    import { useUploadAvatarMutation } from '../apollo/mutations/uploadAvatar';
    Modal.setAppElement('#root');
    const modalStyle = {
      content: {
        width: '400px',
        height: '450px',
        top: '50%',
        left: '50%',
        right: 'auto',
        bottom: 'auto',
        marginRight: '-50%',
        transform: 'translate(-50%, -50%)'
      }
    };
    

    如前述代码片段所示,我们告诉模态包在浏览器 setAppElement 方法的哪个点。对于我们的用例,取 root DOMNode 是可以的,因为这是我们应用程序的起点。模态框在这个 DOMNode 中实例化。

    模态组件接受一个特殊的 style 参数,用于拖拽区域的不同部分。我们可以通过指定 modalStyle 对象和正确的属性来样式化模态框的所有部分。

  3. react-cropper 包给用户提供了裁剪图像的机会。结果是 fileblob 对象,而是一个 dataURI 对象,格式化为 base64。通常这不会是问题,但我们的 GraphQL API 期望我们发送一个真实的文件,而不仅仅是字符串,正如我们之前解释的那样。因此,我们必须将 dataURI 对象转换为 blob,我们可以将其与我们的 GraphQL 请求一起发送。添加以下函数来处理转换:

    function dataURItoBlob(dataURI) {
      var byteString = atob(dataURI.split(',')[1]);
      var mimeString = 
        dataURI.split(',')[0].split(':')[1].split(';')[0];
      var ia = new Uint8Array(byteString.length);
    
      for (var i = 0; i < byteString.length; i++) {
        ia[i] = byteString.charCodeAt(i);
      }
      const file = new Blob([ia], {type:mimeString});
      return file;
    }
    

    让我们不要深入探讨前面函数的逻辑。您需要知道的是,它将所有可读的 blob 对象转换为调用函数。它将数据 URI 转换为 blob。

  4. 我们目前正在实施的新组件被称为 AvatarUpload。它接收 isOpen 属性,该属性用于设置模态框的可见或不可见。默认情况下,模态框是不可见的。此外,当模态框显示时,拖拽区域将渲染在其中。首先,设置组件本身和所需的变量,如下所示:

    const AvatarModal = ({ isOpen, showModal }) => {
      const [file, setFile] = useState(null);
      const [result, setResult] = useState(null);
      const [uploadAvatar] = useUploadAvatarMutation();
      const cropperRef = useRef(null);
    }
    

    我们需要 fileresult 状态变量来管理选定的原始文件和裁剪图像。此外,我们使用 useRef 钩子设置突变和引用,这对于 cropper 库是必需的。

  5. 接下来,我们需要设置所有我们将用于处理不同事件和回调的组件函数。将以下函数添加到组件中:

    const saveAvatar = () => {
      const resultFile = dataURItoBlob(result);
      resultFile.name = file.filename;
      uploadAvatar({variables: { file: resultFile 
        }}).then(() => {
        showModal();
      });
    };
    const changeImage = () => {
      setFile(null);
    };
    const onDrop = (acceptedFiles) => {
      const reader = new FileReader();
      reader.onload = () => {
        setFile({
          src: reader.result,
          filename: acceptedFiles[0].name,
          filetype: acceptedFiles[0].type,
          result: reader.result,
          error: null,
        });
      };
      reader.readAsDataURL(acceptedFiles[0]);
    };
    const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop});
    const onCrop = () => {
      const imageElement = cropperRef?.current;
      const cropper = imageElement?.cropper;
      setResult(cropper.getCroppedCanvas().toDataURL());
    };
    

    saveAvatar 函数是主要函数,它将 base64 字符串转换为 blob。当用户拖放或选择图像时,会调用 onDrop 函数。此时,我们使用 FileReader 读取文件,并给我们一个 base64 字符串,我们将其保存到 file 状态变量中作为一个对象。useDropZone 钩子为我们提供了所有可以用来设置实际拖拽区域的属性。

    changeImage 函数将取消当前的裁剪过程,并允许我们再次上传新文件。

    每当用户更改裁剪选择时,都会调用 onCrop 函数。此时,我们将新的裁剪图像作为 base64 字符串保存到 result 状态变量中,以便在原始 file 变量和 result 变量之间有清晰的分离。

  6. Modal 组件接受一个 onRequestClose 方法,当用户尝试通过点击外部关闭模态框时,会执行 showModal 函数,例如。我们从父组件接收 showModal 函数,我们将在下一步中介绍。模态框还接收默认的 style 属性和一个标签。

    Cropper 组件需要在 crop 属性中接收一个函数,该函数在每次更改时被调用。同时,Cropper 组件从 file 状态变量接收 src 属性,如下代码片段所示:

    return (
      <Modal
        isOpen={isOpen}
        onRequestClose={showModal}
        contentLabel="Change avatar"
        style={modalStyle}
      >
        {!file &&
          (<div className="drop" {...getRootProps()}>
            <input {...getInputProps()} />
            {isDragActive ? <p>Drop the files here ...</p>
            : <p>Drag 'n' drop some files here, or click
            to select files</p>}
            </div>)
          }
          {file && <Cropper ref={cropperRef}
          src={file.src} style={{ height: 400, width:
          "100%" }} initialAspectRatio={16 / 9}
          guides={false} crop={onCrop}/>}
          {file && (
            <button className="cancelUpload" 
              onClick={changeImage}>Change image</button>
          )}
          <button className="uploadAvatar"
            onClick={saveAvatar}>Save</button>
        </Modal>
      )
    

    如你所见,return 语句仅包括作为包装器的模态框和一个裁剪器。最后,我们有一个调用 saveAvatar 的按钮来执行突变,并与之发送裁剪图像或 changeImage,后者取消当前图像的裁剪。

  7. 不要忘记在文件末尾添加 export 语句,如下所示:

    export default AvatarModal
    
  8. 现在,切换到 bar 文件夹中的 user.js 文件,其中存储了所有其他应用程序栏相关的文件。按照以下方式导入新的 AvatarModal 组件:

    import AvatarModal from '../avatarModal';
    
  9. UserBar 组件是 AvatarUploadModal 的父组件。从 bar 文件夹中打开 user.js 文件。这就是为什么我们在 UserBar 组件中处理对话框的 isOpen 状态变量。我们引入了一个 isOpen 状态变量,并在用户的头像上捕获 onClick 事件。将以下代码复制到 UserBar 组件中:

    const [isOpen, setIsOpen] = useState(false);
    const showModal = () => {
      setIsOpen(!isOpen);
    }
    
  10. return 语句替换为以下代码:

    return (
      <div className="user">
        <img src={user.avatar} onClick={() => showModal()} />
        <AvatarModal isOpen={isOpen}
          showModal={showModal}/>
        <span>{user.username}</span>
      </div>
    );
    

    模态组件直接接收 isOpen 属性,正如我们之前解释的那样。当点击头像图像时,会执行 showModal 方法。这个函数更新 AvatarModal 组件的属性,并显示或隐藏模态框。

使用匹配的 npm run 命令启动服务器和客户端。重新加载浏览器并尝试新功能。当选择图像时,会显示裁剪工具。你可以拖动并调整要上传的图像区域的大小。你可以在以下屏幕截图中看到这个示例:

Figure 7.9 – Cropping in progress

Figure 7.09 – B17337.jpg

Figure 7.9 – Cropping in progress

在 S3 存储桶中点击 user 文件夹。多亏了我们编写的突变,顶栏中的头像图像被更新为指向图像在 S3 存储桶位置的新的 URL。

我们所取得的巨大成就是将图像发送到我们的服务器。我们的服务器将所有图像传输到 S3。AWS 响应公共 URL,然后直接放置在浏览器的头像字段中。我们使用 GraphQL API 从后端查询头像图像的方式没有改变。我们返回 S3 文件的 URL,一切正常工作。

摘要

在本章中,我们首先创建了一个 AWS 账户和一个 S3 存储桶,用于从我们的后端上传静态图像。现代社交网络由许多图像、视频和其他类型的文件组成。我们介绍了 Apollo 客户端,它允许我们上传任何类型的文件。在本章中,我们成功地将一张图像上传到我们的服务器,并介绍了如何在 AWS S3 服务器上裁剪图像并保存它们。现在,您的应用程序应该能够随时为用户提供图像服务。

下一章将涵盖客户端路由的基础知识,使用 React Router 实现。

第八章:React 中的路由

目前,我们的用户可以访问一个屏幕和一个路径。当用户访问 Graphbook 时,他们可以登录并查看他们的新闻源和聊天。社交网络的一个要求是用户有自己的个人资料页面。我们将在本章实现此功能。

我们将为我们的 React 应用程序介绍客户端路由。

本章将涵盖以下主题:

  • 设置 React Router

  • 使用 React Router 的高级路由

技术要求

本章的源代码可在以下 GitHub 仓库中找到:

github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition/tree/main/Chapter08

设置 React Router

路由对于大多数 Web 应用程序都是必不可少的。你无法在一个页面上涵盖你应用程序的所有功能。这将导致过载,并且用户会发现很难理解。在社交网络如 Graphbook 中,分享图片、个人资料或帖子的链接也非常重要。例如,一个有利的特性是能够发送到特定个人资料的链接。这要求每个个人资料都有自己的统一资源定位符URL)和页面。否则,将无法分享到应用程序单个项目的直接链接。由于搜索引擎优化SEO)的原因,将内容拆分到不同的页面也非常关键。

目前,我们根据认证状态在浏览器中将完整的应用程序渲染为超文本标记语言HTML)。只有服务器实现了简单的路由功能。如果路由器只是简单地替换 React 中的正确部分,而不是在跟随链接时完全重新加载页面,那么执行客户端路由可以为用户节省大量工作和时间。应用程序利用 HTML5 历史实现来处理浏览器的历史记录至关重要。重要的是,这也应该适用于不同方向上的导航。我们应该能够使用浏览器中的箭头导航按钮前后导航,而无需重新加载应用程序。此解决方案不应发生不必要的页面重新加载。

你可能知道的一些常见框架,如 Angular、Ember 和 Ruby on Rails,使用静态路由。Express.js 也是这样,我们在本书的第二章**, 使用 Express.js 设置 GraphQL中介绍了它。静态路由意味着你预先配置你的路由流程和要渲染的组件。然后,你的应用程序在单独的步骤中处理路由表,渲染所需的组件,并将结果呈现给用户。

随着 4 版本和我们现在将要使用的 5 版本 React Router 的发布,引入了动态路由。它的独特之处在于,路由发生在你的应用程序渲染运行时。它不需要应用程序首先处理配置以显示正确的组件。这种方法与 React 的工作流程非常契合。路由直接在你的应用程序中发生,而不是在预处理的配置中。

安装 React Router

在过去,有很多 React 路由器,具有各种实现和功能。正如我们之前提到的,我们将为这本书安装和配置第 5 版。如果你搜索其他关于这个主题的教程,请确保遵循这个版本的说明。否则,你可能会错过 React Router 经历的一些变化。

要安装 React Router,只需再次运行 npm,如下所示:

npm install --save react-router-dom

从包名来看,你可能会认为这不是 React 的主要包。原因在于 React Router 是一个多包库。当在多个平台上使用相同工具时,这会很有用。核心包被称为 react-router

还有两个额外的包。第一个是 react-router-dom 包,我们在前面的代码片段中已经安装了它,第二个是 react-router-native 包。如果你在某个时候计划构建一个 React Native 应用程序,你可以使用相同的路由,而不是使用浏览器的文档对象模型DOM)来构建真正的移动应用程序。

我们将要采取的第一步是引入一个简单的路由,以便使我们的当前应用程序工作,包括所有屏幕的不同路径。我们将添加的路由在此处详细说明:

  • 我们的应用程序的帖子源、聊天和顶部栏,包括搜索框,应该在 /app 路由下可访问。路径是自解释的,但你也可以使用 / 根路径作为主路径。

  • 登录和注册表单应该有单独的路径,该路径将在 / 根路径下可访问。

  • 由于我们没有其他屏幕,我们还需要处理一种情况,即前面的所有路由都不匹配。在这种情况下,我们可以显示一个所谓的 404 页面,但我们将直接重定向到根路径。

在继续之前,我们必须准备一件事。对于开发,我们使用 webpack 开发服务器,正如我们在 第一章准备你的开发环境 中配置的那样。为了使路由能够直接工作,我们将向 webpack.client.config.js 文件添加两个参数。devServer 字段应如下所示:

devServer: {
  port: 3000,
  open: true,
  historyApiFallback: true,
},

historyApiFallback字段告诉devServer不仅为根路径http://localhost:3000/,而且在它通常会收到 404 错误(例如对于http://localhost:3000/app之类的路径)时也要提供index.html文件。这发生在路径不匹配文件或文件夹时,这在实现路由时是正常的。

config文件顶部的output字段必须有一个publicPath属性,如下所示:

output: {
  path: path.join(__dirname, buildDirectory),
  filename: 'bundle.js',
  publicPath: '/',
},

publicPath属性告诉 webpack 将包 URL 的前缀添加到绝对路径,而不是相对路径。当此属性未包含时,浏览器在访问我们应用程序的子目录时无法下载包,因为我们正在实现客户端路由。让我们从第一个路径开始,将应用程序的中心部分,包括新闻源,绑定到/app路径。

实现您的第一个路由

在实现路由之前,我们将清理App.js文件。为此,请按照以下步骤操作:

  1. client文件夹中,在App.js文件旁边创建一个Main.js文件。插入以下代码:

    import React from 'react';
    import Feed from './Feed';
    import Chats from './Chats';
    import Bar from './components/bar';
    export const Main = ({ changeLoginState }) => {
      return (
        <>
          <Bar changeLoginState={changeLoginState} />
          <Feed />
          <Chats />
        </>
      );
    }
    export default Main;
    

    如您可能已经注意到的,前面的代码基本上与App.js文件内的登录条件相同。唯一的区别是changeLoginState函数是从属性中取出的,而不是组件本身的直接方法。这是因为我们将这部分从App.js文件中分离出来,并放入一个单独的文件中。这提高了我们将要实现的其它组件的可重用性。

  2. 现在,打开并替换App组件的return语句,以反映以下更改:

    return (
      <div className="container">
        <Helmet>
          <title>Graphbook - Feed</title>
          <meta name="description" content="Newsfeed of
            all your friends on Graphbook" />
        </Helmet>
        <Router loggedIn={loggedIn}
          changeLoginState={handleLogin}/>
      </div>
    )
    

    如果你将先前的方法与旧方法进行比较,你会发现我们插入了一个Router组件,而不是直接渲染帖子源或登录表单。App.js文件的原有组件现在位于之前创建的Main.js文件中。在这里,我们将loggedIn属性和changeLoginState函数传递给Router组件。删除顶部的依赖项,如ChatsFeed组件,因为我们不再需要它们,多亏了新的Main组件。

  3. 将以下行添加到我们的App.js文件的依赖项中:

    import Router from './router';
    

    为了使路由工作,我们必须首先实现我们的自定义Router组件。通常,使用 React Router 实现路由运行很容易,并且不需要将路由功能分离到单独的文件中,但这使得代码更易读。

  4. 要做到这一点,在client文件夹中,在App.js文件旁边创建一个新的router.js文件,内容如下:

    import React from 'react';
    import { BrowserRouter as Router, Route, Redirect, Switch } from 'react-router-dom';
    import LoginRegisterForm from './components/loginregister';
    import Main from './Main';
    export const routing = ({ changeLoginState, loggedIn }) => {
      return (
        <Router>
          <Switch>
            <Route path="/app" component={() => <Main
              changeLoginState= {changeLoginState}/>}/>
          </Switch>
        </Router>
      )
    }
    export default routing;
    

在顶部,我们导入所有依赖项。它们包括新的Main组件和react-router包。以下是所有从 React Router 包中导入的组件的快速说明:

  • BrowserRouter(或简称 Router,如我们在这里所称呼)是一个组件,它使地址栏中的 URL 与 用户界面UI)保持同步;它处理所有的路由逻辑。

  • Switch 组件强制渲染第一个匹配的 RouteRedirect 组件。我们需要它来停止在用户已经位于重定向尝试导航到的位置时重新渲染 UI。我通常建议您使用 Switch 组件,因为它可以捕获不可预见的路由错误。

  • Route 是一个组件,它试图将给定的路径与浏览器的 URL 匹配。如果情况如此,则渲染 component 属性。您可以在前面的代码片段中看到,我们并没有直接将 Main 组件作为参数设置;相反,我们从无状态函数中返回它。这是必需的,因为 Route 组件的 component 属性只接受函数,而不是组件对象。这个解决方案允许我们将 changeLoginState 函数传递给 Main 组件。

  • Redirect 将浏览器导航到指定的位置。该组件接收一个名为 to 的属性,它由以 / 开头的路径填充。我们将在下一节中使用这个组件。

前面代码的问题是我们只监听了一个路由,即 /app。如果您未登录,将会有许多未覆盖的错误。最好的做法是将用户重定向到根路径,在那里他们可以登录。

受保护的路由

受保护的路由代表了一种指定只有当用户经过身份验证或具有正确的授权时才能访问的路径的方法。

在 React Router 中实现受保护路由的推荐解决方案是编写一个小的、无状态的函数,该函数根据条件渲染 Redirect 组件或需要经过身份验证的用户指定的路由上的组件。我们将路由的 component 属性提取到 Component 变量中,它是一个可渲染的 React 对象:

  1. 将以下代码插入到 router.js 文件中:

    const PrivateRoute = ({ component: Component, ...rest }) => (
      <Route {...rest} render={(props) => (
        rest.loggedIn === true
          ? <Component {...props} />
          : <Redirect to={{
              pathname: '/',
            }} />
      )} />
    )
    

    我们调用 PrivateRoute 无状态函数。它返回一个标准的 Route 组件,该组件接收最初传递给 PrivateRoute 函数的所有属性。为了传递所有属性,我们使用 ...rest 语法进行解构赋值。在 React 组件的括号内使用该语法将 rest 对象的所有字段作为属性传递给组件。只有当给定的路径匹配时,Route 组件才会被渲染。

    此外,渲染的组件取决于用户的 loggedIn 状态变量,我们必须传递它。如果用户已登录,我们将无问题地渲染 Component 变量。否则,我们使用 Redirect 组件将用户重定向到应用程序的根路径。

  2. Router 组件的 return 语句中使用新的 PrivateRoute 组件,并替换旧的 Route 组件,如下所示:

    <PrivateRoute path="/app" component={() => <Main changeLoginState={changeLoginState} />} loggedIn={loggedIn}/>
    

    注意,我们是通过从 Router 组件本身的属性中取值来传递 loggedIn 属性的。它最初从我们之前编辑的 App 组件接收 loggedIn 属性。很棒的是,loggedIn 变量可以从父 App 组件随时更新。这意味着当用户注销时,Redirect 组件会被渲染,用户会自动导航到登录表单。我们不需要编写单独的逻辑来实现这个功能。

    然而,我们现在又遇到了一个新的问题。当用户未登录时,我们将请求从 /app 重定向到 /,但我们没有为初始的 '/' 路径设置任何路由。这个路径要么显示登录表单,要么在用户登录时将用户重定向到 /app,这样做是有意义的。新组件的模式与 PrivateRoute 组件之前的代码相同,但方向相反。

  3. 将新的 LoginRoute 组件添加到 router.js 文件中,如下所示:

    const LoginRoute = ({ component: Component, ...rest }) => (
      <Route {...rest} render={(props) => (
        rest.loggedIn === false
          ? <Component {...props} />
          : <Redirect to={{
              pathname: '/app',
            }} />
      )} />
    )
    

    上述条件被反转以渲染原始组件。如果用户未登录,将渲染登录表单。否则,他们将被重定向到帖子源。

  4. 将新的路径添加到路由中,如下所示:

    <LoginRoute exact path="/" component={() => <LoginRegisterForm changeLoginState={changeLoginState}/>} loggedIn={loggedIn}/>
    

    代码看起来与 PrivateRoute 组件的代码相同,但我们现在有一个新的属性,称为 exact。如果我们向一个路由传递这个属性,浏览器位置必须匹配 100%。以下表格展示了从官方 React Router 文档中摘取的一个快速示例:

图片

对于根路径,我们将 exact 设置为 true,因为否则路径会与包含 / 的任何浏览器位置匹配,正如您在前面的表中看到的。

注意

React Router 提供了许多更多的配置选项,例如强制使用尾部斜杠、大小写敏感等。您可以在官方文档中找到所有选项和示例,网址为 v5.reactrouter.com/web/api/

React Router 中的通配符路由

目前,我们已经设置了两个路径,即 /app/。如果用户访问一个不存在的路径,例如 /test,他们将看到一个空屏幕。解决方案是实现一个匹配任何路径的路由。为了简单起见,我们将用户重定向到我们应用程序的根目录,但您也可以轻松地将重定向替换为典型的 404 页面。

将以下代码添加到 router.js 文件中:

const NotFound = () => {
  return (
    <Redirect to="/"/>
  );
}

NotFound 组件很简单。它只是将用户重定向到根路径。将下一个 Route 组件添加到 Router 组件中的 Switch 组件。确保它是列表中的最后一个。代码如下所示:

<Route component={NotFound} />

正如你所见,我们在前面的代码中渲染了一个简单的Route组件。使这个路由特殊的是我们没有传递一个path属性。默认情况下,path属性会被完全忽略,组件会在每次渲染时显示,除非与之前的组件匹配。这就是为什么我们将路由添加到Router组件的底部。当没有路由匹配时,我们将用户重定向到根路径的登录屏幕,或者如果用户已经登录,我们将使用根路径的路由逻辑将他们重定向到不同的屏幕。我们的LoginRoute组件处理最后一个情况。

你可以通过使用npm run client启动前端和npm run server启动后端来测试所有更改。我们现在已经将我们的应用程序从标准单路由应用程序转换为根据浏览器位置区分登录表单和新闻源的应用程序。

在下一节中,我们将探讨如何通过添加参数化路由并根据这些参数加载数据来实现更复杂的路由。

使用 React Router 的高级路由

本章的主要目标是为你用户的个人资料页构建一个页面。我们需要一个单独的页面来显示单个用户输入或创建的所有内容。这些内容不适合放在帖子源旁边。当查看 Facebook 时,我们可以看到每个用户都有自己的地址,我们可以在其中找到特定用户的个人资料页。我们将以相同的方式创建我们的个人资料页,并使用用户名作为自定义路径。

我们必须实现以下功能:

  1. 我们为用户个人资料添加一个新的参数化路由。路径以/user/开始,后跟一个用户名。

  2. 我们将用户个人资料页更改为在 GraphQL 请求的variables字段中发送所有 GraphQL 查询,包括username路由参数。

  3. 我们编辑postsFeed查询以通过提供的username参数过滤所有帖子。

  4. 我们在后台实现一个新的 GraphQL 查询,通过用户名请求用户,以便显示有关用户的信息。

  5. 当所有查询完成后,我们渲染一个新的用户个人资料头部组件和帖子源。

  6. 最后,我们启用在每个页面之间导航而无需重新加载整个页面,只需重新加载更改的部分。

让我们从在下一节中实现个人资料页的路由来开始。

路由中的参数

我们已经准备好了添加新用户路由所需的大部分工作。再次打开router.js文件。添加新的路由,如下所示:

<PrivateRoute path="/user/:username" component={props => <User {...props} changeLoginState={changeLoginState}/>} loggedIn={loggedIn}/>

代码中包含两个新元素,如下所示:

  • 我们输入的路径是/user/:username。正如你所见,用户名前有一个冒号作为前缀,这告诉 React Router 将它的值传递给正在渲染的底层组件。

  • 我们之前渲染的组件是一个无状态的函数,它返回LoginRegisterForm组件或Main组件。这两个组件都没有从 React Router 接收任何参数或属性。然而,现在要求将 React Router 的所有属性都传递给子组件。这包括我们刚刚引入的username参数。我们使用相同的解构赋值与props对象来将所有属性传递给User组件。

这些就是我们接受 React Router 参数化路径所需的所有更改。我们在新的用户页面组件内部读取值。在实现它之前,我们在router.js的顶部导入依赖项,以便使前面的路由工作,如下所示:

import User from './User';

Main.js文件旁边创建前面的User.js文件。与Main组件一样,我们正在收集在这个页面上渲染的所有组件。你应该保持这个布局,因为你可以直接看到每个页面由哪些主要部分组成。User.js文件应该看起来像这样:

import React from 'react';
import UserProfile from './components/user';
import Chats from './Chats';
import Bar from './components/bar';
export const User = ({ changeLoginState, match }) => {
  return (
    <>
      <Bar changeLoginState={changeLoginState} />
      <UserProfile username={match.params.username}/>
      <Chats />
    </>
  );
}
export default User

我们拥有所有常见的组件,包括BarChat组件。如果用户访问朋友的个人资料,他们会在顶部看到常见的应用栏。他们可以在右侧访问他们的聊天,就像在 Facebook 上一样。这是 React 和组件的可重用性派上用场的情况之一。

我们移除了Feed组件,并用新的UserProfile组件替换了它。重要的是,UserProfile组件接收username属性。它的值来自User组件的属性。这些属性是通过 React Router 传递的。如果你在路由路径中有一个参数,比如username,那么这个值就存储在子组件的match.params.username属性中。match对象通常包含 React Router 的所有匹配信息。

从这个点开始,你可以使用这个值实现任何你想要的自定义逻辑。我们现在将继续实现个人资料页面。

在构建用户个人资料页面之前,需要将渲染逻辑提取到单独的组件中以供重用。在post文件夹内创建一个名为feedlist.js的新文件。

feedlist.js文件中插入以下代码:

  1. 按照以下方式在顶部导入以下依赖项:

    import React, { useState } from 'react';
    import InfiniteScroll from 'react-infinite-scroll-component';
    import Post from './';
    
  2. 然后,只需复制以下return语句中的主要部分,如下所示:

    export const FeedList = ({fetchMore, posts}) => {
      const [hasMore, setHasMore] = useState(true);
      const [page, setPage] = useState(0);
      return (
        <div className="feed">
          <InfiniteScroll
            dataLength={posts.length}
            next={() => loadMore(fetchMore)}
            hasMore={hasMore}
            loader={<div className="loader"
              key={"loader"}>Loading ...</div>}
          >
          {posts.map((post, i) =>
              <Post key={post.id} post={post} />
          )}
          </InfiniteScroll>
        </div>
      );
    }
    export default FeedList;
    
  3. 现在缺少的是loadMore函数,我们也可以直接复制。只需将其直接添加到前面的组件中,如下所示:

    const loadMore = (fetchMore) => {
        fetchMore({
          variables: {
              page: page + 1,
          },
          updateQuery(previousResult, { fetchMoreResult })
            {
            if(!fetchMoreResult.postsFeed.posts.length) {
              setHasMore(false);
              return previousResult;
            }
            setPage(page + 1);
            const newData = {
              postsFeed: {
                __typename: 'PostFeed',
                posts: [
                  ...previousResult.postsFeed.posts,
                  ...fetchMoreResult.postsFeed.posts
                ]
              }
            };
            return newData;
          }
        });
      }
    
  4. 只需替换Feed.js文件return语句的部分。它应该看起来像这样:

    return (
      <div className="container">
        <div className="postForm">
          <form onSubmit={handleSubmit}>
            <textarea value={postContent} onChange={(e) =>
              setPostContent(e.target.value)} 
                placeholder="Write your custom post!"/>
            <input type="submit" value="Submit" />
          </form>
        </div>
        <FeedList posts={posts} fetchMore={loadMore}/>
      </div>
    )
    

我们现在可以在需要显示帖子列表的地方使用这个FeedList组件,比如在我们的用户个人资料页面上。

按照以下步骤构建用户的个人资料页面:

  1. components文件夹内创建一个名为user的新文件夹。

  2. user文件夹内创建一个名为index.js的新文件。

  3. 按照以下方式在文件顶部导入依赖项:

    import React from 'react';
    import FeedList from '../post/feedlist';
    import UserHeader from './header';
    import Loading from '../loading';
    import Error from '../error';
    import { useGetPostsQuery } from '../../apollo/queries/getPosts';
    import { useGetUserQuery } from '../../apollo/queries/getUser'; 
    

    前三行看起来应该很熟悉。然而,目前有两个导入的文件不存在,但我们很快就会改变这一点。第一个新文件是UserHeader,它负责渲染头像图片、用户名和信息。逻辑上,我们通过一个新的 Apollo 查询钩子getUser请求我们将要在该头部显示的数据。

  4. 将我们目前正在构建的UserProfile组件代码插入到依赖项下方,如下所示:

    const UserProfile = ({ username }) => {
      const { data: user, loading: userLoading } = 
        useGetUserQuery({ username });
      const { loading, error, data: posts, fetchMore } = 
        useGetPostsQuery({ username });
      if (loading || userLoading) return <Loading />;
      if (error) return <Error><p>{error.message}</p>
        </Error>;
      return (
        <div className="user">
          <div className="inner">
            <UserHeader user={user.user} />
          </div>
          <div className="container">
            <FeedList posts={posts.postsFeed.posts}
              fetchMore={fetchMore}/>
          </div>
        </div>
      )
    }
    export default UserProfile;
    

    UserProfile组件并不复杂。我们同时运行两个 Apollo 查询。这两个查询都设置了variables属性。useGetPostsQuery钩子接收用户名,它最初来自 React Router。这个属性也被传递给useGetUserQuery

  5. 现在编辑并创建 Apollo 查询,在编写个人资料头部组件之前。打开queries文件夹中的getPosts.js文件。

  6. 要将用户名作为 GraphQL 查询的输入,我们首先必须将查询字符串从GET_POSTS变量中更改。将前两行更改为以下代码:

    query postsFeed($page: Int, $limit: Int, $username: String) { 
      postsFeed(page: $page, limit: $limit, username:
        $username) { 
    
  7. 然后,将最后一行替换为以下代码,以提供传递变量到useQuery钩子函数的方法:

    export const useGetPostsQuery = (variables) => useQuery(GET_POSTS, { pollInterval: 5000, variables: { page: 0, limit: 10, ...variables } });
    

    如果自定义查询组件接收一个username属性,它将被包含在 GraphQL 请求中。它被用来过滤我们正在查看的特定用户发布的帖子。

  8. queries文件夹中创建一个新的getUser.js文件,创建一个查询钩子,这是我们目前所缺少的。

  9. 按照以下方式在文件顶部导入所有依赖项并使用gql解析新的查询模式:

    import { gql, useQuery } from '@apollo/client';
    import { USER_ATTRIBUTES } from '../fragments/userAttributes';
    export const GET_USER = gql'
      query user($username: String!) {
        user(username: $username) {
          ...userAttributes
        }
      }
      ${USER_ATTRIBUTES}
    ';
    export const useGetUserQuery = (variables) => useQuery(GET_USER, { variables: { ...variables }});
    

    前面的查询几乎与currentUser查询相同。我们将在我们的 GraphQL 应用程序编程接口API)中稍后实现相应的user查询。

  10. 最后一步是实现UserProfileHeader组件。这个组件渲染user属性及其所有值。它只是简单的 HTML 标记。将以下代码复制到user文件夹中的header.js文件:

    import React from 'react';
    export const UserProfileHeader = ({user}) => {
      const { avatar, username } = user;
      return (
        <div className="profileHeader">
          <div className="avatar">
            <img src={avatar}/>
          </div>
          <div className="information">
            <p>{username}</p>
            <p>You can provide further information here
               and build your really personal header 
               component for your users.</p>
          </div>
        </div>
      )
    }
    export default UserProfileHeader;
    

如果你在正确设置层叠样式表CSS)样式方面需要帮助,请查看这本书的官方仓库。前面的代码仅渲染用户数据;你也可以实现聊天按钮等特性,这样用户就可以选择与其他人开始消息交流。目前,我们还没有在任何地方实现这个特性,但解释 React 和 GraphQL 的原则并不必要。

我们已经完成了新的前端组件,但UserProfile组件仍然没有工作。我们在这里使用的查询要么不接受username参数,要么尚未实现。

下一个部分将涵盖后端需要调整的部分。

查询用户资料

使用新的个人资料页面,我们必须相应地更新我们的后端。让我们看看需要做什么,如下所示:

  • 我们必须将 username 参数添加到 postsFeed 查询的模式中,并调整解析函数。

  • 我们必须为新的 UserQuery 组件创建一个模式和解析函数。

我们将从 postsFeed 查询开始:

  1. 编辑 schema.js 文件中的 RootQuery 类型的 postsFeed 查询,以匹配以下代码:

    postsFeed(page: Int, limit: Int, username: String): PostFeed @auth
    

    在这里,我已经将 username 添加为一个可选参数。

  2. 现在,前往 resolvers.js 文件并查看相应的 resolver 函数。将函数签名替换为从变量中提取用户名,如下所示:

    postsFeed(root, { page, limit, username }, context) {
    
  3. 为了使用新参数,在 return 语句上方添加以下代码行:

    if(username) {
      query.include = [{model: User}];
      query.where = { '$User.username$': username };
    }
    

我们已经介绍了基本的 Sequelize API 以及如何使用 include 参数在 第三章 中查询关联模型,连接到数据库。一个重要点是,我们如何通过用户名过滤与用户关联的帖子。我们将在以下步骤中这样做:

  1. 在前面的代码中,我们填充了 query 对象的 include 字段,这是我们想要连接的 Sequelize 模型。这允许我们在下一步中过滤关联的 User 模型。

  2. 然后,我们创建一个普通的 where 对象,在其中写入过滤条件。如果你想通过关联的用户表来过滤帖子,你可以用美元符号包裹你想要过滤的模型和字段名称。在我们的例子中,我们用美元符号包裹 User.username,这告诉 Sequelize 查询 User 模型的表并通过 username 列的值进行过滤。

对于分页部分不需要调整。GraphQL 查询现在已准备就绪。我们所做的这些小改动的好处是,我们只有一个接受多个参数的 API 函数,既可以显示单个用户资料上的帖子,也可以显示帖子列表,如新闻源。

让我们继续并实现新的 user 查询:

  1. 在你的 GraphQL 模式中的 RootQuery 类型中添加以下行:

    user(username: String!): User @auth
    

    此查询仅接受 username 参数,但这次它是新查询中的必需参数。否则,查询就没有意义,因为我们只有在通过用户名访问用户资料时才使用它。

  2. resolvers.js 文件中,使用 Sequelize 实现解析函数,如下所示:

    user(root, { username }, context) {
      return User.findOne({
        where: {
          username: username
        }
      });
    },
    

    在前面的代码片段中,我们使用 Sequelize 的 findOne 方法并搜索我们提供的参数中的用户名,以找到恰好一个用户。

现在后台代码和用户页面都已准备就绪,我们必须允许用户导航到这个新页面。下一节将介绍使用 React Router 的用户导航。

React Router 中的编程导航

我们创建了一个带有用户资料的新网站,但现在我们必须为用户提供一个链接来访问它。新闻源和登录及注册表单之间的转换由 React Router 自动化,但新闻源到个人资料页面的转换则不是。用户决定他们是否想查看用户的个人资料。React Router 有多种处理导航的方式。我们将扩展新闻源以处理对用户名或头像图像的点击,以便导航到用户的个人资料页面。打开 post 组件文件夹中的 header.js 文件。导入 React Router 提供的 Link 组件,如下所示:

import { Link } from 'react-router-dom';

Link 组件是围绕常规 HTML a 标签的一个小包装器。显然,在标准网络应用或网站上,超链接后面没有复杂的逻辑;你点击它们,就会从头开始加载一个新页面。使用 React Router 或大多数 单页应用 (SPA) JavaScript (JS) 框架,你可以在超链接后面添加更多逻辑。重要的是,在导航到不同路由之间时,不再完全重新加载页面,这现在由 React Router 处理。导航时不会有完整的页面重新加载;相反,只需交换所需的部件,并运行 GraphQL 查询。这种方法节省了用户昂贵的带宽,因为它意味着我们可以避免再次下载所有的 HTML、CSS 和图像文件。

为了测试这个,将用户名和头像图像包裹在 Link 组件中,如下所示:

<Link to={'/user/'+post.user.username}>
  <img src={post.user.avatar} />
  <div>
    <h2>{post.user.username}</h2>
  </div>
</Link>

在渲染的 HTML 中,imgdiv 标签被一个共同的 a 标签包围,但它们在 React Router 内部处理。Link 组件接收一个 to 属性,它是导航的目的地。你必须复制一条新的 CSS 规则,因为 Link 组件已经改变了标记。代码在下面的代码片段中展示:

.post .header a > * {
  display: inline-block;
  vertical-align: middle;
}

如果你现在测试这些更改,点击用户名或头像图像,你应该会注意到页面内容动态变化,但不会完全重新加载。一个进一步的任务是将这种方法复制到应用程序栏的用户搜索列表和聊天中。目前,用户被显示出来,但没有选项通过点击它们来访问他们的个人资料页面。

现在,让我们看看使用 React Router 导航的另一种方式。如果用户已经到达了个人资料页面,我们希望他们通过点击应用程序栏中的按钮返回。首先,我们将在 bar 文件夹中创建一个新的 home.js 文件,并输入以下代码:

import React from 'react';
import { withRouter } from 'react-router';
const Home = ({ history }) => {
  const goHome = () => {
    history.push('/app');
  }
  return (
    <button className="goHome" onClick={goHome}>Home
    </button>
  );
}
export default withRouter(Home);

我们在这里使用了多个 React Router 技术。我们通过 withRouter 高阶组件导出 Home 组件,这给了 Home 组件访问 React Router 的 history 对象的权限。这很棒,因为它意味着我们不需要从 React 树的顶部向下传递这个对象。

此外,我们使用history对象将用户导航到新闻源。在render方法中,我们返回一个按钮,当点击时,运行history.push函数。这个函数将新路径添加到浏览器的历史记录中,并将用户导航到'/app'主页面。好事是它和Link组件的工作方式相同,不会重新加载整个网站。

为了让按钮工作,需要做一些事情,如下所示:

  1. 将组件导入到bar文件夹的index.js文件中,如下所示:

    import Home from './home';
    
  2. 然后,将buttons div标签替换为以下代码行:

    <div className="buttons">
      <Home/>
      <Logout changeLoginState={changeLoginState}/>
    </div>
    
  3. 将两个按钮包裹在一个单独的div标签中,这样更容易正确地对齐它们。你可以替换旧的 CSS 样式用于注销按钮,并添加以下内容:

    .topbar .buttons {
      position: absolute;
      right: 5px;
      top: 5px;
      height: calc(100% - 10px);
    }
    .topbar .buttons > * {
      height: 100%;
      margin-right: 5px;
      border: none;
      border-radius: 5px;
    }
    

现在我们已经把所有东西都准备好了,用户可以访问个人资料页面并再次导航。我们的最终结果如下:

图 8.1 – 用户个人资料

图 8.1 – 用户个人资料

我们在窗口底部为用户及其帖子有一个大的个人资料标题。在顶部,你可以看到带有当前登录用户的顶部栏。

记住重定向位置

当访客来到你的页面时,他们可能遵循了在其他地方发布的链接。这个链接很可能是对用户、帖子或其他你提供直接访问内容的直接地址。对于未登录的用户,我们配置了应用程序将那个人重定向到登录或注册表单。这种行为是有意义的。然而,一旦那个人登录或使用新账户注册,他们就会被导航到新闻源。更好的做法是记住那个人最初想要访问的目的地。为了做到这一点,我们将对路由器做一些修改。打开router.js文件。使用 React Router 提供的所有路由组件,我们总是可以访问它们内部的属性。我们将利用这一点并保存我们最后重定向的最后一个位置。

PrivateRoute组件中,用以下代码替换Redirect组件:

<Redirect to={{
  pathname: '/',
  state: { from: props.location }
}} />

在这里,我们添加了state字段。它接收的值来自父Route组件,该组件持有由 React Router 生成的props.location字段中的最后一个匹配路径。路径可以是用户的个人资料页面或新闻源,因为两者都依赖于需要身份验证的PrivateRoute组件。当触发前面的重定向时,你会在路由器的状态中接收到from字段。

我们想在用户登录时使用这个变量。将LoginRoute组件中的Redirect组件替换为以下代码行:

<Redirect to={{
  pathname: (typeof props.location.state !== typeof 
    undefined) ? 
  props.location.state.from.pathname : '/app',
}} />

在这里,我为pathname参数引入了一个小条件。如果location.state属性已定义,我们可以依赖from字段。之前,我们在PrivateRoute组件中存储了重定向路径。如果location.state属性不存在,用户不是直接访问超链接,而是只想正常登录。他们将被导航到带有/app路径的新闻源。

您的应用程序现在应该能够处理所有路由场景,这应该允许您的用户舒适地查看您的网站。

摘要

在本章中,我们从单屏应用过渡到了多页布局。我们用于路由的主要库 React Router 现在有三个路径,在这些路径下我们展示了 Graphbook 的不同部分。此外,我们现在还有一个通配符路由,我们可以将用户重定向到一个有效的页面。

在下一章中,我们将通过实现服务器端渲染来继续这种进步,这需要在前后端进行许多调整。

第九章:实现服务器端渲染

在上一章的进展基础上,我们现在使用我们的 React 应用在不同的路径下服务多个页面。目前,所有路由都在客户端直接进行。在本章中,我们将探讨 服务器端渲染SSR)的优缺点。到本章结束时,你将配置 Graphbook 以从服务器而不是客户端作为预渲染的 HTML 来服务所有页面。

本章涵盖了以下主题:

  • 介绍 SSR

  • Express.js 中设置 SSR 以在服务器上渲染 React

  • 在 SSR 中启用 JSON Web TokenJWT)认证

  • 在 React 树中运行所有我们的 GraphQL 查询

技术要求

本章的源代码可在以下 GitHub 仓库中找到:

github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition/tree/main/Chapter09

介绍 SSR

首先,你必须理解使用服务器端渲染和客户端渲染应用之间的区别。在将纯客户端渲染应用转换为支持服务器端渲染(SSR)时,有许多事情需要考虑。在我们的应用中,当前的用户流程从客户端请求一个标准的 index.html 文件开始。这个文件只包含少量内容,例如一个包含一个 div 元素的少量 body 对象,一个带有一些非常基本的 meta 标签的 head 标签,以及一个至关重要的 script 标签,该标签下载捆绑的 index.htmlbundle.js 文件。然后,客户端的浏览器开始处理我们编写的 React 标记。当 React 完成代码评估后,我们看到的就是我们想要看到的应用的 HTML。所有 CSS 文件或图像都是从我们的服务器下载的,但只有在 React 将 HTML 插入浏览器 文档对象模型DOM)之后才会下载。在 React 的渲染过程中,Apollo 组件被执行,所有查询都被发送。当然,这些查询由我们的后端和数据库处理。

与 SSR 相比,客户端方法更为直接。在AngularEmberReact和其他 JavaScript 框架开发之前,传统的方法是拥有一个后端,它实现了所有的业务逻辑,并且有大量的模板或函数返回有效的 HTML。后端查询数据库,处理数据,并将数据插入到 HTML 中。HTML 直接在客户端请求时提供。然后浏览器根据 HTML 下载 JavaScript、CSS 和图像文件。大多数情况下,JavaScript 只负责允许动态内容或布局变化,而不是渲染整个应用程序。这可能包括下拉菜单、手风琴或只是通过Ajax从后端拉取新数据。然而,应用程序的主要 HTML 直接从后端返回,这导致了一个单体应用程序。这个解决方案的一个显著优点是客户端不需要处理所有的业务逻辑,因为这一切已经在服务器上完成了。

然而,当我们谈论 React 应用程序中的 SSR 时,我们指的是不同的事情。在本书的这一部分,我们已经编写了一个在客户端渲染的 React 应用程序。我们不想以稍微不同的方式重新实现后端的渲染。我们也不希望失去在浏览器中动态更改数据、页面或布局的能力,因为我们已经有一个功能完善的应用程序,它为用户提供了许多交互可能性。

一种允许我们利用预渲染的 HTML 以及 React 提供的动态功能的方法被称为通用渲染。在通用渲染中,客户端的第一个请求包括一个预渲染的 HTML 页面。HTML 应该是客户端在自行处理时生成的确切 HTML。如果是这样,React 可以重用服务器提供的 HTML。由于 SSR 不仅涉及重用 HTML,还涉及节省 Apollo 发出的请求,因此客户端也需要一个 React 可以依赖的起始缓存。服务器在发送渲染的 HTML 之前发出所有请求,并将 Apollo 和 React 的状态变量插入到 HTML 中。结果是,在客户端的第一个请求中,我们的前端不应该需要重新渲染或刷新服务器返回的任何 HTML 或数据。对于所有后续操作,如导航到其他页面或发送消息,之前使用的相同客户端 React 代码仍然适用。换句话说,SSR 仅在第一次页面加载时使用。之后,这些功能不需要 SSR,因为客户端代码将继续像之前一样动态工作。

让我们开始编写一些代码。

在 Express.js 中设置 SSR 以在服务器上渲染 React

在这个例子中,第一步是在后端实现基本的 SSR。我们将在稍后扩展这个功能以验证用户的身份验证。经过身份验证的用户允许我们执行 Apollo 或 GraphQL 请求,而不仅仅是渲染纯 React 标记。首先,我们需要一些新的包。因为我们将使用通用渲染的 React 代码,我们需要一个高级的 webpack 配置。因此,我们将安装以下包:

npm install --save-dev webpack-dev-middleware webpack-hot-middleware @babel/cli

让我们快速浏览一下我们要安装的包。我们只需要这些包进行开发:

  • 第一个 webpack 模块,称为webpack-dev-middleware,允许后端服务由 webpack 生成的包,但仅从内存中生成,而不创建文件。这对于需要直接运行 JavaScript 而不想使用单独文件的情况非常有用。

  • 第二个包,称为webpack-hot-middleware,仅处理客户端更新。如果创建了新的包版本,客户端会收到通知,并交换包。

  • 最后一个包,称为@babel/cli,允许我们引入Babel为我们后端提供的出色功能。我们将使用需要转译的 React 代码。

在生产环境中,不建议使用这些包。相反,在部署应用程序之前,一次性构建包。当应用程序上线时,客户端下载包。

在启用 SSR 的开发中,后端使用这些包在 SSR 完成后将打包的 React 代码分发到客户端。服务器本身依赖于普通的src文件,而不是客户端接收的 webpack 包。

我们还依赖于一个额外的关键包,如下所示:

npm install --save node-fetch

为了设置window.fetch方法。Apollo Client 使用它来发送 GraphQL 请求,这就是为什么我们要安装node-fetch作为 polyfill。我们将在本章后面设置 Apollo Client 以用于后端。

在开始主要工作之前,请确保您的NODE_ENV环境变量设置为development

然后,转到服务器的index.js文件,所有 Express.js 的魔法都在这里发生。我们之前没有涵盖这个文件,因为我们现在要调整它以支持 SSR,包括直接的路由。

首先,我们将为 SSR 设置开发环境,因为这对我们接下来的任务至关重要。按照以下步骤准备您的开发环境以支持 SSR:

  1. 第一步是导入两个新的 webpack 模块:webpack-dev-middlewarewebpack-hot-middleware。这些模块应该只在开发环境中使用,因此我们应该通过检查环境变量有条件地引入它们。在生产环境中,我们提前生成 webpack 包。为了只在开发中使用新包,请将以下代码放在 Express.js helmet 设置下方:

    if(process.env.NODE_ENV === 'development') {
      const devMiddleware = 
        require('webpack-dev-middleware');
      const hotMiddleware = 
        require('webpack-hot-middleware');
      const webpack = require('webpack');
      const config = 
        require('../../webpack.server.config');
      const compiler = webpack(config);
      app.use(devMiddleware(compiler));
      app.use(hotMiddleware(compiler));
    }
    
  2. 在加载这些包之后,我们还将需要 webpack,因为我们将解析一个新的 webpack 配置文件。新的配置文件仅用于 SSR。

  3. 在加载 webpack 和配置文件之后,我们将使用webpack(config)命令解析配置并创建一个新的 webpack 实例。

  4. 接下来,我们将创建 webpack 配置文件。为此,我们将创建的 webpack 实例传递给我们的两个新模块。当一个请求到达服务器时,这两个包将根据配置文件采取行动。

与原始配置文件相比,新的配置文件只有几个小的差异,但这些差异影响很大。创建新的webpack.server.config.js文件,并输入以下配置:

const path = require('path');
const webpack = require('webpack');
const buildDirectory = 'dist';
module.exports = {
  mode: 'development',
  entry: [
    'webpack-hot-middleware/client',
    './src/client/index.js'
  ],
  output: {
    path: path.join(__dirname, buildDirectory),
    filename: 'bundle.js',
    publicPath: '/'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        },
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
      {
        test: /\.(png|woff|woff2|eot|ttf|svg)$/,
        loader: 'url-loader?limit=100000',

      },
    ],
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NamedModulesPlugin(),
  ],
};

与原始的webpack.client.config.js文件相比,我们在前面的配置中做了三项更改,具体如下:

  • entry属性中,我们现在有多个入口点。前端代码的index文件,如之前一样,是一个入口点。第二个入口点是新的webpack-hot-middleware模块,它启动客户端和服务器之间的连接。这个连接用于发送客户端通知,以更新到新版本的 bundle。

  • 我已经移除了devServer字段,因为这个配置不需要 webpack 启动自己的服务器。Express.js 是我们要使用的 web 服务器,当加载配置时我们已经在使用它了。

  • 插件与客户端的 webpack 配置中的插件完全不同。我们不需要CleanWebpackPlugin,因为它会清理dist文件夹,也不需要HtmlWebpackPlugin插件,该插件会将 webpack 打包文件插入到index.html文件中;这由服务器以不同的方式处理。这些插件仅适用于客户端开发。现在,我们有HotModuleReplacementPlugin,它启用了NamedModulesPlugin,显示由 HMR 注入的模块的相对路径。这两个插件仅推荐在开发中使用。

webpack 的准备工作现在已完成。

现在,我们必须关注如何渲染 React 代码以及如何服务生成的 HTML。然而,我们不能使用我们已编写的现有 React 代码。首先,我们必须对主文件进行特定的调整:index.jsApp.jsrouter.jsapollo/index.js。我们使用的许多包,如React Router或 Apollo Client,都有默认设置或模块,当它们在服务器上执行时,我们必须进行不同的配置。

我们将从 React 应用程序的根目录开始,即index.js文件。我们将实现一个单独的 SSR index文件,因为需要进行特定的服务器调整。

server文件夹内创建一个名为ssr的新文件夹。然后,将以下代码插入到ssr文件夹内的index.js文件中:

import React from 'react';
import { ApolloProvider } from '@apollo/client';
import App from './app';
const ServerClient = ({ client, location, context }) => {
  return(
    <ApolloProvider client={client}>
      <App location={location} context={context}/>
    </ApolloProvider>
  );
}
export default ServerClient

上述代码是我们客户端 index.js 根文件的修改版本。该文件所经历的变化如下列所示:

  • 我们现在不再使用 ReactDOM.render 函数将 HTML 插入具有 root ID 的 DOMNode 中,而是导出一个 React 组件。返回的组件被称为 ServerClient。我们没有可以访问的 DOM 来让 ReactDOM 渲染任何内容,所以在服务器端渲染时我们跳过这一步。

  • ApolloProvider 组件现在直接从 ServerClient 属性接收 Apollo Client,而之前我们是直接在这个文件内部通过从 apollo 文件夹导入 index.js 文件并将其传递给提供者来设置 Apollo Client。你很快就会看到我们为什么要这样做。

  • 我们所做的最后一个变化是提取了一个 location 和一个 context 属性。我们将这些属性传递给 App 组件。在原始版本中,没有向 App 组件传递任何属性。这两个属性都是配置 React Router 以与 SSR 一起工作所必需的。我们将在本章后面实现这些属性。

在更详细地查看我们为什么要进行这些更改之前,让我们为后端创建一个新的 App 组件。在 ssr 文件夹中 index.js 文件旁边创建一个 app.js 文件,并插入以下代码:

import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import { withApollo } from '@apollo/client/react/hoc';
import Router from '../../client/router';
import { useCurrentUserQuery } from '../../client/apollo/queries/currentUserQuery';
import '../../client/components/fontawesome';
const App = ({ location, context }) => {
  const { data, loading, error } = useCurrentUserQuery();
  const [loggedIn, setLoggedIn] = useState(false);
  return (
    <div className="container">
      <Helmet>
        <title>Graphbook - Feed</title>
        <meta name="description" content="Newsfeed of all 
          your friends on Graphbook" />
      </Helmet>
      <Router loggedIn={loggedIn}
        changeLoginState={setLoggedIn} location={location}
          context={context} />
    </div>
  )
}
export default withApollo(App)

以下是我们所做的几个更改:

  • 与原始的客户端 App 组件相比,第一个变化是调整了 import 语句,以便从 client 文件夹中加载路由器和 fontawesome 组件,因为它们不存在于 server 文件夹中。

  • 第二个变化是移除了 useEffect 钩子和 localStorage 访问。我们这样做是因为我们构建的认证使用了 localStorage 访问。这对于客户端认证来说是可行的。这两个 useEffect 钩子仅在客户端调用。这就是为什么我们在将我们的应用程序移动到 SSR 时移除认证。我们将在稍后的步骤中将 localStorage 实现替换为 cookies。目前,用户保持从服务器端注销状态。

  • 最后一个变化是将两个新属性 contextlocation 传递给前面代码中的 Router 组件。

React Router 提供了对 SSR 的即时支持。尽管如此,我们仍需要进行一些调整。最好的方式是我们在后端和前端使用相同的路由器,这样我们就不需要定义两次路由,这既低效又可能导致问题。打开 client 文件夹内的 router.js 文件,按照以下步骤操作:

  1. react-router-dom 包的 import 语句更改为以下形式:

    import { BrowserRouter, StaticRouter, Route, Redirect, Switch } from 'react-router-dom';
    
  2. 插入以下代码以提取正确的路由:

    let Router;
    if(typeof window !== typeof undefined) {
      Router = BrowserRouter;
    }
    else {
      Router = StaticRouter;
    }
    

    在导入 React Router 包后,我们通过查找window对象来检查文件是在服务器端还是客户端执行。由于 Node.js 中没有window对象,这是一个足够的检查。另一种方法是在一个单独的文件中设置Switch组件,包括路由。这种方法允许我们在为客户端和服务器端渲染创建两个单独的路由器文件时,直接将路由导入到正确的路由器中。

    如果我们在客户端,我们使用BrowserRouter,如果不是,我们使用StaticRouter。在这里,逻辑是,使用StaticRouter时,我们处于一个无状态的环境,其中我们使用固定的位置渲染所有路由。StaticRouter组件不允许通过重定向更改位置,因为在使用 SSR 时无法发生用户交互。其他组件RouteRedirectSwitch可以像以前一样使用。

    无论提取哪个路由器,我们都会将它们保存在Router变量中。然后我们在routing组件的返回语句中使用它们。

  3. 我们准备了contextlocation属性,这些属性从顶层的ServerClient组件传递给Router变量。如果我们处于服务器端,这些属性应该被填充,因为StaticRouter对象需要它们。你可以在底部的Routing组件中替换Router标签,如下所示:

    <Router context={this.props.context} location={this.props.location}>
    

    location对象包含路由应该渲染的路径。context变量存储Router组件处理的所有信息,例如重定向。我们可以在渲染Router组件后检查这个变量,以手动触发重定向。这是BrowserRouterStaticRouter之间的一大区别。在前一种情况下,BrowserRouter会自动重定向用户,但StaticRouter不会。

成功渲染我们的 React 代码的关键组件现在已经准备好了。然而,还有一些模块在我们使用 React 渲染任何内容之前必须初始化。再次打开index.js服务器文件。目前,我们正在为http://localhost:8000根路径上的dist路径提供静态服务。当我们转向 SSR 时,我们必须在/路径上提供由我们的 React 应用程序生成的 HTML。

此外,任何其他路径,如/app,也应该使用 SSR 在服务器上渲染这些路径。删除文件底部的当前app.get方法,该方法位于app.listen方法之前。然后,插入以下代码作为替代:

app.use('/', express.static(path.join(root, 'dist/client'), { index: false }));
app.get('*', (req, res) => {
  res.status(200);
  res.send('<!doctype html>');
  res.end();
});

代码的第一行应该替换旧的静态路由。它引入了一个名为index的新选项,这将禁用在根路径上提供index.html文件。

我们在先前的代码中使用星号 (*) 可以覆盖 Express.js 路由中定义的任何路径。始终记住,我们在 Express.js 中使用的 services 例程可以实现新的路径,例如 /graphql,我们不希望覆盖。为了避免这种情况,将代码放在文件的底部,在 services 设置下方。该路由捕获发送到后端的任何请求。

您可以通过运行 npm run server 命令来尝试此路由。只需访问 http://localhost:8000 即可。

目前,前面的通配符路由仅返回一个空的站点,状态为 200。让我们改变这一点。逻辑步骤将是加载并渲染 ssr 文件夹中的 index.js 文件中的 ServerClient 组件,因为它是 React SSR 代码的起点。然而,ServerClient 组件需要一个初始化的 Apollo Client 实例,正如我们之前解释的那样。我们将为 SSR 创建一个特殊的 Apollo Client 实例。

创建一个 ssr/apollo.js 文件,因为它还不存在。我们将在该文件中设置 Apollo Client。内容几乎与客户端原始设置相同:

import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { onError } from 'apollo-link-error';
import { ApolloLink } from 'apollo-link';
import { HttpLink } from 'apollo-link-http';
import fetch from 'node-fetch';
export default (req) => {
  const AuthLink = (operation, next) => {
    return next(operation);
  };
  const client = new ApolloClient({
    ssrMode: true,
    link: ApolloLink.from([
      onError(({ graphQLErrors, networkError }) => {
        if (graphQLErrors) {
          graphQLErrors.map(({ message, locations, path,
            extensions }) => {
            console.log('[GraphQL error]: Message:
              ${message}, 
                Location: ${locations}, Path: ${path}');
          });
          if (networkError) {
            console.log('[Network error]:
              ${networkError}');
          }
        }
      }), 
      AuthLink,
      new HttpLink({
        uri: 'http://localhost:8000/graphql',
        credentials: 'same-origin',
        fetch
      })
    ]),
    cache: new InMemoryCache(),
  });
  return client;
};

然而,我们进行了一些更改,以便在服务器上使客户端工作。这些更改相当大,因此我们为服务器端 Apollo Client 设置创建了一个单独的文件。查看以下更改(如下)以了解前端和 SSR 设置之间的差异:

  • 我们不再使用我们之前引入的 createUploadLink 函数来允许用户上传图片或其他文件,而是再次使用标准的 HttpLink 类。您可以使用 UploadClient 函数,但它在服务器上提供的功能将不会使用,因为服务器不会上传文件。

  • AuthLink 函数跳转到下一个链接,因为我们尚未实现服务器端身份验证。

  • HttpLink 对象接收 fetch 属性,该属性由我们在本章开头安装的 node-fetch 包填充。这用于替代在 Node.js 中不可用的 window.fetch 方法。

  • 我们不是直接导出 client 对象,而是导出一个包装函数,它接受一个 request 对象。我们将其作为参数传递给 Express.js 路由。如您在先前的代码示例中看到的,我们尚未使用该对象,但很快就会改变。

在服务器 index.js 文件顶部导入 ApolloClient 组件,如下所示:

import ApolloClient from './ssr/apollo';

导入的 ApolloClient 函数接受我们的 Express.js 服务器的 request 对象。

在新的 Express.js 通配符路由顶部添加以下行:

const client = ApolloClient(req);

这样,我们就设置了一个新的 client 实例,我们可以将其传递给我们的 ServerClient 组件。

我们可以继续并实现 ServerClient 组件的渲染。为了使未来的代码工作,我们必须加载 React 和当然,ServerClient 组件本身:

import React from 'react';
import Graphbook from './ssr/';

ServerClient 组件以 Graphbook 的名称导入。我们导入 React 是因为我们使用标准的 JSX 语法来渲染我们的 React 代码。

现在我们已经可以访问 Apollo Client 和 ServerClient 组件,在 Express.js 路由中的 ApolloClient 设置下方插入以下两行代码:

const context= {};
const App = (<Graphbook client={client} location={req.url}
  context= {context}/>);

我们将初始化的 client 变量传递给 Graphbook 组件。我们使用常规的 React 语法传递所有属性。此外,我们将 location 属性设置为请求对象的 url 对象,以告诉路由器要渲染哪个路径。context 属性被传递为一个空对象。

然而,为什么我们在最后将一个空对象作为 context 传递给路由器呢?

原因是,在将 Graphbook 组件渲染成 HTML 之后,我们可以访问 context 对象并查看是否通常会触发重定向(或其他操作)。正如我们之前提到的,重定向必须由后端代码实现。React Router 的 StaticRouter 组件不会对您使用的 Node.js 网络服务器做出假设。这就是为什么 StaticRouter 不会自动执行它们。使用 context 变量跟踪和后处理这些事件是可能的。

生成的 React 对象被保存到一个新的变量中,该变量被命名为 App。现在,如果你使用 npm run server 启动服务器并访问 http://localhost:8000,应该不会出现任何错误。然而,我们仍然看到一个空页面。这是因为我们只返回了一个空的 HTML 页面;我们还没有将 React 的 App 对象渲染成 HTML。要将对象渲染成 HTML,请在服务器 index.js 文件的顶部导入以下包:

import ReactDOM from 'react-dom/server';

react-dom 包不仅为浏览器提供了绑定,还提供了一个专门用于服务器的模块,这就是为什么我们在导入它时使用 /server 后缀。返回的模块提供了一系列仅适用于服务器的函数。

注意

要了解一些更高级的 SSR 特性和其背后的动态,你应该阅读 react-dom 服务器包的官方文档,请参阅 reactjs.org/docs/react-dom-server.html

我们可以通过使用 ReactDOM.rendertoString 函数将 React 的 App 对象转换成 HTML。在 App 对象下方插入以下代码行:

const content = ReactDOM.renderToString(App);

此函数生成 HTML 并将其存储在 content 变量中。现在可以将 HTML 返回给客户端。如果您从服务器返回预渲染的 HTML,客户端会遍历它并检查其当前状态是否与返回的 HTML 匹配。比较是通过识别 HTML 中的某些点来进行的,例如 data-reactroot 属性。

如果在任何时候,服务器渲染的 HTML 和客户端将生成的 HTML 之间的标记不匹配,则会抛出错误。应用程序仍然可以工作,但客户端将无法使用 SSR;客户端将用重新渲染的一切替换从服务器返回的完整标记。在这种情况下,服务器的 HTML 响应被丢弃。这当然是非常低效的,并不是我们想要的结果。

我们必须将渲染的 HTML 返回给客户端。我们渲染的 HTML 以根div标签开始,而不是以html标签开始。我们必须将content变量包裹在一个包含周围 HTML 标签的模板中。因此,在ssr文件夹内创建一个template.js文件,并输入以下代码以实现我们渲染的 HTML 的模板:

import React from 'react';
import ReactDOM from 'react-dom/server';
const htmlTemplate = (content) => {
  return '
    <html lang="en">
      <head>
        <meta charSet="UTF-8"/>
        <meta name="viewport" content="width=device-width, 
          initial-scale=1.0"/>
        <meta httpEquiv="X-UA-Compatible"
          content="ie=edge"/>
        <link rel="shortcut icon" 
          href="data:image/x-icon;," type="image/x-icon"> 
        ${(process.env.NODE_ENV === 'development')? "":
          "<link rel='stylesheet' href='/bundle.css'/>"}
      </head>
      <body>
        ${ReactDOM.renderToStaticMarkup(<div id="root" 
          dangerouslySetInnerHTML={{ __html: content 
            }}></div>)}
        <script src="img/bundle.js"></script>
      </body>
    </html>
  ';
};
export default htmlTemplate;

上述代码基本上与通常提供给客户端的index.html文件中的 HTML 标记相同。区别在于这里我们使用了 React 和ReactDOM

首先,我们导出一个函数,该函数接受带有渲染后的 HTML 的content变量。

然后,我们在head标签内渲染一个link标签,如果我们在生产环境中,这个标签会下载 CSS 包。对于我们的当前开发场景,没有打包的 CSS。

重要的是,我们在body标签内使用了一个新的ReactDOM函数,名为rendertoStaticMarkup。这个函数将 React 的root标签插入到我们的 HTML 模板的 body 中。之前,我们使用的是renderToString方法,它包含了特殊的 React 标签,例如data-reactroot属性。现在,我们使用rendertoStaticMarkup函数生成没有特殊 React 标签的标准 HTML。我们传递给函数的唯一参数是带有root ID 的div标签和一个新属性dangerouslySetInnerHTML。这个属性是常规innerHTML属性的替代品,但用于 React。它允许 React 在根div标签内插入 HTML。正如其名所示,这样做是有风险的,但只有在客户端这样做时才有风险,因为ReactDOM.renderToStaticMarkup函数无法使用这个属性。插入的 HTML 最初是用renderToString函数渲染的,以便包含所有的关键 React HTML 属性和带有root ID 的包装div标签。然后,它可以在浏览器中被前端代码无问题地重用。

我们需要在服务器index文件中引入这个template.js文件,文件顶部如下:

import template from './ssr/template';

模板函数现在可以直接在res.send方法中使用,如下所示:

res.send('<!doctype html>\n${template(content)}');

我们现在不仅返回一个doctype对象,还响应template函数的返回值。正如您应该看到的,template函数接受作为参数的渲染后的content变量并将其组合成一个有效的 HTML 文档。

到目前为止,我们已经成功使我们的第一个服务器端渲染的 React 应用程序版本工作。你可以通过在浏览器窗口中右键单击并选择查看源代码来证明这一点。窗口显示服务器返回的原始 HTML。输出等于template函数的 HTML,包括登录和注册表单。

尽管如此,我们面临两个问题:

  • 服务器响应中没有包含描述性 meta head标签。React Helmet肯定出了些问题。

  • 当在客户端登录,例如在/app路径下查看新闻源时,服务器响应没有渲染新闻源或登录表单。通常,React Router 会重定向我们到登录表单,因为我们没有在服务器端登录。然而,由于我们使用了StaticRouter,我们必须单独发起重定向,正如我们之前解释的那样。我们将分步骤实现身份验证。

我们将从第一个问题开始。要修复 React Helmet的问题,在服务器index.js文件的顶部导入它,如下所示:

import { Helmet } from 'react-helmet';

现在,在设置响应状态res.status之前,你可以提取 React Helmet的状态,如下所示:

const head = Helmet.renderStatic();

renderStatic方法专门用于 SSR。我们可以在使用renderToString函数渲染 React 应用程序后使用它。它给我们提供了在整个代码中插入的所有head标签。将这个head变量作为第二个参数传递给template函数,如下所示:

res.send('<!doctype html>\n${template(content, head)}');

返回到ssr文件夹中的template.js文件。将head参数添加到导出函数的签名中。然后,在 HTML 的head标签中添加以下两行新代码:

${head.title.toString()}
${head.meta.toString()}

从 React Helmet提取的head变量为每个meta标签都持有属性。这些标签提供了一个toString函数,它返回一个有效的 HTML 标签,你可以直接将其输入到文档的head对象中。第一个问题应该得到修复:现在所有的head标签都包含在服务器的 HTML 响应中。

让我们专注于第二个问题。当访问PrivateRoute组件时,服务器响应返回一个空的 React root标签。正如我们之前解释的那样,这是因为自然发起的重定向没有到达我们这里,因为我们使用了StaticRouter。由于服务器端渲染的代码没有实现身份验证,所以我们被重定向离开了PrivateRoute组件。首先需要修复的是处理重定向,我们至少应该响应登录表单,而不是一个空的 React root标签。稍后,我们需要修复身份验证问题。

如果不查看服务器响应的源代码,你不会注意到这个问题。前端下载bundle.js文件并自行触发渲染,因为它知道用户的身份验证状态。用户不会注意到这一点。然而,如果服务器直接发送正确的 HTML,这将更加高效。如果用户已登录,HTML 将是错误的,但在未认证用户的情况下,登录表单是由服务器预先渲染的,因为它启动了重定向。

为了解决这个问题,我们可以在 React Router 使用renderToString函数后访问已经被填充的context对象。最终的 Express.js 路由应该看起来像以下代码示例:

app.get('*', (req, res) => {
  const client = ApolloClient(req);
  const context= {};
  const App = (<Graphbook client={client} 
    location={req.url} context= {context}/>);
  const content = ReactDOM.renderToString(App);
  if (context.url) {
    res.redirect(301, context.url);
  } else {
    const head = Helmet.renderStatic();
    res.status(200);
    res.send('<!doctype html>\n${template(content, 
      head)}');
    res.end();
  }
});

在服务器上渲染正确路由的条件是检查context.url属性。如果它被填充,我们可以使用 Express.js 启动重定向。这将导航浏览器到正确的路径。如果属性没有被填充,我们可以返回 React 生成的 HTML。

此路由正确渲染了 React 代码,直到需要身份验证的点。SSR 路由正确渲染了所有公共路由,但没有渲染任何安全路由。这意味着我们现在只响应登录表单,因为它是唯一不需要身份验证的路由。

下一步是在与 SSR 结合的情况下实现身份验证,以解决这个问题。

使用 SSR 进行身份验证

你应该已经注意到,我们已经从服务器端的 React 代码中移除了大部分身份验证逻辑。这样做的原因是localStorage不能在页面初始加载时传输到服务器,这是 SSR 可以使用的唯一情况。这导致的问题是我们不能渲染正确的路由,因为我们不能验证用户是否已登录。身份验证必须转移到与每个请求一起发送的 cookies 上。

重要的是要理解,cookies 也会引入一些安全问题。我们将继续使用我们编写的 GraphQL API 的常规 HTTP 授权头。如果我们为 GraphQL API 使用 cookies,我们将使我们的应用程序容易受到潜在的跨站请求伪造CSRF)攻击。前端代码继续使用 HTTP 授权头发送所有 GraphQL 请求。

我们将只使用 cookies 来验证用户的身份验证状态,并初始化对我们的 GraphQL API 的 SSR 请求。SSR GraphQL 请求将在 HTTP 授权头中包含授权 cookie 的值。我们的 GraphQL API 只读取和验证这个头,不接受 cookies。只要你在加载页面时没有修改数据,并且只查询要渲染的数据,就不会存在安全问题。

小贴士

由于 CSRF 和 XSS 是一个重要的主题,我建议你阅读相关内容,以便全面了解如何保护自己和用户。你可以在www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)找到一篇优秀的文章。

因此,只需按照以下说明来在 SSR 上实现身份验证:

  1. 首件事是使用npm安装一个新的包,如下所示:

    cookies package allows us to easily interact through the Express.js request object with the cookies sent by the browser. Instead of manually parsing and reading through the cookie string (which is just a comma-separated list), you can access the cookies with simple get and set methods. To get this package working, you have to initialize it inside Express.js.
    
  2. 导入cookiesjwt包,并从服务器index.js文件顶部的环境变量中提取JWT_SECRET字符串:

    import Cookies from 'cookies';
    import JWT from 'jsonwebtoken';
    const { JWT_SECRET } = process.env;
    

    要使用cookies包,我们需要设置一个新的中间件路由。

  3. 在初始化 webpack 模块和服务流程之前插入以下代码:

    app.use(
      (req, res, next) => {
        const options = { keys: ['Some random keys'] }; 
        req.cookies = new Cookies(req, res, options); 
        next();
      }
    );
    

    这个新的 Express.js 中间件为它处理的每个请求在req.cookies属性下初始化cookies包。Cookies构造函数的第一个参数是请求,第二个是响应对象,最后一个是一个options参数。这个参数接受一个keys数组,用于对 cookies 进行签名。如果你出于安全原因想要对 cookies 进行签名,则这些键是必需的。在生产环境中,你应该注意这一点。你也可以指定一个secure属性,这确保了 cookies 只会在安全的 HTTPS 连接上传输。

  4. 我们现在可以提取authorizationcookie 并验证用户的身份验证。为此,将服务器index.js文件中 SSR 路由的开始部分替换为以下代码:

    app.get('*', async (req, res) => {
      const token = req.cookies.get('authorization', 
        { signed: true });
      var loggedIn;
      try {
        await JWT.verify(token, JWT_SECRET);
        loggedIn = true;
      } catch(e) {
        loggedIn = false;
      }
    

    在这里,我已将async声明添加到回调函数中,因为我们在这个函数内部使用了await语句。第二步是从请求对象中提取authorizationcookie,使用req.cookies.get。重要的是,我们在options参数中指定了signed字段,因为只有这样它才能成功返回签名的 cookies。

    提取的值代表我们在用户登录时生成的 JWT。我们可以通过我们在第六章中实现的典型方法来验证它,即使用 Apollo 和 React 进行身份验证。也就是说,我们在验证 JWT 时使用await语句。如果抛出错误,则用户未登录。状态保存在loggedIn变量中。

  5. loggedIn变量传递给Graphbook组件,如下所示:

    const App = (<Graphbook client={client} loggedIn={loggedIn} location={req.url} context={context}/>);
    

    现在,我们可以从ssr文件夹中的index.js文件访问loggedIn属性。

  6. 从属性中提取loggedIn状态,并将其传递给ssr文件夹的index.js文件中的App组件,如下所示:

    <App location={location} context={context} loggedIn={loggedIn}/>
    

    App组件内部,我们不需要直接将loggedIn状态设置为false,但我们可以获取属性值,因为它在App组件渲染之前就已经确定了。这种流程与客户端流程不同,在客户端流程中,loggedIn状态是在App组件内部确定的。

  7. 修改app.js文件中的App组件,以匹配以下代码:

    const App = ({ location, context, loggedIn: loggedInProp }) => {
      const { data, loading, error } = 
        useCurrentUserQuery();
      const [loggedIn, setLoggedIn] = 
        useState(loggedInProp);
    

    在这里,结果是我们将loggedIn值从我们的 Express.js 路由传递到GraphbookApp组件,再到我们的Router组件。这已经接受loggedIn属性以渲染用户的正确路径。目前,我们还没有在用户成功登录时在后端设置 cookie。

  8. 打开我们的 GraphQL 服务器的resolvers.js文件以修复此问题。我们将为loginsignup函数更改几行。由于两个解析函数在登录或注册后都需要设置认证令牌,因此它们需要相同的更改。所以,直接在返回语句上方插入以下代码:

    context.cookies.set(
      'authorization',
      token, { signed: true, expires: expirationDate, 
        httpOnly: true, secure: false, sameSite: 'strict'
          }
    );
    

    上述函数为用户的浏览器设置 cookie。上下文对象仅是 Express.js 的request对象,其中我们初始化了 cookies 包。cookies.set函数的属性相当直观,但让我们如下描述它们:

    a. signed字段指定在初始化cookies对象时输入的密钥是否应该用于签名 cookie 的值。

    b. expires属性接受一个date对象。它表示 cookie 有效的截止时间。您可以设置属性为任何您想要的日期,但我建议设置一个较短的时间,例如一天。在context.cookies.set语句上方插入以下代码,以正确初始化expirationDate变量:

    const cookieExpiration = 1;
    const expirationDate = new Date(); 
    expirationDate.setDate(
      expirationDate.getDate() + cookieExpiration
    );
    

    c. httpOnly字段确保 cookie 不会被客户端 JavaScript 访问。

    d. secure属性与初始化Cookie包时的含义相同。它将 cookie 限制为仅 SSL 连接。在上线时这是必须的,但在开发时不能使用,因为大多数开发者都是在本地开发,没有 SSL 证书。

    e. sameSite字段可以取strictlax作为值。我建议将其设置为strict,因为您希望您的 GraphQL API 或服务器在每次请求时都接收 cookie,但您也希望排除所有跨站请求,因为这可能是危险的。

  9. 现在,我们应该清理我们的代码。由于我们正在使用 cookies,我们可以从前端代码中移除localStorage认证流程。在client文件夹中打开App.js文件。移除componentWillMount方法,因为我们从localStorage中读取数据。

    cookies 会自动与任何请求一起发送,并且不需要像localStorage那样的单独绑定。这也意味着我们需要一个特殊的logout突变来从浏览器中删除 cookie。JavaScript 无法访问或删除 cookie,因为我们将其指定为httpOnly。只有服务器才能从客户端删除它。

  10. mutations文件夹内创建一个新的logout.js文件,以便创建一个logout突变钩子。内容应如下所示:

    import { gql, useMutation } from '@apollo/client';
    export const LOGOUT = gql'
      mutation logout {
        logout {
          success
        }
      }
    ';
    export const useLogoutMutation = () => useMutation(LOGOUT);
    

    之前的功能钩子仅发送一个简单的 logout 变异,没有任何参数或进一步逻辑。

  11. 我们应该使用 bar 文件夹中 logout.js 文件内的函数来发送 GraphQL 请求。在文件顶部导入组件,如下所示:

    import { useLogoutMutation } from '../../apollo/mutations/logout';
    
  12. logout 方法替换为以下代码,以便在点击 logoutMutation 函数时发送变异。这会将 GraphQL 请求发送到我们的服务器。

  13. 要在 schema.js 中的 GraphQL RootMutation 类型上实现变异,请向后台添加一行代码:

    logout: Response @auth
    

    需要确保尝试注销的用户已被授权,因此我们使用 @auth 指令。

  14. 相应的解析函数如下。将其添加到 resolvers.js 文件中的 RootMutation 属性:

    logout(root, params, context) {
      context.cookies.set(
        'authorization',
        '', { signed: true, expires: new Date(), httpOnly:
          true, secure: false, sameSite: 'strict' }
      );
      return {
        message: true
      };
    },
    

    解析函数是最小的。它通过将过期日期设置为当前时间来删除 cookie。当浏览器收到响应时,由于此时已过期,它会在客户端删除 cookie。与 localStorage 相比,这种行为是一个优点。

我们已经完成了所有工作,使授权与 SSR 一起工作。这是一个非常复杂的任务,因为授权、SSR 和 CSR 对整个应用程序都有影响。每个框架都有自己的方法来实现这个功能,所以请也查看它们。

如果您查看渲染后从我们的服务器返回的源代码,您应该看到登录表单被正确返回,就像之前一样。此外,服务器现在能够识别用户是否已登录。然而,服务器尚未返回渲染的新闻源、应用栏或聊天。返回的 HTML 中只包含一个加载消息。客户端代码也没有识别出用户已登录。我们将在下一节中查看这些问题。

使用 SSR 运行 Apollo 查询

通过本质,GraphQL 查询通过 HttpLink 是异步的。我们已经实现了一个 loading 组件,在数据正在获取时向用户显示加载消息。

这与我们在服务器上渲染 React 代码时发生的情况相同。所有路由都被评估,包括我们是否已登录。如果找到正确的路由,所有 GraphQL 请求都会发送。问题是第一次渲染 React 返回加载状态,由我们的服务器发送到客户端。服务器不会等待 GraphQL 查询完成并收到所有响应,然后渲染我们的 React 代码。

我们现在将解决这个问题。以下是我们必须做的事情列表:

  • 我们需要实现 SSR Apollo 客户端实例的认证。我们已经在路由中做到了这一点,但现在,我们需要将 cookie 传递到服务器端的 GraphQL 请求中。

  • 我们需要使用 React Apollo 特定的方法来异步渲染 React 代码,以便等待所有 GraphQL 请求的响应。

  • 重要的是,我们需要将 Apollo 缓存状态返回给客户端。否则,客户端将重新获取所有内容,因为它的状态在页面首次加载时是空的。

让我们开始吧,如下所示:

  1. 第一步是将 Express.js SSR 路由中的loggedIn变量传递给ApolloClient函数作为第二个参数。将服务器index.js文件中的ApolloClient调用修改为以下代码:

    const client = ApolloClient(req, loggedIn);
    

    将从apollo.js文件导出的函数的签名修改为也包括这个第二个参数。

  2. 用以下代码替换 Apollo Client 设置中的AuthLink函数:

    const AuthLink = (operation, next) => {
      if(loggedIn) {
        operation.setContext(context => ({
          ...context,
          headers: {
            ...context.headers,
            Authorization: 
              req.cookies.get('authorization')
          },
        }));
      }
      return next(operation)
    };
    

    这个AuthLink通过使用 Express.js 提供的request对象将 cookie 添加到 GraphQL 请求中。request对象已经包含了初始化的 cookie 包,我们使用它来提取授权 cookie。这只有在用户之前已经验证为登录状态时才需要执行。

  3. 在服务器的index.js文件中导入 Apollo 包中的一个新函数。用以下代码替换对ReactDOM包的导入:

    import { renderToStringWithData } from "@apollo/client/react/ssr";
    
  4. 最初,我们使用ReactDOM服务器方法将 React 代码渲染为 HTML。这些函数是同步的;这就是为什么 GraphQL 请求没有完成。为了等待所有 GraphQL 请求,替换服务器index.js文件中从rendertoString函数开始直到 SSR 路由结束的所有行。结果应该如下所示:

    renderToStringWithData(App).then((content) => {
      if (context.url) {
        res.redirect(301, context.url);
      } else {
        const head = Helmet.renderStatic();
        res.status(200);
        res.send('<!doctype html>\n${template(content,
          head)}');
        res.end();
      }
    });
    

    renderToStringWithData函数渲染 React 代码,包括通过 Apollo 请求接收到的数据。由于该方法异步,我们将其余代码包裹在一个回调函数中。

    现在,如果你查看服务器返回的 HTML,你应该看到正确的标记,包括聊天、图片以及其他所有内容。问题是客户端不知道所有的 HTML 已经存在并且可以被重用。客户端将重新渲染一切。

  5. 为了让客户端能够重用我们服务器发送的 HTML,我们必须将 Apollo Client 的状态包含在我们的响应中。在先前的回调函数内部,通过插入以下代码来访问 Apollo Client 的状态:

    const initialState = client.extract();
    

    client.extract方法返回一个包含客户端使用renderToStringWithData函数后存储的所有缓存信息的大对象。

  6. 状态必须作为第三个参数传递给template函数。因此,将res.send调用修改为以下代码:

    res.send('<!doctype html>\n${template(content, head, initialState)}');
    
  7. template.js文件中,扩展函数声明并在head变量之后追加state变量作为第三个参数。

  8. 在 HTML body 中bundle.js文件上方插入state变量,使用以下代码。如果你将它添加到bundle.js文件下方,它将无法正确工作:

    ${ReactDOM.renderToStaticMarkup(<script dangerouslySetInnerHTML=
    {{__html: 'window.__APOLLO_STATE__=${JSON.stringify(state).replace
    (/</g, '\\u003c')}'}}/>)}
    

    我们使用renderToStaticMarkup函数插入另一个script标签。它将一个大型、字符串化的 JSON 对象设置为 Apollo Client 的起始缓存值。该 JSON 对象包含在渲染我们的服务器端 React 应用程序时返回的所有 GraphQL 请求的结果。我们直接将 JSON 对象作为字符串存储在window对象的新字段中。window对象很有用,因为您可以直接全局访问该字段。

  9. Apollo 必须了解状态变量。它可以被 Apollo Client 用来用指定数据初始化其缓存,而不是必须再次发送所有 GraphQL 请求。打开客户端apollo文件夹中的index.js文件。初始化过程的最后一个属性是缓存。我们需要将我们的__APOLLO_STATE__实例设置为缓存的起始值。将cache属性替换为以下代码:

    cache: new InMemoryCache().restore(window.__APOLLO_STATE__)
    

    我们创建了InMemoryCache实例并运行其restore方法,其中我们插入来自窗口对象的价值。Apollo Client 应该从该变量重新创建其缓存。

  10. 我们现在已经为 Apollo 设置了缓存。它将不再运行已经存在结果的不必要请求。现在,我们最终可以重用 HTML,只需进行最后一次更改。我们必须在客户端的index.js文件中将ReactDOM.render更改为ReactDOM.hydrate。这两个函数之间的区别在于,如果我们的服务器正确渲染了 HTML,React 会重用该 HTML。在这种情况下,React 仅附加一些必要的事件监听器。如果您使用ReactDOM.render方法,它将显著减慢初始渲染过程,因为它会将初始 DOM 与当前 DOM 进行比较,并根据需要进行更改。

我们遇到最后一个问题是客户端代码在刷新页面后不显示应用程序的登录状态。服务器返回了包含所有数据的正确标记,但前端将我们重定向到登录表单。这是因为我们在客户端代码的App.js文件中静态地将loggedIn状态变量设置为false

检查用户是否认证的最佳方式是验证窗口对象上的__APOLLO_STATE__字段是否被填充并且附加了一个currentUser对象。如果是这样,我们可以假设用户能够检索到自己的数据记录,所以他们必须已经登录。为了相应地更改我们的App.js文件,向loggedIn状态变量添加以下条件:

(typeof window.__APOLLO_STATE__ !== typeof undefined && typeof window.__APOLLO_STATE__.ROOT_QUERY !== typeof undefined && typeof window.__APOLLO_STATE__.ROOT_QUERY.currentUser !== typeof undefined)

如您在前面的代码中所见,我们验证 Apollo 启动缓存变量是否包含一个带有currentUser子字段的ROOT_QUERY属性。如果任何查询可以成功检索,则ROOT_QUERY属性会被填充。只有当认证用户成功请求时,currentUser字段才会被填充。

如果你执行npm run server,你会看到现在一切运行得非常完美。看看返回的标记;你会看到登录表单,或者当你登录时,你访问的页面的全部内容。你可以在客户端登录,新闻源会动态获取,你可以刷新页面,所有的帖子都会直接显示,无需进行单个 GraphQL 请求,因为服务器已经将数据与 HTML 一起返回。这不仅适用于/app路径,也适用于你实现的任何路径。

我们现在已经完成了 SSR 的设置。

到目前为止,我们只看了 SSR 的开发部分。当我们到达想要制作生产构建并发布我们的应用的时候,我们还需要考虑一些其他的事情,这些内容我们将在第十二章使用 CircleCI 和 AWS 进行持续部署中探讨。

摘要

在本章中,我们修改了我们迄今为止编写的大量代码。你学习了提供 SSR 的优势和劣势。React Router、Apollo 以及使用 SSR 进行 cookie 认证的主要原则现在应该已经很清晰了。要让 SSR 运行起来需要做很多工作,并且需要管理你应用中每一次的更改。尽管如此,它为你的用户提供了卓越的性能和用户体验优势。

在下一章中,我们将探讨如何通过Apollo Subscriptions提供实时更新,而不是使用旧的和低效的轮询方法。