无处不在的-JavaScript-全-
无处不在的 JavaScript(全)
原文:
zh.annas-archive.org/md5/3e8c3c656e1ac43106f3a77904737284译者:飞龙
序言
1997 年,我还是高中的一名初学者。有一天,一个朋友在学校图书馆里展示给我看,告诉我可以点击“查看 → 源代码”来查看网页的底层代码。几天后,另一个朋友教会了我如何发布自己的 HTML 页面。我的脑袋简直要炸了。
从那时起,我就着了迷。我四处借鉴我喜欢的网站的各种元素,拼凑出了自己的“弗兰肯网站”。我大部分空闲时间都花在了我家餐厅拼凑出来的电脑前,不停地摆弄。我甚至“写”了(好吧,其实是复制粘贴)我的第一个 JavaScript 代码,用来实现链接的悬停样式,这是用简单的 CSS 还做不到的。
还有一件事我想到了,我觉得有点像电影《几乎出名》,但这是一个书呆子和温暖的版本。我自己做的音乐网站竟然有了一定的人气。因此,我开始收到邮寄过来的宣传 CD,并被列入了音乐会的贵宾名单。然而,对我来说,更重要的是,我能够与世界各地的人们分享我的兴趣。那种能够影响到我从未见过的人的感觉,至今仍然让我感到无比的满足和赋予力量。
如今,我们可以仅仅使用 Web 技术来构建强大的应用程序,但是初学者可能觉得这些技术有些令人生畏。API 是一个在背景中提供数据的看不见的力量。查看 → 源代码显示的是拼接和压缩后的代码。认证和安全性则是神秘莫测的部分。把所有这些东西结合起来可能会让人感到不知所措。如果我们能够超越这些令人困惑的细节,我们会发现 20 多年前我摆弄的同样技术现在可以用来构建强大的 Web 应用程序,编写原生移动应用程序,创建强大的桌面应用程序,设计 3D 动画,甚至编程机器人。
作为一名教育工作者,我发现我们许多人通过构建新事物、拆解它们并为自己的用例调整它们来学习得最好。这本书的目标就是这样。如果你懂一些 HTML、CSS 和 JavaScript,但不确定如何利用这些组件构建你梦想中的强大应用程序,那么这本书适合你。我将指导你构建一个 API,可以支持 Web 应用程序、原生移动应用程序和桌面应用程序的用户界面。最重要的是,你将理解所有这些组件如何结合在一起,以便你可以构建和创造出精彩的事物。
我迫不及待地想看看你们会做出什么来。
Adam
前言
我写完我的第一个 Electron 桌面应用程序后,就有了写这本书的想法。作为一名网页开发者,我立即被使用 Web 技术构建跨平台应用的可能性所吸引。与此同时,React、React Native 和 GraphQL 也都开始流行起来。我寻找资源来学习这些技术如何结合在一起,但却一直找不到。这本书就是我希望自己有的指南。
本书的最终目标是介绍使用单一编程语言 JavaScript 构建各种类型应用的可能性。
本书的目标读者
本书适合具有一定 HTML、CSS 和 JavaScript 经验的中级开发者,或者有志向的初学者,他们希望学习启动业务或侧项目所需的工具。
本书的组织结构
本书旨在指导您开发各种平台的示例应用程序。可以分为以下几个部分:
-
第 1 章节引导您设置 JavaScript 开发环境。
-
第 2 到 10 章节涵盖了使用 Node、Express、MongoDB 和 Apollo Server 构建 API 的内容。
-
第 11 到 25 章节详细介绍了使用 React、Apollo 和各种工具构建跨平台用户界面的细节。具体而言:
-
第 11 章节介绍用户界面开发和 React。
-
第 12 到 17 章节演示了如何使用 React、Apollo Client 和 CSS-in-JS 构建 Web 应用程序。
-
第 18 到 20 章节指导您构建简单的 Electron 应用程序。
-
第 21 到 25 章节介绍使用 React Native 和 Expo 构建 iOS 和 Android 移动应用程序。
-
本书使用的约定
本书中使用的以下排印约定:
斜体
指示新术语、网址、电子邮件地址、文件名和文件扩展名。
常量宽度
用于程序清单,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
常量宽度粗体
显示用户应直接输入的命令或其他文本。
常量宽度斜体
显示应由用户提供的值或根据上下文确定的值的文本。
提示
此元素表示提示或建议。
注意
此元素表示一般性说明。
警告
此元素表示警告或注意事项。
使用代码示例
附加材料(代码示例、练习等)可在 https://github.com/javascripteverywhere 下载。
如果您在使用代码示例时遇到技术问题,请发送电子邮件至 bookquestions@oreilly.com。
本书的目的在于帮助您完成工作。通常情况下,如果本书提供示例代码,您可以在自己的程序和文档中使用它。除非您要复制代码的大部分,否则无需征得我们的许可。例如,编写一个使用本书多个代码块的程序不需要许可。销售或分发 O’Reilly 图书的示例需要许可。引用本书并引用示例代码来回答问题无需许可。将本书大量示例代码整合到您产品的文档中需要许可。
我们感谢您的支持,但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN 号。例如:“JavaScript Everywhere by Adam D. Scott(O’Reilly)。版权所有 2020 Adam D. Scott,978-1-492-04698-1。”
如果您认为您对代码示例的使用超出了合理使用范围或上述许可,请随时联系我们 permissions@oreilly.com。
O’Reilly 在线学习
注意
40 多年来,O’Reilly Media 提供技术和商业培训、知识和见解,帮助公司取得成功。
我们独特的专家和创新者网络通过书籍、文章、会议以及我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境,以及来自 O’Reilly 和 200 多家其他出版商的广泛文本和视频集合。有关更多信息,请访问 http://oreilly.com。
如何联系我们
请将有关本书的评论和问题发送给出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938(在美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为本书设有网页,列出勘误、示例和任何额外信息。您可以访问 https://oreil.ly/javascript-everywhere 查看此页面。
发送电子邮件到 bookquestions@oreilly.com 来评论或询问关于本书的技术问题。
有关我们的书籍、课程、会议和新闻的更多信息,请访问我们的网站 http://www.oreilly.com。
在 Facebook 上找到我们:http://facebook.com/oreilly
在 Twitter 上关注我们:http://twitter.com/oreillymedia
在 YouTube 上观看我们:http://www.youtube.com/oreillymedia
致谢
感谢 O'Reilly 的所有优秀员工,无论是过去还是现在,他们多年来对我的想法始终给予了普遍的欢迎和支持。我特别要感谢我的编辑安吉拉·鲁菲诺,她给了我反馈、鼓励和许多有益的提醒。我还要感谢迈克·鲁奇德斯,他给了我咖啡因和很棒的交流。最后,感谢詹妮弗·波洛克,她的支持和鼓励。
我永远感激开源社区,从中我学到了很多,也受益匪浅。如果没有那些个人和组织创建和维护的许多库,我就无法完成这本书。
几位技术审阅者帮助改善了这本书,确保内容的准确性。感谢安迪·恩戈姆、布莱恩·斯莱特恩、马克西米利亚诺·菲尔特曼和齐尚·查乌达里。进行这样一次详尽的代码审查确实不易,我真诚感谢他们的努力。特别感谢我的老同事和朋友吉米·威尔逊,我在最后关头请他审阅并提供反馈。这是一个相当大的请求,但就像他做的每件事一样,他充满热情地承担了下来。没有他的帮助,这本书就不会成为现在的样子。
我在成年生活中非常幸运,周围都是聪明、充满激情、支持我的同事。他们教会了我许多重要的和不那么重要的技术和非技术课程。列举它们的全部内容会太长,但我想特别感谢伊丽莎白·邦德、约翰·保罗·多根、马克·埃舍尔、詹·拉西特和杰西卡·沙弗。
在写作过程中,音乐是我的不断伴侣。如果没有 Chuck Johnson、Mary Lattimore、Makaya McCraven、G.S. Schray、Sam Wilkes、Hiroshi Yoshimura 等众多出色音乐人的美妙音乐,也许这本书就不会存在。
最后,我要感谢我的妻子艾比和我的孩子们莱利、哈里森和哈洛,在我写这本书的过程中,我牺牲了很多时间。感谢你们忍受我在办公室里被锁着的时光,或者即使不在,我的心灵也在那里。你们四个是我做任何事情的动力来源。
第一章:我们的开发环境
约翰·伍登(John Wooden),UCLA 男子篮球队的已故教练,是有史以来最成功的教练之一,在 12 年内赢得了 10 个国家冠军。他的队伍包括顶级新秀,包括名人堂球员如 Lew Alcindor(Kareem Abdul-Jabbar)和 Bill Walton。在第一天的训练中,伍登会坐下他的每一位新秀队员,这些在美国高中时期曾经是最优秀的球员,并教他们正确穿袜子的方法。当被问及此事时,伍登表示,“正是一些小细节造就了重大成就。”
厨师们使用术语mise en place,意思是“一切就位”,来描述在烹饪前准备菜单所需的工具和食材的做法。这种准备使得厨房的厨师们能够在繁忙时段成功地准备餐食,因为已经考虑到了小细节。就像伍德恩教练的球员和厨师为晚餐高峰期做准备一样,值得花时间来设置我们的开发环境。
一个有用的开发环境并不需要昂贵的软件或顶级硬件。事实上,我鼓励您从简单开始,使用开源软件,并随着您的需求增加工具。尽管跑步者喜欢特定品牌的运动鞋,木匠可能总是会拿起她喜欢的锤子,但建立这些偏好需要时间和经验。尝试不同的工具,观察他人,随着时间的推移,您将创建最适合自己的环境。
在本章中,我们将安装一个文本编辑器、Node.js、Git、MongoDB 和几个有用的 JavaScript 包,以及找到我们的终端应用程序。您可能已经拥有一个对您而言效果很好的开发环境;然而,我们还将安装本书中将使用的几个必需工具。如果您像我一样通常跳过使用说明书,我仍然鼓励您通读本指南。
如果您在任何时候遇到困难,请联系 JavaScript Everywhere 社区,通过我们的 Spectrum 频道spectrum.chat/jseverywhere。
您的文本编辑器
文本编辑器就像衣服一样。我们都需要它们,但我们的偏好可能大相径庭。有些人喜欢简单且结构良好的。有些人喜欢花哨的佩斯利图案。没有错误的选择,您应该使用让自己最舒适的工具。
如果您还没有最喜欢的文本编辑器,我强烈推荐Visual Studio Code (VSCode)。它是一个开源编辑器,适用于 Mac、Windows 和 Linux。此外,它提供了内置功能来简化开发,并可以通过社区扩展进行轻松修改。它甚至是使用 JavaScript 构建的!
终端
如果您使用 VSCode,它带有集成终端。对于大多数开发任务,这可能就足够了。就我个人而言,我发现使用专用终端客户端更可取,因为我发现在我的机器上更容易管理多个选项卡并使用更多的专用窗口空间。我建议尝试两种方法,找到最适合您的方法。
使用专用终端应用程序
所有操作系统都带有一个内置的终端应用程序,这是一个很好的开始。在 macOS 上,它被称为 Terminal。在 Windows 操作系统上,从 Windows 7 开始,该程序是 PowerShell。Linux 发行版的终端名称可能有所不同,但通常包括“Terminal”。
使用 VSCode
要访问 VSCode 中的终端,请单击 Terminal → New Terminal。这将为您呈现一个终端窗口。提示将出现在与当前项目相同的目录中。
导航文件系统
一旦找到您的终端,您将需要关键的能力来导航文件系统。您可以使用cd命令来做到这一点,它代表“更改目录”。
命令行提示
终端指令通常在行首包含$或>。这些用于指示提示,不应复制。在本书中,我将用美元符号($)表示终端提示。在输入指令到您的终端应用程序时,不要输入$。
当您打开终端应用程序时,您将看到一个光标提示,您可以在其中输入命令。默认情况下,您位于计算机的主目录中。如果还没有这样做,我建议在主目录中创建一个项目文件夹作为子目录。这个文件夹可以存放所有您的开发项目。您可以创建一个项目目录并像这样导航到该文件夹:
# first type cd, this will ensure you are in your root directory
$ cd
# next, if you don't already have a Projects directory, you can create one
# this will create Projects as a subfolder in your system's root directory
$ mkdir Projects
# finally you can cd into the Projects directory
$ cd Projects
在未来,您可以按照以下方式导航到您的项目目录:
$ cd # ensure you are in the root directory
$ cd Projects
现在假设您在项目目录中有一个名为jseverywhere的文件夹。您可以从项目目录中键入cd jseverywhere来导航到该文件夹。要向后导航到一个目录(在这种情况下是项目),您将键入cd ..(cd命令后跟两个句点)。
总的来说,这看起来可能是这样的:
> $ cd # ensure you are in your root directory
> $ cd Projects # navigate from root dir to Projects dir
/Projects > $ cd jseverywhere # navigate from Projects dir to jsevewehre dir
/Projects/jseverwhere > $ cd .. # navigate back from jseverwhere to Projects
/Projects > $ # Prompt is currently in the Projects dir
如果这对您来说是新的,请花一些时间浏览您的文件,直到您感到舒适。我发现文件系统问题是初学者开发者常见的绊脚石。掌握这一点将为您建立工作流程提供坚实的基础。
命令行工具和 Homebrew(仅限 Mac)
一些命令行实用程序只有在安装了 Xcode 后才能供 macOS 用户使用。您可以通过在终端中安装xcode-select来绕过这个问题,而不必安装 Xcode。要这样做,请运行以下命令并按照安装提示操作:
$ xcode-select --install
Homebrew 是 macOS 的包管理器。它使得安装开发依赖项(如编程语言和数据库)变得像在命令行提示符下运行一样简单。如果您使用 Mac,它将大大简化您的开发环境。要安装 Homebrew,请访问brew.sh,复制并粘贴安装命令,或者在一行中输入以下内容:
$ /usr/bin/ruby -e "$(curl -fsSL
https://raw.githubusercontent.com/Homebrew/install/master/install)"
Node.js 和 NPM
Node.js是“建立在 Chrome 的 V8 JavaScript 引擎上的 JavaScript 运行时”。从实际角度来看,这意味着 Node 是一个平台,允许开发人员在非浏览器环境下编写 JavaScript 代码。Node.js 自带 NPM 作为默认的包管理器。NPM 使您能够在项目中安装数千个库和 JavaScript 工具。
管理 Node.js 版本
如果您计划管理大量的 Node 项目,您可能会发现需要在计算机上管理多个 Node 版本。如果是这种情况,我建议使用Node Version Manager (NVM)来安装 Node。NVM 是一个脚本,使您能够管理多个活动的 Node 版本。对于 Windows 用户,我建议使用nvm-windows。我不会涵盖 Node 版本管理的内容,但这是一个有用的工具。如果这是您第一次使用 Node,请按照系统的以下说明操作。
在 macOS 上安装 Node.js 和 NPM
macOS 用户可以使用 Homebrew 安装 Node.js 和 NPM。要安装 Node.js,请在终端中输入以下命令:
$ brew update
$ brew install node
安装完成 Node 后,请打开您的终端应用程序以验证其是否正常工作。
$ node --version
## Expected output v12.14.1, your version number may differ
$ npm --version
## Expected output 6.13.7, your version number may differ
如果在输入这些命令后看到版本号码,恭喜你——你已成功在 macOS 上安装了 Node 和 NPM!
在 Windows 上安装 Node.js 和 NPM
对于 Windows 用户,安装 Node.js 最简单的方法是访问nodejs.org,并下载适用于您操作系统的安装程序。
首先,请访问nodejs.org,安装 LTS 版本(本文撰写时为 12.14.1),按照您操作系统的安装步骤进行操作。安装完成 Node 后,打开您的终端应用程序以验证其是否正常工作。
$ node --version
## Expected output v12.14.1, your version number may differ
$ npm --version
## Expected output 6.13.7, your version number may differ
什么是 LTS?
LTS 代表“长期支持”,这意味着 Node.js 基金会承诺为该主要版本号(在本例中为 12.x)提供支持和安全更新。标准支持窗口在该版本初始发布后持续三年。在 Node.js 中,偶数发布版本是 LTS 版本。我建议在应用程序开发中使用偶数发布版本。
如果在输入这些命令后看到版本号码,恭喜你——你已成功在 Windows 上安装了 Node 和 NPM!
MongoDB
MongoDB 是我们在开发 API 时将使用的数据库。Mongo 是使用 Node.js 的热门选择,因为它将我们的数据视为 JSON(JavaScript 对象表示)文档。这意味着 JavaScript 开发人员可以从一开始就轻松使用它。
官方 MongoDB 安装文档
MongoDB 文档提供了跨操作系统安装 MongoDB Community Edition 的定期更新指南。如果在安装过程中遇到问题,建议查阅文档docs.mongodb.com/manual/administration/install-community。
安装和运行 MongoDB for macOS
要在 macOS 上安装 MongoDB,首先使用 Homebrew 安装:
$ brew update
$ brew tap mongodb/brew
$ brew install mongodb-community@4.2
要启动 MongoDB,我们可以将其作为 macOS 服务运行:
$ brew services start mongodb-community
这将启动 MongoDB 服务并将其作为后台进程运行。请注意,每次重新启动计算机并计划使用 Mongo 进行开发时,您可能需要再次运行此命令以重新启动 MongoDB 服务。要验证 MongoDB 是否已安装并运行,请在终端中键入ps -ef | grep mongod。这将列出当前正在运行的 Mongo 进程。
安装和运行 MongoDB for Windows
要在 Windows 上安装 MongoDB,请首先从MongoDB 下载中心下载安装程序。一旦文件下载完成,按照安装向导运行安装程序。我建议选择完整设置类型,并将其配置为服务。所有其他值可以保持默认设置。
安装完成后,我们可能需要创建一个目录,Mongo 将在其中写入我们的数据。在您的终端中运行以下命令:
$ cd C:\
$ md "\data\db"
要验证 MongoDB 是否已安装并启动 Mongo 服务:
-
定位 Windows 服务控制台。
-
查找 MongoDB 服务。
-
右键单击 MongoDB 服务。
-
点击启动。
请注意,每次重新启动计算机并计划使用 Mongo 进行开发时,您可能需要重新启动 MongoDB 服务。
Git
Git 是最流行的版本控制软件,允许您执行诸如复制代码存储库、将代码与其他人合并以及创建不相互影响的自己代码分支等操作。Git 将有助于“克隆”本书示例代码存储库,这意味着它将允许您直接复制一个示例代码文件夹。根据您的操作系统,Git 可能已经安装。在终端窗口中键入以下内容:
$ git --version
如果返回了数字,则表示您已准备就绪!如果没有,请访问git-scm.com安装 Git,或者在 macOS 上使用 Homebrew。完成安装步骤后,再次在终端中键入git --version以验证是否已成功。
Expo
Expo 是一个工具链,简化了使用 React Native 在 iOS 和 Android 项目中启动和开发的过程。我们需要安装 Expo 命令行工具,并可选地(但建议)安装 iOS 或 Android 上的 Expo 应用程序。我们将在本书的移动应用程序部分详细介绍这一点,但如果您有兴趣提前开始,请访问expo.io了解更多信息。要安装命令行工具,请在终端中输入以下内容:
npm install -g expo-cli
使用-g全局标志将使expo-cli工具在您机器上的 Node.js 安装中全局可用。
要安装 Expo 移动应用程序,请访问您设备上的 Apple App Store 或 Google Play Store。
Prettier
Prettier 是一个代码格式化工具,支持多种语言,包括 JavaScript、HTML、CSS、GraphQL 和 Markdown。它使得遵循基本的格式化规则变得很容易,这意味着当您运行 Prettier 命令时,您的代码会自动按照一套标准的最佳实践格式化。更好的是,您可以配置您的编辑器,在每次保存文件时自动执行此操作。这意味着您将永远不会再遇到项目中存在不一致空格和混合引号等问题。
我建议在您的机器上全局安装 Prettier 并为您的编辑器配置插件。要全局安装 Prettier,请转到命令行并键入:
npm install -g prettier
安装了 Prettier 后,请访问Prettier.io为您的文本编辑器找到插件。安装了编辑器插件后,我建议在编辑器的设置文件中添加以下设置:
"editor.formatOnSave": true,
"prettier.requireConfig": true
当项目中存在.prettierrc配置文件时,这些设置将在保存文件时自动格式化文件。.prettierrc文件指定了 Prettier 要遵循的选项。现在,每当该文件存在时,您的编辑器都会自动重新格式化代码以符合项目的约定。本书中的每个项目都将包括一个.prettierrc文件。
ESLint
ESLint 是用于 JavaScript 的代码检查工具。与 Prettier 等格式化工具不同,代码检查工具还会检查代码质量规则,如未使用的变量、无限循环和 return 后面不可达的代码。与 Prettier 一样,我建议为您喜欢的文本编辑器安装 ESLint 插件。这将在您编写代码时实时警告您的错误。您可以在ESLint 网站找到一系列编辑器插件。
类似于 Prettier,项目可以在.eslintrc文件中指定要遵循的 ESLint 规则。这为项目维护者提供了对其代码偏好的细粒度控制,并自动执行编码标准的手段。本书中的每个项目都将包括一组有用但宽容的 ESLint 规则,旨在帮助您避免常见的陷阱。
美观化
这是可选的,但我发现当我发现我的设置在美学上更加令人愉悦时,我更喜欢编程。我控制不住;我有艺术学位。花些时间测试不同的颜色主题和字体。就我个人而言,我已经喜欢上了德拉库拉主题,这是几乎每个文本编辑器和终端都可以使用的颜色主题,以及 Adobe 的源代码 Pro 字体。
结论
在本章中,我们在计算机上建立了一个工作灵活的 JavaScript 开发环境。编程的一大乐趣之一就是个性化你的环境。我鼓励你尝试使用不同的主题、颜色和工具,让这个环境更符合你的喜好。在本书的下一节中,我们将通过开发 API 应用程序来利用这个环境。
第二章:API 介绍
想象一下自己坐在一家小而地道的餐馆的包厢里,你决定点一份三明治。服务员在一张纸条上写下你的订单并递给厨师。厨师读取订单,逐个取材料来制作三明治,然后将三明治交给服务员。服务员再把三明治端到你面前。如果你想要甜点,过程会重复。
应用程序编程接口(API)是一组规范,允许一个计算机程序与另一个进行交互。Web API 的工作方式类似于订购三明治。客户端请求一些数据,该数据通过超文本传输协议(HTTP)传输到 Web 服务器应用程序,Web 服务器应用程序接收请求并处理数据,然后通过 HTTP 将数据发送给客户端。
在本章中,我们将探讨 Web API 的广泛主题,并通过克隆初始 API 项目到我们的本地机器来开始开发。然而,在此之前,让我们先探索一下我们将要构建的应用程序的要求。
我们正在构建什么
在整本书中,我们将构建一个名为 Notedly 的社交笔记应用程序。用户可以创建账户,在普通文本或 Markdown 中编写笔记,编辑他们的笔记,查看其他用户笔记的动态,以及“收藏”其他用户的笔记。在本书的这一部分中,我们将开发支持该应用程序的 API。
在我们的 API 中:
-
用户可以创建笔记,以及阅读、更新和删除他们创建的笔记。
-
用户可以查看其他用户创建的笔记动态,并阅读其他人创建的单个笔记,但不能更新或删除它们。
-
用户可以创建账户,登录和登出。
-
用户可以检索他们的个人资料信息,以及其他用户的公开个人资料信息。
-
用户可以收藏其他用户的笔记,并检索他们收藏的列表。
Markdown
Markdown 是一种流行的文本标记语言,广泛应用于编程社区以及诸如 iA Writer、Ulysses、Byword 等文本应用程序中。要了解更多关于 Markdown 的信息,请访问Markdown Guide 网站。
尽管听起来有点复杂,但我将在本书的这一部分中逐步解释。一旦你学会执行这些类型的交互,你就能够应用它们来构建各种类型的 API。
我们如何构建这个应用程序
为了构建我们的 API,我们将使用 GraphQL API 查询语言。GraphQL 是一个开源规范,最初于 2012 年在 Facebook 开发。GraphQL 的优势在于允许客户端精确请求需要的数据,显著简化和限制请求的数量。当我们向移动客户端发送数据时,这也提供了明显的性能优势,因为我们只需发送客户端需要的数据。在本书的大部分内容中,我们将探讨如何编写、开发和消费 GraphQL API。
REST 是怎样的呢?
如果你熟悉 Web API 的术语,你可能听说过 REST(表述性状态转移)API。REST 架构一直(并将继续)是 API 的主流格式。这些 API 通过依赖 URL 结构和查询参数向服务器发送请求。尽管 REST 仍然重要,但 GraphQL 的简单性、围绕 GraphQL 的强大工具以及通过网络发送有限数据的潜在性能优势使得 GraphQL 成为我在现代平台上的首选。
入门
在我们开始开发之前,我们需要将项目的起始文件复制到我们的机器上。项目的 源代码 包含了我们开发应用程序所需的所有脚本和第三方库的引用。要将代码克隆到本地机器上,请打开终端,导航到你项目保存的目录,git clone 项目仓库,并使用 npm install 安装依赖项。创建一个 notedly 目录以保持书籍所有代码的组织也可能很有帮助:
$ cd Projects
$ mkdir notedly && cd notedly
$ git clone git@github.com:javascripteverywhere/api.git
$ cd api
$ npm install
安装第三方依赖
通过复制书中的起始代码,并在目录中运行 npm install,你避免了需要为每个独立的第三方依赖再次运行 npm install。
代码结构如下:
/src
这是你在跟随书籍开发时应该进行开发的目录。
/解决方案
这个目录包含每个章节的解决方案。如果遇到困难,你可以查阅这些内容。
/最终版
这个目录包含最终的工作项目。
现在你已经将代码下载到本地机器上,你需要复制项目的 .env 文件。这个文件用于保存环境相关信息或项目机密,比如数据库 URL、客户端 ID 和密码。因此,你绝对不应该将其提交到源代码控制中。你需要自己复制一份 .env 文件。在 api 目录下,输入以下命令到终端:
cp .env.example .env
现在你应该在目录中看到一个 .env 文件。目前你不需要对此文件做任何操作,但随着我们在开发 API 后端的过程中,我们将逐步向其添加信息。项目附带的 .gitignore 文件将确保你不会意外提交 .env 文件。
帮助,我找不到 .env 文件!
默认情况下,操作系统会隐藏以点开头的文件,因为这些文件通常由系统而非用户使用。如果您看不到 .env 文件,请尝试在文本编辑器中打开该目录。该文件应该会在编辑器的文件浏览器中可见。或者,您可以在终端窗口中输入 ls -a 来列出当前工作目录中的文件。
结论
API 提供了一个从数据库流向应用程序的接口。通过这种方式,它们是现代应用程序的支柱。使用 GraphQL,我们可以快速开发现代化、可扩展的基于 API 的应用程序。在下一章中,我们将通过使用 Node.js 和 Express 构建一个 Web 服务器来开始我们的 API 开发。
第三章:使用 Node 和 Express 构建 Web 应用程序
在实现我们的 API 之前,我们将构建一个基本的服务器端 Web 应用程序,作为我们 API 后端的基础。我们将使用Express.js 框架,一个“Node.js 的极简主义 Web 框架”,这意味着它不会预装很多功能,但是高度可配置。我们将使用 Express.js 作为我们 API 服务器的基础,但 Express 也可以用于构建功能完整的服务器端 Web 应用程序。
用户界面,如网站和移动应用程序,在需要访问数据时与 Web 服务器进行通信。这些数据可以是任何东西,从在 Web 浏览器中呈现页面所需的 HTML,到用户搜索结果。客户端界面使用 HTTP 与服务器通信。数据请求通过 HTTP 从客户端发送到运行在服务器上的 Web 应用程序。然后,Web 应用程序处理请求并再次通过 HTTP 将数据返回给客户端。
在本章中,我们将构建一个小型的服务器端 Web 应用程序,这将是我们 API 的基础。为此,我们将使用 Express.js 框架构建一个简单的 Web 应用程序,发送一个基本的请求。
Hello World
现在您已经了解了服务器端 Web 应用程序的基础知识,让我们开始吧。在我们 API 项目的src目录中,创建一个名为index.js的文件,并添加以下内容:
const express = require('express');
const app = express();
app.get('/', (req, res) => res.send('Hello World'));
app.listen(4000, () => console.log('Listening on port 4000!'));
在这个例子中,首先我们需要引入express依赖并创建app对象,使用导入的 Express.js 模块。然后,我们使用app对象的get方法来指示我们的应用程序,在用户访问根 URL(/)时发送“Hello World”的响应。最后,我们指示应用程序在 4000 端口上运行。这样可以让我们在本地通过 URL http://localhost:4000 查看应用程序。
现在要运行该应用程序,在您的终端中键入 node src/index.js。完成后,您应该在终端中看到一个日志,内容为Listening on port 4000!。如果是这样,您应该能够在浏览器窗口中打开 http://localhost:4000 并在图 3-1 中看到结果。

图 3-1. 在浏览器中查看我们 Hello World 服务器代码的结果
Nodemon
现在,假设这个例子的输出不能完全表达我们的兴奋之情。我们希望改变我们的代码,以便在我们的响应中添加一个感叹号。继续操作,将res.send的值更改为Hello World!!!。完整的行应如下所示:
app.get('/', (req, res) => res.send('Hello World!!!'));
如果您转到 Web 浏览器并刷新页面,您会注意到输出没有改变。这是因为我们对 Web 服务器所做的任何更改都需要重新启动它。要这样做,请切换回终端并按 Ctrl + C 停止服务器。现在通过再次键入node index.js来重新启动它。现在,当您导航回浏览器并刷新页面时,您应该会看到更新后的响应。
正如您可以想象的那样,每次更改都要停止和重新启动服务器会很快变得乏味。幸运的是,我们可以使用 Node 包nodemon来在更改时自动重新启动服务器。如果您查看项目的package.json文件,您将看到scripts对象中有一个dev命令,该命令指示nodemon监视我们的index.js文件:
"scripts": {
...
"dev": "nodemon src/index.js"
...
}
package.json 脚本
scripts对象中还有几个其他辅助命令。我们将在未来的章节中探讨这些命令。
现在,要从终端启动应用程序,请输入:
npm run dev
在浏览器中导航并刷新页面,您会发现一切都像以前一样工作。为了确认nodemon自动重启服务器,让我们再次更新我们的res.send值,使其如下所示:
res.send('Hello Web Server!!!')
现在,您应该能够在浏览器中刷新页面并查看更新内容,而无需手动重新启动服务器。
扩展端口选项
目前我们的应用程序在 4000 端口上提供服务。这对于本地开发非常好,但在部署应用程序时,我们将需要灵活设置到其他端口号。现在让我们采取步骤来更新这个。我们将首先添加一个port变量:
const port = process.env.PORT || 4000;
这个更改将允许我们在 Node 环境中动态设置端口,但当未指定端口时,则回退到 4000 端口。现在让我们调整我们的app.listen代码以适应这个更改,并使用模板文字来记录正确的端口:
app.listen(port, () =>
console.log(`Server running at http://localhost:${port}`)
);
我们最终的代码现在应该是:
const express = require('express');
const app = express();
const port = process.env.PORT || 4000;
app.get('/', (req, res) => res.send('Hello World!!!'));
app.listen(port, () =>
console.log(`Server running at http://localhost:${port}`)
);
现在,我们的 Web 服务器代码基础已经运行起来了。为了验证一切是否正常工作,请确保控制台中没有错误,并在http://localhost:4000处重新加载您的 Web 浏览器。
结论
服务器端 Web 应用程序是 API 开发的基础。在本章中,我们使用 Express.js 框架构建了一个基本的 Web 应用程序。在开发基于 Node 的 Web 应用程序时,您可以选择多种框架和工具。由于其灵活性、社区支持和作为项目的成熟度,Express.js 是一个很好的选择。在下一章中,我们将把我们的 Web 应用程序转变为 API。
第四章:我们的第一个 GraphQL API
据推测,如果你在阅读这篇文章,你是一个人类。作为一个人类,你有许多兴趣和激情。你还有家庭成员、朋友、熟人、同学和同事。这些人也有他们自己的社交关系、兴趣和激情。有些关系和兴趣是重叠的,而有些则不是。总体来说,我们每个人都有一个连接的生活人物图。
这些类型的互连数据正是 GraphQL 最初在 API 开发中要解决的挑战。通过编写一个 GraphQL API,我们能够有效地连接数据,从而减少请求的复杂性和数量,同时允许我们向客户端提供他们确切需要的数据。
这听起来是否有点过度投入于一个笔记应用程序?也许是这样,但正如您将看到的,GraphQL JavaScript 生态系统提供的工具和技术既能够实现,也能够简化所有类型的 API 开发。
在本章中,我们将使用apollo-server-express包构建一个 GraphQL API。为此,我们将探讨基本的 GraphQL 主题,编写 GraphQL 模式,开发解析我们模式函数的代码,并使用 GraphQL Playground 用户界面访问我们的 API。
将我们的服务器转变为一个 API(有点像)
让我们通过使用apollo-server-express包将我们的 Express 服务器转变为一个 GraphQL 服务器来开始我们的 API 开发。Apollo Server 是一个开源的 GraphQL 服务器库,可与大量 Node.js 服务器框架一起使用,包括 Express、Connect、Hapi 和 Koa。它使我们能够从 Node.js 应用程序中作为 GraphQL API 提供数据,并提供诸如 GraphQL Playground 这样的有用工具,用于在开发中使用我们的 API 的可视化帮助工具。
要编写我们的 API,我们将修改我们在上一章中编写的 Web 应用程序代码。让我们首先包含apollo-server-express包。将以下内容添加到您的src/index.js文件的顶部:
const { ApolloServer, gql } = require('apollo-server-express');
现在我们已经导入了apollo-server,我们将设置一个基本的 GraphQL 应用程序。GraphQL 应用程序由两个主要组件组成:类型定义的模式和解析器,解析执行针对数据的查询和变异。如果这一切听起来像胡言乱语,没关系。我们将实现一个“Hello World”API 响应,并在整个 API 开发过程中进一步探讨这些 GraphQL 主题。
要开始,让我们构建一个基本的模式,我们将存储在名为typeDefs的变量中。这个模式将描述一个名为hello的单个Query,它将返回一个字符串:
// Construct a schema, using GraphQL schema language
const typeDefs = gql`
type Query {
hello: String
}
`;
现在我们已经设置好了我们的模式,我们可以添加一个解析器,以向用户返回一个值。这将是一个简单的函数,返回字符串“Hello world!”:
// Provide resolver functions for our schema fields
const resolvers = {
Query: {
hello: () => 'Hello world!'
}
};
最后,我们将集成 Apollo Server 来提供我们的 GraphQL API。为此,我们将添加一些 Apollo Server 特定的设置和中间件,并更新我们的app.listen代码:
// Apollo Server setup
const server = new ApolloServer({ typeDefs, resolvers });
// Apply the Apollo GraphQL middleware and set the path to /api
server.applyMiddleware({ app, path: '/api' });
app.listen({ port }, () =>
console.log(
`GraphQL Server running at http://localhost:${port}${server.graphqlPath}`
)
);
把所有内容放在一起,我们的 src/index.js 文件现在应该如下所示:
const express = require('express');
const { ApolloServer, gql } = require('apollo-server-express');
// Run the server on a port specified in our .env file or port 4000
const port = process.env.PORT || 4000;
// Construct a schema, using GraphQL's schema language
const typeDefs = gql`
type Query {
hello: String
}
`;
// Provide resolver functions for our schema fields
const resolvers = {
Query: {
hello: () => 'Hello world!'
}
};
const app = express();
// Apollo Server setup
const server = new ApolloServer({ typeDefs, resolvers });
// Apply the Apollo GraphQL middleware and set the path to /api
server.applyMiddleware({ app, path: '/api' });
app.listen({ port }, () =>
console.log(
`GraphQL Server running at http://localhost:${port}${server.graphqlPath}`
)
);
如果您保留了 nodemon 进程正在运行,您可以直接转到您的浏览器;否则,您必须在终端应用程序中键入 npm run dev 来启动服务器。然后访问 http://localhost:4000/api,您将会看到 GraphQL Playground(见图 4-1)。这个与 Apollo Server 捆绑在一起的 Web 应用程序是使用 GraphQL 的一个巨大好处。从这里,您可以运行 GraphQL 查询和变异,并查看结果。您还可以点击“Schema”选项卡访问自动生成的 API 文档。

图 4-1. GraphQL Playground
注意
GraphQL Playground 具有深色默认语法主题。在本书中,我将使用“亮”主题,因为它具有更高的对比度。这可以在 GraphQL Playground 的设置中配置,通过点击齿轮图标即可访问设置。
现在我们可以针对我们的 GraphQL API 编写查询。为此,请在 GraphQL Playground 中键入以下内容:
query {
hello
}
当您点击“运行”按钮时,查询应返回以下结果(见图 4-2):
{
"data": {
"hello": "Hello world!"
}
}

图 4-2. hello 查询
就是这样!现在我们有了一个可以通过 GraphQL Playground 访问的工作中的 GraphQL API。我们的 API 接受一个名为hello的查询并返回字符串Hello world!。更重要的是,我们现在已经有了建立全功能 API 的结构。
GraphQL 基础知识
在前一节中,我们立即开发了我们的第一个 API,但是让我们花几分钟回顾一下 GraphQL API 的不同部分。GraphQL API 的两个主要构建块是架构和解析器。通过理解这两个组件,您可以更有效地应用它们到您的 API 设计和开发中。
架构
架构是我们数据和交互的书面表示。通过需要架构,GraphQL 强制执行了 API 的严格计划。这是因为您的 API 只能返回在架构中定义的数据并执行定义的交互。
GraphQL 架构的基本组件是对象类型。在前面的例子中,我们创建了一个名为Query的 GraphQL 对象类型,其包含一个名为hello的字段,返回一个标量类型String。GraphQL 包含五种内置标量类型:
String
使用 UTF-8 字符编码的字符串
Boolean
一个真或假的值
Int
一个 32 位整数
Float
一个浮点数值
ID
一个唯一标识符
有了这些基本组件,我们可以为 API 构建一个架构。我们首先通过定义类型来做到这一点。让我们想象我们正在为一个披萨菜单创建一个 API。这样做时,我们可以定义一个名为Pizza的 GraphQL 架构类型,如下所示:
type Pizza {
}
现在,每个披萨都有一个唯一的 ID,一个尺寸(如小、中或大)、一定数量的片数和可选的配料。Pizza架构可能看起来像这样:
type Pizza {
id: ID
size: String
slices: Int
toppings: [String]
}
在此模式中,一些字段值是必需的(如 ID、size 和 slices),而其他字段可能是可选的(如 toppings)。我们可以通过使用感叹号来表示字段必须包含一个值。让我们更新我们的模式以表示这些必需值:
type Pizza {
id: ID!
size: String!
slices: Int!
toppings: [String]
}
在这本书中,我们将编写一个基本的模式,这将使我们能够执行常见 API 中找到的绝大多数操作。如果您想探索所有 GraphQL 模式选项,我建议您阅读 GraphQL 模式文档。
解析器
我们的 GraphQL API 的第二部分是解析器(resolvers)。解析器正如它们的名称所示执行的动作一样;它们解析了 API 用户请求的数据。我们将首先在模式中定义这些解析器,然后在我们的 JavaScript 代码中实现逻辑。我们的 API 将包含两种类型的解析器:查询(queries)和变更(mutations)。
查询
查询从 API 中请求特定数据,以其所需格式呈现。在我们的虚拟比萨 API 中,我们可以编写一个查询,它将返回菜单上所有比萨的完整列表,以及另一个将返回单个比萨的详细信息。然后查询将返回一个包含 API 用户请求的数据的对象。查询永远不会修改数据,只是访问它。
变更
当我们想要修改我们的 API 中的数据时,我们使用变更(mutation)。在我们的比萨示例中,我们可以编写一个变更,用于更改给定比萨的配料,另一个允许我们调整片数。类似于查询,变更也期望返回一个对象形式的结果,通常是执行操作的最终结果。
调整我们的 API
现在您对 GraphQL 组件有了很好的理解,让我们为我们的笔记应用程序调整我们最初的 API 代码。首先,我们将编写一些代码来读取和创建笔记。
我们需要的第一件事是一点数据让我们的 API 工作。让我们创建一个“note”对象数组,这将作为我们的 API 提供的基本数据。随着项目的发展,我们将用数据库替换这个内存数据表示。目前,我们将数据存储在一个名为 notes 的变量中。数组中的每个笔记都将是一个带有三个属性 id、content 和 author 的对象。
let notes = [
{ id: '1', content: 'This is a note', author: 'Adam Scott' },
{ id: '2', content: 'This is another note', author: 'Harlow Everly' },
{ id: '3', content: 'Oh hey look, another note!', author: 'Riley Harrison' }
];
现在我们有了一些数据,我们将适应我们的 GraphQL API 使其与之一起工作。让我们首先关注我们的模式。我们的模式是 GraphQL 表示我们的数据及其交互方式。我们知道我们将有笔记,这些笔记将被查询和变更。目前,这些笔记将包含一个 ID、内容和作者字段。让我们在我们的 typeDefs GraphQL 模式中创建一个相应的笔记类型。这将表示 API 中笔记的属性:
type Note {
id: ID!
content: String!
author: String!
}
现在,让我们添加一个查询,允许我们检索所有笔记的列表。让我们更新 Query 类型以包含一个 notes 查询,它将返回笔记对象数组:
type Query {
hello: String!
notes: [Note!]!
}
现在,我们可以更新我们的解析器代码,执行返回数据数组的操作。让我们更新我们的Query代码,包含以下notes解析器,返回原始数据对象:
Query: {
hello: () => 'Hello world!',
notes: () => notes
},
如果我们现在进入运行在http://localhost:4000/api的 GraphQL playground,我们可以测试notes查询。为此,请输入以下查询:
query {
notes {
id
content
author
}
}
然后,当您点击播放按钮时,您应该看到返回的包含数据数组的data对象(图 4-3)。

图 4-3. 备注查询
要尝试 GraphQL 最酷的一个方面,我们可以移除我们请求的任何字段,比如id或author。这样做时,API 会精确返回我们请求的数据。这使得消费数据的客户端能够控制每个请求发送的数据量,并将数据限制在确切所需的范围内(图 4-4)。

图 4-4. 请求仅包含内容数据的备注查询
现在我们可以查询我们的全部备注列表,让我们编写一些代码,允许我们查询单个备注。从用户界面的角度来看,显示包含单个特定备注的视图非常有用。为此,我们将要请求具有特定id值的备注。这将要求我们在我们的 GraphQL 模式中使用一个参数。参数允许 API 消费者向解析器函数传递特定值,提供解析所需的必要信息。让我们添加一个note查询,它将以id为参数,类型为ID。我们将更新我们的typeDefs中的Query对象如下,包括新的note查询:
type Query {
hello: String
notes: [Note!]!
note(id: ID!): Note!
}
更新了我们的模式后,我们可以编写一个查询解析器来返回所请求的备注。为此,我们需要能够读取 API 用户的参数值。幸运的是,Apollo Server 将以下有用的参数传递给我们的解析器函数:
parent
父查询的结果,在嵌套查询时非常有用。
args
这些是用户在查询中传递的参数。
context
从服务器应用程序传递到解析器函数的信息。这可能包括当前用户或数据库信息等内容。
info
查询本身的信息。
在我们的代码中根据需要进一步探索这些内容。如果你好奇,你可以在Apollo Server 文档中了解更多关于这些参数的信息。目前,我们只需要第二个参数args中包含的信息。
note查询将接受备注id作为参数,并在我们的note对象数组中查找它。将以下内容添加到查询解析器代码中:
note: (parent, args) => {
return notes.find(note => note.id === args.id);
}
现在解析器代码应如下所示:
const resolvers = {
Query: {
hello: () => 'Hello world!',
notes: () => notes,
note: (parent, args) => {
return notes.find(note => note.id === args.id);
}
}
};
要运行我们的查询,让我们回到我们的网页浏览器,并访问 GraphQL Playground,网址是 http://localhost:4000/api。我们现在可以查询具有特定 id 的笔记,如下所示:
query {
note(id: "1") {
id
content
author
}
}
当您运行此查询时,您应该收到具有请求的 id 值的笔记结果。如果尝试查询不存在的笔记,则应收到值为 null 的结果。要测试这一点,请尝试更改 id 值以返回不同的结果。
让我们通过引入使用 GraphQL 变更操作创建新笔记的能力来完成我们的初始 API 代码。在该变更操作中,用户将传递笔记的内容。目前,我们将硬编码笔记的作者。让我们从更新我们的 typeDefs 模式开始,添加一个名为 newNote 的 Mutation 类型:
type Mutation {
newNote(content: String!): Note!
}
现在我们将编写一个变更解析器,它将笔记内容作为参数接收,将笔记存储为一个对象,并将其添加到我们的 notes 数组中。为此,我们将在解析器中添加一个名为 Mutation 的对象。在 Mutation 对象中,我们将添加一个名为 newNote 的函数,带有 parent 和 args 参数。在这个函数中,我们将获取 content 参数,并创建一个包含 id、content 和 author 键的对象。正如你所注意到的,这与笔记的当前模式匹配。然后,我们将这个对象推送到我们的 notes 数组中,并返回这个对象。返回对象允许 GraphQL 变更以预期格式接收响应。请继续按以下方式编写此代码:
Mutation: {
newNote: (parent, args) => {
let noteValue = {
id: String(notes.length + 1),
content: args.content,
author: 'Adam Scott'
};
notes.push(noteValue);
return noteValue;
}
}
我们的 src/index.js 文件现在如下所示:
const express = require('express');
const { ApolloServer, gql } = require('apollo-server-express');
// Run our server on a port specified in our .env file or port 4000
const port = process.env.PORT || 4000;
let notes = [
{ id: '1', content: 'This is a note', author: 'Adam Scott' },
{ id: '2', content: 'This is another note', author: 'Harlow Everly' },
{ id: '3', content: 'Oh hey look, another note!', author: 'Riley Harrison' }
];
// Construct a schema, using GraphQL's schema language
const typeDefs = gql`
type Note {
id: ID!
content: String!
author: String!
}
type Query {
hello: String
notes: [Note!]!
note(id: ID!): Note!
}
type Mutation {
newNote(content: String!): Note!
}
`;
// Provide resolver functions for our schema fields
const resolvers = {
Query: {
hello: () => 'Hello world!',
notes: () => notes,
note: (parent, args) => {
return notes.find(note => note.id === args.id);
}
},
Mutation: {
newNote: (parent, args) => {
let noteValue = {
id: String(notes.length + 1),
content: args.content,
author: 'Adam Scott'
};
notes.push(noteValue);
return noteValue;
}
}
};
const app = express();
// Apollo Server setup
const server = new ApolloServer({ typeDefs, resolvers });
// Apply the Apollo GraphQL middleware and set the path to /api
server.applyMiddleware({ app, path: '/api' });
app.listen({ port }, () =>
console.log(
`GraphQL Server running at http://localhost:${port}${server.graphqlPath}`
)
);
随着模式和解析器的更新以接受变更操作,让我们在 GraphQL Playground 中试验一下,网址是 http://localhost:4000/api。在 playground 中,点击 + 号以创建一个新的标签页,并按以下方式编写变更操作:
mutation {
newNote (content: "This is a mutant note!") {
content
id
author
}
}
当您点击播放按钮时,您应该收到包含我们新笔记的内容、ID 和作者的响应。您还可以通过重新运行 notes 查询来查看变更操作是否成功。要这样做,要么切换回包含该查询的 GraphQL Playground 标签页,要么输入以下内容:
query {
notes {
content
id
author
}
}
当此查询运行时,您现在应该看到包括最近添加的笔记在内的四个笔记。
数据存储
我们目前将我们的数据存储在内存中。这意味着每当我们重新启动服务器时,我们将丢失这些数据。在下一章中,我们将使用数据库来持久化我们的数据。
我们现在成功地实现了我们的查询和变更解析器,并在 GraphQL Playground 用户界面中对其进行了测试。
结论
在本章中,我们成功地构建了一个 GraphQL API,使用了 apollo-server-express 模块。我们现在可以针对一个内存数据对象运行查询和变更操作。这种设置为我们构建任何 API 提供了坚实的基础。在下一章中,我们将探讨通过使用数据库来持久化数据的能力。
第五章:数据库
当我还是个孩子时,我痴迷于收集各种类型的体育卡片。收集卡片的重要部分是组织它们。我把明星球员放在一个盒子里,篮球巨星迈克尔·乔丹的卡片则专门放在一个盒子里,我的其他卡片则按照运动类型进行组织,并按照团队进行进一步的子分类。这种组织方法使我能够安全地存储卡片,并在任何时候轻松找到我想要的卡片。我当时并不知道,但这种存储系统相当于数据库的有形等价物。在其核心,数据库允许我们存储信息并在以后检索。
当我刚开始进行 Web 开发时,我觉得数据库很吓人。我会看到运行数据库和输入晦涩 SQL 命令的说明,感觉就像是我无法理解的额外抽象层。幸运的是,最终我能够攀登这道墙,不再害怕 SQL 表连接,所以如果你和我当时一样,我希望你知道,可以探索数据库世界。
在本书中,我们将使用MongoDB作为我们的首选数据库。我选择 Mongo 是因为它在 Node.js 生态系统中非常流行,并且对于任何对该主题新手来说都是一个很好的入门数据库选择。Mongo 将我们的数据存储在“文档”中,这类似于 JavaScript 对象。这意味着我们将能够以任何 JavaScript 开发者熟悉的格式编写和检索信息。然而,如果你有一个强烈偏爱的数据库,比如 PostgreSQL,本书涵盖的主题经过一些工作后也适用于任何类型的系统。
在我们可以使用 Mongo 之前,我们需要确保 MongoDB 服务器在本地运行。这在整个开发过程中都是必需的。要做到这一点,请按照第一章中系统的说明进行操作第一章。
开始学习 MongoDB
当 Mongo 运行时,让我们探索如何直接从终端与 Mongo 交互,使用 Mongo shell。通过输入mongo命令来打开 MongoDB shell:
$ mongo
运行此命令后,您应该会在 MongoDB shell 中看到有关本地服务器连接以及一些其他信息打印到终端的信息。现在我们可以直接从终端应用程序与 MongoDB 交互。我们可以创建一个数据库,以及使用use命令切换到一个新的数据库。让我们创建一个名为learning的数据库:
$ use learning
在本章开头描述的我的卡片收藏中,我将我的卡片组织在单独的盒子中。MongoDB 也带来了相同的概念,称为 集合。集合是我们将相似文档组合在一起的方式。例如,一个博客应用程序可能有一个集合用于文章,另一个用于用户,第三个用于评论。如果我们将集合与 JavaScript 对象进行比较,它将是顶级对象,而文档则是其中的各个对象。我们可以将其可视化如下:
collection: {
document: {},
document: {},
document: {}.
...
}
有了这些信息,让我们在 learning 数据库中创建一个集合内的文档。我们将创建一个 pizza 集合,其中将存储具有披萨类型的文档。在 MongoDB shell 中输入以下内容:
$ db.pizza.save({ type: "Cheese" })
如果成功的话,我们应该看到返回的结果如下:
WriteResult({ "nInserted" : 1 })
我们也可以一次性将多个条目写入数据库:
$ db.pizza.save([{type: "Veggie"}, {type: "Olive"}])
现在我们已经向数据库写入了一些文档,让我们来检索它们。为此,我们将使用 MongoDB 的 find 方法。要查看集合中的所有文档,请运行带空参数的 find 命令:
$ db.pizza.find()
现在我们应该在数据库中看到所有三个条目。除了存储数据之外,MongoDB 还会自动为每个条目分配一个唯一的 ID。结果应该看起来像这样:
{ "_id" : ObjectId("5c7528b223ab40938c7dc536"), "type" : "Cheese" }
{ "_id" : ObjectId("5c7529fa23ab40938c7dc53e"), "type" : "Veggie" }
{ "_id" : ObjectId("5c7529fa23ab40938c7dc53f"), "type" : "Olive" }
我们还可以按属性值以及 Mongo 分配的 ID 找到单个文档:
$ db.pizza.find({ type: "Cheese" })
$ db.pizza.find({ _id: ObjectId("A DOCUMENT ID HERE") })
不仅我们想要能够找到文档,还可以更新它们也非常有用。我们可以使用 Mongo 的 update 方法来进行操作,它接受第一个参数作为要更改的文档,第二个参数是要更改的内容。让我们将我们的 Veggie 披萨更新为 Mushroom 披萨:
$ db.pizza.update({ type: "Veggie" }, { type: "Mushroom" })
现在,如果我们运行 db.pizza.find(),我们应该能看到您的文档已经更新:
{ "_id" : ObjectId("5c7528b223ab40938c7dc536"), "type" : "Cheese" }
{ "_id" : ObjectId("5c7529fa23ab40938c7dc53e"), "type" : "Mushroom" }
{ "_id" : ObjectId("5c7529fa23ab40938c7dc53f"), "type" : "Olive" }
与更新文档类似,我们也可以使用 Mongo 的 remove 方法删除一个文档。让我们从数据库中删除蘑菇披萨:
$ db.pizza.remove({ type: "Mushroom" })
现在,如果我们执行 db.pizza.find() 查询,我们将在集合中看到只有两个条目。如果我们决定不再包含任何数据,我们可以运行 remove 方法,并且不带空对象参数,这将清除我们的整个集合:
$ db.pizza.remove({})
现在我们已成功使用 MongoDB shell 创建了数据库,向集合添加了文档,更新了这些文档,并将它们删除。这些基本的数据库操作将为我们在项目中集成数据库提供坚实的基础。在开发中,我们还可以使用 MongoDB shell 访问我们的数据库。这对于调试和手动删除或更新条目等任务可能非常有帮助。
将 MongoDB 连接到我们的应用程序
现在您已经学会了一些关于使用 MongoDB shell 的知识,让我们将其连接到我们的 API 应用程序。为此,我们将使用Mongoose 对象文档映射器(ODM)。Mongoose 是一个库,通过其基于模式的建模解决方案,在 Node.js 应用程序中简化了与 MongoDB 的工作,通过减少和简化样板代码。是的,您没看错——又是一个模式!一旦我们定义了数据库模式,通过 Mongoose 在 MongoDB 上的操作类似于我们在 Mongo shell 中编写的命令类型。
我们首先需要更新我们的.env文件,其中包含我们本地数据库的 URL。这将允许我们在我们工作的任何环境(例如本地开发和生产)中设置数据库 URL。本地 MongoDB 服务器的默认 URL 是mongodb://localhost:27017,我们将在此 URL 后添加我们数据库的名称。因此,在我们的.env文件中,我们将设置一个DB_HOST变量,并设置 Mongo 数据库实例的 URL 如下:
DB_HOST=mongodb://localhost:27017/notedly
在我们的应用程序中与数据库一起工作的下一步是连接它。让我们编写一些代码,在启动时将我们的应用程序连接到我们的数据库。为此,我们将首先在src目录中创建一个名为db.js的新文件。在db.js中,我们将编写我们的数据库连接代码。我们还将包括一个用于close数据库连接的函数,这在测试应用程序时将非常有用。
在src/db.js中,输入以下内容:
// Require the mongoose library
const mongoose = require('mongoose');
module.exports = {
connect: DB_HOST => {
// Use the Mongo driver's updated URL string parser
mongoose.set('useNewUrlParser', true);
// Use findOneAndUpdate() in place of findAndModify()
mongoose.set('useFindAndModify', false);
// Use createIndex() in place of ensureIndex()
mongoose.set('useCreateIndex', true);
// Use the new server discovery and monitoring engine
mongoose.set('useUnifiedTopology', true);
// Connect to the DB
mongoose.connect(DB_HOST);
// Log an error if we fail to connect
mongoose.connection.on('error', err => {
console.error(err);
console.log(
'MongoDB connection error. Please make sure MongoDB is running.'
);
process.exit();
});
},
close: () => {
mongoose.connection.close();
}
};
现在我们将更新我们的src/index.js来调用这个连接。为此,我们将首先导入我们的.env配置以及db.js文件。在文件顶部的导入中,添加这些导入:
require('dotenv').config();
const db = require('./db');
我喜欢将在.env文件中定义的DB_HOST值作为一个变量进行存储。直接在port变量定义下面添加这个变量。
const DB_HOST = process.env.DB_HOST;
接下来,我们可以通过将以下内容添加到src/index.js文件来调用我们的连接:
db.connect(DB_HOST);
src/index.js文件现在将如下所示:
const express = require('express');
const { ApolloServer, gql } = require('apollo-server-express');
require('dotenv').config();
const db = require('./db');
// Run the server on a port specified in our .env file or port 4000
const port = process.env.PORT || 4000;
// Store the DB_HOST value as a variable
const DB_HOST = process.env.DB_HOST;
let notes = [
{
id: '1',
content: 'This is a note',
author: 'Adam Scott'
},
{
id: '2',
content: 'This is another note',
author: 'Harlow Everly'
},
{
id: '3',
content: 'Oh hey look, another note!',
author: 'Riley Harrison'
}
];
// Construct a schema, using GraphQL's schema language
const typeDefs = gql`
type Note {
id: ID
content: String
author: String
}
type Query {
hello: String
notes: [Note]
note(id: ID): Note
}
type Mutation {
newNote(content: String!): Note
}
`;
// Provide resolver functions for our schema fields
const resolvers = {
Query: {
hello: () => 'Hello world!',
notes: () => notes,
note: (parent, args) => {
return notes.find(note => note.id === args.id);
}
},
Mutation: {
newNote: (parent, args) => {
let noteValue = {
id: notes.length + 1,
content: args.content,
author: 'Adam Scott'
};
notes.push(noteValue);
return noteValue;
}
}
};
const app = express();
// Connect to the database
db.connect(DB_HOST);
// Apollo Server setup
const server = new ApolloServer({ typeDefs, resolvers });
// Apply the Apollo GraphQL middleware and set the path to /api
server.applyMiddleware({ app, path: '/api' });
app.listen({ port }, () =>
console.log(
`GraphQL Server running at http://localhost:${port}${server.graphqlPath}`
)
);
尽管实际功能没有改变,但如果您运行npm run dev,应用程序应该能够成功连接到数据库并且没有错误。
从我们的应用程序中读取和写入数据
现在我们可以连接到我们的数据库了,让我们编写必要的代码来从应用程序内部读取和写入数据。Mongoose 允许我们定义数据将如何存储在我们的数据库中,作为一个 JavaScript 对象,并且我们可以存储和操作符合该模型结构的数据。有了这个想法,让我们创建我们的对象,称为一个 Mongoose 模式。
首先,在我们的src目录中创建一个名为models的文件夹,用于存放这个模式文件。在这个文件夹中,创建一个名为note.js的文件。在src/models/note.js中,我们将从定义文件的基本设置开始:
// Require the mongoose library
const mongoose = require('mongoose');
// Define the note's database schema
const noteSchema = new mongoose.Schema();
// Define the 'Note' model with the schema
const Note = mongoose.model('Note', noteSchema);
// Export the module
module.exports = Note;
接下来,我们将定义我们的模式,在noteSchema变量内。类似于内存数据示例,我们当前的模式将包括笔记内容以及表示作者的硬编码字符串。我们还将包括选项来包含我们笔记的时间戳,这些时间戳将在笔记创建或编辑时自动存储。随着进展,我们将添加功能到我们的笔记模式中。
我们的 Mongoose 模式将被结构化如下:
// Define the note's database schema
const noteSchema = new mongoose.Schema(
{
content: {
type: String,
required: true
},
author: {
type: String,
required: true
}
},
{
// Assigns createdAt and updatedAt fields with a Date type
timestamps: true
}
);
数据永久性
在开发过程中,我们将更新和修改我们的数据模型,有时会从数据库中删除所有数据。因此,我不建议使用此 API 存储重要的东西,如课堂笔记、您朋友的生日清单或您最喜欢的比萨店的地址。
我们的整体 src/models/note.js 文件现在应该如下所示:
// Require the mongoose library
const mongoose = require('mongoose');
// Define the note's database schema
const noteSchema = new mongoose.Schema(
{
content: {
type: String,
required: true
},
author: {
type: String,
required: true
}
},
{
// Assigns createdAt and updatedAt fields with a Date type
timestamps: true
}
);
// Define the 'Note' model with the schema
const Note = mongoose.model('Note', noteSchema);
// Export the module
module.exports = Note;
为了简化将我们的模型导入到我们的 Apollo Server Express 应用程序中,我们将在 src/models 目录下添加一个 index.js 文件。这将把我们的模型组合成一个单独的 JavaScript 模块。虽然这并不是严格必要的,但在应用程序和数据库模型增长时,我发现这是一个很好的模式。在 src/models/index.js 中,我们将导入我们的笔记模型,并将其添加到一个models对象中以便导出:
const Note = require('./note');
const models = {
Note
};
module.exports = models;
现在,我们可以通过将我们的模型导入到 src/index.js 文件中,将数据库模型整合到我们的 Apollo Server Express 应用程序代码中:
const models = require('./models');
当我们的数据库模型代码导入后,我们可以调整我们的解析器以保存和从数据库中读取数据,而不是使用内存变量。为此,我们将重写notes查询以通过使用 MongoDB 的find方法从数据库中获取笔记:
notes: async () => {
return await models.Note.find();
},
在我们的服务器运行后,我们可以在浏览器中访问 GraphQL Playground 并运行我们的notes查询:
query {
notes {
content
id
author
}
}
预期结果将是一个空数组,因为我们尚未向数据库中添加任何数据(图 5-1):
{
"data": {
"notes": []
}
}

图 5-1. 一个笔记查询
为了更新我们的newNote变异以向我们的数据库添加一个笔记,我们将使用我们的 MongoDB 模型的create方法,该方法将接受一个对象。目前,我们将继续硬编码作者的名称:
newNote: async (parent, args) => {
return await models.Note.create({
content: args.content,
author: 'Adam Scott'
});
}
现在,我们可以访问 GraphQL Playground 并编写一个变异来将笔记添加到我们的数据库中:
mutation {
newNote (content: "This is a note in our database!") {
content
author
id
}
}
我们的变异将返回一个新的笔记,其中包含我们放置在参数中的内容,作者的名称,以及 MongoDB 生成的 ID(图 5-2)。

图 5-2. 变异在数据库中创建一个新的笔记
如果我们现在重新运行我们的notes查询,我们应该看到我们从数据库中检索到的笔记!(见 图 5-3。)

图 5-3. 我们的笔记查询从数据库返回的数据
最后一步是重写我们的notes查询,以从我们的数据库中检索特定笔记,使用 MongoDB 分配给每个条目的唯一 ID。为此,我们将使用 Mongoose 的findbyId方法:
note: async (parent, args) => {
return await models.Note.findById(args.id);
}
现在我们可以写一个查询,使用我们在notes查询或newNote变更中看到的唯一 ID,以从我们的数据库中检索单个笔记。为此,我们将编写一个带有id参数的note查询(图 5-4):
query {
note(id: "5c7bff794d66461e1e970ed3") {
id
content
author
}
}
您的笔记 ID
在前面示例中使用的 ID 是我本地数据库特有的。请确保从您自己的查询或变更结果中复制一个 ID。

图 5-4. 查询单个笔记
我们最终的src/index.js文件将如下所示:
const express = require('express');
const { ApolloServer, gql } = require('apollo-server-express');
require('dotenv').config();
const db = require('./db');
const models = require('./models');
// Run our server on a port specified in our .env file or port 4000
const port = process.env.PORT || 4000;
const DB_HOST = process.env.DB_HOST;
// Construct a schema, using GraphQL's schema language
const typeDefs = gql`
type Note {
id: ID
content: String
author: String
}
type Query {
hello: String
notes: [Note]
note(id: ID): Note
}
type Mutation {
newNote(content: String!): Note
}
`;
// Provide resolver functions for our schema fields
const resolvers = {
Query: {
hello: () => 'Hello world!',
notes: async () => {
return await models.Note.find();
},
note: async (parent, args) => {
return await models.Note.findById(args.id);
}
},
Mutation: {
newNote: async (parent, args) => {
return await models.Note.create({
content: args.content,
author: 'Adam Scott'
});
}
}
};
const app = express();
db.connect(DB_HOST);
// Apollo Server setup
const server = new ApolloServer({ typeDefs, resolvers });
// Apply the Apollo GraphQL middleware and set the path to /api
server.applyMiddleware({ app, path: '/api' });
app.listen({ port }, () =>
console.log(
`GraphQL Server running at http://localhost:${port}${server.graphqlPath}`
)
);
现在我们可以通过我们的 GraphQL API 从数据库中读取和写入数据!尝试添加更多笔记,使用notes查询查看完整的笔记列表,并通过使用note查询查看单个笔记的内容。
结论
在本章中,您学会了如何使用 MongoDB 和 Mongoose 库与我们的 API 配合使用。数据库(例如 MongoDB)允许我们安全地存储和检索应用程序的数据。对象建模库(例如 Mongoose)通过提供数据库查询和数据验证工具,简化了与数据库的工作。在下一章中,我们将更新我们的 API,使其具有与数据库内容的完整 CRUD(创建、读取、更新和删除)功能。
第六章:CRUD 操作
我第一次听到“CRUD 应用程序”这个术语时,错误地认为它指的是一个做一些肮脏或诡计的应用程序。诚然,“CRUD”听起来好像指的是可以从鞋底上刮掉的东西。事实上,这个首字母缩略词最早在 20 世纪 80 年代初由英国技术作家詹姆斯·马丁(James Martin)流行起来,用于描述创建、读取、更新和删除数据的应用程序。尽管这个术语已经存在了超过四分之一个世纪,但今天仍然适用于许多开发的应用程序。想想你每天使用的应用程序 - 待办事项列表、电子表格、内容管理系统、文本编辑器、社交媒体网站等等 - 很有可能其中许多都属于 CRUD 应用程序的格式。用户创建一些数据,访问或读取数据,可能会更新或删除该数据。
我们的 Notedly 应用程序将遵循 CRUD 模式。用户将能够创建、读取、更新和删除他们自己的笔记。在本章中,我们将通过连接我们的解析器和数据库来实现 API 的基本 CRUD 功能。
分离我们的 GraphQL 模式和解析器
目前我们的src/index.js文件包含了我们的 Express/Apollo 服务器代码以及我们 API 的模式和解析器。可以想象,随着代码库的增长,这可能会变得有些笨重。在发生这种情况之前,让我们花点时间进行一些小的重构,将我们的模式、解析器和服务器代码分离开来。
首先,让我们将我们的 GraphQL 模式移到单独的文件中。首先,在src文件夹中创建一个名为src/schema.js的新文件,然后将我们的模式内容移动到该文件中,该内容在我们的typeDefs变量中找到。为此,我们还需要导入apollo-server-express包中提供的gql模式语言,并将我们的模式作为模块导出,使用 Node 的module.exports方法。顺便说一句,我们还可以删除hello查询,因为在我们的最终应用程序中不需要它:
const { gql } = require('apollo-server-express');
module.exports = gql`
type Note {
id: ID!
content: String!
author: String!
}
type Query {
notes: [Note!]!
note(id: ID!): Note!
}
type Mutation {
newNote(content: String!): Note!
}
`;
现在我们可以更新我们的src/index.js文件,使用这个外部模式文件来导入它,并删除apollo-server-express中的gql导入,如下所示:
const { ApolloServer } = require('apollo-server-express');
const typeDefs = require('./schema');
现在我们已经将我们的 GraphQL 模式隔离到自己的文件中,让我们为我们的 GraphQL 解析器代码做类似的事情。我们的解析器代码将涵盖大多数 API 的逻辑,所以我们首先将创建一个名为resolvers的文件夹来存放这些代码,在src/resolvers目录中,我们将从三个文件开始:src/resolvers/index.js、src/resolvers/query.js和src/resolvers/mutation.js。与我们在数据库模型中遵循的模式类似,src/resolvers/index.js文件将用于将我们的解析器代码导入单个导出模块中。请按照以下方式设置此文件:
const Query = require('./query');
const Mutation = require('./mutation');
module.exports = {
Query,
Mutation
};
现在你可以为 API 查询代码设置src/resolvers/query.js:
module.exports = {
notes: async () => {
return await models.Note.find()
},
note: async (parent, args) => {
return await models.Note.findById(args.id);
}
}
然后将变异代码移动到src/resolvers/mutation.js文件中:
module.exports = {
newNote: async (parent, args) => {
return await models.Note.create({
content: args.content,
author: 'Adam Scott'
});
}
}
接下来,服务器通过在src/index.js文件中添加以下行来导入解析器代码:
const resolvers = require('./resolvers');
重构解析器的最后一步是将它们连接到我们的数据库模型。正如您可能已经注意到的,我们的解析器模块引用这些模型,但无法访问它们。为了解决这个问题,我们将使用 Apollo Server 称为上下文的概念,它允许我们从服务器代码传递特定信息到每个请求的单个解析器。暂时来看,这可能有些多余,但在将用户身份验证整合到我们的应用程序中时会非常有用。为此,我们将在src/index.js中更新我们的 Apollo Server 设置代码,添加一个context函数,该函数将返回我们的数据库模型:
// Apollo Server setup
const server = new ApolloServer({
typeDefs,
resolvers,
context: () => {
// Add the db models to the context
return { models };
}
});
现在我们将更新每个解析器,以便利用这个上下文,通过在每个函数的第三个参数中添加{ models }。
在src/resolvers/query.js中执行以下操作:
module.exports = {
notes: async (parent, args, { models }) => {
return await models.Note.find()
},
note: async (parent, args, { models }) => {
return await models.Note.findById(args.id);
}
}
将变更代码移至src/resolvers/mutation.js文件:
module.exports = {
newNote: async (parent, args, { models }) => {
return await models.Note.create({
content: args.content,
author: 'Adam Scott'
});
}
}
现在,我们的src/index.js文件将简化如下:
const express = require('express');
const { ApolloServer } = require('apollo-server-express');
require('dotenv').config();
// Local module imports
const db = require('./db');
const models = require('./models');
const typeDefs = require('./schema');
const resolvers = require('./resolvers');
// Run our server on a port specified in our .env file or port 4000
const port = process.env.PORT || 4000;
const DB_HOST = process.env.DB_HOST;
const app = express();
db.connect(DB_HOST);
// Apollo Server setup
const server = new ApolloServer({
typeDefs,
resolvers,
context: () => {
// Add the db models to the context
return { models };
}
});
// Apply the Apollo GraphQL middleware and set the path to /api
server.applyMiddleware({ app, path: '/api' });
app.listen({ port }, () =>
console.log(
`GraphQL Server running at http://localhost:${port}${server.graphqlPath}`
)
);
编写我们的 GraphQL CRUD 架构
现在我们已经为灵活性重构了我们的代码,让我们开始实施我们的 CRUD 操作。我们已经能够创建和读取笔记,现在剩下的是实现我们的更新和删除功能。首先,我们将更新我们的架构。
由于更新和删除操作将更改我们的数据,它们将成为变更操作。我们的更新笔记将需要一个 ID 参数来定位笔记以及新的笔记内容。更新查询然后将返回新更新的笔记。对于我们的删除操作,我们的 API 将返回一个布尔值true,以通知我们笔记删除成功。
更新src/schema.js中的Mutation架构如下:
type Mutation {
newNote(content: String!): Note!
updateNote(id: ID!, content: String!): Note!
deleteNote(id: ID!): Boolean!
}
通过这些添加,我们的架构现在可以执行 CRUD 操作。
CRUD 解析器
有了我们的架构,我们现在可以更新我们的解析器来删除或更新笔记。让我们从我们的deleteNote变更操作开始。要删除一个笔记,我们将使用 Mongoose 的findOneAndRemove方法,并传递我们想要删除的项目的id。如果找到并删除了我们的项目,我们将向客户端返回true,但如果删除失败,我们将返回false。
在src/resolvers/mutation.js中,添加以下内容,位于module.exports对象内部:
deleteNote: async (parent, { id }, { models }) => {
try {
await models.Note.findOneAndRemove({ _id: id});
return true;
} catch (err) {
return false;
}
},
现在我们可以在 GraphQL Playground 中运行我们的变更操作。在 Playground 的新标签页中,编写以下变更操作,确保使用数据库中某个笔记的 ID:
mutation {
deleteNote(id: "5c7d1aacd960e03928804308")
}
如果笔记成功删除,您应该会收到true的响应:
{
"data": {
"deleteNote": true
}
}
如果您传递一个不存在的 ID,您将收到一个"deleteNote": false的响应。
有了我们的删除功能,让我们编写我们的 updateNote 变异。为此,我们将使用 Mongoose 的 findOneAndUpdate 方法。此方法将采用查询的初始参数,在数据库中查找正确的笔记,然后是第二个参数,其中我们将 $set 新的笔记内容。最后,我们将传递 new: true 的第三个参数,这指示数据库将更新后的笔记内容返回给我们。
在 src/resolvers/mutation.js 中,在 module.exports 对象中添加以下内容:
updateNote: async (parent, { content, id }, { models }) => {
return await models.Note.findOneAndUpdate(
{
_id: id,
},
{
$set: {
content
}
},
{
new: true
}
);
},
现在我们可以访问浏览器中的 GraphQL Playground 来尝试我们的 updateNote 变异。在播放器中的新标签页中,编写一个具有 id 和 content 参数的变异:
mutation {
updateNote(
id: "5c7d1f0a31191c4413edba9d",
content: "This is an updated note!"
){
id
content
}
}
如果我们的变异按预期工作,GraphQL 响应应该如下所示:
{
"data": {
"updateNote": {
"id": "5c7d1f0a31191c4413edba9d",
"content": "This is an updated note!"
}
}
}
如果我们传递了错误的 ID,响应将失败,并且我们将收到一个带有 Error updating note 消息的内部服务器错误。
现在我们能够创建、读取、更新和删除笔记。通过这一步骤,我们在 API 中拥有了完整的 CRUD 功能。
日期和时间
在创建数据库模式时,我们请求 Mongoose 自动存储时间戳,记录数据库中条目的创建和更新时间。这些信息在我们的应用程序中非常有用,因为它们允许我们在用户界面中显示笔记的创建或最后编辑时间。让我们将 createdAt 和 updatedAt 字段添加到我们的模式中,以便我们可以返回这些值。
您可能记得 GraphQL 允许默认类型为 String、Boolean、Int、Float 和 ID。不幸的是,GraphQL 不带有内置的日期标量类型。我们可以使用 String 类型,但这意味着我们无法利用 GraphQL 提供的类型验证,以确保我们的日期和时间确实是日期和时间。相反,我们将创建一个自定义标量类型。自定义类型允许我们定义一个新类型,并针对请求该类型数据的每个查询和变异进行验证。
让我们通过在 src/schema.js 中的 GraphQL 字符串文字的顶部添加自定义标量来更新我们的 GraphQL 模式:
module.exports = gql`
scalar DateTime
...
`;
现在,在 Note 类型中添加 createdAt 和 updatedAt 字段:
type Note {
id: ID!
content: String!
author: String!
createdAt: DateTime!
updatedAt: DateTime!
}
最后一步是验证这种新类型。虽然我们可以编写自己的验证,但对于我们的用例,我们将使用graphql-iso-date 包。为此,我们将在任何请求具有 DateTime 类型的值的解析器函数中添加验证。
在 src/resolvers/index.js 文件中,导入包并将 DateTime 值添加到导出的解析器中,如下所示:
const Query = require('./query');
const Mutation = require('./mutation');
const { GraphQLDateTime } = require('graphql-iso-date');
module.exports = {
Query,
Mutation,
DateTime: GraphQLDateTime
};
现在,如果我们在浏览器中访问 GraphQL Playground 并刷新页面,我们可以验证我们的自定义类型是否按预期工作。如果查看我们的模式,我们可以看到 createdAt 和 updatedAt 字段的类型是 DateTime。如图 6-1 所示,此类型的文档说明其为“UTC 时间的日期时间字符串”。

图 6-1. 我们的模式现在具有 DateTime 类型
要测试这个功能,让我们在 GraphQL Playground 中编写一个newNote变异,其中包括我们的日期字段:
mutation {
newNote (content: "This is a note with a custom type!") {
content
author
id
createdAt
updatedAt
}
}
这将返回createdAt和updatedAt值作为 ISO 格式的日期。如果我们然后对同一条笔记运行updateNote变异,我们将看到一个updatedAt值,它与createdAt日期不同。
有关定义和验证自定义标量类型的更多信息,我建议查阅 Apollo Server 的“自定义标量和枚举”文档。
结论
在本章中,我们为我们的 API 添加了创建、读取、更新和删除(CRUD)功能。CRUD 应用程序是许多应用程序使用的一种非常常见的模式。我鼓励你看看你每天与之交互的应用程序,并思考它们的数据如何适合这种模式。在下一章中,我们将为我们的 API 添加功能来创建和验证用户账户。
第七章:用户账号和认证
想象自己走在一条黑暗的小巷里。你正前往加入“超酷人士秘密俱乐部”(如果你正在阅读这个,你绝对是一个值得加入的成员)。当你进入俱乐部的隐秘门时,接待员迎接你,并递给你一张表格填写。在表格上,你必须输入你的名字和一个只有你和接待员知道的密码。
填写完表格后,你将其交给接待员,接待员走向俱乐部的后房。在后房,接待员使用一个秘密钥匙加密你的密码,然后将加密后的密码存储在一个锁定的文件保险箱里。接待员然后给你盖上了一个你独特的会员 ID 的硬币。当返回前厅时,接待员把硬币递给你,你将它收藏在口袋里。现在每次你回到俱乐部,只需展示你的硬币就可以进入了。
这种交互听起来像是低成本间谍电影中的情节,但几乎与我们每次注册 Web 应用程序时遵循的过程几乎完全相同。在本章中,我们将学习如何构建 GraphQL mutations,允许用户创建账号并登录到我们的应用程序。我们还将学习如何加密用户的密码并向用户返回一个令牌,用户可以在与我们的应用程序交互时用来验证他们的身份。
应用程序认证流程
在我们开始之前,让我们退后一步,为用户注册账号和登录现有账号时将要遵循的流程进行规划。如果你还没有完全理解这里涵盖的所有概念,别担心:我们会逐步来讲解。首先,让我们回顾一下账号创建流程:
-
用户在用户界面(UI)的一个字段中输入他们的预期电子邮件、用户名和密码,例如 GraphQL Playground、Web 应用程序或移动应用程序。
-
UI 向服务器发送一个带有用户信息的 GraphQL mutation。
-
服务器加密密码并将用户信息存储在数据库中。
-
服务器将向 UI 返回一个包含用户 ID 的令牌。
-
UI 存储此令牌,一段指定的时间,并将其与每个请求一起发送到服务器以验证用户。
现在让我们看看用户登录流程:
-
用户在 UI 的一个字段中输入他们的电子邮件或用户名和密码。
-
UI 向服务器发送一个带有此信息的 GraphQL mutation。
-
服务器解密存储在数据库中的密码并将其与用户输入的密码进行比较。
-
如果密码匹配,服务器将向 UI 返回一个包含用户 ID 的令牌。
-
UI 存储此令牌,一段指定的时间,并将其与每个请求一起发送到服务器。
正如你所见,这些流程与我们的“秘密俱乐部”流程非常相似。在本章中,我们将专注于实现这些交互的 API 部分。
密码重置流程
您会注意到我们的应用程序不允许用户更改密码。我们可以允许用户通过单个变异解析器重置密码,但通过电子邮件验证重置请求会更安全。为了简洁起见,我们不会在本书中实现密码重置功能,但如果您对创建密码重置流程的示例和资源感兴趣,请访问JavaScript Everywhere Spectrum community。
加密和令牌
在我们探索用户认证流程时,我提到了加密和令牌。这些听起来像是神话般的黑暗艺术,所以让我们花点时间更详细地看看它们吧。
加密密码
为了有效地加密用户密码,我们应该使用哈希和加盐的组合。哈希 是将文本字符串转换为看似随机的字符串来模糊化文本的行为。哈希函数是“单向”的,这意味着一旦文本被哈希化,就无法恢复到原始字符串。当密码被哈希化时,明文密码永远不会存储在我们的数据库中。加盐 是生成一段随机数据字符串,它将被添加到哈希密码之外。这确保即使两个用户的密码相同,哈希和加盐的版本也将是唯一的。
bcrypt是基于blowfish 密码算法的一种流行的哈希函数,在多种网络框架中被广泛使用。在 Node.js 开发中,我们可以使用bcrypt 模块来对密码进行加盐和哈希。
在我们的应用代码中,我们会需要bcrypt模块,并编写一个函数来处理加盐和哈希过程。
加盐和哈希示例
下面的例子仅供参考。我们将在本章后面集成bcrypt的密码加盐和哈希功能。
// require the module
const bcrypt = require('bcrypt');
// the cost of processing the salting data, 10 is the default
const saltRounds = 10;
// function for hashing and salting
const passwordEncrypt = async password => {
return await bcrypt.hash(password, saltRounds)
};
在这个例子中,我可以传递一个密码PizzaP@rty99,它生成一个盐值$2a$10$HF2rs.iYSvX1l5FPrX697O以及哈希和盐化密码$2a$10$HF2rs.iYSvX1l5FPrX697O9dYF/O2kwHuKdQTdy.7oaMwVga54bWG(这是盐值加上一个加密密码字符串)。
现在,当检查用户密码是否与经过哈希和加盐的密码匹配时,我们将使用bcrypt的compare方法:
// password is a value provided by the user
// hash is retrieved from our DB
const checkPassword = async (plainTextPassword, hashedPassword) => {
// res is either true or false
return await bcrypt.compare(hashedPassword, plainTextPassword)
};
通过对用户密码进行加密,我们能够安全地将其存储在数据库中。
JSON Web Tokens
作为用户,如果我们每次想要访问站点或应用程序的单个受保护页面时都需要输入用户名和密码,那将会非常令人沮丧。相反,我们可以安全地将用户的 ID 存储在其设备上的JSON Web Token中。每次用户从客户端发出请求时,他们都可以发送该令牌,服务器将使用它来识别用户。
JSON Web Token (JWT)由三部分组成:
头部
关于令牌的一般信息以及正在使用的签名算法类型
负载
我们故意存储在令牌中的信息(例如用户名或 ID)
签名
验证令牌的方法
如果我们查看令牌,它将看起来由随机字符组成,每个部分由句点分隔:xx-header-xx.yy-payload-yy.zz-signature-zz。
在我们的应用程序代码中,我们可以使用jsonwebtoken模块来生成和验证我们的令牌。为此,我们传入我们希望存储的信息,以及一个秘密密码,通常存储在我们的 .env 文件中。
const jwt = require('jsonwebtoken');
// generate a JWT that stores a user id
const generateJWT = await user => {
return await jwt.sign({ id: user._id }, process.env.JWT_SECRET);
}
// validate the JWT
const validateJWT = await token => {
return await jwt.verify(token, process.env.JWT_SECRET);
}
JWT 与 sessions 的比较
如果您之前在 web 应用程序中使用过用户身份验证,您可能已经遇到过用户 sessions。会话信息通常存储在本地,通常是在 cookie 中,并根据内存中的数据存储(例如 Redis,虽然也可以使用 传统数据库)。关于哪种更好,JWT 还是 sessions,存在很多争论,但我发现 JWT 在灵活性方面提供了最多的选择,特别是在集成非 web 环境(如原生移动应用程序)时。虽然 sessions 在 GraphQL 中表现良好,但在 GraphQL Foundation 和 Apollo Server 的文档中,JWT 也是推荐的方法。
通过使用 JWT,我们可以安全地返回和存储用户的 ID 给客户端应用程序。
将身份验证集成到我们的 API 中
现在您已经对用户身份验证的组件有了扎实的理解,我们将实现用户在我们的应用程序中注册和登录的能力。为此,我们将更新我们的 GraphQL 和 Mongoose 模式,编写 signUp 和 signIn mutation 解析器,生成用户令牌,并在每个请求到服务器时验证令牌。
用户模式
首先,我们将通过添加 User 类型并更新 Note 类型的 author 字段来更新我们的 GraphQL 模式,以引用 User。为此,请按以下方式更新 src/schema.js 文件:
type Note {
id: ID!
content: String!
author: User!
createdAt: DateTime!
updatedAt: DateTime!
}
type User {
id: ID!
username: String!
email: String!
avatar: String
notes: [Note!]!
}
当用户注册我们的应用程序时,他们将提交用户名、电子邮件地址和密码。当用户登录我们的应用程序时,他们将发送一个包含他们的用户名或电子邮件地址以及密码的 mutation。如果注册或登录 mutation 成功,API 将返回一个字符串作为令牌。为了在我们的模式中实现这一点,我们需要在 src/schema.js 文件中添加两个新的 mutations,每个 mutation 将返回一个 String,这将是我们的 JWT:
type Mutation {
...
signUp(username: String!, email: String!, password: String!): String!
signIn(username: String, email: String, password: String!): String!
}
现在我们的 GraphQL 模式已经更新,我们还需要更新我们的数据库模型。为此,我们将在 src/models/user.js 中创建一个 Mongoose 模式文件。该文件将与我们的 note 模型文件类似地设置,包含用户名、电子邮件、密码和头像字段。我们还将要求数据库中的用户名和电子邮件字段是唯一的,通过设置 index: { unique: true }。
要创建用户数据库模型,请在您的 src/models/user.js 文件中输入以下内容:
const mongoose = require('mongoose');
const UserSchema = new mongoose.Schema(
{
username: {
type: String,
required: true,
index: { unique: true }
},
email: {
type: String,
required: true,
index: { unique: true }
},
password: {
type: String,
required: true
},
avatar: {
type: String
}
},
{
// Assigns createdAt and updatedAt fields with a Date type
timestamps: true
}
);
const User = mongoose.model('User', UserSchema);
module.exports = User;
将用户模型文件放置好后,我们现在必须更新src/models/index.js以导出该模型:
const Note = require('./note');
const User = require('./user');
const models = {
Note,
User
};
module.exports = models;
认证解析器
当我们编写了 GraphQL 和 Mongoose 模式后,我们可以实现解析器,允许用户注册并登录到我们的应用程序。
首先,我们需要在.env文件中的JWT_SECRET变量中添加一个值。这个值应该是一个没有空格的字符串。它将用于签署我们的 JWT,在解码时用于验证它们。
JWT_SECRET=YourPassphrase
一旦我们创建了这个变量,我们可以在mutation.js文件中导入所需的包。我们将使用第三方的bcrypt、jsonwebtoken、mongoose和dotenv包,并导入 Apollo Server 的AuthenticationError和ForbiddenError实用程序。此外,我们还将导入gravatar实用函数,我已经包含在项目中。这将根据用户的电子邮件地址生成一个Gravatar 图像 URL。
在src/resolvers/mutation.js中输入以下内容:
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const {
AuthenticationError,
ForbiddenError
} = require('apollo-server-express');
require('dotenv').config();
const gravatar = require('../util/gravatar');
现在我们可以编写我们的signUp变更。这个变更将接受用户名、电子邮件地址和密码作为参数。我们将通过修剪任何空白字符并将其转换为小写来规范化电子邮件地址和用户名。接下来,我们将使用bcrypt模块加密用户的密码。我们还将使用我们的辅助库为用户头像生成 Gravatar 图像 URL。完成这些操作后,我们将用户存储在数据库中,并向用户返回一个令牌。我们可以在try/catch块中设置所有这些,以便如果注册过程中出现任何问题,我们的解析器向客户端返回一个故意模糊的错误。
为了完成所有这些,将signUp变更如下编写在src/resolvers/mutation.js文件中:
signUp: async (parent, { username, email, password }, { models }) => {
// normalize email address
email = email.trim().toLowerCase();
// hash the password
const hashed = await bcrypt.hash(password, 10);
// create the gravatar url
const avatar = gravatar(email);
try {
const user = await models.User.create({
username,
email,
avatar,
password: hashed
});
// create and return the json web token
return jwt.sign({ id: user._id }, process.env.JWT_SECRET);
} catch (err) {
console.log(err);
// if there's a problem creating the account, throw an error
throw new Error('Error creating account');
}
},
现在,如果我们切换到浏览器中的 GraphQL Playground,我们可以尝试我们的signUp变更。为此,我们将编写一个 GraphQL 变更,带有用户名、电子邮件和密码值:
mutation {
signUp(
username: "BeeBoop",
email: "robot@example.com",
password: "NotARobot10010!"
)
}
当我们运行这个变更时,服务器将返回一个像这样的令牌(图 7-1):
"data": {
"signUp": "eyJhbGciOiJIUzI1NiIsInR5cCI6..."
}
}

图 7-1. GraphQL Playground 中的 signUp 变更
下一步将是编写我们的signIn变更。这个变更将接受用户的用户名、电子邮件和密码。然后,它将根据用户名或电子邮件地址在数据库中查找用户。一旦定位到用户,它将解密存储在数据库中的密码,并将其与用户输入的密码进行比较。如果用户和密码匹配,我们的应用程序将向用户返回一个令牌。如果它们不匹配,我们将希望抛出一个错误。
在src/resolvers/mutation.js文件中编写如下变更:
signIn: async (parent, { username, email, password }, { models }) => {
if (email) {
// normalize email address
email = email.trim().toLowerCase();
}
const user = await models.User.findOne({
$or: [{ email }, { username }]
});
// if no user is found, throw an authentication error
if (!user) {
throw new AuthenticationError('Error signing in');
}
// if the passwords don't match, throw an authentication error
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
throw new AuthenticationError('Error signing in');
}
// create and return the json web token
return jwt.sign({ id: user._id }, process.env.JWT_SECRET);
}
现在我们可以在浏览器中访问 GraphQL Playground,并尝试signIn变更,使用我们使用signUp变更创建的帐户:
mutation {
signIn(
username: "BeeBoop",
email: "robot@example.com",
password: "NotARobot10010!"
)
}
再次,如果成功,我们的变更应该以 JWT 的形式解析(图 7-2):
{
"data": {
"signIn": "<TOKEN VALUE>"
}
}

图 7-2. GraphQL Playground 中的 signIn 变更
有了这两个解析器,用户可以使用 JWT 同时注册和登录我们的应用程序。要尝试这个功能,请尝试添加更多帐户,甚至故意输入不匹配的信息,如不匹配的密码,看看 GraphQL API 返回的内容。
将用户添加到解析器上下文
现在,用户可以使用 GraphQL 变更来接收唯一的令牌,我们需要在每个请求中验证该令牌。我们的期望是,无论是 Web、移动还是桌面客户端,我们的客户端都将在 HTTP 头中以Authorization命名的 HTTP 头中发送令牌。然后,我们可以从 HTTP 头中读取令牌,使用我们的JWT_SECRET变量对其进行解码,并通过上下文将用户信息传递给每个 GraphQL 解析器。通过这样做,我们可以确定正在进行请求的已登录用户,并确定是哪个用户。
首先,将jsonwebtoken模块导入到src/index.js文件中:
const jwt = require('jsonwebtoken');
导入了模块后,我们可以添加一个函数来验证令牌的有效性:
// get the user info from a JWT
const getUser = token => {
if (token) {
try {
// return the user information from the token
return jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
// if there's a problem with the token, throw an error
throw new Error('Session invalid');
}
}
};
现在,在每个 GraphQL 请求中,我们将从请求的头部中获取令牌,尝试验证令牌的有效性,并将用户信息添加到上下文中。完成这些操作后,每个 GraphQL 解析器都可以访问我们在令牌中存储的用户 ID。
// Apollo Server setup
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => {
// get the user token from the headers
const token = req.headers.authorization;
// try to retrieve a user with the token
const user = getUser(token);
// for now, let's log the user to the console:
console.log(user);
// add the db models and the user to the context
return { models, user };
}
});
尽管我们尚未执行用户交互,但我们可以在 GraphQL Playground 中测试我们的用户上下文。在 GraphQL Playground UI 的左下角有一个标有 HTTP Headers 的空间。在该部分 UI 中,我们可以添加一个包含 JWT 的头部,该 JWT 是在我们的signUp或signIn变更中返回的,如下所示(图 7-3):
{
"Authorization": "<YOUR_JWT>"
}

图 7-3. GraphQL Playground 中的授权头
我们可以通过将授权头与 GraphQL Playground 中的任何查询或变更一起传递来测试此授权头。为此,我们将编写一个简单的notes查询,并包含Authorization头部(图 7-4)。
query {
notes {
id
}
}

图 7-4. GraphQL Playground 中的授权头和查询
如果我们的身份验证成功,我们应该看到一个包含用户 ID 的对象被记录到我们终端应用程序的输出中,如图 7-5 所示。

图 7-5. 我们终端控制台中的用户对象的截图
有了所有这些组件,我们现在可以在 API 中对用户进行身份验证。
结论
用户账号创建和登录流程可能会让人感到神秘和压力重重,但通过逐步实施,我们可以在 API 中实现稳定且安全的认证流程。在本章中,我们创建了注册和登录用户流程。这些只是账号管理生态系统的一小部分,但将为我们提供一个稳固的基础。在下一章中,我们将在 API 中实现用户特定的交互,这将为应用程序内的笔记和活动分配所有权。
第八章:用户操作
想象一下你刚刚加入了一个俱乐部(记得那个“超酷人士秘密俱乐部”吗?),但是当你第一次来到这里时却没什么事可做。俱乐部是一个空荡荡的大房间,人们进进出出,彼此之间无法互动。我有点内向,所以这听起来并不是 那么 糟糕,但我不会愿意为此支付会费。
现在我们的 API 本质上是一个大而无用的俱乐部。我们有一种方法来创建数据和一种让用户登录的方法,但没有任何方法让用户拥有这些数据。在本章中,我们将通过添加用户交互来解决这个问题。我们将编写的代码将使用户能够拥有他们创建的注释,限制谁可以删除或修改注释,并使用户能够“收藏”他们喜欢的注释。此外,我们将使 API 用户能够进行嵌套查询,从而使我们的 UI 能够编写与用户和注释相关的简单查询。
在我们开始之前
在本章中,我们将对我们的注释文件进行一些相当重大的更改。由于我们的数据库中只有少量数据,您可能会发现将现有注释从本地数据库中删除会更容易。这并非必需,但可以减少您在本章中工作时的混淆。
为了做到这一点,我们将进入 MongoDB shell,确保我们引用的是 notedly 数据库(我们 .env 文件中的数据库名称),并使用 MongoDB 的 .remove() 方法。从您的终端中,输入以下内容:
$ mongo
$ use notedly
$ db.notes.remove({})
将用户附加到新注释
在上一章中,我们更新了我们的 src/index.js 文件,以便当用户发出请求时,我们检查 JWT 是否存在。如果令牌存在,我们解码它并将当前用户添加到我们的 GraphQL 上下文中。这允许我们将用户的信息发送到我们调用的每个解析器函数中。我们将更新我们现有的 GraphQL 变化以验证用户的信息。为此,我们将利用 Apollo Server 的 AuthenticationError 和 ForbiddenError 方法,这将允许我们抛出适当的错误。这些将帮助我们在开发过程中进行调试,同时向客户端发送适当的响应。
在我们开始之前,我们需要将mongoose包导入到我们 在我们开始之前,我们需要将 mongoose 包导入到我们的 mutations.js 解析器文件中。这将允许我们适当地将 MongoDB 对象 ID 分配给我们的字段。更新 src/resolvers/**mutation.js 顶部的模块导入如下:
const mongoose = require('mongoose');
现在,在我们的 newNote 变化中,我们将添加 user 作为函数参数,然后检查是否向函数传递了用户。如果未找到用户 ID,则抛出 AuthenticationError,因为必须登录我们的服务才能创建新的注释。一旦我们验证了请求是由经过身份验证的用户发出的,我们就可以在数据库中创建注释。这样做,我们现在将作者分配给传递给解析器的用户 ID。这将允许我们从注释本身引用创建用户。
在 src/resolvers/mutation.js 中添加以下内容:
// add the users context
newNote: async (parent, args, { models, user }) => {
// if there is no user on the context, throw an authentication error
if (!user) {
throw new AuthenticationError('You must be signed in to create a note');
}
return await models.Note.create({
content: args.content,
// reference the author's mongo id
author: mongoose.Types.ObjectId(user.id)
});
},
最后一步是将交叉引用应用于我们数据库中的数据。为此,我们将需要更新 MongoDB 笔记模式中的 author 字段。在 /src/models/note.js 中,按照以下方式更新 author 字段:
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
}
有了这个参考,所有新的注释将准确记录并交叉引用来自请求上下文的作者。让我们通过在 GraphQL Playground 中编写 newNote 变更来尝试一下:
mutation {
newNote(content: "Hello! This is a user-created note") {
id
content
}
}
在编写变更时,我们还必须确保在 Authorization 标头中传递一个 JWT(参见 图 8-1):
{
"Authorization": "<YOUR_JWT>"
}
如何检索 JWT
如果你没有方便的 JWT,可以执行 signIn 变更以检索一个。

图 8-1. GraphQL Playground 中的 newNote 变更
目前,我们的 API 不返回作者信息,但我们可以通过在 MongoDB shell 中查找注释来验证已正确添加作者。在终端窗口中,输入以下内容:
mongo
db.notes.find({_id: ObjectId("A DOCUMENT ID HERE")})
返回的值应包括一个作者键,其值为对象 ID。
更新和删除的用户权限
现在我们也可以向 deleteNote 和 updateNote 变更中添加用户检查。这些将要求我们检查上下文中是否传递了用户,以及该用户是否是注释的所有者。为了实现这一点,我们将检查存储在我们数据库中的 author 字段中的用户 ID 是否与传递给解析器上下文的用户 ID 匹配。
在 src/resolvers/mutation.js 中,按照以下方式更新 deleteNote 变更:
deleteNote: async (parent, { id }, { models, user }) => {
// if not a user, throw an Authentication Error
if (!user) {
throw new AuthenticationError('You must be signed in to delete a note');
}
// find the note
const note = await models.Note.findById(id);
// if the note owner and current user don't match, throw a forbidden error
if (note && String(note.author) !== user.id) {
throw new ForbiddenError("You don't have permissions to delete the note");
}
try {
// if everything checks out, remove the note
await note.remove();
return true;
} catch (err) {
// if there's an error along the way, return false
return false;
}
},
现在,在 src/resolvers/mutation.js 中,按照以下方式更新 updateNote 变更:
updateNote: async (parent, { content, id }, { models, user }) => {
// if not a user, throw an Authentication Error
if (!user) {
throw new AuthenticationError('You must be signed in to update a note');
}
// find the note
const note = await models.Note.findById(id);
// if the note owner and current user don't match, throw a forbidden error
if (note && String(note.author) !== user.id) {
throw new ForbiddenError("You don't have permissions to update the note");
}
// Update the note in the db and return the updated note
return await models.Note.findOneAndUpdate(
{
_id: id
},
{
$set: {
content
}
},
{
new: true
}
);
},
用户查询
在我们更新现有的变更以包括用户检查之后,让我们也添加一些特定于用户的查询。为此,我们将添加三个新的查询:
user
给定特定用户名,返回用户的信息
users
返回所有用户的列表
me
返回当前用户的用户信息
在编写查询解析器代码之前,将这些查询添加到 GraphQL 的 src/schema.js 文件中,如下所示:
type Query {
...
user(username: String!): User
users: [User!]!
me: User!
}
现在在 src/resolvers/query.js 文件中,编写以下解析器查询代码:
module.exports = {
// ...
// add the following to the existing module.exports object:
user: async (parent, { username }, { models }) => {
// find a user given their username
return await models.User.findOne({ username });
},
users: async (parent, args, { models }) => {
// find all users
return await models.User.find({});
},
me: async (parent, args, { models, user }) => {
// find a user given the current user context
return await models.User.findById(user.id);
}
}
让我们在 GraphQL Playground 中看看这些东西。首先,我们可以编写一个用户查询来查找特定用户的信息。确保使用已经创建的用户名:
query {
user(username:"adam") {
username
email
id
}
}
这将返回一个数据对象,包含指定用户的用户名、电子邮件和 ID 值(图 8-2)。

图 8-2. GraphQL Playground 中的用户查询
现在,我们可以使用 users 查询来查找我们数据库中的所有用户,该查询将返回一个包含所有用户信息的数据对象(图 8-3):
query {
users {
username
email
id
}
}

图 8-3. GraphQL Playground 中的用户查询
现在,我们可以使用 JWT 在 HTTP 头部传递,通过me查询查找已登录用户的信息。
首先,请确保在 GraphQL Playground 的 HTTP 头部部分包含令牌:
{
"Authorization": "<YOUR_JWT>"
}
现在,执行me查询如下(图 8-4):
query {
me {
username
email
id
}
}

图 8-4. GraphQL Playground 中的 me 查询
有了这些解析器的设置,我们现在可以查询我们的 API 以获取用户信息。
切换笔记收藏夹
我们还有最后一个功能要添加到我们的用户交互中。您可能记得我们的应用程序规范说明“用户将能够收藏其他用户的笔记并检索其收藏列表。”类似于 Twitter 的“喜欢”和 Facebook 的“赞”,我们希望用户能够将笔记标记为收藏(取消收藏)。为了实现这一行为,我们将遵循更新 GraphQL 模式、然后更新数据库模型,最后更新解析器函数的标准模式。
首先,我们将通过在./src/schema.js中的Note类型中添加两个新属性来更新我们的 GraphQL 模式。favoriteCount将跟踪笔记收到的“收藏”总数。favoritedBy将包含已收藏笔记的用户数组。
type Note {
// add the following properties to the Note type
favoriteCount: Int!
favoritedBy: [User!]
}
我们还将在User类型中添加收藏列表:
type User {
// add the favorites property to the User type
favorites: [Note!]!
}
接下来,我们将在./src/schema.js中添加一个名为toggleFavorite的变异,通过添加或删除指定笔记的收藏来解决。此变异将接受笔记 ID 作为参数,并返回指定的笔记。
type Mutation {
// add toggleFavorite to the Mutation type
toggleFavorite(id: ID!): Note!
}
接下来,我们需要更新我们的笔记模型,包括在数据库中添加favoriteCount和favoritedBy属性。favoriteCount将是一个Number类型,初始值为0。favoritedBy将是一个包含对数据库中用户对象 ID 引用的对象数组。我们完整的./src/models/note.js文件将如下所示:
const noteSchema = new mongoose.Schema(
{
content: {
type: String,
required: true
},
author: {
type: String,
required: true
},
// add the favoriteCount property
favoriteCount: {
type: Number,
default: 0
},
// add the favoritedBy property
favoritedBy: [
{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}
]
},
{
// Assigns createdAt and updatedAt fields with a Date type
timestamps: true
}
);
通过更新我们的 GraphQL 模式和数据库模型,我们可以编写toggleFavorite变异。此变异将接收一个笔记 ID 作为参数,并检查用户是否已列在favoritedBy数组中。如果用户已列出,我们将通过减少favoriteCount并从列表中移除用户来取消收藏。如果用户尚未收藏笔记,我们将增加favoriteCount1 并将当前用户添加到favoritedBy数组中。为了执行所有这些操作,请将以下代码添加到src/resolvers/mutation.js文件中:
toggleFavorite: async (parent, { id }, { models, user }) => {
// if no user context is passed, throw auth error
if (!user) {
throw new AuthenticationError();
}
// check to see if the user has already favorited the note
let noteCheck = await models.Note.findById(id);
const hasUser = noteCheck.favoritedBy.indexOf(user.id);
// if the user exists in the list
// pull them from the list and reduce the favoriteCount by 1
if (hasUser >= 0) {
return await models.Note.findByIdAndUpdate(
id,
{
$pull: {
favoritedBy: mongoose.Types.ObjectId(user.id)
},
$inc: {
favoriteCount: -1
}
},
{
// Set new to true to return the updated doc
new: true
}
);
} else {
// if the user doesn't exist in the list
// add them to the list and increment the favoriteCount by 1
return await models.Note.findByIdAndUpdate(
id,
{
$push: {
favoritedBy: mongoose.Types.ObjectId(user.id)
},
$inc: {
favoriteCount: 1
}
},
{
new: true
}
);
}
},
有了这段代码,让我们在 GraphQL Playground 中测试切换笔记收藏的能力。我们将使用新创建的笔记进行测试。我们将从编写newNote变异开始,确保在有效的 JWT(图 8-5)中包含Authorization头部:
mutation {
newNote(content: "Check check it out!") {
content
favoriteCount
id
}
}

图 8-5. newNote 变异
你会注意到,这个新笔记的 favoriteCount 自动设置为 0,因为这是我们在数据模型中设置的默认值。现在,让我们编写一个 toggleFavorite mutation 来将其标记为收藏夹,将笔记的 ID 作为参数传递。再次确保包含有效的 JWT 的 Authorization HTTP 标头。
mutation {
toggleFavorite(id: "<YOUR_NOTE_ID_HERE>") {
favoriteCount
}
}
运行此 mutation 后,笔记的 favoriteCount 值应为 1。如果重新运行 mutation,则 favoriteCount 将减少为 0(图 8-6)。

图 8-6. 切换收藏夹的 mutation
用户现在可以标记和取消标记笔记为收藏夹。更重要的是,我希望这个功能展示了如何向 GraphQL 应用程序的 API 添加新功能。
嵌套查询
GraphQL 的一个很棒的功能之一是我们可以 嵌套 查询,允许我们编写一个单一的查询,精确返回我们需要的数据,而不是多个查询。我们的 GraphQL 模式的 User 类型包含作者的笔记列表,以数组格式存在,而 Notes 类型包含其作者的引用。因此,我们可以从用户查询中获取笔记列表,或从笔记查询中获取作者信息。
这意味着我们可以编写如下查询:
query {
note(id: "5c99fb88ed0ca93a517b1d8e") {
id
content
# the information about the author note
author {
username
id
}
}
}
如果我们当前尝试运行类似上述的嵌套查询,我们将收到一个错误。这是因为我们尚未编写执行此信息数据库查找的解析器代码。
要启用此功能,我们将在 src/resolvers 目录中添加两个新文件。
在 src/resolvers/note.js 中,添加以下内容:
module.exports = {
// Resolve the author info for a note when requested
author: async (note, args, { models }) => {
return await models.User.findById(note.author);
},
// Resolved the favoritedBy info for a note when requested
favoritedBy: async (note, args, { models }) => {
return await models.User.find({ _id: { $in: note.favoritedBy } });
}
};
在 src/resolvers/user.js 中,添加以下内容:
module.exports = {
// Resolve the list of notes for a user when requested
notes: async (user, args, { models }) => {
return await models.Note.find({ author: user._id }).sort({ _id: -1 });
},
// Resolve the list of favorites for a user when requested
favorites: async (user, args, { models }) => {
return await models.Note.find({ favoritedBy: user._id }).sort({ _id: -1 });
}
};
现在我们需要更新我们的 src/resolvers/index.js 文件来导入和导出这些新的解析器模块。总体而言,src/resolvers/index.js 文件现在应如下所示:
const Query = require('./query');
const Mutation = require('./mutation');
const Note = require('./note');
const User = require('./user');
const { GraphQLDateTime } = require('graphql-iso-date');
module.exports = {
Query,
Mutation,
Note,
User,
DateTime: GraphQLDateTime
};
现在,如果我们编写一个嵌套的 GraphQL 查询或 mutation,我们将收到我们期望的信息。您可以尝试编写以下 note 查询:
query {
note(id: "<YOUR_NOTE_ID_HERE>") {
id
content
# the information about the author note
author {
username
id
}
}
}
此查询应正确解析出作者的用户名和 ID。另一个实际示例是返回收藏某个笔记的用户的信息:
mutation {
toggleFavorite(id: "<YOUR NOTE ID>") {
favoriteCount
favoritedBy {
username
}
}
}
通过使用嵌套解析器,我们可以编写精确的查询和 mutation,精确返回我们需要的数据。
结论
祝贺!在本章中,我们的 API 毕业成为用户真正可以互动的东西。通过集成用户操作、添加新功能和嵌套解析器,此 API 展示了 GraphQL 的真正威力。我们还遵循了一个行之有效的模式来向项目中添加新代码:首先编写 GraphQL 模式,然后编写数据库模型,最后编写解析器代码来查询或更新数据。通过将这个过程分解为三个步骤,我们可以为我们的应用程序添加各种功能。在下一章中,我们将讨论使我们的 API 准备投入生产所需的最后步骤,包括分页和安全性。
第九章:细节
当现在几乎无处不在的空气清新剂 Febreze 首次发布时,它一无是处。最初的广告展示了人们使用该产品去除特定的恶臭,如香烟烟雾,导致销售疲软。面对这令人失望的结果,营销团队将焦点转移到将 Febreze 用作完美细节。现在,广告描绘了有人在清理房间,拍打枕头,并完成了使用 Febreze 给房间喷雾的任务。这种重新定义产品的方式使销售额飙升。
这是一个很好的例子,细节很重要。现在我们有一个工作中的 API,但缺少让它投入生产的最后修饰。在本章中,我们将实施一些 Web 和 GraphQL 应用程序安全和用户体验最佳实践。这些远远超过空气清新剂的喷雾的细节将对我们应用程序的安全性、可用性和易用性至关重要。
Web 应用程序和 Express.js 最佳实践
Express.js 是支持我们 API 的底层 Web 应用程序框架。我们可以对 Express.js 代码进行一些小的调整,以为我们的应用程序提供坚实的基础。
Express Helmet
Express Helmet 中间件 是一组小型的以安全为导向的中间件函数。这些函数将调整我们应用程序的 HTTP 头部以提升安全性。尽管其中许多是针对基于浏览器的应用程序的,启用 Helmet 是保护我们应用程序免受常见网络漏洞的简单步骤。
要启用 Helmet,我们将在我们的应用程序中引入中间件,并指示 Express 在我们的中间件堆栈中早期使用它。在 ./src/index.js 文件中,添加以下内容:
// first require the package at the top of the file
const helmet = require('helmet')
// add the middleware at the top of the stack, after const app = express()
app.use(helmet());
通过添加 Helmet 中间件,我们可以快速为我们的应用启用常见的网络安全最佳实践。
跨域资源共享
跨域资源共享(CORS)是允许从另一个域请求资源的手段。因为我们的 API 和 UI 代码将分开存放,我们希望从其他来源启用凭据。如果你有兴趣了解 CORS 的方方面面,我强烈推荐 Mozilla CORS Guide。
要启用 CORS,我们将在我们的 .src/index.js 文件中使用 Express.js CORS 中间件 包:
// first require the package at the top of the file
const cors = require('cors');
// add the middleware after app.use(helmet());
app.use(cors());
通过这种方式添加中间件,我们可以允许来自 所有 域的跨源请求。目前这对我们来说效果很好,因为我们处于开发模式,可能会使用由我们的托管提供商生成的域,但通过使用中间件,我们也可以限制请求来自特定来源。
分页
目前,我们的notes和users查询返回数据库中所有笔记和用户的完整列表。这在本地开发中运行良好,但随着应用程序的增长,这将变得难以维护,因为查询可能会返回数百(甚至数千)个笔记,这样的开销会拖慢我们的数据库、服务器和网络。因此,我们可以对这些查询进行分页,仅返回一定数量的结果。
我们可以实现两种常见的分页方式。第一种类型,偏移分页,通过客户端传递偏移数,并返回有限数量的数据。例如,如果每页数据限制为 10 条记录,我们想要请求第三页数据,我们可以传递偏移量为 20。尽管这在概念上是最直观的方法,但它可能会遇到扩展和性能问题。
第二种分页方式是基于游标的分页,其中传递一个基于时间或唯一标识的游标作为起始点。然后,我们请求跟随此记录的特定数据量。这种方法能够给我们最大程度上的分页控制。此外,因为 Mongo 的对象 ID 是有序的(它们以 4 字节的时间值开头),我们可以很容易地将它们用作游标。要了解更多关于 Mongo 对象 ID 的信息,建议阅读对应的 MongoDB 文档。
如果这对你来说听起来过于概念化,没关系。让我们一起来实现一个基于 GraphQL 查询的分页笔记 Feed。首先,我们定义我们将要创建的内容,接着是我们的模式更新,最后是我们的解析器代码。对于我们的 Feed,我们希望在查询 API 时可选地传递一个游标作为参数。API 应返回有限的数据量,一个表示数据集中最后一项的游标点,并一个布尔值,表示是否有另一页数据可查询。
有了这个描述,我们可以更新我们的src/schema.js文件,定义这个新的查询。首先,我们需要在文件中添加一个NoteFeed类型:
type NoteFeed {
notes: [Note]!
cursor: String!
hasNextPage: Boolean!
}
接下来,我们将添加我们的noteFeed查询:
type Query {
# add noteFeed to our existing queries
noteFeed(cursor: String): NoteFeed
}
在我们的模式更新后,我们可以为我们的查询编写解析器代码。在./src/resolvers/query.js中,向导出对象添加以下内容:
noteFeed: async (parent, { cursor }, { models }) => {
// hardcode the limit to 10 items
const limit = 10;
// set the default hasNextPage value to false
let hasNextPage = false;
// if no cursor is passed the default query will be empty
// this will pull the newest notes from the db
let cursorQuery = {};
// if there is a cursor
// our query will look for notes with an ObjectId less than that of the cursor
if (cursor) {
cursorQuery = { _id: { $lt: cursor } };
}
// find the limit + 1 of notes in our db, sorted newest to oldest
let notes = await models.Note.find(cursorQuery)
.sort({ _id: -1 })
.limit(limit + 1);
// if the number of notes we find exceeds our limit
// set hasNextPage to true and trim the notes to the limit
if (notes.length > limit) {
hasNextPage = true;
notes = notes.slice(0, -1);
}
// the new cursor will be the Mongo object ID of the last item in the feed array
const newCursor = notes[notes.length - 1]._id;
return {
notes,
cursor: newCursor,
hasNextPage
};
}
有了这个解析器,我们可以查询我们的noteFeed,它将最多返回 10 个结果。在 GraphQL Playground 中,我们可以编写以下查询来接收笔记列表,它们的对象 ID,它们的“创建时间”时间戳,游标,以及下一页的布尔值:
query {
noteFeed {
notes {
id
createdAt
}
cursor
hasNextPage
}
}
由于我们的数据库中有超过 10 条笔记,这会返回一个游标以及hasNextPage值为true。通过这个游标,我们可以查询 Feed 的第二页:
query {
noteFeed(cursor: "<YOUR OBJECT ID>") {
notes {
id
createdAt
}
cursor
hasNextPage
}
}
我们可以继续为每个游标执行此操作,其中hasNextPage值为true。有了这个实现,我们已经创建了一个分页笔记 Feed。这不仅允许我们的 UI 请求特定数据 Feed,还可以减少服务器和数据库的负担。
数据限制
除了建立分页之外,我们还希望限制可以通过我们的 API 请求的数据量。这可以防止可能会使我们的服务器或数据库超载的查询。
在这个过程中的一个简单的第一步是限制查询可以返回的数据量。我们的两个查询,users 和 notes,返回数据库中所有匹配的数据。我们可以通过在数据库查询上设置 limit() 方法来解决这个问题。例如,在我们的 .src/resolvers/query.js 文件中,我们可以更新我们的 notes 查询如下:
notes: async (parent, args, { models }) => {
return await models.Note.find().limit(100);
}
尽管限制数据是一个很好的开始,但是当前我们的查询可以写成无限深度。这意味着可以写一个单一查询来检索一系列笔记,每个笔记的作者信息,每个作者的收藏列表,每个收藏的作者信息,依此类推。这是一个很多数据的查询,我们还可以继续!为了防止这些过度嵌套的查询,我们可以限制查询的深度。
此外,我们可能有复杂的查询,虽然不是过于嵌套,但仍需要大量计算来返回数据。我们可以通过限制查询复杂性来防止这些类型的请求。
我们可以通过在我们的 ./src/index.js 文件中使用 graphql-depth-limit 和 graphql-validation-complexity 包来实现这些限制:
// import the modules at the top of the file
const depthLimit = require('graphql-depth-limit');
const { createComplexityLimitRule } = require('graphql-validation-complexity');
// update our ApolloServer code to include validationRules
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5), createComplexityLimitRule(1000)],
context: async ({ req }) => {
// get the user token from the headers
const token = req.headers.authorization;
// try to retrieve a user with the token
const user = await getUser(token);
// add the db models and the user to the context
return { models, user };
}
});
通过添加这些包,我们为我们的 API 添加了额外的查询保护。有关如何保护 GraphQL API 免受恶意查询的更多信息,请查看 Spectrum 的首席技术官 Max Stoiber 的精彩文章。
其他考虑事项
在构建完我们的 API 后,您应该对 GraphQL 开发的基础知识有了坚实的理解。如果您渴望深入了解更多主题,一些继续学习的好去处包括测试、GraphQL 订阅和 Apollo Engine。
测试
好吧,我承认:我没有在这本书中写关于测试,我感到有些内疚。测试我们的代码很重要,因为它使我们能够放心地进行更改,并改善与其他开发人员的协作。我们的 GraphQL 设置中的一大优势是解析器只是简单的函数,接受一些参数并返回数据。这使得我们的 GraphQL 逻辑易于测试。
订阅
订阅是 GraphQL 的一个非常强大的功能,它提供了一种简单的方法在我们的应用程序中集成发布-订阅模式。这意味着用户界面可以订阅在服务器上发布数据时进行通知或更新。这使得 GraphQL 服务器成为处理实时数据的理想解决方案。有关 GraphQL 订阅的更多信息,请查看Apollo Server 文档。
Apollo GraphQL 平台
在开发我们的 API 的整个过程中,我们一直在使用 Apollo GraphQL 库。在将来的章节中,我们还将使用 Apollo 客户端库与我们的 API 进行交互。我选择这些库是因为它们是行业标准,并且为使用 GraphQL 的开发者提供了极好的开发体验。如果你把你的应用程序投入生产,维护这些库的公司 Apollo 还提供了一个平台,为 GraphQL API 提供监控和工具支持。您可以在Apollo 的网站上了解更多。
结论
在本章中,我们为我们的应用程序添加了一些最后的修饰。虽然我们可以实现许多其他选项,但在这一点上,我们已经开发出了一个坚实的 MVP(最小可行产品)。在这个状态下,我们已经准备好启动我们的 API!在下一章中,我们将把我们的 API 部署到一个公共 Web 服务器上。
第十章:部署我们的 API
想象一下,如果每次用户想要访问我们的 API 来创建、读取、更新或删除一个笔记时,我们都必须带着笔记本电脑去见他们。目前,这就是我们的 API 运行的方式,因为它仅在我们个人的计算机上运行。我们可以通过将我们的应用程序部署到 Web 服务器来解决这个问题。
在本章中,我们将采取两个步骤:
-
首先,我们将设置一个远程数据库,供我们的 API 访问。
-
其次,我们将部署我们的 API 代码到服务器,并将其连接到数据库。
一旦完成上述步骤,我们可以从任何联网的计算机上访问我们的 API,包括我们将开发的 Web、桌面和移动界面。
托管我们的数据库
对于第一步,我们将使用托管的数据库解决方案。对于我们的 Mongo 数据库,我们将使用 MongoDB Atlas。这是一个由 Mongo 本身背后的组织提供的全管理云服务。此外,他们还提供了一个免费套餐,非常适合我们的初始部署。让我们来看看如何部署到 MongoDB Atlas 的步骤。
首先,访问mongodb.com/cloud/atlas并创建一个账户。创建账户后,您将被提示创建一个数据库。从此屏幕上,您可以管理您的沙箱数据库的设置,但我建议暂时使用默认设置。这些设置是:
-
亚马逊的 AWS 作为数据库主机,尽管谷歌的云平台和微软的 Azure 也提供了选项。
-
最接近的带有“免费套餐”选项的地区
-
集群层级,默认为“M0 Sandbox(共享 RAM,512MB 存储)”
-
其他设置,我们可以保持默认
-
集群名称,我们可以保持默认
在这里,点击创建集群,Mongo 会花几分钟时间设置数据库(图 10-1)。

图 10-1. MongoDB Atlas 数据库创建屏幕
接下来,您将看到 Clusters 页面,您可以管理个人数据库集群(图 10-2)。

图 10-2. MongoDB Atlas 集群
从 Clusters 屏幕上,点击连接,您将被提示设置连接安全性。第一步是将您的 IP 地址加入白名单。因为我们的应用将使用动态 IP 地址,您需要将其开放给任何 IP 地址,使用0.0.0.0/0。当所有 IP 地址都被加入白名单后,您将需要设置一个安全的用户名和密码来访问数据(图 10-3)。

图 10-3. MongoDB Atlas IP 白名单和用户账号管理
一旦您的 IP 地址被加入白名单,用户账号被创建,您将选择数据库的连接方式。在这种情况下,它将是一个“应用程序”连接(图 10-4)。

图 10-4. 在 MongoDB Atlas 中选择连接类型
从这里,你可以复制连接字符串,我们将在我们的生产.env文件中使用它(图 10-5)。

图 10-5. MongoDB Atlas 的数据库连接字符串
Mongo 密码
MongoDB Atlas hex-encodes 密码中的特殊字符。这意味着,如果您使用(并且应该使用!)任何非字母或数字值,您需要在添加密码到连接字符串时使用该代码的十六进制值。网站ascii.cl提供了所有特殊字符对应的十六进制代码。例如,如果您的密码是Pizz@2!,您需要对@和!字符进行编码。您可以使用%后跟十六进制值来执行此操作。生成的密码将是Pizz%402%21。
我们的 MongoDB Atlas 托管数据库已经运行起来,现在我们有了一个托管的数据存储用于我们的应用程序。在下一步中,我们将托管我们的应用程序代码并将其连接到我们的数据库。
部署我们的应用程序
我们部署设置的下一步是部署我们的应用程序代码。为了本书的目的,我们将使用云应用平台 Heroku。我选择 Heroku 是因为其出色的用户体验和慷慨的免费套餐,但其他云平台如亚马逊 Web 服务、Google Cloud Platform、Digital Ocean 或 Microsoft Azure 都提供了 Node.js 应用程序的替代托管环境。
在我们开始之前,您需要访问Heroku 的网站并创建一个帐户。一旦您的帐户已创建,您将需要为您的操作系统安装Heroku 命令行工具。
对于 macOS 用户,您可以使用 Homebrew 安装 Heroku 命令行工具,如下所示:
$ brew tap heroku/brew && brew install heroku
对于 Windows 用户,请访问 Heroku 命令行工具指南并下载适当的安装程序。
项目设置
安装了 Heroku 命令行工具后,我们可以在 Heroku 网站内设置我们的项目。通过点击 New → Create New App 来创建一个新的 Heroku 项目(图 10-6)。

图 10-6. Heroku 新应用对话框
从这里,您将被提示为应用程序提供一个唯一的名称,之后您可以点击 Create App 按钮(图 10-7)。在接下来的步骤中,您可以在任何地方看到YOUR_APP_NAME时使用此名称。

图 10-7. 提供一个唯一的应用程序名称
现在我们可以添加环境变量了。类似于我们在本地使用.env文件的方式,我们可以在 Heroku 网站界面内管理我们的生产环境变量。为此,请点击设置,然后点击显示配置变量按钮。在此屏幕上,添加以下配置变量(图 10-8):
NODE_ENV production
JWT_SECRET A_UNIQUE_PASSPHRASE
DB_HOST YOUR_MONGO_ATLAS_URL

图 10-8. Heroku 的环境变量配置
通过配置我们的应用程序,我们已经准备好部署我们的代码了。
部署
现在我们已经准备好将代码部署到 Heroku 的服务器上了。为此,我们可以从终端应用程序中使用直接的 Git 命令。我们将 Heroku 设置为远程端点,然后添加并提交我们的更改,最后将代码推送到 Heroku。要执行此操作,请在终端应用程序中运行以下命令:
$ heroku git:remote -a <YOUR_APP_NAME>
$ git add .
$ git commit -am "application ready for production"
$ git push heroku master
在 Heroku 构建和部署文件时,您应该在终端中看到输出。完成后,Heroku 将使用我们package.json文件中的run脚本在其服务器上运行我们的应用程序。
测试
一旦我们的应用程序成功部署,我们将能够向远程服务器发出 GraphQL API 请求。默认情况下,在生产环境中禁用 GraphQL Playground UI,但我们可以使用终端应用程序中的curl测试我们的应用程序。要运行curl请求,请在终端应用程序中输入以下内容:
$ curl \
-X POST \
-H "Content-Type: application/json" \
--data '{ "query": "{ notes { id } }" }' \
https://YOUR_APP_NAME.herokuapp.com/api
如果测试成功,我们应该收到一个包含空的notes数组的响应,因为我们的生产数据库尚未包含任何数据:
{"data":{"notes":[]}}
通过这个步骤,我们已经成功部署了我们的应用程序!
结论
在本章中,我们使用云服务部署了数据库和我们的应用程序代码。诸如 MongoDB Atlas 和 Heroku 之类的服务使开发人员能够启动小型应用程序,并从兴趣项目到高流量业务进行扩展。通过部署我们的 API,我们成功地开发了应用程序堆栈的后端服务。在接下来的章节中,我们将专注于应用程序的用户界面。
第十一章:用户界面和 React
1979 年,史蒂夫·乔布斯著名地访问了施乐帕克(Xerox Parc),在那里他看到了施乐阿尔托个人电脑的演示。当时的其他计算机都是通过键入命令来控制的,而阿尔托使用了鼠标,并且具有可以打开和关闭的窗口的图形界面。乔布斯随后借鉴了这些想法创造了最初的苹果 Macintosh。最初 Mac 的流行导致了计算机 UI 的大量普及。如今,在一个典型的日常中,我们可能会与数十个图形用户界面互动,这些界面可能包括个人计算机以及智能手机、平板电脑、ATM 机、游戏机、支付自助服务机等等。UI 现在围绕着我们,跨越各种设备、内容类型、屏幕尺寸和交互格式。
举例来说,最近我为了开会去了一个不同的城市。那天早上,我醒来后在手机上查看了我的航班状态。我驾车去机场,在车上的屏幕显示了地图,并允许我选择正在听的音乐。在路上,我停在一个 ATM 机前取了些现金,输入我的 PIN 并在触摸屏上输入指令。到达机场后,我在一个飞行自助服务机上办理了登机手续。在候机门等待时,我在平板电脑上回复了几封电子邮件。在飞行中,我在一台电子墨水显示设备上读书。一旦我降落,我通过手机上的一个应用叫了一辆车,并在午餐时在显示屏上输入了我的定制订单。在会议中,幻灯片被投射到屏幕上,而我们中的许多人在笔记本电脑上做笔记。晚上回到酒店,我通过酒店电视屏幕上的指南浏览了电视和电影的选择。我的一天充满了许多 UI 和屏幕尺寸,用于完成与核心生活元素如交通、财务和娱乐相关的任务。
在本章中,我们将简要回顾 JavaScript 用户界面开发的历史。在了解了这些背景知识后,我们将深入探讨 React 的基础知识,这是本书剩余部分将使用的 JavaScript 库。
JavaScript 和 UI
最初设计于 1990 年代中期(臭名昭著地,在10 days内)以增强 Web 界面,JavaScript 提供了一个嵌入式脚本语言在 Web 浏览器中。这使得 Web 设计师和开发者能够在 Web 页面上添加小的交互,这是单靠 HTML 无法实现的。不幸的是,各个浏览器供应商对 JavaScript 有不同的实现,这使得依赖它变得困难。这是导致应用程序大量设计成只能在单个浏览器中运行的因素之一。
在 2000 年代中期,jQuery(以及类似的库,如 MooTools)开始流行起来。jQuery 允许开发人员使用简单的 API 在各种浏览器上编写 JavaScript。很快,我们在网页上都在删除、添加、替换和动画化各种元素。与此同时,Ajax(“异步 JavaScript 和 XML”的缩写)允许我们从服务器获取数据并将其注入到页面中。这两种技术的结合为创建强大的交互式 Web 应用程序提供了生态系统。
随着这些应用程序的复杂性增长,组织和样板代码的需求也在相应增长。到了 2010 年代初,诸如 Backbone、Angular 和 Ember 等框架开始主导 JavaScript 应用程序的风景。这些框架通过在框架代码中施加结构并实现常见应用程序模式来工作。这些框架通常模仿软件设计中的模型-视图-控制器(MVC)模式。每个框架都对 Web 应用程序的所有层提出了具体要求,提供了处理模板、数据和用户交互的结构化方式。尽管这带来了许多好处,但也意味着整合新技术或非标准技术的努力可能会相当高。
与此同时,桌面应用程序继续使用特定于系统的编程语言编写。这意味着开发人员和团队通常被迫做出一种选择(或者 Mac 应用程序,或者 Windows 应用程序,或者 Web 应用程序,或者桌面应用程序等)。移动应用程序处于类似的位置。响应式 Web 设计的兴起意味着设计师和开发人员可以为移动 Web 浏览器创建真正令人难以置信的站点和应用程序,但选择仅构建 Web 应用程序将使它们无法进入移动平台应用商店。Apple 的 iOS 应用程序是用 Objective C(最近是 Swift)编写的,而 Android 依赖于 Java 编程语言(不要与我们的朋友 JavaScript 混淆)。这意味着 HTML、CSS 和 JavaScript 组成的 Web 是唯一真正的跨平台用户界面平台。
使用 JavaScript 的声明性界面
在 2010 年代初,Facebook 的开发人员开始面临 JavaScript 代码组织和管理方面的挑战。作为回应,软件工程师乔丹·沃尔克(Jordan Walke)编写了 React,受 Facebook 的 PHP 库 XHP 启发。React 与其他流行的 JavaScript 框架不同,它专注于 UI 的渲染。为了实现这一点,React 采用了“声明性”编程方法,这意味着它提供了一个抽象,允许开发人员专注于描述 UI 的状态应该是什么样的。
随着 React 的兴起,以及类似 Vue.js 这样的库,我们看到开发人员在编写 UI 时发生了变化。这些框架提供了一种在组件级别管理 UI 状态的方式。这使得应用程序对用户来说感觉流畅和无缝,并提供了优秀的开发体验。借助诸如 Electron 用于构建桌面应用程序和 React Native 用于跨平台本地移动应用程序的工具,开发人员和团队现在能够在所有应用程序中利用这些范例。
刚刚够用的 React
在接下来的章节中,我们将依赖于 React 库来构建我们的 UI。你不需要有任何 React 的先验经验来跟进,但在开始之前了解一下语法可能会有所帮助。为此,我们将使用 create-react-app 来快速搭建一个新项目。create-react-app 是由 React 团队开发的工具,可以快速设置一个新的 React 项目,并巧妙地抽象了底层的构建工具,如 Webpack 和 Babel。
在你的终端应用程序中 cd 到你的项目目录,并运行以下命令,将在名为 just-enough-react 的文件夹中创建一个新的 React 应用程序:
$ npx create-react-app just-enough-react
$ cd just-enough-react
运行这些命令将在 just-enough-react 目录中输出一个目录,其中包含所有的项目结构、代码依赖和开发脚本,以构建一个功能齐全的应用程序。通过运行以下命令启动应用程序:
$ npm start
我们的 React 应用程序现在可以在浏览器中的 http://localhost:3000 上看到(图 11-1)。

图 11-1. 输入 npm start 将在浏览器中启动默认的 create-react-app
现在,我们可以通过修改 src/App.js 文件来开始编辑我们的应用程序。这个文件包含了我们的主要 React 组件。在引入一些依赖之后,它包含一个返回类似 HTML 标记的函数:
function App() {
return (
// markup is here
)
}
组件中使用的标记是一种叫做 JSX 的东西。JSX 是一种基于 XML 的语法,类似于 HTML,允许我们精确描述我们的 UI 并将其与用户操作耦合在我们的 JavaScript 文件中。如果你了解 HTML,那么学习 JSX 就是学习一些小的差异。这个例子中的一个重大区别是,HTML 中的 class 属性被 className 取代,以避免与 JavaScript 的原生类语法冲突。
JSX?呸!
如果你和我一样,来自于 web 标准和严格的关注点解耦背景,可能会对这感到很反感。我承认,当我第一次接触到 JSX 时,我立刻对它产生了强烈的反感。然而,UI 逻辑与渲染输出的耦合提供了许多引人注目的优势,随着时间的推移,你可能会渐渐接受它。
让我们开始定制我们的应用程序,通过删除大部分样板代码,并将其简化为一个简单的“Hello World!”:
import React from 'react';
import './App.css';
function App() {
return (
<div className="App">
<p>Hello world!</p>
</div>
);
}
export default App;
您可能会注意到包围所有 JSX 内容的封闭<div>标签。每个 React UI 组件必须包含在父 HTML 元素内或使用 React 片段,表示非 HTML 元素容器,例如:
function App() {
return (
<React.Fragment>
<p>Hello world!</p>
</React.Fragment>
);
}
React 最强大的一点是我们可以通过在花括号{}中包裹 JavaScript 来直接在 JSX 中使用它。让我们更新我们的App函数以利用一些变量:
function App() {
const name = 'Adam'
const now = String(new Date())
return (
<div className="App">
<p>Hello {name}!</p>
<p>The current time is {now}</p>
<p>Two plus two is {2+2}</p>
</div>
);
}
在上面的例子中,您可以看到我们在界面中直接使用 JavaScript。这是多么酷呀!
React 的另一个有用功能是能够将每个 UI 特性转换为自己的组件。一个好的经验法则是,如果 UI 的某个方面以独立的方式行为,那么它应该被分离成自己的组件。让我们创建一个新组件。首先,在src/Sparkle.js创建一个新文件并声明一个新函数:
import React from 'react';
function Sparkle() {
return (
<div>
</div>
);
}
export default Sparkle;
现在让我们添加一些功能。每当用户点击按钮时,它将向我们的页面添加一个闪亮的表情符号(任何应用程序的关键功能)。为此,我们将导入 React 的useState组件,并为我们的组件定义一些初始状态,即空字符串(换句话说,没有闪光)。
import React, { useState } from 'react';
function Sparkle() {
// declare our initial component state
// this a variable of 'sparkle' which is an empty string
// we've also defined an 'addSparkle' function, which
// we'll call in our click handler
const [sparkle, addSparkle] = useState('');
return (
<div>
<p>{sparkle}</p>
</div>
);
}
export default Sparkle;
什么是状态?
我们将在第十五章中更详细地讨论状态,但现在知道状态表示组件内可能会变化的任何信息的当前状态可能会有所帮助。例如,如果 UI 组件有复选框,则当复选框选中时它具有true状态,而当未选中时则具有false状态。
现在我们可以通过添加具有onClick功能的按钮来完成我们的组件。请注意 JSX 中需要使用驼峰命名法:
import React, { useState } from 'react';
function Sparkle() {
// declare our initial component state
// this a variable of 'sparkle' which is an empty string
// we've also defined an 'addSparkle' function, which
// we'll call in our click handler
const [sparkle, addSparkle] = useState('');
return (
<div>
<button onClick={() => addSparkle(sparkle + '\u2728')}>
Add some sparkle
</button>
<p>{sparkle}</p>
</div>
);
}
export default Sparkle;
要使用我们的组件,我们可以将其导入到src/App.js文件中,并将其声明为 JSX 元素,如下所示:
import React from 'react';
import './App.css';
// import our Sparkle component
import Sparkle from './Sparkle'
function App() {
const name = 'Adam';
let now = String(new Date());
return (
<div className="App">
<p>Hello {name}!</p>
<p>The current time is {now}</p>
<p>Two plus two is {2+2}</p>
<Sparkle />
</div>
);
}
export default App;
现在如果您在浏览器中访问我们的应用程序,您应该能看到我们的按钮,并能够单击它将闪光表情符号添加到页面上!这展示了 React 的真正超能力之一。我们能够独立于应用程序的其余部分重新渲染单个组件或组件的元素(图 11-2)。

图 11-2. 单击按钮更新组件状态并向页面添加内容
现在我们已经使用create-react-app创建了一个新应用程序,更新了Application组件的 JSX,创建了一个新组件,声明了一个组件状态,并动态更新了一个组件。通过对这些基础知识的基本理解,我们现在可以准备使用 React 在 JavaScript 中开发声明式 UI。
结论
我们被各种设备上的用户界面所包围。JavaScript 和 Web 技术提供了在多种平台上开发这些界面的无与伦比的机会,使用统一的技术组合。同时,React 和其他声明式视图库使我们能够构建强大、动态的应用程序。这些技术的结合使开发人员能够在不需要为每个平台专门的知识的情况下构建令人惊叹的东西。在接下来的章节中,我们将通过利用 GraphQL API 来为 Web、桌面和本机移动应用程序构建界面来实践这一点。
第十二章:使用 React 构建 Web 客户端
超文本背后的最初想法是将相关文档链接在一起:如果学术论文 A 引用学术论文 B,让点击导航至它们之间变得容易。1989 年,CERN 的软件工程师 Tim Berners-Lee 提出了将超文本与网络化计算机结合起来的想法,使得任何人都能轻松地进行这些连接,无论文档位于何处。每张猫照片、新闻文章、推文、流媒体视频、职位搜索网站和餐厅评论都深受全球链接文档这一简单想法的影响。
在其核心,Web 仍然是连接文档的媒介。每个页面都是 HTML,在 Web 浏览器中呈现,使用 CSS 进行样式化,JavaScript 进行增强。今天,我们使用这些技术来构建从个人博客和小手册网站到复杂交互式应用程序的一切。其根本优势在于 Web 提供了普遍访问。任何人只需在连接到 Web 的设备上使用 Web 浏览器,即可创建一个默认包容性的环境。
我们正在构建什么
在接下来的章节中,我们将为我们的社交笔记应用程序 Notedly 构建 Web 客户端。用户将能够创建并登录账户,在 Markdown 中编写笔记,编辑他们的笔记,查看其他用户笔记的动态,并“收藏”其他用户的笔记。为了实现所有这些功能,我们将与我们的 GraphQL 服务器 API 进行交互。
在我们的 Web 应用程序中:
-
用户将能够创建笔记,以及阅读、更新和删除他们创建的笔记。
-
用户将能够查看其他用户创建的笔记动态,并阅读其他用户创建的单个笔记,尽管他们将无法对其进行更新或删除。
-
用户将能够创建账户、登录和注销。
-
用户将能够检索他们的个人资料信息以及其他用户的公共个人资料信息。
-
用户将能够收藏其他用户的笔记,并检索他们收藏的列表。
这些功能将涵盖很多内容,但我们将在本书的这一部分中分解它们为小块。一旦你学会使用所有这些功能构建 React 应用程序,你将能够将这些工具和技术应用于构建各种丰富的 Web 应用程序。
如何构建这个项目
正如你可能猜到的那样,为了构建这个应用程序,我们将使用 React 作为客户端 JavaScript 库。此外,我们将从我们的 GraphQL API 查询数据。为了帮助查询、变更和缓存数据,我们将使用Apollo Client。Apollo Client 包括一系列用于处理 GraphQL 的开源工具。我们将使用该库的 React 版本,但 Apollo 团队还开发了 Angular、Vue、Scala.js、Native iOS 和 Native Android 集成。
其他 GraphQL 客户端库
尽管本书中我们将使用 Apollo,但这并不是唯一可用的 GraphQL 客户端选项。Facebook 的Relay和 Formiddable 的urql是另外两个流行的选择。
此外,我们将使用Parcel作为我们的代码打包工具。代码打包工具允许我们使用在 Web 浏览器中可能不可用的 JavaScript 功能(例如,较新的语言特性、代码模块、代码最小化),并将它们打包以在浏览器环境中使用。Parcel 是一个无需配置的替代应用构建工具,如Webpack。它提供了许多好用的功能,如代码分割和开发过程中的自动更新浏览器(也称为热模块替换),但无需设置构建链。正如你在前一章节中看到的,create-react-app也提供了一个零配置的初始设置,使用 Webpack 在后台工作,但 Parcel 允许我们从头开始构建我们的应用程序,这种方式对于学习来说是理想的。
开始入门
在我们开始开发之前,我们需要将项目起始文件复制到我们的机器上。项目源代码包含了我们开发应用所需的所有脚本和第三方库的引用。要将代码克隆到本地机器上,请打开终端,导航到你项目存储的目录,并git clone项目仓库。如果你已经完成了 API 章节,可能已经创建了一个notedly目录来保持项目代码的组织:
# change into the Projects directory
$ cd
$ cd Projects
$ # type the `mkdir notedly` command if you don't yet have a notedly directory
$ cd notedly
$ git clone git@github.com:javascripteverywhere/web.git
$ cd web
$ npm install
安装第三方依赖
通过复制书籍的起始代码并在目录中运行npm install,你可以避免再次为任何单独的第三方依赖运行npm install。
代码结构如下:
/src
这是你应该在跟随书籍进行开发时执行的目录。
/solutions
此目录包含每章节的解决方案。如果你遇到困难,可以参考这些解决方案。
/final
此目录包含最终的工作项目。
现在你已经在本地机器上获取了代码,你需要复制项目的.env文件。这个文件是用来存放特定于我们工作环境的变量的地方。例如,当我们在本地工作时,我们将指向本地实例的 API,但当我们部署应用时,我们将指向远程部署的 API。要复制示例.env文件,请在终端中从web目录输入以下内容:
$ cp .env.example .env
现在你应该在目录中看到一个.env文件。你目前不需要对这个文件做任何操作,但随着我们开发 API 后端,我们将逐步向其中添加信息。项目附带的.gitignore文件将确保你不会意外地提交.env文件。
帮助,我看不到.env 文件!
默认情况下,操作系统会隐藏以点开头的文件,因为这些文件通常是系统使用的,而不是用户使用的。如果你看不到.env文件,请尝试在你的文本编辑器中打开该目录。该文件应该在编辑器的文件浏览器中可见。或者,你可以在终端窗口中输入ls -a来列出当前工作目录中的文件。
构建 Web 应用程序
有了我们本地克隆的起始代码,我们准备构建我们的 React Web 应用程序。让我们首先查看我们的src/index.html文件。这看起来像是一个标准的但完全空白的 HTML 文件,但请注意以下两行:
<div id="root"></div>
<script src="./App.js"></script>
这两行对我们的 React 应用程序非常重要。root <div>将为整个应用程序提供容器。与此同时,App.js文件将是我们 JavaScript 应用程序的入口点。
现在我们可以在我们的src/App.js文件中开始开发我们的 React 应用程序了。如果你在上一章节的 React 介绍中跟随操作,这可能会感觉很熟悉。在src/App.js中,我们首先导入了react和react-dom库:
import React from 'react';
import ReactDOM from 'react-dom';
现在我们将创建一个名为App的函数,它将返回我们应用程序的内容。目前,这将简单地是包含在一个<div>元素中的两行 HTML:
const App = () => {
return (
<div>
<h1>Hello Notedly!</h1>
<p>Welcome to the Notedly application</p>
</div>
);
};
为什么这么多的 div 标签?
如果你刚开始学习 React,你可能会对围绕组件使用<div>标签的倾向感到奇怪。React 组件必须包含在父元素中,这通常是一个<div>标签,但也可以是任何其他适当的 HTML 标签,比如<section>、<header>或<nav>。如果包含 HTML 标签感觉多余,我们可以在 JavaScript 代码中使用<React.Fragment>或空的<>标签来包含组件。
最后,我们将指示 React 在具有 ID 为root的元素中渲染我们的应用程序,通过添加以下内容:
ReactDOM.render(<App />, document.getElementById('root'));
我们src/App.js文件的完整内容现在应该是:
import React from 'react';
import ReactDOM from 'react-dom';
const App = () => {
return (
<div>
<h1>Hello Notedly!</h1>
<p>Welcome to the Notedly application</p>
</div>
);
};
ReactDOM.render(<App />, document.getElementById('root'));
完成后,让我们在网页浏览器中查看一下。通过在终端应用程序中输入npm run dev来启动本地开发服务器。一旦代码被打包,访问http://localhost:1234来查看页面(参见图 12-1)。

图 12-1. 我们在浏览器中运行的初始 React 应用程序
路由
网页的一个显著特点是能够链接文档。类似地,对于我们的应用程序,我们希望用户能够在屏幕或页面之间导航。在 HTML 渲染的应用程序中,这将涉及创建多个 HTML 文档。每当用户导航到新文档时,整个文档将重新加载,即使两个页面有共享的部分,如页眉或页脚。
在 JavaScript 应用程序中,我们可以利用客户端路由。在许多方面,这与 HTML 链接类似。用户将点击链接,URL 将更新,然后他们将导航到新的屏幕。不同之处在于,我们的应用程序将仅更新已更改内容的页面。体验会非常流畅和“应用程序般”,这意味着页面不会出现可见的刷新。
在 React 中,最常用的路由库是 React Router。这个库使我们能够为 React Web 应用程序添加路由功能。要向我们的应用程序引入路由,让我们首先创建一个 src/pages 目录,并添加以下文件:
-
/src/pages/index.js
-
/src/pages/home.js
-
/src/pages/mynotes.js
-
/src/pages/favorites.js
我们的 home.js、mynotes.js 和 favorites.js 文件将是我们的各个页面组件。我们可以为每个组件创建一些初始内容和一个 effect 钩子,当用户导航到页面时更新文档标题。
在 src/pages/home.js:
import React from 'react';
const Home = () => {
return (
<div>
<h1>Notedly</h1>
<p>This is the home page</p>
</div>
);
};
export default Home;
在 src/pages/mynotes.js:
import React, { useEffect } from 'react';
const MyNotes = () => {
useEffect(() => {
// update the document title
document.title = 'My Notes — Notedly';
});
return (
<div>
<h1>Notedly</h1>
<p>These are my notes</p>
</div>
);
};
export default MyNotes;
在 src/pages/favorites.js:
import React, { useEffect } from 'react';
const Favorites = () => {
useEffect(() => {
// update the document title
document.title = 'Favorites — Notedly';
});
return (
<div>
<h1>Notedly</h1>
<p>These are my favorites</p>
</div>
);
};
export default Favorites;
useEffect
在上述示例中,我们使用了 React 的 useEffect 钩子来设置页面的标题。Effect 钩子允许我们在组件中包含副作用,更新与组件本身无关的内容。如果您感兴趣,React 的文档提供了对 effect hooks 的深入介绍。
现在,在 src/pages/index.js 中,我们将导入 React Router 和与 react-router-dom 包一起进行 Web 浏览器路由所需的方法:
import React from 'react';
import { BrowserRouter as Router, Route } from 'react-router-dom';
接下来,我们将导入刚刚创建的页面组件:
import Home from './home';
import MyNotes from './mynotes';
import Favorites from './favorites';
最后,我们将为我们创建的每个页面组件指定特定的 URL 作为路由。请注意我们在“Home”路由中使用 exact,这将确保只为根 URL 渲染主页组件:
const Pages = () => {
return (
<Router>
<Route exact path="/" component={Home} />
<Route path="/mynotes" component={MyNotes} />
<Route path="/favorites" component={Favorites} />
</Router>
);
};
export default Pages;
我们完整的 src/pages/index.js 文件现在应该如下所示:
// import React and routing dependencies
import React from 'react';
import { BrowserRouter as Router, Route } from 'react-router-dom';
// import routes
import Home from './home';
import MyNotes from './mynotes';
import Favorites from './favorites';
// define routes
const Pages = () => {
return (
<Router>
<Route exact path="/" component={Home} />
<Route path="/mynotes" component={MyNotes} />
<Route path="/favorites" component={Favorites} />
</Router>
);
};
export default Pages;
最后,我们可以更新 src/App.js 文件,通过导入路由和渲染组件来使用我们的路由:
import React from 'react';
import ReactDOM from 'react-dom';
// import routes
import Pages from '/pages';
const App = () => {
return (
<div>
<Pages />
</div>
);
};
ReactDOM.render(<App />, document.getElementById('root'));
现在,如果您在 Web 浏览器中手动更新 URL,您应该能够查看每个组件。例如,输入 http://localhost:1234/favorites 来渲染“favorites”页面。
链接
我们已经创建了我们的页面,但我们缺少将它们链接在一起的关键组件。因此,让我们从主页添加一些链接到其他页面。为此,我们将使用 React Router 的 Link 组件。
在 src/pages/home.js:
import React from 'react';
// import the Link component from react-router
import { Link } from 'react-router-dom';
const Home = () => {
return (
<div>
<h1>Notedly</h1>
<p>This is the home page</p>
{ /* add a list of links */ }
<ul>
<li>
<Link to="/mynotes">My Notes</Link>
</li>
<li>
<Link to="/favorites">Favorites</Link>
</li>
</ul>
</div>
);
};
export default Home;
有了这个,我们就能够在我们的应用程序中导航。点击主页上的链接将导航到相应的页面组件。核心浏览器导航功能,如后退和前进按钮,仍将继续工作。
用户界面组件
我们已经成功地创建了各自的页面组件,并可以在它们之间导航。随着我们构建页面,它们将具有几个共享的用户界面元素,例如头部和全站导航。每次重写它们将不会很有效(而且会变得非常烦人)。相反,我们可以编写可重用的界面组件,并将它们导入到我们的界面中,无论我们何时需要它们。实际上,将我们的 UI 视为由小组件组成是 React 的核心思想之一,并且这是我理解该框架的突破口。
我们将从为应用程序创建头部和导航组件开始。首先,在我们的 src 目录中创建一个名为 components 的新目录。在 src/components 目录中,我们将创建两个新文件,分别命名为 Header.js 和 Navigation.js。React 组件必须大写开头,因此我们将遵循大写文件名的常见约定。
让我们从 src/components/Header.js 中编写头部组件开始。为此,我们将导入我们的 logo.svg 文件,并为我们的组件添加相应的标记:
import React from 'react';
import logo from '../img/logo.svg';
const Header = () => {
return (
<header>
<img src={logo} alt="Notedly Logo" height="40" />
<h1>Notedly</h1>
</header>
);
};
export default Header;
对于我们的导航组件,我们将导入 React Router 的 Link 功能,并标记一个链接的无序列表。在 src/components/Navigation.js 中:
import React from 'react';
import { Link } from 'react-router-dom';
const Navigation = () => {
return (
<nav>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/mynotes">My Notes</Link>
</li>
<li>
<Link to="/favorites">Favorites</Link>
</li>
</ul>
</nav>
);
};
export default Navigation;
在屏幕截图中,您将看到我还包含了表情符号作为导航图标。如果您也想这样做,那么包含表情符号的可访问标记如下所示:
<span aria-hidden="true" role="img">
<!-- emoji character -->
</span>
头部和导航组件完成后,我们现在可以在应用程序中使用它们。让我们更新我们的 src/pages/home.js 文件以包含这些组件。我们首先将它们导入,然后在 JSX 标记中包含组件。
我们的 src/pages/home.js 现在将如下所示(图 12-2):
import React from 'react';
import Header from '../components/Header';
import Navigation from '../components/Navigation';
const Home = () => {
return (
<div>
<Header />
<Navigation />
<p>This is the home page</p>
</div>
);
};
export default Home;

图 12-2. 使用 React 组件,我们能够轻松地组合可共享的 UI 特性。
这就是我们需要的一切,以便能够在应用程序中创建可共享的组件。关于在 UI 中使用组件,我强烈推荐阅读 React 文档页面 “在 React 中思考”。
结论
Web 仍然是分发应用程序的无与伦比的媒介。它将普遍访问与开发者实时更新的能力结合在一起。在本章中,我们在 React 中构建了我们 JavaScript Web 应用程序的基础。在下一章中,我们将使用 React 组件和 CSS-in-JS 为应用程序添加布局和样式。
第十三章:应用程序的样式化
在他 1978 年的歌曲《Lip Service》中,埃尔维斯·科斯特罗嘲笑地唱道:“不要表现得你高人一等,看看你的鞋子。” 这句告别语暗示着叙述者可以通过看到某人的鞋子来察觉到他们试图提升社会地位的尝试,无论他们的西装多么整洁,他们的礼服多么优雅。不管是好是坏,风格是人类文化的重要组成部分,我们都习惯于捕捉这些社交线索。考古学家甚至发现,上旧石器时代的人类用骨头、牙齿、浆果和石头制作了项链和手镯。我们的衣服不仅仅是保护我们免受自然元素侵害的功能目的,还可能向他人传达有关我们文化、社会地位、兴趣等信息。
一个 Web 应用程序在没有任何超出 Web 默认样式之外的东西时是可以工作的,但是通过应用 CSS,我们能够更清晰地与用户进行沟通。在本章中,我们将探讨如何使用 CSS-in-JS Styled Components 库为我们的应用程序引入布局和样式。这将使我们能够在可维护的基于组件的代码结构中创建一个更易用和美观的应用程序。
创建一个布局组件
许多或者在我们的情况下是所有应用程序的页面将共享一个通用的布局。例如,我们应用程序的所有页面都将有一个标题、一个侧边栏和一个内容区域(参见图 13-1)。我们可以不在每个页面组件中导入共享布局元素,而是专门为我们的布局创建一个组件,并将每个页面组件包装在其中。

图 13-1. 我们页面布局的线框图
要创建我们的组件,我们将从在src/components/Layout.js中创建一个新文件开始。在这个文件中,我们将导入我们的共享组件并布置我们的内容。我们的 React 组件函数将接收一个children属性,这将允许我们指定子内容在布局中的位置。我们还将利用空的<React.Fragment> JSX 元素来帮助避免多余的标记。
让我们在src/components/Layout.js中创建我们的组件:
import React from 'react';
import Header from './Header';
import Navigation from './Navigation';
const Layout = ({ children }) => {
return (
<React.Fragment>
<Header />
<div className="wrapper">
<Navigation />
<main>{children}</main>
</div>
</React.Fragment>
);
};
export default Layout;
现在在我们的src/pages/index.js文件中,我们可以将我们的页面组件包装在新创建的Layout组件中,以应用共享布局到每个页面:
// import React and routing dependencies
import React from 'react';
import { BrowserRouter as Router, Route } from 'react-router-dom';
// import shared layout component
import Layout from '../components/Layout';
// import routes
import Home from './home';
import MyNotes from './mynotes';
import Favorites from './favorites';
// define routes
const Pages = () => {
return (
<Router>
{/* Wrap our routes within the Layout component */}
<Layout>
<Route exact path="/" component={Home} />
<Route path="/mynotes" component={MyNotes} />
<Route path="/favorites" component={Favorites} />
</Layout>
</Router>
);
};
export default Pages;
最后一步是从我们的页面组件中删除任何<Header>或<Navigation>的实例。例如,我们的src/pages/Home.js文件现在将有以下简化的代码:
import React from 'react';
const Home = () => {
return (
<div>
<p>This is the home page</p>
</div>
);
};
export default Home;
完成这一步后,您可以在浏览器中查看您的应用程序。当您在路由之间导航时,您会看到我们的标题和导航链接出现在每个页面上。目前它们没有样式,我们的页面也没有视觉布局。让我们在下一节中探讨如何添加样式。
CSS
层叠样式表的命名很精确:它们是一组规则,允许我们为 Web 编写样式。“层叠”意味着最后定义或最具体的样式将被渲染。例如:
p {
color: green
}
p {
color: red
}
这段 CSS 将使所有段落呈现为红色,使 color: green 规则变得多余。这是一个非常简单的想法,但它产生了许多模式和技术,以帮助避免其缺点。CSS 结构技术,例如 BEM(块元素修饰符)、OOCSS(面向对象的 CSS)和 Atomic CSS 使用规范的类命名来帮助作用域化样式。预处理器如 SASS(Syntactically Awesome Stylesheets)和 Less(Leaner Stylesheets)提供了简化 CSS 语法和支持模块化文件的工具。尽管它们各有其优点,但在开发 React 或其他 JavaScript 驱动的应用程序时,CSS-in-JavaScript 提供了一个引人注目的用例。
CSS 框架有何特点?
CSS 和 UI 框架是开发应用程序的一种流行选择,理由充足。它们提供了坚实的样式基线,并通过为常见的应用程序模式提供样式和功能来减少开发人员编写的代码量。然而,使用这些框架的应用程序可能会变得视觉上相似,并且可能会增加文件捆绑大小。然而,对你来说,这种权衡可能是值得的。我个人在使用 React 时喜欢的一些 UI 框架包括 Ant Design,Bootstrap,Grommet,和 Rebass。
CSS-in-JS
当我第一次接触 CSS-in-JS 时,我的第一反应是恐惧。我在网页标准时代度过了我网页开发职业生涯的关键时期。我继续倡导访问可及性和合理的渐进增强,以支持 Web 开发。“关注点分离”已经成为我网页实践的核心准则超过十年。所以,如果你像我一样,仅仅是看到“CSS-in-JS”就让你感到不舒服,你并不孤单。然而,一旦我真正(并没有带有偏见地)尝试过,我很快就被说服了。CSS-in-JS 让我们能够轻松地将用户界面看作一系列组件,这是多年来我一直试图用结构技术和 CSS 预处理器做到的事情。
在本书中,我们将使用 Styled Components 作为我们的 CSS-in-JS 库。它快速、灵活,在积极开发中,并且是最流行的 CSS-in-JS 库。它还被 Airbnb、Reddit、Patreon、Lego、BBC News、Atlassian 等公司使用。
Styled Components 库通过允许我们使用 JavaScript 模板字面量语法来定义元素的样式工作。我们创建一个 JavaScript 变量,该变量将引用一个 HTML 元素及其相关样式。因为这听起来相当抽象,让我们看一个简单的例子:
import React from 'react';
import styled from 'styled-components'
const AlertParagraph = styled.p`
color: green;
`;
const ErrorParagraph = styled.p`
color: red;
`;
const Example = () => {
return (
<div>
<AlertParagraph>This is green.</AlertParagraph>
<ErrorParagraph>This is red.</ErrorParagraph>
</div>
);
};
export default Example;
正如您所见,我们可以轻松地将样式限定在特定组件中。此外,我们将样式限定到特定组件有助于避免在应用程序不同部分之间的类名冲突。
创建按钮组件
现在,我们对样式化组件有了基本的了解,让我们将它们集成到我们的应用程序中。首先,我们将为 <button> 元素编写一些样式,这样我们就可以在整个应用程序中重用该组件。在之前的示例中,我们将样式与 React/JSX 代码一起集成,但我们也可以编写独立的样式组件。首先,在 src/components/Button.js 创建一个新文件,从 styled-components 中导入 styled 库,并设置可导出的组件为模板文字,如下所示:
import styled from 'styled-components';
const Button = styled.button`
/* our styles will go here */
`;
export default Button;
有了这个组件,我们可以填写一些基线按钮样式,以及鼠标悬停和激活状态样式,如下所示:
import styled from 'styled-components';
const Button = styled.button`
display: block;
padding: 10px;
border: none;
border-radius: 5px;
font-size: 18px;
color: #fff;
background-color: #0077cc;
cursor: pointer;
:hover {
opacity: 0.8;
}
:active {
background-color: #005fa3;
}
`;
export default Button;
现在,我们可以在整个应用程序中使用我们的按钮。例如,在应用程序的主页上使用它,我们可以导入该组件并在通常使用 <button> 的任何地方使用 <Button> 元素。
在 src/pages/home.js 中:
import React from 'react';
import Button from '../components/Button';
const Home = () => {
return (
<div>
<p>This is the home page</p>
<Button>Click me!</Button>
</div>
);
};
export default Home;
通过这样做,我们已经编写了一个样式组件,可以在应用程序的任何地方使用。这对于可维护性非常有利,因为我们可以轻松地找到并跨整个代码库更改我们的样式。此外,我们可以将样式组件与标记耦合,从而创建小型、可重用和可维护的组件。
添加全局样式
虽然我们的许多样式将包含在各个组件中,但每个站点或应用程序还有一组全局样式(如 CSS 重置、字体和基线颜色)。我们可以创建一个 GlobalStyle.js 组件来管理这些样式。
这与我们之前的示例有些不同,因为我们将创建一个样式表而不是附加到特定 HTML 元素的样式。为了实现这一点,我们将从styled-components中导入createGlobalStyle模块。我们还将导入normalize.css库以确保在各种浏览器中对 HTML 元素进行一致的渲染。最后,我们将为应用程序的 HTML body 添加一些全局规则和默认链接样式。
在 src/components/GlobalStyle.js 中:
// import createGlobalStyle and normalize
import { createGlobalStyle } from 'styled-components';
import normalize from 'normalize.css';
// we can write our CSS as a JS template literal
export default createGlobalStyle`
${normalize}
*, *:before, *:after {
box-sizing: border-box;
}
body,
html {
height: 100%;
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
background-color: #fff;
line-height: 1.4;
}
a:link,
a:visited {
color: #0077cc;
}
a:hover,
a:focus {
color: #004499;
}
code,
pre {
max-width: 100%;
}
`;
要应用这些样式,我们将它们导入到我们的 App.js 文件中,并将 <GlobalStyle /> 元素添加到我们的应用程序中:
import React from 'react';
import ReactDOM from 'react-dom';
// import global styles
import GlobalStyle from '/components/GlobalStyle';
// import routes
import Pages from '/pages';
const App = () => {
return (
<div>
<GlobalStyle />
<Pages />
</div>
);
};
ReactDOM.render(<App />, document.getElementById('root'));
这样,我们的全局样式将应用到应用程序中。当您在浏览器中预览应用程序时,您将看到字体类型已更改,链接具有新的样式,并且已删除了边距(图 13-2)。

图 13-2. 我们的应用程序现在应用了全局样式
组件样式
现在我们已经为应用程序应用了一些全局样式,我们可以开始为单个组件添加样式。在这个过程中,我们还会介绍应用程序的整体布局。对于每个我们要样式化的组件,我们首先从 styled-components 中导入 styled 库。然后,我们将一些元素样式定义为变量。最后,我们将在 React 组件的 JSX 中使用这些元素。
样式化组件命名
为了避免与 HTML 元素冲突,我们必须将样式化组件的名称大写化。
我们可以从 src/components/Layout.js 开始,在这里为应用程序的布局结构 <div> 和 <main> 标签添加样式。
import React from 'react';
import styled from 'styled-components';
import Header from './Header';
import Navigation from './Navigation';
// component styles
const Wrapper = styled.div`
/* We can apply media query styles within the styled component */
/* This will only apply the layout for screens above 700px wide */
@media (min-width: 700px) {
display: flex;
top: 64px;
position: relative;
height: calc(100% - 64px);
width: 100%;
flex: auto;
flex-direction: column;
}
`;
const Main = styled.main`
position: fixed;
height: calc(100% - 185px);
width: 100%;
padding: 1em;
overflow-y: scroll;
/* Again apply media query styles to screens above 700px */
@media (min-width: 700px) {
flex: 1;
margin-left: 220px;
height: calc(100% - 64px);
width: calc(100% - 220px);
}
`;
const Layout = ({ children }) => {
return (
<React.Fragment>
<Header />
<Wrapper>
<Navigation />
<Main>{children}</Main>
</Wrapper>
</React.Fragment>
);
};
export default Layout;
完成了 Layout.js 组件后,我们可以为 Header.js 和 Navigation.js 文件添加一些样式:
在 src/components/Header.js 文件中:
import React from 'react';
import styled from 'styled-components';
import logo from '../img/logo.svg';
const HeaderBar = styled.header`
width: 100%;
padding: 0.5em 1em;
display: flex;
height: 64px;
position: fixed;
align-items: center;
background-color: #fff;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.25);
z-index: 1;
`;
const LogoText = styled.h1`
margin: 0;
padding: 0;
display: inline;
`;
const Header = () => {
return (
<HeaderBar>
<img src={logo} alt="Notedly Logo" height="40" />
<LogoText>Notedly</LogoText>
</HeaderBar>
);
};
export default Header;
最后,在 src/components/Navigation.js 文件中:
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
const Nav = styled.nav`
padding: 1em;
background: #f5f4f0;
@media (max-width: 700px) {
padding-top: 64px;
}
@media (min-width: 700px) {
position: fixed;
width: 220px;
height: calc(100% - 64px);
overflow-y: scroll;
}
`;
const NavList = styled.ul`
margin: 0;
padding: 0;
list-style: none;
line-height: 2;
/* We can nest styles in styled-components */
/* The following styles will apply to links within the NavList component */
a {
text-decoration: none;
font-weight: bold;
font-size: 1.1em;
color: #333;
}
a:visited {
color: #333;
}
a:hover,
a:focus {
color: #0077cc;
}
`;
const Navigation = () => {
return (
<Nav>
<NavList>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/mynotes">My Notes</Link>
</li>
<li>
<Link to="/favorites">Favorites</Link>
</li>
</NavList>
</Nav>
);
};
export default Navigation;
经过这些样式的应用,我们现在拥有了一个完全样式化的应用程序(图 13-3)。在未来,我们可以在创建各个组件时应用样式。

图 13-3. 我们应用了样式的应用程序
结论
在本章中,我们为应用程序引入了布局和样式。使用 CSS-in-JS 库 Styled Components 允许我们编写简洁且作用域正确的 CSS 样式。这些样式可以应用于单个组件或全局应用中。在下一章中,我们将通过实现 GraphQL 客户端并调用 API 来朝着一个功能完备的应用程序迈进。
第十四章:使用 Apollo Client
我清楚地记得我的第一个互联网连接。我的计算机调制解调器会拨打连接到我的互联网服务提供商(ISP)的本地号码,让我自由地访问网络。尽管当时这种感觉很神奇,但与我们今天使用的即时、始终在线的连接方式相比,有很大的不同。以下是该过程的步骤:
-
坐在我的电脑前,打开 ISP 软件。
-
点击连接,等待调制解调器拨号连接号码。
-
如果连接成功,听到那美妙的“调制解调器声音”。如果没有,例如在高峰时段电话线可能过载和忙碌时,再试一次。
-
连接成功后,将接收到成功通知,并且沉浸在所有带有 GIF 的 90 年代辉煌的网络中。
这个过程可能看起来很艰苦,但它仍然代表了服务之间交流的方式:它们请求连接,建立连接,发送请求,然后得到返回的东西。我们的客户端应用程序将以相同的方式工作。我们将首先连接到我们的服务器 API 应用程序,如果成功,将向该服务器发出请求。
在本章中,我们将使用 Apollo Client 来连接我们的 API。一旦连接成功,我们将编写一个 GraphQL 查询,用于在页面上显示数据。我们还将介绍分页,既在 API 查询中,也在我们的界面组件中。
本地运行 API
我们的 Web 客户端应用程序的开发需要访问本地的 API 实例。如果您一直在跟着本书进行学习,您可能已经在您的机器上运行了 Notedly API 和其数据库。如果没有,我已经在 附录 A 中添加了如何获取 API 的副本以及一些示例数据的说明。如果您已经运行了 API,但希望获得一些额外的数据来进行操作,请在 API 项目目录的根目录中运行 npm run seed。
设置 Apollo Client
与 Apollo Server 类似,Apollo Client 提供了许多有用的功能,以简化 JavaScript UI 应用程序中使用 GraphQL 的工作。Apollo Client 提供了连接 Web 客户端到 API 的库、本地缓存、GraphQL 语法、本地状态管理等功能。我们还将在 React 应用程序中使用 Apollo Client,但 Apollo 还为 Vue、Angular、Meteor、Ember 和 Web 组件提供了库。
首先,我们要确保我们的 .env 文件包含对我们本地 API URI 的引用。这将允许我们在开发中使用我们的本地 API 实例,同时在发布应用程序到公共 Web 服务器时指向我们的产品 API。在我们的 .env 文件中,我们应该有一个名为 API_URI 的变量,其值是我们本地 API 服务器的地址:
API_URI=http://localhost:4000/api
我们的代码打包工具 Parcel 配置为自动处理 .env 文件。每当我们在代码中引用 .env 变量时,我们可以使用 process.env.VARIABLE_NAME。这将允许我们在本地开发、生产和任何其他环境(如分段或持续集成)中使用唯一的值。
将地址存储在环境变量中后,我们可以连接我们的 Web 客户端到我们的 API 服务器。在我们的 src/App.js 文件中,首先我们需要导入我们将要使用的 Apollo 包:
// import Apollo Client libraries
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client';
导入这些后,我们可以配置一个新的 Apollo Client 实例,传递 API URI,初始化缓存,并启用本地 Apollo 开发工具的使用:
// configure our API URI & cache
const uri = process.env.API_URI;
const cache = new InMemoryCache();
// configure Apollo Client
const client = new ApolloClient({
uri,
cache,
connectToDevTools: true
});
最后,我们可以通过将其包装在 ApolloProvider 中来将我们的 React 应用程序连接到我们的 Apollo Client。我们将空的 <div> 标签替换为 <ApolloProvider>,并包括我们的客户端作为连接:
const App = () => {
return (
<ApolloProvider client={client}>
<GlobalStyle />
<Pages />
</ApolloProvider>
);
};
总体而言,我们的 src/App.js 文件现在将如下所示:
import React from 'react';
import ReactDOM from 'react-dom';
// import Apollo Client libraries
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client';
// global styles
import GlobalStyle from '/components/GlobalStyle';
// import our routes
import Pages from '/pages';
// configure our API URI & cache
const uri = process.env.API_URI;
const cache = new InMemoryCache();
// configure Apollo Client
const client = new ApolloClient({
uri,
cache,
connectToDevTools: true
});
const App = () => (
<ApolloProvider client={client}>
<GlobalStyle />
<Pages />
</ApolloProvider>
);
ReactDOM.render(<App />, document.getElementById('root'));
现在我们的客户端连接到我们的 API 服务器后,我们现在可以将 GraphQL 查询和突变集成到我们的应用程序中。
查询一个 API
当我们查询 API 时,我们正在请求数据。在 UI 客户端中,我们希望能够查询这些数据并将其显示给用户。Apollo 能够帮助我们组合查询以获取数据。我们可以更新 React 组件以将数据显示给最终用户。我们可以通过编写一个 noteFeed 查询来探索查询的使用,该查询将向用户返回最新的笔记并在应用程序的主页上显示它。
当我首次编写查询时,我发现以下过程很有用:
-
考虑查询需要返回的数据。
-
将查询写入 GraphQL Playground。
-
将查询集成到客户端应用程序中。
让我们在起草查询时遵循这个流程。如果你在本书的 API 部分跟随过,你可能还记得 noteFeed 查询返回了一个包含 10 条笔记的列表,还有一个 cursor,指示了最后返回的笔记位置,以及一个 hasNextPage 布尔值,用于确定是否还有其他笔记需要加载。我们可以在 GraphQL Playground 中查看我们的架构,以查看所有可用的数据选项。对于我们的查询,我们很可能需要以下信息:
{
cursor
hasNextPage
notes {
id
createdAt
content
favoriteCount
author {
id
username
avatar
}
}
}
现在,在我们的 GraphQL Playground 中,我们可以将其充实为一个 GraphQL 查询。我们将稍微详细地命名查询,并提供一个名为 cursor 的可选变量。要使用 GraphQL Playground,请确保 API 服务器正在运行,然后访问 http://localhost:4000/api。在 GraphQL Playground 中,添加以下查询:
query noteFeed($cursor: String) {
noteFeed(cursor: $cursor) {
cursor
hasNextPage
notes {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
}
在 GraphQL Playground 中,还可以添加一个“查询变量”来测试变量的使用:
{
"cursor": ""
}
要测试这个变量,用数据库中任何笔记的 ID 值替换空字符串(图 14-1)。

图 14-1. 在 GraphQL Playground 中的我们的 noteFeed 查询
现在我们知道我们的查询已经正确编写,我们可以自信地将其集成到我们的 Web 应用程序中。在 src/pages/home.js 文件中,导入 useQuery 库以及通过 @apollo/client 中的 gql 库导入 GraphQL 语法:
// import the required libraries
import { useQuery, gql } from '@apollo/client';
// our GraphQL query, stored as a variable
const GET_NOTES = gql`
query NoteFeed($cursor: String) {
noteFeed(cursor: $cursor) {
cursor
hasNextPage
notes {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
}
`;
现在我们可以将查询集成到我们的 React 应用程序中了。为此,我们将我们的 GraphQL 查询字符串传递给 Apollo 的 useQuery React hook。我们的 hook 将返回一个包含以下值之一的对象:
data
如果查询成功返回的数据。
loading
当数据正在获取时,设置为 true 的加载状态。这允许我们向用户显示加载指示器。
error
如果我们的数据获取失败,错误将返回到我们的应用程序。
我们可以更新我们的 Home 组件以包括我们的查询:
const Home = () => {
// query hook
const { data, loading, error, fetchMore } = useQuery(GET_NOTES);
// if the data is loading, display a loading message
if (loading) return <p>Loading...</p>;
// if there is an error fetching the data, display an error message
if (error) return <p>Error!</p>;
// if the data is successful, display the data in our UI
return (
<div>
{console.log(data)}
The data loaded!
</div>
);
};
export default Home;
如果您一切顺利,您应该在我们应用程序的主页上看到一个“数据已加载!”的消息(图 14-2)。我们还包含了一个 console.log 语句,它将我们的数据打印到浏览器控制台。查看数据结果的结构可以帮助我们将数据集成到应用程序中。

图 14-2. 如果我们的数据成功获取,我们的组件将显示一个“数据已加载!”的消息,并且数据将打印到控制台
现在,让我们将接收到的数据集成到应用程序中。为此,我们将对返回的注释数组进行 map 操作。React 要求每个结果都分配一个唯一的键,我们将使用每个注释的 ID。首先,我们将显示每个注释的作者的用户名:
const Home = () => {
// query hook
const { data, loading, error, fetchMore } = useQuery(GET_NOTES);
// if the data is loading, display a loading message
if (loading) return <p>Loading...</p>;
// if there is an error fetching the data, display an error message
if (error) return <p>Error!</p>;
// if the data is successful, display the data in our UI
return (
<div>
{data.noteFeed.notes.map(note => (
<div key={note.id}>{note.author.username}</div>
))}
</div>
);
};
使用 JavaScript 的 map() 方法
如果您之前没有使用过 JavaScript 的 map() 方法,该语法一开始可能看起来有点复杂。map() 方法允许您对数组中的每个项执行操作。当您处理从 API 返回的数据时,这将非常有用,允许您执行诸如在模板中以某种方式显示每个项的操作。要了解更多关于 map() 的信息,建议阅读 MDN Web Docs 指南。
如果我们的数据库中有数据,您现在应该在页面上看到一个用户名列表(图 14-3)。

图 14-3. 从我们的数据中打印到屏幕上的用户名
现在我们成功地映射了我们的数据,我们可以编写剩余的组件部分了。由于我们的注释是用 Markdown 编写的,让我们导入一个允许我们将 Markdown 渲染到页面的库。
在 src/pages/home.js 中:
import ReactMarkdown from 'react-markdown';
现在我们可以更新我们的 UI,以包括作者的头像、作者的用户名、注释创建日期、注释的收藏数量,以及注释的内容本身。在 src/pages/home.js 中:
// if the data is successful, display the data in our UI
return (
<div>
{data.noteFeed.notes.map(note => (
<article key={note.id}>
<img
src={note.author.avatar}
alt={`${note.author.username} avatar`}
height="50px"
/>{' '}
{note.author.username} {note.createdAt} {note.favoriteCount}{' '}
<ReactMarkdown source={note.content} />
</article>
))}
</div>
);
在 React 中的空白处
React 会删除新行上元素之间的空格。 在我们的标记中使用{' '}是手动添加空格的一种方式。
现在您应该在浏览器中看到完整的注释列表。 在我们开始为它们添加样式之前,有一个小的重构机会。 这是我们第一页显示注释,但我们知道我们将会创建更多页面。 在其他页面上,我们将需要显示单个注释,以及其他类型注释的反馈(如“我的注释”和“收藏夹”)。 让我们继续创建两个新组件:src/components/Note.js和src/components/NoteFeed.js。
在src/components/Note.js中,我们将包含单个注释的标记。 为了实现这一目标,我们将为每个组件函数传递一个包含适当内容的属性。
import React from 'react';
import ReactMarkdown from 'react-markdown';
const Note = ({ note }) => {
return (
<article>
<img
src={note.author.avatar}
alt="{note.author.username} avatar"
height="50px"
/>{' '}
{note.author.username} {note.createdAt} {note.favoriteCount}{' '}
<ReactMarkdown source={note.content} />
</article>
);
};
export default Note;
现在轮到src/components/NoteFeed.js组件了:
import React from 'react';
import Note from './Note';
const NoteFeed = ({ notes }) => {
return (
<div>
{notes.map(note => (
<div key={note.id}>
<Note note={note} />
</div>
))}
</div>
);
};
export default NoteFeed;
最后,我们可以更新src/pages/home.js组件以引用我们的NoteFeed:
import React from 'react';
import { useQuery, gql } from '@apollo/client';
import Button from '../components/Button';
import NoteFeed from '../components/NoteFeed';
const GET_NOTES = gql`
query NoteFeed($cursor: String) {
noteFeed(cursor: $cursor) {
cursor
hasNextPage
notes {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
}
`;
const Home = () => {
// query hook
const { data, loading, error, fetchMore } = useQuery(GET_NOTES);
// if the data is loading, display a loading message
if (loading) return <p>Loading...</p>;
// if there is an error fetching the data, display an error message
if (error) return <p>Error!</p>;
// if the data is successful, display the data in our UI
return <NoteFeed notes={data.noteFeed.notes} />;
};
export default Home;
通过此重构,我们现在能够在整个应用程序中轻松重新创建注释和注释反馈实例。
一些样式
现在我们已经编写了组件并且可以查看我们的数据,我们可以添加一些样式。 其中最明显的改进机会之一是我们“创建于”日期显示的方式。 为了解决这个问题,我们将使用date-fns库,该库提供了用于在 JavaScript 中处理日期的小组件。 在src/components/Note.js中,导入该库并更新日期标记以应用转换,如下所示:
// import the format utility from `date-fns`
import { format } from 'date-fns';
// update the date markup to format it as Month, Day, and Year
{format(note.createdAt, 'MMM Do YYYY')} Favorites:{' '}
有了我们的日期格式化后,我们可以使用 Styled Components 库来更新注释布局:
import React from 'react';
import ReactMarkdown from 'react-markdown';
import { format } from 'date-fns';
import styled from 'styled-components';
// Keep notes from extending wider than 800px
const StyledNote = styled.article`
max-width: 800px;
margin: 0 auto;
`;
// Style the note metadata
const MetaData = styled.div`
@media (min-width: 500px) {
display: flex;
align-items: top;
}
`;
// add some space between the avatar and meta info
const MetaInfo = styled.div`
padding-right: 1em;
`;
// align 'UserActions' to the right on large screens
const UserActions = styled.div`
margin-left: auto;
`;
const Note = ({ note }) => {
return (
<StyledNote>
<MetaData>
<MetaInfo>
<img
src={note.author.avatar}
alt="{note.author.username} avatar"
height="50px"
/>
</MetaInfo>
<MetaInfo>
<em>by</em> {note.author.username} <br />
{format(note.createdAt, 'MMM Do YYYY')}
</MetaInfo>
<UserActions>
<em>Favorites:</em> {note.favoriteCount}
</UserActions>
</MetaData>
<ReactMarkdown source={note.content} />
</StyledNote>
);
};
export default Note;
我们还可以在NoteFeed.js组件中为我们的注释之间添加一些空间和轻微边框:
import React from 'react';
import styled from 'styled-components';
const NoteWrapper = styled.div`
max-width: 800px;
margin: 0 auto;
margin-bottom: 2em;
padding-bottom: 2em;
border-bottom: 1px solid #f5f4f0;
`;
import Note from './Note';
const NoteFeed = ({ notes }) => {
return (
<div>
{notes.map(note => (
<NoteWrapper key={note.id}>
<Note note={note} />
</NoteWrapper>
))}
</div>
);
};
export default NoteFeed;
通过这些更新,我们已经为我们的应用程序引入了布局样式。
动态查询
目前,我们的应用程序包含三条路由,每条路由都是静态的。 这些路由位于静态 URL 上,并且始终会进行相同的数据请求。 但是,应用程序通常需要基于这些路由的动态路由和查询。 例如,Twitter.com 上的每条推文都分配了一个唯一的 URL,位于twitter.com/
目前,我们的应用程序注释只能在一个反馈中访问,但我们希望允许用户查看和链接到单独的注释。 为了实现这一目标,我们将在我们的 React 应用程序中设置动态路由以及单个注释的 GraphQL 查询。 我们的目标是让用户能够访问/note/<note_id>的路径。
首先,我们将在src/pages/note.js创建一个新的页面组件。 我们将通过 React Router 将我们的props(属性)对象传递给组件,其中包括match属性。 该属性包含有关路由路径如何匹配 URL 的信息。 这将使我们能够通过match.params访问 URL 参数。
import React from 'react';
const NotePage = props => {
return (
<div>
<p>ID: {props.match.params.id}</p>
</div>
);
};
export default NotePage;
现在我们可以在我们的 src/pages/index.js 文件中添加相应的路由。该路由将包括一个以 :id 表示的 ID 参数:
// import React and routing dependencies
import React from 'react';
import { BrowserRouter as Router, Route } from 'react-router-dom';
// import shared layout component
import Layout from '../components/Layout';
// import routes
import Home from './home';
import MyNotes from './mynotes';
import Favorites from './favorites';
import NotePage from './note';
// define routes
const Pages = () => {
return (
<Router>
<Layout>
<Route exact path="/" component={Home} />
<Route path="/mynotes" component={MyNotes} />
<Route path="/favorites" component={Favorites} />
<Route path="/note/:id" component={NotePage} />
</Layout>
</Router>
);
};
export default Pages;
现在,访问 http://localhost:1234/note/123 将在我们的页面上打印 ID: 123。为了测试它,请将 ID 参数替换为您选择的任何内容,比如 /note/pizza 或 /note**/GONNAPARTYLIKE1999。这很酷,但并不是非常有用。让我们更新我们的 src/pages/note.js 组件,以便为在 URL 中找到的笔记进行 GraphQL 查询。为此,我们将使用来自我们 API 的 note 查询以及我们的 Note React 组件:
import React from 'react';
// import GraphQL dependencies
import { useQuery, gql } from '@apollo/client';
// import the Note component
import Note from '../components/Note';
// the note query, which accepts an ID variable
const GET_NOTE = gql`
query note($id: ID!) {
note(id: $id) {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
`;
const NotePage = props => {
// store the id found in the url as a variable
const id = props.match.params.id;
// query hook, passing the id value as a variable
const { loading, error, data } = useQuery(GET_NOTE, { variables: { id } });
// if the data is loading, display a loading message
if (loading) return <p>Loading...</p>;
// if there is an error fetching the data, display an error message
if (error) return <p>Error! Note not found</p>;
// if the data is successful, display the data in our UI
return <Note note={data.note} />;
};
export default NotePage;
现在,导航到带有 ID 参数的 URL 将渲染相应的笔记或错误消息。最后,让我们更新我们的 src/components/NoteFeed.js 组件,以在 UI 中显示到单个笔记的链接。
首先,在文件顶部从 React Router 中导入 {Link}:
import { Link } from 'react-router-dom';
然后,更新 JSX 以包含指向笔记页面的链接如下:
<NoteWrapper key={note.id}>
<Note note={note} />
<Link to={`note/${note.id}`}>Permalink</Link>
</NoteWrapper>
通过这样做,我们在应用程序中使用动态路由,使用户能够查看单个笔记。
分页
目前,我们仅在应用程序的主页中检索最近的 10 条笔记。如果我们想要显示更多的笔记,我们需要启用分页。你可能还记得本章开头和我们 API 服务器的开发阶段,我们的 API 返回一个 cursor,它是页面结果中最后一条笔记的 ID。此外,API 还返回一个 hasNextPage 布尔值,如果在我们的数据库中找到其他笔记,则为 true。在向我们的 API 发出请求时,我们可以传递一个 cursor 参数,它将返回接下来的 10 个项目。
换句话说,如果我们有一个包含 25 个对象的列表(对应的 ID 为 1–25),当我们进行初始请求时,它将返回项目 1–10 以及一个 cursor 值为 10 和一个 hasNextPage 值为 true。如果我们发出请求,传递一个 cursor 值为 10,我们将收到项目 11–20,cursor 值为 20 和 hasNextPage 值为 true。最后,如果我们发出第三个请求,传递一个 cursor 为 20,我们将收到项目 21–25,cursor 值为 25 和 hasNextPage 值为 false。这正是我们将在 noteFeed 查询中实现的逻辑。
为了做到这一点,让我们更新我们的 src/pages/home.js 文件以进行分页查询。在我们的用户界面中,当用户点击“查看更多”按钮时,应在页面上加载下一个 10 条笔记。我们希望这在不刷新页面的情况下完成。为此,我们需要在查询组件中包含 fetchMore 参数,并且仅在 hasNextPage 为 true 时显示 Button 组件。目前,我们将这直接写入我们的主页组件中,但它也可以很容易地被隔离到自己的组件中或成为 NoteFeed 组件的一部分。
// if the data is successful, display the data in our UI
return (
// add a <React.Fragment> element to provide a parent element
<React.Fragment>
<NoteFeed notes={data.noteFeed.notes} />
{/* Only display the Load More button if hasNextPage is true */}
{data.noteFeed.hasNextPage && (
<Button>Load more</Button>
)}
</React.Fragment>
);
React 中的条件语句
在前面的示例中,我们使用内联 if 语句和 && 运算符有条件地显示“Load more”按钮。如果 hasNextPage 为真,则显示按钮。您可以在官方 React 文档中了解更多关于条件渲染的信息。
现在我们可以更新 <Button> 组件以使用一个 onClick 处理程序。当用户点击按钮时,我们将使用 fetchMore 方法进行额外的查询,并将返回的数据附加到我们的页面上。
{data.noteFeed.hasNextPage && (
// onClick peform a query, passing the current cursor as a variable
<Button
onClick={() =>
fetchMore({
variables: {
cursor: data.noteFeed.cursor
},
updateQuery: (previousResult, { fetchMoreResult }) => {
return {
noteFeed: {
cursor: fetchMoreResult.noteFeed.cursor,
hasNextPage: fetchMoreResult.noteFeed.hasNextPage,
// combine the new results and the old
notes: [
...previousResult.noteFeed.notes,
...fetchMoreResult.noteFeed.notes
],
__typename: 'noteFeed'
}
};
}
})
}
>
Load more
</Button>
)}
之前的代码可能看起来有点复杂,所以让我们来分解一下。我们的 <Button> 组件包括一个 onClick 处理程序。当点击按钮时,使用 fetchMore 方法执行新的查询,传递前一个查询返回的 cursor 值。一旦返回,将执行 updateQuery,更新我们的 cursor 和 hasNextPage 值,并将结果组合成一个单一的数组。__typename 是查询的名称,包含在 Apollo 的结果中。
通过这个改变,我们能够查看笔记流中的所有笔记。通过滚动到笔记流的底部,您可以自行尝试。如果您的数据库包含超过 10 条笔记,则按钮将可见。点击“Load more”将把下一个 noteFeed 结果添加到页面中。
结论
在本章中,我们涵盖了很多内容。我们设置了 Apollo Client 来与我们的 React 应用程序配合工作,并将多个 GraphQL 查询集成到我们的 UI 中。GraphQL 的强大之处在于能够编写单一查询,精确返回 UI 需要的数据。在下一章中,我们将集成用户认证到我们的应用程序中,允许用户登录并查看他们的笔记和收藏。
第十五章:Web 身份验证和状态
我和我的家人最近搬家了。填写并签署了几份表格后(我的手还很累),我们拿到了前门的钥匙。每次回家时,我们都能用那些钥匙打开门进去。我很感激的是,我不需要每次回家都填写表格,但也很感激有锁,这样我们就不会有意外的客人了。
客户端 Web 身份验证工作方式基本相同。我们的用户填写一个表单,并被分发一个网站的钥匙,即密码和存储在他们浏览器中的令牌。当他们返回网站时,他们要么会通过令牌自动认证,要么可以使用他们的密码重新登录。
在本章中,我们将使用 GraphQL API 构建一个 Web 身份验证系统。为此,我们将构建表单,在浏览器中存储 JWT,发送每个请求时的令牌,并跟踪我们应用的状态。
创建一个注册表单
要开始使用我们应用的客户端认证,我们可以创建一个用户注册的 React 组件。在此之前,让我们先规划一下组件的工作方式。
首先,用户将在我们应用的 /signup 路由中导航。在这个页面上,他们将看到一个表单,可以输入他们的电子邮件地址、所需用户名和密码。提交表单将执行我们 API 的 signUp 变异。如果变异成功,将创建一个新用户账户,并返回一个 JWT。如果出现错误,我们可以通知用户。我们将显示一个通用错误消息,但我们也可以更新我们的 API 以返回特定的错误消息,比如已存在的用户名或重复的电子邮件地址。
让我们开始创建我们的新路由。首先,我们将在 src/pages/signup.js 中创建一个新的 React 组件:
import React, { useEffect } from 'react';
// include the props passed to the component for later use
const SignUp = props => {
useEffect(() => {
// update the document title
document.title = 'Sign Up — Notedly';
});
return (
<div>
<p>Sign Up</p>
</div>
);
};
export default SignUp;
现在我们将在 src/pages/index.js 中更新我们的路由列表,包括 signup 路由:
// import the signup route
import SignUp from './signup';
// within the Pages component add the route
<Route path="/signup" component={SignUp} />
通过添加路由,我们将能够导航到 http://localhost:1234/signup 查看(大部分为空的)注册页面。现在,让我们为我们的表单添加标记:
import React, { useEffect } from 'react';
const SignUp = props => {
useEffect(() => {
// update the document title
document.title = 'Sign Up — Notedly';
});
return (
<div>
<form>
<label htmlFor="username">Username:</label>
<input
required
type="text"
id="username"
name="username"
placeholder="username"
/>
<label htmlFor="email">Email:</label>
<input
required
type="email"
id="email"
name="email"
placeholder="Email"
/>
<label htmlFor="password">Password:</label>
<input
required
type="password"
id="password"
name="password"
placeholder="Password"
/>
<button type="submit">Submit</button>
</form>
</div>
);
};
export default SignUp;
htmlFor
如果你刚开始学习 React,常见的问题之一是 JSX 属性与其 HTML 对应项不同。在这种情况下,我们使用 JSX 的 htmlFor 替代 HTML 的 for 属性,以避免任何 JavaScript 冲突。你可以在React DOM 元素文档中看到一个完整但简短的这些属性列表。
现在我们可以通过导入我们的 Button 组件并将表单样式化为一个 styled 组件来添加一些样式:
import React, { useEffect } from 'react';
import styled from 'styled-components';
import Button from '../components/Button';
const Wrapper = styled.div`
border: 1px solid #f5f4f0;
max-width: 500px;
padding: 1em;
margin: 0 auto;
`;
const Form = styled.form`
label,
input {
display: block;
line-height: 2em;
}
input {
width: 100%;
margin-bottom: 1em;
}
`;
const SignUp = props => {
useEffect(() => {
// update the document title
document.title = 'Sign Up — Notedly';
});
return (
<Wrapper>
<h2>Sign Up</h2>
<Form>
<label htmlFor="username">Username:</label>
<input
required
type="text"
id="username"
name="username"
placeholder="username"
/>
<label htmlFor="email">Email:</label>
<input
required
type="email"
id="email"
name="email"
placeholder="Email"
/>
<label htmlFor="password">Password:</label>
<input
required
type="password"
id="password"
name="password"
placeholder="Password"
/>
<Button type="submit">Submit</Button>
</Form>
</Wrapper>
);
};
export default SignUp;
React 表单和状态
在一个应用中,事情会发生变化。数据被输入到一个表单中,用户切换一个滑块打开,消息被发送。在 React 中,我们可以通过分配状态来在组件级别跟踪这些变化。在我们的表单中,我们将需要跟踪每个表单元素的状态,以便可以提交它。
React Hooks
在本书中,我们使用功能组件和 React 的新 Hooks API。如果您曾经使用过其他使用 React 的class组件的学习资源,这可能看起来有些不同。您可以在React 文档中了解更多关于 Hooks 的信息。
要开始使用状态,我们首先需要更新位于src/pages/signup.js文件顶部的 React 导入,包括useState:
import React, { useEffect, useState } from 'react';
接下来,在我们的SignUp组件内,我们将设置默认的表单值状态:
const SignUp = props => {
// set the default state of the form
const [values, setValues] = useState();
// rest of component goes here
};
现在我们将更新我们的组件,以便在输入表单字段时更改状态,并在用户提交表单时执行操作。首先,我们将创建一个onChange函数,每当更新表单时都会更新我们组件的状态。我们还将更新每个表单元素的标记,以在用户进行更改时调用此函数,使用onChange属性。然后,我们将更新我们的form元素以包括一个onSubmit处理程序。目前,我们将简单地将我们的表单数据记录到控制台。
在/src/pages/sigunp.js中:
const SignUp = () => {
// set the default state of the form
const [values, setValues] = useState();
// update the state when a user types in the form
const onChange = event => {
setValues({
...values,
[event.target.name]: event.target.value
});
};
useEffect(() => {
// update the document title
document.title = 'Sign Up — Notedly';
});
return (
<Wrapper>
<h2>Sign Up</h2>
<Form
onSubmit={event => {
event.preventDefault();
console.log(values);
}}
>
<label htmlFor="username">Username:</label>
<input
required
type="text"
name="username"
placeholder="username"
onChange={onChange}
/>
<label htmlFor="email">Email:</label>
<input
required
type="email"
name="email"
placeholder="Email"
onChange={onChange}
/>
<label htmlFor="password">Password:</label>
<input
required
type="password"
name="password"
placeholder="Password"
onChange={onChange}
/>
<Button type="submit">Submit</Button>
</Form>
</Wrapper>
);
};
有了这个表单标记,我们准备使用 GraphQL 变异请求数据。
signUp 变异
要注册用户,我们将使用我们 API 的signUp变异。如果注册成功,此变异将接受电子邮件、用户名和密码作为变量,并返回一个 JWT。让我们编写我们的变异并将其集成到我们的注册表单中。
首先,我们需要导入我们的 Apollo 库。我们将使用 Apollo Client 中的useMutation和useApolloClient钩子,以及gql语法。在src/pages/signUp中,将以下内容添加到其他库导入语句旁边:
import { useMutation, useApolloClient, gql } from '@apollo/client';
现在按以下方式编写 GraphQL 变异:
const SIGNUP_USER = gql`
mutation signUp($email: String!, $username: String!, $password: String!) {
signUp(email: $email, username: $username, password: $password)
}
`;
编写完变异后,我们可以更新 React 组件标记,以在用户提交表单时执行变异,将表单元素作为变量传递。目前,我们将把响应(如果成功,应为 JWT)记录到控制台:
const SignUp = props => {
// useState, onChange, and useEffect all remain the same here
//add the mutation hook
const [signUp, { loading, error }] = useMutation(SIGNUP_USER, {
onCompleted: data => {
// console.log the JSON Web Token when the mutation is complete
console.log(data.signUp);
}
});
// render our form
return (
<Wrapper>
<h2>Sign Up</h2>
{/* pass the form data to the mutation when a user submits the form */}
<Form
onSubmit={event => {
event.preventDefault();
signUp({
variables: {
...values
}
});
}}
>
{/* ... the rest of the form remains unchanged ... */}
</Form>
</Wrapper>
);
};
现在,如果您完成并提交表单,您应该在控制台看到一个 JWT 被记录(见图 15-1)。另外,如果在 GraphQL Playground 中执行users查询(http://localhost:4000/api),您将看到新的帐户(见图 15-2)。

图 15-1. 如果成功,当我们提交表单时,将在控制台打印一个 JSON Web Token

图 15-2. 通过在 GraphQL Playground 执行用户查询,我们还可以看到用户列表
完成我们的变异并返回预期的数据后,接下来我们想要存储接收到的响应。
JSON Web Tokens 和本地存储
当我们的 signUp 变异成功时,它会返回一个 JSON Web Token (JWT)。你可能还记得本书的 API 部分提到过,JWT 允许我们安全地将用户的 ID 存储在用户的设备上。为了在用户的 Web 浏览器中实现这一点,我们将把令牌存储在浏览器的 localStorage 中。localStorage 是一个简单的键值存储,可以在浏览器会话之间持久保存,直到存储被更新或清除。让我们更新我们的变异,将令牌存储在 localStorage 中。
在 src/pages/signup.js 中,更新 useMutation hook,将令牌存储在 local``Storage 中(见图 15-3):
const [signUp, { loading, error }] = useMutation(SIGNUP_USER, {
onCompleted: data => {
// store the JWT in localStorage
localStorage.setItem('token', data.signUp);
}
});

图 15-3. 我们的 Web 令牌现在存储在浏览器的 localStorage 中
JWT 和安全性
当令牌存储在 localStorage 中时,页面上可以运行的任何 JavaScript 都可以访问该令牌,使其容易受到跨站脚本(XSS)攻击的影响。因此,当使用 localStorage 存储令牌凭证时,需要特别注意限制(或避免)CDN 托管的脚本。如果第三方脚本被 Compromised,它将可以访问 JWT。
有了我们在本地存储的 JWT,我们准备在我们的 GraphQL 变异和查询中使用它。
重定向
当用户完成注册表单时,当前表单将重新呈现为空表单。这并没有给用户留下太多视觉线索,表明他们的帐户注册成功了。相反,让我们将用户重定向到我们应用程序的主页。另一个选择是创建一个“成功”页面,感谢用户注册并将其引导到应用程序。
正如您可能还记得本章前面所述,我们正在将属性传递到组件中。我们可以使用 React Router 的 history 来重定向路由,通过 props.history.push 我们可以使用它。为了实现这一点,我们将更新我们变异的 onCompleted 事件,包括一个重定向,如下所示:
const [signUp, { loading, error }] = useMutation(SIGNUP_USER, {
onCompleted: data => {
// store the token
localStorage.setItem('token', data.signUp);
// redirect the user to the homepage
props.history.push('/');
}
});
通过此更改,用户现在将在注册帐户后重定向到我们应用程序的主页。
将标头附加到请求
尽管我们将令牌存储在 localStorage 中,但我们的 API 尚无法访问它。这意味着即使用户创建了帐户,API 也无法识别用户。如果您还记得我们的 API 开发,每个 API 调用都会在请求的标头中接收一个令牌。我们将修改客户端,在每个请求中发送 JWT 作为标头。
在 src/App.js 中,我们将更新我们的依赖项,包括从 Apollo Client 导入 createHttpLink 以及从 Apollo 的 Link Context 包中导入 setContext。然后,我们将更新 Apollo 的配置,在每个请求的标头中发送令牌:
// import the Apollo dependencies
import {
ApolloClient,
ApolloProvider,
createHttpLink,
InMemoryCache
} from '@apollo/client';
import { setContext } from 'apollo-link-context';
// configure our API URI & cache
const uri = process.env.API_URI;
const httpLink = createHttpLink({ uri });
const cache = new InMemoryCache();
// check for a token and return the headers to the context
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
authorization: localStorage.getItem('token') || ''
}
};
});
// create the Apollo client
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache,
resolvers: {},
connectToDevTools: true
});
通过此更改,我们现在将能够将已登录用户的信息传递给我们的 API。
本地状态管理
我们已经看过如何在组件内管理状态,但是在整个应用程序中如何处理呢?有时将一些信息共享在许多组件之间是有用的。我们可以从基础组件传递 props 到整个应用程序,但是一旦我们超过几个级别的子组件,这可能变得混乱。诸如 Redux 和 MobX 这样的库已经解决了状态管理的挑战,并且对许多开发人员和团队都很有用。在我们的情况下,我们已经在使用 Apollo Client 库,它包括使用 GraphQL 查询进行本地状态管理的能力。与其引入另一个依赖项,不如实现一个本地状态属性来存储用户是否已登录。
Apollo React 库将 ApolloClient 实例放置在 React 的上下文中,但有时我们可能需要直接访问它。我们可以使用 useApolloClient 钩子来实现这一点,它将允许我们执行直接更新或重置缓存存储或写入本地数据等操作。
目前,我们有两种方法来确定用户是否已登录到我们的应用程序。首先,如果他们成功提交了注册表单,我们知道他们是当前用户。其次,如果访问者带有存储在 localStorage 中的令牌访问站点,则已经登录。让我们首先在用户完成注册表单时添加到我们的状态。为了实现这一点,我们将直接写入到我们的 Apollo 客户端的本地存储中,使用 client.writeData 和 useApolloClient 钩子。
在 src/pages/signup.js 中,我们首先需要更新 @apollo/client 库的导入,以包含 useApolloClient:
import { useMutation, useApolloClient } from '@apollo/client';
在 src/pages/signup.js 中,我们将调用 useApolloClient 函数,并在完成时使用 writeData 更新突变以添加到本地存储:
// Apollo Client
const client = useApolloClient();
// Mutation Hook
const [signUp, { loading, error }] = useMutation(SIGNUP_USER, {
onCompleted: data => {
// store the token
localStorage.setItem('token', data.signUp);
// update the local cache
client.writeData({ data: { isLoggedIn: true } });
// redirect the user to the homepage
props.history.push('/');
}
});
现在,让我们更新我们的应用程序,在页面加载时检查预先存在的令牌,并在找到令牌时更新状态。在 src/App.js 中,首先将 ApolloClient 配置更新为空的 resolvers 对象。这将允许我们在本地缓存上执行 GraphQL 查询。
// create the Apollo client
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache,
resolvers: {},
connectToDevTools: true
});
接下来,我们可以在应用程序的初始页面加载时执行检查:
// check for a local token
const data = {
isLoggedIn: !!localStorage.getItem('token')
};
// write the cache data on initial load
cache.writeData({ data });
现在,这里是很酷的一部分:我们现在可以通过使用 @client 指令在我们应用程序的任何地方将 isLoggedIn 作为 GraphQL 查询访问。为了演示这一点,让我们更新我们应用程序的标题,以便在 isLoggedIn 是 false 时显示 “注册” 和 “登录” 链接,并在 isLoggedIn 是 true 时显示 “登出” 链接。
在 src/components/Header.js 中,导入必要的依赖项并编写查询,如下所示:
// new dependencies
import { useQuery, gql } from '@apollo/client';
import { Link } from 'react-router-dom';
// local query
const IS_LOGGED_IN = gql`
{
isLoggedIn @client
}
`;
现在,在我们的 React 组件中,我们可以包含一个简单的查询来检索状态以及一个三元运算符,显示注销或登录选项:
const UserState = styled.div`
margin-left: auto;
`;
const Header = props => {
// query hook for user logged in state
const { data } = useQuery(IS_LOGGED_IN);
return (
<HeaderBar>
<img src={logo} alt="Notedly Logo" height="40" />
<LogoText>Notedly</LogoText>
{/* If logged in display a logout link, else display sign-in options */}
<UserState>
{data.isLoggedIn ? (
<p>Log Out</p>
) : (
<p>
<Link to={'/signin'}>Sign In</Link> or{' '}
<Link to={'/signup'}>Sign Up</Link>
</p>
)}
</UserState>
</HeaderBar>
);
};
有了这个功能,当用户登录时,他们将看到一个“Log Out”选项;否则,他们将被呈现出注册或登录的选项,所有这些都归功于本地状态。我们不仅限于简单的布尔逻辑。Apollo 允许我们编写本地解析器和类型定义,允许我们利用 GraphQL 在本地状态中提供的所有功能。
注销
当前用户登录后,我们没有办法让他们退出应用程序。让我们将头部的“Log Out”语言转换为一个按钮,点击按钮时将注销用户。为此,当点击按钮时,我们将移除存储在 localStorage 中的令牌。我们将使用 <button> 元素,因为它具有内置的可访问性,既作为用户操作的语义化表示,又在用户通过键盘导航应用程序时接收焦点,就像一个链接一样。
在编写代码之前,让我们编写一个样式化组件,它将渲染一个像链接一样的按钮。在 src/Components/ButtonAsLink.js 创建一个新文件,并添加以下内容:
import styled from 'styled-components';
const ButtonAsLink = styled.button`
background: none;
color: #0077cc;
border: none;
padding: 0;
font: inherit;
text-decoration: underline;
cursor: pointer;
:hover,
:active {
color: #004499;
}
`;
export default ButtonAsLink;
现在在 src/components/Header.js 中,我们可以实现注销功能。我们需要使用 React Router 的 withRouter 高阶组件来处理重定向,因为我们的 Header.js 文件是一个 UI 组件,而不是一个定义好的路由。让我们首先导入 ButtonAsLink 组件以及 withRouter:
// import both Link and withRouter from React Router
import { Link, withRouter } from 'react-router-dom';
// import the ButtonAsLink component
import ButtonAsLink from './ButtonAsLink';
现在,在我们的 JSX 中,我们将更新我们的组件以包括 props 参数,并更新注销标记为一个按钮:
const Header = props => {
// query hook for user logged-in state,
// including the client for referencing the Apollo store
const { data, client } = useQuery(IS_LOGGED_IN);
return (
<HeaderBar>
<img src={logo} alt="Notedly Logo" height="40" />
<LogoText>Notedly</LogoText>
{/* If logged in display a logout link, else display sign-in options */}
<UserState>
{data.isLoggedIn ? (
<ButtonAsLink>
Logout
</ButtonAsLink>
) : (
<p>
<Link to={'/signin'}>Sign In</Link> or{' '}
<Link to={'/signup'}>Sign Up</Link>
</p>
)}
</UserState>
</HeaderBar>
);
};
// we wrap our component in the withRouter higher-order component
export default withRouter(Header);
withRouter
当我们希望在一个不直接可路由的组件中包含路由时,我们需要使用 React Router 的 withRouter 高阶组件。
当用户从我们的应用程序注销时,我们希望重置缓存存储,以防止会话外出现任何不需要的数据。Apollo 提供了调用 resetStore 函数的能力,它将完全清除缓存。让我们为组件的按钮添加一个 onClick 处理程序,以移除用户的令牌,重置 Apollo Store,更新本地状态,并将用户重定向到首页。为此,我们将更新我们的 useQuery 钩子以包括对客户端的引用,并在我们的 export 语句中使用 withRouter 高阶组件包装我们的组件。
const Header = props => {
// query hook for user logged in state
const { data, client } = useQuery(IS_LOGGED_IN);
return (
<HeaderBar>
<img src={logo} alt="Notedly Logo" height="40" />
<LogoText>Notedly</LogoText>
{/* If logged in display a logout link, else display sign-in options */}
<UserState>
{data.isLoggedIn ? (
<ButtonAsLink
onClick={() => {
// remove the token
localStorage.removeItem('token');
// clear the application's cache
client.resetStore();
// update local state
client.writeData({ data: { isLoggedIn: false } });
// redirect the user to the home page
props.history.push('/');
}}
>
Logout
</ButtonAsLink>
) : (
<p>
<Link to={'/signin'}>Sign In</Link> or{' '}
<Link to={'/signup'}>Sign Up</Link>
</p>
)}
</UserState>
</HeaderBar>
);
};
export default withRouter(Header);
最后,在 src/App.js 中,我们将需要 Apollo 将用户状态重新添加到缓存状态中,当存储被重置时。更新缓存设置以包括 onResetStore:
// check for a local token
const data = {
isLoggedIn: !!localStorage.getItem('token')
};
// write the cache data on initial load
cache.writeData({ data });
// write the cache data after cache is reset
client.onResetStore(() => cache.writeData({ data }));
有了这个功能,已登录用户可以轻松注销我们的应用程序。我们已将此功能直接集成到我们的 Header 组件中,但在未来我们可以将其重构为一个独立的组件。
创建一个登录表单
目前,我们的用户可以注册和注销我们的应用程序,但他们无法再次登录。让我们创建一个登录表单,并在此过程中进行一些重构,以便我们可以重用在注册组件中找到的大部分代码。
我们的第一步将是创建一个新的页面组件,该组件将位于/signin。在一个新文件src/pages/signin.js中,添加以下内容:
import React, { useEffect } from 'react';
const SignIn = props => {
useEffect(() => {
// update the document title
document.title = 'Sign In — Notedly';
});
return (
<div>
<p>Sign up page</p>
</div>
);
};
export default SignIn;
现在我们可以使我们的页面可路由,以便用户可以导航到它。在src/pages/index.js中导入路由页面并添加新的路由路径:
// import the sign-in page component
import SignIn from './signin';
const Pages = () => {
return (
<Router>
<Layout>
// ... our other routes
// add a signin route to our routes list
<Route path="/signin" component={SignIn} />
</Layout>
</Router>
);
};
让我们在这里暂停,然后再实现我们的登录表单,考虑我们的选择。我们可以重新实现一个表单,就像我们为注册页面编写的那样,但这感觉很乏味,并且需要我们维护两个类似的表单。当一个改变时,我们需要确保更新另一个。另一个选择是将表单隔离到自己的组件中,这将允许我们重用通用代码,并在一个位置进行更新。让我们继续使用共享表单组件的方法前进。
首先,我们将在src/components/UserForm.js创建一个新组件,将我们的<form>标记和样式导入其中。我们将对此表单进行一些小但显著的更改,以使用它从父组件接收到的属性。首先,我们将将我们的onSubmit变异重命名为props.action,这将允许我们通过组件的属性将变异传递给我们的表单。其次,我们将在我们知道我们的两个表单将有所不同的地方添加一些条件语句。我们将使用一个名为formType的第二个属性,我们将传递一个字符串。我们可以根据字符串的值更改我们模板的渲染。
我们将这些写为内联if语句,使用逻辑&&运算符或条件三元运算符:
import React, { useState } from 'react';
import styled from 'styled-components';
import Button from './Button';
const Wrapper = styled.div`
border: 1px solid #f5f4f0;
max-width: 500px;
padding: 1em;
margin: 0 auto;
`;
const Form = styled.form`
label,
input {
display: block;
line-height: 2em;
}
input {
width: 100%;
margin-bottom: 1em;
}
`;
const UserForm = props => {
// set the default state of the form
const [values, setValues] = useState();
// update the state when a user types in the form
const onChange = event => {
setValues({
...values,
[event.target.name]: event.target.value
});
};
return (
<Wrapper>
{/* Display the appropriate form header */}
{props.formType === 'signup' ? <h2>Sign Up</h2> : <h2>Sign In</h2>}
{/* perform the mutation when a user submits the form */}
<Form
onSubmit={e => {
e.preventDefault();
props.action({
variables: {
...values
}
});
}}
>
{props.formType === 'signup' && (
<React.Fragment>
<label htmlFor="username">Username:</label>
<input
required
type="text"
id="username"
name="username"
placeholder="username"
onChange={onChange}
/>
</React.Fragment>
)}
<label htmlFor="email">Email:</label>
<input
required
type="email"
id="email"
name="email"
placeholder="Email"
onChange={onChange}
/>
<label htmlFor="password">Password:</label>
<input
required
type="password"
id="password"
name="password"
placeholder="Password"
onChange={onChange}
/>
<Button type="submit">Submit</Button>
</Form>
</Wrapper>
);
};
export default UserForm;
现在我们可以简化我们的src/pages/signup.js组件,以利用共享的表单组件:
import React, { useEffect } from 'react';
import { useMutation, useApolloClient, gql } from '@apollo/client';
import UserForm from '../components/UserForm';
const SIGNUP_USER = gql`
mutation signUp($email: String!, $username: String!, $password: String!) {
signUp(email: $email, username: $username, password: $password)
}
`;
const SignUp = props => {
useEffect(() => {
// update the document title
document.title = 'Sign Up — Notedly';
});
const client = useApolloClient();
const [signUp, { loading, error }] = useMutation(SIGNUP_USER, {
onCompleted: data => {
// store the token
localStorage.setItem('token', data.signUp);
// update the local cache
client.writeData({ data: { isLoggedIn: true } });
// redirect the user to the homepage
props.history.push('/');
}
});
return (
<React.Fragment>
<UserForm action={signUp} formType="signup" />
{/* if the data is loading, display a loading message*/}
{loading && <p>Loading...</p>}
{/* if there is an error, display a error message*/}
{error && <p>Error creating an account!</p>}
</React.Fragment>
);
};
export default SignUp;
最后,我们可以编写我们的SignIn组件,利用我们的signIn变异和UserForm组件。在src/pages/signin.js中:
import React, { useEffect } from 'react';
import { useMutation, useApolloClient, gql } from '@apollo/client';
import UserForm from '../components/UserForm';
const SIGNIN_USER = gql`
mutation signIn($email: String, $password: String!) {
signIn(email: $email, password: $password)
}
`;
const SignIn = props => {
useEffect(() => {
// update the document title
document.title = 'Sign In — Notedly';
});
const client = useApolloClient();
const [signIn, { loading, error }] = useMutation(SIGNIN_USER, {
onCompleted: data => {
// store the token
localStorage.setItem('token', data.signIn);
// update the local cache
client.writeData({ data: { isLoggedIn: true } });
// redirect the user to the homepage
props.history.push('/');
}
});
return (
<React.Fragment>
<UserForm action={signIn} formType="signIn" />
{/* if the data is loading, display a loading message*/}
{loading && <p>Loading...</p>}
{/* if there is an error, display a error message*/}
{error && <p>Error signing in!</p>}
</React.Fragment>
);
};
export default SignIn;
有了这个,现在我们有一个可管理的表单组件,并使用户能够注册并登录到我们的应用程序。
受保护的路由
一个常见的应用模式是将对特定页面或站点部分的访问限制为经过身份验证的用户。在我们的情况下,未经身份验证的用户将无法使用“我的笔记”或“收藏夹”页面。我们可以在我们的路由器中实现此模式,当他们尝试访问这些路由时,自动将未经身份验证的用户路由到应用程序的登录页面。
在src/pages/index.js中,我们将从导入所需的依赖项开始,并添加我们的isLoggedIn查询:
import { useQuery, gql } from '@apollo/client';
const IS_LOGGED_IN = gql`
{
isLoggedIn @client
}
`;
现在我们将导入 React Router 的Redirect库并编写PrivateRoute组件,如果用户未登录,则将其重定向:
// update our react-router import to include Redirect
import { BrowserRouter as Router, Route, Redirect } from 'react-router-dom';
// add the PrivateRoute component below our `Pages` component
const PrivateRoute = ({ component: Component, ...rest }) => {
const { loading, error, data } = useQuery(IS_LOGGED_IN);
// if the data is loading, display a loading message
if (loading) return <p>Loading...</p>;
// if there is an error fetching the data, display an error message
if (error) return <p>Error!</p>;
// if the user is logged in, route them to the requested component
// else redirect them to the sign-in page
return (
<Route
{...rest}
render={props =>
data.isLoggedIn === true ? (
<Component {...props} />
) : (
<Redirect
to={{
pathname: '/signin',
state: { from: props.location }
}}
/>
)
}
/>
);
};
export default Pages;
最后,我们可以更新任何用于已登录用户的路由以使用Private``Route组件:
const Pages = () => {
return (
<Router>
<Layout>
<Route exact path="/" component={Home} />
<PrivateRoute path="/mynotes" component={MyNotes} />
<PrivateRoute path="/favorites" component={Favorites} />
<Route path="/note/:id" component={Note} />
<Route path="/signup" component={SignUp} />
<Route path="/signin" component={SignIn} />
</Layout>
</Router>
);
};
重定向状态
当我们重定向到私有路由时,我们还将引用 URL 存储为状态。这样可以使我们将用户重定向回他们最初尝试导航到的页面。我们可以在登录页面上更新我们的重定向,以选择性地使用props.state.location.from来启用此功能。
现在,当用户尝试访问仅限已登录用户的页面时,他们将被重定向到我们的登录页面。
结论
在本章中,我们涵盖了构建客户端 JavaScript 应用程序的两个关键概念:认证和状态。通过构建完整的认证流程,您可以深入了解用户账户如何与客户端应用程序配合工作。从这里开始,我建议您探索诸如 OAuth 和诸如 Auth0、Okta 和 Firebase 等认证服务的替代选项。此外,您已经学会了如何在应用程序中管理状态,既可以在组件级别使用 React Hooks API 进行管理,也可以在整个应用程序中使用 Apollo 的本地状态。掌握了这些关键概念,您现在可以构建健壮的用户界面应用程序。
第十六章:创建、读取、更新和删除操作
我喜欢纸质笔记本,并几乎随身携带一本。通常它们比较便宜,我很快就会用半成品的想法填满它们。不久前,我购买了一本价格较高的硬面笔记本,带有可爱的封面和精美的纸张。购买时,我对这本笔记本的素描和规划有着宏大的抱负,但它在我桌子上空了几个月。最终,我把它放在书架上,又回到了我的标准笔记本品牌。
就像我的精美笔记本一样,我们的应用程序只有在用户能够与之交互时才有用。您可能还记得我们的 API 开发中,Notedly 应用程序是一个“CRUD”(创建、读取、更新和删除)应用程序。经过身份验证的用户可以创建新的笔记、读取笔记、更新笔记内容或将笔记标记为喜欢,并删除笔记。在本章中,我们将在我们的 Web 用户界面中实现所有这些功能。为了完成这些任务,我们将编写 GraphQL 突变和查询。
创建新笔记
目前我们能够查看笔记,但无法创建它们。这就像有了一个没有笔的笔记本一样。让我们添加用户创建新笔记的能力。我们将通过创建一个 textarea 表单来实现这一点,用户可以在其中编写笔记。当用户提交表单时,我们将执行 GraphQL 突变来在我们的数据库中创建笔记。
首先,让我们在 src/pages/new.js 中创建 NewNote 组件:
import React, { useEffect } from 'react';
import { useMutation, gql } from '@apollo/client';
const NewNote = props => {
useEffect(() => {
// update the document title
document.title = 'New Note — Notedly';
});
return <div>New note</div>;
};
export default NewNote;
接下来,让我们在 src/pages/index.js 文件中设置新的路由:
// import the NewNote route component
import NewNote from './new';
// add a private route to our list of routes, within the
<PrivateRoute path="/new" component={NewNote} />
我们知道我们既将创建新的笔记,又将更新现有的笔记。为了适应这种行为,让我们创建一个名为 NoteForm 的新组件,它将用作笔记表单编辑的标记和 React 状态。
我们将在 src/components/NoteForm.js 中创建一个新文件。该组件将包含一个表单元素,其中包含一个文本区域以及一些最小的样式。其功能将类似于我们的 UserForm 组件:
import React, { useState } from 'react';
import styled from 'styled-components';
import Button from './Button';
const Wrapper = styled.div`
height: 100%;
`;
const Form = styled.form`
height: 100%;
`;
const TextArea = styled.textarea`
width: 100%;
height: 90%;
`;
const NoteForm = props => {
// set the default state of the form
const [value, setValue] = useState({ content: props.content || '' });
// update the state when a user types in the form
const onChange = event => {
setValue({
...value,
[event.target.name]: event.target.value
});
};
return (
<Wrapper>
<Form
onSubmit={e => {
e.preventDefault();
props.action({
variables: {
...values
}
});
}}
>
<TextArea
required
type="text"
name="content"
placeholder="Note content"
value={value.content}
onChange={onChange}
/>
<Button type="submit">Save</Button>
</Form>
</Wrapper>
);
};
export default NoteForm;
接下来,我们需要在我们的 NewNote 页面组件中引用我们的 NoteForm 组件。在 src/pages/new.js 中:
import React, { useEffect } from 'react';
import { useMutation, gql } from '@apollo/client';
// import the NoteForm component
import NoteForm from '../components/NoteForm';
const NewNote = props => {
useEffect(() => {
// update the document title
document.title = 'New Note — Notedly';
});
return <NoteForm />;
};
export default NewNote;
通过这些更新,访问 http://localhost:1234/new 将显示我们的表单(参见图 16-1)。

图 16-1. 我们的 NewNote 组件展示了一个大的文本区域和保存按钮
表单完成后,我们可以开始编写我们的突变以创建新的笔记。在 src/pages/new.js 中:
import React, { useEffect } from 'react';
import { useMutation, gql } from '@apollo/client';
import NoteForm from '../components/NoteForm';
// our new note query
const NEW_NOTE = gql`
mutation newNote($content: String!) {
newNote(content: $content) {
id
content
createdAt
favoriteCount
favoritedBy {
id
username
}
author {
username
id
}
}
}
`;
const NewNote = props => {
useEffect(() => {
// update the document title
document.title = 'New Note — Notedly';
});
const [data, { loading, error }] = useMutation(NEW_NOTE, {
onCompleted: data => {
// when complete, redirect the user to the note page
props.history.push(`note/${data.newNote.id}`);
}
});
return (
<React.Fragment>
{/* as the mutation is loading, display a loading message*/}
{loading && <p>Loading...</p>}
{/* if there is an error, display a error message*/}
{error && <p>Error saving the note</p>}
{/* the form component, passing the mutation data as a prop */}
<NoteForm action={data} />
</React.Fragment>
);
};
export default NewNote;
在上述代码中,当提交表单时,我们执行 newNote 突变。如果突变成功,用户将被重定向到个别笔记页面。您可能注意到 newNote 突变请求了相当多的数据。这与 note 突变请求的数据相匹配,理想情况下更新 Apollo 的缓存以快速导航到个别笔记组件。
如前所述,Apollo 对我们的查询进行了积极的缓存,这有助于加快我们应用的导航速度。不幸的是,这也意味着用户可能访问页面时看不到他们刚刚做出的更新。我们可以手动更新 Apollo 的缓存,但更简单的方法是使用 Apollo 的 refetchQueries 功能,在执行变异时有意更新缓存。为此,我们需要访问我们预写的查询。到目前为止,我们一直将它们包含在组件文件的顶部,但让我们将它们移到它们自己的 query.js 文件中。在 /src/gql/query.js 创建一个新文件,并添加每一个我们的笔记查询以及我们的 IS_LOGGED_IN 查询:
import { gql } from '@apollo/client';
const GET_NOTES = gql`
query noteFeed($cursor: String) {
noteFeed(cursor: $cursor) {
cursor
hasNextPage
notes {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
}
`;
const GET_NOTE = gql`
query note($id: ID!) {
note(id: $id) {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
`;
const IS_LOGGED_IN = gql`
{
isLoggedIn @client
}
`;
export { GET_NOTES, GET_NOTE, IS_LOGGED_IN };
可重用的查询和变异
未来,我们将保持所有的查询和变异与我们的组件分开,这将允许我们在应用程序中轻松重用它们,并且在测试期间也是有用的 mocking。
现在,在 src/pages/new.js 中,我们可以请求我们的变异通过导入查询并添加 refetchQueries 选项来重新获取 GET_NOTES 查询:
// import the query
import { GET_NOTES } from '../gql/query';
// within the NewNote component update the mutation
//everything else stays the same
const NewNote = props => {
useEffect(() => {
// update the document title
document.title = 'New Note — Notedly';
});
const [data, { loading, error }] = useMutation(NEW_NOTE, {
// refetch the GET_NOTES query to update the cache
refetchQueries: [{ query: GET_NOTES }],
onCompleted: data => {
// when complete, redirect the user to the note page
props.history.push(`note/${data.newNote.id}`);
}
});
return (
<React.Fragment>
{/* as the mutation is loading, display a loading message*/}
{loading && <p>Loading...</p>}
{/* if there is an error, display a error message*/}
{error && <p>Error saving the note</p>}
{/* the form component, passing the mutation data as a prop */}
<NoteForm action={data} />
</React.Fragment>
);
};
我们的最后一步将是在 /new 页面添加一个链接,以便用户可以轻松导航到它。在 src/components/Navigation.js 文件中,添加一个新的链接项如下:
<li>
<Link to="/new">New</Link>
</li>
通过这样,我们的用户可以导航到新笔记页面,输入笔记,并将笔记保存到数据库中。
读取用户笔记
我们的应用程序目前能够读取我们的笔记源以及单个笔记,但我们尚未查询经过身份验证用户的笔记。让我们编写两个 GraphQL 查询,以创建用户的笔记源以及他们的收藏夹。
在 src/gql/query.js 中,添加一个 GET_MY_NOTES 查询,并像这样更新导出:
// add the GET_MY_NOTES query
const GET_MY_NOTES = gql`
query me {
me {
id
username
notes {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
}
`;
// update to include GET_MY_NOTES
export { GET_NOTES, GET_NOTE, IS_LOGGED_IN, GET_MY_NOTES };
现在,在 src/pages/mynotes.js 中,导入查询并使用 NoteFeed 组件显示笔记:
import React, { useEffect } from 'react';
import { useQuery, gql } from '@apollo/client';
import NoteFeed from '../components/NoteFeed';
import { GET_MY_NOTES } from '../gql/query';
const MyNotes = () => {
useEffect(() => {
// update the document title
document.title = 'My Notes — Notedly';
});
const { loading, error, data } = useQuery(GET_MY_NOTES);
// if the data is loading, our app will display a loading message
if (loading) return 'Loading...';
// if there is an error fetching the data, display an error message
if (error) return `Error! ${error.message}`;
// if the query is successful and there are notes, return the feed of notes
// else if the query is successful and there aren't notes, display a message
if (data.me.notes.length !== 0) {
return <NoteFeed notes={data.me.notes} />;
} else {
return <p>No notes yet</p>;
}
};
export default MyNotes;
我们可以重复这个过程来制作“收藏夹”页面。首先,在 src/gql/query.js 中:
// add the GET_MY_FAVORITES query
const GET_MY_FAVORITES = gql`
query me {
me {
id
username
favorites {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
}
`;
// update to include GET_MY_FAVORITES
export { GET_NOTES, GET_NOTE, IS_LOGGED_IN, GET_MY_NOTES, GET_MY_FAVORITES };
现在,在 src/pages/favorites.js 中:
import React, { useEffect } from 'react';
import { useQuery, gql } from '@apollo/client';
import NoteFeed from '../components/NoteFeed';
// import the query
import { GET_MY_FAVORITES } from '../gql/query';
const Favorites = () => {
useEffect(() => {
// update the document title
document.title = 'Favorites — Notedly';
});
const { loading, error, data } = useQuery(GET_MY_FAVORITES);
// if the data is loading, our app will display a loading message
if (loading) return 'Loading...';
// if there is an error fetching the data, display an error message
if (error) return `Error! ${error.message}`;
// if the query is successful and there are notes, return the feed of notes
// else if the query is successful and there aren't notes, display a message
if (data.me.favorites.length !== 0) {
return <NoteFeed notes={data.me.favorites} />;
} else {
return <p>No favorites yet</p>;
}
};
export default Favorites;
最后,让我们更新我们的 src/pages/new.js 文件,重新获取 GET_MY_NOTES 查询,以确保在创建笔记时更新用户笔记的缓存列表。在 src/pages/new.js 中,首先更新 GraphQL 查询的导入语句:
import { GET_MY_NOTES, GET_NOTES } from '../gql/query';
然后更新变异:
const [data, { loading, error }] = useMutation(NEW_NOTE, {
// refetch the GET_NOTES and GET_MY_NOTES queries to update the cache
refetchQueries: [{ query: GET_MY_NOTES }, { query: GET_NOTES }],
onCompleted: data => {
// when complete, redirect the user to the note page
props.history.push(`note/${data.newNote.id}`);
}
});
通过这些更改,我们现在可以在应用程序中执行所有的读操作。
更新笔记
目前,一旦用户写下一条笔记,他们就没有办法对其进行更新。为了解决这个问题,我们希望在我们的应用程序中启用笔记编辑。我们的 GraphQL API 有一个 updateNote 变异,它接受笔记 ID 和内容作为参数。如果数据库中存在该笔记,则变异将使用变异中发送的内容更新存储的内容。
在我们的应用程序中,我们可以创建一个在 /edit/NOTE_ID 的路由,该路由将在一个表单 textarea 中放置现有笔记内容。当用户点击保存时,我们将提交表单并执行 updateNote 变异。
让我们创建一个新的路由,用于编辑我们的笔记。首先,我们可以复制我们的 src/pages/note.js 页面,并命名为 edit.js。目前,这个页面将简单地显示笔记内容。
在 src/pages/edit.js 中:
import React from 'react';
import { useQuery, useMutation, gql } from '@apollo/client';
// import the Note component
import Note from '../components/Note';
// import the GET_NOTE query
import { GET_NOTE } from '../gql/query';
const EditNote = props => {
// store the id found in the url as a variable
const id = props.match.params.id;
// define our note query
const { loading, error, data } = useQuery(GET_NOTE, { variables: { id } });
// if the data is loading, display a loading message
if (loading) return 'Loading...';
// if there is an error fetching the data, display an error message
if (error) return <p>Error! Note not found</p>;
// if successful, pass the data to the note component
return <Note note={data.note} />;
};
export default EditNote;
现在,我们可以通过将其添加到 src/pages/index.js 中的路由来使页面可导航:
// import the edit page component
import EditNote from './edit';
// add a new private route that accepts an :id parameter
<PrivateRoute path="/edit/:id" component={EditNote} />
现在,如果您导航到 /note/ID 的笔记页面并将其替换为 /edit/ID,您将看到笔记本身的渲染。让我们改变这个,使其显示在表单的 textarea 中呈现的笔记内容。
在 src/pages/edit.js 中,删除 Note 组件的导入语句,并替换为 NoteForm 组件:
// import the NoteForm component
import NoteForm from '../components/NoteForm';
现在我们可以更新我们的 EditNote 组件以使用我们的编辑表单。我们可以通过 content 属性将笔记内容传递给我们的表单组件。尽管我们的 GraphQL 变更仅接受原始作者的更新,我们也可以限制将表单显示给笔记作者,以避免混淆其他用户。
首先,在 src/gql/query.js 文件中添加一个新的查询来获取当前用户、他们的用户 ID 和收藏的笔记 ID 列表:
// add GET_ME to our queries
const GET_ME = gql`
query me {
me {
id
favorites {
id
}
}
}
`;
// update to include GET_ME
export {
GET_NOTES,
GET_NOTE,
GET_MY_NOTES,
GET_MY_FAVORITES,
GET_ME,
IS_LOGGED_IN
};
在 src/pages/edit.js 中,导入 GET_ME 查询并包含用户检查:
import React from 'react';
import { useMutation, useQuery } from '@apollo/client';
// import the NoteForm component
import NoteForm from '../components/NoteForm';
import { GET_NOTE, GET_ME } from '../gql/query';
import { EDIT_NOTE } from '../gql/mutation';
const EditNote = props => {
// store the id found in the url as a variable
const id = props.match.params.id;
// define our note query
const { loading, error, data } = useQuery(GET_NOTE, { variables: { id } });
// fetch the current user's data
const { data: userdata } = useQuery(GET_ME);
// if the data is loading, display a loading message
if (loading) return 'Loading...';
// if there is an error fetching the data, display an error message
if (error) return <p>Error! Note not found</p>;
// if the current user and the author of the note do not match
if (userdata.me.id !== data.note.author.id) {
return <p>You do not have access to edit this note</p>;
}
// pass the data to the form component
return <NoteForm content={data.note.content} />;
};
现在我们能够在表单中编辑笔记,但是点击按钮还不能保存我们的更改。让我们编写我们的 GraphQL updateNote 变更。与我们的查询文件类似,让我们创建一个文件来保存我们的变更。在 src/gql/mutation 中添加以下内容:
import { gql } from '@apollo/client';
const EDIT_NOTE = gql`
mutation updateNote($id: ID!, $content: String!) {
updateNote(id: $id, content: $content) {
id
content
createdAt
favoriteCount
favoritedBy {
id
username
}
author {
username
id
}
}
}
`;
export { EDIT_NOTE };
编写完我们的变更后,我们可以导入它并更新我们的组件代码,以在单击按钮时调用变更。为此,我们将添加一个 useMutation 钩子。当变更完成时,我们将重定向用户到笔记页面。
// import the mutation
import { EDIT_NOTE } from '../gql/mutation';
const EditNote = props => {
// store the id found in the url as a variable
const id = props.match.params.id;
// define our note query
const { loading, error, data } = useQuery(GET_NOTE, { variables: { id } });
// fetch the current user's data
const { data: userdata } = useQuery(GET_ME);
// define our mutation
const [editNote] = useMutation(EDIT_NOTE, {
variables: {
id
},
onCompleted: () => {
props.history.push(`/note/${id}`);
}
});
// if the data is loading, display a loading message
if (loading) return 'Loading...';
// if there is an error fetching the data, display an error message
if (error) return <p>Error!</p>;
// if the current user and the author of the note do not match
if (userdata.me.id !== data.note.author.id) {
return <p>You do not have access to edit this note</p>;
}
// pass the data and mutation to the form component
return <NoteForm content={data.note.content} action={editNote} />;
};
export default EditNote;
最后,我们希望为用户显示一个“编辑”链接,但只有当他们是笔记的作者时才显示。在我们的应用程序中,我们需要检查确保当前用户的 ID 是否与笔记作者的 ID 匹配。为了实现这种行为,我们将涉及多个组件。
现在我们可以在 Note 组件中直接实现我们的功能,但是我们可以创建一个专门用于已登录用户交互的组件,位于 src/components/NoteUser.js。在这个 React 组件中,我们将为当前用户 ID 执行 GraphQL 查询,并提供一个链接到编辑页面的路由。有了这些信息,我们可以开始包含所需的库并设置一个新的 React 组件。在 React 组件内部,我们将包含一个编辑链接,该链接将路由用户到笔记的编辑页面。目前,无论谁拥有这条笔记,用户都将看到此链接。
更新 src/components/NoteUser.js 如下:
import React from 'react';
import { useQuery, gql } from '@apollo/client';
import { Link } from 'react-router-dom';
const NoteUser = props => {
return <Link to={`/edit/${props.note.id}`}>Edit</Link>;
};
export default NoteUser;
接下来,我们将更新我们的 Note 组件以执行本地的 isLoggedIn 状态查询。然后,我们可以根据用户的登录状态有条件地渲染我们的 NoteUser 组件。
首先导入 GraphQL 库以执行查询以及我们的 NoteUser 组件。在 src/components/Note.js 中,将以下内容添加到文件顶部:
import { useQuery } from '@apollo/client';
// import logged in user UI components
import NoteUser from './NoteUser';
// import the IS_LOGGED_IN local query
import { IS_LOGGED_IN } from '../gql/query';
现在,我们可以更新我们的 JSX 组件以检查登录状态。如果用户已登录,我们将显示 NoteUser 组件;否则,我们将显示收藏计数。
const Note = ({ note }) => {
const { loading, error, data } = useQuery(IS_LOGGED_IN);
// if the data is loading, display a loading message
if (loading) return <p>Loading...</p>;
// if there is an error fetching the data, display an error message
if (error) return <p>Error!</p>;
return (
<StyledNote>
<MetaData>
<MetaInfo>
<img
src={note.author.avatar}
alt={`${note.author.username} avatar`}
height="50px"
/>
</MetaInfo>
<MetaInfo>
<em>by</em> {note.author.username} <br />
{format(note.createdAt, 'MMM Do YYYY')}
</MetaInfo>
{data.isLoggedIn ? (
<UserActions>
<NoteUser note={note} />
</UserActions>
) : (
<UserActions>
<em>Favorites:</em> {note.favoriteCount}
</UserActions>
)}
</MetaData>
<ReactMarkdown source={note.content} />
</StyledNote>
);
};
未经身份验证的编辑
虽然我们将在 UI 中隐藏编辑链接,但用户仍然可以导航到笔记的编辑屏幕,而不必是笔记所有者。幸运的是,我们的 GraphQL API 设计为阻止除笔记所有者以外的任何人编辑笔记内容。尽管我们不会在本书中这样做,但一个很好的额外步骤将是更新 src/pages/edit.js 组件,以便如果用户不是笔记所有者,则重定向用户。
通过这个变化,已登录用户可以在每个笔记顶部看到一个编辑链接。点击链接将导航到一个编辑表单,无论笔记的所有者是谁。让我们通过更新 NoteUser 组件来解决这个问题,查询当前用户的 ID 并仅在与笔记作者的 ID 匹配时显示编辑链接。
首先在 src/components/NoteUser.js 中添加以下内容:
import React from 'react';
import { useQuery } from '@apollo/client';
import { Link } from 'react-router-dom';
// import our GET_ME query
import { GET_ME } from '../gql/query';
const NoteUser = props => {
const { loading, error, data } = useQuery(GET_ME);
// if the data is loading, display a loading message
if (loading) return <p>Loading...</p>;
// if there is an error fetching the data, display an error message
if (error) return <p>Error!</p>;
return (
<React.Fragment>
Favorites: {props.note.favoriteCount}
<br />
{data.me.id === props.note.author.id && (
<React.Fragment>
<Link to={`/edit/${props.note.id}`}>Edit</Link>
</React.Fragment>
)}
</React.Fragment>
);
};
export default NoteUser;
随着这个变化,只有笔记的原作者会在 UI 中看到编辑链接(图 16-2)。

图 16-2. 只有笔记的作者会看到编辑链接
删除笔记
我们的 CRUD 应用程序仍然缺少删除笔记的功能。我们可以编写一个按钮 UI 组件,当点击时,将执行 GraphQL 变异,删除该笔记。让我们从创建一个新组件开始,位于 src/components/DeleteNote.js。由于我们将在不可路由的组件内执行重定向,因此我们将使用 React Router 的 withRouter 高阶组件:
import React from 'react';
import { useMutation } from '@apollo/client';
import { withRouter } from 'react-router-dom';
import ButtonAsLink from './ButtonAsLink';
const DeleteNote = props => {
return <ButtonAsLink>Delete Note</ButtonAsLink>;
};
export default withRouter(DeleteNote);
现在,我们可以编写我们的变异。我们的 GraphQL API 有一个 deleteNote 变异,如果笔记被删除,则返回 true。当变异完成时,我们将重定向用户到我们应用程序的 /mynotes 页面。
首先,在 src/gql/mutation.js 中,编写如下变异:
const DELETE_NOTE = gql`
mutation deleteNote($id: ID!) {
deleteNote(id: $id)
}
`;
// update to include DELETE_NOTE
export { EDIT_NOTE, DELETE_NOTE };
现在,在 src/components/DeleteNote 中,添加以下内容:
import React from 'react';
import { useMutation } from '@apollo/client';
import { withRouter } from 'react-router-dom';
import ButtonAsLink from './ButtonAsLink';
// import the DELETE_NOTE mutation
import { DELETE_NOTE } from '../gql/mutation';
// import queries to refetch after note deletion
import { GET_MY_NOTES, GET_NOTES } from '../gql/query';
const DeleteNote = props => {
const [deleteNote] = useMutation(DELETE_NOTE, {
variables: {
id: props.noteId
},
// refetch the note list queries to update the cache
refetchQueries: [{ query: GET_MY_NOTES, GET_NOTES }],
onCompleted: data => {
// redirect the user to the "my notes" page
props.history.push('/mynotes');
}
});
return <ButtonAsLink onClick={deleteNote}>Delete Note</ButtonAsLink>;
};
export default withRouter(DeleteNote);
现在,我们可以在 src/components/NoteUser.js 文件中导入新的 DeleteNote 组件,并仅显示给笔记的作者:
import React from 'react';
import { useQuery } from '@apollo/client';
import { Link } from 'react-router-dom';
import { GET_ME } from '../gql/query';
// import the DeleteNote component
import DeleteNote from './DeleteNote';
const NoteUser = props => {
const { loading, error, data } = useQuery(GET_ME);
// if the data is loading, display a loading message
if (loading) return <p>Loading...</p>;
// if there is an error fetching the data, display an error message
if (error) return <p>Error!</p>;
return (
<React.Fragment>
Favorites: {props.note.favoriteCount} <br />
{data.me.id === props.note.author.id && (
<React.Fragment>
<Link to={`/edit/${props.note.id}`}>Edit</Link> <br />
<DeleteNote noteId={props.note.id} />
</React.Fragment>
)}
</React.Fragment>
);
};
export default NoteUser;
有了这个变异,已登录用户现在可以通过点击按钮删除笔记。
切换收藏夹
我们应用程序中缺失的最后一部分用户功能是添加和删除“收藏”笔记的功能。让我们遵循我们创建此功能的模式,将其创建为一个组件并集成到我们的应用程序中。首先,在 src/components/FavoriteNote.js 中创建一个新组件:
import React, { useState } from 'react';
import { useMutation } from '@apollo/client';
import ButtonAsLink from './ButtonAsLink';
const FavoriteNote = props => {
return <ButtonAsLink>Add to favorites</ButtonAsLink>;
};
export default FavoriteNote;
在添加任何功能之前,让我们继续将此组件合并到 src/components/NoteUser.js 组件中。首先,导入组件:
import FavoriteNote from './FavoriteNote';
现在,在我们的 JSX 中包含对组件的引用。您可能还记得,当我们编写 GET_ME 查询时,我们包括了一组收藏的笔记 ID,我们将在这里使用它:
return (
<React.Fragment>
<FavoriteNote
me={data.me}
noteId={props.note.id}
favoriteCount={props.note.favoriteCount}
/>
<br />
{data.me.id === props.note.author.id && (
<React.Fragment>
<Link to={`/edit/${props.note.id}`}>Edit</Link> <br />
<DeleteNote noteId={props.note.id} />
</React.Fragment>
)}
</React.Fragment>
);
你会注意到我们将三个属性传递给我们的 FavoriteNote 组件。首先是我们的 me 数据,其中包括当前用户的 ID 以及用户收藏的注释列表。其次是当前注释的 noteID。最后是 favoriteCount,即当前用户收藏的总数。
现在我们可以返回到我们的 src/components/FavoriteNote.js 文件。在这个文件中,我们将以状态形式存储当前收藏的数量,并检查当前注释 ID 是否在用户收藏列表中。我们将根据用户收藏的状态更改用户看到的文本。当用户点击按钮时,它将调用我们的 toggleFavorite 变更,这将从用户的列表中添加或移除收藏。让我们开始更新组件,以使用状态来控制点击功能。
const FavoriteNote = props => {
// store the note's favorite count as state
const [count, setCount] = useState(props.favoriteCount);
// store if the user has favorited the note as state
const [favorited, setFavorited] = useState(
// check if the note exists in the user favorites list
props.me.favorites.filter(note => note.id === props.noteId).length > 0
);
return (
<React.Fragment>
{favorited ? (
<ButtonAsLink
onClick={() => {
setFavorited(false);
setCount(count - 1);
}}
>
Remove Favorite
</ButtonAsLink>
) : (
<ButtonAsLink
onClick={() => {
setFavorited(true);
setCount(count + 1);
}}
>
Add Favorite
</ButtonAsLink>
)}
: {count}
</React.Fragment>
);
};
在前述更改中,当用户点击时我们正在更新状态,但我们尚未调用我们的 GraphQL 变更。让我们通过编写变更并将其添加到组件中来完成此组件。结果显示在 图 16-3 中。
在 src/gql/mutation.js 文件中:
// add the TOGGLE_FAVORITE mutation
const TOGGLE_FAVORITE = gql`
mutation toggleFavorite($id: ID!) {
toggleFavorite(id: $id) {
id
favoriteCount
}
}
`;
// update to include TOGGLE_FAVORITE
export { EDIT_NOTE, DELETE_NOTE, TOGGLE_FAVORITE };
在 src/components/FavoriteNote.js 文件中:
import React, { useState } from 'react';
import { useMutation } from '@apollo/client';
import ButtonAsLink from './ButtonAsLink';
// the TOGGLE_FAVORITE mutation
import { TOGGLE_FAVORITE } from '../gql/mutation';
// add the GET_MY_FAVORITES query to refetch
import { GET_MY_FAVORITES } from '../gql/query';
const FavoriteNote = props => {
// store the note's favorite count as state
const [count, setCount] = useState(props.favoriteCount);
// store if the user has favorited the note as state
const [favorited, setFavorited] = useState(
// check if the note exists in the user favorites list
props.me.favorites.filter(note => note.id === props.noteId).length > 0
);
// toggleFavorite mutation hook
const [toggleFavorite] = useMutation(TOGGLE_FAVORITE, {
variables: {
id: props.noteId
},
// refetch the GET_MY_FAVORITES query to update the cache
refetchQueries: [{ query: GET_MY_FAVORITES }]
});
// if the user has favorited the note, display the option to remove the favorite
// else, display the option to add as a favorite
return (
<React.Fragment>
{favorited ? (
<ButtonAsLink
onClick={() => {
toggleFavorite();
setFavorited(false);
setCount(count - 1);
}}
>
Remove Favorite
</ButtonAsLink>
) : (
<ButtonAsLink
onClick={() => {
toggleFavorite();
setFavorited(true);
setCount(count + 1);
}}
>
Add Favorite
</ButtonAsLink>
)}
: {count}
</React.Fragment>
);
};
export default FavoriteNote;

图 16-3. 登录用户将能够创建、读取、更新和删除注释
结论
在本章中,我们将我们的网站转变为一个完全功能的 CRUD(创建、读取、更新、删除)应用程序。我们现在可以根据登录用户的状态实现 GraphQL 查询和变更。能够构建集成 CRUD 用户交互的用户界面将为构建各种 Web 应用程序提供坚实的基础。有了这个功能,我们已经完成了我们应用的 MVP(最小可行产品)。在下一章中,我们将把应用部署到 Web 服务器上。
第十七章:部署 Web 应用程序
当我开始专业进行 web 开发时,“部署”意味着通过 FTP 客户端从我的本地机器上传文件到 web 服务器。当时没有任何构建步骤或流水线,这意味着我的本地机器上的原始文件与我的 web 服务器上的文件相同。如果出现问题,我要么拼命地尝试修复问题,要么通过替换旧文件的副本来回滚更改。这种“荒野西部”的方法当时效果还可以,但也导致了很多站点停机和意外问题。
在今天的 web 开发世界中,我们本地开发环境和 web 服务器的需求是完全不同的。在我的本地机器上,当我更新文件时,我希望看到即时变化,并且拥有未压缩的文件以进行调试。在我的 web 服务器上,我只期望在部署时看到变化,并且重视小文件大小。在本章中,我们将看一种将静态应用程序部署到 web 的方式。
静态网站
web 浏览器解析 HTML、CSS 和 JavaScript 来生成我们交互的网页。不同于 Express、Rails 和 Django 等框架在请求时服务器端生成页面的标记,静态网站只是存储在服务器上的一组 HTML、CSS 和 JavaScript。这可以从包含标记的单个 HTML 文件到编译模板语言、多个 JavaScript 文件和 CSS 预处理器的复杂前端构建过程。然而,静态网站最终只是这三种文件类型的集合。
我们的应用程序 Notedly 是一个静态 web 应用程序。它包含一些标记、CSS 和 JavaScript。我们的构建工具,Parcel,将我们编写的组件编译为浏览器可用的文件。在本地开发中,我们运行一个 web 服务器,并使用 Parcel 的热模块替换功能即时更新这些文件。如果我们查看我们的package.json文件,你会看到我包含了两个deploy脚本:
"scripts": {
"deploy:src": "parcel build src/index.html --public-url ./",
"deploy:final": "parcel build final/index.html --public-url ./"
}
要构建应用程序,请打开你的终端应用程序,cd 进入包含项目的web目录的根目录,然后运行build命令:
# if you're not already in the web directory, be sure to cd into it
$ cd Projects/notedly/web
# build the files from the src directory
$ npm run deploy:src
如果你一直在跟随本书并在src目录中开发你的 web 应用程序,那么在终端中运行 npm run deploy:src,正如刚才描述的那样,将会根据你的代码生成构建好的应用程序。如果你更愿意使用与示例代码捆绑的应用程序的最终版本,那么使用 npm run deploy:final 将会从final应用程序目录构建代码。
在本章的其余部分,我将演示一种部署静态构建应用程序的方法,但这些文件可以托管在任何可以提供 HTML 的地方,从 Web 主机提供商到放在桌子上运行的树莓派。虽然我们将要进行的这种过程有许多实际的好处,但您的部署可能仅需简单地更新 .env 文件以指向远程 API,运行构建脚本并上传文件。
服务器端渲染的 React
虽然我们正在构建我们的 React 应用作为静态 Web 应用程序,但也可以在服务器上渲染 JSX。这种技术通常被称为“通用 JavaScript”,可以带来许多好处,包括性能提升、客户端 JavaScript 备用和 SEO 改进。像 Next.js 这样的框架试图简化这个设置。虽然本书不涵盖服务器端渲染的 JavaScript 应用程序,但我强烈建议一旦您熟悉了客户端 JavaScript 应用程序开发,探索这种方法。
我们的部署流水线
对于我们应用程序的部署,我们将使用一个简单的流水线,这将允许我们自动部署代码库的更改。对于我们的流水线,我们将使用两个服务。第一个将是我们的源代码存储库,GitHub。第二个将是我们的 Web 主机,Netlify。我选择 Netlify 是因为它在部署方面具有广泛且易于使用的功能集,以及它专注于静态和无服务器应用程序。
我们的目标是使我们应用程序的 master 分支的任何提交都自动部署到我们的 Web 主机。我们可以将这个过程可视化如 图 17-1 所示。

图 17-1. 我们的部署过程
使用 Git 托管源代码
我们部署过程的第一步是设置我们的源代码存储库。您可能已经完成了这一步骤,如果是这样,请随意跳过。正如前面提到的,我们将使用 GitHub,但这个过程也可以配置为使用其他公共 Git 主机,比如 GitLab 或 Bitbucket。
GitHub 存储库
我们将创建一个新的 GitHub 存储库,但如果您愿意,您可以通过将官方代码示例 fork 到您的 GitHub 帐户来使用 https://github.com/javascripteverywhere/web。
首先,转到 GitHub 并创建一个帐户或登录您的现有帐户。然后点击“New Repository”按钮。提供一个名称并点击“Create Repository”按钮(图 17-2)。

图 17-2. GitHub 的新存储库页面
现在,在你的终端应用程序中,导航到你的 Web 应用程序目录,将 Git origin 设置为新的 GitHub 仓库,并推送代码。因为我们正在更新现有的 Git 仓库,所以我们的指令与 GitHub 的略有不同:
# first navigate to the directory if you're not already there
cd Projects/notedly/web
# update the GitHub remote origin to match your repository
git remote set-url origin git://YOUR.GIT.URL
# push the code to the new GitHub repository
git push -u origin master
现在,如果你访问https://github.com/<your_username>/<your_repo_name>,你将看到应用程序的源代码。
使用 Netlify 进行部署
现在我们的源代码位于远程 Git 仓库中,我们可以配置我们的 Web 主机 Netlify 来构建和部署我们的代码。首先,访问netlify.com,注册一个账户。一旦你创建了账户,点击“New site from Git”按钮。这将引导你完成设置站点部署的步骤:
-
选择 GitHub 作为你的 Git 提供者,这将连接并授权你的 GitHub 账户。
-
接下来,选择包含源代码的存储库。
-
最后,设置你的构建设置。
对于我们的构建设置,请添加以下内容(图 17-3):
-
构建命令:
npm run deploy:src(或者如果部署最终示例代码,则为npm run deploy:final)。 -
发布目录:
dist。 -
在“高级设置”下,点击“新建变量”,并添加一个名为
API_URI的变量名和值为https://<your_api_name>.herokuapp.com/api(这将是我们部署到 Heroku 的 API 应用的 URL)。
一旦配置了应用程序,点击“Deploy site”按钮。几分钟后,你的应用程序将在 Netlify 提供的 URL 上运行。现在,每当我们推送 GitHub 仓库的更改时,我们的网站将自动部署。
初始加载缓慢
我们部署的 Web 应用程序将从我们部署的 Heroku API 加载数据。使用 Heroku 的免费计划,应用程序容器在一小时不活动后会进入睡眠状态。如果你有一段时间没有使用你的 API,初始数据加载将会很慢,因为容器需要重新启动。

图 17-3. 使用 Netlify 我们可以配置我们的构建过程和环境变量。
结论
在本章中,我们部署了一个静态网络应用程序。为此,我们使用了 Netlify 的部署管道功能来监视我们的 Git 仓库的更改,运行我们的构建流程,并存储环境变量。有了这个基础,我们拥有了发布 Web 应用程序所需的一切条件。
第十八章:桌面应用程序与 Electron
我对个人计算机的初次接触是在一个装满 Apple II 机器的学校实验室里。每周一次,我和我的同学们被引导进入这个房间,拿到一些软盘,并被给予一系列大致的加载应用程序(通常是Oregon Trail)的指导。我对这些会话的记忆不多,只记得感觉完全被锁定在我现在能够控制的小世界里。自 1980 年代中期以来,个人计算机已经发展了很长一段路程,但我们仍然依赖桌面应用程序来执行许多任务。
在一个典型的工作日中,我可能会访问电子邮件客户端、文本编辑器、聊天客户端、电子表格软件、音乐流媒体服务等多个桌面应用程序。通常,这些应用程序都有对应的 Web 应用程序,但桌面应用程序的便利性和集成性可以为用户体验提供几个好处。然而,多年来创建这些应用程序的能力一直觉得遥不可及。幸运的是,今天我们能够利用 Web 技术来构建具有小学习曲线的全功能桌面应用程序。
我们要构建什么
在接下来的几章中,我们将为我们的社交笔记应用程序 Notedly 构建一个桌面客户端。我们的目标是使用 JavaScript 和 Web 技术开发一个用户可以下载并安装到他们的计算机上的桌面应用程序。目前,这个应用程序将是一个简单的实现,它将我们的 Web 应用程序包装在一个桌面应用程序外壳中。以这种方式开发我们的应用程序将使我们能够快速为感兴趣的用户发布一个桌面应用程序,同时为我们提供灵活性,在以后可以为桌面用户引入定制应用程序。
我们将如何构建这个应用程序
要构建我们的应用程序,我们将使用Electron,这是一个使用 Web 技术构建跨平台桌面应用程序的开源框架。它通过利用 Node.js 和 Chrome 的底层浏览器引擎 Chromium 工作。这意味着作为开发人员,我们可以访问浏览器、Node.js 和操作系统特定的功能,这些功能通常在 Web 环境中不可用。Electron 最初由 GitHub 为Atom 文本编辑器开发,但后来被用作包括 Slack、VS Code、Discord 和 WordPress 桌面在内的各种大小应用程序的平台。
入门指南
在我们开始开发之前,我们需要将项目的起始文件复制到我们的计算机上。项目的源代码包含了我们开发应用程序所需的所有脚本和第三方库的引用。要将代码克隆到我们的本地计算机上,请打开终端,导航到您保存项目的目录,并git clone项目存储库。如果您已经完成了 API 和 Web 章节,您可能也已经创建了一个notedly目录来保持项目代码的组织。
$ cd Projects
$ # type the `mkdir notedly` command if you don't yet have a notedly directory
$ cd notedly
$ git clone git@github.com:javascripteverywhere/desktop.git
$ cd desktop
$ npm install
安装第三方依赖项
通过 通过复制书中的启动代码并在目录中运行npm install,你可以避免为任何单独的第三方依赖项再次运行npm install。
代码结构如下:
/src
这是你在跟随书籍的过程中进行开发的目录。
/solutions
这个目录包含每一章的解决方案。如果你卡住了,可以参考这些解决方案。
/final
这个目录包含最终的工作项目。
在我们创建好项目目录并安装依赖项后,我们已经准备好开始开发。
我们的第一个 Electron 应用
在将我们的仓库克隆到机器上后,让我们开发我们的第一个 Electron 应用程序。如果你查看src目录,你会看到有几个文件。index.html 文件包含基本的 HTML 标记。现在,这个文件将作为 Electron 的“渲染进程”,意味着它将作为我们 Electron 应用程序显示的网页。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Notedly Desktop</title>
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>
index.js 文件是我们将设置 Electron 应用程序的地方。在我们的应用程序中,这个文件将包含 Electron 所称的“主进程”,它定义了应用程序的外壳。主进程通过在 Electron 中创建一个 BrowserWindow 实例来工作,它作为应用程序的外壳。
index.js 与 main.js
尽管我将文件命名为 index.js,以遵循我们其余示例应用程序的模式,但在 Electron 开发中,通常将“主进程”文件命名为 main.js。
让我们设置我们的主进程,以显示包含我们的 HTML 页面浏览器窗口。首先,在src/index.js中导入 Electron 的 app 和 browserWindow 功能:
const { app, BrowserWindow } = require('electron');
现在我们可以定义应用程序的browserWindow,并定义应用程序将加载的文件。在src/index.js中,添加以下内容:
const { app, BrowserWindow } = require('electron');
// to avoid garbage collection, declare the window as a variable
let window;
// specify the details of the browser window
function createWindow() {
window = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true
}
});
// load the HTML file
window.loadFile('index.html');
// when the window is closed, reset the window object
window.on('closed', () => {
window = null;
});
}
// when electron is ready, create the application window
app.on('ready', createWindow);
设置好这些后,我们可以在本地运行我们的桌面应用程序。在你的终端应用中,从项目的目录运行以下命令:
$ npm start
这个命令将运行electron src/index.js,启动我们应用程序的开发环境版本(见图 18-1)。

图 18-1。运行启动命令将启动我们的“Hello World” Electron 应用程序。
macOS 应用程序窗口详情
macOS 处理应用程序窗口的方式与 Windows 不同。当用户点击“关闭窗口”按钮时,应用程序窗口会关闭,但应用程序本身不会退出。点击 macOS dock 中的应用程序图标将重新打开应用程序窗口。Electron 允许我们实现这一功能。将以下内容添加到src/index.js 文件的底部:
// quit when all windows are closed.
app.on('window-all-closed', () => {
// On macOS only quit when a user explicitly quits the application
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
// on macOS, re-create the window when the icon is clicked in the dock
if (window === null) {
createWindow();
}
});
添加这些内容后,你可以通过退出应用程序并使用npm start命令重新运行它来查看这些更改。现在,如果用户在 macOS 上访问我们的应用程序,当关闭窗口时,他们将看到预期的行为。
开发者工具
由于 Electron 基于 Chromium 浏览器引擎(Chrome、Microsoft Edge、Opera 和 许多其他浏览器 的引擎),它还使我们能够访问 Chromium 的开发者工具。这使我们能够在浏览器环境中进行与 JavaScript 调试相关的所有操作。让我们检查一下我们的应用程序是否处于开发模式,如果是的话,自动在应用程序启动时打开开发工具。
要执行此检查,我们将使用 electron-util 库。这是一个小型实用工具集,它将允许我们轻松检查系统条件,并简化常见 Electron 模式的样板代码。目前,我们将使用 is 模块,它将允许我们检查我们的应用程序是否处于开发模式。
在我们的 src/index.js 文件的顶部,导入模块:
const { is } = require('electron-util');
现在,在我们的应用程序代码中,我们可以在加载 HTML 文件的地方添加以下内容,在开发环境中(图 18-2)应用程序启动时打开开发工具:
// if in development mode, open the browser dev tools
if (is.development) {
window.webContents.openDevTools();
}

图 18-2. 现在,当我们在开发过程中打开我们的应用程序时,浏览器开发工具将自动打开
Electron 安全警告
你可能会注意到,我们的 Electron 应用当前显示了与不安全的内容安全策略(CSP)相关的安全警告。我们将在下一章节解决这个问题。
通过方便使用浏览器开发工具,我们已经做好了开发客户端应用程序的准备。
Electron API
桌面开发的一个优势是,通过 Electron API,我们可以访问操作系统级别的功能,这些功能在 Web 浏览器环境中是无法获取的,包括:
-
通知
-
原生文件拖放
-
macOS 暗模式
-
自定义菜单
-
强大的键盘快捷键
-
系统对话框
-
应用程序托盘
-
系统信息
正如你可以想象的那样,这些选项允许我们为桌面客户端添加一些独特的功能和改进的用户体验。在我们的简单示例应用程序中,我们不会使用这些功能,但值得熟悉。Electron 的文档 提供了 Electron API 的详细示例。此外,Electron 团队还创建了 electron-api-demos,一个完整的 Electron 应用程序,展示了 Electron API 的许多独特功能。
结论
在本章中,我们已经探讨了使用 Electron 构建基于 Web 技术的桌面应用程序的基础知识。作为开发者,Electron 环境为我们提供了一个机会,可以为用户提供跨平台的桌面体验,而无需学习多种编程语言和操作系统的复杂性。有了本章中探讨的简单设置和对 Web 开发的了解,我们已经做好了构建强大桌面应用程序的准备。在下一章中,我们将探讨如何将现有的 Web 应用程序集成到 Electron 壳程序中。
第十九章:将现有 Web 应用集成到 Electron 中
我倾向于像孩子在沙滩上收集贝壳一样收集网络浏览器标签。我并不一定打算收集它们,但到了一天结束时,我在几个浏览器窗口中打开了几十个标签。我对此并不感到自豪,但我怀疑我并不孤单。因此,我使用一些我最常用的网络应用程序的桌面版本。通常,这些应用程序在 Web 上没有任何优势,但独立应用程序的便利性使它们易于访问、查找和全天切换。
在本章中,我们将看看如何将现有的 Web 应用程序封装到 Electron 外壳中。在继续之前,您需要本地存储我们示例 API 和 Web 应用程序的副本。如果您没有从头到尾跟随整本书的话,请访问附录 A 和 B 运行这些内容。
集成我们的 Web 应用程序
在上一章中,我们设置了我们的 Electron 应用程序加载一个 index.html 文件。或者,我们可以加载一个特定的 URL。在我们的情况下,我们将首先加载本地运行的 Web 应用程序的 URL。首先确保您的 Web 应用程序和 API 在本地运行。然后我们可以更新我们的 src/index.js 文件,首先将 BrowserWindow 中的 nodeIntegration 设置为 false。这将避免本地运行的 Node 应用程序访问外部站点时的安全风险。
webPreferences: {
nodeIntegration: false
},
现在,请将 window.loadFile('index.html'); 行替换为以下内容:
window.loadURL('http://localhost:1234');
运行 Web 应用程序
您的 Web 应用程序的本地实例需要在 1234 端口上运行。如果您一直在跟随本书,从您的 Web 应用程序目录的根目录运行 npm start 启动开发服务器。
这将指示 Electron 加载一个 URL,而不是一个文件。现在,如果您使用 npm start 运行应用程序,您会看到它在 Electron 窗口中加载,但有一些注意事项。
警告和错误
Electron 浏览器开发工具和我们的终端当前显示了大量的警告和错误。让我们逐个查看这些问题(参见 图 19-1)。

图 19-1. 我们的应用程序正在运行,但显示了大量错误和警告
首先,我们的终端显示了大量的 SyntaxError: Unexpected Token 错误。此外,我们的开发工具显示了几个相应的警告,说明 DevTools 无法解析 SourceMap。这两个错误与 Parcel 生成源映射的方式以及 Electron 读取它们的方式有关。不幸的是,考虑到我们使用的技术组合,似乎没有合理的修复此问题的方法。我们的最佳选择是禁用 JavaScript 源映射。在应用程序窗口的开发工具中,单击“设置”,然后取消选中“启用 JavaScript 源映射”(参见 图 19-2)。

图 19-2. 禁用源映射将减少错误和警告的数量
现在,如果您退出并重新启动应用程序,您将不再看到与源映射相关的问题。这样做的一个弊端是,在 Electron 中调试我们的客户端 JavaScript 可能会更加困难,但幸运的是,我们仍然可以在 web 浏览器中访问此功能和我们的应用程序。
最后两个警告与 Electron 的安全性有关。在将应用程序捆绑到生产环境之前,我们将解决这些问题,但现在探索一下这些警告是值得的。
Electron 安全警告(不安全资源)
此警告通知我们,我们正在通过 http 连接加载 web 资源。在生产环境中,我们应始终通过 https 加载资源以确保隐私和安全性。在开发过程中,通过 http 加载本地主机不是问题,因为我们将引用捆绑应用程序中使用 https 的托管网站。
Electron 安全警告(不安全内容安全策略)
此警告告诉我们,我们尚未设置内容安全策略(CSP)。CSP 允许我们指定允许我们的应用程序从哪些域加载资源,极大地减少了跨站脚本(XSS)攻击的风险。在本地开发期间,这并不是一个问题,但在生产环境中很重要。我们将在本章后面实施 CSP。
处理完错误后,我们准备设置应用程序的配置文件。
配置
在本地开发时,我们希望能够运行我们的 Web 应用程序的本地版本,但在捆绑应用程序以供他人使用时,我们希望它引用公开可用的 URL。我们可以设置一个简单的配置文件来处理这个问题。
在我们的 ./src 目录中,我们将添加一个 config.js 文件,其中我们可以存储特定于应用程序的属性。我已经包含了一个 config.example.js 文件,您可以轻松从终端复制:
cp src/config.example.js src/config.js
现在我们可以填写我们应用程序的属性:
const config = {
LOCAL_WEB_URL: 'http://localhost:1234/',
PRODUCTION_WEB_URL: 'https://YOUR_DEPLOYED_WEB_APP_URL',
PRODUCTION_API_URL: 'https://YOUR_DEPLOYED_API_URL'
};
module.exports = config;
为什么不使用 .env?
在我们之前的环境中,我们使用 .env 文件来管理特定环境的设置。在这种情况下,我们使用 JavaScript 配置文件,因为 Electron 应用程序打包它们的依赖项的方式。
现在在我们的 Electron 应用程序的主进程中,我们可以使用配置文件来指定在开发和生产中要加载的 URL。在 src/index.js 中,首先导入 config.js 文件:
const config = require('./config');
现在,我们可以更新 loadURL 功能以在每个环境中加载不同的 URL:
// load the URL
if (is.development) {
window.loadURL(config.LOCAL_WEB_URL);
} else {
window.loadURL(config.PRODUCTION_WEB_URL);
}
通过使用配置文件,我们可以轻松地为 Electron 提供特定环境的设置。
内容安全策略
如本章前面所述,CSP 允许我们限制应用程序有权限加载资源的域。这有助于限制潜在的 XSS 和数据注入攻击。在 Electron 中,我们可以指定我们的 CSP 设置以提高应用程序的安全性。要了解有关 Electron 和 Web 应用程序的 CSP 的更多信息,我建议阅读有关主题的 MDN 文章。
Electron 提供了一个内置的 CSP API,但 electron-util 库提供了更简单和更清晰的语法。在我们的 src/index.js 文件顶部更新 electron-util 导入语句以包含 setContentSecurityPolicy:
const { is, setContentSecurityPolicy } = require('electron-util');
现在我们可以为应用程序的生产版本设置我们的 CSP:
// set the CSP in production mode
if (!is.development) {
setContentSecurityPolicy(`
default-src 'none';
script-src 'self';
img-src 'self' https://www.gravatar.com;
style-src 'self' 'unsafe-inline';
font-src 'self';
connect-src 'self' ${config.PRODUCTION_API_URL};
base-uri 'none';
form-action 'none';
frame-ancestors 'none';
`);
}
使用我们编写的 CSP,我们可以使用 CSP 评估器 工具检查错误。如果我们有意访问其他 URL 的资源,我们可以将它们添加到我们的 CSP 规则集中。
我们最终的 src/index.js 文件将如下所示:
const { app, BrowserWindow } = require('electron');
const { is, setContentSecurityPolicy } = require('electron-util');
const config = require('./config');
// to avoid garbage collection, declare the window as a variable
let window;
// specify the details of the browser window
function createWindow() {
window = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: false
}
});
// load the URL
if (is.development) {
window.loadURL(config.LOCAL_WEB_URL);
} else {
window.loadURL(config.PRODUCTION_WEB_URL);
}
// if in development mode, open the browser dev tools
if (is.development) {
window.webContents.openDevTools();
}
// set the CSP in production mode
if (!is.development) {
setContentSecurityPolicy(`
default-src 'none';
script-src 'self';
img-src 'self' https://www.gravatar.com;
style-src 'self' 'unsafe-inline';
font-src 'self';
connect-src 'self' ${config.PRODUCTION_API_URL};
base-uri 'none';
form-action 'none';
frame-ancestors 'none';
`);
}
// when the window is closed, dereference the window object
window.on('closed', () => {
window = null;
});
}
// when electron is ready, create the application window
app.on('ready', createWindow);
// quit when all windows are closed.
app.on('window-all-closed', () => {
// On macOS only quit when a user explicitly quits the application
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
// on macOS, re-create the window when the icon is clicked in the dock
if (window === null) {
createWindow();
}
});
通过这种方式,我们已经在 Electron 外壳中实现了我们的 Web 应用程序的工作实现(如 图 19-3 所示)。

图 19-3. 我们的 Web 应用程序在 Electron 应用程序外壳中运行
结论
在本章中,我们将现有的 Web 应用程序集成到 Electron 桌面应用程序中,这使我们能够快速将桌面应用程序推向市场。值得注意的是,这种方法存在一些权衡之处,因为它提供了有限的桌面特定优势,并且需要互联网连接才能访问应用程序的全部功能。对于那些希望尽快推向市场的人来说,这些缺点可能是值得的。在下一章中,我们将看看如何构建和分发 Electron 应用程序。
第二十章:电子部署
我第一次教编程课时,想出了一个聪明的主意,通过一个文本冒险游戏介绍课程内容。学生们会进入实验室,坐在桌子前,按照一系列我认为很搞笑的提示和说明进行操作。这受到了不同的反响,不是因为笑话(也许也有笑话的原因),而是因为学生们以前没有以这种方式与“程序”进行互动。学生们习惯于图形用户界面(GUI),通过文本提示与程序进行互动对他们来说感觉不对。
现在,为了运行我们的应用程序,我们需要在终端应用程序中键入一个提示符来启动 Electron 进程。在本章中,我们将看看如何将我们的应用程序打包以进行分发。为了实现这一目标,我们将使用流行的Electron Builder库,该库将帮助我们将应用程序打包并分发给用户。
Electron Builder
Electron Builder 是一个旨在简化 Electron 和Proton Native 应用程序打包和分发的库。虽然有其他打包解决方案,但 Electron Builder 简化了与应用程序分发相关的许多痛点,包括:
-
代码签名
-
多平台分发目标
-
自动更新
-
分发
它在灵活性和功能之间提供了很好的平衡。此外,虽然我们不会使用它们,但还有几个 Electron Builder 的样板文件适用于Webpack、React、Vue和纯 JavaScript。
Electron Builder 与 Electron Forge 的比较
Electron Forge 是另一个流行的库,提供许多与 Electron Builder 类似的功能。Electron Forge 的一个主要优点是它基于官方 Electron 库,而 Electron Builder 是一个独立的构建工具。这意味着用户可以从 Electron 生态系统的增长中受益。缺点是 Electron Forge 基于更加严格的应用程序设置。对于本书的目的,Electron Builder 提供了功能和学习机会的良好平衡,但我鼓励你也仔细研究 Electron Forge。
配置 Electron Builder
所有 Electron Builder 的配置都将在我们应用程序的 package.json 文件中进行。在该文件中,我们可以看到 electron-builder 已列为开发依赖项。在 package.json 文件中,我们可以包含一个名为 "build" 的键,其中将包含所有给 Electron Builder 的打包应用程序的指令。首先,我们将包括两个字段:
appId
这是我们应用程序的唯一标识符。macOS 称之为CFBundleIdentifier,Windows 称之为AppUserModelID。标准是使用反向 DNS 格式。例如,如果我们经营一个域名为jseverywhere.io的公司,并构建一个名为 Notedly 的应用程序,则该 ID 将是io.jseverywhere.notedly。
productName
这是我们产品名称的人类可读版本,因为package.json的name字段需要连字符或单词名称。
所有在一起,我们的初始构建配置将如下所示:
"build": {
"appId": "io.jseverywhere.notedly",
"productName": "Notedly"
},
Electron Builder 为我们提供了许多配置选项,我们将在本章中探讨其中的几个。完整列表,请访问Electron Builder 文档。
为我们当前的平台构建
在我们完成最小配置后,我们可以创建我们的第一个应用程序构建。默认情况下,Electron Builder 将生成适合我们开发的系统的构建。例如,我现在正在 MacBook 上写作,我的构建将默认为 macOS。
首先,在我们的package.json文件中添加两个脚本,这些脚本将负责应用程序的构建。首先,pack脚本将生成一个包目录,而不会完全打包应用程序。这对于测试目的非常有用。其次,dist脚本将以可分发格式打包应用程序,例如 macOS DMG、Windows 安装程序或 DEB 包。
"scripts": {
// add the pack and dist scripts to the existing npm scripts list
"pack": "electron-builder --dir",
"dist": "electron-builder"
}
有了这些改变,您可以在终端应用程序中运行npm run dist,这将在项目的dist/目录中打包应用程序。导航到dist/目录,您可以看到 Electron Builder 已经将应用程序打包为适合您操作系统的分发版本。
应用程序图标
你可能已经注意到的一件事是,我们的应用程序正在使用默认的 Electron 应用程序图标。这在本地开发中是可以的,但对于生产应用程序,我们希望使用自己的品牌。在我们项目的/resources文件夹中,我包含了一些适用于 macOS 和 Windows 的应用程序图标。为了从 PNG 文件生成这些图标,我使用了iConvert Icons 应用程序,它适用于 macOS 和 Windows。
在我们的/resources文件夹中,您将看到以下文件:
-
icon.icns,macOS 应用程序图标
-
icon.ico,Windows 应用程序图标
-
一个包含一系列不同大小的.png文件的icons目录,供 Linux 使用
可选地,我们还可以通过添加具有background.png和background@2x.png名称的图标来包含 macOS DMG 的背景图像,适用于视网膜屏幕。
现在,在我们的package.json文件中,我们更新build对象以指定构建资源目录的名称:
"build": {
"appId": "io.jseverywhere.notedly",
"productName": "Notedly",
"directories": {
"buildResources": "resources"
}
},
现在,当我们构建应用程序时,Electron Builder 将使用我们自定义的应用程序图标打包它(见图 20-1)。

图 20-1. 我们在 macOS dock 中的自定义应用程序图标
面向多个平台构建
目前,我们只为与我们开发平台匹配的操作系统构建我们的应用程序。作为平台的一个巨大优势之一,Electron 允许我们使用相同的代码来针对多个平台进行目标设置,通过更新我们的 dist 脚本来实现这一点。为了实现这一点,Electron Builder 使用了自由开源的 electron-build-service。我们将使用此服务的公共实例,但组织可以自行托管它,以寻求额外的安全性和隐私。
在我们的 package.json 中更新 dist 脚本为:
"dist": "electron-builder -mwl"
这将导致一个面向 macOS、Windows 和 Linux 的构建。从这里,我们可以通过将其作为 GitHub 的发布来分发我们的应用程序,或者通过任何可以分发文件的地方,如 Amazon S3 或我们的 Web 服务器。
代码签名
macOS 和 Windows 都包括 代码签名 的概念。代码签名有助于提升应用程序的安全性和用户的信任度,因为它有助于表明应用程序的可信度。我不会详细介绍代码签名过程,因为它是特定于操作系统的,并且对开发者来说是有成本的。Electron Builder 文档提供了一篇关于各种平台代码签名的全面文章。此外,Electron 文档提供了多个资源和链接。如果您正在构建生产应用程序,我鼓励您进一步研究 macOS 和 Windows 的代码签名选项。
结论
我们仅仅涉及了部署 Electron 应用程序的冰山一角。在本章中,我们使用 Electron Builder 来构建我们的应用程序。然后,我们可以轻松地通过任何 Web 主机上传和分发它们。一旦我们超越了这些需求,我们可以使用 Electron Builder 将构建集成到持续交付流水线中;自动将发布推送到 GitHub、S3 或其他分发平台;并将自动更新集成到应用程序中。如果您有兴趣进一步探索 Electron 开发和应用分发的主题,这些都是绝佳的下一步。
第二十一章:使用 React Native 的移动应用程序
上个世纪 80 年代末的一天,我和父母一起购物时发现了一台小型便携电视机。这是一种电池供电的方形盒子,配有天线、小扬声器和一个小黑白屏幕。我被能够在后院看星期六早晨卡通的可能性震撼了。虽然我永远不会拥有这样一种设备,但仅仅知道这种设备的存在让我感觉就像生活在一个科幻未来的世界中。小小的我没有意识到,作为一个成年人,我会随身携带一部设备,它不仅能让我观看《宇宙大师》,还能够访问无限的信息、听音乐、玩游戏、记笔记、拍照、召唤汽车、购物、查看天气,并完成无限其他任务。
2007 年,史蒂夫·乔布斯推出了 iPhone,并说:“有时会出现一种革命性产品,改变一切。”的确,在 2007 年之前就已经存在智能手机,但要说他们是真正智能的,必须是从 iPhone(及随后的 Android 的崛起)开始的。在随后的几年里,智能手机应用程序从最初的“任何事情都可以”淘金热阶段发展到用户要求质量和对其有高度期望的地步。今天的应用程序具有高功能性、互动性和设计标准。为了增加挑战,现代移动应用开发分散在 Apple iOS 和 Android 平台上,每个平台使用不同的编程语言和工具链。
你可能已经猜到(书名中有提到),JavaScript 使我们作为开发人员能够编写跨平台移动应用程序。在本章中,我将介绍启用这一点的库 React Native,以及 Expo 工具链。我们还将克隆样例项目代码,我们将在接下来的几章中对此进行开发。
我们正在建设什么
在接下来的几章中,我们将为我们的社交笔记应用程序 Notedly 构建一个移动客户端。目标是使用 JavaScript 和网络技术开发一个用户可以安装到其移动设备上的应用程序。我们将实施一部分功能,以避免在 web 应用程序章节中太多重复。具体来说,我们的应用程序将:
-
在 iOS 和 Android 操作系统上工作
-
从 从我们的 GraphQL API 加载笔记提要和用户个别笔记
-
使用 CSS 和样式组件进行样式设置
-
执行标准和动态路由
这些功能将为开发具有 React Native 的移动应用程序的核心概念提供一个坚实的概述。在我们开始之前,让我们仔细看看我们将使用的技术。
我们将如何构建这个
React Native 是我们将用来开发应用程序的核心技术。React Native 允许我们使用 React 在 JavaScript 中编写应用程序,并为原生移动平台渲染它们。这意味着对用户来说,React Native 应用程序与使用平台编程语言编写的应用程序没有明显的区别。这是 React Native 相对于传统上在应用程序外壳中包装 Web 视图的其他流行基于 Web 技术的移动框架的关键优势。Facebook、Instagram、Bloomberg、Tesla、Skype、Walmart、Pinterest 等公司已经使用 React Native 开发应用程序。
我们应用程序开发工作流程的第二个关键部分是 Expo,它是一组通过非常有用的功能简化 React Native 开发的工具和服务,如设备预览、应用程序构建以及扩展核心 React Native 库。在开始我们的开发之前,我建议您执行以下操作:
-
访问expo.io并创建 Expo 账户。
-
通过在终端应用程序中输入
npm install expo-cli--global来安装 Expo 命令行工具。 -
通过在终端应用程序中键入
expo login来本地登录到您的 Expo 账户。 -
为您的移动设备安装 Expo Client 应用程序。Expo Client iOS 和 Android 应用程序的链接可以在expo.io/tools找到。
-
在 Expo Client 应用程序中登录您的账户。
最后,我们将再次使用Apollo Client来与我们的 GraphQL API 的数据进行交互。Apollo Client 是一组用于处理 GraphQL 的开源工具。
入门
在我们开始开发之前,您需要将项目起始文件复制到您的计算机上。项目的source code包含我们开发应用所需的所有脚本和第三方库的引用。要将代码克隆到本地计算机,请打开终端,导航到您保存项目的目录,并git clone项目存储库。如果您已经完成了 API、Web 和/或桌面章节,可能已经创建了一个notedly目录来组织项目代码:
$ cd Projects
$ # type the `mkdir notedly` command if you don't yet have a notedly directory
$ cd notedly
$ git clone git@github.com:javascripteverywhere/mobile.git
$ cd mobile
$ npm install
安装第三方依赖
通过复制书籍起始代码并在目录中运行npm install,您可以避免为任何单独的第三方依赖再次运行npm install。
代码结构如下:
/src
这是您在跟着本书学习时应进行开发的目录。
/solutions
此目录包含每章的解决方案。如果遇到困难,您可以查阅这些内容。
/final
此目录包含最终的工作项目。
其余文件和项目设置与 expo-cli React Native 生成器的标准输出匹配,您可以在终端中键入 expo init 来运行它。
App.js?
由于 Expo 构建链的工作方式,项目目录根目录中的 App.js 文件通常是应用程序的入口点。为了使我们的移动项目与本书中的其余代码标准化,App.js 文件仅用作对 /src/Main.js 文件的引用。
现在我们在本地机器上有了代码并安装了依赖项,让我们运行应用程序。要启动应用程序,在您的终端应用程序中输入以下内容:
$ npm start
这将在我们的浏览器中打开 Expo 的“Metro Bundler” Web 应用程序的本地端口。从这里,您可以通过单击“Run on…”链接在本地设备模拟器上启动应用程序。您还可以通过扫描 QR 码在任何物理设备上使用 Expo 客户端启动应用程序(参见 图 21-1)。

图 21-1. 启动应用程序后的 Expo Metro Bundler
安装设备模拟器
要运行 iOS 设备模拟器,您需要下载并安装 Xcode(仅限 macOS)。对于 Android,请下载 Android Studio 并按照 Expo 的指南 设置设备模拟器(参见 图 21-2 进行比较)。不过,如果您刚开始进行移动应用程序开发,我建议从您自己的物理设备开始。

图 21-2. 我们的应用程序在 iOS 和 Android 设备模拟器上并行运行
如果您已在计算机的终端应用程序中以及在移动设备上的 Expo 客户端应用程序中登录 Expo,您只需打开 Expo 客户端应用程序并点击项目选项卡即可打开应用程序(参见 图 21-3)。

图 21-3. 使用 Expo 客户端,我们可以在物理设备上预览我们的应用程序。
当代码克隆到您的本地计算机并能够使用 Expo 客户端预览应用程序时,您已经具备了开发移动应用程序所需的一切条件。
结论
本章介绍了 React Native 和 Expo。我们克隆了示例项目代码,在本地运行并在物理设备或模拟器上预览了它。React Native 使 Web 和 JavaScript 开发人员能够使用他们熟悉的技能和工具构建功能齐全的原生移动应用程序。Expo 简化了工具链,并降低了原生移动开发的门槛。有了这两个工具,新手可以轻松开始移动开发,而擅长 Web 的团队可以快速引入移动应用开发技能集。在下一章中,我们将更详细地了解 React Native 的功能,并向我们的应用程序介绍路由和样式。
第二十二章:移动应用程序外壳
我的妻子是一名摄影师,这意味着她的生活大部分时间都是围绕在一个矩形框架内构图。在摄影中,有许多变量——主题、光线、角度——但图像的比例保持一致。在这种限制下,不可思议的事情发生了,塑造了我们看待和记住周围世界的方式。移动应用程序开发提供了类似的机会。在一个小矩形屏幕的限制内,我们可以构建具有沉浸式用户体验的极其强大的应用程序。
在这一章中,我们将开始为我们的应用程序建立一个外壳。为了做到这一点,我们首先将更详细地查看 React Native 组件的一些关键构建模块。从那里,我们将看看如何应用样式到我们的应用程序中,既可以使用 React Native 内置的样式支持,也可以选择我们的 CSS-in-JS 库 Styled Components。一旦我们讨论了如何应用样式,我们将看看如何将路由集成到我们的应用程序中。最后,我们将探讨如何通过图标轻松增强我们的应用界面。
React Native 构建模块
让我们从查看 React Native 应用程序的基本构建模块开始。您可能已经猜到,React Native 应用程序由 JSX 编写的 React 组件组成。但是在没有 HTML 页面的 DOM(文档对象模型)的情况下,究竟是什么放在这些组件内?我们可以从查看“Hello World”组件开始,它位于src/Main.js。暂时,我已经删除了样式:
import React from 'react';
import { Text, View } from 'react-native';
const Main = () => {
return (
<View>
<Text>Hello world!</Text>
</View>
);
};
export default Main;
在这个标记中,有两个显著的 JSX 标签:
正如您所想象的那样,我们也可以通过使用<Image> JSX 元素向我们的应用程序添加图像。让我们更新我们的src/Main.js文件以包含一个图像。为此,我们从 React Native 导入Image组件,并使用带有src属性的<Image>标签(参见图 22-1):
import React from 'react';
import { Text, View, Image } from 'react-native';
const Main = () => {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Hello world!</Text>
<Image source={require('../assets/images/hello-world.jpg')} />
</View>
);
};
export default Main;
上述代码在一个视图中呈现了一些文本和图像。您可能注意到我们的<View>和<Image> JSX 标签传递了一些属性,这些属性允许我们控制特定的行为(在本例中是视图的样式和图像的来源)。向元素传递属性允许我们使用各种附加功能扩展元素。React Native 的API 文档列出了每个元素可用的属性。

图 22-1. 使用 <Image> 标签,我们可以向我们的应用程序添加图像(照片由 Windell Oskay 拍摄)
我们的应用程序目前还没有太多功能,但在下一节中,我们将探讨如何利用 React Native 内置的样式支持和 Styled Components 来改善外观和用户体验。
样式和 Styled Components
作为应用程序开发者和设计师,我们希望能够为我们的应用程序设计出精确的外观、感觉和用户体验。有许多 UI 组件库,例如 NativeBase 或 React Native Elements,它们提供了广泛的预定义和通常可定制的组件。这些都值得一试,但为了我们的目的,让我们探讨一下如何组合我们自己的样式和应用程序布局。
正如我们已经看到的,React Native 提供了 style 属性,允许我们将自定义样式应用于应用程序中的任何 JSX 元素。样式名称和值与 CSS 中的相同,只是名称采用驼峰命名,如 lineHeight 和 backgroundColor。让我们更新我们的 /src/Main.js 文件,为 <Text> 元素添加一些样式(见 图 22-2):
const Main = () => {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ color: '#0077cc', fontSize: 48, fontWeight: 'bold' }}>
Hello world!
</Text>
<Image source={require('../assets/images/hello-world.jpg')} />
</View>
);
};

图 22-2. 使用样式我们可以调整 <Text> 元素的外观
你可能会想,正确地认为,在元素级别应用样式很快会变得难以维护。我们可以使用 React Native 的 StyleSheet 库来帮助组织和重用我们的样式。
首先,我们需要在导入列表中添加 StyleSheet(见 图 22-3):
import { Text, View, Image, StyleSheet } from 'react-native';
现在我们可以抽象化我们的样式:
const Main = () => {
return (
<View style={styles.container}>
<Text style={styles.h1}>Hello world!</Text>
<Text style={styles.paragraph}>This is my app</Text>
<Image source={require('../assets/images/hello-world.jpg')} />
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center'
},
h1: {
fontSize: 48,
fontWeight: 'bold'
},
paragraph: {
marginTop: 24,
marginBottom: 24,
fontSize: 18
}
});
Flexbox
React Native 使用 CSS flexbox 算法来定义布局样式。我们不会深入讲解 flexbox,但 React Native 提供了文档,清晰地解释了 flexbox 及其在屏幕上排列元素时的用途。

图 22-3. 通过使用样式表,我们可以扩展应用程序的样式
Styled Components
虽然 React Native 内置的 style 属性和 StyleSheet 可能已经提供了我们所需的一切,但它们远非我们在为应用程序添加样式时的唯一选择。我们还可以利用流行的 web CSS-in-JS 解决方案,例如 Styled Components 和 Emotion。我认为,这些解决方案具有更清晰的语法,更接近 CSS,并且减少了在 web 和移动应用程序代码库之间切换上下文所需的次数。使用这些支持 web 的 CSS-in-JS 库还可以创建在不同平台间共享样式或组件的机会。
就我们的目的而言,让我们看看如何将先前的示例适应使用 Styled Components 库。首先,在 src/Main.js 中,我们将导入该库的 native 版本:
import styled from 'styled-components/native'
从这里,我们可以将我们的样式迁移到 Styled Components 语法。如果您在 第十三章 中跟随了进度,这种语法应该看起来非常熟悉。我们的 src/Main.js 文件的最终代码如下:
import React from 'react';
import { Text, View, Image } from 'react-native';
import styled from 'styled-components/native';
const StyledView = styled.View`
flex: 1;
justify-content: center;
`;
const H1 = styled.Text`
font-size: 48px;
font-weight: bold;
`;
const P = styled.Text`
margin: 24px 0;
font-size: 18px;
`;
const Main = () => {
return (
<StyledView>
<H1>Hello world!</H1>
<P>This is my app.</P>
<Image source={require('../assets/images/hello-world.jpg')} />
</StyledView>
);
};
export default Main;
Styled Components 大小写
在 Styled Components 库中,元素名称必须始终大写。
现在我们能够为我们的应用程序应用自定义样式,可以选择使用 React Native 的内置样式系统或 Styled Components 库。
路由
在 Web 上,我们可以使用 HTML 锚链接来链接到任何其他 HTML 文档,包括我们自己站点上的文档。对于 JavaScript 驱动的应用程序,我们使用路由来链接 JavaScript 渲染的模板。那么对于原生移动应用程序呢?对于这些应用程序,我们将用户路由到不同的屏幕。在本节中,我们将探讨两种常见的路由类型:基于选项卡的导航和堆栈导航。
使用 React Navigation 进行选项卡式导航
为了执行我们的路由,我们将使用 React Navigation 库,这是 React Native 和 Expo 团队推荐的路由解决方案。最重要的是,它非常简单地实现了常见的路由模式,具有特定于平台的外观和感觉。
要开始,请首先在我们的 src 目录中创建一个名为 screens 的新目录。在 screens 目录中,让我们创建三个包含非常基本的 React 组件的新文件。
在 src/screens/favorites.js 中添加以下内容:
import React from 'react';
import { Text, View } from 'react-native';
const Favorites = () => {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Favorites</Text>
</View>
);
};
export default Favorites;
在 src/screens/feed.js 中添加这个:
import React from 'react';
import { Text, View } from 'react-native';
const Feed = () => {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Feed</Text>
</View>
);
};
export default Feed;
最后,在 src/screens/mynotes.js 中添加这个:
import React from 'react';
import { Text, View } from 'react-native';
const MyNotes = () => {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>My Notes</Text>
</View>
);
};
export default MyNotes;
然后,我们可以创建一个新文件 src/screens/index.js 用作我们应用程序路由的根。我们将从导入我们的初始 react 和 react-navigation 依赖项开始:
import React from 'react';
import { createAppContainer } from 'react-navigation';
import { createBottomTabNavigator } from 'react-navigation-tabs';
// import screen components
import Feed from './feed';
import Favorites from './favorites';
import MyNotes from './mynotes';
导入这些依赖项后,我们可以使用 React Navigation 的 createBottomTabNavigator 来在这三个屏幕之间创建一个选项卡导航器,以定义应该在我们的导航中显示哪些 React 组件屏幕:
const TabNavigator = createBottomTabNavigator({
FeedScreen: {
screen: Feed,
navigationOptions: {
tabBarLabel: 'Feed',
}
},
MyNoteScreen: {
screen: MyNotes,
navigationOptions: {
tabBarLabel: 'My Notes',
}
},
FavoriteScreen: {
screen: Favorites,
navigationOptions: {
tabBarLabel: 'Favorites',
}
}
});
// create the app container
export default createAppContainer(TabNavigator);
最后,让我们更新我们的 src/Main.js 文件,只需导入我们的路由器。现在,它应该简化为以下内容:
import React from 'react';
import Screens from './screens';
const Main = () => {
return <Screens />;
};
export default Main;
确保您的应用程序正在运行,输入 npm start 命令在您的终端中。现在,您应该在屏幕底部看到选项卡导航,点击选项卡将会将您路由到相应的屏幕(图 22-4)。

图 22-4. 现在我们可以通过选项卡导航在屏幕之间进行导航
堆栈导航
第二种路由类型是堆栈导航,其中屏幕在概念上被“堆叠”在一起,允许用户向堆栈中更深入和向后导航。考虑一个新闻应用程序,用户查看文章列表。用户可以点击新闻文章标题,深入堆栈到文章内容。然后他们可以点击返回按钮,导航回文章列表,或者可能是不同的文章标题,继续向堆栈深入。
在我们的应用程序中,我们希望用户能够从笔记的列表导航到笔记本身,然后再返回。让我们看看如何为每个屏幕实现堆栈导航。
首先,让我们创建一个新的NoteScreen组件,该组件将包含我们堆栈中的第二个屏幕。在src/screens/note.js路径下创建一个新的文件,包含一个最简单的 React Native 组件:
import React from 'react';
import { Text, View } from 'react-native';
const NoteScreen = () => {
return (
<View style={{ padding: 10 }}>
<Text>This is a note!</Text>
</View>
);
};
export default NoteScreen;
接下来,我们将对我们的路由进行更改,以使NoteScreen组件能够实现堆栈导航。为此,我们将从react-navigation-stack中导入createStackNavigator以及我们的新的note.js组件。在src/screens/index.js中更新导入如下:
import React from 'react';
import { Text, View, ScrollView, Button } from 'react-native';
import { createAppContainer } from 'react-navigation';
import { createBottomTabNavigator } from 'react-navigation-tabs';
// add import for createStackNavigator
import { createStackNavigator } from 'react-navigation-stack';
// import screen components, including note.js
import Feed from './feed';
import Favorites from './favorites';
import MyNotes from './mynotes';
import NoteScreen from './note';
有了我们导入的库和文件,我们可以实现堆栈导航的功能。在我们的路由文件中,我们必须告诉 React Navigation 哪些屏幕是“可堆叠的”。对于我们的每个选项卡路由,我们希望用户能够导航到一个Note屏幕。继续定义这些堆栈如下:
const FeedStack = createStackNavigator({
Feed: Feed,
Note: NoteScreen
});
const MyStack = createStackNavigator({
MyNotes: MyNotes,
Note: NoteScreen
});
const FavStack = createStackNavigator({
Favorites: Favorites,
Note: NoteScreen
});
现在,我们可以更新我们的TabNavigator以引用堆栈,而不是单个屏幕。为此,请在每个TabNavigator对象的screen属性中进行更新:
const TabNavigator = createBottomTabNavigator({
FeedScreen: {
screen: FeedStack,
navigationOptions: {
tabBarLabel: 'Feed'
}
},
MyNoteScreen: {
screen: MyStack,
navigationOptions: {
tabBarLabel: 'My Notes'
}
},
FavoriteScreen: {
screen: FavStack,
navigationOptions: {
tabBarLabel: 'Favorites'
}
}
});
总结一下,我们的src/screens/index.js文件应如下所示:
import React from 'react';
import { Text, View, ScrollView, Button } from 'react-native';
import { createAppContainer } from 'react-navigation';
import { createBottomTabNavigator } from 'react-navigation-tabs';
import { createStackNavigator } from 'react-navigation-stack';
// import screen components
import Feed from './feed';
import Favorites from './favorites';
import MyNotes from './mynotes';
import NoteScreen from './note';
// navigation stack
const FeedStack = createStackNavigator({
Feed: Feed,
Note: NoteScreen
});
const MyStack = createStackNavigator({
MyNotes: MyNotes,
Note: NoteScreen
});
const FavStack = createStackNavigator({
Favorites: Favorites,
Note: NoteScreen
});
// navigation tabs
const TabNavigator = createBottomTabNavigator({
FeedScreen: {
screen: FeedStack,
navigationOptions: {
tabBarLabel: 'Feed'
}
},
MyNoteScreen: {
screen: MyStack,
navigationOptions: {
tabBarLabel: 'My Notes'
}
},
FavoriteScreen: {
screen: FavStack,
navigationOptions: {
tabBarLabel: 'Favorites'
}
}
});
// create the app container
export default createAppContainer(TabNavigator);
如果我们在模拟器或设备上的 Expo 应用中打开我们的应用程序,我们应该看不到明显的区别。这是因为我们尚未添加到我们堆栈导航的链接。让我们更新我们的src/screens/feed.js组件以包含堆栈导航链接。
为此,首先从 React Native 中包含Button依赖项:
import { Text, View, Button } from 'react-native';
现在,我们可以包含一个按钮,当按下时,将导航到我们的note.js组件的内容。我们将传递组件的props,其中包含导航信息,并添加一个<Button>,包括title和onPress属性:
const Feed = props => {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Note Feed</Text>
<Button
title="Keep reading"
onPress={() => props.navigation.navigate('Note')}
/>
</View>
);
};
有了这个,我们应该能够在我们的屏幕之间导航。从 Feed 屏幕点击按钮导航到 Note 屏幕,并点击箭头返回(图 22-5)。

图 22-5。点击按钮链接将导航到新的屏幕,而点击箭头将返回用户到前一个屏幕
添加屏幕标题
添加堆栈导航器会自动在应用程序顶部添加一个标题栏。我们可以为该顶部栏设置样式,甚至删除它。现在让我们为堆栈顶部的每个屏幕添加一个标题。为此,我们将在组件本身之外设置组件 navigationOptions。在 src/screens/feed.js 中:
import React from 'react';
import { Text, View, Button } from 'react-native';
const Feed = props => {
// component code
};
Feed.navigationOptions = {
title: 'Feed'
};
export default Feed;
我们可以重复这个过程来处理我们的其他屏幕组件。
在 src/screens/favorites.js 中:
Favorites.navigationOptions = {
title: 'Favorites'
};
在 src/screens/mynotes.js 中:
MyNotes.navigationOptions = {
title: 'My Notes'
};
现在,我们的每个屏幕将在顶部导航栏中包含一个标题(图 22-6)。

图 22-6. 在 navigationOptions 中设置标题将其添加到顶部导航栏
图标
现在我们的导航在功能上已经完整,但缺少一个视觉组件,以便用户更容易使用。幸运的是,Expo 使我们能够非常轻松地在我们的应用程序中包含图标。我们可以通过访问 expo.github.io/vector-icons 来搜索 Expo 提供的所有图标。包括多个图标集,如 Ant Design、Ionicons、Font Awesome、Entypo、Foundation、Material Icons 和 Material Community Icons。这为我们提供了大量的多样性选择。
让我们在我们的选项卡导航中添加一些图标。首先,我们必须导入我们想要使用的图标集。在我们的情况下,我们将使用 Material Community Icons,通过在 src/screens/index.js 中添加以下内容:
import { MaterialCommunityIcons } from '@expo/vector-icons';
现在,无论我们在组件中的任何位置都想使用图标,我们都可以将其作为 JSX 包含,包括设置诸如 size 和 color 的属性:
<MaterialCommunityIcons name="star" size={24} color={'blue'} />
我们将把图标添加到我们的选项卡导航中。React Navigation 包含一个称为 tabBarIcon 属性,允许我们设置图标。我们可以将其作为一个函数传递,这使我们能够设置 tintColor,以便活动标签图标与非活动标签图标具有不同的颜色:
const TabNavigator = createBottomTabNavigator({
FeedScreen: {
screen: FeedStack,
navigationOptions: {
tabBarLabel: 'Feed',
tabBarIcon: ({ tintColor }) => (
<MaterialCommunityIcons name="home" size={24} color={tintColor} />
)
}
},
MyNoteScreen: {
screen: MyStack,
navigationOptions: {
tabBarLabel: 'My Notes',
tabBarIcon: ({ tintColor }) => (
<MaterialCommunityIcons name="notebook" size={24} color={tintColor} />
)
}
},
FavoriteScreen: {
screen: FavStack,
navigationOptions: {
tabBarLabel: 'Favorites',
tabBarIcon: ({ tintColor }) => (
<MaterialCommunityIcons name="star" size={24} color={tintColor} />
)
}
}
});
有了这个,我们的选项卡导航将显示图标(图 22-7)。

图 22-7. 我们应用程序的导航现在包含图标
结论
在本章中,我们介绍了如何构建 React Native 应用程序的基本组件。现在,您可以创建组件,为其添加样式,并在它们之间导航。希望通过这个基本设置,您能看到 React Native 的无限潜力。凭借最少的新技术,您已经能够构建一个令人印象深刻和专业的移动应用程序的雏形。在下一章中,我们将使用 GraphQL 在应用程序中包含来自 API 的数据。
第二十三章:GraphQL 和 React Native
在宾夕法尼亚州匹兹堡的安迪·沃霍尔博物馆,有一个永久的装置叫做“银云”。这个装置是一个稀疏的房间,里面有十几个矩形铝箔气球,每个气球里充满了氦气和普通空气的混合物。结果是,这些气球会比充满大气空气的气球悬浮时间更长,但不像氦气气球那样飘向天花板。博物馆的参观者穿过博物馆,轻轻地拍打气球以使它们漂浮。
目前,我们的应用程序就像“云朵”的房间一样。在应用程序外壳中轻松点击图标并导航是愉快的,但最终它是一个大部分是空的房间(不冒犯沃霍尔先生)。在本章中,我们将首先探讨如何使用 React Native 的列表视图显示内容来填充我们的应用程序。然后,我们将使用Apollo Client连接到我们的数据 API。一旦连接成功,我们将编写 GraphQL 查询,在应用程序屏幕上显示数据。
本地运行我们的 API
我们的移动应用程序开发将需要访问我们 API 的本地实例。如果您一直在跟着本书,您可能已经在您的机器上运行了 Notedly API 和其数据库。如果没有,我在本书的附录 A 中添加了如何启动 API 并获取一些示例数据的说明。如果您已经运行了 API,但希望获取更多的数据来使用,请从 API 项目目录的根目录运行npm run seed。
创建列表和可滚动内容视图
列表随处可见。在生活中,我们保持待办事项列表、购物清单和客人名单。在应用程序中,列表是最常见的 UI 模式之一:社交媒体帖子列表、文章列表、歌曲列表、电影列表等等。列表(看我干的什么?)继续无穷无尽。因此,React Native 使得创建可滚动内容列表变得非常简单。
React Native 上的两种列表类型是FlatList和SectionList。FlatList适用于单个可滚动列表中的大量项目。React Native 在幕后执行一些有用的操作,例如仅渲染最初可见的项目以提高性能。SectionList与FlatList非常相似,但它允许列表项目的组有一个标题。想象一下联系人列表中的联系人,通常按字母数字顺序分组在一个标题下。
为了我们的目的,我们将使用FlatList来显示一个笔记列表,用户可以滚动并点击预览以查看完整的笔记。为了实现这一点,让我们创建一个名为NoteFeed的新组件,我们可以用来显示笔记列表。现在我们将使用一些临时数据,但很快我们将把它连接到我们的 API。
首先,让我们在src/components/NoteFeed.js中创建一个新的组件。我们将首先导入我们的依赖项并添加一个临时数据数组。
import React from 'react';
import { FlatList, View, Text } from 'react-native';
import styled from 'styled-components/native';
// our dummy data
const notes = [
{ id: 0, content: 'Giant Steps' },
{ id: 1, content: 'Tomorrow Is The Question' },
{ id: 2, content: 'Tonight At Noon' },
{ id: 3, content: 'Out To Lunch' },
{ id: 4, content: 'Green Street' },
{ id: 5, content: 'In A Silent Way' },
{ id: 6, content: 'Lanquidity' },
{ id: 7, content: 'Nuff Said' },
{ id: 8, content: 'Nova' },
{ id: 9, content: 'The Awakening' }
];
const NoteFeed = () => {
// our component code will go here
};
export default NoteFeed;
现在我们可以编写我们的组件代码,其中将包含一个FlatList:
const NoteFeed = props => {
return (
<View>
<FlatList
data={notes}
keyExtractor={({ id }) => id.toString()}
renderItem={({ item }) => <Text>{item.content}</Text>}
/>
</View>
);
};
在前面的代码中,您可以看到FlatList接收了三个属性,简化了遍历数据的过程:
data
此属性指向列表将包含的数据数组。
keyExtractor
列表中的每个项必须具有唯一的key值。我们正在使用keyExtractor将唯一的id值作为key。
renderItem
此属性定义应在列表中呈现什么。目前,我们正在从我们的notes数组中传递一个单独的item并将其显示为Text。
通过更新我们的src/screens/feed.js组件来查看我们的列表,以显示该源:
import React from 'react';
// import NoteFeed
import NoteFeed from '../components/NoteFeed';
const Feed = props => {
return <NoteFeed />;
};
Feed.navigationOptions = {
title: 'Feed'
};
export default Feed;
让我们回到我们的src/components/NoteFeed.js文件,并更新renderItem以在样式化组件中添加列表项之间的间距:
// FeedView styled component definition
const FeedView = styled.View`
height: 100;
overflow: hidden;
margin-bottom: 10px;
`;
const NoteFeed = props => {
return (
<View>
<FlatList
data={notes}
keyExtractor={({ id }) => id.toString()}
renderItem={({ item }) => (
<FeedView>
<Text>{item.content}</Text>
</FeedView>
)}
/>
</View>
);
};
如果预览我们的应用程序,您将看到一个可滚动的数据列表。最后,我们可以在列表项之间添加分隔符。与其通过 CSS 添加底部边框,React Native 允许我们将ItemSeparatorComponent属性传递给我们的FlatList。这使我们能够精细控制在列表元素之间放置任何类型的组件作为分隔符。它还避免在不需要的位置(例如在列表中的最后一项之后)放置分隔符。出于我们的目的,我们将添加一个简单的边框,作为一个样式化的组件View:
// FeedView styled component definition
const FeedView = styled.View`
height: 100;
overflow: hidden;
margin-bottom: 10px;
`;
// add a Separator styled component
const Separator = styled.View`
height: 1;
width: 100%;
background-color: #ced0ce;
`;
const NoteFeed = props => {
return (
<View>
<FlatList
data={notes}
keyExtractor={({ id }) => id.toString()}
ItemSeparatorComponent={() => <Separator />}
renderItem={({ item }) => (
<FeedView>
<Text>{item.content}</Text>
</FeedView>
)}
/>
</View>
);
};
而不是直接在我们的FlatList中呈现和样式化笔记内容,让我们将其隔离在其自己的组件中。为此,我们将引入一种称为ScrollView的新视图类型。ScrollView的功能正是您所期望的:它不会适应屏幕的大小,而是会溢出内容,允许用户滚动。
在src/components/Note.js中创建一个新的组件:
import React from 'react';
import { Text, ScrollView } from 'react-native';
import styled from 'styled-components/native';
const NoteView = styled.ScrollView`
padding: 10px;
`;
const Note = props => {
return (
<NoteView>
<Text>{props.note.content}</Text>
</NoteView>
);
};
export default Note;
最后,我们将更新我们的src/components/NoteFeed.js组件,通过导入并在我们的FeedView中使用我们的新Note组件来使用它。最终组件代码如下(图 23-1):
import React from 'react';
import { FlatList, View, Text } from 'react-native';
import styled from 'styled-components/native';
import Note from './Note';
// our dummy data
const notes = [
{ id: 0, content: 'Giant Steps' },
{ id: 1, content: 'Tomorrow Is The Question' },
{ id: 2, content: 'Tonight At Noon' },
{ id: 3, content: 'Out To Lunch' },
{ id: 4, content: 'Green Street' },
{ id: 5, content: 'In A Silent Way' },
{ id: 6, content: 'Lanquidity' },
{ id: 7, content: 'Nuff Said' },
{ id: 8, content: 'Nova' },
{ id: 9, content: 'The Awakening' }
];
// FeedView styled-component definition
const FeedView = styled.View`
height: 100;
overflow: hidden;
margin-bottom: 10px;
`;
const Separator = styled.View`
height: 1;
width: 100%;
background-color: #ced0ce;
`;
const NoteFeed = props => {
return (
<View>
<FlatList
data={notes}
keyExtractor={({ id }) => id.toString()}
ItemSeparatorComponent={() => <Separator />}
renderItem={({ item }) => (
<FeedView>
<Note note={item} />
</FeedView>
)}
/>
</View>
);
};
export default NoteFeed;

图 23-1. 使用FlatList我们可以显示数据列表
通过这种方式,我们已经布置出一个简单的FlatList。现在让我们使得从列表项到单独路由的路由成为可能。
使列表可路由化
移动应用程序中非常常见的一种模式是点击列表中的项目以查看更多信息或扩展功能。如果您还记得前一章节,我们的 feed 屏幕位于导航堆栈中的 note 屏幕之上。在 React Native 中,我们可以使用TouchableOpacity作为使任何视图响应用户触摸的包装器。这意味着我们可以在FeedView中包装我们的内容,然后在用户按下时路由用户,就像我们以前用按钮做的那样。让我们更新我们的src/components/NoteFeed.js组件来做到这一点。
首先,我们必须更新我们的react-native导入,以在src/components/NoteFeed.js中包括TouchableOpacity:
import { FlatList, View, TouchableOpacity } from 'react-native';
接下来,我们更新我们的组件以使用TouchableOpacity:
const NoteFeed = props => {
return (
<View>
<FlatList
data={notes}
keyExtractor={({ id }) => id.toString()}
ItemSeparatorComponent={() => <Separator />}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() =>
props.navigation.navigate('Note', {
id: item.id
})
}
>
<FeedView>
<Note note={item} />
</FeedView>
</TouchableOpacity>
)}
/>
</View>
);
};
我们还需要更新我们的feed.js屏幕组件,以将导航属性传递给 feed。在src/screens/feed.js中:
const Feed = props => {
return <NoteFeed navigation={props.navigation} />;
};
带有这个功能,我们可以轻松地导航到我们的通用笔记屏幕。让我们定制该屏幕,以显示笔记的 ID。您可能已经注意到,在我们的NoteFeed组件导航中,我们正在传递一个id属性。在screens/note.js中,我们可以读取该属性的值:
import React from 'react';
import { Text, View } from 'react-native';
const NoteScreen = props => {
const id = props.navigation.getParam('id');
return (
<View style={{ padding: 10 }}>
<Text>This is note {id}</Text>
</View>
);
};
export default NoteScreen;
现在,我们可以从列表视图导航到详细页面。接下来,让我们看看如何将 API 中的数据集成到我们的应用程序中。
使用 Apollo Client 的 GraphQL
在这一点上,我们准备好在我们的应用程序中读取和显示数据。我们将访问在书的第一部分创建的 GraphQL API。方便地,我们将利用 Apollo Client,这是来自书的 Web 部分相同的 GraphQL 客户端库。Apollo Client 提供了许多有用的功能,以简化在 JavaScript UI 应用程序中使用 GraphQL 的工作。Apollo 的客户端功能包括从远程 API 获取数据,本地缓存,GraphQL 语法处理,本地状态管理等等。
要开始,我们首先需要设置我们的配置文件。我们将环境变量存储在一个名为config.js的文件中。在 React Native 中管理环境和配置变量有几种方法,但我发现这种样式的配置文件是最直接和有效的。为了开始,我已经包含了一个config-example.js文件,您可以复制并编辑与我们的应用程序值。在项目目录的根目录下,在您的终端应用程序中:
$ cp config.example.js config.js
从这里,我们可以更新任何dev或prod环境变量。在我们的情况下,这将只是一个生产API_URI值:
// set environment variables
const ENV = {
dev: {
API_URI: `http://${localhost}:4000/api`
},
prod: {
// update the API_URI value with your publicly deployed API address
API_URI: 'https://your-api-uri/api'
}
};
现在,我们可以使用getEnvVars函数基于 Expo 的环境访问这两个值。如果您有兴趣进一步探索此设置,配置文件的其余部分不会深入讨论,但有很好的注释。
从这里,我们可以将我们的客户端连接到我们的 API。在我们的src/Main.js文件中,我们将通过使用 Apollo Client 库设置 Apollo。如果您通过书的 Web 部分工作,这看起来会非常熟悉:
import React from 'react';
import Screens from './screens';
// import the Apollo libraries
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client';
// import environment configuration
import getEnvVars from '../config';
const { API_URI } = getEnvVars();
// configure our API URI & cache
const uri = API_URI;
const cache = new InMemoryCache();
// configure Apollo Client
const client = new ApolloClient({
uri,
cache
});
const Main = () => {
// wrap our app in the ApolloProvider higher-order component
return (
<ApolloProvider client={client}>
<Screens />
</ApolloProvider>
);
};
export default Main;
通过这样做,我们的应用程序不会有可见的变化,但我们现在已连接到我们的 API。接下来,让我们看看如何从 API 查询数据。
编写 GraphQL 查询
现在我们已连接到我们的 API,让我们查询一些数据。我们将首先查询数据库中的所有笔记,以在我们的NoteFeed列表中显示。然后,我们将查询单个笔记,以在我们的Note详细视图中显示。
笔记查询
为了简化和减少重复,我们将使用批量note API 查询而不是分页的noteFeed查询。
编写Query组件的工作方式与在 React Web 应用程序中完全相同。在src/screens/feed.js中,我们导入useQuery和 GraphQL 语言(gql)库如下:
// import our React Native and Apollo dependencies
import { Text } from 'react-native';
import { useQuery, gql } from '@apollo/client';
接下来,我们组合我们的查询:
const GET_NOTES = gql`
query notes {
notes {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
`;
最后,我们更新我们的组件来调用查询:
const Feed = props => {
const { loading, error, data } = useQuery(GET_NOTES);
// if the data is loading, our app will display a loading indicator
if (loading) return <Text>Loading</Text>;
// if there is an error fetching the data, display an error message
if (error) return <Text>Error loading notes</Text>;
// if the query is successful and there are notes, return the feed of notes
return <NoteFeed notes={data.notes} navigation={props.navigation} />;
};
综上所述,我们的src/screens/feed.js文件如下所示:
import React from 'react';
import { Text } from 'react-native';
// import our Apollo libraries
import { useQuery, gql } from '@apollo/client';
import NoteFeed from '../components/NoteFeed';
import Loading from '../components/Loading';
// compose our query
const GET_NOTES = gql`
query notes {
notes {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
`;
const Feed = props => {
const { loading, error, data } = useQuery(GET_NOTES);
// if the data is loading, our app will display a loading indicator
if (loading) return <Text>Loading</Text>;
// if there is an error fetching the data, display an error message
if (error) return <Text>Error loading notes</Text>;
// if the query is successful and there are notes, return the feed of notes
return <NoteFeed notes={data.notes} navigation={props.navigation} />;
};
Feed.navigationOptions = {
title: 'Feed'
};
export default Feed;
编写我们的查询后,我们可以更新src/components/NoteFeed.js组件以使用通过props传递给它的数据:
const NoteFeed = props => {
return (
<View>
<FlatList
data={props.notes}
keyExtractor={({ id }) => id.toString()}
ItemSeparatorComponent={() => <Separator />}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() =>
props.navigation.navigate('Note', {
id: item.id
})
}
>
<FeedView>
<Note note={item} />
</FeedView>
</TouchableOpacity>
)}
/>
</View>
);
};
这一改变后,使用 Expo 运行时,我们将在列表中看到来自本地 API 的数据,如图 23-2 所示。

图 23-2. 我们的 API 数据在我们的“Feed”视图中显示
目前,点击列表中的笔记预览仍将显示通用笔记页面。我们将通过在src/screens/note.js文件中进行note查询来解决这个问题:
import React from 'react';
import { Text } from 'react-native';
import { useQuery, gql } from '@apollo/client';
import Note from '../components/Note';
// our note query, which accepts an ID variable
const GET_NOTE = gql`
query note($id: ID!) {
note(id: $id) {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
`;
const NoteScreen = props => {
const id = props.navigation.getParam('id');
const { loading, error, data } = useQuery(GET_NOTE, { variables: { id } });
if (loading) return <Text>Loading</Text>;
// if there's an error, display this message to the user
if (error) return <Text>Error! Note not found</Text>;
// if successful, pass the data to the note component
return <Note note={data.note} />;
};
export default NoteScreen;
最后,让我们更新我们的src/components/Note组件文件,以显示笔记内容。我们将添加两个新依赖项,react-native-markdown-renderer 和 date-fns,以更用户友好的方式解析来自我们的 API 的 Markdown 和日期。
import React from 'react';
import { Text, ScrollView } from 'react-native';
import styled from 'styled-components/native';
import Markdown from 'react-native-markdown-renderer';
import { format } from 'date-fns';
const NoteView = styled.ScrollView`
padding: 10px;
`;
const Note = ({ note }) => {
return (
<NoteView>
<Text>
Note by {note.author.username} / Published{' '}
{format(new Date(note.createdAt), 'MMM do yyyy')}
</Text>
<Markdown>{note.content}</Markdown>
</NoteView>
);
};
export default Note;
通过这些更改,我们将在应用程序的“Feed”视图中看到笔记列表。点击笔记预览将带我们到笔记的完整、可滚动内容(参见图 23-3)。

图 23-3. 通过编写我们的 GraphQL 查询,我们可以在屏幕之间导航以查看笔记预览和完整笔记
添加加载指示器
当我们的应用程序加载数据时,当前显示“加载”文本。这可能有效地传达了消息,但也是一种相当刺眼的用户体验。React Native 为我们提供了一个内置的ActivityIndicator,它显示适合操作系统的加载旋转器。让我们编写一个简单的组件,我们可以在整个应用程序中使用作为加载指示器。
创建一个文件src/components/Loading.js,并编写一个简单的组件,在屏幕中心显示活动指示器:
import React from 'react';
import { View, ActivityIndicator } from 'react-native';
import styled from 'styled-components/native';
const LoadingWrap = styled.View`
flex: 1;
justify-content: center;
align-items: center;
`;
const Loading = () => {
return (
<LoadingWrap>
<ActivityIndicator size="large" />
</LoadingWrap>
);
};
export default Loading;
现在我们可以在我们的 GraphQL 查询组件中替换“加载”文本。首先在src/screens/feed.js和src/screens/note.js中导入Loading组件:
import Loading from '../components/Loading';
然后,在这两个文件中,更新 Apollo 加载状态如下:
if (loading) return <Loading />;
现在,我们的应用程序在加载 API 数据时将显示一个旋转的活动指示器(参见图 23-4)。

图 23-4. 使用 ActivityIndicator,我们可以添加一个与操作系统相匹配的加载旋转器
结论
在本章中,我们首先看了如何将列表视图集成到 React Native 应用程序中,利用常见的应用程序 UI 模式。然后我们配置了 Apollo Client 并将来自 API 的数据集成到应用程序中。有了这些,我们已经具备了构建许多常见类型应用程序所需的一切,比如新闻应用或从网站集成博客信息。在下一章中,我们将向我们的应用程序添加认证功能,并显示用户特定的查询。
第二十四章:手机应用程序认证
如果你曾与亲戚住过,或在租赁物业度假,或租了一间带家具的公寓,你就知道被周围不属于自己的东西包围是什么感觉。在这些环境中很难感到舒适,不想让东西摆放不整齐或弄脏。当我处于这种情况时,无论主人多么友好或周到,这种缺乏归属感总让我感到紧张。我能说什么呢?除非我能放下杯子而不用垫子,否则我就不会感到舒适。
如果我们的应用程序无法自定义或读取用户特定数据,用户可能会感到不适。他们的笔记只是与其他人混在一起,使得应用程序无法真正成为他们自己的。在本章中,我们将为我们的应用程序添加认证功能。为了实现这一点,我们将引入认证路由流程,使用 Expo 的SecureStore存储令牌数据,在 React Native 中创建文本表单,并执行认证 GraphQL 变更。
认证路由流程
让我们开始创建我们的认证流程。当用户首次访问我们的应用程序时,我们将向他们呈现一个登录屏幕。用户登录后,我们将在设备上存储一个令牌,允许他们在将来的应用程序使用中绕过登录屏幕。我们还将添加一个设置屏幕,用户可以点击按钮退出应用程序并从他们的设备中删除令牌。
为了实现这一点,我们将添加几个新的屏幕:
authloading.js
这将是一个插页屏幕,用户不会与之交互。当应用程序打开时,我们将使用这个屏幕来检查是否存在令牌,并将用户导航到登录屏幕或应用程序内容。
signin.js
这是用户可以登录其帐户的屏幕。登录尝试成功后,我们将在设备上存储一个令牌。
settings.js
在设置屏幕中,用户将能够点击按钮退出应用程序。一旦他们退出登录,他们将被路由回登录屏幕。
使用现有帐户
我们将在本章后面添加通过应用程序创建帐户的能力。如果你还没有这样做,通过你的 API 实例的 GraphQL Playground 或 Web 应用程序界面创建一个帐户会很有用。
为了存储和处理令牌,我们将使用 Expo 的SecureStore 库。我发现 SecureStore 是一种简单的方式,在设备上本地加密和存储数据。对于 iOS 设备,SecureStore 利用内置的keychain 服务,而在 Android 上则使用操作系统的 Shared Preferences,并使用Keystore加密数据。所有这些都是在幕后进行的,使我们能够简单地存储和检索数据。
首先,我们将创建我们的登录屏幕。目前,我们的登录屏幕将包含一个Button组件,按下该按钮将存储一个令牌。让我们在src/screens/signin.js创建一个新的屏幕组件,并导入我们的依赖项:
import React from 'react';
import { View, Button, Text } from 'react-native';
import * as SecureStore from 'expo-secure-store';
const SignIn = props => {
return (
<View>
<Button title="Sign in!" />
</View>
);
}
SignIn.navigationOptions = {
title: 'Sign In'
};
export default SignIn;
接下来,让我们在src/screens/authloading.js创建我们的认证加载组件,目前它将简单地显示一个加载指示器:
import React, { useEffect } from 'react';
import * as SecureStore from 'expo-secure-store';
import Loading from '../components/Loading';
const AuthLoading = props => {
return <Loading />;
};
export default AuthLoading;
最后,我们可以在src/screens/settings.js创建我们的设置屏幕:
import React from 'react';
import { View, Button } from 'react-native';
import * as SecureStore from 'expo-secure-store';
const Settings = props => {
return (
<View>
<Button title="Sign Out" />
</View>
);
};
Settings.navigationOptions = {
title: 'Settings'
};
export default Settings;
写完这些组件后,我们将更新路由以处理已认证和未认证的状态。在src/screens/index.js中,按以下方式将新屏幕添加到我们的导入语句列表中:
import AuthLoading from './authloading';
import SignIn from './signin';
import Settings from './settings';
我们还需要更新我们的react-navigation依赖项,包括createSwitchNavigator,它允许我们一次显示一个屏幕并在它们之间切换。SwitchNavigator在用户导航时重置路由到默认状态,并且不提供后退导航选项。
import { createAppContainer, createSwitchNavigator } from 'react-navigation';
我们可以为我们的认证和设置屏幕创建一个新的StackNavigator。这将允许我们在将来需要时添加子导航屏幕:
const AuthStack = createStackNavigator({
SignIn: SignIn
});
const SettingsStack = createStackNavigator({
Settings: Settings
});
然后,我们将我们的设置屏幕添加到底部的TabNavigator。其余的选项卡导航设置将保持不变:
const TabNavigator = createBottomTabNavigator({
FeedScreen: {
// ...
},
MyNoteScreen: {
// ...
},
FavoriteScreen: {
// ...
},
Settings: {
screen: Settings,
navigationOptions: {
tabBarLabel: 'Settings',
tabBarIcon: ({ tintColor }) => (
<MaterialCommunityIcons name="settings" size={24} color={tintColor} />
)
}
}
});
现在,我们可以通过定义要在其间切换的屏幕并设置默认屏幕AuthLoading来创建我们的SwitchNavigator。然后,我们将用一个导出SwitchNavigator的新export语句替换我们现有的语句:
const SwitchNavigator = createSwitchNavigator(
{
AuthLoading: AuthLoading,
Auth: AuthStack,
App: TabNavigator
},
{
initialRouteName: 'AuthLoading'
}
);
export default createAppContainer(SwitchNavigator);
所有这些组件一起,我们的src/screens/index.js文件将如下所示:
import React from 'react';
import { Text, View, ScrollView, Button } from 'react-native';
import { createAppContainer, createSwitchNavigator } from 'react-navigation';
import { createBottomTabNavigator } from 'react-navigation-tabs';
import { createStackNavigator } from 'react-navigation-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import Feed from './feed';
import Favorites from './favorites';
import MyNotes from './mynotes';
import Note from './note';
import SignIn from './signin';
import AuthLoading from './authloading';
import Settings from './settings';
const AuthStack = createStackNavigator({
SignIn: SignIn,
});
const FeedStack = createStackNavigator({
Feed: Feed,
Note: Note
});
const MyStack = createStackNavigator({
MyNotes: MyNotes,
Note: Note
});
const FavStack = createStackNavigator({
Favorites: Favorites,
Note: Note
});
const SettingsStack = createStackNavigator({
Settings: Settings
});
const TabNavigator = createBottomTabNavigator({
FeedScreen: {
screen: FeedStack,
navigationOptions: {
tabBarLabel: 'Feed',
tabBarIcon: ({ tintColor }) => (
<MaterialCommunityIcons name="home" size={24} color={tintColor} />
)
}
},
MyNoteScreen: {
screen: MyStack,
navigationOptions: {
tabBarLabel: 'My Notes',
tabBarIcon: ({ tintColor }) => (
<MaterialCommunityIcons name="notebook" size={24} color={tintColor} />
)
}
},
FavoriteScreen: {
screen: FavStack,
navigationOptions: {
tabBarLabel: 'Favorites',
tabBarIcon: ({ tintColor }) => (
<MaterialCommunityIcons name="star" size={24} color={tintColor} />
)
}
},
Settings: {
screen: SettingsStack,
navigationOptions: {
tabBarLabel: 'Settings',
tabBarIcon: ({ tintColor }) => (
<MaterialCommunityIcons name="settings" size={24} color={tintColor} />
)
}
}
});
const SwitchNavigator = createSwitchNavigator(
{
AuthLoading: AuthLoading,
Auth: AuthStack,
App: TabNavigator
},
{
initialRouteName: 'AuthLoading'
}
);
export default createAppContainer(SwitchNavigator);
现在,当我们预览我们的应用程序时,我们只会看到加载旋转器,因为AuthLoading路由是初始屏幕。让我们更新这个,以便加载屏幕检查应用程序的SecureStore中是否存在token值。如果令牌存在,我们将用户导航到主应用程序屏幕。但是,如果没有令牌存在,用户应该被路由到登录屏幕。让我们更新src/screens/**authloading.js来执行这个检查:
import React, { useEffect } from 'react';
import * as SecureStore from 'expo-secure-store';
import Loading from '../components/Loading';
const AuthLoadingScreen = props => {
const checkLoginState = async () => {
// retrieve the value of the token
const userToken = await SecureStore.getItemAsync('token');
// navigate to the app screen if a token is present
// else navigate to the auth screen
props.navigation.navigate(userToken ? 'App' : 'Auth');
};
// call checkLoginState as soon as the component mounts
useEffect(() => {
checkLoginState();
});
return <Loading />;
};
export default AuthLoadingScreen;
通过这个变化,当我们加载应用程序时,现在我们应该被路由到登录屏幕,因为没有令牌存在。现在,让我们更新我们的登录屏幕,以便在用户按下按钮时存储一个通用令牌,并导航到应用程序(图 24-1)。
import React from 'react';
import { View, Button, Text } from 'react-native';
import * as SecureStore from 'expo-secure-store';
const SignIn = props => {
// store the token with a key value of `token`
// after the token is stored navigate to the app's main screen
const storeToken = () => {
SecureStore.setItemAsync('token', 'abc').then(
props.navigation.navigate('App')
);
};
return (
<View>
<Button title="Sign in!" onPress={storeToken} />
</View>
);
};
SignIn.navigationOptions = {
title: 'Sign In'
};
export default SignIn;

图 24-1。单击按钮将存储一个令牌并将用户路由到应用程序
现在,当用户按下按钮时,将通过SecureStore存储一个令牌。通过设置登录功能,让我们添加用户退出应用程序的功能。为此,在我们的设置屏幕上添加一个按钮,按下该按钮将从SecureStore中删除token(图 24-2)。在src/screens/settings.js中:
import React from 'react';
import { View, Button } from 'react-native';
import * as SecureStore from 'expo-secure-store';
const Settings = props => {
// delete the token then navigate to the auth screen
const signOut = () => {
SecureStore.deleteItemAsync('token').then(
props.navigation.navigate('Auth')
);
};
return (
<View>
<Button title="Sign Out" onPress={signOut} />
</View>
);
};
Settings.navigationOptions = {
title: 'Settings'
};
export default Settings;

Figure 24-2. 点击按钮将从设备中删除令牌,并将用户返回到登录屏幕
有了这些组件,我们拥有了创建应用程序认证流所需的一切。
记得登出
如果你还没有这样做,请点击本地应用实例中的登出按钮。我们将在接下来的章节中添加适当的登录功能。
创建一个登录表单
虽然我们现在可以点击按钮并在用户设备上存储令牌,但我们还没有允许用户通过输入自己的信息来登录帐户。让我们通过创建一个表单来解决这个问题,用户可以在其中输入他们的电子邮件地址和密码。为此,我们将在 src/components/UserForm.js 中创建一个新的组件,使用 React Native 的 TextInput 组件编写表单:
import React, { useState } from 'react';
import { View, Text, TextInput, Button, TouchableOpacity } from 'react-native';
import styled from 'styled-components/native';
const UserForm = props => {
return (
<View>
<Text>Email</Text>
<TextInput />
<Text>Password</Text>
<TextInput />
<Button title="Log In" />
</View>
);
}
export default UserForm;
现在我们可以在我们的认证屏幕上显示这个表单了。为此,更新 src/screens/signin.js 以导入并使用该组件,如下所示:
import React from 'react';
import { View, Button, Text } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import UserForm from '../components/UserForm';
const SignIn = props => {
const storeToken = () => {
SecureStore.setItemAsync('token', 'abc').then(
props.navigation.navigate('App')
);
};
return (
<View>
<UserForm />
</View>
);
}
export default SignIn;
通过这一步骤,我们将在认证屏幕上看到一个基本的表单显示,但缺乏任何样式或功能。我们可以继续在我们的 src/components/UserForm.js 文件中实现表单。我们将使用 React 的 useState 钩子来读取和设置我们表单元素的值:
const UserForm = props => {
// form element state
const [email, setEmail] = useState();
const [password, setPassword] = useState();
return (
<View>
<Text>Email</Text>
<TextInput onChangeText={text => setEmail(text)} value={email} />
<Text>Password</Text>
<TextInput onChangeText={text => setPassword(text)} value={password} />
<Button title="Log In" />
</View>
);
}
现在我们可以为我们的表单元素添加一些额外的属性,以便在处理电子邮件地址或密码时为用户提供预期的功能。完整的 TextInput API 文档可以在 React Native docs 中找到。当按钮被按下时,我们还将调用一个函数,尽管功能会有所限制。
const UserForm = props => {
// form element state
const [email, setEmail] = useState();
const [password, setPassword] = useState();
const handleSubmit = () => {
// this function is called when the user presses the form button
};
return (
<View>
<Text>Email</Text>
<TextInput
onChangeText={text => setEmail(text)}
value={email}
textContentType="emailAddress"
autoCompleteType="email"
autoFocus={true}
autoCapitalize="none"
/>
<Text>Password</Text>
<TextInput
onChangeText={text => setPassword(text)}
value={password}
textContentType="password"
secureTextEntry={true}
/>
<Button title="Log In" onPress={handleSubmit} />
</View>
);
}
我们的表单具备所有必要的组件,但样式还有很大的提升空间。让我们使用 Styled Components 库,为表单赋予更合适的外观:
import React, { useState } from 'react';
import { View, Text, TextInput, Button, TouchableOpacity } from 'react-native';
import styled from 'styled-components/native';
const FormView = styled.View`
padding: 10px;
`;
const StyledInput = styled.TextInput`
border: 1px solid gray;
font-size: 18px;
padding: 8px;
margin-bottom: 24px;
`;
const FormLabel = styled.Text`
font-size: 18px;
font-weight: bold;
`;
const UserForm = props => {
const [email, setEmail] = useState();
const [password, setPassword] = useState();
const handleSubmit = () => {
// this function is called when the user presses the form button
};
return (
<FormView>
<FormLabel>Email</FormLabel>
<StyledInput
onChangeText={text => setEmail(text)}
value={email}
textContentType="emailAddress"
autoCompleteType="email"
autoFocus={true}
autoCapitalize="none"
/>
<FormLabel>Password</FormLabel>
<StyledInput
onChangeText={text => setPassword(text)}
value={password}
textContentType="password"
secureTextEntry={true}
/>
<Button title="Log In" onPress={handleSubmit} />
</FormView>
);
};
export default UserForm;
最后,我们的 Button 组件仅限于默认样式选项,除了接受一个 color 属性值。要创建一个自定义样式的按钮组件,我们可以使用 React Native 的包装器 TouchableOpacity(见 Figure 24-3):
const FormButton = styled.TouchableOpacity`
background: #0077cc;
width: 100%;
padding: 8px;
`;
const ButtonText = styled.Text`
text-align: center;
color: #fff;
font-weight: bold;
font-size: 18px;
`;
const UserForm = props => {
const [email, setEmail] = useState();
const [password, setPassword] = useState();
const handleSubmit = () => {
// this function is called when the user presses the form button
};
return (
<FormView>
<FormLabel>Email</FormLabel>
<StyledInput
onChangeText={text => setEmail(text)}
value={email}
textContentType="emailAddress"
autoCompleteType="email"
autoFocus={true}
autoCapitalize="none"
/>
<FormLabel>Password</FormLabel>
<StyledInput
onChangeText={text => setPassword(text)}
value={password}
textContentType="password"
secureTextEntry={true}
/>
<FormButton onPress={handleSubmit}>
<ButtonText>Submit</ButtonText>
</FormButton>
</FormView>
);
};
通过这个步骤,我们实现了一个登录表单并应用了自定义样式。现在让我们实现表单的功能。

Figure 24-3. 我们的带有自定义样式的登录表单
使用 GraphQL Mutations 进行认证
你可能还记得我们从 API 和 Web 应用程序章节中开发的认证流程,但在继续之前让我们做一个快速复习。我们将向我们的 API 发送一个包含用户电子邮件地址和密码的 GraphQL mutation。如果电子邮件地址存在于我们的数据库中并且密码正确,我们的 API 将回复一个 JWT。我们可以像之前一样将令牌存储在用户的设备上,并在每个 GraphQL 请求中发送它。这将允许我们在每个 API 请求中识别用户,而无需他们不断重新输入密码。
有了我们的表单,我们可以在src/screens/signin.js中编写我们的 GraphQL mutation。首先,我们将添加 Apollo 库以及我们的Loading组件到我们的导入列表中:
import React from 'react';
import { View, Button, Text } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import { useMutation, gql } from '@apollo/client';
import UserForm from '../components/UserForm';
import Loading from '../components/Loading';
接下来,我们可以添加我们的 GraphQL 查询:
const SIGNIN_USER = gql`
mutation signIn($email: String, $password: String!) {
signIn(email: $email, password: $password)
}
`;
并更新我们的storeToken函数以存储作为参数传递的令牌字符串:
const storeToken = token => {
SecureStore.setItemAsync('token', token).then(
props.navigation.navigate('App')
);
};
最后,我们将组件更新为 GraphQL mutation。我们还将几个属性值传递给UserForm组件,允许我们共享 mutation 数据,标识我们调用的表单类型,并利用路由器的导航。
const SignIn = props => {
const storeToken = token => {
SecureStore.setItemAsync('token', token).then(
props.navigation.navigate('App')
);
};
const [signIn, { loading, error }] = useMutation(SIGNIN_USER, {
onCompleted: data => {
storeToken(data.signIn)
}
});
// if loading, return a loading indicator
if (loading) return <Loading />;
return (
<React.Fragment>
{error && <Text>Error signing in!</Text>}
<UserForm
action={signIn}
formType="signIn"
navigation={props.navigation}
/>
</React.Fragment>
);
};
现在我们可以在src/components/UserForm.js组件中做一个简单的更改,使其能够将用户输入的数据传递给 mutation。在组件内部,我们将更新我们的handleSubmit函数,以将表单值传递给我们的 mutation:
const handleSubmit = () => {
props.action({
variables: {
email: email,
password: password
}
});
};
当我们编写完毕并完成表单时,用户现在可以登录应用程序,该应用程序将存储返回的 JSON Web Token 以备将来使用。
鉴权 GraphQL 查询
现在我们的用户可以登录他们的帐户,我们需要使用存储的令牌来认证每个请求。这将允许我们请求用户特定的数据,例如当前用户的笔记列表或用户标记为“收藏夹”的笔记列表。为此,我们将更新 Apollo 配置以检查令牌的存在,并在存在令牌时发送该令牌的值与每个 API 调用。
在src/Main.js中,首先将SecureStore添加到导入列表中,并更新 Apollo Client 的依赖项以包括createHttpLink和setContext:
// import the Apollo libraries
import {
ApolloClient,
ApolloProvider,
createHttpLink,
InMemoryCache
} from '@apollo/client';
import { setContext } from 'apollo-link-context';
// import SecureStore for retrieving the token value
import * as SecureStore from 'expo-secure-store';
然后,我们可以更新我们的 Apollo Client 配置以发送每个请求的令牌值:
// configure our API URI & cache
const uri = API_URI;
const cache = new InMemoryCache();
const httpLink = createHttpLink({ uri });
// return the headers to the context
const authLink = setContext(async (_, { headers }) => {
return {
headers: {
...headers,
authorization: (await SecureStore.getItemAsync('token')) || ''
}
};
});
// configure Apollo Client
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache
});
使用每个请求头中的令牌,我们现在可以更新mynotes和favorites屏幕,请求用户特定的数据。如果您通过网络章节跟随,这些查询应该非常熟悉。
在src/screens/mynotes.js中:
import React from 'react';
import { Text, View } from 'react-native';
import { useQuery, gql } from '@apollo/client';
import NoteFeed from '../components/NoteFeed';
import Loading from '../components/Loading';
// our GraphQL query
const GET_MY_NOTES = gql`
query me {
me {
id
username
notes {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
}
`;
const MyNotes = props => {
const { loading, error, data } = useQuery(GET_MY_NOTES);
// if the data is loading, our app will display a loading message
if (loading) return <Loading />;
// if there is an error fetching the data, display an error message
if (error) return <Text>Error loading notes</Text>;
// if the query is successful and there are notes, return the feed of notes
// else if the query is successful and there aren't notes, display a message
if (data.me.notes.length !== 0) {
return <NoteFeed notes={data.me.notes} navigation={props.navigation} />;
} else {
return <Text>No notes yet</Text>;
}
};
MyNotes.navigationOptions = {
title: 'My Notes'
};
export default MyNotes;
在src/screens/favorites.js中:
import React from 'react';
import { Text, View } from 'react-native';
import { useQuery, gql } from '@apollo/client';
import NoteFeed from '../components/NoteFeed';
import Loading from '../components/Loading';
// our GraphQL query
const GET_MY_FAVORITES = gql`
query me {
me {
id
username
favorites {
id
createdAt
content
favoriteCount
author {
username
id
avatar
}
}
}
}
`;
const Favorites = props => {
const { loading, error, data } = useQuery(GET_MY_FAVORITES);
// if the data is loading, our app will display a loading message
if (loading) return <Loading />;
// if there is an error fetching the data, display an error message
if (error) return <Text>Error loading notes</Text>;
// if the query is successful and there are notes, return the feed of notes
// else if the query is successful and there aren't notes, display a message
if (data.me.favorites.length !== 0) {
return <NoteFeed notes={data.me.favorites} navigation={props.navigation} />;
} else {
return <Text>No notes yet</Text>;
}
};
Favorites.navigationOptions = {
title: 'Favorites'
};
export default Favorites;

图 24-4. 在每个请求的头部传递令牌允许我们在应用程序中进行用户特定的查询。
现在我们基于存储在用户设备上的令牌值检索用户特定的数据(图 24-4)。
添加注册表单
现在用户可以登录到现有帐户,但如果不存在帐户,他们无法创建帐户。一个常见的 UI 模式是在登录链接下(或反之)添加一个链接到注册表单的链接。让我们添加一个注册屏幕,以允许用户从应用程序内创建新帐户。
首先,让我们在src/screens/signup.js创建一个新的屏幕组件。这个组件将几乎与我们的登录屏幕相同,但我们将调用我们的signUp GraphQL mutation,并将一个formType="signUp"属性传递给我们的UserForm组件:
import React from 'react';
import { Text } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import { useMutation, gql } from '@apollo/client';
import UserForm from '../components/UserForm';
import Loading from '../components/Loading';
// signUp GraphQL mutation
const SIGNUP_USER = gql`
mutation signUp($email: String!, $username: String!, $password: String!) {
signUp(email: $email, username: $username, password: $password)
}
`;
const SignUp = props => {
// store the token with a key value of `token`
// after the token is stored navigate to the app's main screen
const storeToken = token => {
SecureStore.setItemAsync('token', token).then(
props.navigation.navigate('App')
);
};
// the signUp mutation hook
const [signUp, { loading, error }] = useMutation(SIGNUP_USER, {
onCompleted: data => {
storeToken(data.signUp);
}
});
// if loading, return a loading indicator
if (loading) return <Loading />;
return (
<React.Fragment>
{error && <Text>Error signing in!</Text>}
<UserForm
action={signUp}
formType="signUp"
navigation={props.navigation}
/>
</React.Fragment>
);
};
SignUp.navigationOptions = {
title: 'Register'
};
export default SignUp;
创建了屏幕后,我们可以将其添加到我们的路由器中。在src/screens/index.js文件中,首先将新组件添加到我们的文件导入列表中:
import SignUp from './signup';
接下来,我们将更新我们的AuthStack以包括注册屏幕:
const AuthStack = createStackNavigator({
SignIn: SignIn,
SignUp: SignUp
});
通过这样,我们的组件已经创建并且可路由;然而,我们的UserForm组件并不包含所有必要的字段。与其创建一个注册表单组件,我们可以利用传递给UserForm的formType属性来根据类型自定义表单。
在我们的src/components/UserForm.js文件中,首先在formType等于signUp时更新表单以包含用户名字段:
const UserForm = props => {
const [email, setEmail] = useState();
const [password, setPassword] = useState();
const [username, setUsername] = useState();
const handleSubmit = () => {
props.action({
variables: {
email: email,
password: password,
username: username
}
});
};
return (
<FormView>
<FormLabel>Email</FormLabel>
<StyledInput
onChangeText={text => setEmail(text)}
value={email}
textContentType="emailAddress"
autoCompleteType="email"
autoFocus={true}
autoCapitalize="none"
/>
{props.formType === 'signUp' && (
<View>
<FormLabel>Username</FormLabel>
<StyledInput
onChangeText={text => setUsername(text)}
value={username}
textContentType="username"
autoCapitalize="none"
/>
</View>
)}
<FormLabel>Password</FormLabel>
<StyledInput
onChangeText={text => setPassword(text)}
value={password}
textContentType="password"
secureTextEntry={true}
/>
<FormButton onPress={handleSubmit}>
<ButtonText>Submit</ButtonText>
</FormButton>
</FormView>
);
};
接下来,在登录表单底部添加一个链接,当按下时允许用户路由到注册表单:
return (
<FormView>
{/* existing form component code is here */}
{props.formType !== 'signUp' && (
<TouchableOpacity onPress={() => props.navigation.navigate('SignUp')}>
<Text>Sign up</Text>
</TouchableOpacity>
)}
</FormView>
)
然后,我们可以使用 styled components 来更新链接的外观:
const SignUp = styled.TouchableOpacity`
margin-top: 20px;
`;
const Link = styled.Text`
color: #0077cc;
font-weight: bold;
`;
并且在组件的 JSX 中:
{props.formType !== 'signUp' && (
<SignUp onPress={() => props.navigation.navigate('SignUp')}>
<Text>
Need an account? <Link>Sign up.</Link>
</Text>
</SignUp>
)}
总之,我们的src/components/UserForm.js文件现在如下所示:
import React, { useState } from 'react';
import { View, Text, TextInput, Button, TouchableOpacity } from 'react-native';
import styled from 'styled-components/native';
const FormView = styled.View`
padding: 10px;
`;
const StyledInput = styled.TextInput`
border: 1px solid gray;
font-size: 18px;
padding: 8px;
margin-bottom: 24px;
`;
const FormLabel = styled.Text`
font-size: 18px;
font-weight: bold;
`;
const FormButton = styled.TouchableOpacity`
background: #0077cc;
width: 100%;
padding: 8px;
`;
const ButtonText = styled.Text`
text-align: center;
color: #fff;
font-weight: bold;
font-size: 18px;
`;
const SignUp = styled.TouchableOpacity`
margin-top: 20px;
`;
const Link = styled.Text`
color: #0077cc;
font-weight: bold;
`;
const UserForm = props => {
const [email, setEmail] = useState();
const [password, setPassword] = useState();
const [username, setUsername] = useState();
const handleSubmit = () => {
props.action({
variables: {
email: email,
password: password,
username: username
}
});
};
return (
<FormView>
<FormLabel>Email</FormLabel>
<StyledInput
onChangeText={text => setEmail(text)}
value={email}
textContentType="emailAddress"
autoCompleteType="email"
autoFocus={true}
autoCapitalize="none"
/>
{props.formType === 'signUp' && (
<View>
<FormLabel>Username</FormLabel>
<StyledInput
onChangeText={text => setUsername(text)}
value={username}
textContentType="username"
autoCapitalize="none"
/>
</View>
)}
<FormLabel>Password</FormLabel>
<StyledInput
onChangeText={text => setPassword(text)}
value={password}
textContentType="password"
secureTextEntry={true}
/>
<FormButton onPress={handleSubmit}>
<ButtonText>Submit</ButtonText>
</FormButton>
{props.formType !== 'signUp' && (
<SignUp onPress={() => props.navigation.navigate('SignUp')}>
<Text>
Need an account? <Link>Sign up.</Link>
</Text>
</SignUp>
)}
</FormView>
);
};
export default UserForm;
通过这些更改,用户可以在我们的应用程序中既登录又注册帐户(图 24-5)。

图 24-5. 用户现在可以注册一个帐户并在认证屏幕之间导航
结论
在本章中,我们看了如何为应用程序引入身份验证。通过 React Native 的文本表单元素、React Navigation 的路由能力、Expo 的 SecureStore 库和 GraphQL 的 mutations 的组合,我们可以创建用户友好的身份验证流程。对这种类型的身份验证有扎实的理解还使我们能够探索额外的 React Native 身份验证方法,例如 Expo 的AppAuth或GoogleSignIn。在下一章中,我们将看看如何发布和分发 React Native 应用程序。
第二十五章:移动应用分发
在上世纪 90 年代中期的高中时代,流行下载TI-81 图形计算器上的游戏。有人会弄到游戏副本,然后它像野火般传播,我们每个人轮流用线缆连接计算器来加载游戏。在课堂后面或自习时间里玩游戏是填充时间的一种方式,同时又保持做学校作业的外表。然而,这种分发方法很慢,需要两名学生保持连接数分钟,而其他人则等待。如今,我们的数字口袋计算机能做的事情远远超过我那款朴素的图形计算器,部分原因是我们可以轻松地通过可安装的第三方应用扩展其功能。
初步应用程序开发完成后,我们现在可以分发应用程序,使他人可以访问。在本章中,我们将学习如何配置我们的app.json文件以进行分发。然后,我们将在 Expo 中公开发布我们的应用程序。最后,我们将生成可提交到 Apple 或 Google Play 商店的应用程序包。
app.json 配置
Expo 应用程序包含一个app.json文件,用于配置应用程序特定设置。
当我们生成一个新的 Expo 应用程序时,app.json文件会自动为我们创建。让我们来看看为我们的应用程序生成的文件:
{
"expo": {
"name": "Notedly",
"slug": "notedly-mobile",
"description": "An example React Native app",
"privacy": "public",
"sdkVersion": "33.0.0",
"platforms": ["ios", "android"],
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"updates": {
"fallbackToCacheTimeout": 1500
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true
},
"android": {}
}
}
大多数可能都很容易理解,但让我们来看看每个的目的:
名称
我们应用程序的名称。
slug
发布 Expo 应用程序的 URL 名称为expo.io/project-owner/slug。
描述
我们项目的描述,将在使用 Expo 发布应用程序时使用。
隐私
Expo 项目的公开可见性。可以设置为public或unlisted。
sdkVersion
Expo SDK 版本号。
platforms
我们的目标平台,可以包括ios、android和web。
版本
我们应用程序的版本号,应遵循语义化版本控制标准。
方向
我们应用程序的默认方向,可以锁定为portrait或landscape,或者与用户设备旋转匹配使用default。
icon
应用程序图标的路径,将用于 iOS 和 Android。
splash
应用加载屏幕的图像位置和设置。
更新
应用程序加载时应如何检查 OTA 更新的配置。fallbackToCacheTimeout 参数允许我们指定以毫秒为单位的时间长度。
assetBundlePatterns
允许我们指定应用程序应捆绑的资产位置。
ios 和 android
启用特定平台的设置。
这个默认配置为我们的应用程序提供了一个坚实的基础。还有许多其他设置,可以在Expo 文档中找到。
图标和应用加载屏幕
在我们的设备上发现的小方形图标已经成为现代社会中最具辨识度的设计之一。闭上眼睛,我相信你可以想象出数十个这样的图标,甚至包括一个标志或特定的背景颜色。此外,当用户点击一个图标时,会显示一个初始的静态“启动屏幕”,用于显示应用程序加载时的界面。到目前为止,我们一直使用默认的空 Expo 图标和启动屏幕。我们可以在我们的应用程序中用自定义设计替换它们。
我在assets/custom文件夹中包含了一个 Notedly 图标和启动屏幕。我们可以通过将这些图像替换为assets目录中的图像或更新我们的app.json配置,指向custom子目录中的文件来使用它们。
应用程序图标
icon.png文件是一个 1024×1024 像素的正方形 PNG 文件。如果我们在app.json文件的icon属性中指定这个文件,Expo 将会为各个平台和设备生成适当的图标大小。图像应该是完全正方形,没有任何透明像素。这是包含应用程序图标的最简单直接的方法:
"icon": "./assets/icon.png",
除了单一的跨平台图标外,我们还可以选择包含特定于平台的图标。采用这种方式的主要优势在于可以为 Android 和 iOS 分别设置不同的图标样式,特别是如果想要使用 Android 的自适应图标。
对于 iOS,我们将继续使用一个 1024×1024 像素的单一 PNG 文件。在app.json文件中:
"ios": {
"icon": IMAGE_PATH
}
要使用 Android 的自适应图标,我们需要指定foregroundImage、backgroundColor(或backgroundImage)以及一个静态的后备icon:
"android": {
"adaptiveIcon": {
"foregroundImage": IMAGE_PATH,
"backgroundColor": HEX_CODE,
"icon": IMAGE_PATH
}
}
对于我们的用例,我们可以继续使用单一静态图标。
启动屏幕
启动屏幕是在设备上启动我们的应用程序时,会短暂显示的全屏图像。我们可以将默认的 Expo 图像替换为在assets/custom目录中找到的一个图像。尽管设备尺寸在各平台和设备之间有所不同,但我选择使用 1242×2436 的尺寸,这是Expo 文档推荐的尺寸。Expo 会自动调整图像大小,以适应不同的设备屏幕和宽高比。
我们可以在app.json文件中像这样配置我们的启动屏幕:
"splash": {
"image": "./assets/splash.png",
"backgroundColor": "#ffffff",
"resizeMode": "contain"
},
默认情况下,我们设置了白色背景色,这可能在图像加载时可见,或者根据我们选择的resizeMode而在启动屏幕图像周围显示为边框。我们可以更新它以匹配屏幕的颜色:
"backgroundColor": "#4A90E2",
resizeMode决定了如何调整图像以适应各种屏幕尺寸。通过将其设置为contain,我们保留了原始图像的宽高比。当您使用contain时,某些屏幕尺寸或分辨率将看到backgroundColor作为启动画面图像周围的边框。或者,我们可以将resizeMode设置为cover,这将扩展图像以填充整个屏幕。由于我们的应用程序有一个微妙的渐变,让我们将我们的resizeMode设置为cover:
"resizeMode": "cover"

图 25-1. 我们的应用程序启动画面
有了这个设置,我们的图标和启动画面图像已经配置完成(见图 25-1)。现在我们可以看看如何分发我们的应用程序,以便让其他人访问。
Expo 发布
在开发过程中,我们的应用程序可以在物理设备上的 Expo 客户端应用程序上通过本地区域网络访问。这意味着只要我们的开发机和手机在同一网络上,我们就可以访问该应用程序。Expo 使我们能够发布我们的项目,将应用程序上传到 Expo CDN 并提供一个公共可访问的 URL。通过这样,任何人都可以通过 Expo 客户端应用程序运行我们的应用程序。这对于测试或快速分发应用程序非常有用。
要发布我们的项目,可以在浏览器的 Expo 开发工具中点击“发布或重新发布项目”链接(见图 25-2),或在我们的终端中键入expo publish。

图 25-2. 我们可以直接从 Expo 开发工具发布我们的应用程序
一旦打包完成,任何人都可以访问https://exp.host/@
创建本地构建
虽然直接通过 Expo 进行分发是测试或快速使用的好选择,但我们很可能希望通过 Apple App Store 或 Google Play Store 发布我们的应用程序。为此,我们将构建可上传到各自商店的文件。
Windows 用户
根据 Expo 文档,Windows 用户需要启用 Windows 子系统用于 Linux(WSL)。要完成此操作,请按照 Microsoft 提供的Windows 10 安装指南进行操作。
iOS
生成 iOS 构建需要加入Apple Developer Program的会员资格,费用为每年 99 美元。有了账户,我们可以在我们的app.json文件中为 iOS 添加一个bundleIdentifier。此标识符应遵循反向 DNS 表示法:
"expo": {
"ios": {
"bundleIdentifier": "com.yourdomain.notedly"
}
}
更新了我们的app.json文件后,我们可以生成构建。在您的终端应用程序中,从项目目录的根目录输入:
$ expo build:ios
构建完成后,您将被提示使用您的 Apple ID 进行登录。登录后,您将被询问关于如何处理凭据的几个问题。Expo 能够为我们管理所有凭据和证书,您可以选择在以下提示中每次选择第一个选项以允许此操作:
? How would you like to upload your credentials? (Use arrow keys)
❯ Expo handles all credentials, you can still provide overrides
I will provide all the credentials and files needed, Expo does limited validat
ion
? Will you provide your own Apple Distribution Certificate? (Use arrow keys)
❯ Let Expo handle the process
I want to upload my own file
? Will you provide your own Apple Push Notifications service key? (Use arrow keys)
❯ Let Expo handle the process
I want to upload my own file
如果您有活跃的 Apple 开发者计划账户,Expo 将生成文件,您可以将其提交到 Apple App Store。
Android
对于 Android,我们可以生成 Android Package File(APK)或 Android App Bundle(AAB)文件。Android App Bundle 是更现代的格式,所以让我们选择这条路线。如果您感兴趣,Android 开发者文档提供了关于应用程序包优势的详细描述。
在生成应用程序包之前,让我们更新我们的 app.json 文件,以包含一个 Android package 标识符。与 iOS 类似,这应该采用反向 DNS 表示法:
"android": {
"package": "com.yourdomain.notedly"
}
通过这个,我们可以从终端应用程序生成应用程序包。确保cd到项目根目录并运行以下命令:
$ build:android -t app-bundle
应用程序包需要签名。虽然我们可以自行生成签名,但 Expo 可以为我们管理密钥库。运行命令生成应用程序包后,您将看到以下提示:
? Would you like to upload a keystore or have us generate one for you?
If you don't know what this means, let us handle it! :)
1) Let Expo handle the process!
2) I want to upload my own keystore!
如果您选择 1,Expo 将为您生成应用程序包。在此过程结束时,您可以下载该文件,然后将其上传到 Google Play 商店。
发布到应用商店
由于审核指南和相关成本的变化,我不会详细介绍如何将我们的应用程序提交到 Apple App Store 或 Google Play Store。Expo 文档收集了资源和指南,并且是关于如何导航应用商店分发流程的有用和最新指南。
结论
在本章中,我们看过了如何发布和分发 React Native 应用程序。Expo 的工具使我们能够快速发布用于测试的应用程序,并生成可上传到应用商店的生产版本。Expo 还为我们提供了管理证书和依赖关系控制级别的选项。
通过这个,我们已成功编写和发布了后端数据 API、Web 应用程序、桌面应用程序以及跨平台移动应用程序!
第二十六章:后记
在美国,送给新高中毕业生一本苏斯博士的《噢,你会去的地方!》作为毕业礼物是很常见的。
祝贺!今天是你的日子。你将去往伟大的地方!你将踏上旅程!
如果你已经读到了这本书的结尾,庆祝一下是再合适不过了。我们涵盖了很多内容,从使用 Node 构建 GraphQL API,到多种类型的 UI 客户端,但我们只是触及了表面。每个主题都可以填满书籍和无数在线教程。我希望你不会感到不知所措,而是现在能够更深入地探索你感兴趣的主题,并创造出了不起的东西。
JavaScript 可以说是一个小小的编程语言,但它做到了。曾经是一个不起眼的“玩具语言”,现在却是世界上最流行的编程语言。结果是,掌握如何编写 JavaScript 是一种超能力,使我们能够为任何平台构建几乎任何类型的应用程序。考虑到这是一种超能力,我想留给你 最后一个陈词滥调:
…拥有强大的力量必然伴随着 —— 伟大的责任!
技术本应该是一种力量,而且应该是积极的力量。我希望你能够将这本书中学到的东西应用到使世界变得更美好的事业中。这可能包括接受一份新工作或者兼职项目,以使家庭生活更美好,教授他人新技能,或者开发旨在带来幸福或改善他人生活的产品。无论是什么,当你为善行使你的力量时,我们都会受益匪浅。
请不要成为陌生人。我很想看到并听到你创造的任何事物。随时给我发电子邮件至 adam@jseverywhere.io,或加入 Spectrum 社区。感谢你的阅读。
— 亚当
附录 A. 在本地运行 API
如果您选择参与书中的 UI 部分,但不参与 API 开发章节,仍然需要在本地运行 API 的副本。
第一步是确保您已经在系统上安装并运行了 MongoDB,如第一章所述。数据库运行起来后,您可以克隆 API 的副本并复制最终的代码。要将代码克隆到本地机器上,请打开终端,导航到您保存项目的目录,并使用git clone克隆项目仓库。如果尚未这样做,创建一个notedly目录以便更好地组织项目代码也可能很有帮助:
$ cd Projects
# only run the following mkdir command if you do not yet have a notedly directory
$ mkdir notedly
$ cd notedly
$ git clone git@github.com:javascripteverywhere/api.git
$ cd api
最后,您需要通过复制.sample.env文件并填写新创建的.env文件中的信息来更新您的环境变量。
在您的终端中运行:
$ cp .env.example .env
现在,在您的文本编辑器中,更新.env文件的值:
## Database
DB_HOST=mongodb://localhost:27017/notedly
TEST_DB=mongodb://localhost:27017/notedly-test
## Authentication
JWT_SECRET=YOUR_PASSWORD
最后,您可以启动 API。在您的终端中运行:
$ npm start
完成这些指令后,您应该在系统上有一个本地运行的 Notedly API 的副本。
附录 B. 在本地运行 Web 应用
如果你选择跟随本书的 Electron 部分,但不涉及 web 开发章节,你仍然需要在本地运行一个 web 应用的副本。
第一步是确保你在本地运行了 API 的副本。如果你还没有,请参考附录 Appendix A 了解如何在本地运行 API。
当你的 API 运行起来后,你可以克隆 web 应用的一个副本。为了将代码克隆到你的本地机器上,打开终端,导航到你项目存放的目录,并 git clone 项目仓库:
$ cd Projects
# if keeping your projects in a notedly folder, cd into the notedly directory
$ cd notedly
$ git clone git@github.com:javascripteverywhere/web.git
$ cd web
接下来,你需要通过复制 .sample.env 文件并填写新创建的 .env 文件来更新你的环境变量信息。
在你的终端中运行:
$ cp .env.example .env
现在,在你的文本编辑器中,更新 .env 文件的数值,确保它与你本地运行的 API 的 URL 匹配。如果所有数值都保持默认,你不需要做任何更改。
API_URI=http://localhost:4000/api
最后,你可以运行最终的 web 代码示例。在你的终端应用中运行:
$ npm run final
按照这些指示操作后,你应该在你的系统上本地运行一个 Notedly web 应用的副本。


浙公网安备 33010602011771号