MEAN-入门指南-全-
MEAN 入门指南(全)
原文:Getting MEAN with Mongo, Express, Angular, and Node
译者:飞龙
第一部分. 建立基础
当你做得正确时,全栈开发是非常有回报的。一个应用程序有很多组成部分,而你的任务是让它们协同工作。你可以采取的最佳第一步是理解你将要工作的构建块,并查看你可以如何将它们组合起来以实现不同的结果。
这些步骤就是第一部分的全部内容。在第一章中,你们将详细探讨学习全栈开发的益处,并探索 MEAN 栈的组成部分。第二章将在此基础上,讨论你们如何将这些组件组合起来构建东西。
到第一部分结束时,你们将很好地理解 MEAN 栈应用程序可能的软件和硬件架构,以及你们在整个书中将要构建的应用程序的规划。
第一章. 全栈开发简介
本章涵盖
-
评估全栈开发
-
了解 MEAN 栈组件
-
探讨使 MEAN 栈如此吸引人的因素
-
预览本书中将要构建的应用程序
如果你们和我们一样,可能都迫不及待地想要开始编写代码,着手构建一些东西。但让我们先花一点时间来明确一下我们所说的全栈开发是什么意思,并查看栈的各个组成部分,以确保你们理解每一个部分。
当我们谈论全栈开发时,我们实际上是在谈论开发网站或应用程序的所有部分。全栈从后端的数据库和网络服务器开始,包含中间的应用逻辑和控制,一直延伸到前端的用户界面。
MEAN 栈是一个纯 JavaScript 栈,由四种主要技术组成,辅以一系列支持技术:
-
MongoDB——数据库
-
Express——网络框架
-
Angular——前端框架
-
Node.js——网络服务器
MongoDB 自 2007 年以来一直存在,并由 MongoDB, Inc.(之前称为 10gen)积极维护。
Express 首次由 T. J. Holowaychuk 于 2009 年发布,并已成为 Node.js 最受欢迎的框架。它是开源的,拥有超过 100 位贡献者,并且正在积极开发和维护。
Angular 是开源的,由 Google 支持。Angular 的第一个版本,被称为 AngularJS 或 Angular 1,自 2010 年以来一直存在。Angular 2,现在简单地称为 Angular,于 2016 年正式发布,并且正在持续开发和扩展。当前版本是 Angular 7.1;Angular 2+与 AngularJS 不向后兼容。有关版本和发布周期的更多信息,请参阅侧边栏“Angular 版本和发布周期”。
Angular 版本和发布周期
从 Angular 1.x 到 Angular 2 的变化在开发者社区中是一件大事。它来得晚,不同,而且不向后兼容。但现在 Angular 正在以每六个月一次的频率发布新版本。当前版本是 Angular 7.1,进一步的迭代已经正在被大量工作。
变化的频率无需担忧,尽管如此;这些变化远不如 1.x 到 2.0 之间的完全重写那么大。变化通常是小的、渐进式的。在 4 到 5,或 5 到 6 等版本之间可能会有一些破坏性变化,但这些变化通常是小的、具体的项,易于掌握——与从 Angular 1.x 到 2.0 的变化不同。
Node.js 于 2009 年创建,其开发和维护目前由 Node Foundation 负责,其中 Joyent(创建 Node 的组织)是主要成员。Node.js 的核心使用的是谷歌的开源 V8 JavaScript 引擎。
1.1. 为什么学习全栈开发?
事实上,为什么要学习全栈开发?这听起来像是一项非常繁重的工作!嗯,是的,这确实是一项相当繁重的工作,但同时也很有回报,因为你能够独自创建完全功能的数据驱动网站和应用。而且使用 MEAN 栈,工作并不会像你想象中那么困难。
1.1.1. 网络开发简史
在网络开发的早期,人们对网站并没有很高的期望。对展示的重视不多;建网站更多的是关于幕后发生的事情。通常,如果你知道像 Perl 这样的东西并且能够将一点 HTML 串联起来,你就是一名网络开发者。
随着互联网的普及,企业开始更加关注他们的在线形象。结合浏览器对层叠样式表(CSS)和 JavaScript 的支持增加,这种兴趣导致了更复杂的前端实现。不再是仅仅能够将 HTML 串联起来;你需要花时间在 CSS 和 JavaScript 上,确保它们看起来正确并且按预期工作。而且所有这些都需要在不同的浏览器上工作,而这些浏览器远不如今天那么兼容。
这就是前端开发者和后端开发者之间的区别所在。图 1.1 展示了这种随着时间的推移而出现的分离。
图 1.1. 前端和后端开发者随时间的变化

当后端开发者专注于幕后机制时,前端开发者专注于构建良好的用户体验。随着时间的推移,对这两方面的期望越来越高,促使这一趋势持续发展。开发者经常不得不选择一个专业领域并专注于它。
帮助开发者使用库和框架
在 2000 年代,库和框架开始在前端和后端最常见的语言中变得流行和普遍。想想前端 JavaScript 的 Dojo 和 jQuery;想想 PHP 和 Ruby on Rails 的 Symfony。这些框架被设计用来让开发者生活更轻松,降低入门门槛。一个好的库或框架可以抽象掉一些开发复杂性,让你更快地编码,并减少对深入专业知识的需求。这种简化趋势导致了全栈开发者的回归,他们既构建前端也构建其后的应用程序逻辑,如图 1.2 所示。
图 1.2. 框架对分离的 Web 开发派系的影响

图 1.2 展示的是一个趋势,而不是宣称一个“所有 Web 开发者都应该成为全栈开发者”的绝对法则。在整个 Web 历史上一直都有全栈开发者,向前看,很可能会有些开发者会选择专注于前端或后端开发。目的是通过使用框架和现代工具,你不再需要选择一个端或另一个端来成为一名优秀的 Web 开发者。
采用框架方法的一个巨大优势是你可以非常高效地工作,因为你将拥有对应用程序及其如何结合在一起的全局视角。
将应用程序代码向前移动到堆栈中
继续遵循框架趋势,过去几年中,人们越来越努力地将应用程序逻辑从服务器移到前端。把这看作是在前端编码后端。在这方面做得最流行的 JavaScript 框架有 Angular、React 和 Vue.js。
以这种方式将应用程序代码紧密耦合到前端,往往会模糊传统前端和后端开发者之间的界限。人们喜欢使用这种方法的一个原因是因为它减少了服务器的负载,从而降低了成本。实际上你所做的是通过将负载推入用户的浏览器,将应用程序所需的计算能力众包。
我们将在第 1.5 节讨论这种方法的优缺点,并解释何时(或可能不)适合使用这些技术之一。
1.1.2. 全栈开发的趋势
正如所讨论的,前端和后端开发者的道路正在融合;在两个学科中完全精通是完全可能的。如果你是一名自由职业者、顾问或小型团队的一员,多技能是非常有用的,可以增加你为客户提供的价值。能够开发整个网站或应用程序的范围,可以让你更好地控制整体,并有助于各部分无缝协作,因为它们不是由不同的团队独立构建的。
如果你作为大型团队的一部分工作,你很可能会需要专门化(或者至少专注于)一个领域。但通常建议你了解你的组件如何与其他组件配合,这将使你更加欣赏其他团队和整个项目的要求和目标。
最后,独立构建全栈是很有回报的。每个部分都伴随着自己的挑战和需要解决的问题,使事情保持有趣。今天可用的技术和工具增强了这种体验,并赋予你相对快速和容易地构建优秀网络应用程序的能力。
1.1.3. 全栈开发的益处
学习全栈开发有许多好处。首先,当然是有学习新事物和玩新技术的乐趣。然后,你会有掌握不同技能的满足感,以及能够独立构建和发布一个全数据库驱动的应用程序的激动。
在团队中工作的好处包括以下内容:
-
通过理解不同的领域以及它们如何配合,你更有可能对大局有更好的看法。
-
你将形成对团队其他部分正在做什么以及他们需要成功所需的了解。
-
就像其他团队成员一样,你可以更加自由地移动。
独自工作的额外好处包括
-
你可以独立构建端到端的应用程序,而不依赖于其他人。
-
你可以开发更多技能、服务和能力,以提供给客户。
总的来说,全栈开发有很多可说的。我们遇到的大部分成功开发者都是全栈开发者。他们对整体的理解和看到大局的能力是一个巨大的优势。
1.1.4. 为什么是 MEAN 栈?
MEAN 栈将一些“最佳”的现代网络技术汇集到一个强大、灵活的栈中。MEAN 栈的一个优点是它不仅在使用浏览器的 JavaScript 中,而且在整个应用中都用 JavaScript。使用 MEAN 栈,你可以用同一种语言编写前端和后端代码。尽管如此,构建栈的 Angular 部分通常使用 TypeScript。我们将在第八章中讨论这个理由。
图 1.3 展示了 MEAN 栈的主要技术,并显示了每个技术通常被用于何处。
图 1.3. MEAN 栈的主要技术

允许全栈 JavaScript 实现的主要技术是 Node.js,它将 JavaScript 引入了后端。
1.2. 介绍 Node.js:网络服务器/平台
Node.js 是MEAN中的N。排在最后并不意味着它是最不重要的:它是栈的基础!
简而言之,Node.js 是一个软件平台,它允许你创建自己的 Web 服务器并在其上构建 Web 应用程序。Node.js 本身不是一个 Web 服务器;它也不是一种语言。它包含一个内置的 HTTP 服务器库,这意味着你不需要运行像 NGINX、Apache 或 Internet Information Services (IIS) 这样的独立 Web 服务器程序。这让你能够更好地控制 Web 服务器的工作方式,但也增加了将其启动和运行,尤其是在实时环境中的复杂性。
例如,使用 PHP,你可以轻松找到一个运行 Apache 的共享服务器 Web 主机,并通过 FTP 发送一些文件,如果一切顺利,你的网站就可以运行了。这是因为 Web 主机已经为你和其他人配置了 Apache。但在 Node.js 中,情况并非如此,因为你在创建应用程序时配置 Node.js 服务器。许多传统的 Web 主机在 Node.js 支持方面落后于时代,但有几家新的平台即服务(PaaS)主机正在涌现以满足这一需求,包括 Heroku、Nodejitsu 和 DigitalOcean。在这些 PaaS 主机上部署实时网站的方法与旧的 FTP 模型不同,但一旦掌握了技巧,就会变得简单。当你阅读本书时,你将部署一个网站到 Heroku。
托管 Node.js 应用程序的另一种方法是自己在专用服务器或云服务提供商(如 AWS 或 Azure)的虚拟服务器上自行操作,你可以在上面安装你需要的任何东西。但生产服务器管理是另一本书的主题!尽管你可以独立用替代技术替换任何其他组件,但如果移除 Node.js,其上的一切都会改变。
1.2.1. JavaScript:通过堆栈的单一种语言
Node.js 获得广泛流行的主要原因之一是你可以用大多数 Web 开发者已经熟悉的语言来编写它:JavaScript。在 Node 发布之前,如果你想成为一名全栈开发者,你必须至少精通两种语言:前端使用 JavaScript,后端使用类似 PHP 或 Ruby 的语言。
微软对服务器端 JavaScript 的探索
在 1990 年代后期,微软发布了 Active Server Pages(现在称为 Classic ASP)。ASP 可以用 VBScript 或 JavaScript 编写,但 JavaScript 版本并没有流行起来,很大程度上是因为当时很多人熟悉 Visual Basic,而 VBScript 的外观与 Visual Basic 类似。许多书籍和在线资源都是针对 VBScript 的,因此它滚雪球般地成为了 Classic ASP 的标准语言。
随着 Node.js 的发布,你可以使用你已知的技能并将其用于服务器。学习这种新技术中最困难的部分之一是学习语言,但如果你已经了解一些 JavaScript,你就已经领先一步了!
当你开始学习 Node.js 时,即使你是一个经验丰富的前端 JavaScript 开发者,也会有一个学习曲线。服务器端编程的挑战和障碍与前端不同,但无论你使用什么技术,你都会面临这些挑战。在前端,你可能担心确保各种浏览器在不同设备上都能正常工作。在服务器端,你更可能会关注代码的流程,以确保没有任何东西被阻塞,并且你不会浪费系统资源。
1.2.2. 快速、高效和可扩展
Node.js 受欢迎的另一个原因是,当代码编写正确时,它非常快,并且能够高效地利用系统资源。这些特性使得 Node.js 应用程序能够在比其他主流服务器技术更少的资源上服务更多的用户。企业主也喜欢 Node.js 的想法,因为它可以降低他们的运营成本,即使在大型规模下也是如此。
Node.js 是如何做到这一点的?Node.js 资源消耗轻,因为它采用单线程模式,而传统的网络服务器是多线程的。在接下来的章节中,我们将探讨这些术语的含义,从传统的多线程方法开始。
传统的多线程网络服务器
目前的大多数主流网络服务器都是多线程的,包括 Apache 和 IIS。这意味着每个新的访客(或会话)都会被分配一个单独的线程和相应数量的 RAM,通常约为 8 MB。
考虑一个现实世界的类比,想象两个人进入银行想要做不同的事情。在多线程模型中,他们会各自去不同的柜员,柜员会处理他们的请求,如图 1.4 所示。
图 1.4. 多线程方法的示例:访客使用单独的资源。访客及其专用资源对其他访客及其资源没有意识或联系。

你可以从图 1.4 中看到,西蒙去柜员 1,萨莉去柜员 2。双方都没有意识到或受到对方的影响。柜员 1 整个交易过程中只处理西蒙的事务;柜员 2 和萨莉的情况也是如此。
只要你有足够的柜员来服务客户,这种方法就非常有效。当银行变得繁忙,客户数量超过柜员时,服务开始变慢,客户必须等待才能得到服务。尽管银行并不总是过分担心这种情况,看起来也乐于让你排队,但网站的情况并非如此。如果一个网站响应缓慢,用户可能会离开并且再也不回来。
这就是为什么即使 90%的时间不需要,网络服务器通常也会过载并且拥有大量的 RAM 的原因之一。硬件的设置方式是为了应对流量激增。这种设置就像银行在午餐时间繁忙时雇佣额外的 50 名全职出纳员并搬到一个更大的建筑一样。
当然,一定有更好的方法——一种稍微更可扩展的方法。这就是单线程方法发挥作用的地方。
单线程网络服务器
Node.js 服务器是单线程的,并且与多线程服务器的工作方式不同。服务器不是为每个访客提供一个唯一的线程和单独的资源隔离区,而是让每个访客加入同一个线程。访客和线程只在必要时交互——当访客请求某物或线程响应请求时。
回到银行出纳员的类比,只有一个出纳员处理所有客户。但出纳员不是从头到尾管理所有请求,而是将任何耗时任务委托给后台工作人员,并处理下一个请求。图 1.5 展示了这个流程可能如何工作,使用了多线程示例中的两个请求。
图 1.5。单线程方法示例:访客使用相同的中央资源。中央资源必须非常规范,以防止一个访客影响其他访客。

在图 1.5 中展示的单线程方法中,萨利和西蒙将他们的请求提交给同一个银行出纳员。但出纳员不是在处理完一个请求后再处理下一个,而是先处理第一个请求,将其转交给最适合处理的人,然后再处理下一个请求并做同样的事情。当出纳员被告知请求的任务已完成时,出纳员将结果转回提出请求的访客。
阻塞与非阻塞代码
在单线程模型中,重要的是要记住,所有用户都使用相同的中央进程。为了保持流程顺畅,你需要确保代码中没有任何东西会导致延迟,从而阻塞其他操作。一个例子是,如果银行出纳员必须去保险柜存钱给西蒙,那么萨利就必须等待她的请求。
同样,如果你的中央进程负责读取每个静态文件(如 CSS、JavaScript 或图像),它将无法处理任何其他请求,从而阻塞流程。另一个可能阻塞的常见任务是与数据库交互。如果你的进程每次被要求访问数据库时,无论是搜索数据还是保存数据,它将无法做其他任何事情。
为了使单线程方法有效,你必须确保你的代码是非阻塞的。实现这一目标的方法是将任何阻塞操作异步执行,防止它们阻塞主进程的流程。
尽管只有一个出纳员,但两位访客都没有意识到对方的存在,也没有受到对方请求的影响。这种方法意味着银行不需要总是有多个出纳员在岗。当然,这种模式并不是无限可扩展的,但效率更高。您可以用更少的资源做更多的事情。但这并不意味着您永远不会需要添加更多资源。
由于 JavaScript 的异步能力,这种特定方法在 Node.js 中是可行的,您将在本书的整个内容中看到其实际应用。但如果您对理论不太确定,请查看附录 D(可在网上或电子书中找到),特别是关于回调的部分。
1.2.3. 通过 npm 使用预构建的包
当您安装 Node.js 时,会安装包管理器 npm。npm 使您能够下载 Node.js 模块或 包 来扩展您应用程序的功能。目前,通过 npm 可用超过 350,000 个包,这表明您可以为应用程序带来多少知识和经验。这个数字比四年前 Getting MEAN 第一版编写时的 46,000 个有所增加!
npm 中的包在提供的内容上差异很大。您将在本书中使用一些 npm 包来引入具有模式支持的框架和数据库驱动程序。其他例子包括像 Underscore 这样的辅助库、像 Mocha 这样的测试框架以及像 Colors 这样的实用工具,它为 Node.js 控制台日志添加了颜色支持。您将在第三章开始构建应用程序时更详细地了解 npm 和它的工作方式。
正如您所看到的,Node.js 非常强大和灵活,但在您尝试创建网站或应用程序时,它并不提供太多帮助。Express 可以在这里为您提供帮助。您可以通过 npm 安装 Express。
1.3. 介绍 Express:框架
Express 是 MEAN 中的 E。因为 Node.js 是一个平台,它不规定应该如何设置或使用,这是它的一个巨大优势。但每次您创建网站和 Web 应用程序时,都需要完成许多常见任务。Express 是一个为 Node.js 设计的 Web 应用程序框架,旨在以经过测试、可重复的方式执行这些任务。
1.3.1. 简化您的服务器设置
如前所述,Node.js 是一个平台,而不是服务器,这允许您在服务器设置方面发挥创意,做一些其他 Web 服务器无法做到的事情。这也使得建立一个基本的网站变得更加困难。
Express 通过设置一个网络服务器来监听传入的请求并返回相关响应,从而抽象出这种困难。此外,它还定义了目录结构。一个文件夹被设置为以非阻塞方式提供静态文件;您最不希望的是当有人请求 CSS 文件时,您的应用程序需要等待!您可以直接在 Node.js 中配置此设置,但 Express 会为您完成。
1.3.2. 将 URL 路由到响应
Express 的一个伟大功能是它提供了一个简单的接口,可以将传入的 URL 指向特定的代码片段。无论这个接口是提供静态 HTML 页面、从数据库读取还是写入数据库,这都不重要。该接口简单且一致。
Express 抽象了在原生 Node.js 中创建 Web 服务器的某些复杂性,以使代码更快编写且更容易维护。
1.3.3. 视图:HTML 响应
很可能你将希望通过向浏览器发送一些 HTML 来响应你应用程序的许多请求。到目前为止,Express 使这项任务比在原生 Node.js 中更容易,这对你来说可能不会感到惊讶。
Express 提供了对许多模板引擎的支持,这使得使用可重用组件以及应用程序中的数据以智能方式构建 HTML 页面变得更加容易。Express 将这些组件编译在一起,并以 HTML 的形式提供给浏览器。
1.3.4. 使用会话支持记住访客
由于 Node.js 是单线程的,它不会记住一个请求到下一个请求的访问者。它没有为用户预留的 RAM 隔离区;它只看到一系列 HTTP 请求。HTTP 是一种无状态协议,因此没有存储会话状态的概念。目前,在 Node.js 中创建个性化的体验或拥有一个用户必须登录的安全区域都很难;如果网站在每一页都忘记你是谁,那就没什么用了。当然,你可以做到这一点,但你必须自己编写代码。
你永远猜不到:Express 对这个问题也有解决方案!Express 可以使用 会话 来识别通过多个请求和页面访问的个别访客。感谢 Express!
建立在 Node.js 之上,Express 为构建 Web 应用程序提供了极大的帮助和稳固的起点。它抽象了许多我们大多数人不需要或不想担心的复杂性和重复性任务。我们只想构建 Web 应用程序。
1.4. 介绍 MongoDB:数据库
存储和使用数据的能力对于大多数应用程序至关重要。在 MEAN 堆栈中,首选的数据库是 MongoDB,即 MEAN 中的 M。MongoDB 与堆栈结合得非常好。像 Node.js 一样,它以其快速和可扩展性而闻名。
1.4.1. 关系型数据库与文档存储
如果你之前使用过关系型数据库,或者甚至使用过电子表格,你将熟悉列和行的概念。通常,列定义了名称和数据类型,而每一行是不同的条目。参见 表 1.1 以获取示例。
表 1.1. 关系型数据库表中的行和列示例
| firstName | middleName | lastName | maidenName | nickname |
|---|---|---|---|---|
| Simon | David | Holmes | Si | |
| Sally | June | Panayiotou | ||
| Rebecca | Norman | Holmes | Bec |
MongoDB 并非如此!MongoDB 是一个文档存储。行的概念仍然存在,但列从图中移除。不是列定义了行中应该有什么,而是每一行都是一个文档,这个文档既定义又持有数据本身。表 1.2 展示了文档集合可能如何列出。(缩进布局是为了可读性,而不是列的可视化。)
表 1.2. 文档数据库中的每个文档都定义并持有数据,没有特定的顺序。
| 名字: "Simon" | 中间名: "David" | 姓氏: "Holmes" | 昵称: "Si" |
|---|---|---|---|
| 姓氏: "Panayiotou" | 中间名: "June" | 名字: "Sally" | |
| 姓氏: "Holmes" | 名字: "Rebecca" | 姓氏: "Norman" | 昵称: "Bec" |
这种不太结构化的方法意味着文档集合内部可能包含各种类型的数据。在下一节中,你将查看一个示例文档,以更好地了解我们所说的内容。
1.4.2. MongoDB 文档:JavaScript 数据存储
MongoDB 以 BSON 格式存储文档,即二进制 JSON(JavaScript 序列化对象表示法)。现在如果你对 JSON 不太熟悉,不用担心;请查看附录 D 中相关的部分。附录 D。简而言之,JSON 是一种 JavaScript 存储数据的方式,这也是为什么 MongoDB 与以 JavaScript 为中心的 MEAN 栈如此契合!
以下代码片段展示了 MongoDB 文档的一个简单示例:
{
"firstName" : "Simon",
"lastName" : "Holmes",
_id : ObjectId("52279effc62ca8b0c1000007")
}
即使你对 JSON 不太了解,你可能也能看出这个文档存储了西蒙·霍姆斯的姓名。与一个文档持有与一组列相对应的数据集不同,一个文档持有名称/值对,这使得文档本身非常有用,因为它既描述又定义了数据。
关于 id 的一句快速说明:你很可能注意到了前面示例 MongoDB 文档中与名称并列的 id 条目。id 实体是 MongoDB 在创建新文档时分配给任何新文档的唯一标识符。
当你开始在应用程序中添加数据时,你将在第五章中更详细地了解 MongoDB 文档。
1.4.3. 不仅仅是文档数据库
MongoDB 通过其对二级索引和丰富查询的支持,将自己与其他许多文档数据库区分开来。你可以在除了唯一标识符字段之外的地方创建索引,并且查询索引字段要快得多。你还可以对 MongoDB 数据库创建一些相当复杂的查询——虽然不是像到处都有连接的巨大 SQL 命令那样,但对于大多数用例来说已经足够强大。
在你阅读本书的过程中构建应用程序时,你将有机会与 MongoDB 一起享受乐趣,并开始真正欣赏它所能做到的事情。
1.4.4. MongoDB 不擅长什么?
截至 4.0 版本,传统的 RDBMS 几乎无法做到 MongoDB 能做到的事情,除了我们已经讨论过的明显差异之外。MongoDB 早期版本中最大的问题之一是缺乏事务支持。本书使用的 MongoDB 4 版本具有执行具有 ACID(原子性、一致性、隔离性、持久性)保证的多文档事务的能力。
1.4.5. Mongoose 用于数据建模及其他
MongoDB 在文档中存储数据的灵活性对数据库来说是一件好事。但大多数应用程序需要其数据有一定的结构。请注意,应用程序 需要结构,而不是数据库。那么在哪里定义应用程序数据结构最有意义呢?在应用程序本身中!
因此,MongoDB 背后的公司创建了 Mongoose。用公司的话说,Mongoose 提供“优雅的 MongoDB 对象建模,适用于 Node.js”(mongoosejs.com)。
什么是数据建模?
在 Mongoose 和 MongoDB 的背景下,数据建模定义了文档中可以存储的数据以及必须存储的数据。当存储用户信息时,你可能希望能够保存姓氏、名字、电子邮件地址和电话号码。但你只需要姓氏和电子邮件地址,并且电子邮件地址必须是唯一的。这些信息在 模式 中定义,该模式用作数据模型的基础。
Mongoose 还提供了什么?
除了建模数据之外,Mongoose 在 MongoDB 上添加了一层完整的特性,这些特性对于构建 Web 应用程序非常有用。Mongoose 使得管理对 MongoDB 数据库的连接以及保存和读取数据变得更加容易。你将在后面的内容中使用所有这些特性。本书后面还将讨论 Mongoose 如何使你能够在模式级别添加数据验证,确保只允许有效数据被保存到数据库中。
MongoDB 是大多数 Web 应用程序的理想数据库选择,因为它在纯文档数据库的速度和关系数据库的强大功能之间提供了平衡。数据以 JSON 格式有效存储,这使得它成为 MEAN 栈的完美数据存储。
图 1.6 展示了 Mongoose 的亮点以及它是如何位于数据库和应用之间的。
图 1.6. Mongoose 位于数据库和应用之间,提供了一个易于使用的接口(对象模型)以及访问其他功能,如验证。

1.5. 介绍 Angular:前端框架
Angular 是 MEAN 中的 A。简单来说,Angular 是一个用于创建网站或应用程序界面的 JavaScript 框架。在本书中,你将使用 Angular 7,这是最近可用的版本。所有之前的版本都已弃用,在线文档不再适用。
你可以使用 Node.js、Express 和 MongoDB 来构建一个完全功能的数据驱动型网络应用程序,你将在本书中这样做。但你可以通过添加 Angular 到这个堆栈来锦上添花。
传统的方法是将所有数据处理和应用程序逻辑放在服务器上,然后服务器将 HTML 传递给浏览器。Angular 允许你将一些或所有这些处理和逻辑移动到浏览器,通常让服务器从数据库传递数据。我们将在讨论数据绑定时讨论这个过程,但首先,我们需要解决 Angular 是否像 jQuery(领先的客户端 JavaScript 库)这样的问题。
1.5.1. jQuery 与 Angular 的比较
如果你熟悉 jQuery,可能会想知道 Angular 是否以相同的方式工作。简短的答案是:不,实际上不是。jQuery 通常是在 HTML 发送到浏览器并且文档对象模型(DOM)完全加载后添加到页面上的,以提供交互性。Angular 在这一步之前介入,根据提供的数据从模板构建 HTML。
此外,jQuery 是一个库,因此它有一系列你可以按需使用的功能。Angular 被称为一个有偏见的框架,这意味着它强迫你按照它认为的方式使用。它还抽象了一些底层复杂性,简化了开发体验。
如前所述,Angular 帮助根据提供的数据组合 HTML,但它还做了更多:如果数据发生变化,它会立即更新 HTML;如果 HTML 发生变化,它还可以更新数据。这个特性被称为双向数据绑定,我们将在下一节中简要介绍。
1.5.2. 双向数据绑定:在页面中处理数据
要理解双向数据绑定,可以考虑一个简单的例子。将这种方法与传统单向数据绑定进行比较。想象你有一个网页和一些数据,你想要做以下事情:
-
将该数据以列表形式显示给用户
-
允许用户通过在表单字段中输入文本来过滤该列表
在两种方法——单向和双向绑定——中,第一步是相似的。你使用数据为最终用户生成一些 HTML 标记。第二步是事情变得有点不同的地方。
在第二步中,你希望让用户在表单字段中输入一些文本以过滤显示的数据列表。使用单向数据绑定,你必须手动添加事件监听器到表单输入字段以捕获数据并更新数据模型(最终改变显示给用户的内容)。
通过双向数据绑定,可以自动捕获表单的任何更新,更新模型并更改向用户显示的内容。这种功能可能听起来并不重要,但要理解其强大之处,了解使用 Angular,你可以在步骤 1 和 2 中实现所有功能,而无需编写任何 JavaScript 代码!没错——这一切都是通过 Angular 的双向数据绑定...以及一些其他 Angular 功能的帮助来完成的。
当你阅读这本书的第三部分时,你将看到——并使用——这一功能在实际中的应用。这个特性是“眼见为实”,你不会失望的。
1.5.3. 使用 Angular 加载新页面
Angular 特别设计用于的功能之一是单页应用程序(SPA)功能。实际上,SPA 在浏览器内部运行所有内容,并且永远不会进行完整的页面刷新。所有应用程序逻辑、数据处理、用户流程和模板交付都可以在浏览器中管理。
以 Gmail 为例。那是一个 SPA。页面会显示不同的视图,以及各种数据集,但页面本身永远不会完全刷新。
这种方法可以减少你在服务器上需要的资源量,因为你实际上是在众包计算能力。每个人的浏览器都在做艰苦的工作;你的服务器在请求时提供静态文件和数据。
在这种方法下,用户体验也可以得到改善。在应用程序加载后,对服务器的调用次数减少,减少了延迟的可能性。
所有这些听起来都很棒,但肯定要付出代价。为什么不是所有东西都集成到 Angular 中?
1.5.4. 有任何缺点吗?
尽管 Angular 有许多优点,但它并不适合每个网站。像 jQuery 这样的前端库最适合用于渐进增强。想法是,你的网站在没有 JavaScript 的情况下也能完美运行,你使用的 JavaScript 可以让体验变得更好。Angular 或任何其他 SPA 框架都不是这样。Angular 使用 JavaScript 从模板和数据构建渲染的 HTML,所以如果你的浏览器不支持 JavaScript 或代码中存在错误,网站将无法运行。
这种依赖于 JavaScript 来构建页面的做法也导致搜索引擎出现问题。当搜索引擎爬取你的网站时,它不会运行所有 JavaScript;在 Angular 中,在 JavaScript 接管之前,你只能得到服务器上的基本模板。如果你想确保你的内容和数据被搜索引擎索引,而不是只有你的模板,你需要考虑 Angular 是否适合那个项目。
你有方法来应对这个问题:简而言之,你需要你的服务器输出编译后的内容以及 Angular。但是,如果你不需要战斗这场战斗,我们建议不要这样做。
你可以做的事情之一是,对于某些事情使用 Angular,而对于其他事情则不使用。在你的项目中选择性使用 Angular 没有什么问题。例如,你可能有一个数据丰富的交互式应用或网站的部分,非常适合在 Angular 中构建。或者你可能有一个围绕你的应用的博客或一些营销页面。这些元素不需要在 Angular 中构建,并且从服务器以传统方式提供可能更好。因此,你的网站的一部分由 Node.js、Express 和 MongoDB 提供,而另一部分也由 Angular 执行其功能。
这种灵活的方法是 MEAN 栈最强大的特性之一。只要你在思考时保持灵活性,不要将 MEAN 栈视为单一的架构栈,你就可以通过一个栈实现许多事情。
尽管如此,情况正在改善。网络爬虫技术,尤其是谷歌使用的那些,变得越来越强大,这个问题很快就会成为过去式。
1.5.5. 使用 TypeScript 进行开发
Angular 应用可以编写多种 JavaScript 风格,包括 ES5、ES2015+ 和 Dart。但最受欢迎的无疑是 TypeScript。
TypeScript 是 JavaScript 的超集,这意味着它就是JavaScript,但增加了额外的功能。在这本书中,你将使用 TypeScript 来构建应用的 Angular 部分。但别担心:我们将从头开始,在第三部分中介绍你需要了解的 TypeScript 部分。
1.6. 支持角色
MEAN 栈为你提供了创建数据丰富的交互式网络应用所需的一切,但你可能还想使用一些额外的技术来帮助你。例如,你可以使用 Twitter Bootstrap 来创建良好的用户界面,使用 Git 来帮助管理你的代码,以及使用 Heroku 来通过托管应用在实时 URL 上提供帮助。在后面的章节中,我们将探讨将这些技术整合到 MEAN 栈中。在本节中,我们将简要介绍每个技术能为你做什么。
1.6.1. Twitter Bootstrap 用于用户界面
在这本书中,你将使用 Twitter Bootstrap 以最小的努力创建响应式设计。它对于栈来说不是必需的,如果你正在构建一个基于现有 HTML 或特定设计的应用,你可能不想添加它。但在这本书中,你将以快速原型风格构建一个应用,从想法到应用,没有任何外部影响。
Bootstrap 是一个前端框架,它为创建出色的用户界面提供了丰富的帮助。在其特性中,Bootstrap 提供了一个响应式网格系统、许多界面组件的默认样式,以及通过主题改变视觉外观的能力。
响应式网格布局
在响应式布局中,你提供一个 HTML 页面,通过检测屏幕分辨率而不是尝试检测实际设备来在不同设备上以不同的方式排列自己。Bootstrap 针对四种不同的像素宽度断点进行布局,大致针对手机、平板电脑、笔记本电脑和外接显示器。如果你稍微考虑一下如何设置你的 HTML 和 CSS 类,你可以使用一个 HTML 文件在不同的布局中提供相同的内容,以适应屏幕大小。
CSS 类和 HTML 组件
Bootstrap 附带一系列预定义的 CSS 类,可以创建有用的视觉组件,例如页面标题、警告信息容器、标签和徽章,以及风格化的列表。Bootstrap 的制作者在框架上投入了大量的思考。Bootstrap 可以帮助你快速构建应用程序,无需花费太多时间在 HTML 布局和 CSS 样式上。
教学 Bootstrap 不是本书的目标,但我们会在你使用时指出各种功能。
添加不同的主题以改变感觉
Bootstrap 有一个默认的外观和感觉,提供了一个整洁的基线,并且由于其广泛的使用,你的网站可能会看起来像任何其他人的。幸运的是,你可以下载 Bootstrap 的主题来给你的应用程序带来不同的风格。下载主题通常只是用新的 CSS 文件替换 Bootstrap CSS 文件。你将在本书中使用免费的主题来构建你的应用程序,但也可以从多个网站购买高级主题,以给应用程序带来独特的风格。
1.6.2. Git 用于源代码控制
在你的计算机或网络驱动器上保存代码是非常好和有用的,但计算机或网络驱动器只保存当前版本,并且只允许你(或你网络上的其他用户)访问它。
Git 是一个分布式版本控制和源代码管理系统,允许多个人在不同的计算机和网络上的同一代码库上同时工作。这些可以一起推送,所有更改都存储并记录。如果需要,还可以回滚到早期状态。
如何使用 Git
Git 通常从命令行使用,尽管 Windows、Linux 和 Mac 都有 GUI。在这本书的整个过程中,你将使用命令行语句来发出所需的命令。Git 功能强大,我们将在本书中触及它的表面,但我们所做的一切都将作为示例的一部分提供。
在典型的 Git 设置中,你在你的机器上有一个本地仓库,并在 GitHub 或 Bitbucket 等地方托管一个远程集中式主仓库。你可以从远程仓库拉取到本地仓库,或者从本地推送到远程。所有这些任务都可以从命令行轻松执行,GitHub 和 Bitbucket 都有网络界面,这样你可以直观地跟踪你所提交的一切。
这里 Git 的用途是什么?
在这本书中,你将使用 Git 的两个原因:
-
本书中的示例应用程序的源代码将存储在 GitHub 上,不同分支对应不同的里程碑。你将能够克隆主分支或单独的分支来使用代码。
-
你将使用 Git 作为将你的应用程序部署到全球可见的实时服务器的方法。对于托管,你将使用 Heroku。
1.6.3. 使用 Heroku 托管
托管 Node.js 应用程序可能很复杂,但并不一定如此。许多传统的共享托管提供商没有跟上 Node.js 的兴趣。一些提供商为你安装它,这样你就可以运行应用程序,但服务器通常没有针对 Node.js 的独特需求进行设置。要成功运行 Node.js 应用程序,你需要一个针对 Node.js 进行配置的服务器,或者你可以使用专门为托管 Node.js 设计的 PaaS 提供商。
在这本书中,你将采取后一种方法。你将使用 Heroku (www.heroku.com) 作为你的托管提供商。Heroku 是 Node.js 应用程序的主要托管商之一,它提供了一个出色的免费层,你将利用这个免费层。
在 Heroku 上的应用程序本质上都是 Git 仓库,这使得发布过程变得极其简单。在一切设置完成后,你可以使用单个命令将你的应用程序发布到实时环境:
$ git push heroku master
1.7. 将其实践示例整合起来
正如我们已经多次提到的,在这本书的过程中,你将在 MEAN 栈上构建一个工作应用程序。这个过程将使你对每种技术都有很好的了解,并展示它们是如何结合在一起的。
1.7.1. 介绍示例应用程序
那么在你阅读本书的过程中,你将构建什么?你将构建一个名为 Loc8r 的应用程序。Loc8r 列出附近的带有 Wi-Fi 的地点,人们可以去那里完成工作。它还显示每个地点的设施、营业时间、评分和位置地图。用户将能够登录并提交评分和评论。
这个应用程序在现实世界中有些基础。基于位置的应用程序本身并不特别新颖,它们以几种形式出现。Swarm 和 Facebook Check In 列出他们能找到的所有附近事物,并为新地点和信息更新众包数据。Urbanspoon 帮助人们找到附近的餐馆,允许用户根据价格区间和菜系类型进行搜索。甚至像星巴克和麦当劳这样的公司,它们的应用程序中也有帮助用户找到最近店铺的部分。
真实数据还是假数据?
好吧,在这本书中,我们将为 Loc8r 假设数据,但如果你愿意,你也可以收集数据、众包数据或使用外部来源。对于快速原型方法,你通常会发现在你的应用程序的第一个私有版本中伪造数据可以加快这个过程。
最终产品
你将使用 MEAN 栈的所有层来创建 Loc8r,包括 Twitter Bootstrap 帮助你创建响应式布局。图 1.7 展示了你在本书中将要构建的一些截图。
图 1.7. Loc8r 是你在本书中将构建的应用程序。它在不同的设备上显示不同,显示地点列表和每个地点的详细信息,并允许访客登录并留下评论。

1.7.2. MEAN 栈组件是如何协同工作的
在你阅读完这本书后,你将有一个运行在 MEAN 栈上的应用程序,全程使用 JavaScript。MongoDB 以二进制 JSON 格式存储数据,通过 Mongoose 暴露为 JSON。Express 框架建立在 Node.js 之上,代码以 JavaScript 编写。前端是 Angular,它是 TypeScript。图 1.8 展示了这个流程和连接。
图 1.8. JavaScript(部分为 TypeScript)是 MEAN 栈中的通用语言,JSON 是通用数据格式。

我们将在第二章中探讨各种构建 MEAN 栈的方法以及如何构建 Loc8r。
由于 JavaScript 在栈中扮演着如此关键的角色,请参阅附录 D(可在网上和电子书中找到),其中包含 JavaScript 陷阱和最佳实践的复习。
摘要
在本章中,你学习了
-
构成 MEAN 栈的技术有哪些以及它们是如何协同工作的
-
MongoDB 作为数据层的位置
-
Node.js 和 Express 如何协同工作以提供应用服务器层
-
Angular 如何提供出色的前端和数据绑定层
-
几种扩展 MEAN 栈的额外技术的方法
第二章. 设计 MEAN 栈架构
本章涵盖
-
介绍常见的 MEAN 栈架构
-
单页应用程序
-
探索替代的 MEAN 栈架构
-
为真实应用程序设计架构
-
根据架构设计进行构建规划
在第一章中,我们探讨了 MEAN 栈的组成部分以及它们是如何相互配合的。在本章中,我们将更详细地探讨它们是如何相互配合的。
我们将从一些人认为的“MEAN 栈架构”开始,特别是当他们第一次遇到这个栈时。通过一些示例,我们将探讨为什么你可能使用不同的架构,然后稍作调整,移动一些东西。MEAN 是一个强大的栈,可以用来解决各种问题……如果你在解决方案的设计上富有创意。
2.1. 常见的 MEAN 栈架构
架构 MEAN 栈应用程序的一种常见方式是使用表示状态转移(REST)API 为单页应用程序(SPA)提供数据。API 通常使用 MongoDB、Express 和 Node.js 构建,SPA 则使用 Angular 构建。这种方法对于那些从 Angular 背景转向 MEAN 栈并且寻找提供快速、响应式 API 的堆栈的人来说尤其受欢迎。图 2.1 展示了基本设置和数据流。
图 2.1. MEAN 栈架构的一种常见方法,使用 MongoDB、Express 和 Node.js 构建 REST API,将 JSON 数据传输到在浏览器中运行的 Angular SPA

什么是 REST API?
REST 代表 表征状态转移,这是一种架构风格,而不是严格的协议。REST 是无状态的;它对任何当前用户状态或历史没有任何概念。
API 是 应用程序编程接口 的缩写,它使得应用程序之间能够相互通信。在网页的情况下,API 通常是一组在正确的方式和正确的信息下调用时返回数据的 URL。
REST API 是对应用程序的无状态接口。在 MEAN 栈的情况下,REST API 用于创建对数据库的无状态接口,从而为其他应用程序,如 Angular SPA,提供了一种与数据交互的方式。换句话说,你创建了一个结构化的 URL 集合,当调用时返回特定的数据。
图 2.1 是一个很好的设置,如果你有或打算构建一个 SPA 作为你的用户界面,那么它是非常理想的。Angular 是专门设计用来构建 SPAs 的,它从 REST API 中拉取数据,并将其推回。MongoDB、Express 和 Node.js 在构建 API 方面也非常强大,整个堆栈(包括数据库本身)都使用 JSON。
这就是许多人开始使用 MEAN 栈的地方,寻找问题的答案:“我已经用 Angular 开发了一个应用程序;现在我该从哪里获取数据?”
如果你有 SPA,这样的架构很棒,但如果你有不同的需求呢?MEAN 栈比当前的设计要灵活得多。所有四个组件都各自强大,有很多东西可以提供。
2.2. 超越 SPAs 的视角
在 Angular 中编码 SPA 就像在沿海道路上开着敞篷的保时捷一样。两者都非常棒。它们有趣、快速、性感、敏捷,并且功能强大。如果你在历史上没有做过这两件事,那么很可能两者都是巨大的改进。
但有时,它们并不合适。如果你想收拾冲浪板,带着家人离开一周,你将难以驾驭跑车。尽管你的车可能很棒,但在这种情况下,你可能会想要使用不同的东西。对于 SPA 来说,情况也是如此。是的,在 Angular 中构建它们很棒,但有时 SPA 并不是解决你问题的最佳方案。让我们简要地看看在设计解决方案和决定是否全 SPA 适合你的项目时,关于 SPA 需要注意的一些事项。
SPA 通常提供极好的用户体验,同时减少服务器负载,因此也降低了你的托管成本。在 2.3.1 和 2.3.2 节中,你将看到 SPA 的良好和不良用例,并在本书结束时构建一个完整的 SPA。
2.2.1. 难以爬取
JavaScript 应用程序对搜索引擎的爬取和索引很困难。大多数搜索引擎查看页面上的 HTML 内容,但不会执行或下载很多 JavaScript。对于那些确实会执行或下载的,对 JavaScript 创建的内容的实际爬取远不如由服务器提供的内容。如果你的所有内容都是通过 JavaScript 应用程序提供的,你无法确定其中有多少会被索引。
一个相关的缺点是,来自 Facebook、LinkedIn 和 Pinterest 等社交分享网站的自动预览效果不佳,这也是因为他们查看你链接到的页面的 HTML,并尝试提取一些相关的文本和图像。像搜索引擎一样,它们不会在页面上运行 JavaScript,所以通过 JavaScript 提供的内容将不会被看到。
所有这些都在慢慢改进。我们希望这本书的未来版本不需要有这个章节!
使 SPA 可爬取
你可以使用一些解决方案来使你的网站看起来可爬取。这两种方法都涉及创建单独的 HTML 页面,以反映你的 SPA 的内容。你可以让服务器创建你网站的 HTML 版本并将其提供给爬虫,或者你可以使用无头浏览器,如 PhantomJS,来运行你的 JavaScript 应用程序并输出生成的 HTML。
每种方法都需要相当多的努力,如果你有一个大型、复杂的网站,最终可能会变成一个维护难题。你还有潜在的搜索引擎优化(SEO)陷阱。如果你的服务器生成的 HTML 被认为与 SPA 内容差异太大,你的网站将会受到惩罚。运行 PhantomJS 输出 HTML 可能会减慢你页面的响应速度,这是搜索引擎——特别是 Google——会降低你排名的原因。
这重要吗?
这是否重要取决于你想要构建什么。如果你所构建的任何东西的主要增长计划是通过搜索引擎流量或社交分享,你想要对这些担忧给予极大的思考。如果你正在创建一些将保持小规模的东西,管理这些解决方案是可行的,而在更大规模上,你将面临挑战。
另一方面,如果你正在构建一个不需要太多 SEO 的应用程序——或者实际上,如果你希望你的网站更难被抓取——你不需要担心这个问题。这甚至可能是一个优势。
2.2.2. 分析和浏览器历史
像 Google Analytics 这样的分析工具严重依赖于整个新页面在浏览器中的加载,由 URL 变化启动。SPA 不这样做。这就是为什么它们被称为单页应用程序的原因!
在第一次页面加载之后,所有后续的页面和内容更改都由应用程序内部处理。浏览器永远不会触发新的页面加载;不会添加任何内容到浏览器历史记录;你的分析包也不知道谁在网站上做了什么。
在 SPA 中添加页面加载
你可以通过使用 HTML5 历史 API 将页面加载事件添加到 SPA 中,这将帮助你集成分析。困难在于管理和确保一切都被准确跟踪,这涉及到检查缺失的报告和重复记录。
好消息是,你不必从头开始构建一切。网上有几种针对 Angular 的开源分析集成,解决了大多数主要分析提供商的问题。你仍然需要将它们集成到你的应用程序中并确保一切正常工作,但你不必从头开始做所有事情。
这是一个重大问题吗?
这是否是一个问题取决于你对不可否认的准确分析的需求。如果你想监控访客流量和行为的趋势,你可能会发现集成分析很容易。你需要越多的细节和确切的准确性,开发和测试的工作量就越大。尽管在服务器生成的网站上在每个页面上包含你的分析代码可能更容易,但分析集成不太可能是选择非 SPA 路线的唯一原因。
2.2.3. 初始加载速度
与基于服务器的应用程序相比,SPA 的首次页面加载速度较慢,因为第一次加载需要在浏览器中将框架和应用程序代码下载下来,然后再将所需的视图作为 HTML 渲染。基于服务器的应用程序只需要将所需的 HTML 推送到浏览器,从而减少延迟和下载时间。
加快页面加载
你有一些方法可以加快 SPA 的初始加载速度,例如在需要时采用缓存和懒加载模块的重量级方法。但你永远无法摆脱 SPA 需要下载框架(至少,一些应用程序代码)并在显示浏览器中的内容之前很可能击中 API 获取数据的事实。
你应该关心速度吗?
你是否应该关心初始页面加载的速度,答案再次是“这取决于。”这取决于你正在构建的内容以及人们将如何与之互动。
以 Gmail 为例。Gmail 是一个 SPA,加载需要相当长的时间。诚然,这个加载时间通常只有几秒钟,但如今网上的人都缺乏耐心,期望即时的响应。但人们不介意等待 Gmail 加载,因为一旦进入,它就非常迅速和响应灵敏。而且当你进入后,你通常会停留一段时间。
但如果你有一个博客,从搜索引擎和其他外部链接中吸引流量,你不想首页加载需要几秒钟。人们会认为你的网站出了问题或运行缓慢,在你有机会向他们展示内容之前,他们就会点击后退按钮。
2.2.4. 是 SPA 还是非 SPA?
只提醒一下,前面的章节并不是 SPA 的批评练习;我们只是花点时间思考一下那些经常被推到一边直到为时已晚的事情。关于可爬行性、分析集成和页面加载速度的三个点并不是为了给出何时创建 SPA 和何时做其他事情的明确定义。它们的存在是为了提供一个考虑框架。
可能这些事情对你的项目来说都不是问题,而且 SPA 绝对是正确的选择。如果你发现每个点都让你停下来思考,并且看起来你需要为这三个点添加折衷方案,那么 SPA 可能不是正确的选择。
如果你处于中间位置,那么这是一个关于什么最重要以及,关键的是,什么是对项目来说最好的解决方案的判断。一般来说,如果你的解决方案一开始就包含大量折衷方案,你可能需要重新考虑。
即使你决定 SPA 不适合你,这并不意味着你不能使用 MEAN 栈。在下一节中,我们将探讨如何设计不同的架构。
2.3. 设计灵活的 MEAN 架构
如果 Angular 就像拥有一辆保时捷,那么其余的栈就像也在车库里有一辆奥迪 RS6。很多人可能只关注你前面的跑车,而不会多看一眼车库里那辆旅行车。但如果你真的走进车库四处看看,你会发现引擎盖下有一台兰博基尼 V10 引擎。这辆旅行车比一些人想象的要复杂得多!
只使用 MongoDB、Express 和 Node.js 一起构建 REST API,就像只使用奥迪 RS6 进行学校接送一样。它们都非常能干,并且会非常出色地完成工作,但它们还有更多可以提供的东西。
我们在第一章中简要讨论了这些技术能做什么,但这里有一些起点:
-
MongoDB 可以存储和流式传输二进制信息。
-
Node.js 特别适合使用 Web sockets 进行实时连接。
-
Express 是一个内置模板、路由和会话管理的 Web 应用程序框架。
还有更多内容,我们当然无法在本书中涵盖所有技术的全部功能。为此,我们需要几本书!我们在这里能做的是提供一个简单的例子,并展示你如何将 MEAN 堆栈的各个部分组合起来,以设计最佳解决方案。
2.3.1. 博客引擎的需求
在本节中,你将了解熟悉的博客引擎概念,并了解如何最佳地架构 MEAN 堆栈来构建一个博客引擎。
一个博客引擎通常有两个方面:一个面向公众的方面,为读者提供文章,并(我们希望)在互联网上被聚合和共享,以及一个管理员界面,博客所有者可以登录以撰写新文章和管理他们的博客。图 2.2 展示了这两个方面的关键特性。
图 2.2. 博客引擎两方面的冲突特性:面向公众的博客条目和私有管理员界面

通过查看图 2.2 中的列表,你可以很容易地看到这两个方面的特性之间存在高度冲突。对于博客文章,你拥有丰富的内容和低交互性,而对于管理员界面,则是一个功能丰富、高度交互的环境。博客文章应该快速加载以减少跳出率,而管理员区域应该快速响应用户输入和操作。最后,用户通常在博客条目上停留的时间较短,但可能会与他人分享,而管理员界面是私人的,单个用户可能会长时间登录。
考虑到我们关于 SPA 潜在问题的讨论,以及查看博客条目的特性,你会发现有很多重叠之处。考虑到这一点,你很可能不会选择使用 SPA 来向读者提供博客文章。另一方面,管理员界面非常适合 SPA。
那么,你该怎么做呢?可以说,最重要的事情是保持博客读者的持续关注。如果他们获得糟糕的体验,他们就不会回来;他们也不会分享。如果一个博客没有读者,作者就会停止写作或转移到另一个平台。同样,一个缓慢且无响应的管理员界面也会导致博客所有者跳船。那么,你到底该怎么做?如何让每个人都满意,并保持博客引擎的运营?
2.3.2. 博客引擎架构
答案在于不要寻找一个适合所有情况的解决方案。实际上,你拥有两个应用程序:面向公众的内容应该直接从服务器发送,以及一个你希望构建为 SPA 的交互式私有管理员界面。首先,分别查看这两个应用程序,从管理员界面开始。
管理员界面:Angular SPA
我们已经说过,这个接口非常适合用 Angular 构建的 SPA。这个引擎部分的架构应该看起来很熟悉:一个用 MongoDB、Express 和 Node.js 构建的 REST API,前端是一个 Angular SPA。图 2.3 展示了它的样子。
图 2.3. 熟悉的景象:管理界面是一个 Angular SPA,利用了用 MongoDB、Express 和 Node.js 构建的 REST API。

图 2.3 中展示的并没有什么特别新的内容。整个应用都是用 Angular 构建的,并在浏览器中运行,Angular 应用和 REST API 之间通过 JSON 数据进行交互。
博客条目:该做什么?
看看博客条目,你会发现事情变得有些复杂。
如果你只把 MEAN 栈看作是一个调用 REST API 的 Angular 单页应用(SPA),你可能会有些困惑。尽管如此,你仍然可以构建一个面向公众的 SPA,因为你想要使用 JavaScript 和 MEAN 栈。但这并不是最佳解决方案。你可以决定在这种情况下 MEAN 栈并不合适,并选择不同的技术栈。但你不想这么做!你想要端到端的 JavaScript。
再看看 MEAN 栈,并思考所有组件。你知道 Express 是一个 Web 应用框架。你知道 Express 可以使用模板引擎在服务器上构建 HTML。你知道 Express 可以使用 URL 路由和 MVC 模式。你应该开始思考,也许 Express 有答案!
博客条目:充分利用 Express
在这个博客场景中,直接从服务器发送 HTML 和内容正是你想要做的。Express 在这方面做得尤其出色,甚至从一开始就提供了模板引擎的选择。HTML 内容需要从数据库中获取数据,所以你将再次使用 REST API。(关于为什么采取这种方法的更多内容,请参阅第 2.3.3 节 2.3.3。)图 2.4 展示了这种架构的基础。
图 2.4. 直接从服务器发送 HTML 的架构:前端是一个 Express 和 Node.js 应用,与在 MongoDB、Express 和 Node.js 中构建的 REST API 交互

这种方法使你能够使用 MEAN 栈(至少是它的一部分)直接从服务器将数据库驱动的内容发送到浏览器。但这并不一定要停止在这里。MEAN 栈甚至更加灵活。
博客条目:使用更多的栈
你正在查看一个 Express 应用,向访客提供博客内容。如果你想让访客能够登录,可能是为了添加文章评论,你需要跟踪用户会话。你可以使用 MongoDB 与你的 Express 应用一起做到这一点。
您也可能在您的帖子侧边栏中有些动态数据,例如相关帖子或带有自动补全功能的搜索框。您可以使用 Angular 来实现这些功能。记住,Angular 不仅仅用于 SPA;它还可以用来创建添加一些丰富数据交互性的独立组件,使原本静态的页面更加生动。图 2.5 显示了这些可选的 MEAN 部分添加到博客条目架构中。
图 2.5. 在博客引擎的公共面向部分添加使用 Angular 和 MongoDB 作为选项,为访客提供博客条目

现在,您有了构建一个完整的 MEAN 应用程序的可能性,该应用程序可以向与您的 REST API 交互的访客提供内容。
博客引擎:混合架构
在这一点上,您有两个独立的应用程序,每个应用程序都使用 REST API。通过一点规划,您可以让两个应用程序的两侧都使用一个共同的 REST API。图 2.6 显示了这种作为单一架构的视图,其中单一的 REST API 与两个前端应用程序交互。
图 2.6. 混合 MEAN 堆栈架构:一个单一的 REST API 为两个不同的用户界面应用程序提供数据,这些应用程序使用 MEAN 堆栈的不同部分来提供最合适的解决方案

这个图是一个简单的例子,说明您如何将 MEAN 堆栈的各个部分组合成不同的架构,以回答您的项目向您提出的问题。您的选择仅限于您对组件的理解以及您在组合它们时的创造力。对于 MEAN 堆栈来说,没有一种正确的架构。
2.3.3. 最佳实践:为数据层构建内部 API
您可能已经注意到,架构的每个版本都包括一个 API 来展示数据并允许主应用程序与数据库之间的交互。这样做有很好的理由。
如果您从使用 Node.js 和 Express 构建应用程序开始,直接从服务器提供 HTML,那么从 Node.js 应用程序代码中直接与数据库通信将很容易。从短期来看,这是一种简单的方法。但从长期来看,这会变得困难,因为它将您的数据与应用程序代码紧密耦合,以至于其他任何东西都无法使用它。
另一个选择是构建自己的 API,该 API 可以直接与数据库通信并输出所需的数据。然后您的 Node.js 应用程序可以与这个 API 通信,而不是直接与数据库通信。图 2.7 显示了两种设置的对比。
图 2.7. 将数据集成到您的 Node.js 应用程序中的短期视图。您可以设置您的 Node.js 应用程序直接与数据库通信,或者您可以创建一个与数据库交互的 API,并让您的 Node.js 应用程序只与 API 通信。

看到图 2.7,你可能会想知道为什么你要费劲去创建一个 API,只是为了在应用程序和数据库之间放置一个中间件。这难道不是增加了更多的工作量吗?在这个阶段,是的,它确实增加了工作量,但你应该看得更远一些。如果你以后想在原生移动应用程序或 Angular 前端中使用你的数据呢?
你当然不希望发现自己不得不为每个应用程序编写单独但相似的接口。如果你在前面已经构建了一个 API,它输出你需要的数据,你可以避免这项工作。如果你有一个现成的 API,当你想要将数据层集成到应用程序中时,你只需让它引用你的 API 即可。无论你的应用程序是 Node.js、Angular、iOS 还是 Android,只要你能访问它,就不必是一个任何人都可以使用的公共 API。图 2.8 显示了当你有 Node.js、Angular 和 iOS/Android 应用程序都使用相同的数据源时,两种方法的比较。
图 2.8. 将数据集成到你的 Node.js 应用程序以及额外的 Angular 和 iOS 应用程序的长期视图。集成方法已经变得碎片化,而 API 方法简单且易于维护。

如图 2.8 所示,之前简单的集成方法正在变得碎片化和复杂。你将需要管理和维护三个数据集成,因此任何更改都必须在多个地方进行,以保持一致性。如果你有一个单一的 API,你就不会有这些烦恼。通过在开始时做一点额外的工作,你可以让你的未来生活变得更加轻松。我们将在第六章中探讨创建内部 API。
2.4. 规划真实的应用程序
正如我们在第一章中讨论的,在整个本书的过程中,你将构建一个基于 MEAN 栈的工作应用程序,名为 Loc8r。Loc8r 列出了附近有 Wi-Fi 的地方,人们可以去那里完成工作。它还显示了每个地点的设施、营业时间、评分和位置地图。访客将能够提交评分和评论。
为了演示应用程序,你需要创建一些假数据,这样你可以快速轻松地进行测试。在下一节中,我们将带你了解应用程序规划。
2.4.1. 在高层次上规划应用程序
第一步是思考在你的应用程序中需要哪些屏幕。关注单独的页面视图和用户旅程。你可以从高层次开始,不必真正关心每个页面上具体的内容。在纸上或白板上勾勒出这个阶段是个好主意,这有助于你整体可视化应用程序。这也有助于在准备构建时将屏幕组织成集合和流程,同时作为一个良好的参考点。由于没有数据附加到页面或其背后的应用程序逻辑上,因此很容易添加和删除部分,更改显示的内容,甚至更改你想要的页面数量。很可能会第一次就做不对;关键是开始,然后迭代和改进,直到你对单独的页面和整体用户流程感到满意。
规划屏幕
考虑 Loc8r。如前所述,你的目标是以下内容:
Loc8r 列出附近有 Wi-Fi 的地点,人们可以去那里完成一些工作。它还显示设施、营业时间、评分和每个地点的位置地图。访客将能够提交评分和评论。
从这个描述中,你可以对将要需要的屏幕有一个大致的了解:
-
列出附近地点的屏幕
-
显示单个地点详细信息的屏幕
-
添加关于地点评论的屏幕
你可能还希望告诉访客 Loc8r 是做什么的以及为什么存在,因此你应该将另一个屏幕添加到列表中:
- “关于我们”信息的屏幕
将屏幕划分为集合
接下来,将屏幕列表整理到它们逻辑上属于一起的地方。例如,列表中的前三个屏幕处理位置。关于页面不属于任何地方,所以它可以放在杂项的其他集合中。这种安排的草图看起来像图 2.9。
图 2.9. 将你的应用程序的单独屏幕整理成逻辑集合。

制作一个快速草图,如图 2.9,是规划的第一阶段,在你开始考虑架构之前,你需要完成这个阶段。这个阶段给你一个机会查看基本页面并思考流程。图 2.9 例如,还显示了在位置集合中的基本用户旅程,从列表页面到详情页面,然后到添加评论的表单。
2.4.2. 架构应用程序
表面上看,Loc8r 是一个相当简单的应用程序,只有几个屏幕。但你仍然需要考虑如何构建它,因为你将要从数据库传输数据到浏览器,让用户与数据交互,并允许数据发送回数据库。
从 API 开始
因为应用程序将使用数据库并传递数据,所以从你肯定需要的部分开始构建架构。图 2.10 显示了起点:使用 Express 和 Node.js 构建的 REST API,以实现与 MongoDB 数据库的交互。
图 2.10. 从标准的 MEAN REST API 开始,使用 MongoDB、Express 和 Node.js。

构建一个 API 来与你的数据交互是基本的要求,也是架构的基础点。更有趣的问题是你是如何架构应用程序本身的。
应用架构选项
在这一点上,你需要考虑你应用程序的具体要求以及如何将 MEAN 栈的各个部分组合起来以构建最佳解决方案。你是否需要 MongoDB、Express、Angular 或 Node.js 的特殊功能,这将影响决策的方向?你是否希望直接从服务器提供 HTML,或者一个 SPA 是更好的选择?
对于 Loc8r,你没有不寻常或特定的要求,并且它是否应该容易被搜索引擎抓取取决于业务增长计划。如果目标是吸引来自搜索引擎的有机流量,那么它需要是可抓取的。如果目标是作为应用程序推广应用程序并以此方式推动使用,那么搜索引擎的可视性就不再是主要关注点。
回想一下博客示例,你可以立即想象出三种可能的应用程序架构,如图 2.11 所示:
-
一个 Node.js 和 Express 应用程序
-
一个带有 Angular 增加的 Node.js 和 Express 应用程序,用于交互性
-
一个 Angular 单页应用 (SPA)
图 2.11. 构建 Loc8r 应用程序的三个选项,从服务器端的 Express 和 Node.js 应用程序到完整的客户端 Angular SPA

考虑到这三个选项,Loc8r 最好的选择是哪一个?
选择应用程序架构
没有特定的业务需求推动你偏向于选择某种架构而不是另一种。没关系,因为在这本书中你将构建所有三种架构。构建所有三种架构让你能够探索每种方法是如何工作的,并使你能够依次查看每种技术,通过层层构建应用程序层。
你将按照 图 2.11 中所示的顺序构建架构,首先是 Node.js 和 Express 应用程序,然后添加一些 Angular,最后重构为 Angular SPA。虽然这不一定是你通常构建网站的方式,但它为你提供了一个很好的机会来学习 MEAN 栈的所有方面。在 2.5 节中,我们将讨论这种方法,并更详细地介绍计划。
2.4.3. 将一切封装在 Express 项目中
你迄今为止看到的架构图暗示,你将会有单独的 Express 应用用于 API 和应用逻辑。这在大型项目中是完全可能的,也是一条很好的路线。如果你预计会有大量的流量,你可能甚至希望你的主要应用和 API 在不同的服务器上。这种方法的另一个好处是,你可以为每个服务器和应用设置更具体的设置,以最适合特定需求。
另一种方法是保持简单和紧凑,将所有内容都放在一个 Express 项目中。采用这种方法,你只需关注一个应用的主机部署,并管理一套源代码。这就是 Loc8r 的做法:创建一个包含几个子应用的 Express 项目。图 2.12 展示了这种方法。
图 2.12. API 和应用逻辑包裹在同一 Express 项目中的应用架构

当你以这种方式组合应用时,组织好代码非常重要,以便将应用的不同部分保持分离。这不仅使代码更容易维护,还便于在将来决定这样做是正确路线时,将代码拆分为单独的项目。我们将在整本书中不断回到这个关键主题。
2.4.4. 最终产品
如你所见,你使用 MEAN 栈的所有层来创建 Loc8r。你还包括 Twitter Bootstrap 来创建响应式布局。图 2.13 展示了你在整本书中将要构建的内容的截图。
图 2.13. Loc8r 是你在整本书中将要构建的应用。它在不同的设备上显示不同,显示地点列表和每个地点的详细信息,并允许访客登录并留下评论。

2.5. 将开发分解为阶段
在这本书中,你有两个目标:
-
在 MEAN 栈上构建一个应用。
-
在进行的过程中了解栈的不同层。
你将以构建快速原型的方式处理项目,但会进行一些调整,以覆盖整个栈的最佳效果。首先,看看快速原型开发的五个阶段,然后看看如何使用这种方法一层层地构建 Loc8r,同时关注你正在使用的技术。
2.5.1. 快速原型开发阶段
下面的章节将过程分解为阶段,这样你可以一次专注于一件事情,增加成功的机会。我们发现这种方法对于将想法变为现实非常有效。
阶段 1:构建静态网站
第一阶段是构建应用的静态版本,这本质上是由几个 HTML 屏幕组成的。这一阶段的目标是
-
为了快速确定布局
-
为了确保用户流程合理
在这个阶段,你不需要关心数据库或用户界面的华丽效果;你只想创建一个用户将通过应用程序进行的主要屏幕和旅程的工作原型。
第 2 阶段:设计数据模型并创建数据库
当你对一个满意的静态原型感到满意时,下一步要做的是查看静态应用程序中的任何硬编码数据,并将其放入数据库中。这个阶段的目标是
-
定义一个反映应用程序需求的数据模型
-
创建一个数据库以与模型一起工作
第一部分是定义数据模型。退回到宏观视角,你需要关于哪些对象的数据,这些对象是如何相互连接的,以及它们包含哪些数据?
在构建静态原型之前尝试进行这个阶段,你是在处理抽象的概念和想法。当你有一个原型时,你可以看到不同页面上发生的事情以及需要哪些数据。突然之间,这个阶段变得容易多了。几乎在你没有意识到的情况下,你在构建静态原型时已经完成了艰难的思考。
第 3 阶段:构建你的数据 API
在第 1 和第 2 阶段之后,你有一边是静态网站,另一边是数据库。这个阶段和下一个阶段是自然地将它们连接起来的步骤。第 3 阶段的目标是
- 创建一个 RESTful API,允许你的应用程序与数据库交互
第 4 阶段:将数据库连接到应用程序
当你到达这个阶段时,你有一个静态应用程序和一个 API,该 API 公开了数据库的接口。这个阶段的目标是
- 使你的应用程序能够与你的 API 通信
当这个阶段完成时,应用程序看起来几乎和之前一样,但数据将来自数据库。完成之后,你将拥有一个数据驱动的应用程序!
第 5 阶段:增强应用程序
这个阶段完全是关于用额外的功能装饰应用程序。你可能添加身份验证系统、数据验证或向用户显示错误消息的方法。这个阶段可能包括向前端添加更多交互性或加强应用程序中的业务逻辑。
这个阶段的目标是
-
为你的应用程序添加最后的修饰
-
为了使应用程序准备好供人们使用
这五个开发阶段为处理新的建设项目提供了一个很好的方法论。在下一节中,你将了解如何遵循这些步骤来构建 Loc8r。
2.5.2.构建 Loc8r 的步骤
在本书中构建 Loc8r 的过程中,你有两个目标。首先,当然,你想要在 MEAN 堆栈上构建一个可工作的应用程序。其次,你想要了解不同的技术,如何使用它们,以及如何以不同的方式将它们组合在一起。
在整本书中,你将遵循五个开发阶段,但会有一些变化,这样你就可以看到整个堆栈的实际运行情况。在详细查看步骤之前,快速回顾一下在 图 2.14 中展示的提议架构。
图 2.14. 你将在本书中构建的 Loc8r 的提议架构

第 1 步:构建静态网站
你将从遵循第 1 阶段并构建一个静态网站开始。我们建议为任何应用程序或网站都这样做,因为你可以用相对较少的努力学到很多东西。在构建静态网站时,最好关注未来,同时考虑最终架构将是什么样子。Loc8r 的架构已经定义,如 图 2.14 所示。
基于这个架构,你将在 Node 和 Express 中构建静态应用程序,将其作为进入 MEAN 堆栈的起点。图 2.15 强调了这个过程中的这一步骤,作为开发提议架构的第一部分。这一步骤在 第三章 和 第四章 中有详细说明。
图 2.15. 你的应用程序的起点是使用 Express 和 Node.js 构建用户界面。

第 2 步:设计数据模型并创建数据库
仍然遵循开发阶段,通过创建数据库和设计数据模型,你将继续进入第 2 阶段。再次强调,任何应用程序都可能需要这一步骤,如果你首先完成了第 1 步,你会从中获得更多。
图 2.16 展示了这一步骤是如何为构建应用程序架构的整体图景增添内容的。
图 2.16. 静态网站构建完成后,你将利用获取的信息来设计数据模型并创建 MongoDB 数据库。

在 MEAN 堆栈中,你将使用 MongoDB 进行这一步骤,在数据建模方面高度依赖 Mongoose。数据模型实际上是在 Express 应用程序内部定义的。这一步骤在 第五章 中有详细说明。
第 3 步:构建你的 REST API
当你构建了数据库并定义了数据模型后,你将想要创建一个 REST API,这样你就可以通过进行网络调用来与数据交互。几乎任何数据驱动型应用程序都将从拥有 API 接口中受益,因此这一步骤是大多数构建项目中你希望拥有的另一个步骤。
你可以在 图 2.17 中看到这一步骤在构建整体项目中的位置。
图 2.17. 使用 Express 和 Node.js 构建一个 API,暴露与数据库交互的方法。

在 MEAN 堆栈中,这一步骤主要在 Node.js 和 Express 中完成,大量依赖 Mongoose。你将使用 Mongoose 与 MongoDB 进行接口,而不是直接处理 MongoDB。这一步骤在 第六章 中有详细说明。
第 4 步:使用应用程序中的 API
这一步骤与开发过程中的第 4 阶段相匹配,Loc8r 开始变得有生命力。第 1 步中的静态应用程序将更新为使用第 3 步中的 REST API 与第 2 步中创建的数据库进行交互。
要了解堆栈的所有部分以及你可以使用它们的不同方式,你将使用 Express 和 Node.js 来调用 API。如果在现实世界的场景中,你计划在 Angular 中构建应用程序的大部分内容,你将把你的 API 连接到 Angular。这种方法在第八章、第九章和第十章中有详细说明。
在这一步结束时,你将有一个运行在三种架构中的第一个架构上的应用程序:一个 Express 和 Node.js 应用程序。图 2.18 展示了这一步如何将架构的两部分粘合在一起。
通过将其连接到数据 API 来更新静态 Express 应用程序,允许应用程序数据库驱动。

在这个构建过程中,你将主要使用 Node.js 和 Express 来完成这一步。这一步在第七章中有详细说明。
第 5 步:美化应用程序
第 5 步与开发过程中的第 5 阶段相关,在这一阶段,你可以为应用程序添加额外的细节。你将使用这一步来查看 Angular,并了解如何将 Angular 组件集成到 Express 应用程序中。这一项目架构的添加在第图 2.19 中得到了突出。
图 2.19。在 MEAN 应用程序中使用 Angular 的一种方法是在 Express 应用程序的前端添加组件。

这一步骤完全是关于引入和使用 Angular。为了支持这一步骤,你很可能会也改变一些你的 Node.js 和 Express 设置。这一步骤在第八章中有详细说明。
第 6 步:将代码重构为 Angular SPA
在第 6 步中,你将通过替换 Express 应用程序并将所有逻辑移动到 SPA 中(使用 Angular)来彻底改变架构。与之前的步骤不同,这一步不是在之前的基础上构建,而是替换了之前的一些内容。
在正常的构建过程中,这一步可能是不寻常的——在 Express 中开发应用程序,然后在 Angular 中重新做——但这种方式非常适合本书的学习方法。你将能够专注于 Angular,因为你已经知道应用程序应该做什么,而且数据 API 已经准备好供你使用。
图 2.20 展示了这种变化如何影响整体架构。这一步再次聚焦于 Angular,并在第九章和第十章中进行了介绍。

第 7 步:添加身份验证
在第 7 步中,你将通过允许用户注册和登录来为应用程序添加功能。你还将看到如何在使用应用程序时利用用户数据。你将在之前所做的一切基础上添加认证到 Angular SPA。作为这一步骤的一部分,你将在数据库中保存用户信息并确保某些 API 端点只能由认证用户使用。
图 2.21 展示了你在架构中将要处理的内容。在这个步骤中,你将使用所有的 MEAN 技术。这一步骤在第十一章和第十二章中有详细说明。
图 2.21. 使用所有 MEAN 堆栈为 Angular SPA 添加认证

那是计划的软件架构。在下一节中,我们将简要讨论硬件。
2.6. 硬件架构
没有关于架构的讨论会不包含一个关于硬件的部分。你已经看到了软件和代码组件是如何组合在一起的,但你需要什么样的硬件来运行它们呢?
2.6.1. 开发硬件
好消息是,你不需要任何特别的东西来运行开发堆栈。一台笔记本电脑甚至一个虚拟机(VM)就足够开发一个 MEAN 应用程序。堆栈的所有组件都可以安装在 Windows、macOS 和大多数 Linux 发行版上。
我们已经在 Windows 和 macOS 笔记本电脑以及 Ubuntu 虚拟机上成功开发了应用程序。我们更喜欢在 macOS 上本地开发,但我们知道有些人对 Linux 虚拟机情有独钟。
如果你有一个本地网络和多个服务器,你可以在它们之间运行应用程序的不同部分。例如,你可以有一个机器作为数据库服务器,另一个用于 REST API,第三个用于主应用程序代码本身。只要服务器之间可以互相通信,这种设置就不会有问题。
2.6.2. 生产硬件
生产硬件架构的方法与开发硬件并没有太大的不同。主要区别是生产硬件通常规格更高,并且可以公开接入互联网以接收公共请求。
初学者尺寸
所有应用程序的部分都可以托管并运行在同一个服务器上。你可以在图 2.22 中看到一个基本的示意图。
图 2.22. 最简单的硬件架构,所有内容都在单个服务器上

这种架构适用于流量较低的应用程序,但随着应用程序的增长,通常不建议使用,因为你不希望应用程序和数据库争夺相同的资源。
成长:独立的数据库服务器
通常首先迁移到独立服务器的是数据库。现在你有两个服务器:一个用于应用程序代码,一个用于数据库。图 2.23 展示了这种方法。
图 2.23. 常见的硬件架构方法:一个服务器运行应用程序代码和 API,另一个独立的数据库服务器

这个模型很常见,尤其是如果你选择使用平台即服务(PaaS)提供商来托管你的应用时。你将在本书中使用这种方法。
追求规模
就像我们在开发硬件部分所讨论的那样,你可以为应用程序的不同部分使用不同的服务器:数据库服务器、API 服务器和应用程序服务器。这种设置允许你在三个服务器之间分散负载,如图 2.24 所示。
图 2.24. 使用三个服务器的解耦架构:一个用于数据库,一个用于 API,一个用于应用程序代码

但这并不止于此。如果你的流量开始超过你的三个服务器,你可以拥有这些服务器的多个实例(或集群),如图 2.25 所示。
图 2.25. 你可以通过为应用程序的每个部分拥有服务器集群来扩展 MEAN 应用程序。

设置这种方法比之前的方法稍微复杂一些,因为你需要确保数据库保持准确,并且负载在服务器之间均衡。再次强调,PaaS 提供商为这种类型的架构提供了一个方便的途径。
你将通过创建一个将包含所有内容的 Express 项目来开始第三章的旅程。
摘要
在本章中,你学习了
-
如何使用在 Node.js、Express 和 MongoDB 中构建的 REST API 设计一个常见的 MEAN 堆栈架构,使用 Angular SPA
-
如何评估项目中的因素以确定单页面应用(SPA)是否适合
-
如何在 MEAN 堆栈中设计一个灵活的架构
-
构建 API 以暴露数据层的最佳实践
-
开发和生产硬件架构
第二部分. 构建 Node 网络应用程序
Node.js 是任何 MEAN 应用程序的基础,因此您将从这里开始。在整个第二部分中,您将通过使用 Node.js、Express 和 MongoDB 来构建一个数据驱动的 Web 应用程序。您将在学习过程中了解这些技术,并逐步构建应用程序,直到您拥有一个完全功能的 Node 网络应用程序。
在第三章中,您将通过创建和设置一个 MEAN 项目开始,在深入了解 Express 之前先熟悉 Express,然后在第四章中通过构建应用程序的静态版本来获得更深入的理解。在第五章中,您将使用 MongoDB 和 Mongoose 来设计和构建您需要的数据库模型。
良好的应用程序架构应包括数据 API,而不是将数据库交互与应用程序逻辑紧密耦合。在第六章中,您将使用 Express、MongoDB 和 Mongoose 创建一个 REST API,然后在第七章中将它连接回应用程序,通过从静态应用程序中消费 REST API 来实现。当您到达第二部分的结尾时,您将拥有一个使用 Node.js、MongoDB 和 Express 构建的数据驱动网站,以及一个完全功能的 REST API。
第三章. 创建和设置 MEAN 项目
本章涵盖
-
使用 npm 和 package.json 管理依赖项
-
创建和配置 Express 项目
-
设置 MVC 环境
-
添加 Twitter Bootstrap 进行布局
-
发布到实时 URL,并使用 Git 和 Heroku
在本章中,您将开始构建您的应用程序。记住,从第一章和第二章中,在整个本书中,您将构建一个名为 Loc8r 的应用程序——一个位置感知的 Web 应用程序,它显示用户附近的列表,并邀请人们登录并留下评论。
获取源代码
此应用程序的源代码位于 GitHub 上的github.com/cliveharber/gettingMean-2。每个有重大更新的章节都将有自己的分支。我们鼓励您从头开始构建,但如果您愿意,您可以从 GitHub 上的 chapter-03 分支获取本章中将要构建的代码。在终端的新文件夹中,如果您已经安装了 Git,以下两个命令将克隆它:
$ git clone -b chapter-03 https://github.com/cliveharber/
gettingMean-2.git
这为您提供了存储在 GitHub 上的代码副本。要运行应用程序,您需要使用以下命令安装一些依赖项:
$ cd gettingMean-2
$ npm install
如果其中一些内容现在还不明白,或者一些命令不起作用,请不要担心。在本章中,您将在学习过程中安装这些技术。
在 MEAN 堆栈中,Express 是 Node 网络应用程序框架。Node.js 和 Express 一起构成了整个堆栈的基础,所以你将从那里开始。在构建应用程序架构方面,图 3.1 显示了本章的重点。你将做两件事:
-
创建项目以及封装的 Express 应用程序,该应用程序将包含除数据库之外的所有内容。
-
设置主 Express 应用程序。
图 3.1. 创建封装的 Express 应用程序并开始设置主 Express 应用程序

你将从一些基础工作开始,通过查看 Express 来了解如何使用 npm 和 package.json 文件来管理依赖项和模块。你需要这些背景知识来开始并设置一个 Express 项目。
在你做任何事情之前,确保你的机器上已经安装了所有你需要的东西。当这一切都准备好了,看看如何从命令行创建新的 Express 项目以及你可以在此时指定的各种选项。
Express 很好,但你可以通过稍微调整一些东西来使其更好,并更好地了解它。这涉及到对模型-视图-控制器(MVC)架构的快速了解。这里你可以稍微深入 Express 的内部,通过修改它来获得一个清晰的 MVC 设置。
当 Express 框架按照你的意愿设置好时,你将包括 Twitter 的 Bootstrap 框架,并通过更新 Pug 模板使网站响应式。在本章的最后一步,你将使用 Heroku 和 Git 将修改后的、响应式的 MVC Express 应用程序推送到一个实时 URL。
3.1. 简要了解 Express、Node 和 npm
如前所述,Express 是 Node 的一个网络应用程序框架。在基本术语中,一个 Express 应用程序是一个恰好使用 Express 作为框架的 Node 应用程序。记得从第一章中提到的 npm 是一个包管理器,当你安装 Node 时会自动安装,这使你能够下载 Node 模块或包来扩展应用程序的功能。
但这些是如何协同工作的,以及你如何使用它们呢?这个谜题的关键部分是 package.json 文件。
3.1.1. 使用 package.json 定义包
在每个 Node 应用程序中,你都应该在应用程序的根目录中有一个名为 package.json 的文件。此文件可以包含有关项目的一些元数据,包括它运行所依赖的包。以下列表显示了一个你可能在 Express 项目的根目录中找到的示例 package.json 文件。
列表 3.1. 新 Express 项目中的示例 package.json 文件
{
"name": "application-name", ***1***
"version": "0.0.0", ***1***
"private": true, ***1***
"scripts": { ***1***
"start": "node ./bin/www" ***1***
}, ***1***
"dependencies": ***2***
"body-parser": "~1.18.3", ***2***
"cookie-parser": "~1.4.3", ***2***
"debug": "~4.1.0", ***2***
"express": "⁴.16.4", ***2***
"morgan": "¹.9.1", ***2***
"pug": "².0.3", ***2***
"serve-favicon": "~2.5.0" ***2***
} ***2***
}
-
1 定义应用程序的各种元数据
-
2 应用程序运行所需的包依赖项
此列表是文件的完整内容,因此它并不特别复杂。文件顶部的各种元数据后面跟着依赖项部分。在这个 Express 项目的默认安装中,Express 运行需要许多依赖项,但您不必担心每个依赖项的作用。Express 本身是模块化的,这样您可以单独添加组件或升级它们。
3.1.2. 在 package.json 中处理依赖项版本
在每个依赖项的名称旁边是应用程序将使用的版本号。请注意,它们前面带有 tilde (~) 或 caret (^)。
查看 Express 4.16.3 的依赖项定义,它指定了三个级别的特定版本:
-
主版本 (4)
-
次要版本 (16)
-
补丁版本 (3)
在整个版本号前加上 ~ 就像用通配符替换了补丁版本,这意味着应用程序将使用可用的最新补丁版本。同样,在版本前加上一个 caret (^) 就像用通配符替换了次要版本。这已成为最佳实践,因为补丁和次要版本应只包含不会对应用程序产生任何影响的修复。但是,当进行破坏性更改时,会发布新的主要版本,因此您希望避免自动使用这些版本的后续版本,以防破坏性更改影响您的应用程序。如果您发现一个违反这些规则的模块,可以通过移除任何前缀来轻松指定要使用的确切版本。请注意,出于这个原因,始终指定完整版本而不使用通配符是良好的实践:您始终有一个您 知道 可以正常工作的特定版本的参考。
3.1.3. 使用 npm 安装 Node 依赖项
任何 Node 应用程序或模块都可以在 package.json 文件中定义依赖项。安装它们很容易,并且无论应用程序或模块如何,都是用相同的方式进行安装。
在与 package.json 文件相同的文件夹中使用终端提示符,运行以下命令:
$ npm install
此命令告诉 npm 安装 package.json 文件中列出的所有依赖项。当您运行它时,npm 下载所有列出的依赖项包,并将它们安装到应用程序的特定文件夹中,该文件夹称为 node_modules。图 3.2 说明了三个关键部分。
图 3.2. 当您运行 npm install 终端命令时,package.json 文件中定义的 npm 模块将被下载并安装到应用程序的 node_modules 文件夹中。

npm 将每个包安装到其自己的子文件夹中,因为每个包本身就是一个 Node 包。因此,每个包也有自己的 package.json 文件,定义了元数据,包括特定的依赖项。一个包拥有自己的 node_modules 文件夹是很常见的。尽管如此,您不必担心手动安装所有嵌套依赖项,因为这项任务由原始的 npm install 命令处理。
向现有项目添加更多包
你可能一开始就不会有一个项目的完整依赖项列表。更有可能的是,你会从几个关键依赖项开始,这些依赖项你知道你需要,也许还有一些你在工作流程中总是使用的依赖项。
使用 npm,你可以随时轻松地向应用程序添加更多包。找到你想要安装的包的名称,在同一文件夹中打开 package.json 文件所在的命令提示符,然后运行一个简单的命令,例如:
$ npm install --save package-name
使用此命令,npm 将下载并安装 node_modules 文件夹中的新包。--save标志告诉 npm 将此包添加到 package.json 文件中的依赖项列表中。从 npm 版本 5 开始,--save标志不再需要,因为 NPM 会自动将更改保存到 package.json 文件中。我们在这里添加它是为了完整性。当运行此命令时,npm 会生成一个 package-lock.json 文件,以在环境之间维护依赖项的版本,这在从开发环境部署到实时服务器时非常有用。
更新包到较新版本
npm 下载并重新安装现有包的唯一情况是你升级到新版本。当你运行npm install时,npm 会遍历所有依赖项并检查以下内容:
-
package-lock.json 文件中定义的版本(如果存在)或 package.json(如果不存在)
-
npm 上的最新匹配版本(这可能与你使用了
~或^不同) -
node_modules 文件夹中模块的版本(如果有的话)
如果你的安装版本与 package.json(或 package-lock.json)文件中的定义不同,npm 将下载并安装定义的版本。同样,如果你正在使用通配符,并且有更晚的匹配版本可用,npm 将下载并安装它以替换之前的版本。
在掌握这些知识的基础上,你可以开始创建你的第一个 Express 项目。
3.2. 创建 Express 项目
所有旅程都必须有一个起点,对于构建 MEAN 应用程序来说,这个起点就是创建一个新的 Express 项目。要创建一个 Express 项目,你需要在你的开发机器上安装以下五个关键组件:
-
Node 和 npm
-
全局安装的 Express 生成器
-
Git
-
Heroku
-
适合的命令行界面(CLI)或终端
3.2.1. 安装组件
如果你还没有安装 Node、npm 或 Express 生成器,请参阅附录 A 中的说明和在线资源指南。所有这些都可以安装在 Windows、macOS 和所有主流 Linux 发行版上。
到本章结束时,你还将使用 Git 来管理 Loc8r 应用程序的源代码控制,并将其推送到由 Heroku 托管的实时 URL。请参阅附录 B,其中将指导你设置 Git 和 Heroku。
根据您的操作系统,您可能需要安装一个新的 CLI 或终端。请参阅附录 B 以了解此要求是否适用于您。
注意
在本书中,我们经常将 CLI 称为终端。当我们说“在终端中运行此命令”时,意味着在您使用的任何 CLI 中运行它。当终端命令作为本书中的代码片段包含时,它们以 $ 开头。您不应在终端中键入此符号;它只是用来表示命令行语句。例如,如果您输入 echo 命令 $ echo 'Welcome to Getting MEAN',则键入 echo 'Welcome to Getting MEAN'。
3.2.2. 验证安装
要创建一个新的 Express 项目,你必须已安装 Node 和 npm,并且还必须全局安装 Express 生成器。您可以通过在终端中检查版本号来验证,使用以下命令:
$ node --version
$ npm --version
$ express --version
每个这些命令都应该在终端输出一个版本号。如果其中之一失败,请转到附录 A 以获取如何重新安装的详细信息。
3.2.3. 创建项目文件夹
假设一切顺利,首先在您的机器上创建一个名为 loc8r 的新文件夹。这个文件夹可以放在您的桌面上、您的文档中或 Dropbox 文件夹中;位置不重要,只要您有对该文件夹的完整读写权限即可。
西蒙在他的 Dropbox 文件夹中进行了大量的 MEAN 开发,以便他的工作可以立即备份并在他的任何机器上访问。然而,如果您处于企业环境中,这种方法可能不适合您,因此请创建您认为最好的文件夹。
3.2.4. 配置 Express 安装
Express 项目是通过命令行安装的,配置是通过你使用的命令的参数传递的。如果你不熟悉使用命令行,不要担心;本书中我们将要讨论的内容并不特别复杂,而且都很容易记住。一旦你开始使用它,你可能会爱上它使某些操作变得如此快速。
您可以使用简单的命令在文件夹中安装 Express(但不要现在这样做):
$ express
此命令使用默认设置在当前文件夹中安装框架。这一步可能是一个好的开始,但首先看看一些配置选项。
创建 Express 项目时的配置选项
以这种方式创建 Express 项目时可以配置什么?您可以指定以下内容:
-
使用哪个 HTML 模板引擎
-
使用哪个 CSS 预处理器
-
是否创建一个 .gitignore 文件
默认安装使用 Jade 模板引擎,但它没有 CSS 预处理或会话支持。你可以指定一些选项,如表 3.1 中所述。
表 3.1. 创建新的 Express 项目的命令行配置选项
| 配置命令 | 影响 |
|---|---|
| --css=less|stylus | 根据你在命令中输入的内容,将 CSS 预处理器添加到你的项目中,无论是 Less 还是 Stylus |
| --view=ejs|hbs|pug | 根据你输入的选项,将 HTML 模板引擎从 Jade 更改为 EJS、Handlebars 或 Pug |
| --git | 在目录中添加一个 .gitignore 文件 |
你在这里不会这样做,但如果你想创建一个使用 Less CSS 预处理器和 Handlebars 模板引擎并包含 .gitignore 文件的项目,你将在终端中运行以下命令:
$ express --css=less --view=hbs --git
为了使你的项目保持简单,你不会使用 CSS 预处理器,因此你可以坚持使用默认的纯 CSS。但是,你需要使用一个模板引擎,所以在下文中,你会快速浏览一下选项。
不同的模板引擎
当你以这种方式使用 Express 时,有一些模板选项可供选择,包括 Jade、EJS、Handlebars 和 Pug。模板引擎的基本工作流程是创建 HTML 模板,包括数据占位符,然后传递一些数据。然后引擎将模板和数据一起编译,以创建浏览器将接收的最终 HTML 标记。
所有引擎都有自己的优点和特性,如果你已经有一个偏好的引擎,那很好。在这本书中,你将使用 Pug。Pug 功能强大,提供了你将要需要的所有功能。Pug 是 Jade 的下一代;由于商标问题,Jade 的创造者不得不将其重命名,他们选择了 Pug。Jade 仍然存在,所以现有的项目不会中断,但所有的新版本都使用 Pug 命名。Jade 曾经(并且仍然是)Express 的默认模板引擎,所以你会发现大多数在线的示例和项目都使用它,这意味着熟悉语法是有帮助的。最后,Jade 和 Pug 的最小化风格使它们非常适合在书中展示代码示例。
快速了解 Pug
与其他模板引擎相比,Pug 很不寻常,因为它在模板中不包含 HTML 标签。相反,Pug 采用了一种相当简约的方法,使用标签名称、缩进和受 CSS 启发的引用方法来定义 HTML 的结构。例外的是 <div> 标签。因为它非常常见,如果模板中省略了标签名称,Pug 假设你想要一个 <div>。
小贴士
Pug 模板必须使用空格缩进,而不是制表符。
以下代码片段显示了一个简单的 Pug 模板示例:
#banner.page-header ***1***
h1 My page ***1***
p.lead Welcome to my page ***1***
- 1 Pug 模板不包含 HTML 标签
此代码片段显示了编译后的输出:
<div id="banner" class="page-header"> ***1***
<h1>My page</h1> ***1***
<p class="lead">Welcome to my page</p> ***1***
</div> ***1***
- 1 编译后的输出是可识别的 HTML
从输入和输出的第一行,你应该能够看到:
-
如果你没有指定标签名称,将创建一个
<div>。 -
在 Pug 中
#banner变成 HTML 中的id="banner"。 -
在 Pug 中
.page-header变成 HTML 中的class="page-header"。
注意,Pug 中的缩进很重要,因为它定义了 HTML 输出的嵌套。记住,缩进必须使用空格,而不是制表符!
回顾一下,你不需要 CSS 预处理器,但确实想要 Pug 模板引擎。那么.gitignore 文件呢?
.gitignore 文件的快速介绍
.gitignore 文件是一个简单的配置文件,位于项目文件夹的根目录。此文件指定 Git 命令应忽略哪些文件和文件夹。本质上,它说,“假装这些文件不存在,不要跟踪它们”,这意味着它们不会出现在源控制中。
常见的例子包括日志文件和 node_modules 文件夹。日志文件不需要上传到 GitHub 供每个人查看,而且你的 Node 依赖项应该在应用程序下载时从 npm 安装。你将在第 3.5 节中使用 Git,所以请让 Express 生成器为你创建一个文件。
在你掌握这些基础知识之后,是时候创建一个项目了。
3.2.5. 创建 Express 项目并尝试运行
你已经知道了创建 Express 项目的基本命令,并决定使用 Pug 模板引擎。你还将让它为你生成一个.gitignore 文件。现在创建一个新的项目。在第 3.2.3 节中,你应该创建了一个名为 loc8r 的新文件夹。在终端中导航到这个文件夹,并运行以下命令:
$ express --view=pug --git
此命令在 loc8r 文件夹内创建了一系列文件夹和文件,这些文件构成了你的 Loc8r 应用程序的基础。但你现在还没有完全准备好。接下来,你需要安装依赖项。你可能还记得,你可以通过在 package.json 文件所在的文件夹中的终端提示符下运行以下命令来完成此操作:
$ npm install
一旦运行,你的终端窗口就会亮起,显示所有正在下载的内容。当它完成后,应用程序就准备好测试了。
尝试运行
确保一切按预期工作。在第 3.2.6 节中,我们将向您展示运行项目的更好方法。
在终端中,在 loc8r 文件夹中,运行以下命令(但如果你的应用程序在一个不同名称的文件夹中,相应地替换loc8r):
$ DEBUG=loc8r:* npm start
你应该会看到类似的确认信息:
loc8r:server Listening on port 3000 +0ms
这个确认意味着 Express 应用程序正在运行。你可以通过打开浏览器并转到 localhost:3000 来看到它在行动。我们希望你会看到类似于图 3.3 中的截图。
图 3.3. 纯粹的 Express 项目的着陆页

虽然这并不完全是开创性的东西,但将 Express 应用程序运行起来并在浏览器中工作是非常容易的,对吧?
如果你现在回到终端,你应该会看到一些日志语句确认页面已被请求,并且样式表已被请求。为了更好地了解 Express,看看这里发生了什么。
Express 如何处理请求
默认的 Express 着陆页很简单。页面包含少量 HTML,其中一些文本内容是通过 Express 路由推送到数据中的。还有一个 CSS 文件。终端中的日志应该确认这是 Express 请求并返回给浏览器的内容。但这是如何实现的呢?
关于 Express 中介件
app.js 文件中包含一些以app.use开头的行。这些行被称为中介件。当请求进入应用程序时,它会依次通过每一块中介件。每一块中介件可能会也可能不会对请求进行操作,但它总是传递给下一块,直到达到应用程序逻辑本身,然后返回一个响应。
以app.use(express.cookieParser());为例。这一行接收一个传入的请求,解析出任何 cookie 信息,并以一种便于在控制器代码中引用的方式将数据附加到请求上。
你现在不需要知道每块中介件具体做什么,但随着你构建应用程序,你可能会发现自己正在添加到这个列表中。
所有对 Express 服务器的请求都会通过在 app.js 文件中定义的中介件(见侧边栏“关于 Express 中介件”)。除了做其他事情外,默认的中介件会查找静态文件的路径。当中介件将路径与文件匹配时,Express 会异步返回该文件,确保 Node.js 进程不会被此操作占用,从而阻止其他操作。当请求通过所有中介件后,Express 会尝试将请求的路径与定义的路由匹配。我们将在 3.3.3 节(#ch03lev2sec12)中更详细地介绍这个主题。
图 3.4 展示了这个流程,使用的是来自图 3.3 的默认 Express 主页的示例。图 3.4 中的流程显示了分别发出的请求以及 Express 如何不同地处理它们。两个请求都作为第一个动作通过中介件,但结果却不同。
图 3.4. Express 在响应默认着陆页请求时经历的关键交互和过程。HTML 页面由 Node 处理以编译数据和视图模板,CSS 文件则从静态文件夹异步提供。

3.2.6. 重启应用程序
Node 应用程序在运行前会进行编译,因此如果你在应用程序运行时修改了代码,这些更改将不会在 Node 进程停止并重新启动之前被捕获。请注意,这仅适用于应用程序代码;Jade 模板、CSS 文件和客户端 JavaScript 都可以在运行时更新。
重新启动 Node 进程是一个两步过程。首先,你必须通过按 Ctrl-C 在终端中停止正在运行的过程。然后,你必须使用之前的相同命令在终端中再次启动该过程:DEBUG=loc8r:* npm start。
这个过程听起来并不成问题,但当你积极开发和测试应用程序时,每次想要检查更新时都必须执行这两个步骤,这会变得相当令人沮丧。幸运的是,有一个更好的方法。
使用 nodemon 自动重启应用程序
一些服务已被开发出来以监控应用程序代码,并在检测到更改时重启进程。其中一个这样的服务,也是你将在本书中使用的服务,是 nodemon。nodemon 包装 Node 应用程序,除了监控更改外,不会造成干扰。
要使用 nodemon,首先全局安装它,就像你安装 Express 一样。在终端中使用 npm:
$ npm install -g nodemon
安装完成后,你将能够在任何地方使用 nodemon。使用它很简单。你不需要输入 node 来启动应用程序,而是输入 nodemon。所以,确保你在终端中位于 loc8r 文件夹中,并且已经停止了 Node 进程(如果它仍在运行),然后输入以下命令:
$ nodemon
你应该在终端中看到几行额外的输出,确认 nodemon 正在运行,并且它已经启动了 node ./bin/www。如果你回到浏览器并刷新,你应该看到应用程序仍然在那里。
注意
nodemon 仅用于简化开发环境中的开发过程,不应在实时生产环境中使用。像 pm2 或 foreman 这样的项目是为生产使用而设计的。
使用提供的 Docker 环境
每一章都附带一个 Dockerfile 设置。前往附录 B 查看如何安装和使用 Docker 容器。你不必使用 Docker 就能从这本书中受益;它已被添加为便利功能。
3.3. 修改 Express 以支持 MVC
首先,什么是 MVC 架构?MVC 架构将数据(模型)、显示(视图)和应用程序逻辑(控制器)分开。这种分离旨在消除组件之间的紧密耦合,从理论上讲,使代码更易于维护和重用。一个额外的好处是,这些组件非常适合你的快速原型开发方法,并允许你一次专注于一个方面,正如我们讨论 MEAN 栈的每个部分时那样。
整本书都是关于 MVC 的细微差别,但在这里我们不会深入探讨。我们将保持对 MVC 的讨论在较高层次,并展示如何使用 Express 来构建你的 Loc8r 应用程序。
3.3.1. MVC 的鸟瞰图
你构建的大多数应用程序或网站都是设计用来接收传入的请求,对其进行处理,并返回响应。在简单的层面上,MVC 架构中的这个循环是这样工作的:
-
一个请求进入应用程序。
-
请求被路由到控制器。
-
如果需要,控制器会向模型发出请求。
-
模型响应控制器的请求。
-
控制器将视图和数据合并以形成响应。
-
控制器将生成的响应发送给原始请求者。
实际上,根据您的配置,控制器可能会在向访客发送响应之前编译视图。效果是相同的,所以请记住这个简单的流程,作为您 Loc8r 应用程序中将要发生的事情的视觉参考。参见 图 3.5 以了解这个循环的说明。
图 3.5. 基本 MVC 架构的请求-响应流程

图 3.5 强调了 MVC 架构的各个部分,并展示了它们是如何相互连接的。它还说明了需要一个路由机制,以及模型、视图和控制器组件。
现在您已经看到了您希望 Loc8r 应用程序的基本流程如何工作,是时候修改 Express 设置以使其发生。
3.3.2. 修改文件夹结构
如果您查看位于 loc8r 文件夹中的新创建的 Express 项目内部,您应该会看到一个包括视图文件夹甚至路由文件夹的文件结构,但没有提到模型或控制器。与其在应用程序的根级别添加一些新文件夹而使事情变得混乱,不如通过创建一个新文件夹来整理所有 MVC 架构。遵循以下三个快速步骤:
-
创建一个名为 app_server 的新文件夹。
-
在 app_server 中,创建两个新的文件夹,分别命名为 models 和 controllers。
-
将视图和路由文件夹从应用程序的根目录移动到 app_server 文件夹。
图 3.6 阐述了这些更改,并显示了修改前后的文件夹结构。
图 3.6. 将 Express 项目的文件夹结构修改为 MVC 架构

现在您在应用程序中有一个明显的 MVC 设置,这使得分离您的关注点更容易。但如果你现在尝试运行应用程序,它将不会工作,因为您刚刚破坏了它。所以修复它。Express 不了解您已经添加了一些新文件夹或对它们有何用途有任何想法,所以您需要告诉它。
3.3.3. 使用重新定位的视图和路由文件夹
您需要做的第一件事是告诉 Express 您已经移动了视图和路由文件夹,因为 Express 将会在它们旧的位置寻找它们。
使用新的视图文件夹位置
Express 将会寻找 /views,但它需要寻找 /app_server/views。修改路径很简单。在 app.js 中,找到以下行:
app.set('views', path.join(__dirname, 'views'));
修改如下(加粗部分):
app.set('views', path.join(__dirname, 'app_server', 'views'));
您的应用程序仍然无法工作,因为您已经移动了路由,所以您也需要告诉 Express 关于它们。
使用新的路由文件夹位置
Express 将会寻找 /routes,但它需要寻找 /app_server/routes。修改这个路径也很简单。在 app.js 中,找到以下行:
const indexRouter = require('./routes/index');
const usersRouter = require('./routes/users');
将这些行更改为以下内容(加粗部分为修改):
const indexRouter = require('./app_server/routes/index');
const usersRouter = require('./app_server/routes/users');
在 ES2015 中定义变量
ES2015 中最基本的变化之一是废弃了 var 关键字来定义变量。它仍然有效,但你应该使用这两个新关键字之一:const 和 let。使用 const 定义的变量在代码的后续部分不能被更改,而使用 let 定义的变量可以被更改。
最佳实践是使用 const 来定义变量,除非它们的值将要改变。app.js 中的所有 var 实例都可以更改为 const。我们已经在本书的源代码中这样做过;你也可以这样做。
另有一点需要记住的是,const 和 let 是块级变量初始化器,而 var 是上下文级变量初始化器。如果这些术语对你来说毫无意义,请阅读附录 D,该附录可在电子书或从 manning.com 在线获取。
注意,你还将 var 更改为 const 以升级到 ES2015。如果你对这一概念感到陌生,请查看侧边栏“在 ES2015 中定义变量”。如果你保存更改并再次运行应用程序,你会发现它再次工作!
3.3.4. 从路由中分离控制器
在默认的 Express 设置中,控制器是路由的一部分,但你希望将它们分离出来。控制器应管理应用程序逻辑,而路由应将 URL 请求映射到控制器。
理解路由定义
要了解路由是如何工作的,请查看用于提供默认 Express 主页的路由。在 app_server/routes 的 index.js 内,你应该看到以下代码片段:
/* GET homepage. */
router.get('/', function(req, res) { ***1***
res.render('index', { title: 'Express' }); ***2***
});
-
1 路由器查找 URL 的位置
-
2 控制器内容,尽管目前非常基础
在代码的 1 处你可以看到 router.get('/')。路由器内部检查映射到主页 URL 路径的 GET 请求,该路径是 '/'。运行代码 1 的匿名函数是控制器。这个基本示例没有任何应用程序代码。所以 1 和 2 是你想要在这里分离的部分。
不要直接将控制器代码放入控制器文件夹,而是首先在同一个文件中测试这种方法。为此,你可以将路由定义中的匿名函数定义为命名函数。然后,将此函数的名称作为回调传递到路由定义中。这两个步骤在下面的列表中都有,你可以将其放在 app_server/routes/index.js 内部。
列表 3.2. 将控制器代码从路由中移除:步骤 1
const homepageController = (req, res) => { ***1***
res.render('index', { title: 'Express' }); ***1***
}; ***1***
/* GET homepage. */
router.get('/', homepageController); ***2***
-
1 为箭头函数命名
-
2 在路由定义中将函数名称作为回调传递
如果你现在刷新你的主页,它应该仍然像以前一样工作。你没有改变网站工作的任何方面——只是朝着分离关注点迈出一步。
理解 res.render
你将在第四章中更详细地了解这个主题,但render是 Express 函数,用于将视图模板编译为发送给浏览器的 HTML 响应。render方法接受视图模板的名称和一个 JavaScript 数据对象,如下所示:

注意,模板文件不需要文件扩展名后缀,所以 index.pug 可以引用为 index。你也不需要指定视图文件夹的路径,因为你已经在 Express 的主设置中这样做过了。
现在你已经清楚了解路由定义的工作原理,是时候将控制器代码放到合适的位置了。
将控制器从路由文件中移出
在 Node 中,要引用外部文件中的代码,你需要在你的新文件中创建一个模块,然后在原始文件中require它。有关此过程的总体原则,请参阅侧边栏“创建和使用 Node 模块”。
创建和使用 Node 模块
将一些代码从 Node 文件中提取出来创建外部模块,幸运的是,这个过程很简单。本质上,你为你的代码创建一个新文件,选择你想要公开给原始文件的部分,然后在原始文件中require你的新文件。
在你的新模块文件中,你可以使用module.exports方法公开你想要的代码部分,如下所示:
module.exports = function () {
console.log("This is exposed to the requester");
};
然后,你可以在你的主文件中这样require它:
require('./yourModule');
如果你希望你的模块公开具有单独命名的函数,你可以通过以下方式在你的新文件中定义它们:
module.exports.logThis = function (message){
console.log(message);
};
更好的做法是在文件末尾定义一个命名函数并导出它。这让你可以在一个地方公开所有需要的函数,为你的未来(或后续的开发者)创建一个方便的列表。
const logThis = function (message) {
console.log(message);
};
module.exports = {
logThis
};
要在原始文件中引用此内容,你需要将你的模块分配给一个变量名,然后调用该方法。你可以在主文件中输入以下内容:
const yourModule = require('./yourModule');
yourModule.logThis("Hooray, it works!");
此代码将你的新模块分配给变量yourModule。导出的函数logThis现在作为yourModule的方法可用。
注意,当使用require函数时,你不需要指定文件扩展名。require函数会查找几件事情:一个 npm 模块、同名的 JavaScript 文件,或者给定名称文件夹内的 index.js 文件。
首件事是创建一个文件来保存控制器代码。在 app_server/controllers 中创建一个名为 main.js 的新文件。在这个文件中,创建并导出一个名为index的方法,并使用它来存放res.render代码,如下所示。
列表 3.3. 在 app_server/controllers/main.js 中设置主页控制器
/* GET homepage */
const index = (req, res) => { ***1***
res.render('index', { title: 'Express' }); ***2***
};
module.exports = { ***3***
index ***3***
}; ***3***
-
1 创建一个 index 函数
-
2 包含主页的控制器代码
-
3 公开 index 函数作为方法
导出控制器就这些了。下一步是在路由文件中require这个控制器模块,这样你就可以在路由定义中使用暴露的方法。以下列表显示了app_server/routes中的 index.js 文件应该看起来像什么。
列表 3.4. 更新路由文件以使用外部控制器
const express = require('express');
const router = express.Router();
const ctrlMain = require('../controllers/main'); ***1***
/* GET homepage. */
router.get('/', ctrlMain.index); ***2***
module.exports = router;
-
1 require 主控制器文件
-
2 在路由定义中引用控制器的 index 方法
这段代码通过“require”控制器文件1并在router.get函数的第二参数中引用控制器函数2来将路由链接到新的控制器。
现在你已经有了路由和控制器架构,如图 3.7 所示,其中 app.js requires routes/index.js,而 routes/index.js 又requires controllers/main.js。如果你现在在浏览器中测试,你应该会看到默认的 Express 主页再次正确显示。
图 3.7. 将控制器逻辑与路由定义分离

目前一切都已经用 Express 设置好了,所以差不多是时候开始构建过程了。但是你还需要做几件事情。第一件事是将 Twitter Bootstrap 添加到应用程序中。
3.4. 导入 Bootstrap 以实现快速、响应式布局
如第一章中所述,你的 Loc8r 应用程序使用 Twitter 的 Bootstrap 框架来加速响应式设计的开发。你还将通过添加一些字体图标和自定义样式来使应用程序脱颖而出。目的是帮助你快速构建应用程序,而不会因为开发响应式界面的语义而分心。
3.4.1. 下载 Bootstrap 并将其添加到应用程序中
有关下载 Bootstrap、下载字体图标(通过 Font Awesome)、创建自定义样式以及将文件添加到项目文件夹的说明请见附录 B。请注意,你使用的是 Bootstrap 4.1。一个关键点是下载的文件都是直接发送到浏览器的静态文件;它们不需要 Node 引擎的任何处理。你的 Express 应用程序已经有一个用于此目的的文件夹:public 文件夹。当你准备好时,public 文件夹应该看起来像图 3.8。
图 3.8. 添加 Bootstrap 后 Express 应用程序中 public 文件夹的结构

Bootstrap 还需要 jQuery 和 Popper.js 来使一些交互组件正常工作。因为它们不是你应用程序的核心,所以你将在下一步从内容分发网络(CDN)中引用它们。
3.4.2. 在应用程序中使用 Bootstrap
现在所有的 Bootstrap 零件都放在了应用程序中,是时候将其连接到前端了,这意味着查看 Pug 模板。
使用 Pug 模板
Pug 模板通常有一个主布局文件,该文件为其他 Pug 文件定义了扩展区域。当你构建一个网络应用程序时,这非常有意义,因为许多屏幕或页面具有相同的底层结构,但顶部内容不同。
这是 Pug 在默认 Express 安装中的样子:如果你查看应用程序中的 views 文件夹,你会看到三个文件——layout.pug、index.pug 和 error.pug。index.pug 文件控制着应用程序索引页的内容。打开它,你会看到里面没有多少内容。整个内容在下面的列表中显示。
列表 3.5. 完整的 index.pug 文件
extends layout ***1***
block content ***2***
h1= title ***3***
p Welcome to #{title.} ***3***
-
1 声明此文件正在扩展布局文件
-
2 声明以下部分将进入布局文件中的 content 区域
-
3 输出 h1 和 p 标签到内容区域
这里发生的事情比表面看起来要多。在文件顶部有一个声明,表明此文件是另一个文件的扩展 1——在这种情况下,是布局文件。接下来是一个定义属于布局文件特定区域(称为 content 的区域)的代码块的声明 2。最后,是 Express 索引页上显示的最小内容:一个 <h1> 标签和一个 <p> 标签 3。
这里没有对 <head> 或 <body> 标签的引用,也没有任何样式表引用。这些都在布局文件中处理,所以你想要去的地方是添加全局脚本和样式表到应用程序中。打开 layout.pug,你应该会看到类似于以下列表的内容。
列表 3.6. 默认布局.pug 文件
doctype html
html
head
title= title
link(rel='stylesheet', href='/stylesheets/style.css')
body
block content ***1***
- 1 空命名块可以被其他模板使用
这显示了用于默认 Express 安装的基本索引页的布局文件。有一个 head 部分和一个 body 部分,在 body 部分内部有一个没有任何内容的 block content 行。这个名为 block 的内容可以被其他 Pug 模板引用,例如 列表 3.5 中的 index.pug 文件。当视图被编译时,index 文件中的 block content 被推入布局文件的 block content 区域。
将 Bootstrap 添加到整个应用程序中
如果你想将一些外部引用文件添加到整个应用程序中,在当前设置中使用布局文件是有意义的。在 layout.pug 中,你需要完成以下四件事:
-
引用 Bootstrap 和 Font Awesome CSS 文件。
-
引用 Bootstrap JavaScript 文件。
-
引用 jQuery 和 Popper.js,这是 Bootstrap 所需要的。
-
添加视口元数据,以便页面在移动设备上良好缩放。
CSS 文件和视口元数据都应该在文档的 head 部分,而两个脚本文件应该在 body 部分的末尾。以下列表显示了 layout.pug 中的所有这些内容,新行以粗体显示。
列表 3.7. 包含 Bootstrap 引用的更新布局.pug 文件
doctype html
html
head
meta(name='viewport', content='width=device-width,
initial-scale=1.0') ***1***
title= title
link(rel='stylesheet', href='/stylesheets/bootstrap.min.css') ***2***
link(rel='stylesheet', href='/stylesheets/all.min.css') ***2***
link(rel='stylesheet', href='/stylesheets/style.css')
body
block content
script(src='https://code.jquery.com/jquery-3.3.1.slim.min.js', ***3***
integrity='sha384- ***3***
q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo', ***3***
crossorigin='anonymous') ***3***
script(src='https://cdnjs.cloudflare.com/ajax/libs/ ***3***
popper.js/1.14.3/umd/popper.min.js',integrity='sha384- ***3***
ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49', ***3***
crossorigin='anonymous') ***3***
script(src='/javascripts/bootstrap.min.js') ***4***
-
1 设置视口元数据,以在移动设备上更好地显示
-
2 包含 Bootstrap 和 Font Awesome CSS
-
3 引入 jQuery 和 Popper,Bootstrap 所需。确保所有的脚本标签都有相同的缩进。
-
4 引入 Bootstrap JavaScript 文件
完成这些后,你创建的任何新模板都会自动包含 Bootstrap,并且可以在移动设备上缩放——当然,前提是你的新模板扩展了布局模板。如果你在这个阶段遇到任何问题或意外结果,请记住 Pug 对缩进、间距和换行很敏感。所有缩进都必须使用空格来完成,以在 HTML 输出中获得正确的嵌套。
小贴士
如果你按照附录 B 中的说明进行操作,你也会在 /public/stylesheets 目录下的 style.css 文件中找到一些自定义样式,以防止默认 Express 样式覆盖 Bootstrap 文件并帮助你获得你想要的样式。
现在你已经准备好测试了。
验证其是否工作
如果应用程序还没有用 nodemon 运行,请启动它,并在浏览器中查看。内容没有变化,但外观应该有所改变。你应该有一个看起来像图 3.9 的东西。
图 3.9. Bootstrap 和你的样式对默认 Express 索引页的影响

如果你的样子不像这样,请确保你已经按照附录 B 中概述的方式添加了自定义样式。记住,你可以从 GitHub 上的 chapter-03 分支获取到目前为止的应用程序源代码。在终端的新文件夹中,使用以下命令克隆它:
$ git clone -b chapter-03 https://github.com/cliveharber/
gettingMean-2.git
现在你已经在本地有了些东西在运行。在下一节中,你将了解如何将其部署到实时生产服务器。
3.5. 将其部署到 Heroku
Node 应用程序的一个常见问题是将它们部署到实时生产服务器。你将尽早解决这个问题,并将 Loc8r 应用程序直接推送到实时 URL。随着你迭代和构建它,你可以继续推送更新。对于原型设计,这种方法很棒,因为它使得向他人展示你的进度变得容易。
如第一章中所述,有一些 PaaS 提供商,如 Google Cloud Platform、Nodejitsu、OpenShift 和 Heroku。这里你将使用 Heroku,但没有任何阻止你尝试其他选项的理由。接下来,你将启动 Heroku 并运行它,然后通过几个基本的 Git 命令将你的应用程序部署到实时服务器。
3.5.1. 设置 Heroku 的准备工作
在您可以使用 Heroku 之前,您需要注册一个免费账户并在您的开发机器上安装 Heroku CLI。附录 B 提供了更多关于如何进行此操作的信息。您还需要一个兼容 bash 的终端;Mac 用户的默认终端是好的,但 Windows 用户的默认 CLI 不行。如果您使用 Windows,您需要下载类似 GitHub 终端的东西,它是 GitHub 桌面应用程序的一部分。当您设置好一切后,您可以继续准备将应用程序推送到线上。
更新 package.json
Heroku 可以在各种类型的代码库上运行应用程序,因此您需要告诉它您的应用程序正在运行什么。除了告诉它您正在使用 npm 作为包管理器运行 Node 应用程序之外,您还需要告诉它您正在运行哪个版本,以确保生产环境设置与开发环境设置相同。
如果您不确定您正在运行哪个版本的 Node 和 npm,您可以使用几个终端命令来找出:
$ node --version
$ npm --version
目前,这些命令分别返回v11.0.0和6.4.1。使用~语法添加一个用于次要版本的通配符,如您之前所见,您需要将这些添加到 package.json 文件中的新 engines 部分。完整的更新后的 package.json 文件如下所示,其中添加的部分用粗体表示。
列表 3.8. 在 package.json 中添加 engines 部分
{
"name": "Loc8r",
"version": "0.0.1",
"private": true,
"scripts": {
"start": "node ./bin/www"
},
"engines": { ***1***
"node": ">=11.0.0", ***1***
"npm": ">=6.4.0" ***1***
}, ***1***
"dependencies": {
"body-parser": "~1.18.3",
"cookie-parser": "~1.4.3",
"debug": "~3.1.0",
"express": "~4.16.3",
"morgan": "~1.9.0",
"pug": "~2.0.0-beta11",
"serve-favicon": "~2.5.0"
}
}
- 1 在 package.json 中添加 engines 部分,以告诉 Heroku 您的应用程序所在的平台以及要使用的版本
当代码被推送到 Heroku 时,这段代码会告诉 Heroku 您的应用程序使用 Node 11 的最新次要版本和 npm 6 的最新次要版本。
创建 Procfile
package.json 文件告诉 Heroku 该应用程序是一个 Node 应用程序,但没有告诉它如何启动它。对于这个任务,您需要一个 Procfile,它用于声明应用程序使用的进程类型以及启动它们的命令。
对于 Loc8r,您需要一个 Web 进程,并且希望它运行 Node 应用程序。在应用程序的根目录中创建一个名为 Procfile 的文件。(文件名区分大小写,没有文件扩展名。)在 Procfile 中输入以下行:
web: npm start
当代码被推送到 Heroku 时,此文件会告诉 Heroku 应用程序需要一个 Web 进程,并且应该运行npm start。
使用 Heroku Local 进行本地测试
Heroku CLI 附带一个名为 Heroku Local 的实用工具。您可以使用此实用工具验证您的设置,在将应用程序推送到 Heroku 之前在本地运行您的应用程序。如果应用程序当前正在运行,请按终端窗口中运行进程的 Ctrl-C 停止它。然后,在终端窗口中,确保您位于应用程序文件夹中,并输入以下命令:
$ heroku local
如果设置一切顺利,这个命令将再次在 localhost 上启动应用程序,但这次是在不同的端口:5000。你在终端中获得的确认信息应该是这样的:
16:09:02 web.1 | > loc8r@0.0.1 start /path/to/your/application/folder
16:09:02 web.1 | > node ./bin/www
你可能还会看到警告“未找到 ENV 文件”。在这个阶段,这条消息没有什么好担心的。如果你打开浏览器并访问 localhost:5000(注意端口号是 5000 而不是 3000),你应该会看到应用程序再次运行起来。
现在你已经知道设置是有效的,是时候将你的应用程序推送到 Heroku 了。
3.5.2. 使用 Git 将网站上线
Heroku 使用 Git 作为部署方法。如果你已经使用 Git,你会喜欢这种方法;如果你还没有,你可能会对它感到有点不安,因为 Git 的世界可能很复杂。但事实并非如此,当你开始使用时,你也会喜欢这种方法!
在 Git 中存储应用程序
第一步是在你的本地机器上使用 Git 存储应用程序。这个过程涉及以下三个步骤:
-
将应用程序文件夹初始化为 Git 仓库。
-
告诉 Git 你想要添加到仓库中的文件。
-
将这些更改提交到仓库中。
这个过程听起来可能很复杂,但实际上并不复杂。你需要为每个步骤输入一个简单、短的终端命令。如果应用程序正在本地运行,请在终端中停止它(Ctrl-C)。然后,确保你仍然位于应用程序的根目录中,保持在终端中,并运行以下命令:
$ git init ***1***
$ git add --all ***2***
$ git commit -m "First commit" ***3***
-
1 初始化文件夹为本地 Git 仓库
-
2 将文件夹中的所有内容添加到仓库中
-
3 提交更改到仓库并附上消息
这三件事一起创建了一个包含应用程序整个代码库的本地 Git 仓库。当你稍后更新应用程序并想要将一些更改实时推送到线上时,你将使用后两个命令,并附上不同的消息来更新仓库。你的本地仓库已经准备好了。现在是时候创建 Heroku 应用程序了。
创建 Heroku 应用程序
此下一步是在 Heroku 上创建一个本地仓库的远程 Git 仓库的应用程序。你可以通过单个终端命令完成所有这些操作:
$ heroku create
你将在终端中看到一个确认信息,包括应用程序所在的 URL、Git 仓库地址和远程仓库的名称,如下例所示:
https://pure-temple-67771.herokuapp.com/ | git@heroku.com:pure-temple-
67771.git
Git remote heroku added
如果你通过浏览器登录到你的 Heroku 账户,你也会看到应用程序在那里。现在你已经在 Heroku 上为应用程序找到了一个位置,下一步是将应用程序代码推送到那里。
将应用程序部署到 Heroku
你已经将应用程序存储在本地 Git 仓库中,并在 Heroku 上创建了一个新的远程仓库。远程仓库是空的,因此你需要将本地仓库的内容推送到 heroku 远程仓库。
如果你不知道 Git,有一个用于此目的的单个命令,其结构如下:

此命令将您本地 Git 仓库的内容推送到 heroku 远程仓库。目前,您的仓库中只有一个分支——主分支——所以您将推送到 Heroku。有关 Git 分支的更多信息,请参阅侧边栏“什么是 Git 分支?”。
当您运行此命令时,终端会显示大量日志消息,在处理过程中最终会显示(从末尾大约五行)一个确认信息,表明应用程序已部署到 Heroku。这个确认信息类似于以下内容,但当然,您会有一个不同的 URL:
http://pure-temple-67771.herokuapp.com deployed to Heroku
什么是 Git 分支?
如果您在相同的代码版本上工作,并定期将其推送到像 Heroku 或 GitHub 这样的远程仓库,您正在 master 分支上工作。这个过程对于只有一个开发者的线性开发来说绝对是可以的。但是,如果您有多个开发者,或者您的应用程序已经发布,您不希望在主分支上进行开发。相反,您可以从主代码中启动一个新的分支,在那里您可以继续开发、添加修复或构建新功能。当分支上的工作完成后,它可以合并回主分支。
关于 Heroku 上的 web dynos
Heroku 使用 dynos 的概念来运行和扩展应用程序。您拥有的 dynos 越多,您为应用程序可用的系统资源和进程就越多。当您的应用程序变得更大、更受欢迎时,添加更多 dynos 是很容易的。
Heroku 还有一个出色的免费层,非常适合应用程序原型设计和构建概念验证。每个应用程序您都免费获得一个 web dyno,这对于您在这里的目的来说已经足够了。如果您有需要更多资源的应用程序,您始终可以登录您的账户并支付更多。
在下一节中,您将检查实时 URL。
在实时 URL 上查看应用程序
一切准备就绪,应用程序已经在互联网上上线了!您可以通过在确认信息中输入给您的 URL、通过 Heroku 网站的您的账户,或者使用以下终端命令来查看它:
$ heroku open
此命令将在您的默认浏览器中启动应用程序,您应该会看到类似于 图 3.10 的内容。
图 3.10. 在实时 URL 上运行的 MVC Express 应用程序

您的 URL 当然会不同,在 Heroku 中,您可以将它更改为使用您的域名而不是它提供的地址。在 Heroku 网站的“应用程序设置”中,您可以将它更改为更有意义的子域名 herokuapp.com。
将原型放在可访问的 URL 上对于跨浏览器和跨设备测试很有用,以及用于发送给同事和合作伙伴。
简单的更新过程
现在 Heroku 应用程序已经设置好了,更新它很容易。每次您想要通过推送一些新更改时,您都需要三个终端命令:
$ git add --all ***1***
$ git commit -m "Commit message here" ***2***
$ git push heroku master ***3***
-
1 将所有更改添加到本地 Git 仓库
-
2 使用有用的消息提交更改到本地仓库
-
3 将更改推送到 Heroku 仓库
至少目前就是这样。如果你要处理多个开发人员和分支,事情可能会变得稍微复杂一些,但使用 Git 将代码推送到 Heroku 的过程保持不变。
在 第四章 中,当你构建 Loc8r 应用程序的原型时,你会更深入地了解 Express。
概述
在本章中,你学习了
-
如何创建一个新的 Express 应用程序
-
如何使用 npm 和 package.json 文件管理应用程序依赖
-
如何将标准的 Express 项目更改为满足 MVC 架构方法
-
路由和控制器如何配合
-
使用 Git 将 Express 应用程序以最简单的方式实时发布到 Heroku
第四章. 使用 Node 和 Express 构建静态网站
本章涵盖
-
通过构建静态版本来原型化应用程序
-
定义应用程序 URL 的路由
-
使用 Pug 和 Bootstrap 在 Express 中创建视图
-
在 Express 中使用控制器将路由绑定到视图
-
从控制器传递数据到视图
到 第三章 的结尾,你应该已经有一个运行中的 Express 应用程序,以 MVC 方式设置,Bootstrap 已包含在内,有助于构建页面布局。你的下一步是在这个基础上构建,创建一个可以点击的静态网站。这一步对于构建任何网站或应用程序至关重要。即使你已经得到了一些设计或线框来工作,也没有什么可以替代快速创建一个可以在浏览器中使用的真实原型。在布局或可用性方面,总会有一些你没有注意到的细节。从这个静态原型中,你将从视图中提取数据并将其放入控制器中。到本章结束时,你将拥有智能视图,可以显示传递给它们的 数据,并且控制器将硬编码的数据传递到视图中。
获取源代码
如果你还没有从第三章构建应用程序,你可以从 GitHub 上的 chapter-03 分支获取代码,网址为 github.com/cliveharber/gettingMean-2。在终端的新文件夹中,输入以下命令以克隆它并安装 npm 模块依赖项:
$ git clone -b chapter-03 https://github.com/cliveharber/
gettingMean-2.git
$ cd gettingMean-2
$ npm install
在构建应用程序架构方面,本章重点介绍了如图 4.1 所示的 Express 应用程序。
图 4.1. 使用 Express 和 Node 构建用于测试视图的静态网站

本章完成了两个主要步骤,因此提供了两种源代码版本。第一个版本包含所有视图中的数据,代表了在 4.4 节结束时的应用程序状态。此代码可在 GitHub 上的 chapter-04-views 分支找到。
第二个版本具有控制器中的数据,代表了在章节结束时的应用程序状态。此代码可在 GitHub 上的 chapter-04 分支找到。
要获取这些版本之一,请在终端的新文件夹中使用以下命令,记得指定你想要的分支:
$ git clone -b chapter-04 https://github.com/cliveharber/gettingMean-2.git
$ cd gettingMean2
$ npm install
如果你想要运行 Docker 环境,请参阅附录 B。现在你准备好回到 Express 了。
4.1. 在 Express 中定义路由
在第二章中,你规划了应用程序并确定了你要构建的四页内容。你有一系列位置页面和“其他”集合中的一个页面,如图 4.2 所示。
图 4.2. 你将为 Loc8r 应用程序构建的屏幕集合

拥有一组屏幕很棒,但这些屏幕需要与传入的 URL 相关联。在你开始任何编码之前,制定屏幕和 URL 之间的链接图,并建立一个良好的标准是个好主意。看看表 4.1,它展示了屏幕与 URL 的简单映射。这些映射将构成你应用程序路由的基础。
表 4.1. 为原型中每个屏幕定义 URL 路径或路由
| 集合 | 屏幕 | URL 路径 |
|---|---|---|
| 位置 | 位置列表(主页) | / |
| 位置 | 位置详情 | /location |
| 位置 | 位置评论表单 | /location/review/new |
| 其他 | 关于 Loc8r | /about |
当有人访问主页时,例如,你希望向他们展示地点列表,但当有人访问/about URL 路径时,你希望向他们展示 Loc8r 的信息。
4.1.1. 不同集合的控制器文件
在第三章中,你将控制器逻辑从路由定义中移出,放入外部文件。展望未来,你知道你的应用程序将会增长,你不想所有控制器都在一个文件中。将它们分开的逻辑起点是按集合划分它们。
看看你已决定采用的集合,你决定将控制器分为“位置”和“其他”。为了从文件架构的角度了解这种方法可能如何工作,你可以绘制类似图 4.3 的东西。在这里,应用程序包括路由文件,该文件反过来又包括多个控制器文件,每个文件都根据相关的集合命名。
图 4.3. 应用程序中路由和控制器建议的文件架构

你有一个单独的路由文件,以及每个逻辑屏幕集合的一个控制器文件。这种设置旨在帮助你根据应用程序的组织方式来组织代码。你将很快查看控制器,但首先,你需要处理路由。
规划的时间已经结束;现在是采取行动的时候了!回到你的开发环境并打开应用程序。你将从在 routes 文件 index.js 中工作开始。
引入控制器文件
如图 4.3 所示,你想要在这个路由文件中引用两个控制器文件。你还没有创建这些控制器文件;你将在稍后完成这项任务。
这些文件将被命名为 locations.js 和 others.js。它们将被保存在 app_server/controllers 目录中。在 index.js 中,你需要require这两个文件并将它们分别分配给相关的变量名,如下面的列表所示。
列表 4.1. 在 app_server/routes/index.js 中引入控制器文件
const express = require('express');
const router = express.Router();
const ctrlLocations = require('../controllers/locations'); ***1***
const ctrlOthers = require('../controllers/others'); ***1***
- 1 用两个新的 require 替换现有的 ctrlMain 引用
现在你有两个可以在路由定义中引用的变量,它们包含不同的路由集合。
设置路由
在 index.js 中,你需要为 Locations 集合中的三个屏幕和 Others 集合中的关于页面设置路由。每个路由还需要一个控制器引用。记住,路由充当映射服务,将传入请求的 URL 映射到特定的应用程序功能。
从表 4.1 中,你已经知道你想要映射哪些路径,所以你需要将所有内容组合到 routes/index.js 文件中。文件中需要的内容在下面的列表中完整显示。
列表 4.2. 定义路由并将它们映射到控制器
const express = require('express');
const router = express.Router();
const ctrlLocations = require('../controllers/locations'); ***1***
const ctrlOthers = require('../controllers/others'); ***1***
/* Locations pages */
router.get('/', ctrlLocations.homelist); ***2***
router.get('/location', ctrlLocations.locationInfo); ***2***
router.get('/location/review/new', ctrlLocations.addReview); ***2***
/* Other pages */
router.get('/about', ctrlOthers.about); ***3***
module.exports = router;
-
1 引入控制器文件
-
2 定义位置路由并将它们映射到控制器函数
-
3 定义其他路由
这个路由文件将定义的 URL 映射到一些特定的控制器,尽管你还没有创建它们。你将在下一节中处理这个任务。
4.2. 构建基本控制器
在这一点上,你将保持控制器的基本性,以便你的应用程序可以运行,并且你可以测试 URL 和路由。
4.2.1. 设置控制器
你目前有一个文件:位于 app_server 文件夹中的 controllers 文件夹中的 main.js 文件,它包含一个控制主页的单个函数。这个函数在下面的代码片段中显示:
/* GET 'home' page */
const index = (req, res) => {
res.render('index', { title: 'Express' });
};
你不再需要“主要”控制器文件,但你可以使用这个文件作为模板。首先,将此文件重命名为 others.js。
添加其他控制器
从列表 4.2 回忆一下,你想要在 others.js 中有一个名为about的控制器。将现有的index控制器重命名为about;现在保持相同的视图模板;并将title属性更新为相关的内容。这种方法使得测试路由是否按预期工作变得容易。以下列表显示了经过这些小改动后 others.js 控制器文件的完整内容。
列表 4.3. 其他控制器文件
/* GET 'about' page */
const about = (req, res) => { ***1***
res.render('index', { title: 'About' }); ***1***
};
module.exports = {
about ***2***
};
-
1 定义路由,使用相同的视图模板,但将标题改为关于
-
2 更新导出以反映名称更改
那是第一个完成的控制器,但应用程序仍然无法工作,因为没有为位置路由添加任何控制器。
添加位置控制器
为位置路由添加控制器的过程基本上是相同的。在路由文件中,你指定了要查找的控制器文件名以及三个控制器函数的名称。
在控制器文件夹中,创建一个名为 locations.js 的文件,并创建和导出三个基本控制器函数:homelist、locationInfo和addReview。以下列表显示了该文件应该如何看起来。
列表 4.4. 位置控制器文件
/* GET 'home' page */
const homelist = (req, res) => {
res.render('index', { title: 'Home' });
};
/* GET 'Location info' page */
const locationInfo = (req, res) => {
res.render('index', { title: 'Location info' });
};
/* GET 'Add review' page */
const addReview = (req, res) => {
res.render('index', { title: 'Add review' });
};
module.exports = {
homelist,
locationInfo,
addReview
};
一切就绪,你现在可以开始测试了。
4.2.2. 测试控制器和路由
现在路由和基本控制器已经就位,你应该能够启动并运行应用程序。如果你还没有用 nodemon 启动它,请在终端中转到应用程序的根目录并启动它:
$ nodemon
故障排除
如果你在这个时候遇到重启应用程序的问题,主要需要检查的是所有文件、函数和引用的命名是否正确。查看终端窗口中的错误消息,看看是否提供了任何线索。一些消息比其他消息更有帮助。看看以下可能的错误,并挑选出对你有意义的部分:
module.js:340
throw err;
^
Error: Cannot find module '../controllers/other' ***1***
at Function.Module._resolveFilename (module.js:338:15)
at Function.Module._load (module.js:280:25)
at Module.require (module.js:364:17)
at require (module.js:380:17)
at module.exports (/Users/sholmes/Dropbox/
Manning/GettingMEAN/Code/Loc8r/
BookCode/routes/index.js:2:3) ***2***
at Object.<anonymous> (/Users/sholmes/Dropbox/
Manning/GettingMEAN/Code/Loc8r/
BookCode/app.js:26:20)
at Module._compile (module.js:456:26)
at Object.Module._extensions..js (module.js:474:10)
at Module.load (module.js:356:32)
at Function.Module._load (module.js:312:12)
-
1 线索 1:找不到模块。
-
2 线索 2:发生了文件抛出错误。
首先,你会看到无法找到名为other的模块 1。在堆栈跟踪的更下方,你会看到错误起源的文件 2。打开 routes/index.js 文件,你会发现你写了require('../controllers/other'),而你想要导入的文件是 others.js。为了解决这个问题,通过将其更改为require('../controllers/others')来纠正引用。
如果一切顺利,这次运行应该不会出现错误,这意味着路由已指向控制器。此时,您可以前往浏览器并检查您创建的四个路由,例如主页的 localhost:3000 和位置信息页的 localhost:3000/location。因为您已经更改了每个控制器发送到视图模板的数据,所以您可以轻松地看到每个都在正确运行——每个页面的标题和标题应该不同。图 4.4 显示了新创建的路由和控制器的截图集合。您可以看到每个路由都获得了独特的内容,因此您知道路由和控制器设置已经成功。
图 4.4. 到目前为止创建的四个路由的截图,其中包含来自与每个路由关联的特定控制器的不同标题文本

在这个原型制作过程的下一阶段,是在每个屏幕上放置一些 HTML、布局和内容。您将通过使用视图来完成这项工作。
4.3. 创建一些视图
当您已经整理好空页面、路径和路由后,是时候将一些内容和布局添加到您的应用程序中。这一步是将应用程序激活并开始看到您的想法变为现实的地方。为此步骤,您将使用的科技是 Pug 和 Bootstrap。Pug 是您在 Express 中使用的模板引擎(尽管如果您愿意,您也可以使用其他引擎),Bootstrap 是一个前端布局框架,它使得构建在不同桌面和移动设备上看起来不同的响应式网站变得容易。
4.3.1. Bootstrap 的概述
在开始之前,让我们快速了解一下 Bootstrap。我们不会深入探讨 Bootstrap 的所有细节以及它能做什么,但在您尝试将其放入模板文件之前,看到一些关键概念是有用的。
Bootstrap 使用 12 列网格。无论您使用的显示器的尺寸如何,总会存在这 12 列。在手机上,每列都很窄,而在大外部显示器上,每列都很宽。Bootstrap 的基本概念是您可以定义元素使用多少列,并且这个数字可以针对不同的屏幕尺寸不同。
Bootstrap 提供了各种 CSS 参考,允许您为布局设置多达五个不同像素宽度的断点。这些断点在表 4.2 中有说明,同时列出了每个尺寸针对的示例设备。
表 4.2. Bootstrap 为针对不同类型设备设置的断点
| 断点名称 | CSS 参考 | 示例设备 | 宽度 |
|---|---|---|---|
| 超小型设备 | (无) | 小型手机 | 少于 576 像素 |
| 小型设备 | sm | 智能手机 | 576 像素或以上 |
| 中型设备 | md | 平板电脑 | 768 像素或以上 |
| 大型设备 | lg | 笔记本电脑 | 992 像素或以上 |
| 超大设备 | xl | 外部显示器 | 1,200 像素或以上 |
要定义一个元素的宽度,你需要将来自表 4.2 的 CSS 引用与你想让它跨越的列数结合起来。一个表示列的类可以这样构建:

这个col-sm-6类使得应用到的元素在sm尺寸及更大的屏幕上占据六个列。在平板电脑、笔记本电脑和显示器上,这个列将占据可用宽度的一半。
要使响应式功能正常工作,你可以将多个类应用到单个元素上。如果你想让一个div在手机上占据整个屏幕宽度,但在平板电脑和更大设备上只占据一半宽度,你可以使用以下代码片段:
<div class="col-12 col-md-6"></div>
col-12类告诉布局在超小设备上使用 12 列,而col-md-6类告诉布局在中型设备及更大设备上使用 6 列。图 4.5 展示了如果页面上有两个这样的类,一个接一个,它们在不同设备上的效果。
<div class="col-12 col-md-6">DIV ONE</div>
<div class="col-12 col-md-6">DIV TWO</div>
图 4.5. 在桌面设备和移动设备上 Bootstrap 的响应式列系统。CSS 类用于确定每个元素在不同屏幕分辨率下应占用的列数(12 列中的几列)。

这种方法允许以语义化的方式组合响应式模板,你将大量依赖它来构建 Loc8r 页面。说到这一点,你将在下一节开始着手。
4.3.2. 使用 Pug 模板和 Bootstrap 设置 HTML 框架
应用程序中的页面有一些共同的要求。在每一页的顶部,你将需要一个导航栏和标志;在页面的底部,你将在页脚中有一个版权声明;中间将有一个内容区域。你追求的是类似于图 4.6 的东西。这个布局框架很简单,但能满足你的需求。它提供了统一的视觉和感觉,同时允许中间有不同布局。
图 4.6. 可重复使用的布局的基本结构,包括一个标准的导航栏和页脚,以及中间的可扩展、可更改的内容区域

正如你在第三章中看到的,Pug 模板使用可扩展布局的概念,使你能够在布局文件中定义这种可重复的结构一次。在布局文件中,你可以指定哪些部分可以扩展;当你设置了这种布局文件,你可以根据需要多次扩展它。在布局文件中创建框架意味着你只需做一次,你可以在一个地方维护它。
查看布局
要构建通用框架,你主要会在app_server/views文件夹中的layout.pug文件上工作。这个文件很简单,看起来像以下代码片段:
doctype html
html
head
meta(name='viewport', content='width=device-width, initial-scale=1.0')
title= title
link(rel='stylesheet', href='/stylesheets/bootstrap.min.css')
link(rel='stylesheet', href='/stylesheets/all.min.css')
link(rel='stylesheet', href='/stylesheets/style.css')
body
block content
script(src='https://code.jquery.com/jquery-3.3.1.slim.min.js',
integrity='sha384
q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo',
crossorigin=anonymous)
script(src=https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/
umd/popper.min.js,
integrity='sha384
ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49',
crossorigin='anonymous')
script(src='/javascripts/bootstrap.min.js')
在主体区域还没有任何 HTML 内容——只有一个名为 content 的可扩展块和一些脚本引用。你想要保留所有这些,但在 content 块上方添加一个导航部分,在其下方添加一个页脚。
构建导航
Bootstrap 提供了一系列元素和类,你可以使用它们来创建一个固定在顶部并可在移动设备上折叠选项的下拉菜单的粘性导航栏。我们在这里不会探讨 Bootstrap CSS 类的细节。你所需要做的就是从 Bootstrap 网站获取示例代码,稍作修改,并更新为正确的链接。
在导航中,你想要有
-
链接到主页的 Loc8r 标志
-
左侧的一个关于链接,指向 /about URL 页面
执行这些操作的代码在以下片段中,并且可以放置在 layout.pug 文件中 block content 行上方:
nav.navbar.fixed-top.navbar-expand-md.navbar-light ***1***
.container
a.navbar-brand(href='/') Loc8r ***2***
button.navbar-toggler(type='button', data-toggle='collapse',
data-target='#navbarMain') ***3***
span.navbar-toggler-icon
#navbarMain.navbar-collapse.collapse
ul.navbar-nav.mr-auto
li.nav-item
a.nav-link(href='/about/') About ***4***
-
1 设置一个固定在窗口顶部的 Bootstrap 导航栏
-
2 为主页添加一个品牌风格的链接
-
3 为较小的屏幕分辨率设置可折叠的导航
-
4 将关于链接添加到栏的左侧
如果你将这段代码放入并运行,你会注意到导航现在覆盖了页面标题。当你构建内容区域的布局时,你将在第 4.3.3 和 4.4 节中修复这个问题,所以不用担心。
提示
记住 Pug 不包含任何 HTML 标签,并且正确的缩进对于提供预期的结果至关重要。
这就是导航栏的全部内容,目前你只需要这些。如果你对 Pug 和 Bootstrap 还不熟悉,可能需要一点时间来习惯这种方法和语法,但正如你所看到的,你可以用很少的代码实现很多功能。
包装内容
从页面顶部向下工作,下一个区域是 content 块。你不需要对这个块做太多,因为其他 Pug 文件决定了内容。然而,目前 content 块固定在左边缘,并且不受限制,这意味着它扩展到任何设备的全宽度。
通过 Bootstrap 解决这种情况很容易。仍然在 layout.pug 中,像这样将 content 块包裹在一个容器 div 中,同时确保缩进正确:
.container
block content
具有类名为 container 的 div 在窗口中居中,并在大屏幕上限制在合理的最大宽度内。容器 div 的内容仍然按照常规向左对齐。
添加页脚
在页面底部,你想要添加一个标准的页脚。你可以在这里添加一些链接,或者条款和条件,或者隐私政策。为了保持简单,你将添加一个版权声明。因为这个更改是在布局文件中进行的,所以如果你稍后需要更新这个通知,将很容易跨所有页面进行更新。
以下代码片段显示了在 layout.pug 中创建简单页脚所需的所有代码:
footer
.row
.col-12
small © Getting Mean - Simon Holmes/Clive Harber 2018
这段代码被放置在包含content块的容器div中,所以当你添加它时,确保footer行与block content行的缩进级别相同。
一同完成
现在已经处理了导航栏、内容区域和页脚,你有了完整的布局文件。layout.pug 的完整代码如下所示。
列表 4.5. app_server/views/layout.pug 中布局框架的最终代码
doctype html
html
head
meta(name='viewport', content='width=device-width, initial-scale=1.0')
title= title
link(rel='stylesheet', href='/stylesheets/bootstrap.min.css')
link(rel='stylesheet', href='/stylesheets/all.min.css')
link(rel='stylesheet', href='/stylesheets/style.css')
body
nav.navbar.fixed-top.navbar-expand-md.navbar-light ***1***
.container
a.navbar-brand(href='/') Loc8r
button.navbar-toggler(type='button', data-toggle='collapse',
data-target='#navbarMain')
span.navbar-toggler-icon
#navbarMain.navbar-collapse.collapse
ul.navbar-nav.mr-auto
li.nav-item
a.nav-link(href='/about/') About
.container.content
block content ***2***
footer ***3***
.row
.col-12
small © Getting MEAN – Simon Holmes/Clive Harber 2018
script(src='https://code.jquery.com/jquery-3.3.1.slim.min.js',
integrity='sha384-
q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo',
crossorigin='anonymous')
script(src='https://cdnjs.cloudflare.com/ajax/libs/
popper.js/1.14.3/umd/popper.min.js' integrity='sha384- ZMP7rVo
]3mIykV+2+9J3UJ46jBk0WLaUAdn689a CwoqbBJiSnjAK/l8WvCWPIPm49',
crossorigin='anonymous')
script(src='/javascripts/bootstrap.min.js')
-
1 从固定导航栏开始布局
-
2 可扩展的内容块现在被包裹在一个容器 div 中
-
3 简单的版权页脚在内容块相同的容器中
使用 Bootstrap、Pug 和 Express 创建响应式布局框架,只需做这些就足够了。如果你已经一切准备就绪,当你运行应用程序时,你应该能看到类似图 4.7 中的截图,这取决于你的设备。
图 4.7. 布局模板设置后的主页。Bootstrap 自动折叠了手机小屏幕尺寸下的导航。导航栏覆盖了内容,但这个问题将在创建内容布局时得到解决。

你会看到导航仍然覆盖了内容,但当你开始查看内容布局时,你会解决那个问题。这是一个很好的迹象,表明导航正在按你的意愿工作:你希望导航始终存在,固定在窗口顶部。同时请注意,Bootstrap 已经将导航折叠成下拉菜单,这是在你手机较小的屏幕上——这是你付出很少努力就能得到的好结果。
小贴士
如果你无法在手机上访问你的开发网站,你可以尝试调整浏览器窗口的大小。所有主流的网页浏览器都允许你通过内置的开发者工具模拟各种移动设备和屏幕尺寸。
现在通用布局模板已经完成,是时候开始构建你应用程序的实际页面了。
4.3.3. 构建模板
当你构建模板时,从对你最有意义的一个开始。这个模板可能是最复杂或最简单的,或者是主要用户旅程中的第一个。对于 Loc8r 来说,一个好的起点是主页,这是我们将会最详细地讨论的例子。
定义布局
主页的主要目的是显示位置列表。每个位置都需要有名称、地址、距离用户的距离、用户评分和设施列表。你还需要添加一个页眉和一些文本,以便将列表置于上下文中,这样用户在访问时就知道他们在看什么。
你可能会觉得,像我们一样,在一张纸或白板上草拟一个或两个布局是有用的。我们发现这个草图对于创建布局的起点很有帮助,确保我们在页面上拥有所有需要的组件,而不会陷入代码的技术细节。展示了你可能为 Loc8r 的首页草拟的内容。
图 4.8. 首页的桌面和移动布局草图。为页面绘制布局可以快速了解你将要构建的内容,而不会因为 Adobe Photoshop 的复杂性或代码的技术细节而分心。
![Images/04fig08_alt.jpg]
你会看到有两种布局:一种用于桌面,另一种用于手机。在这个阶段,理解 Bootstrap 能做什么以及它是如何工作的,区分这两种布局是有意义的。这是开始思考响应式设计的起点。
在这个阶段,布局绝对不是最终的,你完全可以在构建代码的过程中调整和更改它们。但如果你有一个目的地和一张地图,旅程就会容易得多,草图为你提供了这些。你可以从正确的方向开始编写代码。创建草图所花费的几分钟可以节省你几个小时的时间,尤其是当你发现需要移动部分内容或甚至完全放弃并重新开始时。与一大堆代码相比,这个过程有了草图会容易得多。
现在你已经对布局和所需的内容组件有了概念,是时候将所有内容组合到一个新的模板中。
设置视图和控制器
第一步是创建一个新的视图文件并将其链接到控制器。在 app_server/views 文件夹中,复制 index.pug 视图文件,并将其保存到与 locations-list.pug 相同的文件夹中。最好不要将文件命名为 homepage 或类似名称,因为某个时候,你可能会改变主意,不知道首页应该显示什么。这样,视图的名称可以清楚地标识它,并且可以在任何地方使用而不会引起混淆。
第二步是告诉首页的控制器你想要使用这个新的视图。首页的控制器位于 app_server/controllers 中的 locations.js 文件。更新此文件以更改由homelist控制器调用的视图,如下面的代码片段所示(加粗部分为修改):
const homelist = (req, res) => {
res.render('locations-list', { title: 'Home' });
};
现在,你已经准备好构建视图模板了。
编写模板:页面布局
当我们编写布局的代码时,我们更喜欢从大的组件开始,然后逐步细化。随着布局文件的扩展,导航栏和页脚已经完成,但你仍然需要考虑页面标题、列表的主要区域和侧边栏。
在这个阶段,你需要尝试确定每个元素在不同设备上想要占用多少个 Bootstrap 列。以下代码片段显示了 locations-list.pug 中 Loc8r 列表页面的三个不同区域的布局:
.row.banner
.col-12 ***1***
h1 Loc8r ***1***
small Find places to work with wifi near you! ***1***
.row
.col-12.col-md-8 ***2***
p List area. ***2***
.col-12.col-md-4 ***3***
p.lead Loc8r helps you find places to work when out and about. ***3***
-
1 填充整个屏幕宽度的页面标题
-
2 位置列表的容器,在超小号和小号设备上跨越所有 12 列,在中号设备和大号设备上跨越 8 列
-
3 次要信息或侧边栏信息的容器,在超小号和小号设备上跨越所有 12 列,在中号设备和大号设备上跨越 4 列
你可能需要来回测试几次,调整不同分辨率下的列,直到你对它们满意。拥有设备模拟器可以使这个过程更容易,但一个简单的方法是改变浏览器窗口的宽度,以强制不同的 Bootstrap 断点。当你认为某个布局可能合适时,你可以将其推送到 Heroku 上,并在你的手机或平板电脑上实际测试。
编码模板:位置列表
现在主页的容器已经定义好了,接下来是主要区域。你可以从为页面布局绘制的草图中获得这里想要的内容的想法。每个地方都应该显示名称、地址、评分、用户距离以及关键设施。
因为你在创建一个可点击的原型,所以现在所有数据都将硬编码到模板中。这种方法是快速组合模板并确保你以你想要的方式显示所需信息的最快方式。你将在稍后处理数据方面的问题。如果你从一个现有的数据源工作或对可以使用的数据有约束,那么在创建布局时,你自然需要考虑这些事实。
再次强调,获得你满意的布局可能需要一些测试,但 Pug 和 Bootstrap 的结合使得这个过程比单独使用它们要容易得多。以下代码片段显示了你可以为单个位置提供的,以替换 locations-list.pug 中的 p List area 占位符:
.card ***1***
.card-block ***1***
h4
a(href="/location") Starcups ***2***
small
i.fas.fa-star ***3***
i.fas.fa-star ***3***
i.fas.fa-star ***3***
i.far.fa-star ***3***
i.far.fa-star ***3***
span.badge.badge-pill.badge-default.float-right 100m ***4***
p.address 125 High Street, Reading, RG6 1PS ***5***
.facilities
span.badge.badge-warning Hot drinks ***6***
span.badge.badge-warning Food ***6***
span.badge.badge-warning Premium wifi ***6***
-
1 创建一个新的 Bootstrap 卡片和卡片块来包裹内容
-
2 列表名称及其位置链接
-
3 使用 Font Awesome 图标输出星级评分
-
4 使用 Bootstrap 的徽章辅助类来显示距离
-
5 位置的地址
-
6 位置设施,使用 Bootstrap 的徽章类输出
再次强调,你可以看到,通过相对较少的努力和代码,你可以实现多少。这都要归功于 Pug 和 Bootstrap 的结合。记住,一些用于美化的自定义类在 public/stylesheets 下的 styles.css 文件中,可在 GitHub 仓库中找到。没有这些类,你的视觉效果将大不相同。要了解前面的代码片段做了什么,请查看图 4.9。
图 4.9. 列表页面上单个位置的屏幕渲染

此部分设置为跨越可用区域的全部宽度:所有设备上的 12 列。但请记住,尽管这个部分嵌套在响应式列中,所以“全宽”是包含列的全宽,而不是浏览器视口的宽度。当您将所有内容组合在一起并看到应用程序的实际运行时,这个解释将更有意义。
编码模板:将其组合在一起
您已经有了页面元素布局、列表区域的结构和一些硬编码的数据,现在是时候看看一切看起来是什么样子了。为了更好地感受浏览器中的布局,复制并修改列表页面以显示多个位置是个好主意。以下列出的是包括单个位置以节省篇幅的代码。
列表 4.6. app_server/views/locations-list.pug 的完整模板
extends layout
block content
.row.banner ***1***
.col-12
h1 Loc8r
small Find places to work with wifi near you!
.row
.col-12.col-md-8 ***2***
.card ***3***
.card-block ***3***
h4 ***3***
a(href="/location") Starcups ***3***
small ***3***
i.fas.fa-star ***3***
i.fas.fa-star ***3***
i.fas.fa-star ***3***
i.far.fa-star ***3***
i.far.fa-star ***3***
span.badge.badge-pill.badge-default.float-right 100m ***3***
p.address 125 High Street, Reading, RG6 1PS ***3***
p.facilities ***3***
span.badge.badge-warning Hot drinks ***3***
span.badge.badge-warning Food ***3***
span.badge.badge-warning Premium wifi ***3***
.col-12.col-md-4 ***4***
p.lead Looking for wifi and a seat? Loc8r helps you find places to
work when out and about. Perhaps with coffee, cake or a pint?
Let Loc8r help you find the place you're looking for.
-
1 开始标题区域
-
2 开始响应式主列表列部分
-
3 单个列表;复制此部分以创建多个项目的列表
-
4 设置侧边栏区域并填充一些内容
当您将此代码放置到位后,您就完成了主页列表模板。如果您运行应用程序并转到 localhost:3000,您应该会看到类似于图 4.10 的内容。
图 4.10. 在不同设备上运行的首页响应式模板

您可以看到桌面视图和移动视图之间的布局如何变化吗?这种变化归功于 Bootstrap 的响应式框架和您选择的 CSS 类。在移动视图中向下滚动,您会在主列表和页脚之间看到侧边栏的文本内容。在较小的屏幕上,显示列表在可用空间中比显示文本更重要。
很好;您通过在 Express 和 Node 中使用 Pug 和 Bootstrap 创建了一个响应式布局的主页。接下来,您将添加其他视图。
4.4. 添加其余视图
位置列表页面已构建,因此您需要创建其他页面,以便用户可以点击浏览。在本节中,我们将介绍添加这些页面:
-
详情
-
添加评论
-
关于
尽管我们不会对所有这些过程进行详细说明,但只会提供一些解释、代码和输出。如果您更喜欢,可以始终从 GitHub 下载源代码。
4.4.1. 详细信息页面
合理的步骤,并且可以说是查看下一个最重要的页面,即单个位置的详细信息页面。
此页面需要显示有关位置的所有信息,包括
-
名称
-
地址
-
评分
-
营业时间
-
设施
-
位置地图
-
评论,每个都有
-
评分
-
评论者姓名
-
评论日期
-
评论文本
-
添加新评论的按钮
-
设置页面上下文的文本
-
这相当多的信息!这个模板是您应用程序中最复杂的一个。
准备
第一步是更新此页面的控制器以使用不同的视图。在 app_server/controllers 中的 locations.js 文件中查找locationInfo控制器。将视图的名称更改为location-info,如下代码片段所示:
const locationInfo = (req, res) => {
res.render('location-info', { title: 'Location info' });
};
下一步是获取访问谷歌地图 API 的密钥。如果你还没有账户,你需要在该地址注册一个账户:
https://developers.google.com/maps/documentation/javascript/
get-api-key?utm_source=geoblog&utm_medium=social&utm_campaign=
2016-geo-na-website-gmedia-blogs-us-blogPost&utm_content=TBC
确保你保管好你的 API 密钥;你将在下一个列表中需要它。
记住,如果你在这个时候运行应用程序,它将无法工作,因为 Express 找不到视图模板——这并不奇怪,因为你还没有创建它。这就是下一个部分。
视图
在 app_server/views 中创建一个新文件,并将其保存为 location-info.pug。该文件的内容显示在列表 4.7 中,这是本书中最大的列表。记住,在原型开发的这个阶段,你是在生成带有直接硬编码数据的可点击页面。
列表 4.7. 详情页面的视图,app_server/views/location-info.pug
extends layout
block content
.row.banner ***1***
.col-12 ***1***
h1 Starcups ***1***
.row
.col-12.col-lg-9 ***2***
.row
.col-12.col-md-6 ***2***
p.rating
i.fas.fa-star
i.fas.fa-star
i.fas.fa-star
i.far.fa-star
i.far.fa-star
p 125 High Street, Reading, RG6 1PS
.card.card-primary ***3***
.card-block ***3***
h2.card-title Opening hours ***3***
p.card-text Monday - Friday : 7:00am - 7:00pm ***3***
p.card-text Saturday : 8:00am - 5:00pm ***3***
p.card-text Sunday : closed ***3***
.card.card-primary ***4***
.card-block ***4***
h2.card-title Facilities
span.badge.badge-warning
i.fa.fa-check ***4***
| Hot drinks ***4***
|
span.badge.badge-warning
i.fa.fa-check ***4***
| Food ***4***
|
span.badge.badge-warning
i.fa.fa-check
| Premium wifi
|
.col-12.col-md-6.location-map
.card.card-primary
.card-block
h2.card-title Location map
img.img-fluid.rounded(src=
'http://maps.googleapis.com/maps/api/.............. ***5***
staticmap?center=51.455041,-0.9690884&zoom=17&size=400x350 ***5***
&sensor=false&markers=51.455041,-0.9690884&scale=2&key=<API Key>') ***5***
.row
.col-12
.card.card-primary.review-card
.card-block
a.btn.btn-primary.float-right(href='/location/review/new')
Add review ***6***
h2.card-title Customer reviews
.row.review
.col-12.no-gutters.review-header
span.rating
i.fas.fa-star
i.fas.fa-star
i.fas.fa-star
i.far.fa-star
i.far.fa-star
span.review Author Simon Holmes
small.review Timestamp 16 February 2017
.col-12
p What a great place.
.row.review
.col-12.no-gutters.review-header
span.rating
i.fas.fa-star
i.fas.fa-star
i.fas.fa-star
i.far.fa-star
i.far.fa-star
span.reviewAuthor Charlie Chaplin
small.reviewTimestamp 14 February 2017
.col-12
p It was okay. Coffee wasn't great.
.col-12.col-lg-3 ***7***
p.lead
| Starcups is on Loc8r because it has accessible wifi and space to
sit down with your laptop and get some work done.
p
| If you've been and you like it - or if you don't - please leave
a review to help other people just like you.
-
1 从页面标题开始
-
2 设置模板所需的嵌套响应式列
-
3 用于定义信息区域(在本例中为营业时间)的多个 Bootstrap 卡片组件之一
-
4 使用 实体是因为 Pug 并不总是理解空白,并且有删除它的习惯。
-
5 使用静态谷歌地图图像,包括查询字符串中的坐标 51.455041,-0.9690884。记得用你之前获得的 Google API Key 替换
。 -
6 使用 Bootstrap 的按钮辅助类创建到添加评论页面的链接
-
7 侧边栏上下文信息的最后一个响应式列
这是一个很长的模板,你很快就会看到如何缩短它。但页面本身很复杂,包含大量信息和几个嵌套的响应式列。想象一下,如果它完全用 HTML 编写,将会多么长!
确保你包含了 GitHub 上的完整版本 style.css,因为你正在使用它为标准的 Bootstrap 主题添加一些活力。
完成所有这些后,详情页面布局就完成了;你可以转到 localhost:3000/location 查看。
显示了在浏览器和移动设备上此布局的外观。
图 4.11. 桌面和移动设备上的详情页面布局

用户旅程的下一步是添加评论页面,它有更简单的要求。
4.4.2. 添加评论页面
这个页面很简单,包含一个包含用户姓名和评分及评论输入字段的表单。
第一步是更新控制器以引用新的视图。在 app_server/controllers/locations.js 中,将addReview控制器更改为使用新的视图location-review-form,如下代码片段所示:
const addReview = (req, res) => {
res.render('location-review-form', { title: 'Add review' });
};
第二步是创建视图本身。在app_server/views目录下的views文件夹中,创建一个名为location-review-form.pug的新文件。因为这个页面被设计成可点击的原型,所以您不会将表单数据提交到任何地方,所以目标是让动作重定向到显示评论数据的详情页面。在表单中,然后设置动作为/location,方法为get。稍后,您将将其更改为post方法,但这个表单将为您提供目前需要的功能。评论表单页面的全部代码如下所示。
列表 4.8. 添加评论页面的视图,app_server/views/location-review-form.pug
extends layout
block content
.row.banner
.col-12
h1 Review Starcups
.row
.col-12.col-md-8
form(action="/location", method="get", role="form") ***1***
.form-group.row
label.col-10.col-sm-2.col-form-label(for="name") Name
.col-12.col-sm-10
input#name.form-control(name="name") ***2***
.form-group.row
label.col-10.col-sm-2.col-form-label(for="rating") Rating
.col-12.col-sm-2
select#rating.form-control.input-sm(name="rating") ***3***
option 5 ***3***
option 4 ***3***
option 3 ***3***
option 2 ***3***
option 1 ***3***
.form-group.row
label.col-sm-2.col-form-label(for="review") Review
.col-sm-10
textarea#review.form-control(name="review", rows="5") ***4***
button.btn.btn-primary.float-right Add my review ***5***
.col-12.col-md-4
-
1 设置表单动作到 /location,方法到 get
-
2 输入框供评论者留下他们的名字
-
3 用于评分 1 到...的下拉选择框
-
4 用于评论文本内容的文本区域
-
5 表单的提交按钮
Bootstrap 提供了许多处理表单的辅助类,这在列表 4.8 中很明显。但页面很简单,当您运行它时,它应该看起来像图 4.12。
图 4.12. 桌面和移动视图中的完整添加评论页面

添加评论页面的标记是用户通过屏幕集合的“位置”结束旅程。只剩下关于页面需要完成。
4.4.3. 添加关于页面
静态原型的最后一页是关于页面,它有一个页眉和一些内容——没有复杂的东西。布局可能对后续的其他页面有用,例如隐私政策或条款和条件页面,因此最好创建一个通用、可重用的视图。
关于页面的控制器位于app_server/controllers目录下的others.js文件中。您正在寻找名为about的控制器,并且想要将视图的名称更改为generic-text,如下代码片段所示:
const about = (req, res) => {
res.render('generic-text', { title: 'About' });
};
接下来,在app_server/views中创建名为generic-text.pug的视图。这个模板很小,应该看起来如下所示。
列表 4.9. 仅文本页面的视图:app_server/views/generic-text.pug
extends layout
block content
.row.banner
.col-12
h1= title
.row
.col-12.col-lg-8
p ***1***
| Loc8r was created to help people find places to sit down and ***1***
get a bit of work done. ***1***
| <br /><br /> ***1***
| Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc ***1***
sed lorem ac nisi dignissim accumsan. ***1***
- 1 使用|在
标签内创建普通文本行。
这是一个简单的布局。现在不要担心在通用视图中包含特定页面的内容;您很快就会承担这项任务并使页面可重用。为了完成可点击的静态原型,这是可以的。
您可能需要一些额外的行,以便页面看起来有真实的内容。注意,以竖线字符(|)开头的行可以包含 HTML 标签,如果您想的话。图 4.13 显示了页面在浏览器中带有更多内容时的样子。
图 4.13. 渲染关于页面的通用文本模板

这就是静态网站所需的最后四页之一。你可以将此页面推送到 Heroku,让人们访问 URL 并四处点击。如果你忘记了如何操作,以下代码片段显示了你需要使用的终端命令,假设你已经设置了 Heroku。在终端中,你需要位于应用程序的根目录。然后发出以下命令:
$ git add --all
$ git commit –m "Adding the view templates"
$ git push heroku master
获取源代码
目前应用程序的源代码可在 GitHub 上的 chapter-04-views 分支中找到。在终端的新文件夹中,输入以下命令以克隆它并安装 npm 模块依赖项:
$ git clone -b chapter-04-views
https://github.com/cliveharber/gettingMean-2.git
$ cd gettingMean-2
$ npm install
接下来是什么?路由、视图和控制器已设置好,你可以点击浏览静态网站,并且你已经将其推送到 Heroku,以便其他人也可以尝试。在某种程度上,你已经达到了这个阶段的目标;你可以在这里停下来,同时玩转旅程并获得反馈。这个阶段无疑是整个过程中最容易进行大规模、全面更改的点。
如果你确实计划构建一个 Angular SPA,并且假设你对到目前为止所做的工作感到满意,你可能不会进一步创建静态原型。相反,你将开始创建一个 Angular 应用程序。
但你现在将要采取的下一步将继续沿着创建 Express 应用程序的道路前进。所以,尽管保持静态网站,你将把数据从视图中移除,放入控制器中。
4.5. 从视图中移除数据并使它们更智能
目前,所有内容和数据都存储在视图中。这种安排非常适合测试和移动内容,但你需要继续前进。MVC 架构的一个目标是拥有没有内容或数据的视图。视图应该提供数据给最终用户,同时对于提供的数据保持无知。视图需要一个数据结构,但数据本身的内容对视图本身并不重要。
考虑 MVC 架构:模型持有数据;控制器处理数据;最后,视图渲染处理后的数据。你现在还没有处理模型;你将在第五章开始处理。现在,你正在处理视图和控制器。
要使视图更智能并执行它们预期的功能,你需要将数据和内容从视图中移除,并将其放入控制器中。图 4.14 说明了 MVC 架构中的数据流以及你想要进行的更改。
图 4.14. MVC 模式中数据应该如何流动,从模型通过控制器到视图。在这个原型阶段,你的数据在视图中,但你希望将其退回一步到控制器中。

现在做出这些更改,可以使你最终确定视图,以便为下一步做好准备。作为奖励,你将开始思考处理后的数据在控制器中应该是什么样子。与其从数据结构开始,不如从理想的前端开始,随着你对需求理解的加深,逐步逆向工程数据,通过 MVC 步骤返回。
你将如何做这些事情?从主页开始,你将把每一块内容从 Pug 视图中移除。你将更新 Pug 文件,用变量替换内容,并将内容作为变量放入控制器。然后控制器可以将这些值传递给视图。在浏览器中,结果应该看起来相同,用户不应该能够发现差异。各种部分的作用以及数据的移动和使用方式在 图 4.15 中展示。
图 4.15. 当控制器指定数据时,它将数据作为变量传递给视图;视图使用这些数据生成最终发送给用户的 HTML。

在这个阶段结束时,数据仍然是硬编码的,但现在是控制器而不是视图。现在视图更智能,能够接受并显示发送给它们的任何数据(当然,前提是数据格式正确)。
4.5.1. 从视图中移动数据到控制器
你将从主页开始,将 locations-list.pug 视图中的数据移出,放入 locations.js 控制器文件中的 homelist 函数。从顶部开始,做一些简单的事情:页面标题。以下代码片段显示了 locations-list.pug 视图的页面标题部分,其中包含两块内容:
.row.banner
.col-12
h1 Loc8r ***1***
small Find places to work with wifi near you! ***2***
-
1 大字体页面标题
-
2 页面的小字体标语
这两块内容是你首先将移入控制器的。当前的主页控制器看起来如下所示:
const homelist = (req, res) => {
res.render('locations-list', { title: 'Home' });
};
这个控制器已经向视图发送了一块数据。记住,render 函数中的第二个参数是一个包含要发送到视图的数据的 JavaScript 对象。在这里,homelist 控制器发送了数据对象 { title: 'Home' } 到视图。这个对象被布局文件用来在 HTML <title> 中放置字符串 Home,这并不一定是最佳文本选择。
更新控制器
将标题更改为更适合页面的内容,并添加两个页面标题数据项。首先对这些更改进行控制器更新,如下(加粗部分):
const homelist = (req, res) => {
res.render('locations-list', {
title: 'Loc8r - find a place to work with wifi',
pageHeader: { ***1***
title: 'Loc8r', ***1***
strapline: 'Find places to work with wifi near you!' ***1***
} ***1***
});
};
- 1 包含页面标题和标语属性的新的嵌套 pageHeader 对象
为了整洁和未来的可管理性,标题和标语被组合在一个 pageHeader 对象中。这种做法是一个好习惯,会使控制器更容易更新和维护。
更新视图
现在控制器将这些数据片段传递给视图后,你可以更新视图以在适当位置引用它们,而不是使用硬编码的内容。像这样的嵌套数据项使用点符号引用,就像你在 JavaScript 中从对象中获取数据时那样。要引用 locations-list.pug 视图中的页面标题标语,请使用 pageHeader.strapline。以下代码片段显示了视图的页面标题部分(加粗的部分):
.row.banner
.col-12
h1= pageHeader.title ***1***
small #{pageHeader.strapline} ***2***
-
1 = 表示以下内容是缓冲代码——在这种情况下,是一个 JavaScript 对象。
-
2 #{} 定界符用于将数据插入到特定位置,例如文本的一部分。
代码在视图的相关位置输出 pageHeader.title 和 pageHeader.strapline。有关更多详细信息,请参阅侧边栏“在 Pug 模板中引用数据”。
在 Pug 模板中引用数据
在 Pug 模板中引用数据有两种主要的语法。第一种语法称为 插值,通常用于将数据插入到其他内容中间。插值数据由开始定界符 #{ 和结束定界符 } 定义。你通常像这样使用它:
h1 Welcome to #{pageHeader.title}
如果你的数据包含 HTML,出于安全考虑,它会进行转义;最终用户不会看到任何 HTML 标签作为文本显示,浏览器也不会将它们解释为 HTML。如果你想让浏览器渲染数据中包含的任何 HTML,可以使用以下语法:
h1 Welcome to !{pageHeader.title}
然而,这种语法存在潜在的安全风险,并且仅应针对你信任的数据源进行。你不应该允许用户输入以这种方式显示,除非进行一些额外的安全检查。
输出数据的第二种方法是使用 缓冲代码。不是将数据插入到字符串中,而是使用 JavaScript 直接在标签声明后使用等号 = 来构建字符串,如下所示:
h1= "Welcome to " + pageHeader.title
再次强调,这是出于安全考虑而转义任何 HTML。如果你想在输出中包含未转义的 HTML,可以使用稍微不同的语法:
h1!= "Welcome to " + pageHeader.title
一次又一次,请小心。只要有可能,你应该使用其中一种转义方法以确保安全。
对于这种缓冲代码方法,你还可以使用 JavaScript 模板字符串,如下所示:
h1= `Welcome to ${pageHeader.title}`
如果你现在运行应用程序并返回主页,你应该注意到的唯一变化是 <title> 已被更新。其他一切看起来都一样,但一些数据现在来自控制器。
这个部分是一个简单的例子,说明你现在正在做什么以及你是如何做的。主页的复杂部分是列表部分,所以在下一段中,你将了解如何处理这项任务。
4.5.2. 处理复杂、重复的数据模式
关于列表部分,首先要注意的是它有多个条目,所有条目都遵循相同的数据模式和布局模式。就像你刚刚对页面标题所做的那样,从数据开始,从视图到控制器获取数据。
在 JavaScript 数据方面,一个可重复的模式非常适合作为对象数组的想法。你想要一个数组来存储多个对象,每个对象都包含单个列表的所有相关信息。
分析视图中的数据
看看列表以确定控制器需要发送哪些信息。图 4.16 提醒你列表在主页视图中的样子。
图 4.16. 单个列表,显示你需要的数据

从这个屏幕截图,你可以看到主页上的单个列表需要以下数据要求:
-
名称
-
评分
-
距离
-
地址
-
设施列表
从图 4.16 中的屏幕截图获取数据,并从中创建一个 JavaScript 对象,你可以得到一个简单的代码片段,如下所示:
{
name: 'Starcups',
address: '125 High Street, Reading, RG6 1PS',
rating: 3,
facilities: ['Hot drinks', 'Food', 'Premium wifi'], ***1***
distance: '100m'
}
- 1 设施列表作为字符串值数组发送
这就是表示单个位置作为对象所需的所有内容。对于多个位置,你需要这些对象的数组。
将重复数据数组添加到控制器
如果你想要使用视图中的现有数据创建一个单位置对象的数组,并将其添加到控制器中传递给render函数的数据对象中。以下代码片段显示了更新的homelist控制器,包括位置数组:
const homelist = (req, res) => {
res.render('locations-list', {
title: 'Loc8r - find a place to work with wifi',
pageHeader: {
title: 'Loc8r',
strapline: 'Find places to work with wifi near you!'
},
locations: [{ ***1***
name: 'Starcups',
address: '125 High Street, Reading, RG6 1PS',
rating: 3,
facilities: ['Hot drinks', 'Food', 'Premium wifi'],
distance: '100m'
},{
name: 'Cafe Hero',
address: '125 High Street, Reading, RG6 1PS',
rating: 4,
facilities: ['Hot drinks', 'Food', 'Premium wifi'],
distance: '200m'
},{
name: 'Burger Queen',
address: '125 High Street, Reading, RG6 1PS',
rating: 2,
facilities: ['Food', 'Premium wifi'],
distance: '250m'
}]
});
};
- 1 将位置数组作为渲染的参数传递给视图
在这里,你有三个位置的详细信息被发送到数组中。当然,你可以添加更多,但这段代码是一个很好的开始。现在你需要让视图渲染这些信息,而不是目前硬编码在其中的数据。
在 Pug 视图中遍历数组
控制器正在将一个数组发送到 Pug 作为变量locations。Pug 提供了一种简单的语法来遍历数组。在一行中,你指定要使用哪个数组以及你想要用作键的变量名。键是对数组中当前项的命名引用,因此其内容在循环遍历数组时会改变。Pug 循环的结构如下:

在 Pug 中,任何嵌套在这行内的内容都会针对数组中的每个项目进行迭代。看看使用位置数据和部分视图的示例。在视图文件locations-list.pug中,每个位置都以以下代码片段开始,每次使用不同的名字:
.card
.card-block
h4
a(href="/location") Starcups
你可以使用 Pug 的each/in语法遍历locations数组中的所有位置,并输出每个位置的名字。这是如何工作的,下一代码片段将展示:
each location in locations ***1***
.card ***2***
.card-block ***2***
h4 ***2***
a(href="/location")= location.name ***3***
-
1 设置循环,定义一个变量位置作为键
-
2 嵌套项都会被遍历。
-
3 输出每个位置的名字,访问每个位置的名字属性
给定你拥有的控制器数据,其中包含三个位置,使用前面的代码与这些数据结合将产生以下 HTML:
<div class="card">
<div class="card-block">
<h4>
<a href="/location">Starcups</a>
</h4>
</div>
</div>
<div class="card">
<div class="card-block">
<h4>
<a href="/location">Cafe Hero</a>
</h4>
</div>
</div>
<div class="card">
<div class="card-block">
<h4>
<a href="/location">Burger Queen</a>
</h4>
</div>
</div>
如您所见,HTML 结构——div元素和h4以及a标签——被重复了三次。但每个位置的名称都不同,对应控制器中的数据。
遍历数组很容易,通过这个小测试,你已经得到了更新视图文本所需的前几行。现在你需要继续处理列表中使用的其余数据。你不能用这种方式处理评分星标,所以现在先忽略它们,稍后再处理。
处理其余的数据,你可以生成以下代码片段,它输出每个列表的所有数据。由于设施作为数组传递,你需要为每个列表遍历该数组:
each location in locations
.card
.card-block
h4
a(href="/location")= location.name
small
i.fas.fa-star
i.fas.fa-star
i.fas.fa-star
i.far.fa-star
i.far.fa-star
span.badge.badge-pill.badge-default.float-right= location.distance
p.address= location.address
.facilities
span.badge.badge-warning= facility ***1***
- 1 遍历嵌套数组以输出每个位置的设施
遍历facilities数组没有问题,Pug 可以轻松处理这一点。提取其余的数据,如距离和地址,使用你已经使用过的技术是直接的。
剩下要处理的部分就是评分星标。为此任务,你需要一点内联 JavaScript 代码。
4.5.3. 使用代码操作数据和视图
对于星标评分,视图正在输出带有不同类的span元素,使用 Font Awesome 的图标系统。评分系统总共有五颗星,根据评分是实心还是空心。例如,五颗星的评分显示五颗实心星;三颗星的评分显示三颗实心星和两颗空心星,如图 4.17 所示;零颗星的评分显示五颗空心星。
图 4.17. Font Awesome 星标系统在实际应用中的效果,显示五颗星中的三颗星

要生成这种类型的输出,你将在 Pug 模板中使用一些代码。代码基本上是 JavaScript,加入了一些 Pug 特定的约定。要在 Pug 模板中添加一行内联代码,请在行前加上一个连字符(hyphen)。这个前缀告诉 Pug 运行 JavaScript 代码而不是将其传递到浏览器。
要生成星标的输出,你将使用几个for循环。第一个循环输出正确数量的实心星,第二个循环输出任何剩余的空心星。以下代码片段显示了这些循环在 Pug 中的外观和工作方式:
small
- for (let i = 1; i <= location.rating; i++)
i.fas.fa-star
- for (let i = location.rating; i < 5; i++)
i.far.fa-star
注意,语法看起来很熟悉,是 JavaScript,但没有用花括号定义代码块来运行。相反,代码块是通过缩进来定义的,就像 Pug 的其他部分一样。还要注意代码和 Pug 的混合。代码行表示,“每次评估为true时,渲染缩进的 Pug 内容。”这种设计很好,因为你不需要尝试用 JavaScript 构建你的 HTML。
这样,主页的内容和布局就整理好了,你可以继续进行。你还可以做一件事来改进你已有的内容,并使一些代码可重用。
4.5.4. 使用 includes 和 mixins 创建可重用的布局组件
星级评分代码在其他布局中也会很有用。你可能想在详情页上使用它,也许未来还会在更多的地方使用。你不想手动添加到每个页面上。如果你决定不再喜欢 Font Awesome 图标并想更改标记,你当然不希望不得不在每个显示评分的页面上进行更改——如果你能避免的话。
幸运的是,Pug 允许你通过使用 mixin 和 includes 来创建可重用的组件。
定义 Pug mixins
Pug 中的mixin本质上是一个函数。你可以在文件的顶部定义一个 mixin 并在多个地方使用它。mixin 的定义很简单:你定义 mixin 的名称,然后通过缩进来嵌套其内容。以下代码片段展示了基本的 mixin 定义:
mixin welcome
p Welcome
这个定义在调用它的任何地方输出Welcome文本到<p>标签中。
Mixins 也可以接受参数,就像 JavaScript 函数一样。这个特性对于创建显示评分所需的 mixin 非常有用,因为 HTML 输出会根据实际评分而有所不同。以下代码片段展示了这个过程是如何工作的,定义了在主页上输出评分星级的 mixin:
mixin outputRating(rating) ***1***
- for (let i = 1; i <= rating; i++) ***2***
i.fas.fa-star
- for (let i = rating; i < 5; i++) ***2***
i.far.fa-star
-
1 定义期望单个参数 rating 的 mixin outputRating
-
2 使用评分参数在 for 循环中输出正确的 HTML
在某种意义上,这个 mixin 的工作方式就像一个 JavaScript 函数。当你定义 mixin 时,你可以指定它期望的参数。你可以在 mixin 中使用这些参数。你可以将前面的代码片段放入 locations-list.pug 文件的顶部,在extends layout和block content行之间。
调用 Pug mixins
在定义 mixin 之后,你当然会想要使用它。调用 mixin 的语法是在其名称前放置一个+。如果你没有参数,例如welcomemixin,这个语法看起来如下所示:
+welcome
这个语法调用welcomemixin 并在<p>标签内输出文本Welcome。
使用参数调用 mixin 同样简单。你需要在括号内发送参数的值,就像调用 JavaScript 函数时一样。在 locations-list.pug 文件中,在你输出评分的地方,评分的值存储在变量location.rating中,如下所示:
small
- for (let i = 1; i <= location.rating; i++)
i.fas.fa-star
- for (let i = location.rating; i < 5; i++)
i.far.fa-star
你可以用调用你新创建的 mixin outputRating来替换这段代码,发送location.rating变量作为参数。这个调用看起来如下代码片段:
h4
a(href='/location')= location.name
+outputRating(location.rating)
这段代码输出的 HTML 与之前完全相同,但你已经将部分代码移出了布局的内容。目前,这段代码只能在同一文件内重用,但接下来,你将使用包含来使其对其他文件可用。
在 Pug 中使用包含
要允许你的新 mixin 可以从其他 Pug 模板中调用,你需要将其制作成一个包含文件,这很简单。
在 app_server/views 文件夹内,创建一个名为 includes 的子文件夹。( 前缀是我们发现对保持此类文件夹在顶部有用的约定。)在这个文件夹内,创建一个名为 sharedHTMLfunctions.pug 的新文件,并将 outputRating mixin 定义粘贴进去,如下所示:
mixin outputRating(rating)
- for (let i = 1; i <= rating; i++)
i.fas.fa-star
- for (let i = rating; i < 5; i++)
i.far.fa-star
保存文件,你就创建了一个包含文件。Pug 提供了一种简单的语法来在布局中使用包含文件:使用关键字 include,后跟包含文件的相对路径。以下代码片段显示了如何进行操作。这一行应立即跟在 locations-list.pug 顶部的 extends layout 行之后:
include _includes/sharedHTMLfunctions
现在,你不再需要在模板中内联 mixin 代码,而是从包含文件中调用它。注意,在调用包含文件时可以省略 .pug 文件扩展名。从现在开始,当你创建一个需要包含评分星的新模板时,你可以轻松地引用这个包含文件并调用 outputRatings mixin。
现在主页已经完成了!
4.5.5. 查看完成的主页
在本章中,你对主页模板进行了相当多的修改。现在,看看你最终得到了什么。首先,看看更新的控制器。以下列表显示了最终的 homelist 控制器,它包含了标题、页面标题、侧边栏和位置列表的硬编码数据。
列表 4.10. 将硬编码数据传递给视图的 homelist 控制器
const homelist = (req, res) => {
res.render('locations-list', {
title: 'Loc8r - find a place to work with wifi', ***1***
pageHeader: { ***2***
title: 'Loc8r', ***2***
strapline: 'Find places to work with wifi near you!' ***2***
},
sidebar: "Looking for wifi and a seat? Loc8r helps you find places ***3***
to work when out and about. Perhaps with coffee, cake or a pint? ***3***
Let Loc8r help you find the place you're looking for.",
locations: [{ ***4***
name: 'Starcups',
address: '125 High Street, Reading, RG6 1PS',
rating: 3,
facilities: ['Hot drinks', 'Food', 'Premium wifi'],
distance: '100m'
},{
name: 'Cafe Hero',
address: '125 High Street, Reading, RG6 1PS',
rating: 4,
facilities: ['Hot drinks', 'Food', 'Premium wifi'],
distance: '200m'
},{
name: 'Burger Queen',
address: '125 High Street, Reading, RG6 1PS',
rating: 2,
facilities: ['Food', 'Premium wifi'],
distance: '250m'
}]
});
};
-
1 更新 HTML
的文本</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>在对象内部添加页面标题的文本作为两个条目</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>添加侧边栏的文本</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>为列表中的每个位置创建一个对象的数组</strong></p> </li> </ul> <p>看到所有这些代码放在一起,你可以开始欣赏这种方法的走向。你对 Loc8r 主页所需的所有数据有一个清晰的了解,这在第五章(<a href="https://kindle_split_016.xhtml#ch05" target="_blank">kindle_split_016.xhtml#ch05</a>)中会很有用。这个控制器包含了侧边栏的文本。我们没有讨论这一步,但从视图中将文本移到控制器中就像在控制器中为它创建一个新变量并在视图中引用它一样简单。</p> <p>通过这个过程,你实现了一个重要的事情,那就是从视图中移除了数据。用数据构建视图是一个很好的第一步,因为它允许你专注于最终用户体验,而不会被技术细节所分散。现在,你已经将数据从视图移到控制器中,你有一个更智能、更动态的视图。视图知道它需要哪些数据片段,但它不关心这些片段中的内容。以下列表显示了首页的最终视图。</p> <h5 id="列表-411-首页的最终视图app_serverviewslocations-listpug">列表 4.11. 首页的最终视图:app_server/views/locations-list.pug</h5> <pre><code>extends layout include _includes/sharedHTMLfunctions ***1*** block content .row.banner .col-12 h1= pageHeader.title ***2*** small #{pageHeader.strapline} ***2*** .row .col-12.col-md-8 each location in locations ***3*** .card .card-block h4 a(href="/location")= location.name +outputRating(location.rating) ***4*** span.badge.badge-pill.badge-default.float-right= location.distance p.address= location.address .facilities each facility in location.facilities span.badge.badge-warning= facility .col-12.col-md-4 p.lead= sidebar ***5*** </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>引入包含 outputRating 混入的外部包含文件</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>使用不同的方法输出页面标题文本</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>遍历位置数组</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>为每个位置调用 outputRating 混入,传递当前位置评分的值</strong></p> </li> <li> <p><em><strong>5</strong></em> <strong>从控制器引用侧边栏内容</strong></p> </li> </ul> <p>这是一个小模板,尤其是考虑到它所做的一切。这是 Pug 和 Bootstrap 共同作用的力量的证明,结合了移除所有内容。</p> <p>你已经接近了 MVC——以及一般开发——的目标,即关注点的分离,至少对于首页来说是这样。</p> <h4 id="456-更新其余的视图和控制器">4.5.6. 更新其余的视图和控制器</h4> <p>我们已经详细地介绍了首页的过程,但不会在其他页面上花费太多时间。在你可以进入开发下一阶段——构建数据模型——之前,你需要对所有页面进行处理。目标是所有视图中都没有数据;相反,视图将更智能,数据将硬编码到相关的控制器中。</p> <p>每个页面的处理过程如下:</p> <ol> <li> <p>查看视图中的数据。</p> </li> <li> <p>在控制器中为该数据创建一个结构。</p> </li> <li> <p>将视图中的数据替换为对控制器数据的引用。</p> </li> <li> <p>寻找代码重用的机会。</p> </li> </ol> <p>附录 C 讲解了剩余三个页面的处理过程,展示了每个控制器和视图代码应该是什么样子。当你完成时,你的视图不应包含任何硬编码的数据;每个页面的控制器应传递所需的数据。图 4.18 展示了本阶段结束时你应该拥有的最终页面的截图集合。</p> <h5 id="图-418-使用智能视图和控制器中硬编码的数据的静态原型中所有四个页面的截图">图 4.18. 使用智能视图和控制器中硬编码的数据的静态原型中所有四个页面的截图</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/04fig18_alt.jpg" alt="" loading="lazy"></p> <p>你已经完成了快速原型开发的第一个阶段,并准备好开始下一阶段。</p> <p><strong>获取源代码</strong></p> <p>应用程序到目前为止的源代码可在 GitHub 的 gettingMean-2 仓库的 chapter-04 分支上找到。在终端的新文件夹中,输入以下命令以克隆它并安装依赖项:</p> <pre><code>$ git clone -b chapter-04 https://github.com/cliveharber/gettingMean-2.git $ cd gettingMean-2 $ npm install </code></pre> <p>在第五章中,您将继续通过使用 MongoDB 和 Mongoose 创建数据模型来将数据回移到 MVC 架构中的旅程。没错;现在是数据库时间!</p> <h3 id="摘要-2">摘要</h3> <p>在本章中,您学习了</p> <ul> <li> <p>在 Express 中定义和组织路由的简单方法</p> </li> <li> <p>如何使用 Node 模块来存储控制器</p> </li> <li> <p>通过正确定义路由来设置多个控制器集的最佳方法</p> </li> <li> <p>使用 Pug 和 Bootstrap 原型化视图</p> </li> <li> <p>创建可重用 Pug 组件和混入</p> </li> <li> <p>在 Pug 模板中显示动态数据</p> </li> <li> <p>将数据从控制器传递到视图</p> </li> </ul> <h2 id="第五章-使用-mongodb-和-mongoose-构建数据模型">第五章. 使用 MongoDB 和 Mongoose 构建数据模型</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>使用 Mongoose 将 Express/Node 应用程序连接到 MongoDB</p> </li> <li> <p>使用 Mongoose 定义数据模型的模式</p> </li> <li> <p>将应用程序连接到数据库</p> </li> <li> <p>使用 MongoDB shell 管理数据库</p> </li> <li> <p>将数据库推送到实时环境</p> </li> </ul> <p>在第四章中,您将数据从视图移出,并将 MVC 路径回退到控制器。最终,控制器将数据传递给视图,但它们不应该存储它。图 5.1 回顾了 MVC 模式中的数据流。</p> <h5 id="图-51-在-mvc-模式中数据存储在模型中由控制器处理然后由视图渲染">图 5.1. 在 MVC 模式中,数据存储在模型中,由控制器处理,然后由视图渲染。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig01_alt.jpg" alt="" loading="lazy"></p> <p>对于存储数据,您需要一个数据库——具体来说,是 MongoDB。这一步是过程中的下一步:创建数据库和数据模型。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="注意-2">注意</h5> <p>如果您还没有从第四章构建应用程序,您可以在<a href="https://github.com/cliveharber/gettingMean-2" target="_blank"><code>github.com/cliveharber/gettingMean-2</code></a>的 chapter-04 分支上获取代码。在终端的新文件夹中,输入以下命令以克隆它:</p> <pre><code>$ git clone -b chapter-04 https://github.com/cliveharber/gettingMean-2.git </code></pre> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>您将首先连接应用程序到数据库,然后使用 Mongoose 定义模式和模型。当您对结构满意时,可以直接向 MongoDB 数据库添加一些测试数据。最后一步是确保当推送到 Heroku 时,对数据存储的访问也能正常工作。图 5.2 显示了这四个步骤的流程。</p> <h5 id="图-52-本章的四个主要步骤从将应用程序连接到数据库到将整个内容推送到实时环境">图 5.2. 本章的四个主要步骤,从将应用程序连接到数据库到将整个内容推送到实时环境</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig02_alt.jpg" alt="" loading="lazy"></p> <p>对于那些担心遗漏了一两个部分的人,不用担心;您还没有创建数据库。而且您不需要。在各种其他技术堆栈中,这种情况可能会引发问题并抛出错误。但与 MongoDB 一起,您在尝试使用它之前不需要创建数据库。图 5.3 显示了本章在整体架构方面的重点。</p> <h5 id="图-53-查看-mongodb-数据库并在-express-中使用-mongoose-模型数据和管理数据库连接">图 5.3. 查看 MongoDB 数据库,并在 Express 中使用 Mongoose 模型数据和管理数据库连接</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig03_alt.jpg" alt="" loading="lazy"></p> <p>你将使用 MongoDB 数据库,但大部分工作将在 Express 和 Node 中完成。在第二章中,我们讨论了通过创建 API 而不是将数据紧密集成到主 Express 应用程序中来解耦数据集成的好处。尽管你将在 Express 和 Node 中工作,并且仍然在同一个封装的应用程序中,但你将开始构建你的 API 层的基础。</p> <h5 id="注意-3">注意</h5> <p>为了继续本章的学习,你需要安装 MongoDB。如果你还没有安装,可以在附录 A 中找到安装说明。本章末尾应用程序的源代码可在 GitHub 的 chapter-05 分支上找到。在终端的新文件夹中,输入以下命令以克隆它并安装 npm 模块依赖项:</p> <pre><code>$ git clone -b chapter-05 https://github.com/cliveharber/gettingMean-2.git $ cd gettingMean-2 $ npm install </code></pre> <h3 id="51-通过-mongoose-将-express-应用程序连接到-mongodb">5.1. 通过 Mongoose 将 Express 应用程序连接到 MongoDB</h3> <p>你可以直接将应用程序连接到 MongoDB,并使用原生驱动程序使两者交互。尽管原生 MongoDB 驱动程序功能强大,但使用起来并不特别容易。它也没有提供一种内置的方式来定义和维护数据结构。Mongoose 提供了原生驱动程序的大部分功能,但以更方便的方式提供,旨在适应应用程序开发的流程。</p> <p>Mongoose 真正出色之处在于它使你能够定义数据结构和管理模型,并使用它们从应用程序代码中与数据库交互。作为这种方法的一部分,Mongoose 包括向你的数据定义添加验证的能力,这意味着你不需要在应用程序中每个发送数据回数据库的地方都编写验证代码。</p> <p>Mongoose 通过作为应用程序和数据库之间的联络人而融入 Express 应用程序中,如图 5.4 所示。</p> <h5 id="图-54-mean-栈中的数据交互以及-mongoose-的位置nodeexpress-应用程序通过-mongoose-与-mongodb-交互node-和-express-也可以与-angular-通信">图 5.4. MEAN 栈中的数据交互以及 Mongoose 的位置。Node/Express 应用程序通过 Mongoose 与 MongoDB 交互;Node 和 Express 也可以与 Angular 通信。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig04_alt.jpg" alt="" loading="lazy"></p> <p>MongoDB 只与 Mongoose 通信,而 Mongoose 又与 Node 和 Express 通信。Angular 不会直接与 MongoDB 或 Mongoose 通信——只与 Express 应用程序通信。</p> <p>你应该已经在系统上安装了 MongoDB(在附录 A 中有介绍),但没有安装 Mongoose。Mongoose 不是全局安装,而是直接添加到你的应用程序中。你将在下一节中这样做。</p> <h4 id="511-将-mongoose-添加到你的应用程序中">5.1.1. 将 Mongoose 添加到你的应用程序中</h4> <p>Mongoose 可作为 npm 模块使用。正如你在第三章中看到的,安装 npm 模块最快、最简单的方法是通过命令行。你可以使用一条命令安装 Mongoose 并将其添加到 package.json 中的依赖列表中。</p> <p>转到终端,并确保提示符位于应用程序的根目录,即 package.json 文件所在的目录。然后运行以下命令:</p> <pre><code>$ npm i mongoose </code></pre> <p>在这里,我们使用了一个替代版本;这个版本可以节省输入。当该命令运行完成后,你将在应用程序的 node_modules 文件夹内看到一个名为 mongoose 的新文件夹,package.json 文件中的依赖项部分应该看起来像以下代码片段:</p> <pre><code>"dependencies": { "body-parser": "~1.18.3", "cookie-parser": "~1.4.3", "debug": "~4.1.0", "express": "~4.16.4", "mongoose": "⁵.3.11", "morgan": "~1.9.1", "pug": "~2.0.3", "serve-favicon": "~2.5.0" } </code></pre> <p>当然,你可能会有略微不同的版本号,但截至目前,Mongoose 的最新稳定版本是 5.3.11。现在 Mongoose 已经安装,你就可以准备将其连接到应用程序了。</p> <h4 id="512-将-mongoose-连接添加到你的应用程序">5.1.2. 将 Mongoose 连接添加到你的应用程序</h4> <p>在这个阶段,你将连接应用程序到数据库。你还没有创建数据库,但这没关系,因为当你首次尝试使用 MongoDB 时,它会自动创建一个数据库。这可能会有些奇怪,但对于构建应用程序来说,这是一个巨大的优势:你不需要在应用程序代码中留下混乱,去操作不同的环境。</p> <h5 id="mongodb-和-mongoose-连接">MongoDB 和 Mongoose 连接</h5> <p>当 Mongoose 连接到 MongoDB 数据库时,它会打开一个包含五个可重用连接的池。这个连接池被所有请求共享。五个是默认数量;如果你需要,可以增加或减少连接选项。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="最佳实践提示">最佳实践提示</h5> <p>打开和关闭数据库连接可能需要一点时间,尤其是如果你的数据库位于单独的服务器或服务上。最好只在需要时运行这些操作。最佳实践是在应用程序启动时打开连接,并在应用程序重启或关闭时保持连接打开。这就是你将要采取的方法。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="设置连接文件">设置连接文件</h5> <p>当你首次整理应用程序的文件结构时,你在 app_server 文件夹内创建了三个文件夹:models、views 和 controllers。对于处理数据和模型,你将主要位于 app_server/models 文件夹。设置连接文件是一个两步过程——创建文件并将其引入应用程序以便使用:</p> <ul> <li> <p>第 1 步:在 app_server/models 中创建一个名为 db.js 的文件,并保存它。目前,你将使用以下单个命令行在文件中 <code>require</code> Mongoose:</p> <pre><code>const mongoose = require('mongoose'); </code></pre> </li> <li> <p>第 2 步:通过在 app.js 中引入此文件将此文件引入应用程序。由于创建应用程序和数据库之间的连接可能需要一点时间,因此你希望在设置初期就完成这项操作。修改 app.js 的顶部部分,使其看起来像以下代码片段(粗体表示修改):</p> <pre><code>const express = require('express'); const path = require('path'); const cookieParser = require('cookie-parser'); const logger = require('morgan'); const favicon = require('serve-favicon'); require('./app_server/models/db'); </code></pre> </li> </ul> <p>你不需要从 db.js 导出任何函数,因此你不需要在 <code>require</code> 它时将其分配给变量。你需要它在应用程序中存在,但你不需要在 app.js 中调用其任何方法。</p> <p>如果您重新启动应用程序,它应该像以前一样运行,但现在您在应用程序中有了 Mongoose。如果您遇到错误,请检查<code>require</code>语句中的路径是否与新文件的路径匹配,您的 package.json 是否包含 Mongoose 依赖项,并且您是否已从应用程序根目录的终端中运行了<code>npm install</code>。</p> <h5 id="创建-mongoose-连接">创建 Mongoose 连接</h5> <p>创建一个 Mongoose 连接可以像声明数据库的 URI 并将其传递给 Mongoose 的<code>connect</code>方法一样简单。数据库 URI 是一个遵循以下结构的字符串:</p> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/p0124-01_alt.jpg" alt="" loading="lazy"></p> <p>用户名、密码和端口是可选的。在您的本地机器上,您的数据库 URI 将很简单。目前,假设您已经在本地机器上安装了 MongoDB,将以下代码片段添加到 db.js 中就足够创建一个连接:</p> <pre><code>const dbURI = 'mongodb://localhost/Loc8r'; mongoose.connect(dbURI, {useNewUrlParser: true}); </code></pre> <p><code>connect()</code>的第二个参数告诉 Mongoose 使用其新的内部 URL 解析器,这避免了由于 MongoDB 弃用而产生的弃用警告,但仍然保留了旧的连接字符串解析器。如果您在 db.js 中添加此修改后运行应用程序,它应该像以前一样启动并运行。那么您如何知道您的连接是否正常工作呢?答案在于连接事件。</p> <h5 id="使用-mongoose-连接事件监控连接">使用 Mongoose 连接事件监控连接</h5> <p>Mongoose 根据连接的状态发布事件,并且这些事件很容易挂钩,以便您可以查看正在发生的事情。您将使用事件来查看何时建立连接、何时出现错误以及何时断开连接。当这些事件中的任何一个发生时,您将在控制台记录一条消息。以下代码片段显示了所需的代码:</p> <pre><code>mongoose.connection.on('connected', () => { *1* console.log(`Mongoose connected to ${dbURI}`); *1* }); *1* mongoose.connection.on('error', err => { *2* console.log('Mongoose connection error:', err); *2* }); *2* mongoose.connection.on('disconnected', () => { *3* console.log('Mongoose disconnected'); *3* }); *3* </code></pre> <ul> <li> <p><strong><em>1</em> 监听通过 Mongoose 的成功连接</strong></p> </li> <li> <p><strong><em>2</em> 检查连接错误</strong></p> </li> <li> <p><strong><em>3</em> 检查断开连接事件</strong></p> </li> </ul> <p>在 db.js 中添加此代码后,当您重新启动应用程序时,您应该在终端窗口中看到以下确认信息:</p> <pre><code>Express server listening on port 3000 Mongoose connected to mongodb://localhost/Loc8r </code></pre> <p>然而,如果您再次重新启动应用程序,您会注意到您没有收到任何断开连接的消息,因为当应用程序停止或重新启动时,Mongoose 连接不会自动关闭。您需要监听 Node 进程的变化来处理这种情况。</p> <h5 id="关闭-mongoose-连接">关闭 Mongoose 连接</h5> <p>当应用程序停止时关闭 mongoose 连接与在启动时打开连接一样,是最佳实践的一部分。连接有两个端点:一个在您的应用程序中,一个在 MongoDB 中。MongoDB 需要知道您何时想要关闭连接,以便它不会保持冗余的连接打开。</p> <p>要监听应用程序停止时的情况,您需要监听 Node.js 进程的名为 SIGINT 的事件。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>在 Windows 上监听 SIGINT</strong></p> <p>SIGINT 是操作系统级别的信号,在基于 UNIX 的系统(如 Linux 和 macOS)上触发。它也在一些 Windows 的较新版本上触发。如果你在 Windows 上运行,并且断开连接事件没有触发,你可以模拟它们。如果你需要在 Windows 上模拟此行为,首先向你的应用程序添加一个新的 npm 包,<code>readline</code>。像之前一样,使用命令行中的 <code>npm install</code> 命令,如下所示:</p> <pre><code>$ npm install --save readline </code></pre> <p>完成后,在 db.js 文件中,在事件监听器代码上方添加以下内容:</p> <pre><code>const readLine = require ('readline'); if (process.platform === 'win32'){ const rl = readLine.createInterface ({ input: process.stdin, output: process.stdout }); rl.on ('SIGINT', () => { process.emit ("SIGINT"); }); } </code></pre> <p>此代码在 Windows 机器上发出 SIGINT 信号,允许你捕获它,并在进程结束前优雅地关闭其他任何需要关闭的内容。</p> <p>如果你使用 nodemon 自动重新启动应用程序,你还需要监听 Node 进程上的第二个事件:SIGUSR2。Heroku 使用不同的事件,SIGTERM,因此你需要监听该事件。</p> <h5 id="捕获进程终止事件">捕获进程终止事件</h5> <p>捕获这些事件可以防止默认行为发生。你需要确保手动重新启动所需的操作(当然是在关闭 Mongoose 连接之后)。</p> <p>要做到这一点,你需要三个事件监听器和一個用于关闭数据库连接的函数。关闭数据库是一个异步操作,因此你需要传递一个回调函数来重启或结束 Node 进程。同时,你可以在控制台输出一条消息,确认连接已关闭以及关闭的原因。你可以在 db.js 中将这些内容包装在一个名为 <code>gracefulShutdown</code> 的函数中:</p> <pre><code>const gracefulShutdown = (msg, callback) => { *1* mongoose.connection.close( () => { *2* console.log(`Mongoose disconnected through ${msg}`); *3* callback(); *3* }); }; </code></pre> <ul> <li> <p><strong><em>1</em> 定义一个函数以接受消息和回调函数</strong></p> </li> <li> <p><strong><em>2</em> 关闭 Mongoose 连接,通过匿名函数在关闭时运行</strong></p> </li> <li> <p><strong><em>3</em> 输出消息并在 Mongoose 连接关闭时调用回调函数</strong></p> </li> </ul> <p>当应用程序终止或 nodemon 重新启动它时,你需要调用此函数。以下代码片段显示了你需要添加到 db.js 中的两个事件监听器,以便实现此功能:</p> <pre><code>process.once('SIGUSR2', () => { *1* gracefulShutdown('nodemon restart', () => { *2* process.kill(process.pid, 'SIGUSR2'); *2* }); }); process.on('SIGINT', () => { *3* gracefulShutdown('app termination', () => { *4* process.exit(0); *4* }); }); process.on('SIGTERM', () => { *5* gracefulShutdown('Heroku app shutdown', () => { *6* process.exit(0); *6* }); }); </code></pre> <ul> <li> <p><strong><em>1</em> 监听 SIGUSR2,这是 nodemon 使用的</strong></p> </li> <li> <p><strong><em>2</em> 向 graceful-Shutdown 发送消息,向终止进程发送回调函数,再次发出 SIGUSR2</strong></p> </li> <li> <p><strong><em>3</em> 监听应用程序终止时发出的 SIGINT 信号</strong></p> </li> <li> <p><strong><em>4</em> 向 gracefulShutdown 发送消息,向退出 Node 进程发送回调函数</strong></p> </li> <li> <p><strong><em>5</em> 监听 Heroku 关闭进程时发出的 SIGTERM 信号</strong></p> </li> <li> <p><strong><em>6</em> 向 gracefulShutdown 发送消息,向退出 Node 进程发送回调函数</strong></p> </li> </ul> <p>现在,当应用程序终止时,它会在结束前优雅地关闭 Mongoose 连接。同样,当 nodemon 由于源文件更改而重新启动应用程序时,应用程序首先关闭当前的 Mongoose 连接。nodemon 监听器使用 <code>process.once</code> 而不是 <code>process.on</code>,因为你只想监听 SIGUSR2 事件一次。nodemon 也监听相同的事件,你不希望每次都捕获它,以防止 nodemon 无法工作。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="小贴士-3">小贴士</h5> <p>在你创建的每个应用程序中正确管理打开和关闭数据库连接非常重要。如果你使用具有不同进程终止信号的环境,你应该确保你监听所有这些信号。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="完整的连接文件">完整的连接文件</h5> <p>你已经向 db.js 文件添加了很多内容,所以花点时间回顾一下。到目前为止,你已经</p> <ul> <li> <p>定义了数据库连接字符串</p> </li> <li> <p>在应用程序启动时打开了 Mongoose 连接</p> </li> <li> <p>监控了 Mongoose 连接事件</p> </li> <li> <p>监控了一些 Node 进程事件,以便在应用程序结束时关闭 Mongoose 连接</p> </li> </ul> <p>总体而言,db.js 文件应如下所示。注意,它包括 Windows 生成 SIGINT 事件所需的额外代码。</p> <h5 id="列表-51-完整数据库连接文件-dbjs-在-app_servermodels">列表 5.1. 完整数据库连接文件 db.js 在 app_server/models</h5> <pre><code>const mongoose = require('mongoose'); const dbURI = 'mongodb://localhost/Loc8r'; *1* mongoose.connect(dbURI, {useNewUrlParser: true}); *1* mongoose.connection.on('connected', () => { *2* console.log(`Mongoose connected to ${dbURI}`); *2* }); *2* mongoose.connection.on('error', err => { *2* console.log(`Mongoose connection error: ${err}`); *2* }); *2* mongoose.connection.on('disconnected', () => { *2* console.log('Mongoose disconnected'); *2* }); *2* const gracefulShutdown = (msg, callback) => { *3* mongoose.connection.close( () => { *3* console.log(`Mongoose disconnected through ${msg}`); *3* callback(); *3* }); *3* }; *3* // For nodemon restarts *4* process.once('SIGUSR2', () => { *4* gracefulShutdown('nodemon restart', () => { *4* process.kill(process.pid, 'SIGUSR2'); *4* }); *4* }); *4* // For app termination *4* process.on('SIGINT', () => { *4* gracefulShutdown('app termination', () => { *4* process.exit(0); *4* }); *4* }); *4* // For Heroku app termination *4* process.on('SIGTERM', () => { *4* gracefulShutdown('Heroku app shutdown', () => { *4* process.exit(0); *4* }); *4* }); *4* </code></pre> <ul> <li> <p><strong><em>1</em> 定义数据库连接字符串并使用它打开 Mongoose 连接</strong></p> </li> <li> <p><strong><em>2</em> 监听 Mongoose 连接事件并将状态输出到控制台</strong></p> </li> <li> <p><strong><em>3</em> 可重用函数用于关闭 Mongoose 连接</strong></p> </li> <li> <p><strong><em>4</em> 监听 Node 进程的终止或重启信号,并在适当的时候调用 gracefulShutdown 函数,传递一个延续回调</strong></p> </li> </ul> <p>当你有一个这样的文件时,你可以轻松地从应用程序复制到另一个应用程序,因为你正在监听的事件总是相同的。每次你只需要更改数据库连接字符串。记住,你也在 app.js 中<code>require</code>了这个文件,就在顶部附近,这样连接就可以在应用程序的生命周期早期打开。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>使用多个数据库</strong></p> <p>你到目前为止看到的是默认连接,非常适合在整个应用程序运行期间保持单个连接打开。但如果你想要连接到第二个数据库,比如用于日志记录或管理用户会话,你可以使用命名连接。在<code>mongoose.connect</code>方法的地方,你会使用一个名为<code>mongoose.createConnection</code>的方法,并将其分配给一个变量。你可以在下面的代码片段中看到这一点:</p> <pre><code>const dbURIlog = 'mongodb://localhost/Loc8rLog'; const logDB = mongoose.createConnection(dbURIlog); </code></pre> <p>此代码片段创建了一个名为<code>logDB</code>的新 Mongoose 连接对象。你可以像与<code>mongoose.connection</code>默认连接一样与之交互。这里有一些例子:</p> <pre><code>logDB.on('connected', () => { *1* console.log(`Mongoose connected to ${dbURIlog}`); *1* }); *1* logDB.close( () => { *2* console.log('Mongoose log disconnected'); *2* }); *2* </code></pre> <ul> <li> <p><strong><em>1</em> 监听命名连接的连接事件</strong></p> </li> <li> <p><strong><em>2</em> 关闭命名连接</strong></p> </li> </ul> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h3 id="52-为什么对数据进行建模">5.2. 为什么对数据进行建模?</h3> <p>在第一章中,我们讨论了 MongoDB 是一个文档存储,而不是使用行和列的传统表式数据库。这一事实赋予了 MongoDB 极大的自由度和灵活性,但有时你希望——或者<em>需要</em>——对数据进行结构化。</p> <p>以 Loc8r 首页为例。如图 5.5 所示,列表部分包含一个对所有位置都通用的特定数据集。</p> <h5 id="图-55-首页列表部分已定义数据需求和结构">图 5.5. 首页列表部分已定义数据需求和结构</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig05_alt.jpg" alt="" loading="lazy"></p> <p>页面需要所有位置的数据项,并且每个位置的数据记录必须有一个一致的命名结构。没有这个结构,应用程序将无法找到并使用数据。在开发的这个阶段,数据被保存在控制器中,并传递到视图中。从 MVC 架构的角度来看,你从<em>视图</em>中的数据开始,然后将其退回一步到<em>控制器</em>。现在你需要做的是将其退回最后一步到它应该属于的地方:在<em>模型</em>中。图 5.6 说明了你当前的位置,突出了目标。</p> <h5 id="图-56-mvc-模式中数据应该如何流动从模型通过控制器到视图在你当前的原型中你的数据在控制器中所以你想要将其退回一步到模型">图 5.6. MVC 模式中数据应该如何流动,从模型通过控制器到视图。在你当前的原型中,你的数据在控制器中,所以你想要将其退回一步到模型。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig06_alt.jpg" alt="图片" loading="lazy"></p> <p>将数据逐步通过 MVC 流程,正如你迄今为止所做的那样,一个结果是它有助于巩固数据结构的要求,确保数据结构准确地反映了你应用程序的需求。如果你首先尝试定义模型,你最终会猜测应用程序的外观和工作方式。</p> <p>当你谈论数据建模时,你是在描述你希望数据如何结构化。在你的应用程序中,你可以手动创建和管理定义,并自己进行大量工作,或者你可以使用 Mongoose 并让它做艰苦的工作。</p> <h4 id="521-什么是-mongoose-以及它是如何工作的">5.2.1. 什么是 Mongoose 以及它是如何工作的?</h4> <p>Mongoose 专门为 Node 应用程序构建,作为 MongoDB 对象文档模型器(ODM)。一个关键原则是你可以从你的应用程序内部管理你的数据模型。你不必直接与数据库或外部框架或关系型映射器打交道;你可以在应用程序的舒适环境中定义你的数据模型。</p> <p>首先,我们将解决一些命名约定:</p> <ul> <li> <p>在 MongoDB 中,数据库中的每个条目被称为<em>文档</em>。</p> </li> <li> <p>在 MongoDB 中,一组文档被称为<em>集合</em>。(如果你习惯于关系型数据库,可以将其视为<em>表</em>。)</p> </li> <li> <p>在 Mongoose 中,文档的定义被称为<em>模式</em>。</p> </li> <li> <p>在模式中定义的每个单独的数据实体被称为<em>路径</em>。</p> </li> </ul> <p>以名片堆叠为例,图 5.7 展示了这些命名约定以及它们是如何相互关联的。</p> <h5 id="图-57-mongodb-和-mongoose-中集合文档模式和路径之间的关系使用名片隐喻">图 5.7. MongoDB 和 Mongoose 中集合、文档、模式和路径之间的关系,使用名片隐喻</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig07_alt.jpg" alt="图片" loading="lazy"></p> <p>最后一个定义是针对模型的。<em>模型</em>是模式的编译版本。所有使用 Mongoose 的数据交互都通过模型进行。你将在第六章中更多地使用模型,但就目前而言,你正专注于构建它们。</p> <h4 id="522-mongoose-如何建模数据">5.2.2. Mongoose 如何建模数据?</h4> <p>如果您在应用程序中定义数据,您将如何做?当然是在 JavaScript 中——更确切地说,是 JavaScript 对象。您已经在图 5.7 中瞥见了,但现在请看看一个简单的 MongoDB 文档,看看它的 Mongoose 模式可能是什么样子。以下代码片段显示了一个 MongoDB 文档,后面跟着相应的 Mongoose 模式:</p> <pre><code>{ *1* "firstname" : "Simon", *1* "surname" : "Holmes", *1* _id : ObjectId("52279effc62ca8b0c1000007") *1* } *1* { *2* firstname : String, *2* surname : String *2* } *2* </code></pre> <ul> <li> <p><strong><em>1</em> 示例 MongoDB 文档</strong></p> </li> <li> <p><strong><em>2</em> 对应的 Mongoose 模式</strong></p> </li> </ul> <p>如您所见,模式与数据本身有很强的相似性。模式定义了每个数据路径的名称及其将包含的数据类型。在这个例子中,您只是简单地将路径<code>firstname</code>和<code>surname</code>声明为字符串。</p> <p><strong>关于 _id 路径</strong></p> <p>您可能已经注意到,您在模式中没有声明<code>id</code>路径。<code>_id</code>是唯一标识符——如果您愿意,可以称为主键——对于每个文档。MongoDB 在创建每个文档时自动创建此路径,并分配一个唯一的<code>ObjectId</code>值。该值的设计是通过结合自 UNIX 纪元以来的时间、机器和进程标识符以及一个计数器来确保始终唯一。</p> <p>如果您更喜欢使用自己的唯一键系统(例如,如果您有一个现有的数据库),这是可能的。在这本书和 Loc8r 应用程序中,您将坚持使用默认的<code>ObjectId</code>。</p> <h4 id="523-拆分模式路径">5.2.3. 拆分模式路径</h4> <p>单个路径定义的基本结构是路径名称后跟一个属性对象。在前面的例子中,您查看了一个 Mongoose 模式,它展示了定义数据路径及其数据类型的一种简写方式。模式路径由路径名称和属性对象组成,如下所示:</p> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/p0132-01.jpg" alt="" loading="lazy"></p> <p><strong>允许的模式类型</strong></p> <p>模式类型是定义给定路径数据类型的属性。对于所有路径都是必需的。如果路径的唯一属性是类型,您可以使用简写定义。您可以使用以下八种模式类型:</p> <ul> <li> <p><code>String—</code>任何字符串,UTF-8 编码。</p> </li> <li> <p><code>Number—</code>Mongoose 不支持长或双精度数字,但可以使用 Mongoose 插件进行扩展;在大多数情况下,默认支持已经足够。</p> </li> <li> <p><code>Date—</code>通常由 MongoDB 作为<code>ISODate</code>对象返回。</p> </li> <li> <p><code>Boolean—</code>真或假。</p> </li> <li> <p><code>Buffer—</code>用于二进制信息,如图像。</p> </li> <li> <p><code>Mixed—</code>任何数据类型。</p> </li> <li> <p><code>Array—</code>可以是相同数据类型的数组或嵌套子文档的数组。</p> </li> <li> <p><code>ObjectId—</code>用于除 _id 之外的路径的唯一 ID;通常用于引用其他文档中的 _id 路径。</p> </li> </ul> <p>如果您需要使用不同的模式类型,您可以编写自己的自定义模式类型,或者使用来自<a href="http://plugins.mongoosejs.io" target="_blank"><code>plugins.mongoosejs.io</code></a>的现有 Mongoose 插件。</p> <p>路径名遵循 JavaScript 对象定义的约定和要求。没有空格或特殊字符,你应该尽量避免使用保留词。我们的约定是使用 camelCase 作为路径名。如果你正在使用现有的数据库,请使用文档中已经存在的路径名。如果你正在创建一个新的数据库,模式中的路径名将用于文档,所以请仔细思考。</p> <p>属性对象本质上是一个 JavaScript 对象。这个对象定义了路径中包含的数据的特征。至少,这个对象包含数据类型,但它可以包括验证特征、边界、默认值等。在接下来的几章中,当你将 Loc8r 转变为一个数据驱动应用程序时,你将探索并使用这些选项中的一些。</p> <p>在下一节中,你将开始定义应用程序中需要的模式。</p> <h3 id="53-定义简单的-mongoose-模式">5.3. 定义简单的 Mongoose 模式</h3> <p>我们讨论了这样一个事实,即 Mongoose 模式本质上是一个 JavaScript 对象,你可以在应用程序内部定义它。首先,设置并包含该文件,以便完成并移除它,这样你就可以自由地专注于模式。</p> <p>如你所料,你将在模型文件夹中定义模式,与 db.js 文件并列。实际上,你将把它<code>require</code>到 db.js 中,以便将其暴露给应用程序。在 app_server/models 文件夹中,创建一个名为 locations.js 的新空文件。你需要 Mongoose 来定义一个 Mongoose 模式,这是自然的,所以进入以下行到 locations.js:</p> <pre><code>const mongoose = require('mongoose'); </code></pre> <p>你将通过在 db.js 中添加一个<code>require</code>来将此文件引入应用程序。在 db.js 的末尾添加以下行:</p> <pre><code>require('./locations'); </code></pre> <p>有了这些,你就已经设置好了,准备出发了。</p> <h4 id="531-设置模式的基本知识">5.3.1. 设置模式的基本知识</h4> <p>Mongoose 为你提供了一个用于定义新模式的构造函数,你通常将其分配给一个变量,以便以后可以访问它。这个函数看起来像以下行:</p> <pre><code>const locationSchema = new mongoose.Schema({ }); </code></pre> <p>实际上,这正是你将要使用的结构。将其添加到 locations.js 模型中,在<code>require</code> Mongoose 的行下面。<code>mongoose-Schema({ })</code>括号内的空对象是你要定义模式的地方。</p> <h5 id="从控制器数据定义模式">从控制器数据定义模式</h5> <p>将数据从视图移回到控制器的一个结果是你将能够很好地了解你需要的数据结构。从查看 app_server/controllers/locations.js 中的<code>homelist</code>控制器开始,简单起见。<code>homelist</code>控制器将要在主页上显示的数据传递给视图。图 5.8 显示了主页上某个位置的外观。</p> <h5 id="图-58-主页列表中显示的单个位置">图 5.8. 主页列表中显示的单个位置</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig08_alt.jpg" alt="Images/05fig08_alt.jpg" loading="lazy"></p> <p>以下代码片段显示了控制器中找到的此位置的数据:</p> <pre><code>locations: [{ name: 'Starcups', *1* address: '125 High Street, Reading, RG6 1PS', *2* rating: 3, *3* facilities: ['Hot drinks', 'Food', 'Premium wifi'], *4* distance: '100m' }] </code></pre> <ul> <li> <p><strong>1</strong> 级名称是一个字符串。</p> </li> <li> <p><strong>2</strong> 级地址是另一个字符串。</p> </li> <li> <p><strong>3</strong> 级评分是一个数字。</p> </li> <li> <p><strong>4</strong> 级设施是一个字符串数组。</p> </li> </ul> <p>你稍后会回到距离,因为那需要计算。其他四个数据项相当直接:两个字符串,一个数字和一个字符串数组。根据你目前所知,你可以使用这些信息来定义一个基本的模式,如下所示:</p> <pre><code>const locationSchema = new mongoose.Schema({ name: String, address: String, rating: Number, facilities: [String] *1* }); </code></pre> <ul> <li><strong><em>1</em> 通过在方括号内声明类型来声明相同模式类型的数组</strong></li> </ul> <p>注意声明设施为数组的简单方法 <em><strong>1</strong></em>。如果你的数组将只包含一种模式类型,例如 <code>String</code>,你可以通过将模式类型括在方括号中来定义它。</p> <h5 id="分配默认值">分配默认值</h5> <p>在某些情况下,当根据你的模式创建新的 MongoDB 文档时设置默认值是有用的。在 <code>locationSchema</code> 中,<code>rating</code> 路径是一个很好的候选者。当新的位置添加到数据库中时,它还没有任何评论,因此没有评分。但你的视图期望评分在零到五星级之间,这正是控制器需要传递的。</p> <p>你想要做的是为每个新文档的评分设置默认值 <code>0</code>。Mongoose 允许你在模式内部这样做。记住 <code>rating: Number</code> 是 <code>rating: {type: Number}</code> 的简写吗?嗯,你可以在定义对象中添加其他选项,包括默认值。这意味着你可以像以下这样更新模式中的评分路径:</p> <pre><code>rating: { type: Number, 'default': 0 } </code></pre> <p>单词 <code>default</code> 不 <em>必须</em> 用引号括起来,但在 JavaScript 中是一个保留字;因此,使用引号是一个好主意。</p> <h5 id="添加一些基本验证必需字段">添加一些基本验证:必需字段</h5> <p>通过 Mongoose,你可以在模式级别快速添加一些基本验证。这种做法有助于维护数据完整性,并可以保护你的数据库免受缺失或格式错误的数据的影响。Mongoose 的助手使得添加一些最常见的验证任务变得容易,这意味着你不必每次都编写或导入代码。</p> <p>这种类型验证的第一个例子确保在将文档保存到数据库之前,所需的字段不为空。你不必在代码中为每个所需的字段编写检查,而是可以在你决定应强制执行的每个路径的定义对象中添加 <code>required: true</code> 标志。在 <code>locationSchema</code> 中,你当然想确保每个位置都有一个名称,因此你可以像这样更新名称路径:</p> <pre><code>name: { type: String, required: true } </code></pre> <p>如果你尝试保存一个没有名称的位置,Mongoose 会返回一个验证错误,你可以在代码中立即捕获它,而无需往返数据库。</p> <h5 id="添加一些基本验证数字边界">添加一些基本验证:数字边界</h5> <p>你可以使用类似的技术来定义你想要为数字路径设置的最大和最小值。这些验证器称为 <code>max</code> 和 <code>min</code>。你拥有的每个位置都有一个评分分配给它,你给它设置了默认值 <code>0</code>。该值不应小于 <code>0</code> 或大于 <code>5</code>。如下更新 <code>rating</code> 路径:</p> <pre><code>rating: { type: Number, 'default': 0, min: 0, max: 5 } </code></pre> <p>通过这次更新,Mongoose 不会让你保存小于<code>0</code>或大于<code>5</code>的评分值。如果你尝试这样做,它会返回一个验证错误,你可以在代码中处理。这种方法的一个优点是,应用程序不需要往返数据库来检查边界。另一个优点是,你不需要在应用程序中可能添加、更新或计算评分值的每个地方编写验证代码。</p> <h4 id="532-在-mongodb-和-mongoose-中使用地理数据">5.3.2. 在 MongoDB 和 Mongoose 中使用地理数据</h4> <p>当你开始将应用程序的数据从控制器映射到 Mongoose 模式时,你将距离问题留到了以后。现在,我们来讨论你将如何处理地理信息。</p> <p>MongoDB 可以将地理数据存储为经纬度坐标,甚至可以根据这些数据创建和管理索引。这种能力使用户能够快速搜索彼此靠近或靠近特定经纬度的地点——这对于构建基于位置的应用程序非常有帮助!</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>关于 MongoDB 索引</strong></p> <p>任何数据库系统中的索引都能使查询更快、更高效,MongoDB 也不例外。当一个路径被索引时,MongoDB 可以使用这个索引快速获取数据子集,而无需扫描集合中的所有文档。</p> <p>想象一下你可能在家的文件系统。假设你需要找到一张特定的信用卡对账单。你可能把所有的文件都放在一个抽屉或柜子里。如果所有东西都是随机放入的,你将不得不翻阅所有类型的无关文件,直到找到你想要的东西。然而,如果你已经将文件索引到文件夹中,你可以快速找到你的信用卡文件夹。当你挑选出这个文件夹后,你只需查看这一组文件,这使得你的搜索更加高效。</p> <p>这种场景类似于数据库中索引的工作方式。然而,在数据库中,每个文档可以有多个索引,这使你能够针对不同的查询进行高效搜索。</p> <p>索引确实需要维护和数据库资源,因为正确归档文件需要时间。为了获得最佳的整体性能,尽量将数据库索引限制在需要索引且用于大多数查询的路径上。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>单个地理位置的数据根据 GeoJSON 格式规范存储,你很快就会看到它的实际应用。Mongoose 支持这种数据类型,允许你在模式中定义地理空间路径。由于 Mongoose 是 MongoDB 之上的抽象层,它努力使事情变得更容易。你只需在模式中添加 GeoJSON 路径:</p> <ol> <li> <p>将路径定义为数字类型的数组。</p> </li> <li> <p>将路径定义为具有<code>2dsphere</code>索引。</p> </li> </ol> <p>要将此付诸实践,你可以在位置模式中添加一个<code>coords</code>路径。如果你遵循前两个步骤,你的模式应该看起来像这样:</p> <pre><code>const locationSchema = new mongoose.Schema({ name: { type: String, required: true }, address: String, rating: { type: Number, 'default': 0, min: 0, max: 5 }, facilities: [String], coords: { type: { type: String }, coordinates: [Number] } }); locationSchema.index({coords: '2dsphere'}); </code></pre> <p>这里的<code>2dsphere</code>是关键部分,因为它使得 MongoDB 在运行查询和返回结果时能够进行正确的计算。它允许 MongoDB 根据球形对象计算几何形状。当你构建 API 并开始与数据交互时,你将在第六章中更多地使用这个功能。</p> <h5 id="小贴士-4">小贴士</h5> <p>为了满足 GeoJSON 规范,坐标对必须按照正确的顺序输入到数组中:经度,然后是纬度。有效的经度值范围从-180 到 180,而有效的纬度值范围从-90 到 90。将坐标顺序搞错是一个容易犯的错误,所以在保存位置数据到集合时请记住这一点。</p> <p>你已经掌握了基础知识,Loc8r 的当前模式包含了满足主页需求所需的所有内容。接下来,是时候看看详情页面了。这个页面有更复杂的数据需求,你将看到如何使用 Mongoose 模式来处理它们。</p> <h4 id="533-使用子文档创建更复杂的模式">5.3.3. 使用子文档创建更复杂的模式</h4> <p>你到目前为止使用的数据很简单,可以放在一个相当扁平的模式中。你已经使用了一些数组来存储设施和位置坐标,但同样,这些数组很简单,每个数组只包含单一的数据类型。现在,你将看看当你处理一个稍微复杂的数据集时会发生什么。</p> <p>首先,重新熟悉一下详情页面以及它显示的数据。图 5.9 显示了包含所有不同信息区域的页面截图。名称、评分和地址位于顶部;稍低一些是设施。右侧是一张基于地理坐标的地图。你已经用基本模式覆盖了这些元素。你还没有为营业时间和客户评论准备任何内容。</p> <h5 id="图-59-详情页面显示的单个位置的信息">图 5.9. 详情页面显示的单个位置的信息</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig09_alt.jpg" alt="" loading="lazy"></p> <p>支持这个视图的数据目前保存在<code>locationInfo</code>控制器中,位于<code>app_server/controllers/locations.js</code>。以下列表显示了该控制器中的相关数据部分。</p> <h5 id="列表-52-驱动详情页面的控制器中的数据">列表 5.2. 驱动详情页面的控制器中的数据</h5> <pre><code>location: { name: 'Starcups', *1* address: '125 High Street, Reading, RG6 1PS', *1* rating: 3, *1* facilities: ['Hot drinks', 'Food', 'Premium wifi'], *1* coords: {lat: 51.455041, lng: -0.9690884}, *1* days: 'Monday - Friday', *2* opening: '7:00am', *2* closing: '7:00pm', *2* closed: false *2* },{ *2* days: 'Saturday', *2* opening: '8:00am', *2* closing: '5:00pm', *2* closed: false *2* },{ *2* days: 'Sunday', *2* closed: true *2* }], *2* reviews: [{ *3* author: 'Simon Holmes', *3* rating: 5, *3* timestamp: '16 July 2013', *3* reviewText: 'What a great place. *3* I can\'t say enough good things about it.' *3* },{ *3* author: 'Charlie Chaplin', *3* rating: 3, *3* timestamp: '16 June 2013', *3* reviewText: 'It was okay. Coffee wasn\'t great, *3* but the wifi was fast.' *3* }] *3* } </code></pre> <ul> <li> <p><strong><em>1</em> 已经用现有模式覆盖</strong></p> </li> <li> <p><strong><em>2</em> 营业时间的数据被存储为对象数组。</strong></p> </li> <li> <p><strong><em>3</em> 评论也作为对象数组传递给视图。</strong></p> </li> </ul> <p>在这里,你拥有用于营业时间的对象数组和用于评论的对象数组。在一个关系型数据库中,你会创建这些作为单独的表,并在需要信息时在查询中<code>join</code>它们。但文档数据库(包括 MongoDB)并不是这样工作的。在文档数据库中,任何特定于父文档的内容都应该包含在<strong>该文档</strong>内。图 5.10 说明了这两种方法之间的概念差异。</p> <h5 id="图-510-关系型数据库和文档数据库在存储与父元素相关的重复信息方面的区别">图 5.10. 关系型数据库和文档数据库在存储与父元素相关的重复信息方面的区别</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig10_alt.jpg" alt="" loading="lazy"></p> <p>MongoDB 提供了 <em>子文档</em> 的概念来存储这种重复的嵌套数据。子文档与文档非常相似,它们都有自己的模式;每个子文档在创建时都会由 MongoDB 分配一个唯一的 <code>_id</code>。但是子文档嵌套在文档内部,并且只能通过父文档的路径来访问。</p> <h5 id="在-mongoose-中使用嵌套模式来定义子文档">在 Mongoose 中使用嵌套模式来定义子文档</h5> <p>Mongoose 通过嵌套模式定义子文档——一个模式嵌套在另一个模式内部。在本节中,你将创建一个子文档模式来查看它在代码中的工作方式。第一步是定义一个子文档的新模式。从营业时间开始,创建以下模式。请注意,此模式需要与 <code>locationSchema</code> 定义在同一文件中,并且(重要的是)必须在 <code>locationSchema</code> 定义之前:</p> <pre><code>const openingTimeSchema = new mongoose.Schema({ days: { type: String, required: true }, opening: String, closing: String, closed: { type: Boolean, required: true } }); </code></pre> <p><strong>存储时间信息的选项</strong></p> <p>在营业时间模式中,你有一个有趣的情况:你想要保存时间信息,如早上 7:30,但不需要与之关联的日期。</p> <p>在这里,你使用的是 <code>String</code> 方法,因为它在放入数据库或检索后不需要任何处理。这也使得每条记录都很容易理解。缺点是它使得对数据进行任何计算处理变得更加困难。</p> <p>一种选项是创建一个带有任意数据值的数据对象,并手动设置小时和分钟,例如</p> <pre><code>const d = new Date(); d.setHours(15); d.setMinutes(30); *1* </code></pre> <ul> <li><strong><em>1</em> d 现在是 Sun Mar 12 2017 15:30:40 GMT+0000 (GMT)。</strong></li> </ul> <p>使用这种方法,你可以轻松地从数据中提取时间。缺点是你会存储不必要的数据,并且这种方法在技术上是不正确的。</p> <p>另一种选项是存储午夜以来的分钟数。所以早上 7:30 是 (7 × 60) + 30 = 450。当你将数据放入数据库并再次检索时,这个计算相当简单。但数据看起来没有意义。</p> <p>然而,这个第二种选项是我们更倾向于使用的,可以使日期更智能,如果想要尝试新事物,这也可以是一个很好的扩展。为了提高可读性和避免干扰,你将保持使用字符串方法贯穿整本书。</p> <p>此模式定义很简单,并映射来自控制器中的数据。你有两个必填字段:<code>closed</code> 布尔标志和每个子文档引用的 <code>days</code>。</p> <p>在位置模式内部嵌套此模式是另一个简单的任务。你需要向父模式添加一个新的路径,并将其定义为子文档模式的数组。以下代码片段显示了如何在 <code>locationSchema</code> 内部嵌套 <code>openingTimeSchema</code>:</p> <pre><code>const locationSchema = new mongoose.Schema({ name: { type: String, required: true }, address: String, rating: { type: Number, 'default': 0, min: 0, max: 5 }, facilities: [String], coords: { type: {type: String}, coordinates: [Number] }, openingTimes: [openingTimeSchema] *1* }); </code></pre> <ul> <li><strong><em>1</em> 通过引用另一个模式对象作为数组添加嵌套模式</strong></li> </ul> <p>在此基础上,您可以为特定位置添加多个开馆时间子文档,这些子文档存储在该位置文档中。以下代码片段展示了基于此模式的 MongoDB 示例文档,其中开馆时间的子文档以粗体显示:</p> <pre><code>{ "_id": ObjectId("52ef3a9f79c44a86710fe7f5"), "name": "Starcups", "address": "125 High Street, Reading, RG6 1PS", "rating": 3, "facilities": ["Hot drinks", "Food", "Premium wifi"], "coords": [-0.9690884, 51.455041], "openingTimes": [{ *1* "_id": ObjectId("52ef3a9f79c44a86710fe7f6"), *1* "days": "Monday - Friday", *1* "opening": "7:00am", *1* "closing": "7:00pm", *1* "closed": false *1* }, { *1* "_id": ObjectId("52ef3a9f79c44a86710fe7f7"), *1* "days": "Saturday", *1* "opening": "8:00am", *1* "closing": "5:00pm", *1* "closed": false *1* }, { *1* "_id": ObjectId("52ef3a9f79c44a86710fe7f8"), *1* "days": "Sunday", *1* "closed": true *1* }] *1* } </code></pre> <ul> <li><strong><em>1</em> 在 MongoDB 文档中,嵌套的开馆时间子文档位于位置文档内部。</strong></li> </ul> <p>在处理开馆时间的方案得到妥善处理后,接下来您将查看添加评论子文档方案的步骤。</p> <h5 id="添加第二组子文档">添加第二组子文档</h5> <p>MongoDB 和 Mongoose 都不限制文档中子文档路径的数量,因此您可以自由地使用您为开馆时间所做的工作,并复制此过程用于评论:</p> <ul> <li> <p>第一步:查看评论中使用的数据:</p> <pre><code> { author: 'Simon Holmes', rating: 5, timestamp: '16 July 2013', reviewText: 'What a great place. I can\'t say enough good things about it.' } </code></pre> </li> <li> <p>第二步:将此代码映射到 app_server/models/ location.js 中的新 <code>reviewSchema</code>:</p> <pre><code>const reviewSchema = new mongoose.Schema({ author: String, rating: { type: Number, required: true, min: 0, max: 5 }, reviewText: String, createdOn: { type: Date, 'default': Date.now } }); </code></pre> </li> <li> <p>第三步:将此 <code>reviewSchema</code> 作为新路径添加到 <code>locationSchema</code>:</p> <pre><code>const locationSchema = new mongoose.Schema({ name: {type: String, required: true}, address: String, rating: {type: Number, "default": 0, min: 0, max: 5}, facilities: [String], coords: {type: { type: String }, coordinates: [Number]}, openingTimes: [openingTimeSchema], reviews: [reviewSchema] }); </code></pre> </li> </ul> <p>当您已定义评论的方案并将其添加到主位置方案中,您就有了一切所需,以结构化的方式存储所有位置的数据。</p> <h4 id="534-最终方案">5.3.4. 最终方案</h4> <p>在本节中,您在文件中做了很多工作,所以一起看看,以了解发生了什么。以下列表显示了 app_server/models 中 locations.js 文件的全部内容,定义了位置数据的方案。</p> <h5 id="列表-53-最终位置方案定义包括嵌套方案">列表 5.3. 最终位置方案定义,包括嵌套方案</h5> <pre><code>const mongoose = require( 'mongoose' ); *1* const openingTimeSchema = new mongoose.Schema({ *2* days: {type: String, required: true}, *2* opening: String, *2* closing: String, *2* closed: { *2* type: Boolean, *2* required: true *2* } *2* }); *2* const reviewSchema = new mongoose.Schema({ *3* author: String, *3* rating: { *3* type: Number, *3* required: true, *3* min: 0, *3* max: 5 *3* }, *3* reviewText: String, *3* createdOn: {type: Date, default: Date.now} *3* }); *3* const locationSchema = new mongoose.Schema({ *4* name: { type: String, required: true }, address: String, rating: { type: Number, 'default': 0, min: 0, max: 5 }, facilities: [String], coords: { type: {type: String }, coordinates:[Number] *5* }, openingTimes: [openingTimeSchema], *6* reviews: [reviewSchema] *6* }); locationSchema.index({coords: '2dsphere'}); </code></pre> <ul> <li> <p><strong><em>1</em> 需要 Mongoose 以便使用其方法</strong></p> </li> <li> <p><strong><em>2</em> 定义开馆时间的方案</strong></p> </li> <li> <p><strong><em>3</em> 定义评论的方案</strong></p> </li> <li> <p><strong><em>4</em> 开始主要位置方案的定义</strong></p> </li> <li> <p><strong><em>5</em> 使用 2dsphere 以添加对 GeoJSON 经纬度坐标对的支撑</strong></p> </li> <li> <p><strong><em>6</em> 引用开馆时间和评论方案以添加嵌套子文档</strong></p> </li> </ul> <p>文档和子文档都有定义其结构的方案,您还添加了一些默认值和基本验证。为了使这个场景更真实,以下列表展示了基于此方案的示例 MongoDB 文档。</p> <h5 id="列表-54-基于位置方案的示例-mongodb-文档">列表 5.4. 基于位置方案的示例 MongoDB 文档</h5> <pre><code>{ "_id": ObjectId("52ef3a9f79c44a86710fe7f5"), "name": "Starcups", "address": "125 High Street, Reading, RG6 1PS", "rating": 3, "facilities": ["Hot drinks", "Food", "Premium wifi"], "coords": [-0.9690884, 51.455041], *1* "openingTimes": [{ *2* "_id": ObjectId("52ef3a9f79c44a86710fe7f6"), *2* "days": "Monday - Friday", *2* "opening": "7:00am", *2* "closing": "7:00pm", *2* "closed": false *2* }, { *2* "_id": ObjectId("52ef3a9f79c44a86710fe7f7"), *2* "days": "Saturday", *2* "opening": "8:00am", *2* "closing": "5:00pm", *2* "closed": false *2* }, { *2* "_id": ObjectId("52ef3a9f79c44a86710fe7f8"), *2* "days": "Sunday", *2* "closed": true *2* }], *2* "reviews": [{ "_id": ObjectId("52ef3a9f79c44a86710fe7f9"), *3* "author": "Simon Holmes", *3* "rating": 5, *3* "createdOn": ISODate("2013-07-15T23:00:00Z"), *3* "reviewText": "What a great place. I can't say enough good *3* things about it." *3* }, { *3* "_id": ObjectId("52ef3a9f79c44a86710fe7fa"), *3* "author": "Charlie Chaplin", *3* "rating": 3, *3* "createdOn": ISODate("2013-06-15T23:00:00Z"), *3* "reviewText": "It was okay. Coffee wasn't great, but the wifi was fast." *3* }] } </code></pre> <ul> <li> <p><strong><em>1</em> 坐标存储为 GeoJSON 对 [经度,纬度]。</strong></p> </li> <li> <p><strong><em>2</em> 开馆时间存储为嵌套对象数组(子文档)。</strong></p> </li> <li> <p><strong><em>3</em> 评论也是子文档的数组。</strong></p> </li> </ul> <p>该列表应能给您一个关于基于已知方案的 MongoDB 文档外观的概念,包括子文档。以这种可读的形式,它是一个 JSON 对象,尽管技术上 MongoDB 以 BSON(二进制 JSON)的形式存储它。</p> <h4 id="535-编译-mongoose-方案到模型">5.3.5. 编译 Mongoose 方案到模型</h4> <p>当与数据交互时,应用程序不会直接与方案交互;数据交互是通过模型完成的。</p> <p>在 Mongoose 中,模型是模式的编译版本。当它被编译时,模型的单个实例直接映射到数据库中的单个文档。正是通过这种直接的一对一关系,模型可以创建、读取、保存和删除数据。图 5.11 说明了这种安排。</p> <h5 id="图-511-应用程序和数据库通过模型进行交互模型的单个实例与数据库中的单个文档有一对一的关系正是通过这种关系管理数据的创建读取更新和删除">图 5.11. 应用程序和数据库通过模型进行交互。模型的单个实例与数据库中的单个文档有一对一的关系。正是通过这种关系,管理数据的创建、读取、更新和删除。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig11_alt.jpg" alt="图片" loading="lazy"></p> <p>这种方法使得 Mongoose 易于使用,你将在第六章中构建应用程序的内部 API 时深入了解它。</p> <h5 id="从模式编译模型">从模式编译模型</h5> <p>任何包含单词<em>compiling</em>的东西听起来可能有点复杂。实际上,从模式编译 Mongoose 模型是一个简单的单行任务。在调用<code>model</code>命令之前,你需要确保模式是完整的。<code>model</code>命令遵循以下结构:</p> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/p0145-01_alt.jpg" alt="图片" loading="lazy"></p> <h5 id="小贴士-5">小贴士</h5> <p>MongoDB 集合名称是可选的。如果你省略了它,Mongoose 将使用模型名称的小写复数形式。例如,模型名称为<code>Location</code>时,它会寻找名为 locations 的集合名称,除非你指定了不同的名称。</p> <p>由于你正在创建数据库而不是连接到现有的数据源,你可以使用默认的集合名称,因此你不需要在<code>model</code>命令中包含该参数。要构建你的位置模式模型,你可以在<code>locationSchema</code>定义下面的代码中添加以下行:</p> <pre><code>mongoose.model('Location', locationSchema); </code></pre> <p>这就是全部内容。你已经为位置定义了数据模式,并将模式编译成了可以在应用程序中使用的模型。你现在需要一些数据。</p> <h3 id="54-使用-mongodb-shell-创建-mongodb-数据库并添加数据">5.4. 使用 MongoDB shell 创建 MongoDB 数据库并添加数据</h3> <p>要构建 Loc8r 应用程序,你需要创建一个新的数据库并手动添加一些测试数据。你可以创建自己的 Loc8r 个人版本进行测试,同时直接与 MongoDB 交互。</p> <h4 id="541-mongodb-shell-基础">5.4.1. MongoDB shell 基础</h4> <p>MongoDB shell 是一个与 MongoDB 一起安装的命令行实用程序,允许你与系统上的任何 MongoDB 数据库交互。它功能强大,可以做很多事情。你只需要熟悉基础知识来启动和运行。</p> <h5 id="启动-mongodb-shell">启动 MongoDB shell</h5> <p>通过在终端中运行以下行进入 shell:</p> <pre><code>$ mongo </code></pre> <p>此命令应在终端中响应几行确认</p> <ul> <li> <p>Shell 版本</p> </li> <li> <p>它连接到的服务器和端口</p> </li> <li> <p>它连接到的服务器版本</p> </li> </ul> <p>这些确认行应该看起来像这样,只要版本等于或高于 4:</p> <pre><code>MongoDB shell version 4.0.0 connecting to: mongodb://127.0.0.1:27017 MongoDB server version: 4.0.0 </code></pre> <p>如果您正在使用 MongoDB 的较旧版本,您可能会看到不同的消息,但通常如果命令已成功或失败是显而易见的。您也可能看到一些以 <code>Server has startup warnings</code> 开头并继续说明 <code>Access control is not enabled for the database</code> 的几行。在您的本地开发机器上,这并不是什么需要担心的事情。</p> <h5 id="小贴士-6">小贴士</h5> <p>当您在 shell 中时,新行以 <code>></code> 开头,以区分标准命令行入口点。本节中打印的 shell 命令以 <code>></code> 开头而不是 <code>$</code>,以清楚地表明您正在使用 shell,但与 <code>$</code> 一样,您不需要输入它。</p> <h5 id="列出所有本地数据库">列出所有本地数据库</h5> <p>接下来是一个简单的命令,显示了所有本地 MongoDB 数据库的列表。在 shell 中输入以下行:</p> <pre><code>> show dbs </code></pre> <p>这行返回了本地 MongoDB 数据库名称及其大小的列表。如果您在此点还没有创建任何数据库,您仍然会看到两个默认数据库,它们看起来像这样:</p> <pre><code>admin 0.000GB local 0.000GB </code></pre> <h5 id="使用特定数据库">使用特定数据库</h5> <p>如果您想使用特定数据库,例如默认的名为 local 的数据库,您可以使用 <code>use</code> 命令,如下所示:</p> <pre><code>> use local </code></pre> <p>shell 会响应如下信息:</p> <pre><code>switched to db local </code></pre> <p>这条消息确认了 shell 连接到的数据库的名称。</p> <h5 id="列出数据库中的集合">列出数据库中的集合</h5> <p>当您使用特定数据库时,使用以下命令很容易输出其集合列表:</p> <pre><code>> show collections </code></pre> <p>如果您正在使用本地数据库,您可能会在终端看到一个单独的集合名称输出:<code>startup_log</code>。</p> <h5 id="查看集合的内容">查看集合的内容</h5> <p>MongoDB shell 还允许您查询数据库中的集合。查询或查找操作的构造如下:</p> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/p0148-01_alt.jpg" alt="" loading="lazy"></p> <p><code>query</code> 对象用于指定您在集合中试图查找的内容,您将在第六章中查看这个 <code>query</code> 对象的示例。(Mongoose 也使用 <code>query</code> 对象。)最简单的查询是一个空查询,它返回集合中的所有文档。如果您的集合很大,请不要担心,因为 MongoDB 会返回一个您可以分页查看的文档子集。以 <code>startup_log</code> 集合为例,您可以运行以下命令:</p> <pre><code>> db.startup_log.find() </code></pre> <p>此命令返回 MongoDB 启动日志中的几个文档,其内容在这里展示并不足够有趣。当您在设置数据库并确保一切按预期保存时,此命令非常有用。</p> <h4 id="542-创建-mongodb-数据库">5.4.2. 创建 MongoDB 数据库</h4> <p>您不需要 <em>创建</em> MongoDB 数据库;您只需要开始使用它。对于 Loc8r 应用程序,拥有一个名为 Loc8r 的数据库是有意义的。在 shell 中,您可以使用以下命令使用它:</p> <pre><code>> use Loc8r </code></pre> <p>如果您运行 <code>show collections</code> 命令,它目前不会返回任何内容,即使您运行 <code>show dbs</code>,您也看不到它。但是,在向其中保存一些数据后,您将能够看到它。</p> <h5 id="创建集合和文档">创建集合和文档</h5> <p>同样,你不需要显式创建集合,因为当你第一次向其中保存数据时,MongoDB 会为你创建它。</p> <p><strong>更符合你个人需求的地理位置数据</strong></p> <p>Loc8r 是关于基于位置的数据,示例中的地点都是虚构的,地理位置上靠近西蒙在英国的居住地。你可以通过更改名称、地址和坐标来使你的版本更加个性化。</p> <p>要获取您的当前坐标,请访问 <a href="https://whatsmylatlng.com" target="_blank"><code>whatsmylatlng.com</code></a>。页面上有一个按钮,可以通过 JavaScript 查找您的位置,这比第一次尝试更准确。请注意,坐标以纬度-经度顺序显示给您,您需要将它们翻转以便数据库中经度在前。</p> <p>要获取任何地址的坐标,你可以使用 <a href="http://mygeoposition.com" target="_blank"><code>mygeoposition.com</code></a>。此网站允许你输入地址或拖放指针以获取地理坐标。再次提醒,MongoDB 中的对必须先经度后纬度。</p> <p>为了匹配 <code>Location</code> 模型,你将需要一个 <code>locations</code> 集合。记住,默认的集合名称是模型名称的复数小写版本。你可以通过将数据对象传递给集合的 <code>save</code> 命令来创建和保存一个新的文档,如下面的代码片段所示:</p> <pre><code>> db.locations.save({ *1* name: 'Starcups', address: '125 High Street, Reading, RG6 1PS', rating: 3, facilities: ['Hot drinks', 'Food', 'Premium wifi'], coords: [-0.9690884, 51.455041], openingTimes: [{ days: 'Monday - Friday', opening: '7:00am', closing: '7:00pm', closed: false }, { days: 'Saturday', opening: '8:00am', closing: '5:00pm', closed: false }, { days: 'Sunday', closed: true }] }) </code></pre> <ul> <li><strong><em>1</em> 注意在保存命令中指定的集合名称</strong></li> </ul> <p>在一步中,你创建了 <code>Loc8r</code> 数据库和新的 <code>locations</code> 集合,并将第一个文档添加到集合中。如果你现在在 MongoDB shell 中运行 <code>show dbs</code>,你应该会看到新的 <code>Loc8r</code> 数据库与其他数据库一起返回,如下所示:</p> <pre><code>> show dbs Loc8r 0.000GB admin 0.000GB local 0.000GB </code></pre> <p>现在当你运行 MongoDB shell 中的 <code>show collections</code> 时,你应该会看到返回的新 <code>locations</code> 集合:</p> <pre><code>> show collections locations </code></pre> <p>你可以查询集合以找到文档。目前只有一个文档,所以返回的信息很少。你还可以在集合上使用 <code>find</code> 命令:</p> <pre><code>> db.locations.find() *1* { "_id": ObjectId("530efe98d382e7fa4345f173"), *2* "address": "125 High Street, Reading, RG6 1PS", "coords": [-0.9690884, 51.455041], "facilities": ["Hot drinks", "Food", "Premium wifi"], "name": "Starcups", "openingTimes": [{ "days": "Monday - Friday", "opening": "7:00am", "closing": "7:00pm", "closed": false }, { "days": "Saturday", "opening": "8:00am", "closing": "5:00pm", "closed": false }, { "days": "Sunday", "closed": true }], "rating": 3, } </code></pre> <ul> <li> <p><strong><em>1</em> 记得在集合本身上运行查找操作。</strong></p> </li> <li> <p><strong><em>2</em> MongoDB 已经为该文档自动添加了一个唯一标识符。</strong></p> </li> </ul> <p>此代码片段已格式化以提高可读性;MongoDB 返回给 shell 的文档不会有换行符和缩进。但如果你在命令末尾添加 <code>.pretty()</code>,MongoDB shell 可以为你美化它,如下所示:</p> <pre><code>> db.locations.find().pretty() </code></pre> <p>注意,返回文档中的数据顺序与您提供的数据对象中的顺序不匹配。由于数据结构不是基于列的,MongoDB 如何存储文档内的单个路径顺序并不重要。数据始终在正确的路径中,并且数组内部的数据保持相同的顺序。</p> <h5 id="添加子文档">添加子文档</h5> <p>你可能已经注意到,你的第一个文档没有完整的数据集;没有审查子文档。你可以像处理营业时间那样,在初始的 <code>save</code> 命令中添加它们,或者你可以更新现有文档并将它们推入。</p> <p>MongoDB 有一个 <code>update</code> 命令,它接受两个参数:一个查询,以便它知道要更新哪个文档,以及当它找到文档时要执行的操作的说明。在这个阶段,你可以进行一个简单的查询,通过名称(Starcups)查找位置,因为你知道没有重复项。对于指令对象,你可以使用一个 <code>$push</code> 命令向评论路径添加一个新的对象。即使评论路径尚不存在,MongoDB 也会在推送操作中将其添加。</p> <p>将所有这些放在一起,就像以下代码片段所示:</p> <pre><code>> db.locations.update({ *1* name: 'Starcups' *1* }, { $push: { *2* reviews: { *2* author: 'Simon Holmes', *3* _id: ObjectId(), *3* rating: 5, *3* timestamp: new Date("Mar 12, 2017"), *3* reviewText: "What a great place." *3* } } }) </code></pre> <ul> <li> <p><strong><em>1</em> 从查询对象开始以找到正确的文档</strong></p> </li> <li> <p><strong><em>2</em> 当文档找到时,将子文档推入评论路径</strong></p> </li> <li> <p><strong><em>3</em> 子文档包含此数据</strong></p> </li> </ul> <p>如果你使用 <code>Loc8r</code> 数据库在 MongoDB 壳中运行该命令,你将向文档添加一个评论。你可以根据需要重复该命令,更改数据以添加多个评论。</p> <p>你可能已经注意到,在这里,你指定了 <code>_id</code> 属性并将其赋值为 <code>ObjectId()</code>。MongoDB 不会像对文档那样自动将 <code>_id</code> 添加到子文档中,但这个特性对你来说将非常有用。给评论子文档赋值 <code>ObjectId()</code> 告诉 MongoDB 为此子文档创建一个新的唯一标识符。</p> <p>注意设置评论时间戳的 <code>new Date()</code> 函数调用。使用此时间戳确保 MongoDB 将日期存储为 ISO 日期对象,而不是字符串——这是你的模式所期望的,并且允许对日期数据进行更高级的操作。</p> <h5 id="重复此过程">重复此过程</h5> <p>这几个命令已经为你提供了一个测试应用程序的位置,但理想情况下,你需要更多。向你的数据库添加一些更多位置。</p> <p>完成此操作并设置好数据后,你几乎可以开始从应用程序中使用它了。在这种情况下,你将构建一个 API。但在你跳转到第六章(<a href="https://example.org/kindle_split_017.xhtml#ch06" target="_blank">kindle_split_017.xhtml#ch06</a>)中的任务之前,还有一项家务要做。你想要定期向 Heroku 推送更新,现在你已经向应用程序添加了数据库连接和数据模型,你需要确保这些更新在 Heroku 上得到支持。</p> <h3 id="55-使数据库上线">5.5. 使数据库上线</h3> <p>如果您的应用程序已经发布到野外,将数据库放在本地主机上是没有用的。您的数据库也需要外部可访问。在本节中,您将把数据库推送到一个实时环境中,并更新您的 Loc8r 应用程序,使其使用发布站点的已发布数据库和开发站点的本地主机数据库。您将首先使用一个名为 mLab 的服务的免费层,该服务可以用作 Heroku 的附加组件。如果您有其他首选提供商或自己的数据库服务器,那没问题。本节的第一部分将介绍在 mLab 上的设置,但以下部分——迁移数据和在 Node 应用程序中设置连接字符串——不是平台特定的。</p> <h4 id="551-设置-mlab-并获取数据库-uri">5.5.1. 设置 mLab 并获取数据库 URI</h4> <p>第一个目标是获取一个外部可访问的数据库 URI,以便您可以将数据推送到它并将其添加到应用程序中。您将使用 mLab 来实现这一目的,因为它有一个良好的免费层,优秀的在线文档,以及响应的支持团队。</p> <p>您有几种在 mLab 上设置数据库的方法。最快、最简单的方法是通过 Heroku 的附加组件。这里您将使用这种方法,但它确实要求您在 Heroku 上注册一张有效的信用卡。Heroku 在您通过其生态系统使用附加组件时要求您这样做,以保护自己免受滥用行为。使用 mLab 的免费沙盒层不会产生任何费用。如果您不习惯使用信用卡直接通过 Heroku 设置您的 mLab 数据库,请查看侧边栏“手动设置 mLab”以获取有关手动设置 mLab 数据库并将其连接到您的 Heroku 应用程序的详细信息。如果您选择手动设置数据库,请勿遵循 Heroku 附加组件安装的说明;否则,您最终会拥有与您的应用程序关联的多个数据库。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>手动设置 mLab</strong></p> <p>如果您不想使用 Heroku 附加组件系统,您可以这样做。您想要做的是在云中设置一个 MongoDB 数据库,并获取其连接字符串。</p> <p>mLab 文档可以指导您完成此过程;请参阅<a href="https://docs.mlab.com" target="_blank"><code>docs.mlab.com</code></a>。</p> <p>简而言之,步骤如下</p> <ol> <li> <p>注册一个免费账户。</p> </li> <li> <p>创建一个新的数据库(对于免费层,选择单节点,沙盒)。</p> </li> <li> <p>添加一个用户。</p> </li> <li> <p>获取数据库 URI(连接字符串)。</p> </li> </ol> <p>连接字符串看起来可能如下所示:</p> <pre><code>mongodb://dbuser:dbpassword@ds059957.mlab.com:59957/loc8r-dev </code></pre> <p>当然,所有这些部分对于您来说都会有所不同,您将不得不将用户名和密码替换为步骤 3 中指定的内容。</p> <p>当您拥有完整的连接字符串时,您应该将其保存为 Heroku 配置的一部分。在您的应用程序根目录的终端提示符下,您可以使用以下命令来完成此操作:</p> <pre><code>$ heroku config:set MLAB_URI=your_db_uri </code></pre> <p>将<code>your_db_uri</code>替换为包括<code>mongodb://</code>协议在内的完整连接字符串。快速且简单的方法会自动在 Heroku 配置中创建<code>MLAB_URI</code>设置。这些手动步骤将你带到与快速方法相同的位置,你可以跳回正文。</p> <h5 id="将-mlab-附加组件添加到-heroku-应用程序中">将 mLab 附加组件添加到 Heroku 应用程序中</h5> <p>通过终端将 mLab 作为 Heroku 附加组件添加的最快方式是通过以下命令(使用 mLab 的旧名称 MongoLab):确保你在应用程序的根目录中,并运行以下命令:</p> <pre><code>$ heroku addons:create mongolab </code></pre> <p>令人难以置信,就是这样!你已经在云端准备好并等待使用 MongoDB 数据库了。你可以通过以下命令来证明这一点并打开这个新数据库的 Web 界面:</p> <pre><code>$ heroku addons:open mongolab </code></pre> <p>要使用数据库,你需要知道它的 URI。</p> <h5 id="获取数据库-uri">获取数据库 URI</h5> <p>你可以通过命令行获取完整的数据库 URI。这种方法提供了你可以在应用程序中使用的完整连接字符串,同时也显示了你需要将数据推送到数据库的各种组件。</p> <p>获取数据库 URI 的命令是</p> <pre><code>$ heroku config:get MONGODB_URI </code></pre> <p>此命令输出完整的连接字符串,看起来像这样:</p> <pre><code>mongodb://heroku_t0zs37gc:1k3t3pgo8sb5enovqd9sk314gj@ds159330.mlab.com:59330/ heroku_t0zs37gc </code></pre> <p>保留你的版本,因为你在应用程序中很快就会用到它。首先,你需要将其分解为其组成部分。</p> <h5 id="将-uri-分解为其组成部分">将 URI 分解为其组成部分</h5> <p>URI 看起来像是一团乱码,但你可以将其分解以使其有意义。从 5.1.2 部分,你知道这是数据库 URI 的构建方式:</p> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/p0154-01_alt.jpg" alt="图片" loading="lazy"></p> <p>将 mLab 提供的 URI 分解成类似以下的形式:</p> <ul> <li> <p><em>用户名</em>—<code>heroku_t0zs37gc</code></p> </li> <li> <p><em>密码</em>—<code>1k3t3pgo8sb5enovqd9sk314gj</code></p> </li> <li> <p><em>服务器地址</em>—<code>ds159330.mlab.com</code></p> </li> <li> <p><em>端口号</em>—<code>59330</code></p> </li> <li> <p><em>数据库名称</em>—<code>heroku_t0zs37gc</code></p> </li> </ul> <p>这些示例来自示例 URI。当然,你的将会有所不同,但请注意它们;它们会很有用。</p> <h4 id="552-推送数据">5.5.2. 推送数据</h4> <p>现在你已经设置了一个外部可访问的数据库,并且知道了连接到它的所有详细信息,你可以将数据推送到它。以下是步骤:</p> <ol> <li> <p>导航到你的机器上的一个目录,这个目录适合存放数据转储。</p> </li> <li> <p>从你的开发 Loc8r 数据库中转储数据。</p> </li> <li> <p>将数据恢复到你的实时数据库。</p> </li> <li> <p>测试实时数据库。</p> </li> </ol> <p>所有这些步骤都可以通过终端快速完成,所以这就是你将要做的。这样可以避免在不同环境之间跳转。</p> <h5 id="导航到一个合适的目录">导航到一个合适的目录</h5> <p>当你在命令行中运行数据转储命令时,它会在当前目录下创建一个名为/dump 的文件夹,并将数据转储放在里面。因此,第一步是在终端导航到硬盘上的一个合适位置。你的主目录或文档文件夹都可以,或者如果你更喜欢,可以创建一个特定的文件夹。</p> <h5 id="从开发数据库中转储数据">从开发数据库中转储数据</h5> <p>数据转储听起来像是您从本地开发版本中删除了所有内容,但这并不是情况。这个过程更像是导出而不是丢弃。</p> <p>使用的命令是<code>mongodump</code>,它可以接受许多参数,其中您需要这两个:</p> <ul> <li> <p><code>-h</code>—主机服务器(和端口)</p> </li> <li> <p><code>-d</code>—数据库名称</p> </li> </ul> <p>将所有这些放在一起,并使用默认的 MongoDB 端口<code>27017</code>,您应该得到以下类似的命令:</p> <pre><code>$ mongodump -h localhost:27017 -d Loc8r </code></pre> <p>运行该命令,您将有一个临时数据转储。</p> <h5 id="将数据恢复到您的实时数据库">将数据恢复到您的实时数据库</h5> <p>将数据推送到您的实时数据库的过程类似,这次使用<code>mongorestore</code>命令。此命令期望以下参数:</p> <ul> <li> <p><code>-h</code>—实时主机和端口</p> </li> <li> <p><code>-d</code>—实时数据库名称</p> </li> <li> <p><code>-u</code>—实时数据库用户名</p> </li> <li> <p><code>-p</code>—实时数据库密码</p> </li> <li> <p>数据转储目录的路径和数据库名称(位于命令末尾,没有像其他参数那样的对应标志)</p> </li> </ul> <p>将所有这些放在一起,使用您关于数据库 URI 的信息,您应该得到以下类似的命令:</p> <pre><code>$ mongorestore -h ds159330.mlab.com:59330 -d heroku_t0zs37gc -u heroku_t0zs37gc -p 1k3t3pgo8sb5enovqd9sk314gj dump/ </code></pre> <p>您的命令将略有不同,当然,因为您将有一个不同的主机、实时数据库名称、用户名和密码。当您运行<code>mongorestore</code>命令时,它将数据转储中的数据推送到您的实时数据库。</p> <h5 id="测试实时数据库">测试实时数据库</h5> <p>MongoDB shell 不仅限于访问本地机器上的数据库。您还可以使用 shell 连接到外部数据库(当然,如果您有正确的凭证的话)。</p> <p>要将 MongoDB shell 连接到外部数据库,您使用相同的<code>mongo</code>命令,但添加您想要连接到的数据库的信息。您需要包括主机名、端口和数据库名称,如果需要,您还可以提供用户名和密码。使用以下结构:</p> <pre><code>$ mongo hostname:port/database_name -u username -p password </code></pre> <p>使用您在本节中查看的设置,您将得到以下命令:</p> <pre><code>$ mongo ds159330.mlab.com:59330/heroku_t0zs37gc -u heroku_t0zs37gc -p 1k3t3pgo8sb5enovqd9sk314gj </code></pre> <p>此命令通过 MongoDB shell 将您连接到数据库。当连接建立后,您可以使用您已经使用过的命令来查询它,例如</p> <pre><code>> show collections > db.locations.find() </code></pre> <p>现在,您有两个数据库和两个连接字符串。在正确的时间使用正确的一个是重要的。</p> <h4 id="553-使应用程序使用正确的数据库">5.5.3. 使应用程序使用正确的数据库</h4> <p>您在本地机器上有原始的开发数据库,以及在上面的 mLab(或其他地方)上的新实时数据库。您希望在开发应用程序时继续使用开发数据库,并且希望应用程序的实时版本使用实时数据库。然而,两者都使用相同的源代码。图 5.12 显示了问题。</p> <h5 id="图-512源代码在两个位置运行每个位置都需要连接到不同的数据库">图 5.12。源代码在两个位置运行,每个位置都需要连接到不同的数据库。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/05fig12_alt.jpg" alt="图 5.12" loading="lazy"></p> <p>现在,您有一组源代码在两个环境中运行,每个环境都应该使用不同的数据库。处理此问题的方法是使用 Node 环境变量<code>NODE_ENV</code>。</p> <h5 id="node_env-环境变量">NODE_ENV 环境变量</h5> <p>环境变量会影响核心进程的运行方式,你将在这里查看并使用的是 <code>NODE_ENV</code>。应用程序已经使用了 <code>NODE_ENV</code>;你不会在任何地方看到它被暴露出来。默认情况下,Heroku 应该将 <code>NODE_ENV</code> 设置为 <code>production</code>,这样应用程序就会在其服务器上以生产模式运行。</p> <p><strong>确保 Heroku 使用生产模式</strong></p> <p>在某些情况下,根据应用程序的设置方式,Heroku 应用程序可能不会以生产模式运行。你可以使用以下终端命令来确保 Heroku 环境变量设置正确:</p> <pre><code>$ heroku config:set NODE_ENV=production </code></pre> <p>你可以通过使用此命令的 <code>get</code> 版本来验证此设置,如下所示:</p> <pre><code>$ heroku config:get NODE_ENV </code></pre> <p>你可以在应用程序的任何地方使用以下语句来读取 <code>NODE_ENV</code>:</p> <pre><code>process.env.NODE_ENV </code></pre> <p>如果你的环境没有指定,此语句将返回 <code>undefined</code>。你可以通过在启动 Node 应用程序的命令前添加赋值来指定不同的环境变量,如下例所示:</p> <pre><code>$ NODE_ENV=production nodemon </code></pre> <p>此命令以生产模式启动应用程序,并将 <code>process.env.NODE_ENV</code> 的值设置为 <code>production</code>。</p> <h5 id="小贴士-7">小贴士</h5> <p>不要在应用程序内部设置 <code>NODE_ENV</code>;只需读取它。</p> <h5 id="根据环境设置数据库-uri">根据环境设置数据库 URI</h5> <p>你的应用程序的数据库连接存储在 <code>app_server/models</code> 文件夹中的 <code>db.js</code> 文件中。此文件中的连接部分目前看起来像这样:</p> <pre><code>const dbURI = 'mongodb://localhost/Loc8r'; mongoose.connect(dbURI); </code></pre> <p>根据当前环境更改 <code>dbURI</code> 的值就像使用一个 <code>if</code> 语句来检查 <code>NODE_ENV</code> 一样简单。下面的代码片段展示了如何使用它来传递你的实时 MongoDB 连接。请记住使用你自己的 MongoDB 连接字符串,而不是示例中的那个:</p> <pre><code>let dbURI = 'mongodb://localhost/Loc8r'; if (process.env.NODE_ENV === 'production') { dbURI = 'mongodb://heroku_t0zs37gc:1k3t3pgo8sb5enosk314gj@ds159330.mlab.com:5933 0/ heroku_t0zs37gc'; } mongoose.connect(dbURI); </code></pre> <p>如果源代码将存储在公共仓库中,你可能不希望每个人都拥有你数据库的登录凭证。绕过这种情况的一种方法是用环境变量。在 Heroku 上的 mLab,你自动设置了一个;这是你最初获取连接字符串的方式。(如果你手动设置了 mLab 账户,这个变量就是你在 Heroku 上设置的配置变量。)如果你使用的是没有添加任何内容的 Heroku 配置的不同提供商,你可以使用你之前用来确保 Heroku 以生产模式运行的 <code>heroku config:set</code> 命令来添加你的 URI。</p> <p>以下代码片段展示了如何使用在环境变量中设置的连接字符串:</p> <pre><code>let dbURI = 'mongodb://localhost/Loc8r'; if (process.env.NODE_ENV === 'production') { dbURI = process.env.MONGODB_URI; } mongoose.connect(dbURI, { useNewUrlParser: true }); </code></pre> <p>现在,你可以分享你的代码,但只有你保留对数据库凭证的访问权限。</p> <h5 id="发布前测试">发布前测试</h5> <p>在将代码推送到 Heroku 之前,你可以在终端启动应用程序时设置环境变量来在本地测试此代码更新。你之前设置的 Mongoose 连接事件在数据库连接时会在控制台输出日志,验证所使用的 URI。</p> <p>要做到这一点,你需要在 nodemon 命令前添加<code>NODE_ENV</code>和<code>MJONGODB_URI</code>环境变量,如下所示(注意以下所有内容应作为一行输入):</p> <pre><code>$ NODE_ENV=production MONGODB_URI=mongodb://<username>:<password>@<hostname>:<port>/<database> nodemon </code></pre> <p>现在启动时的控制台日志应该看起来像这样:</p> <pre><code>Mongoose connected to mongodb://heroku_t0zs37gc:1k3t3pgo8sb5enosk314gj@ds159330.mlab.com:59330 / heroku_t0zs37gc </code></pre> <p>当运行此命令时,你可能会注意到 Mongoose 连接确认在生产环境中出现得较慢,这是由于使用单独的数据库服务器导致的延迟。这就是为什么在应用程序启动时打开数据库连接并保持其打开状态是个好主意。</p> <h5 id="在-heroku-上测试">在 Heroku 上测试</h5> <p>如果你的本地测试成功,并且你可以通过临时以生产模式启动应用程序来连接到远程数据库,那么你就可以将其推送到 Heroku 了。使用与正常相同的命令来推送代码的最新版本:</p> <pre><code>$ git add --all $ git commit –m "Commit message here" $ git push heroku master </code></pre> <p>Heroku 允许你通过运行终端命令查看最新的 100 行日志。你可以检查这些日志以查看控制台日志消息的输出,其中之一将是你的<code>Mongoose connected to</code>日志。要查看日志,请在终端中运行以下命令:</p> <pre><code>$ heroku logs </code></pre> <p>此命令将最新的 100 行输出到终端窗口,最新的消息在底部。向上滚动,直到找到类似以下内容的<code>Mongoose connected to</code>消息:</p> <pre><code>2017-04-14T07:01:22.066997+00:00 app[web.1]: Mongoose connected to mongodb://heroku_t0zs37gc:1k3t3pgo8sb5enosk314gj@ds159330.mlab.com:59330/ heroku_t0zs37gc </code></pre> <p>当你看到这条消息时,你就知道 Heroku 上的实时应用程序正在连接到你的实时数据库。</p> <p>因此,这是定义和建模的数据,你的 Loc8r 应用程序已连接到数据库。但你现在还没有与数据库进行任何交互。接下来就是了!</p> <p><strong>获取源代码</strong></p> <p>到目前为止的应用程序源代码可在 GitHub 上找到,位于 gettingMean-2 存储库的 chapter-05 分支。在终端的新文件夹中,输入以下命令以克隆它并安装 npm 模块依赖项:</p> <pre><code>$ git clone -b chapter-05 https://github.com/cliveharber/ gettingMean-2.git $ cd gettingMean-2 $ npm install </code></pre> <p>在第六章中,你将使用 Express 创建一个 REST API,以便通过 Web 服务访问数据库。</p> <h3 id="概述-1">概述</h3> <p>本章中,你学习了</p> <ul> <li> <p>一些将 MongoDB 数据库连接到 Express 应用程序使用 Mongoose 的方法</p> </li> <li> <p>管理 Mongoose 连接的最佳实践</p> </li> <li> <p>如何使用 Mongoose 模式建模数据</p> </li> <li> <p>模式如何编译成模型</p> </li> <li> <p>使用 MongoDB shell 直接与数据库工作</p> </li> <li> <p>将数据库推送到实时 URI</p> </li> <li> <p>从不同的环境连接到不同的数据库</p> </li> </ul> <h2 id="第六章-编写-rest-api将-mongodb-数据库暴露给应用程序">第六章. 编写 REST API:将 MongoDB 数据库暴露给应用程序</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>检查 REST API 的规则</p> </li> <li> <p>评估 API 模式</p> </li> <li> <p>处理典型的 CRUD 函数(创建、读取、更新、删除)</p> </li> <li> <p>使用 Express 和 Mongoose 与 MongoDB 交互</p> </li> <li> <p>测试 API 端点</p> </li> </ul> <p>当你进入本章时,你已经设置了 MongoDB 数据库,但你只能通过 MongoDB shell 与之交互。在本章的过程中,你将构建一个 REST API,以便你可以通过 HTTP 调用与数据库交互并执行常见的 CRUD 功能:创建、读取、更新和删除。</p> <p>你将主要使用 Node 和 Express,利用 Mongoose 来帮助进行交互。图 6.1 显示了本章在整个架构中的位置。</p> <h5 id="图-61-本章重点在于构建与数据库交互的-api为应用程序提供一个通信接口">图 6.1. 本章重点在于构建与数据库交互的 API,为应用程序提供一个通信接口。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/06fig01_alt.jpg" alt="" loading="lazy"></p> <p>你将从查看 REST API 的规则开始。我们将讨论正确定义 URL 结构的重要性,以及用于不同操作的不同的请求方法(<code>GET</code>、<code>POST</code>、<code>PUT</code>和<code>DELETE</code>),以及 API 应该如何响应数据和适当的 HTTP 状态码。当你掌握了这些知识后,你将开始构建 Loc8r 的 API,涵盖所有典型的 CRUD 操作。我们将沿途讨论 Mongoose,并涉及一些 Node 编程和更多的 Express 路由。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="注意-4">注意</h5> <p>如果你还没有从第五章构建应用程序,你可以从 GitHub 的 chapter-05 分支<a href="https://github.com/cliveharber/gettingMean-2" target="_blank"><code>github.com/cliveharber/gettingMean-2</code></a>获取代码。在终端的新文件夹中,输入以下命令以克隆它并安装 npm 模块依赖项:</p> <pre><code>$ git clone -b chapter-05 https://github.com/cliveharber/ gettingMean-2.git $ cd gettingMean-2 $ npm install </code></pre> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h3 id="61-rest-api-的规则">6.1. REST API 的规则</h3> <p>我们将从回顾 REST API 的特点开始。从第二章中,你可能还记得</p> <ul> <li> <p><em>REST</em>代表<em>表示性状态转移</em>,这是一种架构风格,而不是严格的协议。REST 是无状态的;它对任何当前用户状态或历史没有任何概念。</p> </li> <li> <p><em>API</em>是<em>应用程序程序接口</em>的缩写,它使应用程序能够相互通信。</p> </li> </ul> <p>REST API 是应用程序的无状态接口。在 MEAN 栈的情况下,REST API 用于创建数据库的无状态接口,使其他应用程序能够与数据交互。</p> <p>REST API 有一套相关的标准。虽然你不必坚持这些标准来构建自己的 API,但通常最好这样做,因为这意味着你创建的任何 API 都将遵循相同的方法。这也意味着如果你决定将你的 API 公开,你已经习惯了以“正确”的方式做事。</p> <p>在基本术语中,REST API 接收传入的 HTTP 请求,进行一些处理,并始终发送回一个 HTTP 响应,如图 6.2 所示。</p> <h5 id="图-62-rest-api-接收传入的-http-请求进行一些处理并返回-http-响应">图 6.2. REST API 接收传入的 HTTP 请求,进行一些处理,并返回 HTTP 响应。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/06fig02_alt.jpg" alt="" loading="lazy"></p> <p>你将遵循 Loc8r 的标准围绕请求和响应。</p> <h4 id="611-请求-url">6.1.1. 请求 URL</h4> <p>REST API 的请求 URL 有一个简单的标准。遵循这个标准可以使您的 API 易于理解、使用和维护。</p> <p>处理这个任务的思路是开始考虑您的数据库中的集合,因为您通常为每个集合有一组 API URL。您也可能为每组子文档有一组 URL。集合中的每个 URL 都有相同的基本路径,其中一些可能有额外的参数。</p> <p>在一组 URL 中,您需要涵盖几个操作,通常基于标准的 CRUD 操作。您可能希望执行的一些常见操作是</p> <ul> <li> <p>创建一个新项目</p> </li> <li> <p>读取多个项目的列表</p> </li> <li> <p>读取特定项目</p> </li> <li> <p>更新特定项目</p> </li> <li> <p>删除特定项目</p> </li> </ul> <p>以 Loc8r 为例,数据库中有一个位置集合,您希望与之交互。表 6.1 展示了该集合的 URL 路径可能的样子。请注意,所有 URL 都有相同的基路径,并且在使用时,都有相同的地理位置 ID 参数。</p> <h5 id="表-61-位置集合-api-的-url-路径和参数">表 6.1. 位置集合 API 的 URL 路径和参数</h5> <table> <thead> <tr> <th>操作</th> <th>URL 路径</th> <th>示例</th> </tr> </thead> <tbody> <tr> <td>创建新位置</td> <td>/locations</td> <td><a href="http://loc8r.com/api/locations" target="_blank"><code>loc8r.com/api/locations</code></a></td> </tr> <tr> <td>读取位置列表</td> <td>/locations</td> <td><a href="http://loc8r.com/api/locations" target="_blank"><code>loc8r.com/api/locations</code></a></td> </tr> <tr> <td>读取特定位置</td> <td>/locations/:locationid</td> <td><a href="http://loc8r.com/api/locations/123" target="_blank"><code>loc8r.com/api/locations/123</code></a></td> </tr> <tr> <td>更新特定位置</td> <td>/locations/:locationid</td> <td><a href="http://loc8r.com/api/locations/123" target="_blank"><code>loc8r.com/api/locations/123</code></a></td> </tr> <tr> <td>删除特定位置</td> <td>/locations/:locationid</td> <td><a href="http://loc8r.com/api/locations/123" target="_blank"><code>loc8r.com/api/locations/123</code></a></td> </tr> </tbody> </table> <p>如表 6.1 所示,每个操作都有相同的 URL 路径,其中三个操作期望相同的参数来指定位置。这种情况提出了一个明显的问题:您如何使用相同的 URL 来启动不同的操作?答案在于请求方法。</p> <h4 id="612-请求方法">6.1.2. 请求方法</h4> <p>HTTP 请求可以有不同方法,这些方法本质上告诉服务器执行什么类型的操作。最常见的一种请求是<code>GET</code>请求——这是您在浏览器地址栏中输入 URL 时使用的方法。另一种常见的方法是<code>POST</code>,通常用于提交表单数据。</p> <p>表 6.2 展示了您在 API 中将使用的各种方法、它们的典型用途以及您期望返回的内容。</p> <h5 id="表-62-rest-api-中使用的四种请求方法">表 6.2. REST API 中使用的四种请求方法</h5> <table> <thead> <tr> <th>请求方法</th> <th>用途</th> <th>响应</th> </tr> </thead> <tbody> <tr> <td>POST</td> <td>在数据库中创建新数据</td> <td>数据库中看到的新数据对象</td> </tr> <tr> <td>GET</td> <td>从数据库读取数据</td> <td>响应请求的数据对象</td> </tr> <tr> <td>PUT</td> <td>更新数据库中的文档</td> <td>数据库中看到已更新的数据对象</td> </tr> <tr> <td>DELETE</td> <td>从数据库中删除对象</td> <td>Null</td> </tr> </tbody> </table> <p>您将使用的四种 HTTP 方法是 <code>POST</code>、<code>GET</code>、<code>PUT</code> 和 <code>DELETE</code>。如果您查看 Use 列中的相应条目,您会注意到每种方法都执行不同的 CRUD 操作。</p> <p>方法很重要,因为设计良好的 REST API 通常对于不同的操作具有相同的 URL。在这些情况下,方法告诉服务器执行哪种类型的操作。我们将在本章后面讨论如何在 Express 中构建和组织方法的路由。</p> <p>如果您将路径和参数映射到适当的请求方法,您可以为您的 API 制定一个计划,如 表 6.3 所示。</p> <h5 id="表-63-将-url-与所需操作链接的请求方法使-api-能够使用相同的-url-执行不同的操作">表 6.3. 将 URL 与所需操作链接的请求方法,使 API 能够使用相同的 URL 执行不同的操作</h5> <table> <thead> <tr> <th>操作</th> <th>方法</th> <th>URL 路径</th> <th>示例</th> </tr> </thead> <tbody> <tr> <td>创建新位置</td> <td>POST</td> <td>/locations</td> <td><a href="http://loc8r.com/api/locations" target="_blank"><code>loc8r.com/api/locations</code></a></td> </tr> <tr> <td>读取位置列表</td> <td>GET</td> <td>/locations</td> <td><a href="http://loc8r.com/api/locations" target="_blank"><code>loc8r.com/api/locations</code></a></td> </tr> <tr> <td>读取特定位置</td> <td>GET</td> <td>/locations/:locationid</td> <td><a href="http://loc8r.com/api/locations/123" target="_blank"><code>loc8r.com/api/locations/123</code></a></td> </tr> <tr> <td>更新特定位置</td> <td>PUT</td> <td>/locations/:locationid</td> <td><a href="http://loc8r.com/api/locations/123" target="_blank"><code>loc8r.com/api/locations/123</code></a></td> </tr> <tr> <td>删除特定位置</td> <td>DELETE</td> <td>/locations/:locationid</td> <td><a href="http://loc8r.com/api/locations/123" target="_blank"><code>loc8r.com/api/locations/123</code></a></td> </tr> </tbody> </table> <p>表 6.3 展示了您将用于与位置数据交互的路径和方法。有五个操作,但只有两种 URL 模式,因此您可以使用请求方法来获取所需的结果。</p> <p>Loc8r 目前只有一个集合,因此这是您的起点。但 Locations 集合中的文档确实有作为子文档的评论,所以您也会很快将它们映射出来。</p> <p>子文档的处理方式类似,但需要额外的参数。每个请求都需要指定位置的 ID,某些请求还需要指定评论的 ID。表 6.4 展示了操作列表及其相关的方法、URL 路径和参数。</p> <h5 id="表-64-与子文档交互的-url-规范每个基本-url-路径都必须包含父文档的-id">表 6.4. 与子文档交互的 URL 规范;每个基本 URL 路径都必须包含父文档的 ID</h5> <table> <thead> <tr> <th>操作</th> <th>方法</th> <th>URL 路径</th> <th>示例</th> </tr> </thead> <tbody> <tr> <td>创建新评论</td> <td>POST</td> <td>/locations/:locationid/reviews</td> <td><a href="http://loc8r.com/api/locations/123/reviews" target="_blank"><code>loc8r.com/api/locations/123/reviews</code></a></td> </tr> <tr> <td>读取特定评论</td> <td>GET</td> <td>/locations/:locationid/reviews/:reviewid</td> <td><a href="http://loc8r.com/api/locations/123/reviews/abc" target="_blank"><code>loc8r.com/api/locations/123/reviews/abc</code></a></td> </tr> <tr> <td>更新特定评论</td> <td>PUT</td> <td>/locations/:locationid/reviews/:reviewid</td> <td><a href="http://loc8r.com/api/locations/123/reviews/abc" target="_blank"><code>loc8r.com/api/locations/123/reviews/abc</code></a></td> </tr> <tr> <td>删除特定评论</td> <td>DELETE</td> <td>/locations/:locationid/reviews/:reviewid</td> <td><a href="http://loc8r.com/api/locations/123/reviews/abc" target="_blank"><code>loc8r.com/api/locations/123/reviews/abc</code></a></td> </tr> </tbody> </table> <p>你可能已经注意到,对于子文档,你没有“读取评论列表”的操作,因为你将作为主文档的一部分检索评论列表。前面的表格应该给你一个如何创建基本的 API 请求规范的想法。URL、参数和操作将因应用程序而异,但方法应该保持一致。</p> <p>那就是关于请求的故事。流程的另一部分,在你陷入某些代码之前,是响应。</p> <h4 id="613-响应和状态码">6.1.3. 响应和状态码</h4> <p>一个好的 API 就像一个好的朋友。如果你去击掌,一个好的朋友不会让你感到无助。同样的,一个好的 API。如果你发起一个请求,一个好的 API 总是响应,不会让你感到无助。每个 API 请求都应该返回一个响应。好的 API 和坏的 API 之间的对比在图 6.3 中展示。</p> <h5 id="图-63-一个好的-api-总是返回响应不应该让你感到无助">图 6.3. 一个好的 API 总是返回响应,不应该让你感到无助。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/06fig03_alt.jpg" alt="" loading="lazy"></p> <p>对于一个成功的 REST API,标准化响应与标准化请求格式一样重要。响应有两个关键组成部分:</p> <ul> <li> <p>返回的数据</p> </li> <li> <p>HTTP 状态码</p> </li> </ul> <p>将返回的数据与适当的状态码结合起来,应该给请求者提供所有继续所需的信息。</p> <h5 id="从-api-返回数据">从 API 返回数据</h5> <p>你的 API 应该返回一致的数据格式。REST API 的典型格式是 XML 和/或 JSON。你将使用 JSON 作为你的 API,因为它与 MEAN 栈是天然的匹配。MongoDB 输出 JSON,Node 和 Angular 都可以原生理解。毕竟,JSON 是 JavaScript 传输数据的方式。JSON 也比 XML 更紧凑,因此可以通过减少所需的带宽来帮助提高 API 的响应时间和效率。</p> <p>你的 API 将为每个请求返回三件事之一:</p> <ul> <li> <p>包含响应查询数据的 JSON 对象</p> </li> <li> <p>包含错误数据的 JSON 对象</p> </li> <li> <p>空响应</p> </li> </ul> <p>在本章中,我们将讨论如何在构建 Loc8r API 的过程中完成所有这些事情。除了响应数据外,REST API 还应返回正确的 HTTP 状态码。</p> <h5 id="使用-http-状态码">使用 HTTP 状态码</h5> <p>一个好的 REST API 应该返回正确的 HTTP 状态码。大多数人熟悉的状态码是 404,这是当用户请求一个找不到的页面时,Web 服务器返回的状态码。这个错误码可能是互联网上最普遍的一个,但还有几十个其他的状态码,它们与客户端错误、服务器错误、重定向和成功请求有关。表 6.5 显示了 10 个最受欢迎的 HTTP 状态码以及它们在构建 API 时可能的有用之处。</p> <h5 id="表-65-最受欢迎的-http-状态码及其可能用于向-api-请求发送响应的方式">表 6.5. 最受欢迎的 HTTP 状态码及其可能用于向 API 请求发送响应的方式</h5> <table> <thead> <tr> <th>状态码</th> <th>名称</th> <th>用例</th> </tr> </thead> <tbody> <tr> <td>200</td> <td>OK</td> <td>成功的 GET 或 PUT 请求</td> </tr> <tr> <td>201</td> <td>已创建</td> <td>成功的 POST 请求</td> </tr> <tr> <td>204</td> <td>无内容</td> <td>成功的 DELETE 请求</td> </tr> <tr> <td>400</td> <td>错误请求</td> <td>由于内容无效而导致的 GET、POST 或 PUT 请求失败</td> </tr> <tr> <td>401</td> <td>未授权</td> <td>使用不正确的凭据请求受限制的 URL</td> </tr> <tr> <td>403</td> <td>禁止</td> <td>进行不允许的请求</td> </tr> <tr> <td>404</td> <td>未找到</td> <td>由于 URL 参数错误而导致的请求失败</td> </tr> <tr> <td>405</td> <td>不允许的方法</td> <td>对于给定的 URL 不允许请求方法</td> </tr> <tr> <td>409</td> <td>冲突</td> <td>当存在具有相同数据的另一个对象时,POST 请求失败</td> </tr> <tr> <td>500</td> <td>内部服务器错误</td> <td>服务器或数据库服务器出现问题</td> </tr> </tbody> </table> <p>当你阅读本章并构建 Loc8r API 时,你将使用这些状态码中的几个来返回适当的数据。</p> <h3 id="62-设置-express-中的-api">6.2. 设置 Express 中的 API</h3> <p>你已经对 API 需要执行的操作和执行这些操作所需的 URL 路径有了很好的了解。正如你在 第四章 中所知,为了使 Express 根据传入的 URL 请求执行某些操作,你需要设置控制器和路由。控制器执行操作,而路由将传入的请求映射到适当的控制器。</p> <p>你已经在应用程序中为路由和控制器设置了文件,所以你可以使用这些文件。然而,更好的选择是将 API 代码保持独立,这样你就不必担心应用程序中的混淆和复杂化。实际上,这是创建 API 的原因之一。此外,将 API 代码保持独立使得在未来某个时刻将其剥离并放入一个单独的应用程序中变得更加容易。你确实希望实现轻松解耦。</p> <p>你想要做的第一件事是在应用程序中为创建 API 的文件创建一个单独的区域。在应用程序的顶层,创建一个名为 app_api 的新文件夹。如果你一直在跟随并构建应用程序,则此文件夹位于 app_server 文件夹旁边。</p> <p>此文件夹包含与 API 相关的所有特定内容:路由、控制器和模型。当你设置好一切后,看看一些测试这些 API 占位符的方法。</p> <h4 id="621-创建路由">6.2.1. 创建路由</h4> <p>正如你在主 Express 应用程序的路由中做的那样,你将在 app_api/routes 文件夹中有一个 index.js 文件,它将包含你将在 API 中使用的所有路由。首先,在主应用程序文件 app.js 中引用此文件。</p> <h5 id="在应用程序中包含路由">在应用程序中包含路由</h5> <p>第一步是告诉你的应用程序你正在添加更多需要关注的路由,以及何时应该使用它们。你可以在 app.js 中复制一行来 <code>require</code> 服务器应用程序的路由,并将路径设置为 API 路由,如下所示:</p> <pre><code>const indexRouter = require('./app_server/routes/index'); const apiRouter = require('./app_api/routes/index'); </code></pre> <p>你可能在 app.js 中还有一行代码,仍然包含示例<code>user</code>路由。如果你有的话,现在可以删除它,因为你不需要它。接下来,你需要告诉应用程序何时使用这些路由。你目前在 app.js 中有以下行,告诉应用程序检查服务器应用程序路由以处理所有传入的请求:</p> <pre><code>app.use('/', indexRouter); </code></pre> <p>注意第一个参数是<code>'/'</code>。这个参数允许你指定一个子集的 URL,对于这些 URL,路由将适用。你将定义所有以/api/开头的 API 路由。通过添加以下代码片段中显示的行,你可以告诉应用程序仅在路由以/api/开头时使用 API 路由:</p> <pre><code>app.use('/', indexRouter); app.use('/api', apiRouter); </code></pre> <p>如前所述,如果你有,可以删除类似的<code>user</code>路由行。现在,是时候设置这些 URL 了。</p> <h5 id="在路由中指定请求方法">在路由中指定请求方法</h5> <p>到目前为止,你只在路由中使用了<code>GET</code>方法,如你的主应用程序路由中的以下代码片段所示:</p> <pre><code>router. get ('/location', ctrlLocations.locationInfo); </code></pre> <p>使用其他方法——<code>POST</code>、<code>PUT</code>和<code>DELETE</code>——就像将<code>get</code>与相应的关键字<code>post</code>、<code>put</code>和<code>delete</code>交换一样简单。以下代码片段展示了使用<code>POST</code>方法创建新位置的示例:</p> <pre><code>router.post('/locations', ctrlLocations.locationsCreate); </code></pre> <p>注意,你不需要在路径的开头指定/api。你在 app.js 中指定,只有当路径以/api 开头时才使用这些路由,因此假设此文件中指定的所有路由都带有/api 前缀。</p> <h5 id="指定必需的-url-参数">指定必需的 URL 参数</h5> <p>API URL 中包含用于识别特定文档或子文档的参数是很常见的——在 Loc8r 的情况下,这些参数是位置和评论。在路由中指定这些参数很简单;在定义每个路由时,你只需在参数名称前加上冒号即可。</p> <p>假设你正在尝试访问一个 ID 为<code>abc</code>的评论,该评论属于 ID 为<code>123</code>的位置。你的 URL 路径可能如下所示:</p> <pre><code>/api/locations/:locationid/reviews/:reviewid </code></pre> <p>将 ID 替换为参数名称(带有冒号前缀)给出如下路径:</p> <pre><code>/api/locations/:locationid/reviews/:reviewid </code></pre> <p>使用这样的路径,Express 只会匹配匹配该模式的 URL。因此,必须指定位置 ID,并且它必须在 URL 中的 locations/和/reviews 之间。同样,必须在 URL 的末尾指定评论 ID。当将这样的路径分配给控制器时,参数将在代码中使用,其名称由路径指定(在本例中为<code>locationid</code>和<code>reviewid</code>)。</p> <p>我们将在稍后详细说明如何访问它们,但首先,你需要设置 Loc8r API 的路由。</p> <h5 id="定义-loc8r-api-路由">定义 Loc8r API 路由</h5> <p>现在你已经知道如何设置路由以接受参数,你也知道你想要在 API 中拥有的操作、方法和路径。你可以结合所有这些知识来创建 Loc8r API 的路由定义。</p> <p>如果你还没有这样做,你应该在 app_api/routes 文件夹中创建一个 index.js 文件。为了控制单个文件的大小,将位置和评论控制器分别放入不同的文件中。</p> <p>您还将使用一种在 Express 中定义路由的不同方法,这对于管理单个路由上的多个方法非常理想。使用这种方法,您首先定义路由,然后链式连接不同的 HTTP 方法。这个过程简化了路由定义,使它们更容易阅读。</p> <p>下面的列表显示了定义的路由应该如何看起来。</p> <h5 id="列表-61-在-app_apiroutesindexjs-中定义的路由">列表 6.1. 在 app_api/routes/index.js 中定义的路由</h5> <pre><code>const express = require('express'); const router = express.Router(); const ctrlLocations = require('../controllers/locations'); *1* const ctrlReviews = require('../controllers/reviews'); *1* // locations router *2* .route('/locations') *2* .get(ctrlLocations.locationsListByDistance) *2* .post(ctrlLocations.locationsCreate); *2* router *2* .route('/locations/:locationid') *2* .get(ctrlLocations.locationsReadOne) *2* .put(ctrlLocations.locationsUpdateOne) *2* .delete(ctrlLocations.locationsDeleteOne); *2* // reviews router *3* .route('/locations/:locationid/reviews') *3* .post(ctrlReviews.reviewsCreate); *3* router *3* .route('/locations/:locationid/reviews/:reviewid') *3* .get(ctrlReviews.reviewsReadOne) *3* .put(ctrlReviews.reviewsUpdateOne) *3* .delete(ctrlReviews.reviewsDeleteOne); *3* module.exports = router; *4* </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>包含控制器文件。(您将在下面创建这些。)</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>为位置定义路由</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>为评论定义路由</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>导出路由</strong></p> </li> </ul> <p>在此路由文件中,您需要 <code>require</code> 相关的控制器文件。您尚未创建这些控制器文件,将在稍后创建。这种方法是一个很好的方法,因为通过在这里定义所有路由并声明相关的控制器函数,您可以开发一个控制器所需的高级视图。</p> <p>应用程序现在有两组路由:主 Express 应用程序路由和新的 API 路由。但是,由于 API 路由引用的任何控制器都不存在,应用程序目前无法启动。</p> <h4 id="622-创建控制器占位符">6.2.2. 创建控制器占位符</h4> <p>为了使应用程序能够启动,您可以创建控制器的占位符函数。这些函数不会做任何事情,但它们会阻止应用程序在构建 API 功能时崩溃。</p> <p>当然,第一步是创建控制器文件。您知道这些文件应该在何处以及它们应该被称为什么,因为您已经在 app_api/routes 文件夹中声明了它们。您需要在 app_api/controllers 文件夹中创建两个新文件,分别命名为 locations.js 和 reviews.js。</p> <p>您可以为每个控制器函数创建一个占位符,作为一个空函数,如下代码片段所示:</p> <pre><code>const locationsCreate = (req, res) => { }; </code></pre> <p>请记住,根据它是用于位置还是评论,将每个控制器放入正确的文件,并在文件底部导出它们,如下例所示:</p> <pre><code>module.exports = { locationsListByDistance, locationsCreate, locationsReadOne, locationsUpdateOne, locationsDeleteOne }; </code></pre> <p>然而,为了测试路由和函数,您需要返回一个响应。</p> <h4 id="623-从-express-请求返回-json">6.2.3. 从 Express 请求返回 JSON</h4> <p>在构建 Express 应用程序时,您渲染了一个视图模板以将 HTML 发送到浏览器,但使用 API,您希望发送一个状态码和一些 JSON 数据。Express 通过以下行使这项任务变得简单:</p> <pre><code>res *1* .status(status) *2* .json(content); *3* </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>使用 Express 响应对象</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>发送响应状态码,例如 200</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>发送响应数据,例如 {“status” : “success”}</strong></p> </li> </ul> <p>您可以使用以下两个命令在占位符函数中测试成功,如下代码片段所示:</p> <pre><code>const locationsCreate = (req, res) => { res .status(200) .json({"status" : "success"}); }; </code></pre> <p>随着您构建您的 API,您将大量使用此方法来发送不同的状态码和数据作为响应。</p> <h4 id="624-包含模型">6.2.4. 包含模型</h4> <p>API 能够与数据库通信至关重要;没有它,API 将不会有多大用处!要使用 Mongoose 实现这一点,你首先需要将 Mongoose <code>require</code> 到控制器文件中,然后引入 <code>Location</code> 模型。在控制器文件的最顶部,在所有占位符函数之上,添加以下两行:</p> <pre><code>const mongoose = require('mongoose'); const Loc = mongoose.model('Location'); </code></pre> <p>第一行给控制器提供了数据库连接的访问权限,第二行引入了 <code>Location</code> 模型,这样你就可以与 Locations 集合进行交互。</p> <p>如果你查看应用程序的文件结构,你会看到包含数据库连接的 /models 文件夹,Mongoose 设置在 app_server 文件夹内。但处理数据库的是 API,而不是主要的 Express 应用程序。如果两个应用程序是分开的,模型将保留在 API 中,所以它应该在那里。</p> <p>将 /models 文件夹从 app_server 文件夹移动到 app_api 文件夹,创建一个类似于 图 6.4 中所示的文件夹结构。</p> <h5 id="图-64-此时的应用程序文件夹结构app_api-包含模型控制器和路由而-app_server-包含视图控制器和路由">图 6.4. 此时的应用程序文件夹结构。app_api 包含模型、控制器和路由,而 app_server 包含视图、控制器和路由。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/06fig04.jpg" alt="图片" loading="lazy"></p> <p>当然,你需要告诉应用程序你已经移动了 app_api/models 文件夹,因此需要更新 app.js 中指向模型的行,使其指向正确的位置:</p> <pre><code>require('./app_api/models/db'); </code></pre> <p>完成这些操作后,应用程序应该再次启动并仍然连接到你的数据库。接下来要问的问题是如何测试 API。</p> <h4 id="625-测试-api">6.2.5. 测试 API</h4> <p>你可以通过访问适当的 URL,例如 <a href="http://localhost:3000/api/locations/1234%EF%BC%8C%E5%BF%AB%E9%80%9F%E5%9C%A8%E6%B5%8F%E8%A7%88%E5%99%A8%E4%B8%AD%E6%B5%8B%E8%AF%95" target="_blank">http://localhost:3000/api/locations/1234,快速在浏览器中测试</a> <code>GET</code> 路由。你应该看到成功响应被发送到浏览器,如图 图 6.5 所示。</p> <h5 id="图-65-在浏览器中测试-api-的-get-请求">图 6.5. 在浏览器中测试 API 的 <code>GET</code> 请求</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/06fig05_alt.jpg" alt="图片" loading="lazy"></p> <p>这对于测试 <code>GET</code> 请求是可行的,但对于 <code>POST</code>、<code>PUT</code> 和 <code>DELETE</code> 方法帮助不大。一些工具可以帮助你测试这样的 API 调用,但我们目前最喜欢的免费应用程序是 Postman REST 客户端,它可以作为独立应用程序或浏览器扩展使用。</p> <p>Postman 允许你使用多种请求方法测试 API URL,允许你指定额外的查询字符串参数或表单数据。点击发送按钮后,Postman 会向指定的 URL 发送请求,并显示响应数据和状态码。</p> <p>图 6.6 展示了 Postman 向之前相同的 URL 发送 <code>PUT</code> 请求的截图。</p> <h5 id="图-66-使用-postman-rest-客户端测试-api-的-put-请求">图 6.6. 使用 Postman REST 客户端测试 API 的 <code>PUT</code> 请求</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/06fig06_alt.jpg" alt="图片" loading="lazy"></p> <p>现在启动 Postman 或其他 REST 客户端是个好主意。在构建 REST API 的过程中,你将在本章中大量使用它。在下一节中,你将使用 <code>GET</code> 请求从 MongoDB 读取数据来开始 API 的操作。</p> <h3 id="63-get-方法从-mongodb-读取数据">6.3. GET 方法:从 MongoDB 读取数据</h3> <p><code>GET</code>方法都是关于查询数据库并返回一些数据的。在你的 Loc8r 路由中,有三个<code>GET</code>请求执行不同的操作,如表 6.6 中列出。</p> <h5 id="表-66-loc8r-api-的三个get请求">表 6.6. Loc8r API 的三个<code>GET</code>请求</h5> <table> <thead> <tr> <th>操作</th> <th>方法</th> <th>URL 路径</th> <th>示例</th> </tr> </thead> <tbody> <tr> <td>读取位置列表</td> <td>GET</td> <td>/locations</td> <td><a href="http://loc8r.com/api/locations" target="_blank"><code>loc8r.com/api/locations</code></a></td> </tr> <tr> <td>读取特定位置</td> <td>GET</td> <td>/locations/:locationid</td> <td><a href="http://loc8r.com/api/locations/123" target="_blank"><code>loc8r.com/api/locations/123</code></a></td> </tr> <tr> <td>读取特定评论</td> <td>GET</td> <td>/locations/:locationid/reviews/:reviewid</td> <td><a href="http://loc8r.com/api/locations/123/reviews/abc" target="_blank"><code>loc8r.com/api/locations/123/reviews/abc</code></a></td> </tr> </tbody> </table> <p>你将首先查看如何查找单个位置,因为它为 Mongoose 的工作方式提供了一个很好的介绍。接下来,你将使用 ID 定位单个文档,然后你将扩展到搜索多个文档。</p> <h4 id="631-在-mongodb-中使用-mongoose-查找单个文档">6.3.1. 在 MongoDB 中使用 Mongoose 查找单个文档</h4> <p>Mongoose 通过其模型与数据库交互,这就是为什么你将<code>Location</code>模型作为<code>Loc</code>导入到控制器文件顶部。Mongoose 模型有几个相关方法可以帮助管理交互,如侧边栏 Mongoose 查询方法中所述。</p> <p><strong>Mongoose 查询方法</strong></p> <p>Mongoose 模型有几种方法可以帮助查询数据库。以下是一些关键方法:</p> <ul> <li> <p><code>find</code>—基于提供的查询对象进行通用搜索</p> </li> <li> <p><code>findById</code>—查找特定 ID</p> </li> <li> <p><code>findOne</code>—获取与提供的查询匹配的第一个文档</p> </li> <li> <p><code>geoNear</code>—查找与提供的纬度和经度地理上接近的地方</p> </li> <li> <p><code>geoSearch</code>—为<code>geoNear</code>操作添加查询功能</p> </li> </ul> <p>你将在这本书中使用其中的一些方法,但不是全部。</p> <p>对于在 MongoDB 中查找具有已知 ID 的单个数据库文档,Mongoose 有<code>findById()</code>方法。</p> <h5 id="将-findbyid-方法应用于模型">将 findById 方法应用于模型</h5> <p><code>findById()</code>方法相对简单,接受一个参数:要查找的 ID。作为模型方法,它应用于模型,如下所示:</p> <pre><code>Loc.findById(locationid) </code></pre> <p>此方法不会启动数据库查询操作;它告诉模型查询将是什么。要启动数据库查询,Mongoose 模型有一个<code>exec</code>方法。</p> <h5 id="使用-exec-方法运行查询">使用 exec 方法运行查询</h5> <p><code>exec</code>方法执行查询,并传递一个回调函数,当操作完成时将运行。回调函数应接受两个参数:一个错误对象和找到的文档实例。作为回调函数,这些参数的名称可以随意命名。</p> <p>方法可以按以下方式链接:</p> <pre><code>Loc .findById(locationid) *1* .exec((err, location) => { *2* console.log("findById complete"); *3* }); </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>将 findById 方法应用于 Location 模型,使用 Loc</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>执行查询</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>完成时记录消息</strong></p> </li> </ul> <p>这种方法确保数据库交互是异步的,因此不会阻塞主 Node 进程。</p> <h5 id="在控制器中使用-findbyid-方法">在控制器中使用 findById 方法</h5> <p>你正在使用的用于通过 ID 查找单个位置的控制器是 <code>locationsReadOne()</code>,位于 app_api/controllers 中的 locations.js 文件。</p> <p>你知道操作的基本结构:将 <code>findById()</code> 和 <code>exec</code> 方法应用于 <code>Location</code> 模型。为了在控制器上下文中使此操作生效,你需要做两件事:</p> <ul> <li> <p>从 URL 中获取 <code>locationid</code> 参数,并将其传递给 <code>findById()</code> 方法。</p> </li> <li> <p>将输出函数提供给 <code>exec</code> 方法。</p> </li> </ul> <p>Express 使得获取你在路由中定义的 URL 参数变得简单。这些参数存储在附加到请求对象的 <code>params</code> 对象中。如果你的路由定义如下</p> <pre><code>router .route('/api/locations/:locationid') </code></pre> <p>你可以在控制器内部这样访问 <code>locationid</code> 参数:</p> <pre><code>req.params.locationid </code></pre> <p>对于输出函数,你可以使用一个简单的回调,将找到的位置作为 JSON 响应发送。将这些放在一起,你将得到以下内容:</p> <pre><code>const locationsReadOne = (req, res) => { Loc .findById(req.params.locationid) *1* .exec((err, location) => { *2* res *3* .status(200) *3* .json(location); *3* }); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> 从 URL 参数中获取 locationid,并将其传递给 findById 方法</p> </li> <li> <p><em><strong>2</strong></em> <strong>定义回调以接受可能的参数</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>以 HTTP 状态码 200 将找到的文档作为 JSON 响应发送</strong></p> </li> </ul> <p>现在你已经有一个基本的 API 控制器。你可以通过在浏览器中访问 MongoDB 中某个位置的 ID 的 URL 或者在 Postman 中调用它来尝试它。要获取一个 ID 值,你可以在 Mongo shell 中运行命令 <code>db.locations.find ()</code>,该命令会列出你拥有的所有位置,每个位置都包含 <code>_id</code> 值。当你组合好 URL 后,输出应该是一个完整的存储在 MongoDB 中的位置对象;你应该会看到类似图 6.7 的内容。</p> <h5 id="图-67-通过-id-查找单个位置的基本控制器在找到-id-时向浏览器返回一个-json-对象">图 6.7. 通过 ID 查找单个位置的基本控制器在找到 ID 时向浏览器返回一个 JSON 对象。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/06fig07_alt.jpg" alt="" loading="lazy"></p> <p>你尝试了基本控制器吗?你在 URL 中放入了一个无效的位置 ID 吗?如果你这样做了,你就会看到你没有收到任何东西——没有警告,没有消息;一个 200 状态码告诉你一切正常,但没有返回数据。</p> <h5 id="捕获错误">捕获错误</h5> <p>那个基本控制器的问题在于它只输出成功响应,无论是否成功。这种行为对 API 来说并不好。一个好的 API 应该在出现问题时返回错误代码。</p> <p>为了响应错误消息,控制器需要设置以捕获潜在的错误并发送适当的响应。这种方式的错误捕获通常涉及 <code>if</code> 语句。每个 <code>if</code> 语句都必须有一个相应的 <code>else</code> 语句或包含一个 <code>return</code> 语句。</p> <h5 id="提示-1">提示</h5> <p>你的 API 代码绝不能对请求置之不理。</p> <p>使用你的基本控制器,你需要捕获三个错误:</p> <ul> <li> <p>请求参数不包含 <code>locationid</code>。</p> </li> <li> <p><code>findById()</code> 方法不返回位置。</p> </li> <li> <p><code>findById()</code> 方法返回一个错误。</p> </li> </ul> <p>不成功的<code>GET</code>请求的状态码为 404。考虑到这一点,查找并返回单个位置的控制器最终代码如下所示。</p> <h5 id="列表-62-locationsreadone控制器">列表 6.2. <code>locationsReadOne</code>控制器</h5> <pre><code>const locationsReadOne = (req, res) => { Loc .findById(req.params.locationid) .exec((err, location) => { if (!location) { *1* return res *1* .status(404) *1* .json({ *1* "message": "location not found" *1* }); *1* } else if (err) { *2* return res *2* .status(404) *2* .json(err); *2* } res *3* .status(200) *3* .json(location); *3* }); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>错误陷阱 1:如果 Mongoose 没有返回位置,则发送 404 消息并退出函数作用域,使用 return 语句</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>错误陷阱 2:如果 Mongoose 返回错误,则将其作为 404 响应发送并退出控制器,使用 return 语句</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>如果 Mongoose 没有出错,则继续之前的操作,并在 200 响应中发送位置对象</strong></p> </li> </ul> <p>列表 6.2 使用了<code>if</code>语句的两种捕获方法。错误陷阱 1 <em><strong>1</strong></em> 和错误陷阱 2 <em><strong>2</strong></em> 使用<code>if</code>语句检查 Mongoose 返回的错误。每个<code>if</code>语句都包含一个<code>return</code>语句,这阻止了回调作用域中任何后续代码的执行。如果没有找到错误,则忽略<code>return</code>语句,代码继续发送成功的响应 <em><strong>3</strong></em>。</p> <p>这些陷阱中的每一个都提供了成功和失败的响应,没有留下 API 让请求者悬而未决的空间。如果你愿意,你还可以加入一些<code>console.log()</code>语句,这样在终端中跟踪正在发生的事情会更容易;GitHub 上的源代码中也有一些。</p> <p>图 6.8 展示了使用 Chrome 中的 Postman 扩展程序时,成功请求与失败请求之间的区别。</p> <h5 id="图-68-使用-postman-测试成功左和失败右的-api-响应">图 6.8. 使用 Postman 测试成功(左)和失败(右)的 API 响应</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/06fig08_alt.jpg" alt="" loading="lazy"></p> <p>这样就处理完了一个完整的 API 路由。现在,是时候查看第二个<code>GET</code>请求以返回单个评论了。</p> <h4 id="632-基于-id-查找单个子文档">6.3.2. 基于 ID 查找单个子文档</h4> <p>要查找子文档,你首先必须找到父文档,然后使用其 ID 定位所需的位置。当你找到文档后,你可以查找特定的子文档。你可以以<code>locationsReadOne()</code>控制器为起点,并添加一些修改来创建<code>reviewsReadOne()</code>控制器。这些修改包括</p> <ul> <li> <p>接受并使用额外的<code>reviewid</code> URL 参数。</p> </li> <li> <p>仅从文档中选择名称和评论,而不是让 MongoDB 返回整个文档。</p> </li> <li> <p>查找具有匹配 ID 的评论。</p> </li> <li> <p>返回适当的 JSON 响应。</p> </li> </ul> <p>要完成这些事情,你可以使用几个新的 Mongoose 方法。</p> <h5 id="限制从-mongodb-返回的路径">限制从 MongoDB 返回的路径</h5> <p>当你从 MongoDB 检索文档时,你并不总是需要完整的文档;有时,你只想获取一些特定的数据。限制传递的数据也有助于带宽消耗和速度。</p> <p>Mongoose 通过将<code>select()</code>方法链接到模型查询来实现这一点。以下代码片段告诉 MongoDB 你只想获取位置的名字和评论:</p> <pre><code>Loc .findById(req.params.locationid) .select('name reviews') .exec(); </code></pre> <p><code>select()</code>方法接受一个由空格分隔的路径字符串,表示你想要检索的路径。</p> <h5 id="使用-mongoose-查找特定的子文档">使用 Mongoose 查找特定的子文档</h5> <p>Mongoose 还提供了一个辅助方法来通过 ID 查找子文档。给定一个子文档数组,Mongoose 的<code>id</code>方法接受你想要查找的 ID。<code>id</code>方法返回单个匹配的子文档,可以使用以下方式使用:</p> <pre><code>Loc .findById(req.params.locationid) .select('name reviews') .exec((err, location) => { const review = location.reviews.id(req.params.reviewid); *1* } ); </code></pre> <ul> <li><em><strong>1</strong></em> <strong>将参数中的 reviewid 传递给 id 方法</strong></li> </ul> <p>在这个代码片段中,回调函数将单个评论返回到<code>review</code>变量中。</p> <h5 id="添加一些错误处理并将所有内容组合在一起">添加一些错误处理并将所有内容组合在一起</h5> <p>现在你已经拥有了制作<code>reviewsReadOne()</code>控制器的所需成分。从<code>locationsReadOne()</code>控制器的副本开始,你可以进行必要的修改以返回单个评论。</p> <p>以下列表显示了<code>reviewsReadOne()</code>控制器在 review.js 中的内容(加粗的修改):</p> <h5 id="列表-63-查找一个单个评论的控制台">列表 6.3. 查找一个单个评论的控制台</h5> <pre><code> const reviewsReadOne = (req, res) => { Loc .findById(req.params.locationid) .select('name reviews') *1* .exec((err, location) => { if (!location) { return res .status(404) .json({ "message": "location not found" }); } else if (err) { return res .status(400) .json(err); } if (location.reviews && location.reviews.length > 0) { *2* const review = location.reviews.id(req.params.reviewid); *3* if (!review) { *4* return res *4* .status(400) *4* .json({ *4* "message": "review not found" *4* }); *4* } else { *5* response = { *5* location : { *5* name : location.name, *5* id : req.params.locationid *5* }, *5* review *5* }; *5* return res *5* .status(200) *5* .json(response); *5* } *5* } else { *6* return res *6* .status(404) *6* .json({ *6* "message": "No reviews found" *6* }); *6* } *6* } ); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>将 Mongoose 选择方法添加到模型查询中,表示你想要获取位置名称及其评论</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>检查返回的位置是否有评论</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>使用 Mongoose 子文档.id 方法作为搜索匹配 ID 的辅助工具</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>如果没有找到评论,返回适当的响应</strong></p> </li> <li> <p><em><strong>5</strong></em> <strong>如果找到评论,构建一个响应对象,返回评论和位置名称和 ID</strong></p> </li> <li> <p><em><strong>6</strong></em> <strong>如果没有找到评论,返回适当的错误信息</strong></p> </li> </ul> <p>当这段代码保存并准备好后,你可以再次使用 Postman 进行测试。你需要有正确的 ID 值,这些值可以从你为检查单个位置所做的 Postman 查询中获取,或者可以直接从 MongoDB 通过 Mongo shell 获取。Mongo 命令<code>db.locations.find()</code>返回所有位置及其评论。请记住,URL 的结构是/locations/:locationid/reviews/:reviewid。</p> <p>你还可以测试如果你输入了一个错误的位置或评论 ID 会发生什么,或者尝试来自不同位置的评论 ID。</p> <h4 id="633-使用地理空间查询查找多个文档">6.3.3. 使用地理空间查询查找多个文档</h4> <p>Loc8r 的首页应根据用户的当前地理位置显示位置列表。MongoDB 和 Mongoose 提供了一些特殊的地理空间聚合方法,以帮助找到附近的地点。</p> <p>在这里,你将使用 Mongoose 聚合<span class="math inline">\(`geoNear`来查找一个接近指定点的位置列表,直到指定的最大距离。\)</span><code>geoNear</code>是一个聚合方法,它接受多个配置选项,其中以下选项是必需的:</p> <ul> <li> <p><code>near</code>作为一个<code>geoJSON</code>地理点</p> </li> <li> <p>一个<code>distanceField</code>对象选项</p> </li> <li> <p>一个<code>maxDistance</code>对象选项</p> </li> </ul> <p>以下代码片段显示了基本结构:</p> <pre><code>Loc.aggregate([{$geoNear: {near: {}, distanceField: "distance", maxDistance: 100}}]); </code></pre> <p>与<code>findById</code>方法一样,$<code>geoNear</code>聚合返回一个 Promise,其值可以通过使用回调、其<code>exec</code>方法或 async/await 来获取。</p> <h5 id="构建一个-geojson-点">构建一个 geoJSON 点</h5> <p><code>$geoNear</code>的第一个参数是一个<code>geoJSON</code>点:一个包含纬度和经度数组的简单 JSON 对象。<code>geoJSON</code>点的结构在以下代码片段中显示:</p> <pre><code>const point = { *1* type: "Point", *2* coordinates: [lng, lat] *3* }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>声明对象</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>将其定义为“Point”类型</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>在数组中设置经纬度坐标,先设置经度</strong></p> </li> </ul> <p>这里设置的获取位置列表的路由没有在 URL 参数中包含坐标,这意味着它们将以不同的方式指定。查询字符串是这种数据类型的理想选择,因此请求 URL 将看起来更像是这样:</p> <pre><code>api/locations?lng=-0.7992599&lat=51.378091 </code></pre> <p>Express 当然为您提供了访问查询字符串中的值的方法,将它们放入附加到请求对象的查询对象中,例如<code>req.query.lng</code>。当检索到经纬度值时,它们将是字符串,但需要将它们作为数字添加到点对象中。JavaScript 的<code>parseFloat()</code>函数可以处理这一点。以下代码片段显示了如何从查询字符串中获取坐标并创建<code>geoJSON</code>点,这是<code>$geoNear</code>聚合所必需的:</p> <pre><code>const locationsListByDistance = async (req, res) => { const lng = parseFloat(req.query.lng); *1* const lat = parseFloat(req.query.lat); *1* const near = { *2* type: "Point", *2* coordinates: [lng, lat] *2* }; *2* const geoOptions = { distanceField: "distance.calculated", spherical: true, *3* maxDistance: 20000, limit: 10 }; try { const results = await Loc.aggregate([ *4* { $geoNear: { near, ...geoOptions *5* } } ]); } catch (err) { console.log(err); } }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>从查询字符串中获取坐标并将其从字符串转换为数字</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>创建 geoJSON 点</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>在这里您使用的是 spherical: true,因为这会导致 MongoDB 使用$nearSphere 语义,它使用球面几何来计算距离。如果这是 false,它将使用 2D 几何。</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>聚合</strong></p> </li> <li> <p><em><strong>5</strong></em> <strong>扩展运算符(参见附近的侧边栏)</strong></p> </li> </ul> <p>尝试执行此控制器代码不会产生响应,因为数据处理尚未开始。请记住,此代码返回一个 Promise 对象。</p> <p><strong>扩展运算符</strong></p> <p>ES2015 中引入了扩展运算符。此运算符接受一个可迭代对象(一个数组、字符串或对象),并允许它扩展到期望零个或多个参数(在函数调用中使用)或元素(在数组字面量中使用)的位置。</p> <p>在前一个代码块中的聚合函数情况下,它将<code>geoOptions</code>中的对象属性注入到<code>$geoNear</code>对象中。扩展运算符有很多用途;详细信息请参阅<a href="http://mng.bz/wEya" target="_blank"><code>mng.bz/wEya</code></a>。</p> <h5 id="聚合规范中的球形选项">聚合规范中的球形选项</h5> <p><code>geoOptions</code>对象包含一个球形键。此值必须设置为<code>true</code>,因为您已经将 MongoDB 数据存储中的搜索索引指定为<code>2dsphere</code>。如果您尝试将其设置为<code>false</code>,应用程序将抛出异常:</p> <pre><code>const geoOptions = { distanceField: "distance.calculated", spherical: true }; </code></pre> <h5 id="通过数量限制-geonear-结果">通过数量限制 geoNear 结果</h5> <p>当返回列表时,您通常会希望通过限制结果数量来关注 API 服务器以及最终用户看到的响应性。在$<code>geoNear</code>聚合中,添加<code>num</code>或<code>limit</code>选项可以做到这一点。您指定希望返回的最大结果数量。您可以指定两者,但<code>num</code>优先于<code>limit</code>。</p> <p>以下代码片段显示了添加到之前的<code>geoOptions</code>对象中的<code>limit</code>,限制返回数据集的大小为 10 个对象:</p> <pre><code>const geoOptions = { distanceField: "distance.calculated", spherical: true, limit: 10 }; </code></pre> <p>现在搜索只会返回最多 10 个最近的结果。</p> <h5 id="通过距离限制-geonear-结果">通过距离限制 geoNear 结果</h5> <p>当返回基于位置的数据时,另一种保持 API 处理在控制之下的是通过距离限制结果列表。这是一个添加另一个名为<code>maxDistance</code>的选项的情况。当你使用球形选项时,MongoDB 会为你以米为单位进行计算,使生活变得简单。这并不总是这种情况。MongoDB 的旧版本使用弧度,这使得事情变得更加复杂。</p> <p>如果你想要以英里为单位输出,你需要做一些计算,但你会坚持使用米和公里。你将设置一个 20 公里的限制,即 20,000 米。现在你可以将<code>maxDistance</code>值添加到选项中,并将这些选项按如下方式添加到控制器中:</p> <pre><code>const locationsListByDistance = (req, res) => { const lng = parseFloat(req.query.lng); const lat = parseFloat(req.query.lat); const near = { type: "Point", coordinates: [lng, lat] }; const geoOptions = { *1* distanceField: "distance.calculated", spherical: true, *1* maxDistance: 20000, *1* num: 10 *1* }; ... *2* }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> 创建一个选项对象,包括设置最大距离为 20 公里</p> </li> <li> <p><em><strong>2</strong></em><strong>定义对象的其余部分</strong></p> </li> </ul> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>额外加分</strong></p> <p>尝试从查询字符串值中获取最大距离,而不是将其硬编码到函数中。本章在 GitHub 上的代码有答案。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>这是你需要的最后一个选项来对你的$<code>geoNear</code>数据库搜索,所以现在是时候开始处理输出了。</p> <h5 id="查看-the-geonear-聚合输出">查看 the $geoNear 聚合输出</h5> <p>$<code>geoNear</code>聚合方法的返回结果对象是数据库中匹配项的列表或错误对象。如果你使用回调函数,它将有以下签名:<code>callback(err, result)</code>。由于你使用<code>async</code>/<code>await</code>,你使用<code>try</code>/<code>catch</code>来执行操作或捕获错误。</p> <p>在成功查询的情况下,错误对象是未定义的;结果对象是一个项目列表,如前所述。你将首先处理成功的查询响应,然后再添加错误处理。</p> <p>在成功的$<code>geoNear</code>聚合之后,MongoDB 返回一个对象数组。每个对象包含一个距离值(由<code>distanceField</code>指定的路径)和从数据库返回的文档。换句话说,MongoDB 在数据中包含了距离。以下代码片段显示了返回数据的示例,为了简洁而截断:</p> <pre><code>[ { _id: 5b2c166f5caddf7cd8cea46b, name: 'Starcups', address: '125 High Street, Reading, RG6 1PS', rating: 3, facilities: [ 'Hot drinks', 'Food', 'Premium wifi' ], coords: { type: 'Point', coordinates: [Array] }, openingTimes: [ [Object], [Object], [Object] ], distance: { calculated: 5005.183015553589 } } ] </code></pre> <p>这个数组只有一个对象,但成功的查询可能会一次性返回多个对象。$<code>geoNear</code>聚合返回数据存储中包含的整个文档,但 API 不应该返回比请求更多的数据。所以,而不是将返回的数据作为响应发送,你首先需要进行一些处理。</p> <h4 id="处理geonear-输出">处理$geoNear 输出</h4> <p>在 API 发送响应之前,你需要确保它发送的是正确的内容,并且只发送所需的内容。你知道主页列表需要什么数据;你已经在 app_server/controllers/location.js 中构建了主页控制器。<code>homelist()</code>函数发送几个位置对象,类似于以下示例:</p> <pre><code>{ id: 111, name: 'Starcups', address: '125 High Street, Reading, RG6 1PS', rating: 3, facilities: ['Hot drinks', 'Food', 'Premium wifi'], distance: '100m' } </code></pre> <p>为了从结果中创建一个类似的对象,你需要遍历结果并将相关数据映射到一个新的数组中。然后,可以以状态 200 的响应返回处理后的数据。以下代码片段显示了这种结果可能的样子:</p> <pre><code>try { const results = await Loc.aggregate([ { $geoNear: { near, ...geoOptions } } ]); const locations = results.map(result => { *1* return { *2* id: result._id, name: result.name, address: result.address, rating: result.rating, facilities: result.facilities, distance: `${result.distance.calculated.toFixed()}m` *3* } }); return res *4* .status(200) .json(locations); } catch (err) { ... </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>创建一个新的数组来存储映射的结果数据</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>返回映射的结果</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>获取距离并将其修正为最接近的整数</strong></p> </li> <li> <p><em><strong>4</strong></em> 将处理后的数据作为 JSON 响应发送</p> </li> </ul> <p>如果你使用 Postman 测试这个 API 路由——记得在查询字符串中添加经纬度坐标——你会看到类似图 6.9 的内容。</p> <h5 id="图-69-在-postman-中测试位置列表路由应该返回-200-状态和结果列表具体取决于查询字符串中发送的地理坐标">图 6.9. 在 Postman 中测试位置列表路由应该返回 200 状态和结果列表,具体取决于查询字符串中发送的地理坐标。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/06fig09_alt.jpg" alt="" loading="lazy"></p> <p><strong>额外加分</strong></p> <p>尝试将结果传递给一个外部命名函数来构建位置列表。这个函数应该返回处理后的列表,然后可以将其传递到 JSON 响应中。</p> <p>如果你通过发送距离测试数据太远的坐标来测试,你应该仍然得到 200 状态,但返回的数组将是空的。</p> <h5 id="添加错误处理">添加错误处理</h5> <p>再次,你首先构建了成功功能。现在你需要添加一些错误陷阱,以确保 API 总是发送适当的响应。</p> <p>你需要设置的陷阱应该检查</p> <ul> <li> <p>所有参数都已正确发送。</p> </li> <li> <p>$<code>geoNear</code> 聚合操作没有返回错误条件。</p> </li> </ul> <p>以下列表显示了最终的控制器,包括这些错误陷阱。</p> <h5 id="列表-64-位置列表控制器-locationslistbydistance">列表 6.4. 位置列表控制器 locationsListByDistance</h5> <pre><code>const locationsListByDistance = async(req, res) => { const lng = parseFloat(req.query.lng); const lat = parseFloat(req.query.lat); const near = { type: "Point", coordinates: [lng, lat] }; const geoOptions = { distanceField: "distance.calculated", key: 'coords', spherical: true, maxDistance: 20000, limit: 10 }; if (!lng || !lat) { *1* return res *1* .status(404) *1* .json({ *1* "message": "lng and lat query parameters are required" *1* }); *1* } *1* try { const results = await Loc.aggregate([ { $geoNear: { near, ...geoOptions } } ]); const locations = results.map(result => { return { id: result._id name: result.name, address: result.address, rating: result.rating, facilities: result.facilities, distance: `${result.distance.calculated.toFixed()}m` } }); res .status(200) .json(locations); } catch (err) { res .status(404) *2* .json(err); } }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> 检查 lng 和 lat 查询参数是否存在正确的格式;如果不存在,则返回 404 错误和信息</p> </li> <li> <p><strong><em>2</em> 如果 $geoNear 聚合查询返回错误,则以 404 状态发送此响应</strong></p> </li> </ul> <p>这个列表完成了你的 API 需要服务的 <code>GET</code> 请求,因此现在是时候处理 <code>POST</code> 请求了。</p> <h3 id="64-post-方法向-mongodb-添加数据">6.4. POST 方法:向 MongoDB 添加数据</h3> <p><code>POST</code> 方法主要涉及在数据库中创建文档或子文档,然后返回保存的数据作为确认。在 Loc8r 的路由中,你有两个 <code>POST</code> 请求执行不同的操作,如表 6.7 中列出。</p> <h5 id="表-67-loc8r-api-的两个-post-请求">表 6.7. Loc8r API 的两个 <code>POST</code> 请求</h5> <table> <thead> <tr> <th>操作</th> <th>方法</th> <th>URL 路径</th> <th>示例</th> </tr> </thead> <tbody> <tr> <td>创建新位置</td> <td>POST</td> <td>/locations</td> <td><a href="http://api.loc8r.com/locations" target="_blank"><code>api.loc8r.com/locations</code></a></td> </tr> <tr> <td>创建新评论</td> <td>POST</td> <td>/locations/:locationid/reviews</td> <td><a href="http://api.loc8r.com/locations/123/reviews" target="_blank"><code>api.loc8r.com/locations/123/reviews</code></a></td> </tr> </tbody> </table> <p><code>POST</code> 方法通过获取发送给它们的表单数据并将其添加到数据库中工作。就像通过 <code>req.params</code> 访问 URL 参数和通过 <code>req.query</code> 访问查询字符串一样,Express 控制器通过 <code>req.body</code> 访问发送的表单数据。</p> <p>首先看看如何创建文档。</p> <h4 id="641-在-mongodb-中创建新文档">6.4.1. 在 MongoDB 中创建新文档</h4> <p>在 Loc8r 的数据库中,每个位置都是一个文档,所以你将在本节中创建一个文档。Mongoose 无法使创建 MongoDB 文档的过程对你来说更加简单。你将 <code>create()</code> 方法应用于你的模型,并传递一些数据和回调函数。这个结构是最小的,因为它将附加到你的 <code>Loc</code> 模型:</p> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/p0187-01_alt.jpg" alt="" loading="lazy"></p> <p>这很简单。创建过程有两个主要步骤:</p> <ol> <li> <p>使用发布的表单数据创建一个与架构匹配的 JavaScript 对象。</p> </li> <li> <p>根据创建操作的成功或失败,在回调中发送适当的响应。</p> </li> </ol> <p>看看步骤 1,你已经知道你可以通过使用 <code>req.body</code> 来获取发送给你的数据,步骤 2 现在应该很熟悉了。直接进入代码。</p> <p>以下列表显示了创建新文档的完整 <code>locationsCreate()</code> 控制器。</p> <h5 id="列表-65-创建新位置的完整控制器">列表 6.5. 创建新位置的完整控制器</h5> <pre><code>const locationsCreate = (req, res) => { Loc.create({ *1* name: req.body.name, address: req.body.address, facilities: req.body.facilities.split(","), *2* coords: { *3* type: "Point", [ parseFloat(req.body.lng), parseFloat(req.body.lat) ] }, { days: req.body.days2, opening: req.body.opening2, closing: req.body.closing2, closed: req.body.closed2, }] }, (err, location) => { *4* if (err) { res .status(400) .json(err); } else { res .status(201) .json(location); } }); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>将创建方法应用于模型</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>通过分割逗号分隔的列表创建设施数组</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>将坐标从字符串解析为数字</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>提供一个包含成功和失败适当响应的回调函数</strong></p> </li> </ul> <p>此列表显示了在 MongoDB 中创建新文档并保存一些数据是多么容易。为了简洁起见,你已将 <code>openingTimes</code> 数组限制为两个条目,但这个数组可以很容易地扩展,或者更好的是,放入循环中检查值的存在。</p> <p>你可能还会注意到没有设置 <code>rating</code>。记住,在架构中,你设置了默认值 <code>0</code>,如下面的代码片段所示:</p> <pre><code>rating: { type: Number, "default": 0, min: 0, max: 5 }, </code></pre> <p>当文档创建时应用此代码片段,将初始值设置为 <code>0</code>。关于这段代码的其他一些可能让你感到惊讶的事情:没有验证!</p> <h4 id="642-使用-mongoose-验证数据">6.4.2. 使用 Mongoose 验证数据</h4> <p>这个控制器内部没有验证代码,那么阻止某人输入大量空或部分文档的又是什么呢?再次强调,你开始在 Mongoose 架构中构建验证。在架构中,你将一些路径的 <code>required</code> 标志设置为 <code>true</code>。当这个标志被设置时,Mongoose 不会将数据发送到 MongoDB。</p> <p>给定以下位置的基础架构,例如,你可以看到只有 <code>name</code> 是必填字段:</p> <pre><code>const locationSchema = new mongoose.Schema({ name: { type: String, required: true }, address: String, rating: { type: Number, 'default': 0, min: 0, max: 5 }, facilities: [String], coords: { type: {type: String}, coordinates: [Number] }, openingTimes: [openingTimeSchema], reviews: [reviewSchema] }); </code></pre> <p>如果这个字段缺失,<code>create()</code> 方法会引发错误,并且不会尝试将文档保存到数据库中。</p> <p>在 Postman 中测试这个 API 路由看起来像图 6.10。注意方法设置为 <code>post</code>,并且选择的数据类型(在名称和值列表上方)是 <code>x-www-form-urlencoded</code>。你将在 Postman 界面中输入要随 <code>POST</code> 请求一起提交的键和值,如图所示。注意不要在 Postman 字段中输入的键前后留空白,因为空格会导致意外的输入。</p> <h5 id="图-610-在-postman-中测试post方法确保方法和表单数据设置正确">图 6.10. 在 Postman 中测试<code>POST</code>方法,确保方法和表单数据设置正确</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/06fig10_alt.jpg" alt="" loading="lazy"></p> <h4 id="643-在-mongodb-中创建新的子文档">6.4.3. 在 MongoDB 中创建新的子文档</h4> <p>在 Loc8r 位置上下文中,评论是子文档。子文档通过其父文档创建和保存。换句话说,要创建和保存一个新的子文档,您必须</p> <ol> <li> <p>找到正确的父文档。</p> </li> <li> <p>添加一个新的子文档。</p> </li> <li> <p>保存父文档。</p> </li> </ol> <p>找到正确的父文档不是问题,因为您已经完成了这个操作,并且可以用它作为下一个控制器<code>reviewsCreate()</code>的框架。当您找到父文档后,您可以调用一个外部函数来完成下一部分(您很快就会编写这个函数),如下所示。</p> <h5 id="列表-66-创建评论的控制器">列表 6.6. 创建评论的控制器</h5> <pre><code>const reviewsCreate = (req, res) => { const locationId = req.params.locationid; if (locationId) { Loc .findById(locationId) .select('reviews') .exec((err, location) => { if (err) { res .status(400) .json(err); } else { doAddReview(req, res, location); *1* } }); } else { res .status(404) .json({"message": "Location not found"}); } }; </code></pre> <ul> <li><em><strong>1</strong></em> <strong>成功的查找操作将调用一个新函数来添加评论,传递请求、响应和位置对象</strong></li> </ul> <p>这段代码并没有做特别新的事情;您之前都见过。通过调用一个新函数,您可以通过减少嵌套和缩进的数量来使代码更整洁,同时也更容易进行测试。</p> <h5 id="添加和保存子文档">添加和保存子文档</h5> <p>找到父文档并检索现有的子文档列表后,您需要添加一个新的子文档。子文档是对象的数组,向数组中添加新对象的最简单方法是创建数据对象并使用 JavaScript 的<code>push()</code>方法,如下面的代码片段所示:</p> <pre><code>location.reviews.push({ author: req.body.author, rating: req.body.rating, reviewText: req.body.reviewText }); </code></pre> <p>这段代码正在获取表单数据;因此,它使用<code>req.body</code>。</p> <p>当子文档被添加后,必须保存父文档,因为子文档不能单独保存。为了保存文档,Mongoose 有一个模型方法<code>save()</code>,它期望一个带有错误参数和返回对象参数的回调。以下代码片段展示了这个方法的作用:</p> <pre><code>location.save((err, location) => { if (err) { res .status(400) .json(err); } else { let thisReview = location.reviews[location.reviews.length - 1]; *1* res .status(201) .json(thisReview); } }); </code></pre> <ul> <li><em><strong>1</strong></em> <strong>在返回的数组中找到最后一个评论,因为 MongoDB 返回整个父文档,而不仅仅是新的子文档</strong></li> </ul> <p>通过<code>save</code>方法返回的文档是完整的父文档,而不是仅有的新子文档。为了在 API 响应中返回正确的数据——即子文档——您需要从数组中检索最后一个子文档 <em><strong>1</strong></em>。</p> <p>当添加文档和子文档时,您需要考虑到这个操作可能对其他数据产生的影响。例如,在 Loc8r 中,添加评论会增加一个新的评分,这个新的评分会影响文档的整体评分。在评论成功保存后,您将调用另一个函数来更新平均评分。</p> <p>将您拥有的所有内容组合到<code>doAddReview()</code>函数中,再加上一点错误处理,可以得到以下列表。</p> <h5 id="列表-67-添加和保存子文档">列表 6.7. 添加和保存子文档</h5> <pre><code>const doAddReview = (req, res, location) => { *1* if (!location) { res .status(404) .json({"message": "Location not found"}); } else { const {author, rating, reviewText} = req.body; location.reviews.push({ *2* author, rating, reviewText }); location.save((err, location) => { *3* if (err) { res .status(400) .json(err); } else { updateAverageRating(location._id); *4* const thisReview = location.reviews.slice(-1).pop(); *5* res *5* .status(201) .json(thisReview); } }); } }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>当提供一个父文档 . . .</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>. . . 将新数据推送到子文档数组 . . .</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>. . . 在保存之前。</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>在成功保存操作后,调用一个函数来更新平均评分</strong></p> </li> <li> <p><em><strong>5</strong></em> <strong>检索数组中添加的最后一个评论,并将其作为 JSON 确认响应返回</strong></p> </li> </ul> <h5 id="更新平均评分">更新平均评分</h5> <p>计算平均评分并不特别复杂,所以我们不会过多停留。步骤如下</p> <ol> <li> <p>根据提供的 ID 查找正确的文档。</p> </li> <li> <p>将所有评论子文档的评分加起来。</p> </li> <li> <p>计算平均评分值。</p> </li> <li> <p>更新父文档的评分值。</p> </li> <li> <p>保存文档。</p> </li> </ol> <p>将此步骤列表转换为代码,你将得到以下类似列表,应将其放置在 reviews.js 控制器文件中,与基于评论的控制器一起。</p> <h5 id="列表-68-计算和更新平均评分">列表 6.8. 计算和更新平均评分</h5> <pre><code>const doSetAverageRating = (location) => { *1* if (location.reviews && location.reviews.length > 0) { const count = location.reviews.length; const total = location.reviews.reduce((acc, {rating}) => { *2* return acc + rating; }, 0); location.rating = parseInt(total / count, 10); *3* location.save(err => { *4* if (err) { console.log(err); } else { console.log(`Average rating updated to ${location.rating}`); } }); } }; const updateAverageRating = (locationId) => { *5* Loc.findById(locationId) .select('rating reviews') .exec((err, location) => { if (!err) { doSetAverageRating(location); } }); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>使用提供的位置数据</strong></p> </li> <li> <p><em><strong>2</strong></em><strong>使用 JavaScript 数组 reduce 方法汇总子文档的评分</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>计算平均评分值并更新父文档的评分值</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>保存父文档</strong></p> </li> <li> <p><em><strong>5</strong></em> <strong>根据提供的 locationid 数据查找位置</strong></p> </li> </ul> <p>你可能已经注意到你没有发送任何 JSON 响应,因为你已经发送了。整个操作是异步的,不需要影响发送确认已保存评论的 API 响应。</p> <p>添加评论不是唯一需要更新平均评分的情况,这就是为什么让这些函数可以从其他控制器访问,而不是紧密耦合到创建评论的操作中,更有意义。</p> <p>你在这里所做的是使用 Mongoose 更新 MongoDB 数据的预览,因此现在你将进入 API 的 <code>PUT</code> 方法。</p> <h3 id="65-put-方法在-mongodb-中更新数据">6.5. PUT 方法:在 MongoDB 中更新数据</h3> <p><code>PUT</code> 方法全部关于在数据库中更新现有文档或子文档,并将保存的数据作为确认返回。在 Loc8r 的路由中,你有两个 <code>PUT</code> 请求执行不同的操作,如 表 6.8 所列。</p> <h5 id="表-68-loc8r-api-更新位置和评论的两种-put-请求">表 6.8. Loc8r API 更新位置和评论的两种 <code>PUT</code> 请求</h5> <table> <thead> <tr> <th>Action</th> <th>Method</th> <th>URL path</th> <th>Example</th> </tr> </thead> <tbody> <tr> <td>更新特定位置</td> <td>PUT</td> <td>/locations/:locationid</td> <td><a href="http://loc8r.com/api/locations/123" target="_blank"><code>loc8r.com/api/locations/123</code></a></td> </tr> <tr> <td>更新特定评论</td> <td>PUT</td> <td>/locations/:locationid/reviews/:reviewid</td> <td><a href="http://loc8r.com/api/locations/123/reviews/abc" target="_blank"><code>loc8r.com/api/locations/123/reviews/abc</code></a></td> </tr> </tbody> </table> <p><code>PUT</code> 方法与 <code>POST</code> 方法类似,因为它们通过接收发送给它们的表单数据来工作。但与使用数据在数据库中创建新文档不同,<code>PUT</code> 方法使用数据来更新现有文档。</p> <h4 id="651-使用-mongoose-更新-mongodb-中的文档">6.5.1. 使用 Mongoose 更新 MongoDB 中的文档</h4> <p>在 Loc8r 中,你可能想要更新位置以添加新设施、更改开放时间或修改其他数据。在文档中更新数据的方法可能已经开始看起来熟悉:</p> <ol> <li> <p>查找相关文档。</p> </li> <li> <p>对实例进行一些更改。</p> </li> <li> <p>保存文档。</p> </li> <li> <p>发送 JSON 响应。</p> </li> </ol> <p>这种方法是通过 Mongoose 模型实例直接映射到 MongoDB 中的文档的方式实现的。当你的查询找到文档时,你得到一个模型实例。如果你对这个实例进行更改然后保存它,Mongoose 将使用你的更改更新数据库中的原始文档。</p> <h4 id="652-使用-mongoose-的-save-方法">6.5.2. 使用 Mongoose 的 save 方法</h4> <p>当你更新平均评分值时,你看到了这个方法的作用。<code>save</code> 方法应用于 <code>find()</code> 函数返回的模型实例。它期望一个带有标准参数的错误对象和返回数据对象的回调。</p> <p>以下代码片段显示了这种方法的一个简化的骨架:</p> <pre><code> Loc .findById(req.params.locationid) *1* .exec((err, location) => { location.name = req.body.name; *2* location.save((err, loc) => { *3* if (err) { res .status(404) *4* .json(err); *4* } else { res .status(200) *4* .json(loc); *4* } }); } ); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>查找要更新的文档</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>对模型实例进行更改,更改一个路径的值</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>使用 Mongoose 的 save 方法保存文档</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>返回成功或失败响应</strong></p> </li> </ul> <p>在这里,你可以清楚地看到查找、更新、保存和响应的单独步骤。通过添加一些错误处理和你要保存的数据,将这个骨架扩展到 <code>locationsUpdateOne()</code> 控制器,可以得到以下列表。</p> <h5 id="列表-69-在-mongodb-中修改现有文档">列表 6.9. 在 MongoDB 中修改现有文档</h5> <pre><code>const locationsUpdateOne = (req, res) => { if (!req.params.locationid) { return res .status(404) .json({ "message": "Not found, locationid is required" }); } Loc .findById(req.params.locationid) *1* .select('-reviews -rating') .exec((err, location) => { if (!location) { return res .json(404) .status({ "message": "locationid not found" }); } else if (err) { return res .status(400) .json(err); } location.name = req.body.name; *2* location.address = req.body.address; *2* location.facilities = req.body.facilities.split(','); *2* location.coords = { *2* type: "Point", *2* [ *2* parseFloat(req.body.lng), *2* parseFloat(req.body.lat) *2* ] *2* }; *2* location.openingTimes = [{ *2* days: req.body.days1, *2* opening: req.body.opening1, *2* closing: req.body.closing1, *2* closed: req.body.closed1, *2* }, { *2* days: req.body.days2, *2* opening: req.body.opening2, *2* closing: req.body.closing2, *2* closed: req.body.closed2, *2* }]; location.save((err, loc) => { *3* if (err) { res *4* .status(404) .json(err); } else { res *4* .status(200) .json(loc); } }); } ); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>通过提供的 ID 查找位置文档</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>使用提交表单中的值更新路径</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>保存实例</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>根据保存操作的结果发送适当的响应</strong></p> </li> </ul> <p>现在,由于代码已经完全展开,这里显然有更多的代码,但你仍然可以轻松地识别更新过程的关键步骤。</p> <p>眼尖的你们中的一些人可能已经注意到了 <code>select</code> 语句中的奇怪之处:</p> <pre><code>.select('-reviews -rating') </code></pre> <p>之前,你使用 <code>select()</code> 方法来说明你<em>想要</em>选择哪些列。通过在路径名前添加一个连字符,你声明你<em>不想要</em>从数据库中检索它。因此,这个 <code>select()</code> 语句表示要检索除 <code>reviews</code> 和 <code>rating</code> 之外的所有内容。</p> <h4 id="653-在-mongodb-中更新现有子文档">6.5.3. 在 MongoDB 中更新现有子文档</h4> <p>更新子文档与更新文档的过程完全相同,只有一个例外:在找到文档后,你必须找到正确的子文档以进行更改。然后应用 <code>save</code> 方法到文档上,而不是子文档上。因此,更新现有子文档的步骤如下</p> <ol> <li> <p>查找相关文档。</p> </li> <li> <p>找到相关的子文档。</p> </li> <li> <p>在子文档中进行一些更改。</p> </li> <li> <p>保存文档。</p> </li> <li> <p>发送 JSON 响应。</p> </li> </ol> <p>对于 Loc8r,你正在更新的子文档是评论,因此当评论被更改时,你必须记得重新计算平均评分。这将是你需要添加的唯一额外内容。以下列表显示了在 <code>reviewsUpdateOne()</code> 控制器中放置的所有内容。</p> <h5 id="列表-610-在-mongodb-中更新子文档">列表 6.10. 在 MongoDB 中更新子文档</h5> <pre><code>const reviewsUpdateOne = (req, res) => { if (!req.params.locationid || !req.params.reviewid) { return res .status(404) .json({ "message": "Not found, locationid and reviewid are both required" }); } Loc .findById(req.params.locationid) *1* .select('reviews') .exec((err, location) => { if (!location) { return res .status(404) .json({ "message": "Location not found" }); } else if (err) { return res .status(400) .json(err); } if (location.reviews && location.reviews.length > 0) { const thisReview = location.reviews.id(req.params.reviewid); *2* if (!thisReview) { res .status(404) .json({ "message": "Review not found" }); } else { thisReview.author = req.body.author; *3* thisReview.rating = req.body.rating; *3* thisReview.reviewText = req.body.reviewText; *3* location.save((err, location) => { *4* if (err) { res *5* .status(404) .json(err); } else { updateAverageRating(location._id); res *5* .status(200) .json(thisReview); } }); } } else { res .status(404) .json({ "message": "No review to update" }); } } ); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>查找父文档</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>查找子文档</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>根据提供的表单数据对子文档进行更改</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>保存父文档</strong></p> </li> <li> <p><em><strong>5</strong></em> <strong>返回 JSON 响应,根据成功保存发送子文档对象</strong></p> </li> </ul> <p>在这个列表中可以清楚地看到更新的五个步骤:查找文档;查找子文档;进行更改;保存;并响应。再次强调,这里的大部分代码都是错误处理,但对于创建一个稳定、响应式的 API 至关重要。你不想保存错误的数据,发送错误的响应,或者删除你不想删除的数据。说到删除数据,你现在可以继续到最后一个你使用的四个 API 方法:<code>DELETE</code>。</p> <h3 id="66-delete-方法从-mongodb-中删除数据">6.6. DELETE 方法:从 MongoDB 中删除数据</h3> <p><code>DELETE</code>方法不出所料,完全是关于在数据库中删除现有文档或子文档。在 Loc8r 的路由中,你有一个用于删除位置的<code>DELETE</code>请求,还有一个用于删除评论的请求。详细信息列在表 6.9 中。首先,让我们看看如何删除文档。</p> <h5 id="表-69-loc8r-api-删除位置和评论的两个delete请求">表 6.9. Loc8r API 删除位置和评论的两个<code>DELETE</code>请求</h5> <table> <thead> <tr> <th>操作</th> <th>方法</th> <th>URL 路径</th> <th>示例</th> </tr> </thead> <tbody> <tr> <td>删除特定位置</td> <td>DELETE</td> <td>/locations/:locationid</td> <td><a href="http://loc8r.com/api/locations/123" target="_blank"><code>loc8r.com/api/locations/123</code></a></td> </tr> <tr> <td>删除特定评论</td> <td>DELETE</td> <td>/locations/:locationid/reviews/:reviewid</td> <td><a href="http://loc8r.com/api/locations/123/reviews/abc" target="_blank"><code>loc8r.com/api/locations/123/reviews/abc</code></a></td> </tr> </tbody> </table> <h4 id="661-在-mongodb-中删除文档">6.6.1. 在 MongoDB 中删除文档</h4> <p>Mongoose 通过提供<code>findByIdAndRemove()</code>方法使在 MongoDB 中删除文档变得极其简单。此方法期望一个参数:要删除的文档的 ID。</p> <p>API 在出错时应响应 404,在成功时应响应 204。以下列表显示了<code>locationsDeleteOne()</code>控制器中的所有内容。</p> <h5 id="列表-611-根据-id-从-mongodb-中删除文档">列表 6.11. 根据 ID 从 MongoDB 中删除文档</h5> <pre><code>const locationsDeleteOne = (req, res) => { const {locationid} = req.params; if (locationid) { Loc .findByIdAndRemove(locationid) *1* .exec((err, location) => { *2* if (err) { return res *3* .status(404) .json(err); } res *3* .status(204) .json(null); } ); } else { res .status(404) .json({ "message": "No Location" }); } }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>调用 findByIdAndRemove 方法,传入 locationid</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>执行方法</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>响应失败或成功</strong></p> </li> </ul> <p>这是一种快速简单的删除文档的方法,但你也可以将其分解为两步过程,或者如果你更喜欢,先找到它然后删除。这给了你在删除之前对文档进行操作的机会(如果你需要的话)。以下代码片段展示了这一点:</p> <pre><code>Loc .findById(locationid) .exec((err, location) => { // Do something with the document location.remove((err, loc) => { // Confirm success or failure }); } ); </code></pre> <p>这个片段有一个额外的嵌套层,但如果你需要的话,它带来了额外的灵活性。</p> <h4 id="662-从-mongodb-中删除子文档">6.6.2. 从 MongoDB 中删除子文档</h4> <p>删除子文档的过程与其他你使用子文档所做的操作没有区别;所有操作都是通过父文档来管理的。删除子文档的步骤如下</p> <ol> <li> <p>查找父文档。</p> </li> <li> <p>查找相关的子文档。</p> </li> <li> <p>删除子文档。</p> </li> <li> <p>保存父文档。</p> </li> <li> <p>确认操作的成功或失败。</p> </li> </ol> <p>删除子文档本身很简单,因为 Mongoose 给了你另一个辅助方法。你已经看到你可以使用 <code>id</code> 方法通过 ID 查找子文档,如下所示:</p> <pre><code>location.reviews.id(reviewid) </code></pre> <p>Mongoose 允许你像这样将 <code>remove</code> 方法链接到这个语句的末尾:</p> <pre><code>location.reviews.id(reviewid).remove() </code></pre> <p>这条指令会从数组中删除子文档。请记住保存父文档以将更改持久化回数据库。将所有步骤(包括大量错误处理)组合到 <code>reviewsDeleteOne()</code> 控制器中,如下所示。</p> <h5 id="列表-612-在-mongodb-中查找和删除子文档">列表 6.12. 在 MongoDB 中查找和删除子文档</h5> <pre><code>const reviewsDeleteOne = (req, res) => { const {locationid, reviewid} = req.params; if (!locationid || !reviewid) { return res .status(404) .json({'message': 'Not found, locationid and reviewid are both required'}); } Loc .findById(locationid) *1* .select('reviews') .exec((err, location) => { if (!location) { return res .status(404) .json({'message': 'Location not found'}); } else if (err) { return res .status(400) .json(err); } if (location.reviews && location.reviews.length > 0) { if (!location.reviews.id(reviewid)) { return res .status(404) .json({'message': 'Review not found'}); } else { location.reviews.id(reviewid).remove(); *2* location.save(err => { *3* if (err) { return res *4* .status(404) .json(err); } else { updateAverageRating(location._id); res *4* .status(204) .json(null); } }); } } else { res .status(404) .json({'message': 'No Review to delete'}); } }); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>查找相关的父文档</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>一步查找并删除相关的子文档</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>保存父文档</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>返回适当的成功或失败响应</strong></p> </li> </ul> <p>再次强调,这里的代码大部分是错误处理。API 可能会返回七个可能的响应,只有一个才是成功的。删除子文档很简单;务必确保你删除的是正确的文档。</p> <p>当你删除一个带有相关评分的评论时,你还得记得调用 <code>updateAverageRating()</code> 函数来重新计算位置的平均评分。这个函数只有在删除操作成功时才应该被调用。</p> <p>就这样。你已经构建了一个 Express 和 Node 的 REST API,它可以接受 <code>GET</code>、<code>POST</code>、<code>PUT</code> 和 <code>DELETE</code> HTTP 请求来在 MongoDB 数据库上执行 CRUD 操作。</p> <p>在 第七章 中将要介绍,你将看到如何从 Express 应用程序内部使用此 API,最终使 Loc8r 网站数据库驱动化!</p> <h3 id="摘要-3">摘要</h3> <p>在本章中,你学习了</p> <ul> <li> <p>创建 REST API 的最佳实践,包括 URL、请求方法和响应代码</p> </li> <li> <p><code>POST</code>、<code>GET</code>、<code>PUT</code> 和 <code>DELETE</code> HTTP 请求方法如何映射到常见的 CRUD 操作</p> </li> <li> <p>Mongoose 辅助方法用于创建辅助方法</p> </li> <li> <p>通过 Mongoose 模型与数据交互的方式,以及模型的一个实例如何直接映射到数据库中的一个文档</p> </li> <li> <p>如何通过父文档管理子文档</p> </li> <li> <p>一些通过检查你所能想到的任何可能的错误来使 API 坚固的方法,以确保请求永远不会得到无响应</p> </li> </ul> <h2 id="第七章-消费-rest-api在-express-中使用-api">第七章. 消费 REST API:在 Express 中使用 API</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>从 Express 应用程序中调用 API</p> </li> <li> <p>处理和使用 API 返回的数据</p> </li> <li> <p>与 API 响应代码一起工作</p> </li> <li> <p>从浏览器将数据提交回 API</p> </li> <li> <p>验证和捕获错误</p> </li> </ul> <p>这章内容非常精彩!这是你第一次将前端与后端连接起来。你将移除控制器中的硬编码数据,并最终在浏览器中显示数据库中的数据。你还将通过 API 将数据从浏览器推送到数据库,创建新的子文档。</p> <p>本章的技术重点是 Node 和 Express。图 7.1 展示了本章在整个架构和宏伟计划中的位置。</p> <h5 id="图-71-本章的重点是将-express-应用程序从第四章更新为与在第六章中开发的-rest-api-交互">图 7.1. 本章的重点是将 Express 应用程序从第四章更新为与在第六章中开发的 REST API 交互。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig01_alt.jpg" alt="" loading="lazy"></p> <p>在本章中,我们将讨论如何在 Express 中调用 API 以及如何处理响应。您将对 API 进行调用以从数据库中读取和写入数据。在这个过程中,我们将探讨错误处理、数据处理以及通过分离关注点来创建可重用代码。在结束时,我们将涵盖架构的各个层次,您可以在这些层次上添加验证,以及为什么这些不同的层次是有用的。</p> <p>首先,看看如何从 Express 应用程序调用 API。</p> <h3 id="71-如何从-express-调用-api">7.1. 如何从 Express 调用 API</h3> <p>我们需要首先解决的问题是如何从 Express 调用 API。这种方法不仅限于您的 API;您可以使用它来调用任何 API。</p> <p>您的 Express 应用程序需要能够调用您在第六章中设置的 API URL,当然,发送正确的请求方法,并且能够解释响应。为此,您将使用一个名为 <code>request</code> 的模块。</p> <h4 id="711-将请求模块添加到您的项目中">7.1.1. 将请求模块添加到您的项目中</h4> <p><code>request</code> 模块就像您迄今为止使用的其他任何包一样,可以通过 npm 添加到您的项目中。要安装最新版本并将其添加到 package.json 文件中,请转到终端,并输入以下命令:</p> <pre><code>$ npm install --save request </code></pre> <p>当 npm 完成其操作后,您可以将 <code>request</code> 包含在将使用它的文件中。在 Loc8r 中,您只有一个文件需要进行 API 调用:包含主要服务器端应用程序控制器的文件。因此,在 app_server/controllers 中的 locations.js 文件顶部添加以下行以引入 <code>request</code> 模块:</p> <pre><code>const request = require('request'); </code></pre> <p>现在您已经准备就绪了!</p> <h4 id="712-设置默认选项">7.1.2. 设置默认选项</h4> <p>每次使用 <code>request</code> 调用的 API 都必须有一个完全限定的 URL,这意味着它必须包含完整的地址,而不是一个相对链接。但这个 URL 在开发和生产环境中是不同的。</p> <p>为了避免在每个进行 API 调用的控制器中都要进行此检查,您可以在控制器文件顶部设置一个默认配置选项。为了根据环境使用正确的 URL,您可以使用您熟悉的环境变量 <code>NODE_ENV</code>。</p> <p>在实践中,app_server/controllers/locations.js 文件的开头应该看起来像以下列表。</p> <h5 id="列表-71-将请求和默认-api-选项添加到-locationsjs-控制器文件">列表 7.1. 将请求和默认 API 选项添加到 locations.js 控制器文件</h5> <pre><code>const request = require('request'); const apiOptions = { *1* server: 'http://localhost:3000' *1* }; *1* if (process.env.NODE_ENV === 'production') { *2* apiOptions.server = 'https://pure-temple-67771.herokuapp.com'; *2* } *2* </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>设置本地开发的默认服务器 URL</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>如果应用程序以生产模式运行,则设置不同的基本 URL;更改应用程序的实时地址</strong></p> </li> </ul> <p>在此代码到位后,您对 API 的每次调用都可以引用<code>apiOptions.server</code>并使用正确的基 URL。</p> <h4 id="713-使用请求模块">7.1.3. 使用请求模块</h4> <p>发送请求的基本结构很简单,是一个接受选项和回调参数的单个命令,如下所示:</p> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/p0203-01.jpg" alt="" loading="lazy"></p> <p>选项指定了请求的所有内容,包括 URL、请求方法、请求正文和查询字符串参数。这些选项确实是您在本章中将使用的选项,它们在表 7.1 中有详细说明。</p> <h5 id="表-71-定义-api-调用时的四个常见请求选项">表 7.1. 定义 API 调用时的四个常见请求选项</h5> <table> <thead> <tr> <th>选项</th> <th>描述</th> <th>必需</th> </tr> </thead> <tbody> <tr> <td>url</td> <td>要进行的请求的完整 URL,包括协议、域名、路径和 URL 参数</td> <td>是</td> </tr> <tr> <td>method</td> <td>请求方法,如 GET、POST、PUT 或 DELETE</td> <td>否——如果未指定,则默认为 GET</td> </tr> <tr> <td>json</td> <td>请求体作为 JavaScript 对象;如果不需要正文数据,则发送空对象</td> <td>是——确保响应体也被解析为 JSON</td> </tr> <tr> <td>qs</td> <td>表示任何查询字符串参数的 JavaScript 对象</td> <td>否</td> </tr> </tbody> </table> <p>以下代码片段显示了您可能如何将这些选项组合起来进行<code>GET</code>请求。<code>GET</code>请求不应发送正文,但可能包含查询字符串参数:</p> <pre><code>const requestOptions = { url: 'http://yourapi.com/api/path', *1* method: 'GET', *2* json: {}, *3* qs: { *4* offset: 20 *4* } *4* }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>定义要进行的 API 调用的 URL</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>设置请求方法</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>定义请求的正文,即使它是一个空的 JSON 对象</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>可选地添加 API 可能使用的任何查询字符串参数</strong></p> </li> </ul> <p>您可以指定更多选项,但这四个是最常见的,也是您在本章中将使用的选项。有关其他可能选项的更多信息,请参阅 GitHub 仓库中的参考:<a href="https://github.com/mikeal/request" target="_blank"><code>github.com/mikeal/request</code></a>。</p> <p>当 API 返回响应时,回调函数会运行,并具有三个参数:一个错误对象、完整的响应和解析后的响应正文。除非捕获到错误,否则错误对象是<code>null</code>。在您的代码中,最有用的三份数据将是响应的状态码、响应体以及抛出的任何错误。以下代码片段显示了您可能如何为<code>request()</code>函数结构化一个回调:</p> <pre><code>(err, response, body) => { if (err) { *1* console.log(err); *1* } else if (response.statusCode === 200) { *2* console.log(body); *2* } else { *3* console.log(response.statusCode); *3* } } </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>如果已通过错误,则对其进行处理</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>如果响应状态码为 200(请求成功),则输出响应的 JSON 正文</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>如果请求返回不同的状态码,则输出该代码</strong></p> </li> </ul> <p>完整的响应对象包含大量信息,所以我们在这里不会深入探讨。您可以通过在开始将 API 调用添加到应用程序时使用<code>console.log</code>语句来自己检查它。</p> <p>将这些部分组合起来,进行 API 调用的基本结构如下所示:</p> <pre><code>const requestOptions = { *1* url: 'http://yourapi.com/api/path', *1* method: 'GET', *1* json: {}, *1* qs: { *1* offset: 20 *1* } *1* }; *1* request(requestOptions, (err, response, body) => { *2* if (err) { *2* console.log(err); *2* } else if (response.statusCode === 200) { *2* console.log(body); *2* } else { *2* console.log(response.statusCode); *2* } *2* }); </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>定义请求选项</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>发送请求,通过选项发送并通过提供回调函数按需使用响应</strong></p> </li> </ul> <p>在下一节中,您将把这种理论付诸实践,并开始构建 Loc8r 控制器以使用您已经构建的 API。</p> <h3 id="72-使用来自-api-的数据列表loc8r-主页">7.2. 使用来自 API 的数据列表:Loc8r 主页</h3> <p>到目前为止,将要执行工作的控制器文件应该已经包含了<code>request</code>模块的导入和一些默认值的设置。现在来点有趣的:更新控制器以调用 API 并从数据库中提取页面数据。</p> <p>您有两个主要页面需要提取数据:主页,显示位置列表,以及详细信息页面,提供特定位置更多信息。从开始处获取主页的数据。</p> <p>当前主页控制器包含一个<code>res.render()</code>函数调用,将硬编码的数据发送到视图。但您希望的方式是在 API 返回一些数据后渲染主页。主页控制器无论如何都会有相当多的事情要做,所以将这个渲染移动到它自己的函数中。</p> <h4 id="721-分离关注点将渲染移动到命名函数中">7.2.1. 分离关注点:将渲染移动到命名函数中</h4> <p>将渲染移动到自己的命名函数中有几个原因。首先,您将渲染与应用逻辑解耦。渲染过程不关心数据是从哪里或如何获得的;如果提供了正确格式的数据,它就会使用这些数据。使用单独的函数有助于您更接近可测试的理想,即每个函数应该做一件事情。一个相关的额外好处是,该函数变得可重用,因此您可以从多个地方调用它。</p> <p>创建用于主页渲染的新函数的第二个原因是渲染过程发生在 API 请求的回调中。除了使代码难以测试外,它还使代码难以阅读。所需的嵌套级别使得控制器函数相当大,缩进很多。作为一个最佳实践,您应该尽量避免深度缩进代码:当您再次回到它时,它很难阅读和理解。</p> <p>第一步是在<code>app_server/controllers</code>文件夹中的 locations.js 文件中创建一个新的函数<code>renderHomepage()</code>,并将<code>homelist</code>控制器的内容移动到其中。请记住确保新函数也接受<code>req</code>和<code>res</code>参数。以下列表显示了您在这里所做的工作的简化版本。您可以从<code>homelist</code>控制器中调用此代码,如列表所示,一切将像以前一样工作。</p> <h5 id="列表-72-将homelist控制器的内容移动到外部函数中">列表 7.2. 将<code>homelist</code>控制器的内容移动到外部函数中</h5> <pre><code>const renderHomepage = (req, res) => { res.render('locations-list', { title: 'Loc8r - find a place to work with wifi', *1* ... *1* }); *1* }; const homelist = (req, res) => { *2* renderHomepage(req, res); *2* }; *2* </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>在此处包含从 res.render 调用中所有的代码(为了简洁而省略)</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>从 homelist 控制器调用新的 renderHomepage 函数</strong></p> </li> </ul> <p>这一步是一个开始,但您还没有完成;您需要数据!</p> <h4 id="722-构建-api-请求">7.2.2. 构建 API 请求</h4> <p>你可以通过请求 API 来获取你想要的数据,为此,你需要构建请求。要构建请求,你需要知道要发送的 URL、方法、JSON body 和查询字符串。回顾第六章,或者查看 API 代码本身,你可以看到你需要提供表 7.2 中所示的信息。</p> <h5 id="表-72-向-api-请求位置列表所需的信息">表 7.2. 向 API 请求位置列表所需的信息</h5> <table> <thead> <tr> <th>参数</th> <th>值</th> </tr> </thead> <tbody> <tr> <td>URL</td> <td>SERVER:PORT/api/locations</td> </tr> <tr> <td>方法</td> <td>GET</td> </tr> <tr> <td>JSON body</td> <td>null</td> </tr> <tr> <td>查询字符串</td> <td>lng, lat, maxDistance</td> </tr> </tbody> </table> <p>将此信息映射到请求是直接的。正如你在本章前面所看到的,请求的选项是 JavaScript 对象。目前,你将硬编码经纬度值到选项中,这是一种更快、更简单的方法来测试。在本书的后面部分,你将使应用程序具有位置感知能力。现在,你将选择接近测试数据存储位置的坐标。最大距离设置为 20 公里。</p> <p>当你发起请求时,你将通过一个简单的回调函数来调用<code>renderHomepage()</code>函数,这样就不会让浏览器挂起。将这个想法用代码表达如下所示。</p> <h5 id="列表-73-更新homelist控制器以在渲染页面之前调用-api">列表 7.3. 更新<code>homelist</code>控制器以在渲染页面之前调用 API</h5> <pre><code>const homelist = (req, res) => { const path = '/api/locations'; *1* const requestOptions = { *2* url: `${apiOptions.server}${path}`, *2* method: 'GET', *2* json: {}, *2* qs: { *2* lng: -0.7992599, *2* lat: 51.378091, *2* maxDistance: 20 *2* } *2* }; *2* request( *3* requestOptions, *3* (err, response, body) => { *4* renderHomepage(req, res); *4* } *4* ); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>设置 API 请求的路径。(服务器已在文件顶部设置。)</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>设置请求选项,包括 URL、方法、空的 JSON body 和硬编码的查询字符串参数</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>发起请求,通过请求选项发送</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>提供回调以渲染主页</strong></p> </li> </ul> <p>如果你保存此代码并再次运行应用程序,主页应该会显示得和之前完全一样。你现在可能正在向 API 发起请求,但你正在忽略响应。</p> <h4 id="723-使用-api-响应数据">7.2.3. 使用 API 响应数据</h4> <p>既然你费心去调用 API,至少你应该使用它返回的数据。你可以在以后更稳健地处理响应,但你现在将从使处理器工作开始。为了实现这一点,你将假设响应体被返回到回调中,你可以直接将其传递到<code>renderHomepage()</code>函数中,如下所示。</p> <h5 id="列表-74-更新homelist控制器的内容以使用-api-响应">列表 7.4. 更新<code>homelist</code>控制器的内容以使用 API 响应</h5> <pre><code> request( requestOptions, (err, response, body) => { renderHomepage(req, res, body); *1* } ); </code></pre> <ul> <li><em><strong>1</strong></em> <strong>将请求返回的 body 传递给 renderHomepage()函数</strong></li> </ul> <p>你编写了 API,所以你知道 API 返回的响应体应该是一个位置数组。<code>renderHomepage()</code>函数需要一个位置数组来发送到视图,所以尝试直接传递,对以下列表中加粗的部分进行更改。</p> <h5 id="列表-75-更新renderhomepage函数以使用-api-数据">列表 7.5. 更新<code>renderHomepage</code>函数以使用 API 数据</h5> <pre><code>const renderHomepage = (req, res, responseBody) => { *1* res.render('locations-list', { title: 'Loc8r - find a place to work with wifi', pageHeader: { title: 'Loc8r', strapline: 'Find places to work with wifi near you!' }, sidebar: "Looking for wifi and a seat? Loc8r helps you find places to work when out and about. Perhaps with coffee, cake or a pint? Let Loc8r help you find the place you're looking for.", locations: responseBody *2* }); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>在函数声明中添加一个额外的 responseBody 参数</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>移除硬编码的位置数组,并通过 responseBody 传递</strong></p> </li> </ul> <p>这个过程能这么简单吗?在浏览器中尝试一下,看看会发生什么。我们希望您会得到类似图 7.2 的结果。</p> <h5 id="图-72-使用浏览器中的数据库数据得到的初始结果接近期望的结果">图 7.2. 使用浏览器中的数据库数据得到的初始结果:接近期望的结果</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig02_alt.jpg" alt="" loading="lazy"></p> <p>看起来相当不错,对吧?您需要处理一下距离的显示方式,但除此之外,数据正如您所期望的那样传输过来。由于您在前期设计视图、基于视图构建控制器以及基于控制器开发模型的工作,插入数据变得快速且简单。</p> <p>您已经让它工作了。现在您需要让它变得更好。目前还没有错误处理,距离需要一些改进。</p> <h4 id="724-在显示之前修改数据修正距离">7.2.4. 在显示之前修改数据:修正距离</h4> <p>目前,列表中的距离显示有 15 位小数,没有单位,因此它们非常精确,但完全无用!您想要说明每个距离是以米还是千米为单位,并将数字四舍五入到单个米或千米的一位小数。您应该在将数据发送到 <code>renderHomepage()</code> 函数之前这样做,因为这个函数应该保留用于处理实际渲染,而不是整理数据。</p> <p>您需要遍历返回的位置数组,格式化每个位置的距离值。而不是直接这样做,您将创建一个外部函数(在同一文件中),称为 <code>formatDistance()</code>,它接受一个距离值并返回格式良好的值。在控制器文件中,在 <code>renderHomepage()</code> 之前放置以下函数。</p> <h5 id="列表-76-添加-formatdistance-函数">列表 7.6. 添加 formatDistance 函数</h5> <pre><code>const formatDistance = (distance) => { let thisDistance = 0; let unit = 'm'; if (distance > 1000) { *1* thisDistance = parseFloat(distance / 1000).toFixed(1); *1* unit = 'km'; *1* } else { *2* thisDistance = Math.floor(distance); *2* } return thisDistance + unit; }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>如果提供的距离超过 1000 米,则转换为千米,四舍五入到一位小数,并添加千米单位</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>否则,向下取整到最近的米</strong></p> </li> </ul> <p>现在按照以下列表中加粗所示进行更改。请注意,此代码片段中省略了 <code>homelist</code> 控制器的框架。为了使内容简短,<code>request</code> 语句仍然位于控制器内部。</p> <h5 id="列表-77-添加并使用一个函数来格式化-api-返回的距离">列表 7.7. 添加并使用一个函数来格式化 API 返回的距离</h5> <pre><code>request( requestOptions, (err, response, body) => { let data = []; *1* data = body.map( (item) => { *2* item.distance = formatDistance(item.distance); return item; }); renderHomepage(req, res, data); *3* } ); </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>创建一个变量供将来使用</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>将数据映射到数组中,格式化位置的距离值</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>发送修改后的数据以进行渲染,而不是原始的正文内容</strong></p> </li> </ul> <p>您需要做一个小小的额外修改。您在 API 输出的距离中添加了 <code>m</code>,但使用 <code>formatDistance()</code> 函数后,这个添加就不再需要了,所以请在 <code>/app_api/controllers/locations.js</code> 中进行以下更改。</p> <h5 id="列表-78-从-api-响应中移除单位">列表 7.8. 从 API 响应中移除单位</h5> <pre><code>const locations = results.map(result => { return { name: result.name, address: result.address, rating: result.rating, facilities: result.facilities, distance: `${result.distance.calculated.toFixed()}` *1* } }); </code></pre> <ul> <li><em><strong>1</strong></em> <strong>从这一行中移除 m</strong></li> </ul> <p>如果您进行这些更改并刷新页面,应该会看到距离被整理了一下,变得更有用,如图图 7.3 所示。</p> <h5 id="图-73-在您格式化-api-返回的距离后主页看起来更好">图 7.3. 在您格式化 API 返回的距离后,主页看起来更好。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig03_alt.jpg" alt="" loading="lazy"></p> <p>这样更好;主页看起来更符合您的期望。为了加分,您可以在<code>formatDistance()</code>函数中添加一些错误捕获,以确保已经传递了一个<code>distance</code>参数,并且它是一个数字。</p> <h4 id="725-捕获-api-返回的错误">7.2.5. 捕获 API 返回的错误</h4> <p>到目前为止,您一直假设 API 将始终返回一个包含 200 成功状态码的数据数组。但这并不一定。您编写了 API,即使没有找到附近的位置,也会返回 200 状态码。目前的情况是,当这种情况发生时,主页将显示一个中央区域没有任何内容。更好的用户体验将是向用户输出一条消息,说明附近没有地方。</p> <p>您也知道,您的 API 可能会返回 404 错误,因此您需要确保您适当地处理这些错误。在这种情况下,您不希望向用户显示 404 错误,因为错误不会是由于主页缺失造成的。更好的选择是,再次发送一条消息到浏览器,在主页的上下文中。</p> <p>处理这些场景不应该太难。以下章节将向您展示如何操作,从控制器开始。</p> <h5 id="使请求回调更健壮">使请求回调更健壮</h5> <p>捕获错误的主要原因是为了确保它们不会导致代码失败。第一个弱点在于<code>request</code>回调,在那里您在将数据发送到渲染之前正在操作响应。如果数据始终一致,这是可以的,但您没有这样的奢侈。</p> <p><code>request</code>回调目前运行一个<code>for</code>循环来格式化距离,无论 API 返回什么数据。您应该只在 API 返回 200 状态码和一些结果时运行这个循环。</p> <p>以下列表展示了如何通过添加一个简单的<code>if</code>语句(app_server/controllers/locations.js)来检查状态码和返回数据的长度,从而实现这一结果。</p> <h5 id="列表-79-在尝试使用-api-返回的数据之前验证-api-是否已返回数据">列表 7.9. 在尝试使用 API 返回的数据之前验证 API 是否已返回数据</h5> <pre><code>request( requestOptions, (err, {statusCode}, body) => *1* let data = []; if (statusCode === 200 && body.length) { *2* data = body.map( (item) => { item.distance = formatDistance(item.distance): return item; }); } renderHomepage(req, res, data); } ); </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>使用对象解构来获取 statusCode,因为您只需要这个</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>仅在 API 返回 200 状态码和一些数据时运行循环来格式化距离</strong></p> </li> </ul> <p>更新这段代码应该可以防止在 API 响应状态码不是 200 时,这个回调崩溃并抛出错误。链中的链接是<code>renderHomepage()</code>函数。</p> <h5 id="根据响应数据定义输出消息">根据响应数据定义输出消息</h5> <p>与<code>request</code>回调一样,您最初对<code>renderHomepage()</code>函数的关注是使其在传递要显示的位置数组时工作。现在,由于这个函数可能会收到不同类型的数据,您需要使其能够适当地处理这些可能性。</p> <p>响应体可能是以下三种情况之一:</p> <ul> <li> <p>位置数组</p> </li> <li> <p>当没有找到位置时,数组为空</p> </li> <li> <p>当 API 返回错误时,包含消息的字符串</p> </li> </ul> <p>你已经有了处理地点数组的代码,所以你需要处理其他两种可能性。在捕获这些错误时,你还需要设置一个可以发送到视图的消息。</p> <p>为了做到这一点,你需要更新<code>renderHomepage()</code>函数,同时还要执行以下操作:</p> <ul> <li> <p>设置一个用于消息的变量容器</p> </li> <li> <p>检查响应体是否为数组;如果不是,设置一个适当的消息</p> </li> <li> <p>如果响应是数组,如果它是空的(即没有返回地点),则设置不同的消息</p> </li> <li> <p>将消息发送到视图</p> </li> </ul> <p>以下列表显示了代码中的样子。</p> <h5 id="列表-710-如果-api-不返回位置数据则输出消息">列表 7.10. 如果 API 不返回位置数据则输出消息</h5> <pre><code>const renderHomepage = function(req, res, responseBody){ let message = null; *1* if (!(responseBody instanceof Array)) { *2* message = "API lookup error"; *2* responseBody = []; *2* } else { *3* if (!responseBody.length) { *3* message = "No places found nearby"; *3* } *3* } res.render('locations-list', { title: 'Loc8r - find a place to work with wifi', pageHeader: { title: 'Loc8r', strapline: 'Find places to work with wifi near you!' }, sidebar: "Looking for wifi and a seat? Loc8r helps you find places to work when out and about. Perhaps with coffee, cake or a pint? Let Loc8r help you find the place you're looking for.", locations: responseBody, message *4* }); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>定义一个变量来保存消息</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>如果响应不是数组,设置一个消息并将 responseBody 设置为空数组</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>如果响应是长度为零的数组,设置一个消息</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>向要发送到视图的变量中添加消息</strong></p> </li> </ul> <p>唯一的惊喜是当你将<code>responseBody</code>设置为空数组时,如果它最初是通过字符串传递的。你这样做是为了防止视图抛出错误。视图期望在<code>locations</code>变量中发送一个数组;如果发送一个空数组,它会忽略它,但如果发送一个字符串,则会抛出错误。</p> <p>这条链的最后一个链接是更新视图以在发送消息时显示消息。</p> <h5 id="更新视图以显示错误消息">更新视图以显示错误消息</h5> <p>你正在捕获 API 的错误,并且你也在处理它们以将某些内容返回给用户。最后一步是通过在视图模板中添加占位符让用户看到消息。</p> <p>你在这里不需要做任何复杂的事情;一个简单的带有<code>error</code>类的<code>div</code>来包含任何消息就足够了。以下列表显示了应用服务器视图<code>locations-list.pug</code>的主页视图的<code>block content</code>部分。</p> <h5 id="列表-711-更新视图以在需要时显示错误消息">列表 7.11. 更新视图以在需要时显示错误消息</h5> <pre><code>block content .row.banner .col-12 h1= pageHeader.title small #{pageHeader.strapline} .row .col-12.col-md-8 .error= message *1* each location in locations .card .card-block h4 a(href="/location")= location.name small +outputRating(location.rating) span.badge.badge-pill.badge-default.float-right= location.distance p.address= location.address .facilities each facility in location.facilities span.badge.badge-warning= facility .col-12.col-md-4 p.lead= sidebar </code></pre> <ul> <li><em><strong>1</strong></em> <strong>在主要内容区域添加一个 div,并在发送消息时显示它</strong></li> </ul> <p>这很简单——基本但简单。现在应该足够了。剩下要做的就是测试它。</p> <h5 id="测试-api-错误捕获">测试 API 错误捕获</h5> <p>任何新的代码,你都需要确保它能正常工作。测试这段代码的一个简单方法是更改<code>requestOptions</code>中发送的查询字符串值。</p> <p>为了测试“附近没有找到地点”的陷阱,你可以将<code>maxDistance</code>设置为一个小数值(记住它是以公里为单位的)或者将<code>lng</code>和<code>lat</code>设置为没有地点的点,如下例所示:</p> <pre><code>requestOptions = { url: `${apiOptions.server}${path}`, method: 'GET', json: {}, qs: { lng: 1, *1* lat: 1, *1* maxDistance : 0.002 *1* } }; </code></pre> <ul> <li><em><strong>1</strong></em> <strong>更改请求中发送的查询字符串值以获取无结果返回</strong></li> </ul> <p><strong>修复一个有趣的错误</strong></p> <p>你尝试通过将<code>lng</code>或<code>lat</code>设置为<code>0</code>来测试 API 错误捕获了吗?你应该期望看到“附近没有找到地点”的消息,但相反你看到了“API 查找错误”,这是由于你的 API 代码中的错误捕获错误导致的。</p> <p>在<code>locationsListByDistance</code>控制器中,使用通用的“falsey”JavaScript 测试来检查是否省略了<code>lng</code>和<code>lat</code>查询字符串参数。您的代码如下:<code>if (!lng || !lat)</code>。</p> <p>在此类“falsey”测试中,JavaScript 会寻找它认为的任何假值,例如空字符串、未定义、<code>null</code>以及(对您来说很重要)<code>0</code>。这会在您的代码中引入一个意外的错误。如果某人恰好位于赤道或本初子午线(即格林威治标准时间线),他们将收到 API 错误。</p> <p>您可以通过验证“falsey”测试来修复此错误,使其表示“如果它是假的但不是零”。在代码中,此语句看起来像这样:<code>if ((!lng && lng !== 0) || (!lat && lat !== 0))</code>。</p> <p>更新 API 中的控制器将消除此错误。</p> <p>您可以使用类似的策略来测试 404 错误。API 期望所有查询字符串参数都被发送,如果其中一个缺失,则返回 404。为了快速测试代码,您可以注释掉其中一个参数:</p> <pre><code>const requestOptions = { url: `${apiOptions.server}${path}`, method: 'GET', json: {}, qs: { // lng: -0.7992599, *1* lat: 51.378091, maxDistance: 20 } }; </code></pre> <ul> <li><em><strong>1</strong></em> <strong>在请求中注释掉一个查询字符串参数以帮助测试当 API 返回 404 时会发生什么。</strong></li> </ul> <p>一次做这两件事,刷新主页以查看不同的消息。这些消息在图 7.4 中显示。</p> <h5 id="图-74-显示在捕获返回的错误后在视图中显示的错误消息">图 7.4. 显示在捕获返回的错误后在视图中显示的错误消息</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig04_alt.jpg" alt="" loading="lazy"></p> <p>那个图显示了设置得很好的主页。您的 Express 应用程序查询您构建的 API,该 API 从 MongoDB 数据库中提取数据并将其传递回应用程序。当应用程序从 API 收到响应时,它会确定如何处理它,并在浏览器中显示数据或错误消息。</p> <p>接下来,您将为详情页面做同样的事情,这次处理单个数据实例。</p> <h3 id="73-从-api-获取单个文档loc8r-详情页面">7.3. 从 API 获取单个文档:Loc8r 详情页面</h3> <p>详情页面应显示您关于特定位置的所有信息,从名称和地址到评分、评论、设施和位置地图。目前,此页面正在使用控制器中硬编码的数据,看起来像图 7.5。</p> <h5 id="图-75-现在的详情页面使用控制器中硬编码的数据">图 7.5. 现在的详情页面,使用控制器中硬编码的数据</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig05_alt.jpg" alt="" loading="lazy"></p> <p>在本节中,您将更新应用程序,使其允许您指定您想要获取详情的位置,从 API 获取详情,并将它们输出到浏览器。当然,您还会添加一些错误处理。</p> <h4 id="731-设置-url-和路由以访问特定的-mongodb-文档">7.3.1. 设置 URL 和路由以访问特定的 MongoDB 文档</h4> <p>您当前访问详情页面的路径是/location。此路径没有提供指定您想要查看的位置的方法。为了解决这个问题,您可以借鉴 API 路由的方法,在那里您将位置文档的 ID 指定为 URL 参数。</p> <p>单个位置的路由 API 是 /api/location/:locationid。你可以为主要的 Express 应用程序做同样的事情,并更新路由以包含 <code>locationid</code> 参数。位置的主要应用程序路由在 <code>/routes</code> 文件夹中的 index.js 文件中。以下代码片段显示了更新位置详情路由以接受 <code>locationid</code> URL 参数所需的简单更改(app_server/routes/index.js):</p> <pre><code>router.get('/', ctrlLocations.homelist); router.get('/location/:locationid', ctrlLocations.locationInfo); *1* router.get('/location/review/new', ctrlLocations.addReview); </code></pre> <ul> <li><strong><em>1</em> 为单个位置的路由添加 locationid 参数</strong></li> </ul> <p>好的,很好,但你是从哪里获取位置 ID 的呢?从整个应用程序的角度考虑,主页是最好的起点,因为详情页的链接都是从主页来的。</p> <p>当主页 API 返回位置数组时,每个位置对象都包含其唯一的 ID。这个整个对象已经传递到视图中,所以更新主页视图以添加这个 ID 作为 URL 参数不应该太难。</p> <p>这一点也不难!以下列表显示了在 locations-list.pug 文件中需要进行的微小更改,以便将每个位置的唯一 ID 添加到链接中,以便通过详情页。</p> <h5 id="列表-712-更新列表视图以添加位置-id-到相关链接">列表 7.12. 更新列表视图以添加位置 ID 到相关链接</h5> <pre><code>block content .row.banner .col-12 h1= pageHeader.title small #{pageHeader.strapline} .row .col-12.col-md-8 .error= message each location in locations .card .card-block h4 a(href=`/location/${location._id}`)= location.name *1* small +outputRating(location.rating) span.badge.badge-pill.badge-default.float-right= location.distance p.address= location.address .facilities each facility in location.facilities span.badge.badge-warning= facility </code></pre> <ul> <li><em><strong>1</strong></em> <strong>在遍历数组中的每个位置时,从对象中提取唯一的 ID 并将其添加到链接的 href 中</strong></li> </ul> <p>如果生活中的一切都这么简单就好了。现在主页包含了每个位置的唯一链接,所有链接都点击通过到详情页。现在你需要让它们显示正确的数据。</p> <h4 id="732-分离关注点将渲染移动到命名函数">7.3.2. 分离关注点:将渲染移动到命名函数</h4> <p>正如你对主页所做的那样,你将详情页的渲染移动到其自己的命名函数中。再次强调,你这样做是为了将渲染功能与 API 调用和数据处理分离。</p> <p>以下列表显示了新的 <code>renderDetailPage()</code> 函数的简化版本以及它是如何从 <code>locationInfo</code> 控制器中调用的。</p> <h5 id="列表-713-将-locationinfo-控制器的内容移动到外部函数">列表 7.13. 将 <code>locationInfo</code> 控制器的内容移动到外部函数</h5> <pre><code>const renderDetailPage = (req, res) => { *1* res.render('location-info', { *1* title: 'Starcups', *1* ... *1* }); *1* const locationInfo = (req, res) => { renderDetailPage(req, res); *2* }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>创建了一个名为 renderDetailPage 的新函数,并将 locationInfo 控制器的所有内容移动到其中</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>从控制器调用一个新函数,记得传递 req 和 res 参数</strong></p> </li> </ul> <p>现在你已经设置了一个清晰、简洁的控制器,准备查询 API。</p> <h4 id="733-使用-url-参数中的唯一-id-查询-api">7.3.3. 使用 URL 参数中的唯一 ID 查询 API</h4> <p>API 调用的 URL 需要包含位置的 ID。你的详情页现在有这个 ID,作为 URL 参数 <code>locationid</code>,因此你可以通过使用 <code>req.params</code> 获取这个值,并将其添加到请求选项中的 <code>path</code>。请求是一个 <code>GET</code> 请求,所以 <code>json</code> 值将是一个空对象。</p> <p>了解这些后,你可以使用在主页控制器中创建的模式来构建对 API 的请求。当 API 响应时,你会调用<code>renderDetailPage()</code>函数。所有这些都在以下列表中展示。</p> <h5 id="列表-714-更新locationinfo控制器以调用-api">列表 7.14. 更新<code>locationInfo</code>控制器以调用 API</h5> <pre><code>const locationInfo = (req, res) => { const path = `/api/locations/${req.params.locationid}`; *1* const requestOptions = { *2* url: `${apiOptions.server}${path}`, *2* method: 'GET', *2* json: {} *2* }; *2* request( requestOptions, (err, response, body) => { renderDetailPage(req, res); *3* } ); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>从 URL 中获取 locationid 参数并将其附加到 API 路径</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>设置所有调用 API 所需的请求选项</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>当 API 响应时调用 renderDetailPage()函数</strong></p> </li> </ul> <p>如果你现在运行此代码,你会看到与之前相同的静态数据,因为你还没有将 API 返回的数据传递到视图中。如果你想快速查看返回的内容,可以在<code>request</code>回调中添加一些控制台日志语句。</p> <p>如果你确认一切按预期工作,那么是时候将数据传递到视图中了。</p> <h4 id="734-将-api-数据传递给视图">7.3.4. 将 API 数据传递给视图</h4> <p>你目前假设 API 返回的是正确数据;你很快就会处理错误检测。这些数据需要一点预处理:API 返回的坐标是一个数组,但视图需要它们作为对象中的命名键值对。</p> <p>以下列表展示了如何在<code>request</code>语句的上下文中执行此操作,在将数据发送到<code>renderDetailPage()</code>函数之前,将 API 中的数据转换。</p> <h5 id="列表-715-在控制器中预处理数据">列表 7.15. 在控制器中预处理数据</h5> <pre><code>request( requestOptions, (err, response, body) => { const data = body; *1* data.coords = { *2* lng: body.coords[0], *2* lat: body.coords[1] *2* }; *2* renderDetailPage(req, res, data); *3* } ); </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>在新的变量中创建返回数据的副本</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>将 coords 属性重置为对象,使用从 API 响应中提取的值设置 lng 和 lat</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>发送要渲染的转换后数据</strong></p> </li> </ul> <p>下一个逻辑步骤是更新<code>renderDetailPage()</code>函数以使用这些数据而不是硬编码的数据。为了使这可行,你需要确保该函数接受数据作为参数,然后根据需要更新传递给视图的值。以下列表以粗体突出显示了所需的更改。</p> <h5 id="列表-716-更新renderdetailpage以接受和使用-api-数据">列表 7.16. 更新<code>renderDetailPage</code>以接受和使用 API 数据</h5> <pre><code>const renderDetailPage = function (req, res, location) { *1* res.render('location-info', { title: location.name, *2* pageHeader: { *2* title: location.name *2* }, *2* sidebar: { context: 'is on Loc8r because it has accessible wifi and space to sit down with your laptop and get some work done.', callToAction: "If you've been and you like it - or if you don't - please leave a review to help other people just like you." }, location *3* }); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>在函数定义中添加一个用于数据的新的参数</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>在函数中根据需要引用具体的数据项</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>将包含所有详细信息的完整位置数据对象传递给视图</strong></p> </li> </ul> <p>你可以采取发送完整对象的方法,因为你最初基于视图和控制器所需的数据构建数据模型。如果你现在运行应用程序,你应该会看到页面加载了从数据库中检索的数据,如图图 7.6 所示。</p> <h5 id="图-76-详细页面通过-api-从-mongodb-中拉取数据">图 7.6. 详细页面通过 API 从 MongoDB 中拉取数据</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig06_alt.jpg" alt="" loading="lazy"></p> <p>留意细节的读者会发现图 7.6 中的截图存在问题:评论没有与之关联的日期。</p> <h4 id="735-调试和修复视图错误">7.3.5. 调试和修复视图错误</h4> <p>因此,你遇到了一个视图问题,它没有正确输出评论日期。你基于视图和控制器提供的数据构建了数据模型,但现在你发现信息不足。在本节中,你将查看发生了什么。</p> <p>从查看<code>app_server/views</code>中的 Pug 文件 location-info.pug 开始,你可以隔离输出此部分的行:</p> <pre><code>small.reviewTimestamp #{review.timestamp} </code></pre> <p>现在,你需要检查模式(schema)以查看在定义模型时是否进行了更改。评论的 schema 位于<code>app_api/models</code>中的 locations.js,如下代码片段所示:</p> <pre><code>const reviewSchema = new mongoose.Schema({ author: String, rating: { type: Number, required: true, min: 0, max: 5 }, reviewText: String, createdOn: { type: Date, 'default': Date.now } }); </code></pre> <p>哎,是的;在这里你可以看到你将时间戳改为了<code>createdOn</code>,这是一个更准确的路径名称。</p> <p>使用此值更新 Pug 文件看起来如下:</p> <pre><code>small.reviewTimestamp #{review.createdOn} </code></pre> <p>进行这些更改并刷新页面后,你会得到图 7.7。</p> <h5 id="图-77-直接从返回的数据中提取名称和日期日期的格式对用户来说不够友好">图 7.7. 直接从返回的数据中提取名称和日期;日期的格式对用户来说不够友好。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig07_alt.jpg" alt="" loading="lazy"></p> <p>成功!某种程度上。日期现在显示出来了,但并不是你希望看到的那种用户可读的格式。你应该能够通过使用 Pug 来修复这个问题。</p> <h4 id="736-使用-pug-混合格式化日期">7.3.6. 使用 Pug 混合格式化日期</h4> <p>在设置视图时,你使用了一个 Pug 混合来根据提供的评分数字输出评分星级。在 Pug 中,混合就像函数一样;你可以调用它们时传递参数,如果你想的话,运行一些 JavaScript 代码,并让它们生成一些输出。</p> <p>在多个地方格式化日期可能很有用,因此创建一个混合(mixin)来完成这项工作。你的<code>outputRating</code>混合在<code>app_server/views/_includes</code>中的共享 HTMLfunctions.pug 文件里。向该文件添加一个新的混合,名为<code>formatDate</code>。</p> <p>在这个混合中,你将主要使用 JavaScript 将日期从长 ISO 格式转换为更易读的格式<em>Day Month Year</em>(例如<em>10 May 2017</em>)。ISO 日期对象以字符串形式到达这里,所以首先要做的事情是将它转换为 JavaScript 日期对象。完成这个操作后,你将能够使用各种 JavaScript 日期方法来访问日期的各个部分。</p> <p>下面的列表展示了如何在混合中完成这个操作。记住,在 Pug 文件中的 JavaScript 代码行必须以一个短横线为前缀。</p> <h5 id="列表-717-创建一个-jade-混合来格式化日期">列表 7.17. 创建一个 Jade 混合来格式化日期</h5> <pre><code>mixin formatDate(dateString) - const date = new Date(dateString); *1* - const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; *2* - const d = date.getDate(); *3* - const m = monthNames[date.getMonth()]; *3* - const y = date.getFullYear(); *3* - const output = `${d} ${m} ${y}`; *4* =output *4* </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>将提供的日期字符串转换为日期对象</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>设置一个包含月份名称的值的数组</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>使用 JavaScript 数据方法提取和转换日期的所需部分</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>将部分重新组合成所需的格式并渲染输出</strong></p> </li> </ul> <p>现在,这个混合接受一个日期并对其进行处理,以输出你想要的格式。当混合渲染输出时,你只需从代码中的正确位置调用它。以下代码演示了这次调用,再次基于整个模板中的相同两个独立的行:</p> <pre><code>span.reviewAuthor #{review.author.displayName} small.reviewTimestamp +formatDate(review.createdOn) *1* </code></pre> <ul> <li><em><strong>1</strong></em> <strong>从自己的行调用 mixin,传递评论的创建日期(确保新行正确缩进)</strong></li> </ul> <p>应将 mixin 的调用放在新的一行上,所以你需要记住注意缩进;日期应该嵌套在<code><small></code>标签内。现在详情页面已经完成,看起来应该像图 7.8 中所示的那样。</p> <h5 id="图-78-完整的详情页面位置-id-从-url-传递到-apiapi-检索数据并将其格式化后返回到页面进行正确渲染">图 7.8. 完整的详情页面。位置 ID 从 URL 传递到 API,API 检索数据并将其格式化后返回到页面进行正确渲染。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig08_alt.jpg" alt="" loading="lazy"></p> <p>很好;这正是你想要的。如果 URL 中包含数据库中找到的 ID,页面将很好地显示。但如果 ID 错误或未在数据库中找到呢?</p> <h4 id="737-创建特定状态的错误页面">7.3.7. 创建特定状态的错误页面</h4> <p>如果 URL 中的 ID 在数据库中找不到,API 将返回 404 错误。这个错误源于浏览器中的 URL,因此浏览器也应该返回 404;找不到该 ID 的数据,所以本质上页面是无法找到的。</p> <p>通过使用本章中已经看到的技术,你可以轻松地捕获 API 返回 404 状态码的情况,在<code>request</code>回调中使用<code>response.statusCode</code>。你不想在回调中处理它,所以你会将流程传递到一个新的函数中,你可以调用它:<code>showError()</code>。</p> <h5 id="捕获所有错误代码">捕获所有错误代码</h5> <p>捕获 404 响应比捕获更好,你可以将其颠倒过来,寻找 API 返回的任何不是 200 成功响应的响应。你可以将状态码传递给<code>showError()</code>函数,让它决定如何处理。为了使<code>showError()</code>函数保持控制,你还将传递<code>req</code>和<code>res</code>对象。</p> <p>以下列表展示了如何更新<code>request</code>回调以渲染成功 API 调用详情页面,并将所有其他错误路由到<code>showError()</code>捕获函数。</p> <h5 id="列表-718-捕获-api-未返回-200-状态码引起的任何错误">列表 7.18. 捕获 API 未返回 200 状态码引起的任何错误</h5> <pre><code>request( requestOptions, (err, {statusCode}, body) => { *1* let data = body; if (statusCode === 200) { *2* data.coords = { *3* lng : body.coords[0], *3* lat : body.coords[1] *3* }; *3* renderDetailPage(req, res, data); *3* } else { *4* showError(req, res, statusCode); *4* } *4* } ); </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>你只对 statusCode 感兴趣,所以只获取它。</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>检查 API 是否返回成功响应</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>如果检查成功,则继续渲染页面</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>如果检查不成功,将错误传递给<code>showError()</code>函数。</strong></p> </li> </ul> <p>很好;现在如果你从 API 获取了一些要显示的内容,你将尝试渲染详情页面。对于错误,你打算怎么办?嗯,目前你想要向用户发送一条消息,让他们知道存在问题。</p> <h5 id="显示错误消息">显示错误消息</h5> <p>你不想在这里做任何花哨的事情——只需让用户知道正在发生某些事情,并给他们一些关于它是什么的指示。你已经有一个适合此目的的通用 Pug 模板;它被称为<code>generic-text.pug</code>,只需要一个标题和一些内容。这就足够了。</p> <p>如果你想,你可以为每种错误类型创建一个独特的页面和布局,但到目前为止,你只是满足于捕获它并让用户知道。此外,你应通过在页面显示时返回适当的状态码来让浏览器知道。</p> <p>以下列表显示了<code>showError()</code>函数的样子,它接受一个状态参数,除了作为响应状态码传递之外,还用于定义页面的标题和内容。在这里,您有一个针对 404 页面的特定消息,以及针对传递的任何其他错误的通用消息。</p> <h5 id="列表-719-为非-200-状态码的-api-状态码创建错误处理函数">列表 7.19. 为非 200 状态码的 API 状态码创建错误处理函数</h5> <pre><code>const showError = (req, res, status) => { let title = ''; let content = ''; if (status === 404) { *1* title = '404, page not found'; *1* content = 'Oh dear. Looks like you can\'t find this page. Sorry.'; *1* } else { *2* title = `${status}, something's gone wrong`; *2* content = 'Something, somewhere, has gone just a little bit wrong.'; *2* } res.status(status); *3* res.render('generic-text', { *4* title, *4* content *4* }); *4* }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>如果通过的状态是 404,则设置页面的标题和内容</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>否则,设置一个通用的捕获消息</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>使用状态参数设置响应状态</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>将数据发送到视图进行编译并发送到浏览器</strong></p> </li> </ul> <p>这个函数可以从任何你可能觉得有用的控制器中重用。它还构建得易于添加新的、特定的错误消息,如果你想要的话。</p> <p>您可以通过稍微更改 URL 中的位置 ID 来测试 404 错误页面,您应该会看到类似于图 7.9 的内容。</p> <h5 id="图-79-当-api-在数据库中找不到-url-中的位置-id-时显示的-404-错误页面">图 7.9. 当 API 在数据库中找不到 URL 中的位置 ID 时显示的 404 错误页面</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig09_alt.jpg" alt="" loading="lazy"></p> <p>这就带您来到了详情页的结尾。您可以成功显示给定位置的数据库中的所有信息,如果找不到位置,还可以向访客显示 404 消息。</p> <p>沿着用户旅程继续前进,您的下一个也是最后的任务是添加添加评论的功能。</p> <h3 id="74-通过-api-将数据添加到数据库添加-loc8r-评论">7.4. 通过 API 将数据添加到数据库:添加 Loc8r 评论</h3> <p>在本节中,您将了解如何处理用户提交的表单数据,对其进行处理,并将其发布到 API。通过点击位置详情页上的“添加评论”按钮,填写表单并提交,评论被添加到 Loc8r。无论如何,这是计划。您目前有执行此操作的视图,但没有实现其底层功能的代码。您需要立即改变这种情况。</p> <p>下面是一个你将要做的列表:</p> <ol> <li> <p>让评论表单知道评论将针对哪个位置。</p> </li> <li> <p>为表单创建一个<code>POST</code>路由。</p> </li> <li> <p>将新的评论数据发送到 API。</p> </li> <li> <p>在详情页上显示新的评论。</p> </li> </ol> <p>注意,在当前的开发阶段,你还没有设置认证方法,因此你没有用户账户的概念。</p> <h4 id="741-设置路由和视图">7.4.1. 设置路由和视图</h4> <p>您列表中的第一项涉及将位置的 ID 添加到“添加评论”页面,以便在表单提交时使用。毕竟,这个 ID 是 API 添加评论所需的唯一标识符。将 ID 添加到页面的最佳方法是将它包含在 URL 中,就像您为详情页面本身所做的那样。</p> <h5 id="定义两个评论路由">定义两个评论路由</h5> <p>将位置 ID 放入 URL 意味着更改“添加评论”页面的路由,以添加一个<code>locationid</code>参数。在此过程中,您还可以处理列表上的第二项,为表单创建一个<code>POST</code>请求的路由。理想情况下,这个路由应该与评论表单有相同的路径,并关联不同的请求方法和不同的控制器。为此,您将更新到<code>router.route</code>语法,使其明确您正在使用一个具有两种不同方法的单个路由。</p> <p>以下代码片段显示了如何在<code>app_server/routes</code>文件夹中的 index.js 中更新路由:</p> <pre><code>router.get('/', ctrlLocations.homelist); router.get('/location/:locationid', ctrlLocations.locationInfo); router .route('/location/:locationid/review/new') *1* .get(ctrlLocations.addReview) .post(ctrlLocations.doAddReview); *2* </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>更新 router.route 语法,并在评论表单路由中插入 locationid 参数</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>在相同的 URL 上创建一个新的路由,但使用 POST 方法并引用不同的控制器</strong></p> </li> </ul> <p>这些路由就是本节所需的所有内容,但重新启动应用程序将失败,因为<code>POST</code>路由引用了一个不存在的控制器。您可以通过向控制器文件添加一个占位符函数来修复这个问题。将以下代码片段添加到<code>app_server/controllers</code>中的 locations.js,并将<code>doAddReview</code>添加到导出列表的底部。然后应用程序将再次成功启动:</p> <pre><code>const doAddReview = (req, res) => { }; </code></pre> <p>然而,如果您点击进入“添加评论”页面,您会得到一个错误。哦,是的——您需要从详情页面更新到“添加评论”页面的链接。</p> <h5 id="修复位置详情视图">修复位置详情视图</h5> <p>您需要将位置 ID 添加到详情页面上的“添加评论”按钮指定的<code>href</code>中。该页面的控制器将传递从 API 返回的完整数据对象,其中包含<code>_id</code>字段,以及其他数据。当传递到视图时,这个数据对象被称为<code>location</code>。</p> <p>以下代码片段显示了<code>app_server/views</code>文件夹中的 location-info.pug 模板中的一行。这一行显示了如何将位置 ID 添加到“添加评论”按钮的链接中;请注意,您现在使用 JavaScript 模板字符串作为<code>href</code>值:</p> <pre><code>a.btn.btn-primary.float-right(href=`/location/${location._id} /review/new`) Add review </code></pre> <p>在模板更新并保存后,您可以为每个单独的位置点击进入评论表单。然而,仍然存在一些问题:表单没有提交到任何地方,并且位置名称被硬编码到控制器中。</p> <h5 id="更新评论表单视图">更新评论表单视图</h5> <p>接下来,您想要确保表单提交到正确的 URL。现在,当表单提交时,它会向/location URL 发起一个<code>GET</code>请求:</p> <pre><code>form(action="/location", method="get", role="form") </code></pre> <p>这行代码来自<code>app_server/views</code>中的<code>location-review-form.pug</code>文件。在应用程序中,/location 路径不再有效,你也不想使用<code>GET</code>请求。你想要将表单发送到的 URL 与添加评论的 URL 相同:/location/:locationid/reviews/new。</p> <p>实现此任务的一个简单方法是将表单的动作设置为空字符串,并将方法设置为<code>post</code>,如下所示:</p> <pre><code>form(action="", method="post", role="form") </code></pre> <p>现在,当表单提交时,它会向当前页面的 URL 发送一个<code>POST</code>请求。</p> <h5 id="为渲染添加评论页面创建一个命名函数">为渲染添加评论页面创建一个命名函数</h5> <p>与其他页面一样,你将把页面的渲染移动到单独的命名函数中。这一步骤允许你在编码时分离关注点,并为下一步做好准备。</p> <p>以下列表显示了在代码中应该看起来是什么样子。请在<code>app_server/controllers</code>中的 locations.js 中进行更改。</p> <h5 id="列表-720-为addreview控制器主体创建渲染函数">列表 7.20. 为<code>addReview</code>控制器主体创建渲染函数</h5> <pre><code>const renderReviewForm = (req, res) => { *1* res.render('location-review-form', { title: 'Review Starcups on Loc8r', pageHeader: { title: 'Review Starcups' } }); }; /* GET 'Add review' page */ const addReview = (req, res) => { renderReviewForm(req, res); *2* }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> 创建新的函数<code>renderReviewForm()</code>,并将<code>addReview</code>控制器的内容移动到其中</p> </li> <li> <p><em><strong>2</strong></em> 在<code>addReview</code>控制器内部调用新函数,并传递相同的参数</p> </li> </ul> <p>这段代码可能看起来有点奇怪——创建一个命名函数,然后让对该函数的调用成为控制器中唯一的事情——但它在稍后将会很有用。</p> <h5 id="获取位置详情">获取位置详情</h5> <p>在添加评论页面上,你想要显示位置名称以保留用户的上下文感。你想要再次调用 API,给它提供位置 ID,并将信息返回到控制器和视图中。你已经为详情页面做了这件事,尽管使用了不同的控制器。如果你正确地处理这个任务,你不需要编写太多新的代码。</p> <p>而不是复制代码并维护两份,你会选择 DRY(不要重复自己)的方法。详情页面和添加评论页面都想要调用 API 获取位置信息,然后对其进行处理。那么为什么不创建一个执行此操作的新函数呢?你已经在<code>locationInfo</code>控制器中有了大部分代码;你需要改变它调用最终函数的方式。你将不再显式调用<code>renderDetailPage()</code>,而是将其作为一个回调。</p> <p>你将有一个名为<code>getLocationInfo()</code>的新函数,它执行 API 请求。在请求成功后,此函数应调用传递的任何回调函数。<code>locationInfo</code>控制器调用此函数,传递一个调用<code>renderDetailPage()</code>函数的回调函数。同样,<code>addReview</code>控制器也可以调用这个新函数,传递<code>renderReviewForm()</code>函数作为回调。</p> <p>这些更改使你拥有一个执行 API 调用并具有不同结果的函数,这取决于发送的回调函数。以下列表显示了所有内容。</p> <h5 id="列表-721-创建一个新的可重用函数以获取位置信息">列表 7.21. 创建一个新的可重用函数以获取位置信息</h5> <pre><code>const getLocationInfo = (req, res, callback) => { *1* const path = `/api/locations/${req.params.locationid}`; const requestOptions = { url : `${apiOptions.server}${path}`, method : 'GET', json : {} }; request( requestOptions, (err, {statusCode}, body) => { let data = body; if (statusCode === 200) { data.coords = { lng : body.coords[0], lat : body.coords[1] }; callback(req, res, data); *2* } else { showError(req, res, statusCode); } } ); }; const locationInfo = (req, res) => { getLocationInfo(req, res, (req, res, responseData) => renderDetailPage(req, res, responseData) ); *3* }; const addReview = (req, res) => { getLocationInfo(req, res, (req, res, responseData) => renderReviewForm(req, res, responseData) ); *4* }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>新函数 getLocationInfo() 接受一个回调作为第三个参数,并包含原来在 locationInfo 控制器中使用的所有代码</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>在成功的 API 响应后,调用回调而不是命名函数</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>在 locationInfo 控制器中调用 getLocationInfo() 函数,传递一个回调函数,该函数在完成时将调用 renderDetailPage() 函数</strong></p> </li> <li> <p><em><strong>4</strong></em> <strong>也从 addReview 控制器中调用 getLocationInfo(),但这次传递 renderReviewForm() 作为回调</strong></p> </li> </ul> <p>这样,你就得到了一个优雅的 DRY 方法来解决这个问题。很容易从控制器中复制粘贴 API 代码——如果我们说实话,如果你在弄清楚代码以及你需要做什么来让它工作,这是绝对可以接受的。但是当你看到两段几乎做同样的事情的代码时,总是要问自己如何使其 DRY,以使你的代码更干净、更容易维护。</p> <h5 id="显示位置详情">显示位置详情</h5> <p>你还有一件事要处理。用于渲染表单的函数仍然包含硬编码的数据而不是使用来自 API 的数据。对函数进行快速调整就可以改变这种情况。</p> <h5 id="列表-722-从-renderreviewform-函数中移除硬编码的数据">列表 7.22. 从 <code>renderReviewForm</code> 函数中移除硬编码的数据</h5> <pre><code>const renderReviewForm = function (req, res, {name}) { *1* res.render('location-review-form', { title: `Review ${name} on Loc8r`, *2* pageHeader: { title: `Review ${name}` } *2* }); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>更新 renderReviewForm() 函数以接受一个包含数据的新的参数,解构为所需的内容</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>使用模板字符串替换硬编码的数据为数据引用</strong></p> </li> </ul> <p>“添加评论”页面再次看起来不错,根据在 URL 中找到的 ID 显示正确的名称,如图 7.10 所示。</p> <h5 id="图-710-通过-api-拉取包含在-url-中的位置名称的添加评论页面">图 7.10. 通过 API 拉取包含在 URL 中的位置名称的“添加评论”页面</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig10_alt.jpg" alt="" loading="lazy"></p> <h4 id="742-将评论数据-post-到-api">7.4.2. 将评论数据 POST 到 API</h4> <p>到现在为止,你已经设置了“添加评论”页面并准备就绪,包括发布目的地。你甚至已经设置了<code>POST</code>操作的路线和控制台。不过,控制台<code>doAddReview</code>是一个空的占位符。</p> <p>此控制器的计划如下:</p> <ol> <li> <p>从 URL 中获取位置 ID 以构建 API 请求 URL。</p> </li> <li> <p>获取表单中提交的数据,并将其打包以供 API 使用。</p> </li> <li> <p>执行 API 调用。</p> </li> <li> <p>如果成功,则在当前位置显示新的评论。</p> </li> <li> <p>如果不成功,则显示错误页面。</p> </li> </ol> <p>你还没有看到此过程的唯一部分是将数据传递给 API;到目前为止,你已经传递了一个空的 JSON 对象以确保响应格式为 JSON。现在你将表单数据以 API 期望的格式传递给它。表单上有三个字段,API 期望有三个引用。你所需要做的就是将它们映射到一起。表单字段和模型路径显示在表 7.3 中。</p> <h5 id="表-73-将表单字段名称映射到-api-期望的模型路径">表 7.3. 将表单字段名称映射到 API 期望的模型路径</h5> <table> <thead> <tr> <th>表单字段</th> <th>API 引用</th> </tr> </thead> <tbody> <tr> <td>名称</td> <td>作者</td> </tr> <tr> <td>评分</td> <td>评分</td> </tr> <tr> <td>评论</td> <td>评论文本</td> </tr> </tbody> </table> <p>将此映射转换为 JavaScript 对象是直接的。创建一个新的对象,包含 API 期望的变量名,并使用<code>req.body</code>从提交的表单中获取值。以下代码片段显示了此对象,你将在稍后将其放入控制器中:</p> <pre><code>const postdata = { author: req.body.name, rating: parseInt(req.body.rating, 10), reviewText: req.body.review }; </code></pre> <p>现在你已经看到了它是如何工作的,你可以将其添加到你一直在使用的这些 API 控制器标准模式中,并构建出<code>doAddReview</code>控制器。记住,API 返回的成功的<code>POST</code>操作的状态码是 201,而不是你迄今为止在<code>GET</code>请求中使用的 200。以下列表显示了<code>doAddReview</code>控制器,使用了你迄今为止学到的所有内容。</p> <h5 id="列表-723-doaddreview控制器用于将评论数据发布到-api">列表 7.23. <code>doAddReview</code>控制器用于将评论数据发布到 API</h5> <pre><code>const doAddReview = (req, res) => { const locationid = req.params.locationid; *1* const path = `/api/locations/${locationid}/reviews`; *1* const postdata = { *2* author: req.body.name, *2* rating: parseInt(req.body.rating, 10), *2* reviewText: req.body.review *2* }; *2* const requestOptions = { url: `${apiOptions.server}${path}`, *3* method: 'POST', *3* json: postdata *3* }; request( *4* requestOptions, (err, {statusCode}, body) => { if (statusCode === 201) { *5* res.redirect(`/location/${locationid}`); *5* } else { *5* showError(req, res, statusCode); *5* } } ); }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> 从 URL 获取位置 ID 以构建 API URL</p> </li> <li> <p><em><strong>2</strong></em> 创建一个数据对象以发送到 API,使用提交的表单数据</p> </li> <li> <p><em><strong>3</strong></em> 设置请求选项,包括路径,设置 POST 方法,并将提交的表单数据传递给 json 参数</p> </li> <li> <p><em><strong>4</strong></em> 发送请求</p> </li> <li> <p><em><strong>5</strong></em> 如果评论添加成功,则重定向到详情页面,如果 API 返回错误,则显示错误页面</p> </li> </ul> <p>现在你可以创建一个评论,提交它,然后可以在详情页面上看到它,如图 7.11 所示。</p> <h5 id="图-711-填写并提交评论表单后评论在详情页面上显示">图 7.11. 填写并提交评论表单后,评论在详情页面上显示。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig11_alt.jpg" alt="Images/07fig11_alt.jpg" loading="lazy"></p> <p>现在一切正常工作,让我们快速看一下如何添加表单验证。</p> <h3 id="75-使用数据验证保护数据完整性">7.5. 使用数据验证保护数据完整性</h3> <p>每当应用程序接受外部输入并将其添加到数据库时,你需要确保数据尽可能完整和准确——尽可能多或者有意义的那么多。如果有人添加一个电子邮件地址,你应该检查它是否是有效的电子邮件格式,但你不能通过编程方式验证它是否是一个<em>真实</em>的电子邮件地址。</p> <p>在本节中,你将了解你可以如何向应用程序添加验证,以防止人们提交空评论。你可以在三个地方添加验证:</p> <ul> <li> <p>在使用 Mongoose 的模式级别,在数据保存之前</p> </li> <li> <p>在应用级别,在数据发布到 API 之前</p> </li> <li> <p>在客户端,在表单提交之前</p> </li> </ul> <p>你将依次查看这些地方,并在每个步骤中添加一些验证。</p> <h4 id="751-使用-mongoose-在模式级别进行验证">7.5.1. 使用 Mongoose 在模式级别进行验证</h4> <p>在保存数据之前验证数据可能是最重要的阶段。这一步是最后一步,是确保一切尽可能正确的最后机会。当数据通过 API 公开时,这一阶段尤为重要;如果你无法控制所有使用 API 的应用程序,你无法保证你将获得的数据质量。在保存之前确保数据有效是很重要的。</p> <h5 id="更新模式">更新模式</h5> <p>当你在第五章中首次设置模式时,你查看在 Mongoose 中添加一些验证。你将<code>rating</code>路径设置为必需的,但你还想将<code>author displayName</code>和<code>reviewText</code>设置为必需的。如果这些字段中的任何一个缺失,评论就没有意义。将此添加到模式中很简单,如下所示列表。(模式在 app_api/model 文件夹中的 locations.js 中。)</p> <h5 id="列表-724-在模式级别添加评论的验证">列表 7.24. 在模式级别添加评论的验证</h5> <pre><code>const reviewSchema = new mongoose.Schema({ author: { *1* type: String, *1* required: true *1* }, *1* rating: { *1* type: Number, *1* required: true, *1* min: 0, *1* max: 5 *1* }, *1* reviewText: { *1* type: String, *1* required: true *1* }, *1* createdOn: { *2* type: Date, *2* 'default': Date.now *2* } *2* }); </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>将这些路径中的每一个都设置为必需字段,因为如果其中任何一个缺失,评论就没有意义</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>createdOn 不需要是必需的,因为 Mongoose 在创建新评论时会自动填充它。</strong></p> </li> </ul> <p>当这段代码被保存时,你将无法在没有任何评论文本的情况下保存评论。你可以尝试,但你会看到图 7.12 中显示的错误页面。</p> <h5 id="图-712-当尝试保存没有评论文本的评论时显示的错误消息因为模式表明它是必需的">图 7.12. 当尝试保存没有评论文本的评论时显示的错误消息,因为模式表明它是必需的</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig12_alt.jpg" alt="" loading="lazy"></p> <p>一方面,你保护数据库是好事,但用户体验并不好。你应该尝试捕捉那个错误,并让访客再次尝试。</p> <h5 id="捕获-mongoose-验证错误">捕获 Mongoose 验证错误</h5> <p>如果你尝试保存一个缺少一个或多个必需路径或为空的文档,Mongoose 会返回一个错误。它这样做而不需要调用数据库,因为 Mongoose 本身持有模式并知道什么是必需的。以下代码片段显示了一个这样的错误消息示例:</p> <pre><code>{ message: 'Validation failed', name: 'ValidationError', errors: { 'reviews.1.reviewText': { message: 'Path `reviewText` is required.', name: 'ValidatorError', path: 'reviewText', type: 'required', value: '' } } } </code></pre> <p>在应用程序的流程中,这发生在<code>save</code>函数的回调中。如果你查看<code>doAddReview()</code>函数(在 app_api/controllers/reviews.js 中)内的<code>save</code>命令,你可以看到错误是如何冒泡的以及你设置了 400 状态。以下代码片段显示了这一点,包括一个临时的控制台日志语句来显示错误输出到终端:</p> <pre><code>location.save((err, location) => { if (err) { *1* console.log(err); *1* res *1* .status(400) *1* .json(err); *1* } else { updateAverageRating(location._id); let thisReview = location.reviews[location.reviews.length - 1]; res .status(201) .json(thisReview); } }); </code></pre> <ul> <li><em><strong>1</strong></em> <strong>Mongoose 验证错误是在尝试保存操作后通过一个错误对象返回的。</strong></li> </ul> <p>你的 API 将此消息作为响应体返回,并附带 400 状态。你可以在 API 返回 400 状态时查看响应体以查找此信息。</p> <p>做这件事的地方是在 app_server 中——在 controllers/locations.js 中的<code>doAddReview()</code>函数中,更确切地说。当你捕捉到一个验证错误时,你想要通过重定向到添加评论页面让用户再次尝试。为了使页面知道已经尝试过,你可以在查询字符串中传递一个标志。</p> <p>以下列表显示了这段代码的位置,在<code>doAddReview()</code>函数的请求语句回调中。</p> <h5 id="列表-725-捕获-api-返回的验证错误">列表 7.25. 捕获 API 返回的验证错误</h5> <pre><code>request( requestOptions, (err, {statusCode},{name}) => { if (statusCode === 201) { res.redirect(`/location/${locationid}`); } else if (statusCode === 400 && name && name === 'ValidationError') { *1* res.redirect(`/location/${locationid}/review/new?err=val`); *2* } else { console.log(body); showError(req, res, statusCode); } } ); </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>添加了一个检查,看状态是否为 400,正文是否有名称,并且该名称是</strong> <strong>ValidationError</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>如果为真,则通过查询字符串传递错误标志重定向到评论表单</strong></p> </li> </ul> <p>现在当 API 返回验证错误时,你可以捕获它并将用户送回表单再次尝试。在查询字符串中传递一个值意味着你可以在显示评论表单的控制器中查找它,并发送一条消息到视图以提醒用户问题。</p> <h5 id="在浏览器中显示错误消息">在浏览器中显示错误消息</h5> <p>在视图中显示错误消息,如果你在查询字符串中看到了传递的<code>err</code>参数,你需要将一个变量发送到视图中。<code>renderReviewForm()</code>函数负责将变量传递到视图中。当它被调用时,它也会传递<code>req</code>对象,该对象包含<code>query</code>对象,这使得在存在的情况下传递<code>err</code>参数变得容易。以下列表显示了实现这一功能所需进行的简单更改。</p> <h5 id="列表-726-更新控制器以从查询对象传递错误字符串到视图">列表 7.26. 更新控制器以从查询对象传递错误字符串到视图</h5> <pre><code>const renderReviewForm = (req, res,{name}) => { res.render('location-review-form', { title: `Review ${name} on Loc8r`, pageHeader: { title: `Review ${name}` }, error: req.query.err *1* }); }; </code></pre> <ul> <li><em><strong>1</strong></em> <strong>向视图发送新的错误变量,传递任何现有的查询参数</strong></li> </ul> <p><code>query</code>对象始终是<code>req</code>对象的一部分,无论它是否有内容。这就是为什么你不需要错误捕获此对象来检查它是否存在;如果找不到<code>err</code>参数,它返回<code>undefined</code>。</p> <p>剩下的就是要在视图中处理这些信息,让用户知道问题是什么。如果验证错误被冒泡到顶部,你将在表单顶部向用户显示一条消息。为了给这条消息一些样式和页面上的存在感,你将使用一个 Bootstrap 警告组件:一个带有相关类和属性的<code>div</code>。以下代码片段显示了要添加到<code>location-review-form</code>视图中的两行代码:</p> <pre><code>form(action="", method="post", role="form") - if (error == "val") .alert.alert-danger(role="alert") All fields required, please try again </code></pre> <p>现在当 API 返回验证错误时,你捕获它并向用户显示一条消息。图 7.13 显示了这条消息的外观。</p> <h5 id="图-713-浏览器中的验证错误消息这是由-mongoose-捕获错误并返回错误的结果">图 7.13. 浏览器中的验证错误消息,这是由 Mongoose 捕获错误并返回错误的结果</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/07fig13_alt.jpg" alt="" loading="lazy"></p> <p>这种在 API 级别的验证很重要,并且通常是一个很好的起点,因为它可以保护数据库免受不一致或不完整数据的影响,无论其来源如何。但最终用户的体验并不总是最好的;他们必须提交表单,表单请求在页面重新加载并显示错误之前会绕一圈到 API。显然,还有改进的空间,第一步是在将数据传递到 API 之前在应用级别执行一些验证。</p> <h4 id="752-使用-node-和-express-在应用级别进行验证">7.5.2. 使用 Node 和 Express 在应用级别进行验证</h4> <p>在模式级别进行验证是最后的防线,是数据库前的最后一道防线。然而,应用程序不应仅依赖于这个防线,你应该尝试减少对 API 的不必要调用,以减少开销并加快用户的速度。一种方法是在应用程序级别添加验证,在将数据发送到 API 之前检查提交的数据。</p> <p>在你的应用程序中,评论所需的验证很简单;你可以添加一些简单的检查以确保每个字段都有值。如果这个测试失败,你将用户重定向回表单,添加与之前相同的查询字符串错误标志。如果验证检查成功,你允许控制器继续到请求方法。以下列表显示了在 app_server/controllers 文件夹中的 locations.js 中的 <code>doAddReview</code> 控制器所需添加的内容。</p> <h5 id="列表-727-向-express-控制器添加一些简单的验证">列表 7.27. 向 Express 控制器添加一些简单的验证</h5> <pre><code>const doAddReview = (req, res) => { const locationid = req.params.locationid; const path = `/api/locations/${locationid}/reviews`; const postdata = { author: req.body.name, rating: parseInt(req.body.rating, 10), reviewText: req.body.review }; const requestOptions = { url: apiOptions.server + path, method: 'POST', json: postdata }; if (!postdata.author || !postdata.rating || !postdata.reviewText) { *1* res.redirect(`/location/${locationid}/review/new?err=val`); *1* } else { *3* request( requestOptions, (err, {statusCode},{name}) => { if (statusCode === 201) { res.redirect(`/location/${locationid}`); } else if (statusCode === 400 && name && name === 'ValidationError' ) { res.redirect(`/location/${locationid}/review/new?err=val`); } else { showError(req, res, statusCode); } } ); } }; </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>如果三个必需的数据字段中的任何一个为假,则重定向到“添加评论”页面,并附加用于显示错误消息的查询字符串</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>否则,继续之前的操作</strong></p> </li> </ul> <p>结果与之前相同:如果评论文本缺失,用户将在“添加评论”页面上看到错误消息。用户不知道你不再向 API 发送数据,但这减少了往返次数,因此应该是一个更快的体验。但是,你可以通过第三级验证:基于浏览器的验证来使其更快。</p> <h4 id="753-使用-jquery-在浏览器中进行验证">7.5.3. 使用 jQuery 在浏览器中进行验证</h4> <p>由于应用程序级别的验证通过不需要调用 API 来加快速度,因此浏览器端的客户端验证可以通过在表单提交到应用程序之前捕获错误来加快速度,通过减少另一个调用。在这一点上捕获错误将保持用户在同一个页面上。</p> <p>要在浏览器中运行 JavaScript,你需要将其放置在应用程序的 public 文件夹中。Express 将此文件夹的内容视为要下载到浏览器的静态文件,而不是在服务器上运行。如果你在 public 文件夹中没有名为 javascripts 的文件夹,现在创建一个。在这个文件夹中,创建一个名为 validation.js 的新文件。</p> <h5 id="编写-jquery-验证">编写 jQuery 验证</h5> <p>在这个新的 validation.js 文件中,放置一个 jQuery 函数,执行以下操作:</p> <ul> <li> <p>监听评论表单的提交事件</p> </li> <li> <p>检查所有必需字段是否有值</p> </li> <li> <p>如果其中一个为空,显示类似于其他类型验证中使用的错误消息,并阻止表单提交</p> </li> </ul> <p>以下列表显示了执行此操作的代码。我们不会深入探讨 jQuery 的语义,假设你对它或类似的库有所了解。</p> <h5 id="列表-728-创建一个-jquery-表单验证函数">列表 7.28. 创建一个 jQuery 表单验证函数</h5> <pre><code>$('#addReview').submit(function (e) { *1* $('.alert.alert-danger').hide(); if (!$('input#name').val() || !$('select#rating').val() || !$('textarea#review').val()) { *1* if ($('.alert.alert-danger').length) { *3* $('.alert.alert-danger').show(); *3* } else { *3* $(this).prepend('<div role="alert" class="alert alert-danger"> *3* All fields required, please try again</div>'); *3* } *3* return false; *4* } }); </code></pre> <ul> <li> <p><em><strong>1</strong></em> <strong>监听评论表单的提交事件</strong></p> </li> <li> <p><em><strong>2</strong></em> <strong>检查是否有缺失的值</strong></p> </li> <li> <p><em><strong>3</strong></em> <strong>如果值缺失,则在页面上显示或注入错误消息</strong></p> </li> <li> <p><em><strong>4</strong></em> 防止表单在缺少值时提交</p> </li> </ul> <p>你需要确保表单有一个名为<code>addReview</code>的 ID,这样 jQuery 可以监听正确的事件。你还需要将此脚本添加到页面中,以便浏览器可以运行它。</p> <h5 id="将-jquery-添加到页面中">将 jQuery 添加到页面中</h5> <p>你将在 body 的末尾包含此 jQuery 文件,以及其他客户端 JavaScript 文件。这些文件在<code>layout.pug</code>视图中设置,位于 app_server/views 的底部。在下面的其他文件下方添加新行,指向新文件:</p> <pre><code>script(src='/bootstrap/js/bootstrap.min.js') script(src='/javascripts/validation.js') </code></pre> <p>就这些了。现在表单在浏览器中进行了验证,而无需提交任何数据,从而消除了页面刷新和任何相关的服务器调用。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="小贴士-8">小贴士</h5> <p>客户端验证可能看起来是你所需要的全部,但其他类型对于应用程序的健壮性至关重要。在浏览器中可以关闭 JavaScript,这将移除运行此验证的能力,或者验证可以被绕过,数据可以直接提交到表单操作 URL 或 API 端点。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>在第八章中,你将把 Angular 引入其中,并开始在 Express 应用程序之上玩一些交互式前端组件。</p> <h3 id="摘要-4">摘要</h3> <p>在本章中,你学习了</p> <ul> <li> <p>如何使用<code>request</code>模块从 Express 中发起 API 调用,以及如何向 API 端点发送<code>POST</code>和<code>GET</code>请求</p> </li> <li> <p>一些通过将渲染函数与 API 请求逻辑分离来分离关注点的方法</p> </li> <li> <p>如何将简单模式应用于每个控制器的 API 逻辑</p> </li> <li> <p>在架构中的三个位置应用数据验证,以及何时以及为什么使用每个</p> </li> </ul> <h2 id="第三部分-使用-angular-添加动态前端">第三部分. 使用 Angular 添加动态前端</h2> <p>Angular 是我们这个时代最激动人心的技术之一,是 MEAN 堆栈的核心部分,具有经过验证的稳定性和持久性。到目前为止,你已经与 Express 做了很多工作,它是服务器端框架。Angular 是客户端框架,它使你能够构建在浏览器中运行的应用程序。</p> <p>在第八章中,你将了解 Angular 和 TypeScript(就像 JavaScript,有点不同但<em>很好</em>的不同),了解所有这些喧嚣的原因,并了解与之相关的特定语法语义和术语。Angular 的学习曲线可能很陡峭,但不必如此。在第八章中开始使用 Angular 和 TypeScript 时,你将看到如何使用它们来构建一个现有网页的组件,包括调用你的 REST API 来获取数据。</p> <p>第九章 和 第十章 专注于如何使用 Angular 构建单页应用程序(SPA)。在第八章所学的基础上,你将重新创建 Loc8r 作为 SPA。你将关注最佳实践,学习如何构建一个模块化应用程序,它具有易于维护的组件,这些组件可以轻松重用。到第三部分结束时,你将拥有一个完全功能化的 SPA,它可以与你的 REST API 交互以创建和读取数据。</p> <h2 id="第八章-使用-typescript-创建-angular-应用程序">第八章. 使用 TypeScript 创建 Angular 应用程序</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>使用 Angular CLI 创建 Angular 应用程序</p> </li> <li> <p>理解 TypeScript 的基本知识</p> </li> <li> <p>创建和使用 Angular 组件</p> </li> <li> <p>从 API 获取数据并将数据绑定到 HTML 模板中</p> </li> <li> <p>构建用于生产的 Angular 应用程序</p> </li> </ul> <p>到此为止。是时候看看 MEAN 堆栈的最后一部分了:Angular!当你开始使用 Angular 和 TypeScript 时,有时会感觉像是一门不同的语言,但 TypeScript 是 JavaScript 的超集,所以它是带有一些额外部分的 JavaScript。TypeScript 是创建 Angular 应用程序的首选语言。我们将随着进展逐步介绍所需的内容,到本章结束时,你将相当熟悉它。</p> <p>要全面了解,你需要重建主页上显示的位置列表,作为一个 Angular 应用程序。你将在这个由 Express 驱动的主页中嵌入这个小应用程序,替换 Express 提供的列表,以实现两个目的:</p> <ul> <li> <p>你将处理 Angular 的一些构建块,而不会感到不知所措。</p> </li> <li> <p>你将看到如何使用 Angular 在现有页面或应用程序中创建一个单组件。</p> </li> </ul> <p>图 8.1 展示了你在整体计划中的位置,即将 Angular 添加到现有 Express 应用程序的前端。</p> <h5 id="图-81-本章重点介绍如何将-angular-添加到现有的-express-应用程序的前端">图 8.1. 本章重点介绍如何将 Angular 添加到现有的 Express 应用程序的前端</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig01_alt.jpg" alt="" loading="lazy"></p> <p>本章采用的方法是如果您想用一点 Angular 来增强页面、项目或应用程序时您会做的事情。完全在 Angular 中构建完整应用程序的内容将在第九章(kindle_split_021.xhtml#ch09)和第十章(kindle_split_022.xhtml#ch10)中介绍,并将补充您在本章中学到的内容。</p> <h3 id="81-使用-angular-运行起来">8.1. 使用 Angular 运行起来</h3> <p>在本节中,您将创建一个 Angular 应用程序的骨架,查看它是如何组成的,并探索一些附带工具以帮助开发。如果您还没有这样做,您需要按照附录 A(kindle_split_026.xhtml#app01)中所述安装 Angular 命令行界面(CLI)。</p> <p>您将首先使用 CLI 创建一个新应用程序。</p> <h4 id="811-使用命令行创建-angular-应用程序的模板">8.1.1. 使用命令行创建 Angular 应用程序的模板</h4> <p>创建新的 Angular 应用程序最简单的方法是使用 Angular CLI,它创建一个功能齐全的小型应用程序并生成良好的文件夹结构。</p> <p>基本命令很简单:</p> <pre><code>ng new your-app-name </code></pre> <p>在您运行命令以创建 Loc8r 的 Angular 应用程序之前——这将创建一个名为 your-app-name 的新应用程序,并在当前文件夹中使用默认设置——您可能想要查看一些选项。</p> <p>您可以向这个命令应用许多选项,您可以通过在命令行中运行 <code>ng help</code> 来查看它们。您感兴趣的选项如下:</p> <ul> <li> <p><code>--skipGit</code>,跳过默认的 Git 初始化和第一次提交。默认情况下,<code>ng new</code> 将文件夹初始化为新的 Git 仓库,但您不需要这样做,因为您将在现有的 Git 仓库内创建它。</p> </li> <li> <p><code>--skipTests</code>,跳过安装一些测试文件。本书没有涵盖单元测试,因此您不需要这些额外的文件。有关为什么我们不涵盖这个主题的更多信息,请参阅侧边栏“测试 Angular 应用程序”。</p> </li> <li> <p><code>--directory</code>,指定您希望应用程序生成的文件夹。</p> </li> <li> <p><code>--defaults</code> 强制使用默认的 Angular 设置。</p> </li> </ul> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>测试 Angular 应用程序</strong></p> <p>测试是一个重要但非常大的主题——实际上,关于这个主题已经写了很多本书。(Manning 出版公司出版了一些非常好的书籍。)</p> <p>由于篇幅限制,本书没有涵盖测试内容。如果您想了解更多关于测试 Angular 应用程序的信息,那么您应该首先访问<a href="https://www.manning.com/books/testing-angular-applications" target="_blank"><code>www.manning.com/books/testing-angular-applications</code></a>。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>将所有这些组合起来,您将使用一个命令在名为 app_public 的新文件夹内创建一个 Angular 应用程序的模板。这个命令会安装很多东西,所以运行起来会花费一些时间,并且您需要保持在线状态才能使其工作。确保在终端中,您在 Loc8r 应用程序的根目录下运行以下命令之前:</p> <pre><code>$ ng new loc8r-public --skipGit=true --skipTests=true –defaults=true – directory app_public </code></pre> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="改进">改进</h5> <p>对于熟悉 AngularJS(Angular 1.x)的人来说,这与能够下载单个库文件开始编码的日子有很大的不同!好消息是,这种新的方法从一开始就鼓励了更好的应用程序架构。</p> <p>当一切安装完毕后,你的 app_public 文件夹的内容应该看起来像图 8.2。</p> <h5 id="图-82-新生成的-angular-项目的默认内容">图 8.2. 新生成的 Angular 项目的默认内容</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig02.jpg" alt="图片" loading="lazy"></p> <p>你可能会注意到这个项目有自己的 package.json 文件和 node_modules 文件夹,所以它看起来很像一个 Node 应用。src 文件夹是你要做大部分工作的地方。</p> <h4 id="812-运行-angular-应用">8.1.2. 运行 Angular 应用</h4> <p>这是一个完全功能性的 Angular 应用,尽管相当简约。现在运行它,看看你得到了什么,并查看其内部结构。要运行应用,请在终端中转到你的 app_public 文件夹,并运行以下命令:</p> <pre><code>$ ng serve </code></pre> <p>当你运行这个命令时,你会在终端中看到一些通知,Angular 正在构建应用程序,最后以<code>?wdm?: 编译成功</code>结束。当你看到这条消息时,你的应用已经准备好在 4200 端口上查看。要查看它,请打开你的浏览器,并转到 <a href="http://localhost:4200" target="_blank">http://localhost:4200</a>。诚然,这里没有什么特别的事情发生,但如果你查看源代码或检查元素,你应该会看到像图 8.3 那样的内容。</p> <h5 id="图-83-在浏览器中与生成的-html-一起工作的自动生成的-angular-应用">图 8.3. 在浏览器中与生成的 HTML 一起工作的自动生成的 Angular 应用</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig03_alt.jpg" alt="图片" loading="lazy"></p> <p>你会看到一些基本的 HTML 和一些引用的 JavaScript 文件。但是请注意<code>app-root</code> HTML 标签;这是不寻常的,但很重要。记住这个标签,因为当你查看源文件时,你会回到它。</p> <h4 id="813-应用程序背后的源代码">8.1.3. 应用程序背后的源代码</h4> <p>Angular 应用是用组件构建的,这些组件被编译成模块。"组件"和"模块"是常用来标记应用程序构建块的术语,但在 Angular 中,它们有特定的含义。组件处理特定的功能,而模块包含一个或多个一起工作的组件。这个默认示例是一个简单的模块,包含一个组件。</p> <p>在你的编辑器中打开 src 文件夹,你会看到几个文件和文件夹。从 src 文件夹中的 index.html 文件开始,它应该看起来像列表 8.1。</p> <h5 id="列表-81-srcindexhtml-文件的默认内容">列表 8.1. src/index.html 文件的默认内容</h5> <pre><code><!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Loc8rPublic</title> *1* <base href="/"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="icon" type="image/x-icon" href="favicon.ico"> </head> <body> <app-root></app-root> *2* </body> </html> </code></pre> <ul> <li> <p><strong><em>1</em> 标题是从应用程序名称创建的。</strong></p> </li> <li> <p><strong><em>2</em> 主体中唯一的标签是 app-root。</strong></p> </li> </ul> <p>这里除了一些基本的 HTML 框架之外,没有太多内容。你可以看到 Angular 已经为你填充了<code>title</code>标签 <em><strong>1</strong></em>,它将你在终端命令中指定的应用程序名称(<code>loc8r-public</code>)转换成了驼峰式。你还看到了<code>app-root</code>标签 <em><strong>2</strong></em>,这是你在运行的应用程序源中注意到的,但这次里面没有<code><h1></code>标签。</p> <p>深入挖掘并查看 src 文件夹中的 app 文件夹(在 src 文件夹内)。</p> <h5 id="主模块">主模块</h5> <p>记得我们说过“Angular 应用程序是用组件构建的,这些组件被编译成模块”吗?一个开始调查的好地方是模块定义。</p> <p>在 src/app 中,你会找到一个名为 app.module.ts 的文件。这个文件是 Angular 模块的中心点,所有组件都汇集在这里。目前,这个文件看起来像列表 8.2(#ch08ex02)。</p> <p>我们现在不会深入探讨每个部分的语义;我们只会给你一个每个部分功能的概述。本质上,这个文件执行以下操作:</p> <ul> <li> <p>导入应用将使用的各种 Angular 功能模块</p> </li> <li> <p>导入应用将使用的组件</p> </li> <li> <p>使用装饰器描述模块</p> </li> <li> <p>导出模块</p> </li> </ul> <p><strong>装饰器和依赖注入</strong></p> <p><em>装饰器</em>是 ES2015 和 TypeScript 提供的一种方式,用于向函数、模块和类提供元数据和注释。在 Angular 中,一个常见的用例是处理依赖注入,这是一种说“这个模块或类依赖于这个功能来运行”的方式。</p> <p>你可以在列表 8.2(#ch08ex02)中看到,你将模块 <code>BrowserModule</code> 导入到你的模块中。在这种情况下,装饰器还声明了它包含的组件以及哪个组件应该用作起点(<code>bootstrap</code>)。</p> <p>在这个文件中,跟随 <code>AppComponent</code> 的旅程,如列表 8.2(#ch08ex02)中用粗体突出显示的。首先,它从文件系统中导入(你可能从 <code>require</code> 和 Node.js 中的 <code>./</code> 语法中认出了它),然后在模块装饰器内部声明和启动。有关装饰器的更多信息,请参阅侧边栏“装饰器和依赖注入。”</p> <h5 id="列表-82-srcappappmodulets-文件默认内容">列表 8.2. src/app/app.module.ts 文件默认内容</h5> <pre><code>import { BrowserModule } from '@angular/platform-browser'; *1* import { NgModule } from '@angular/core'; *1* import { AppComponent } from './app.component'; *2* @NgModule({ *3* declarations: [ *3* AppComponent *3* ], *3* imports: [ *3* BrowserModule, *3* ], *3* providers: [], *3* bootstrap: [AppComponent] *4* }) export class AppModule { } *5* </code></pre> <ul> <li> <p><strong><em>1</em> 导入应用将使用的各种 Angular 模块</strong></p> </li> <li> <p><strong><em>2</em> 从文件系统中导入一个组件</strong></p> </li> <li> <p><strong><em>3</em> 使用装饰器描述模块 . . .</strong></p> </li> <li> <p><strong><em>4</em> . . . 包括应用程序的入口点</strong></p> </li> <li> <p><strong><em>5</em> 导出模块</strong></p> </li> </ul> <p>这是主模块,你可以从装饰器中的 <code>bootstrap</code> 行看到,应用程序本身的入口点是 <code>AppComponent</code>。你还可以从 <code>import</code> 语句中看到这个组件在文件系统中的位置——在这个例子中,它与模块定义在同一文件夹中。查看一下。</p> <h5 id="默认启动组件">默认启动组件</h5> <p>在 app_public/src/app 文件夹中,与模块文件并列,你可以看到三个 app.component 文件:</p> <ul> <li> <p>app.component.css</p> </li> <li> <p>app.component.html</p> </li> <li> <p>app.component.ts</p> </li> </ul> <p>这些文件对于任何组件都是典型的。CSS 和 HTML 文件定义了组件的样式和标记,而 TS 文件定义了 TypeScript 中的行为。</p> <p>CSS 文件为空,但 HTML 文件包含以下代码:</p> <pre><code><!--The content below is only a placeholder and can be replaced.--> <div style="text-align:center"> <h1> Welcome to {{ title }}! </h1> <img width="300" alt="Angular Logo" src="data:image/svg+xml;base64, PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcv MjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiN ERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC 45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkP SJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoi IC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTg yLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNy A4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg=="> </div> <h2>Here are some links to help you start: </h2> <ul> <li> <h2><a target="_blank" rel="noopener" href="https://angular.io/tutorial"> Tour of Heroes</a></h2> </li> <li> <h2><a target="_blank" rel="noopener" href="https://github.com/angular/angular- cli/wiki">CLI Documentation</a></h2> </li> <li> <h2><a target="_blank" rel="noopener" href="https://blog.angular.io/">Angular blog</a></h2> </li> </ul> </code></pre> <p>这段代码有些道理,因为你回想起在浏览器中检查元素时看到的一些最小 HTML 内容。在 Angular 中,双大括号用于表示数据与视图之间的绑定。在这里,变量<code>title</code>正在被绑定,以及<code><h1></code>标签的内容。要看到这个<code>title</code>变量在哪里被定义,你需要查看组件定义文件 app.component.ts,该文件在列表 8.3 中完整展示。</p> <p>此组件文件主要做三件事:</p> <ul> <li> <p>从 Angular 导入所需的内容</p> </li> <li> <p>装饰组件,为应用程序提供运行所需的信息</p> </li> <li> <p>将组件作为类导出</p> </li> </ul> <h5 id="列表-83-appcomponentts-的默认内容">列表 8.3. app.component.ts 的默认内容</h5> <pre><code>import { Component } from '@angular/core'; *1* @Component({ *2* selector: 'app-root', *2* templateUrl: './app.component.html', *2* styleUrls: ['./app.component.css'] *2* }) *2* export class AppComponent { *3* title = 'loc8r-public'; *3* } *3* </code></pre> <ul> <li> <p><strong><em>1</em> 从 Angular 核心导入组件</strong></p> </li> <li> <p><strong><em>2</em> 装饰组件</strong></p> </li> <li> <p><strong><em>3</em> 将组件作为类导出</strong></p> </li> </ul> <p>此文件很简单,但如果习惯了纯 JavaScript,其语法可能有点陌生。不过,如果你查看它的内部,你可以看到一些有趣的信息,并且可以看到各个部分是如何组合在一起的。</p> <p>从装饰器开始,你可以看到引用的 HTML 和 CSS 文件,但你也可以看到<code>selector:</code> ‘<code>app-root</code>’。啊哈!这就是你在 index.html 文件中找到的标签名!当你检查元素时,你看到了这个带有<code><h1></code>标签和一些内容的标签,这与你的 app.component.html 文件相匹配。好吧,一切都在逐渐明朗。</p> <p>接下来,你看到<code>AppComponent</code>类被导出,这你已经在模块定义中看到过导入和引导。最后,你看到<code>title</code>的定义(你在 HTML 文件中看到了组件的绑定)以及<code>loc8r-public</code>的值(你在浏览器中运行时看到过)。请注意,与<code>title</code>相关联的没有<code>var</code>、<code>const</code>或<code>let</code>,因为在类定义内部,你定义的是<em>类成员</em>而不是变量。</p> <h5 id="将一切整合在一起">将一切整合在一起</h5> <p>好吧,你在这里看到了很多,所以我们将快速回顾一下所有这些是如何联系在一起的:</p> <ul> <li> <p>组件<code>AppComponent</code>由三个文件组成:TypeScript、HTML 和 CSS。</p> </li> <li> <p>TypeScript 文件是组件的关键部分,定义了功能,引用了其他文件,并声明了它将绑定到的选择器(HTML 标签)。</p> </li> <li> <p>组件 TypeScript 文件导出<code>AppComponent</code>类。</p> </li> <li> <p>模块文件从组件 TypeScript 文件中导入了<code>AppComponent</code>类,并将其声明为应用程序的入口点。</p> </li> <li> <p>模块文件还导入了各种原生 Angular 功能。</p> </li> </ul> <p>图 8.4 展示了所有这些内容。</p> <h5 id="图-84-简单-angular-应用程序的各个部分是如何组合在一起的">图 8.4. 简单 Angular 应用程序的各个部分是如何组合在一起的</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig04_alt.jpg" alt="" loading="lazy"></p> <p>这些信息让你对如何构建这个简单的应用程序有了很好的理解。但是,当你之前在浏览器中查看源代码时,你查看的所有文件都没有被引用,你看到了一些 JavaScript 文件。发生了什么?TypeScript 文件是如何在浏览器中变成 JavaScript 的?</p> <h5 id="angular-构建过程">Angular 构建过程</h5> <p>目前,浏览器不支持 TypeScript——只支持 JavaScript——而且一些浏览器甚至还没有完全支持 ES2015。但是使用 TypeScript 可以编写更健壮的代码。尽管这个示例应用程序很小,但您可以展望未来,看看如果您有一个包含多个组件的应用程序,您将有很多单独的文件需要处理。您不希望在您的 HTML 源代码中指定所有这些文件。</p> <p>Angular 通过使用 <em>构建</em> 过程来处理这些问题,将所有单独的 TypeScript 文件合并,将它们转换为纯 JavaScript,并将它们放入一个名为 main.bundle.js 的文件中。如果您查看浏览器中的源代码,您将能够找到 <code>title = 'loc8r-public'</code>,如图 8.5 所示。</p> <h5 id="图-85-在构建的-javascript-代码中找到组件定义">图 8.5. 在构建的 JavaScript 代码中找到组件定义</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig05_alt.jpg" alt="图片" loading="lazy"></p> <p>目前,您正在使用 <code>ng serve</code> 命令来编译、构建并将 Angular 应用程序交付到浏览器上的 4200 端口。此命令在内存中运行;您不会在应用程序代码的任何地方找到这些构建文件。当涉及到构建最终版本时,您将使用不同的命令,<code>ng build</code>。关于这一点,稍后会有更多介绍。</p> <p>对于开发,<code>ng serve</code> 是完美的。它不仅为您提供了这个浏览器环境,而且还监视源代码的变化,并在变化时重新构建和刷新应用程序。您可以通过将 src/app/app.component.ts 中的 <code>'loc8r-app'</code> 更改为 <code>'I am Getting MEAN!'</code> 来看到这一功能的效果。回到浏览器中的应用程序,您会看到内容已经改变,如图 8.6 所示。</p> <h5 id="图-86-当源代码发生变化时ng-serve-会重新构建和重新加载应用程序">图 8.6. 当源代码发生变化时,<code>ng serve</code> 会重新构建和重新加载应用程序。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig06.jpg" alt="图片" loading="lazy"></p> <p><code>ng serve</code> 通过消除每次更改时手动构建和刷新的需求,帮助开发过程。</p> <p>现在您对 Angular 的了解已经足够深入,可以开始为 Loc8r 构建一些内容了。随着您的深入,您将了解更多关于 Angular 和 TypeScript 的知识,并且一切都将变得更加熟悉。</p> <h3 id="82-使用-angular-组件">8.2. 使用 Angular 组件</h3> <p>您将从构建主页的列表部分开始,您将将其嵌入到 Express 应用程序中。这是一个示例,说明您如何向现有网站添加一些 Angular 功能,这在大型企业网站上是一个常见的需求,在这些网站上您可能无法完全控制所有内容。在接下来的章节中,您将在此基础上构建知识,并了解如何在 Angular 中构建独立的单页应用程序(SPA)。</p> <p>首先,创建一个新的组件。</p> <h4 id="821-创建一个新的-home-list-组件">8.2.1. 创建一个新的 home-list 组件</h4> <p>您可以手动创建所有文件,或者可以使用 Angular CLI。您将利用 CLI 来创建一个组件骨架。在终端中,在 app_public 文件夹内,运行以下命令:</p> <pre><code>$ ng generate component home-list </code></pre> <p>此命令在 src 文件夹内创建一个名为 home-list 的新文件夹。在其内部创建 TypeScript、HTML 和 CSS 文件,并更新 app.module.ts 文件以通知模块关于新组件的信息。你还会在新的组件文件夹中看到一个 spec.ts 文件。此文件是单元测试的模板,但我们在这里不涉及它,所以你现在可以忽略它。Angular CLI 将所有这些操作的确认输出到终端。</p> <h5 id="设置为默认组件">设置为默认组件</h5> <p>新的 <code>home-list</code> 组件将是此 Angular 模块的基础,所以你需要将其设置为默认组件。你可以在 app.module.ts 文件中通过更改模块装饰器中的 bootstrap 值从 <code>AppComponent</code> 更改为 <code>HomeListComponent</code> 来完成此操作。</p> <p><code>AppComponent</code> 不再需要,所以你可以删除导入语句,从声明中删除它,甚至删除文件。app.module.ts 的更改如下所示。</p> <h5 id="列表-84-在-appmodulets-中更改到新组件">列表 8.4. 在 app.module.ts 中更改到新组件</h5> <pre><code>import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { HomeListComponent } from './home-list/home-list.component'; *1* @NgModule({ declarations: [ HomeListComponent *2* ], imports: [ BrowserModule ], providers: [], bootstrap: [HomeListComponent] *3* }) </code></pre> <ul> <li> <p><strong><em>1</em> 这一行是由 Angular CLI 添加的;删除 AppComponent 导入,因为它不再需要。</strong></p> </li> <li> <p><strong><em>2</em> 从声明数组中删除 AppComponent</strong></p> </li> <li> <p><strong><em>3</em> 将 bootstrap 值的 AppComponent 更改为 HomeListComponent</strong></p> </li> </ul> <p>如果你运行 <code>ng serve</code> 或者它仍在运行,你将在浏览器窗口中看到一个空白页面,并在 JavaScript 控制台中看到几个错误。这些错误是大量的红色文本,可能会显得令人畏惧,但第一行是有帮助的:它说,“选择器“app-home-list”没有匹配任何元素。”</p> <p>如果你回顾一下原始组件,你会记得 <code>selector</code> 定义了组件将绑定到的页面上的标签。你已经更改了组件,但没有更改页面上的标签!</p> <h5 id="设置组件的-html-标签">设置组件的 HTML 标签</h5> <p>为了确保你使用正确的标签,打开 home-list.component.ts 文件,查看组件装饰器,它应该看起来像这样:</p> <pre><code>@Component({ selector: 'app-home-list', templateUrl: './home-list.component.html', styleUrls: ['./home-list.component.css'] }) </code></pre> <p>在这里,你可以看到选择器是 <code>app-home-list</code>,所以这就是你需要使用的。如果你想有不同的命名约定,你可以更改它,但这会工作。打开 src 文件夹中的 index.html 文件,将 app-root 标签更改为 app-home-list,使其看起来像这样:</p> <pre><code><body> <app-home-list></app-home-list> </body> </code></pre> <p>现在检查浏览器——从现在开始,我们将假设你在检查浏览器时总是运行着 <code>ng serve</code> ——你会看到页面已经改变,显示 <code>home-list works!</code>,如图 8.7 所示。</p> <h5 id="图-87-确认新的-home-list-组件作为默认组件在应用程序中工作">图 8.7. 确认新的 <code>home-list</code> 组件作为默认组件在应用程序中工作</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig07.jpg" alt="" loading="lazy"></p> <p>现在你的组件已经准备好了,你可以开始工作,使其看起来应该的样子。</p> <h4 id="822-创建-html-模板">8.2.2. 创建 HTML 模板</h4> <p>使用与构建 Express 应用程序类似的方法,你将首先创建一些静态 HTML,并使用硬编码的数据。这样,你就可以确保在尝试从 API 获取数据之前,一切都在正常工作。</p> <p>幸运的是,你已经为这个组件创建了标记和样式;现在,你需要将它们转移到 Angular 上。</p> <h5 id="获取-html-标记">获取 HTML 标记</h5> <p>你不能直接从 Express 源代码复制粘贴 HTML,因为它是以 Pug 格式编写的,并且也是模板化的,以使用数据绑定。目前,你想要完整的 HTML,包括数据。</p> <p>获取 HTML 的最简单方法是运行 Express 应用程序,并在浏览器中转到主页。不同的浏览器获取 HTML 的方法略有不同,但与以下 Chrome 中的步骤相似:</p> <ol> <li> <p>在 HTML 区域右键单击,并在上下文菜单中选择检查元素。</p> </li> <li> <p>高亮显示 <code><div class="card"></code> 元素。</p> </li> <li> <p>选择复制,然后复制外部 HTML。</p> </li> </ol> <p>将此内容粘贴到 home-list.component.html 中,替换现有内容,你应该会看到以下内容。</p> <h5 id="列表-85-用于-home-listcomponenthtml-的某些静态-html-以开始">列表 8.5. 用于 home-list.component.html 的某些静态 HTML 以开始</h5> <pre><code><div class="card"> <div class="card-block"> <h4> <a href="/location/590d8dc7a7cb5b8e3f1bfc48">Costy</a> <small> <i class="far fa-star"></i> <i class="far fa-star"></i> <i class="far fa-star"></i> <i class="far fa-star"></i> <i class="far fa-star"></i> </small> <span class="badge badge-pill badge-default float- right">14.0km</span> </h4> <p class="address">High Street, Reading</p> <div class="facilities"> <span class="badge badge-warning">hot drinks</span> <span class="badge badge-warning">food</span> <span class="badge badge-warning">power</span> </div> </div> </div> </code></pre> <p>如果你在浏览器中查看保存后的内容,你将能够看到内容,但看起来不会很好。你需要添加样式。</p> <h5 id="引入样式">引入样式</h5> <p>与 HTML 一样,CSS 样式已经存在于 Express 应用程序中;你只需要访问它们。你可以更新 index.html 文件,直接从 localhost:3000 访问它们,但某些浏览器在尝试时可能会给出 CORS 警告,因为 Angular 开发应用程序和 Express 应用程序在不同的端口上运行。如果你对这个术语不熟悉,请参阅侧边栏“什么是 CORS?”。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>什么是 CORS?</strong></p> <p>浏览器不允许从不同域名访问或请求某些资源,包括请求字体文件和进行 AJAX 调用。这项政策被称为<em>同源策略</em>。</p> <p><em>CORS</em>(跨源资源共享)是一种允许这种情况发生的机制,但只能从托管资源的服务器设置。如果服务器拒绝,从浏览器端无法做任何事情来改变它。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>要允许访问资源,服务器必须设置为响应一个名为 <code>Access-Control-Allow-Origin</code> 的新 HTTP 标头,其值与请求域名匹配。</p> <p>并非所有浏览器都会为不同端口提供 CORS 警告,但为了避免这个问题,获取所有样式和字体,并将它们放入 Angular 应用程序中。从 /public 文件夹复制 webfonts、stylesheets 和 js 文件夹,并将它们粘贴到 app_public 中的 src/assets 文件夹。</p> <p>接下来,在 index.html 文件(在 app_public 中)中引用这些 CSS 和 JS 文件,如下所示。注意,你还在添加对 bootstrap 依赖项的引用。</p> <h5 id="列表-86-将-css-文件添加到-indexhtml-以供-angular-应用程序使用">列表 8.6. 将 CSS 文件添加到 index.html 以供 Angular 应用程序使用</h5> <pre><code><!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Loc8rPublic</title> <base href="/"> <link rel="stylesheet" href="assets/stylesheets/bootstrap.min.css"> <link rel="stylesheet" href="assets/stylesheets/all.min.css"> <link rel="stylesheet" href="assets/stylesheets/style.css"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="icon" type="image/x-icon" href="favicon.ico"> </head> <body> <app-home-list></app-home-list> <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRv H+8abtTE1Pi6jizo" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/ umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46 jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"> </script> <script src="assets/javascripts/bootstrap.min.js"></script> </body> </html> </code></pre> <p>添加样式后,你可以在浏览器中看到类似图 8.8 的东西。</p> <h5 id="图-88-显示静态内容并使用样式和字体的-angular-应用程序">图 8.8. 显示静态内容并使用样式和字体的 Angular 应用程序</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig08.jpg" alt="图片" loading="lazy"></p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="注意-5">注意</h5> <p>当你构建一个要嵌入另一个页面中的应用程序时,就像你现在这样,该应用程序使用包含页面的 CSS。你这里拥有的样式表副本仅用于开发,所以你的模块在构建时看起来是正确的。然而,当你构建一个 SPA 时,最终的应用程序使用 Angular 应用程序内部的样式表。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>现在你已经让你的主页组件看起来大致正确,你就可以继续通过将硬编码的数据移出,使 HTML 更智能。</p> <h4 id="823-将数据从模板移到代码中">8.2.3. 将数据从模板移到代码中</h4> <p>正如你在本章前面看到的,使用 Angular,你可以在组件代码中定义一个类成员,并通过使用花括号将其绑定到 HTML。你可以在 home-list.component.ts 中添加此内容来定义位置名称:</p> <pre><code>export class HomeListComponent implements OnInit { constructor() { } name = 'Costy'; ngOnInit() { } } </code></pre> <p>然后,你可以通过将位置名称替换为绑定,在 HTML 中显示此内容,如粗体所示:</p> <pre><code><a href="/location/590d8dc7a7cb5b8e3f1bfc48">{{name}}</a> </code></pre> <p>结果将是浏览器以相同的方式显示,但现在数据的一部分来自代码,并被绑定到模板中;它不再是硬编码的 HTML。</p> <p>这个例子很好,展示了前进的方向,但你需要更多关于位置的数据以及更好的管理方式。为此,你需要使用一个类。</p> <h5 id="定义一个类以给数据结构化">定义一个类以给数据结构化</h5> <p>在 Angular 中,<em>类</em>用于定义数据对象的结构。从你已学到的内容来看,你可以将其视为类似于简单的 Mongoose 模式——本质上,是一个列表,列出了你期望对象持有的数据片段及其类型。</p> <p>类型很重要。JavaScript 没有的一项能力是声明可以分配给给定变量的值的类型。从字符串更改为数字或布尔值很容易;JavaScript 不在乎!但 TypeScript 在乎,并且它可以通过确保你始终为每个变量使用正确的数据类型来帮助你的代码更加健壮。TypeScript 之所以被称为<em>TypeScript</em>,是有原因的。请参阅侧边栏“TypeScript 中的类型”以获取可用类型的列表。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>TypeScript 中的类型</strong></p> <p>TypeScript 接受的不同的数据类型如下:</p> <ul> <li> <p><strong><code>String</code>—</strong> 文本值。</p> </li> <li> <p><strong><code>number</code>—</strong> 任何数值;整数和小数被同等对待。</p> </li> <li> <p><strong><code>boolean</code>—</strong> 真或假。</p> </li> <li> <p><strong><code>Array</code>—</strong> 指定类型数据的数组。</p> </li> <li> <p><strong><code>enum</code>—</strong> 给一组数值命名友好名称的方式。</p> </li> <li> <p><strong><code>Any</code>—</strong> 此数据类型可以是任何东西,就像 JavaScript 默认那样。</p> </li> <li> <p><strong><code>Void</code>—</strong> 没有类型,通常用于不返回任何内容的函数。</p> </li> </ul> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>定义一个类是一个简单的任务,你将在 home-list.component.ts 文件的顶部执行此操作,在初始<code>import</code>语句之后但在组件装饰器之前。为了定义一个类并使其可访问,导出它;给它一个名字;然后列出数据项的名称及其预期的数据类型。</p> <h5 id="列表-87-在-home-listcomponentts-中定义location类">列表 8.7. 在 home-list.component.ts 中定义<code>Location</code>类</h5> <pre><code>import { Component, OnInit } from '@angular/core'; export class Location { *1* _id: string; *2* name: string; *2* distance: number; *2* address: string; *2* rating: number; *2* facilities: string[]; *3* } </code></pre> <ul> <li> <p><strong><em>1</em> 创建并导出一个名为 Location 的类</strong></p> </li> <li> <p><strong><em>2</em> 定义类成员及其类型 . . .</strong></p> </li> <li> <p><strong><em>3</em> . . . 包括字符串数组</strong></p> </li> </ul> <p>完成这些后,你已定义了你期望在位置对象中看到的数据。事实上——这很重要——使用<code>Location</code>类定义的每个对象<em>必须</em>为每个指定项提供一个值。</p> <p>现在你已经定义了一个类,你就可以使用它了。</p> <h5 id="创建位置类的实例">创建位置类的实例</h5> <p>在 TypeScript 中声明变量和类成员时,你应该声明数据类型以及名称,就像你在定义<code>Location</code>类的属性时做的那样。使用格式<code>variableName: variableType = variableValue</code>。</p> <p>例如,当你向<code>home-list</code>组件添加<code>name = 'Costy'</code>来尝试它时,你应该添加<code>name: string = 'Costy'</code>而不是。这段代码会告诉 TypeScript,<code>name</code>应该始终是字符串值。</p> <p>当创建一个类的实例或类成员变量时,你也会这样做,但在这个情况下,你需要声明类型是类的名称。列表 8.8 展示了如何向<code>home-list</code>组件添加一个类型为<code>Location</code>的<code>location</code>类成员,给它所有需要的值。通常我们会说,<em><code>location</code>是类型<code>Location</code>的实例</em>。</p> <h5 id="列表-88-在-home-listcomponentts-中使用location类定义location">列表 8.8. 在 home-list.component.ts 中使用<code>Location</code>类定义<code>location</code></h5> <pre><code>export class HomeListComponent implements OnInit { constructor() { } location: Location = { _id: '590d8dc7a7cb5b8e3f1bfc48', name: 'Costy', distance: 14.0, address: 'High Street, Reading', rating: 3, facilities: ['hot drinks', 'food', 'power'] }; ngOnInit() { } } </code></pre> <p>稍后,你将查看<code>constructor</code>和<code>ngOnInit</code>,了解它们为什么存在以及它们可以用来做什么。现在,你可以忽略它们,专注于你创建的新类成员。这个类成员包含了你需要用于主页列表的所有数据,所以接下来,你将在 HTML 中使用这些数据。</p> <h4 id="824-在-html-模板中使用类成员数据">8.2.4. 在 HTML 模板中使用类成员数据</h4> <p>快速回顾一下,你已经看到了如何通过使用花括号在 HTML 模板中绑定从组件类暴露的数据——就像<code>{{title}}</code>这样。现在你的数据稍微复杂一些,你需要访问类成员的属性,这可以通过使用标准的 JavaScript 点语法来实现。例如,<code>location.name</code>会给你<code>name</code>属性的值。</p> <p>下一个列表突出了对 HTML 模板进行的一些快速简单的更改,以便引入数据。</p> <h5 id="列表-89-在-home-listcomponenthtml-中绑定第一份数据">列表 8.9. 在 home-list.component.html 中绑定第一份数据</h5> <pre><code><div class="card"> <div class="card-block"> <h4> <a href="/location/{{location._id}}">{{location.name}}</a> <small> <i class="far fa-star"></i> <i class="far fa-star"></i> <i class="far fa-star"></i> <i class="far fa-star"></i> <i class="far fa-star"></i> </small> <span class="badge badge-pill badge-default float- right">{{location.distance}}km</span> </h4> <p class="address">{{location.address}}</p> <div class="facilities"> <span class="badge badge-warning">hot drinks</span> <span class="badge badge-warning">food</span> <span class="badge badge-warning">power</span> </div> </div> </div> </code></pre> <p>这里,你有四条单独的数据被绑定到 HTML 模板中。设施和星级评分需要更多的工作。从设施开始,遍历数据数组。</p> <h5 id="设施在-html-模板中遍历项目数组">设施:在 HTML 模板中遍历项目数组</h5> <p>在 TypeScript 文件中,你将设施定义为字符串数组,如下所示:<code>['hot drinks', 'food', 'power']</code>。现在你将看到 Angular 如何帮助你遍历这些字符串,并为数组中的每个设施创建一个<code>span</code>标签。</p> <p>秘诀是使用一个名为 <code>*ngFor</code> 的 Angular 指令。当应用于 HTML 标签并给定一个数据数组时,它会遍历数组,为每个条目创建一个元素。要访问每个项的值或属性,你需要定义一个 Angular 在遍历过程中可以使用的变量。</p> <p>以下列表显示了如何使用 <code>*ngFor</code> 指令遍历 <code>location.facilities</code> 数组,分配并使用变量 <code>facility</code> 来访问值。</p> <h5 id="列表-810-在-home-listcomponenthtml-中使用-ngfor-遍历数组">列表 8.10. 在 home-list.component.html 中使用 <code>*ngFor</code> 遍历数组</h5> <pre><code><div class="facilities"> <span *ngFor="let facility of location.facilities" class="badge badge-warning">{{facility}}</span> </div> </code></pre> <p><code>*</code> 符号很重要,因为没有它,Angular 不会执行循环。有了 <code>*</code>,它会重复 <code><span></code> 以及其中的所有内容。给定数据设施 <code>['hot drinks', 'food', 'power']</code>,输出如下</p> <pre><code><span class="badge badge-warning">hot drinks</span> <span class="badge badge-warning">food</span> <span class="badge badge-warning">power</span> </code></pre> <p>注意,Angular 创建了一些额外的注释和标签属性,你可以在 图 8.9 中看到,以及浏览器中的输出。</p> <h5 id="图-89-angular-遍历设施数组的输出">图 8.9. Angular 遍历设施数组的输出</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig09_alt.jpg" alt="" loading="lazy"></p> <p>现在设施已经完成,你可以继续处理评分星号。</p> <h5 id="评分星号使用-angular-表达式设置-css-类">评分星号:使用 Angular 表达式设置 CSS 类</h5> <p>到目前为止,你使用的数据绑定都很简单:双大括号内的一个变量名或属性。使用 Angular,你还可以在绑定中使用简单的表达式。你可以使用 <code>{{ 'Getting ' + 'MEAN' }}</code> 来连接两个字符串,或者使用 <code>{{ Math.floor(14.65) }}</code> 来执行简单的数学运算。</p> <p>对于评分星号,每个星号都使用 Font Awesome 类定义:<code>.fas.fa-star</code> 用于实心星号,<code>.far.fa-star</code> 用于轮廓。你想要使用 Angular 设置这些类,确保你有正确数量的实心和空心星号来传达评分。</p> <p>要完成这个任务,你需要使用 JavaScript 三元运算符,它是简单 <code>if</code> / <code>else</code> 表达式的简写。以第一个星号为例,你想要表达的是:“如果评分小于 1,则使星号空心;否则,使它实心。” 示例代码:</p> <pre><code>if (location.rating < 1) { return 'far'; } else { return 'fas'; } </code></pre> <p>转换为三元运算符,相同的表达式看起来是这样的:</p> <pre><code>{{ location.rating < 1 ? 'far' : 'fas' }} </code></pre> <p>将此逻辑应用到构成评分星号的 <code><i></code> 标签中,并将表达式放入 Angular 绑定中,结果如下所示。注意,每个表达式都有一个不同的数字来显示正确的星号,而且你总是输出 <code>fa-star</code>,所以你将其从表达式中移除了。</p> <h5 id="列表-811-绑定三元表达式以生成-ratings-stars-类">列表 8.11. 绑定三元表达式以生成 ratings-stars 类</h5> <pre><code><small> <i class="fa{{ location.rating < 1 ? 'r' : 's' }} fa-star"></i> <i class="fa{{ location.rating < 2 ? 'r' : 's' }} fa-star"></i> <i class="fa{{ location.rating < 3 ? 'r' : 's' }} fa-star"></i> <i class="fa{{ location.rating < 4 ? 'r' : 's' }} fa-star"></i> <i class="fa{{ location.rating < 5 ? 'r' : 's' }} fa-star"></i> </small> </code></pre> <p>你可以在浏览器中验证此代码是否正确运行,你将看到类似 图 8.10 的内容。</p> <h5 id="图-810-正确显示评分星号使用-angular-表达式绑定生成正确的类">图 8.10. 正确显示评分星号,使用 Angular 表达式绑定生成正确的类</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig10_alt.jpg" alt="" loading="lazy"></p> <p>看起来不错!你还有另一份数据要处理:距离。</p> <h5 id="使用管道格式化数据">使用管道格式化数据</h5> <p>Angular 提供了一种在绑定中格式化数据的方法,使用的是所谓的 <em>管道</em>。对于那些熟悉 AngularJS 的人来说,管道过去被称为 <em>过滤器</em>。Angular 有几个内置的管道,包括日期和货币格式化,以及大写、小写和标题化字符串转换。</p> <p>在绑定内部应用管道是通过在要绑定的变量或表达式后添加管道字符(<code>|</code>),然后跟管道名称来实现的。如果你想以大写形式显示一个位置的地址,例如,你可以添加大写绑定如下:</p> <pre><code><p class="address">{{location.address | uppercase}}</p> </code></pre> <p>你可能不想这样做,但如果你愿意的话可以!</p> <p>一个对调试有用的管道是 JSON 管道,它将一个 JSON 对象转换为字符串,以便在浏览器中显示。如果你不确定 <code>location</code> 对象中通过的数据是什么,你可以在 HTML 中的某个地方临时绑定到它并添加 JSON 管道。</p> <p>一些管道可以接受选项来定义它们的工作方式。以货币管道为例。你可以不带任何选项地应用货币管道,如下所示:</p> <pre><code>{{ 12.3485 | currency }} </code></pre> <p>这个管道假设默认货币为美元,并将数字四舍五入到最接近的美分。在这个例子中,输出将是 <code>USD12.35</code>。</p> <p>你可以向这个管道应用选项来更改货币并显示符号而不是货币代码。管道选项直接跟在管道名称后面,由冒号分隔。选项的顺序很重要。货币管道的第一个选项是货币代码本身,用于更改货币;第二个选项是一个布尔值,用于表示是否显示符号。</p> <p>如果你想要以欧元的形式显示货币,例如,并显示符号而不是代码,你可以像这样使用管道:</p> <pre><code>{{ 12.3485 | currency:'EUR':true }} </code></pre> <p>这个管道将输出 <code>€12.35</code>。</p> <p>这就是管道的工作方式,在你构建 Loc8r 应用程序的过程中,你将使用一些其他默认的管道。现在你需要将距离格式化为米或千米,为此,你需要创建一个自定义管道。</p> <h5 id="距离创建一个自定义管道">距离:创建一个自定义管道</h5> <p>在你创建一个新的管道来格式化距离之前,确保传递给它的数据反映了你将从 API 获得的数据。在你的当前模拟数据中,你有 <code>14.0</code> 以确保距离数字显示得很好。但是 API 返回的是米,所以更新 home-list.component.ts 中的距离以反映这一事实——例如 <code>14000.1234</code>。</p> <p>要为自定义管道创建模板文件,你可以使用 Angular CLI。在终端中,从 app_public 文件夹运行以下命令:</p> <pre><code>ng generate pipe distance </code></pre> <p>这个命令在 src/app 文件夹中生成两个新文件——distance.pipe.ts 和 distance.pipe.spec.ts。CLI 将导入添加到 app.module.ts 文件中。如果你想要将管道文件移动到其他地方,例如子文件夹中,你必须更新 app.module.ts 来指定它们的新位置。现在先让他们留在原地。</p> <p>模板管道文件,distance.pipe.ts,看起来是这样的:</p> <pre><code>import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ name: 'distance' }) export class DistancePipe implements PipeTransform { transform(value: any, args?: any): any { return null; } } </code></pre> <p>这种结构应该开始看起来熟悉了。你顶部有导入,然后是装饰器,最后是导出类。你在这里感兴趣的是类的内容——特别是那个<code>transform</code>函数。</p> <p>初看,这段代码看起来有点奇怪,有些复杂,到处都是冒号和<code>any</code>。但这是 TypeScript 在执行它的工作:为变量定义类型。括号内的内容(<code>value: any, args?: any</code>)表示该函数<em>接受</em>任何类型的参数<code>value</code>和其他任何类型的参数。括号后面的第三个<code>: any</code>定义了函数的<em>返回</em>值的类型。</p> <p>你想要更改这些,因为你的距离函数将接受一个数字并返回一个字符串。为此,更新<code>transform</code>函数如下:</p> <pre><code>transform(distance: number): string { return null; } </code></pre> <p>注意你已经将参数的名称更改为<code>distance</code>。你已经在 Node 中编写了格式化距离的代码,所以你可以从<code>/app_server/controllers/locations.js</code>复制它并粘贴到这里。你需要<code>isNumeric</code>辅助函数以及<code>formatDistance</code>函数的内容。完成这些后,<code>transform</code>函数看起来如下。</p> <h5 id="列表-812-在-distancepipets-中创建距离格式化管道">列表 8.12. 在 distance.pipe.ts 中创建距离格式化管道</h5> <pre><code>transform(distance: number): string { const isNumeric = function (n) { return !isNaN(parseFloat(n)) && isFinite(n); }; if (distance && isNumeric(distance)) { let thisDistance = '0'; let unit = 'm'; if (distance > 1000) { thisDistance = (distance / 1000).toFixed(1); unit = 'km'; } else { thisDistance = Math.floor(distance).toString(); } return thisDistance + unit; } else { return '?'; } } </code></pre> <p>注意,所有代码,包括辅助函数,都在<code>transform</code>函数内部。现在剩下的只是更新绑定以使用你的新管道,并从模板中删除<code>km</code>。以下是从 home-list.component.html 中更新的绑定片段:</p> <pre><code><span class="badge badge-pill badge-default float- right">{{location.distance | distance}}</span> </code></pre> <p>你也可以在浏览器中查看这个(见图 8.11)。</p> <h5 id="图-811-使用-angular-管道格式化以米为单位的距离">图 8.11. 使用 Angular 管道格式化以米为单位的距离</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig11_alt.jpg" alt="" loading="lazy"></p> <p>在组件定义中玩转数据,并测试它是否按你想象的方式显示。看起来不错,你已经设置了所有数据绑定,所有数据都是由组件定义提供的。然而,这是一个单独的项目,你的 API 将返回多个项目的数组;毕竟,它是一个列表!在下一节中,你将更新它以作为列表工作。</p> <h5 id="与类的多个实例一起工作">与类的多个实例一起工作</h5> <p>你单个位置的数据定义为<code>location</code>类型<code>Location</code>。不要大声读出来!没有数据时,结构看起来像这样:</p> <pre><code>location: Location = {}; </code></pre> <p>当你从 API 获取数据时,这将会是一个数组,因此你需要定义一个类型为<code>Location</code>的对象数组。这样做的方法是在类名后面添加方括号,使其看起来像这样的结构:</p> <pre><code>locations: Location[] = [{},{}]; </code></pre> <p>如果你采取这种方法(注意你将成员名称更改为复数<code>locations</code>,因为你正在处理一个数组)并更新你的<code>home-list</code>组件以包含两个位置,结果看起来如下。</p> <h5 id="列表-813-在-home-listcomponentts-中将位置实例化改为数组">列表 8.13. 在 home-list.component.ts 中将位置实例化改为数组</h5> <pre><code>locations: Location[] = [{ _id: '590d8dc7a7cb5b8e3f1bfc48', name: 'Costy', distance: 14000.1234, address: 'High Street, Reading', rating: 3, facilities: ['hot drinks', 'food', 'power'] }, { _id: '590d8dc7a7cb5b8e3f1bfc48', name: 'Starcups', distance: 120.542, address: 'High Street, Reading', rating: 5, facilities: ['wifi', 'food', 'hot drinks'] }]; </code></pre> <p>在将<code>location</code>重命名为<code>locations</code>并更改类型为数组后,你需要更新 HTML 模板。你已经看到如何通过使用<code>*ngFor</code>来遍历数组,这个过程没有不同。实际上,你只需要在单个位置的最外层 div(具有<code>card</code>类)上添加一个<code>*ngFor</code>属性。它看起来像这样:</p> <pre><code><div class="card" *ngFor="let location of locations"> </code></pre> <p>通过定义实例名称<code>location</code>,你不需要更改模板内部的数据绑定,因为那正是你之前使用的。</p> <p>现在你的列表中有多个项目,如图 8.12 所示。看起来不错,工作得很好。下一步是完全删除硬编码的数据,并调用 API。</p> <h5 id="图-812-更新组件以在列表中显示多个位置">图 8.12. 更新组件以在列表中显示多个位置</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig12_alt.jpg" alt="" loading="lazy"></p> <h3 id="83-从-api-获取数据">8.3. 从 API 获取数据</h3> <p>在本节中,你将了解如何从 Angular 应用程序中调用 API 以获取数据。当你得到数据时,你将显示它而不是你目前拥有的硬编码数据。</p> <p>要与 API 交互,你需要使用 Angular 应用程序的另一个构建块:一个<em>服务</em>。服务在后台工作,并不直接连接到用户界面,就像你迄今为止看到的一切。</p> <h4 id="831-创建数据服务">8.3.1. 创建数据服务</h4> <p>你创建服务的方式与你迄今为止创建组件和管道的方式相同:使用 Angular CLI。你使用与之前相同的<code>ng generate</code>命令,这次后面跟着<code>service</code>和<code>service name</code>选项。在<code>app_public</code>文件夹中,在终端中运行以下命令:</p> <pre><code>$ ng generate service loc8r-data </code></pre> <p>此命令在<code>app/src</code>文件夹中生成一个名为<code>loc8r-data</code>的新服务文件。终端确认文件已创建。</p> <p>服务通过传递给<code>Injectable</code>装饰器的<code>providedIn</code>值生成,默认为<code>'root'</code>。它取代了在应用程序根模块中显式列出服务在提供者数组中的做法,适合你的目的,所以保留默认值不变。</p> <p>在你担心包含它之前,看看模板代码并构建它。现在代码布局应该很熟悉了:导入后跟一个装饰器,然后是导出的类:</p> <pre><code>import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class Loc8rDataService { constructor() { } } </code></pre> <p>这个模板文件很简洁,这并不奇怪,因为服务除了从 API 请求数据之外,还可以用于许多其他事情。开始使用服务之前,给它一些它需要的东西。</p> <h5 id="在服务中启用-http-请求和承诺处理">在服务中启用 HTTP 请求和承诺处理</h5> <p>在 Angular 中,HTTP 请求是异步运行的,并返回可观察对象,但你在处理数据之前想等待数据完成,所以你会将它们转换为承诺。为了快速解释,请参阅侧边栏“可观察对象和承诺。”</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>可观察对象和承诺</strong></p> <p>Observables 和 Promises 是处理异步请求的绝佳方式。Observables 以流的形式返回数据块,而 Promises 返回完整的数据集。Angular 包括 RxJS 库来处理 Observables,包括将它们转换为 Promises。</p> <p>关于 RxJS 和 Observables,我们在这里无法涵盖的内容还有很多——实际上足够写一本书。查看 Luis Atencio 和 Paul P. Daniels 所著的《RxJS in Action》以了解更多信息(<a href="https://www.manning.com/books/rxjs-in-action" target="_blank"><code>www.manning.com/books/rxjs-in-action</code></a>)。</p> <p>这并不意味着你不能,或者不应该使用 Observables——只是你不在示例应用程序中。如果你想了解如何在 Loc8r 应用程序中使用 Observables,请查看附录 C。</p> <p>要设置服务以发起 HTTP 请求并返回 Promises,你需要将 HTTP 服务注入到你的服务中。你通过更新<code>loc8r-data.service.ts</code>文件顶部的导入来导入 HTTP 服务,如下所示:</p> <pre><code>import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; </code></pre> <p>第二步是将<code>HTTPClient</code>服务注入到你的服务中,这样你就可以使用它并调用 HTTP 服务的方法。为此,你使用样板代码的构造函数部分。类构造函数定义了在类实例化时提供的参数。Angular 使用它来管理依赖注入,告诉类它需要哪些其他服务或组件来运行。</p> <p>注入服务很简单:你定义参数名称和其类型。你也可以声明服务是公共的还是私有的——也就是说,它是否可以从类外部访问或保持在其内部。私有是最常见的选项。</p> <p>你注入了类型为<code>HttpClient</code>的<code>http</code>,并通过更新<code>loc8r-data.service.ts</code>中的构造函数将其保持为私有,如下所示:</p> <pre><code>constructor(private http: HttpClient) { } </code></pre> <p>最后,你需要确保<code>HttpClientModule</code>被导入并可供你的应用程序使用。通过在你的<code>app.module.ts</code>文件中添加以下导入来实现这一点:</p> <pre><code>import { HttpClientModule } from '@angular/common/http'; </code></pre> <p>在同一文件中,将模块的名称添加到<code>@NgModule</code>装饰器中的<code>imports</code>数组中,如下所示:</p> <pre><code>@NgModule({ declarations: [ HomeListComponent, DistancePipe ], imports: [ BrowserModule, HttpClientModule ], providers: [], bootstrap: [HomeListComponent] }) </code></pre> <p>通过这些小的更新,你的数据服务可以发起 HTTP 请求并返回 Promise。</p> <h5 id="创建获取数据的方法">创建获取数据的方法</h5> <p>你的服务需要一个公开的方法供组件调用。在这个阶段,该方法不需要接受任何参数,但返回一个包含位置数组的 Promise。</p> <p>在<code>Loc8rDataService</code>类内部,你想要定义一个类似这样的方法:</p> <pre><code>public getLocations(): Promise<Location[]> { // Your code will go here } </code></pre> <p>这很好,除了你的服务不知道<code>Location</code>是什么。你在<code>home-list</code>组件中定义并导出了<code>Location</code>类,所以你可以通过添加以下行将这个类导入到服务中,与其他导入一起:</p> <pre><code>import { Location } from './home-list/home-list.component'; </code></pre> <p>现在你已经准备好编写服务的核心代码了。</p> <h5 id="发起-http-请求">发起 HTTP 请求</h5> <p>向 API 发起 HTTP 请求很简单,只需要几个步骤:</p> <ol> <li> <p>构建要调用的 URL。</p> </li> <li> <p>告诉 HTTP 服务对 URL 发起请求。</p> </li> <li> <p>将 Observable 响应转换为 Promise。</p> </li> <li> <p>将响应转换为 JSON。</p> </li> <li> <p>返回响应。</p> </li> <li> <p>捕获、处理并返回错误。</p> </li> </ol> <p>将这些步骤放入代码中看起来如下所示,所有这些都是位于 loc8r-data.service.ts 中的 <code>Loc8rDataService</code> 类内部。</p> <h5 id="列表-814-在-loc8r-dataservicets-中创建并返回-http-请求">列表 8.14. 在 loc8r-data.service.ts 中创建并返回 HTTP 请求</h5> <pre><code>private apiBaseUrl = 'http://localhost:3000/api'; *1* *1* public getLocations(): Promise<Location[]> { *1* const lng: number = -0.7992599; *1* const lat: number = 51.378091; *1* const maxDistance: number = 20; *1* const url: string = `${this.apiBaseUrl}/locations?lng= *1* ${lng}&lat=${lat}&maxDistance=${maxDistance}`; *1* return this.http *2* .get(url) *3* .toPromise() *4* .then(response => response as Location[]) *5* .catch(this.handleError); *6* } *6* private handleError(error: any): Promise<any> { *6* console.error('Something has gone wrong', error); *6* return Promise.reject(error.message || error); *6* } *6* </code></pre> <ul> <li> <p><strong><em>1</em> 使用参数构建 API 的 URL,以供未来增强使用</strong></p> </li> <li> <p><strong><em>2</em> 返回 Promise</strong></p> </li> <li> <p><strong><em>3</em> 对你构建的 URL 进行 HTTP GET 调用</strong></p> </li> <li> <p><strong><em>4</em> 将 Observable 响应转换为 Promise</strong></p> </li> <li> <p><strong><em>5</em> 将响应转换为类型为 Location 的 JSON 对象</strong></p> </li> <li> <p><strong><em>6</em> 处理并返回任何错误</strong></p> </li> </ul> <p>注意,只有你需要从其他地方调用的方法 <code>getLocations</code> 是公开的;其他所有内容都被定义为私有,因此不能从外部访问。</p> <p>这不是很多代码,但它做了很多事情。正如你将看到的,在 Angular 中相当常见,在你掌握了组件、类和服务的设置之后,实际的代码可以很简单,因为许多常见任务已经将复杂性抽象掉了。</p> <p>现在数据服务已经创建,是时候从 <code>home-list</code> 组件中使用它了。</p> <h4 id="832-使用数据服务">8.3.2. 使用数据服务</h4> <p>你现在处于一个可以显示位置数组(目前是硬编码的)的 Angular 组件、可以返回位置数组的 API 以及调用该 API 并公开响应的服务都有的位置。缺失的环节是组件和服务之间的连接。</p> <h5 id="将服务导入到组件中">将服务导入到组件中</h5> <p>将服务包含到组件中需要三个步骤,所有这些步骤都在 home-list.component.ts 文件内部进行。你需要导入服务,注入服务,然后提供服务。</p> <p>首先,从 TypeScript 文件中导入服务,你需要在组件文件顶部直接在现有导入行下方进行,如下所示:</p> <pre><code>import { Component, OnInit } from '@angular/core'; import { Loc8rDataService } from '../loc8r-data.service'; </code></pre> <p>注意,你使用 <code>../</code> 定义了服务文件的相对路径,这意味着“在文件夹结构中向上提升一级。”如果你将服务文件移动到不同的位置,你需要记住更新代码中的引用。</p> <p>第二步是将服务注入到组件中,使用与在数据服务内部相同的方式。不过,这次你通过注入 <code>loc8rDataService</code> 类型为 <code>Loc8rDataService</code> 并将其保持为私有来更新 home-list.component.ts 中的构造函数,如下所示:</p> <pre><code>constructor(private loc8rDataService: Loc8rDataService) { } </code></pre> <p>到最后,home-list.component.ts 文件顶部应该看起来如下所示。</p> <h5 id="列表-815-在-home-listcomponentts-中使服务对组件可用">列表 8.15. 在 home-list.component.ts 中使服务对组件可用</h5> <pre><code>import { Component, OnInit } from '@angular/core'; import { Loc8rDataService } from '../loc8r-data.service'; *1* export class Location { _id: string; name: string; distance: number; address: string; rating: number; facilities: [string]; } @Component({ selector: 'app-home-list', templateUrl: './home-list.component.html', styleUrls: ['./home-list.component.css'] }) export class HomeListComponent implements OnInit { constructor(private loc8rDataService: Loc8rDataService) { } *2* </code></pre> <ul> <li> <p><strong><em>1</em> 从源代码文件导入服务</strong></p> </li> <li> <p><strong><em>2</em> 使用构造函数将服务注入到组件中</strong></p> </li> </ul> <p>现在服务已经创建并引入到组件中,你可以使用它了。</p> <h5 id="使用服务获取数据">使用服务获取数据</h5> <p>在类内部,创建一个私有方法来调用您的数据服务方法并处理 Promise 响应。当它有 Promise 响应时,此方法可以设置位置数组的值,这将在 HTML 中自动更新。</p> <p>为了证明这是有效的,从组件中删除所有硬编码的数据,并将<code>locations</code>声明为<code>Location</code>类型,不分配任何值。将下一列表中的代码放入 home-list.component.ts 中的<code>HomeListComponent</code>类定义中。</p> <h5 id="列表-816-在-home-listcomponentts-中创建一个调用数据服务的函数">列表 8.16. 在 home-list.component.ts 中创建一个调用数据服务的函数</h5> <pre><code>public locations: Location[]; *1* private getLocations(): void { *2* this.loc8rDataService *3* .getLocations() *3* .then(foundLocations => this.locations = foundLocations); *4* } </code></pre> <ul> <li> <p><strong><em>1</em> 将位置声明更改为其没有默认值</strong></p> </li> <li> <p><strong><em>2</em> 定义一个不接受任何参数也不返回任何内容的 getLocations 方法</strong></p> </li> <li> <p><strong><em>3</em> 调用您的数据服务方法</strong></p> </li> <li> <p><strong><em>4</em> 使用响应内容更新位置数组</strong></p> </li> </ul> <p>很好。尽管如此,这段代码仍然无法工作,因为您没有在组件中调用私有的<code>getLocations</code>方法。这一步是下一个也是最后一步,但您需要确保在正确的时间这样做。</p> <p>正如您所看到的,Angular 应用程序由许多文件组成。但您无法控制文件组合的顺序,因此无法直接控制执行顺序。您需要确保在服务可用之后才调用它,这就是那个小小的空<code>ngOnInit()</code>块发挥作用的地方。</p> <p><code>ngOnInit</code>是几个 Angular 生命周期钩子之一。当 Angular 应用程序启动和运行时,事情按照特定的顺序发生,以确保应用程序保持完整性并且始终以相同的方式进行操作。生命周期钩子允许您在特定时间监听过程并采取行动。</p> <p><code>ngOnInit</code>钩子允许您在组件初始化并准备好时进行挂钩。这是一个进行数据调用的好时机,因为您知道这样做是安全的,并且组件已经准备好运行。在 home-list.component.ts 中调用<code>local getLocations</code>方法,如下所示:</p> <pre><code>ngOnInit() { this.getLocations(); } </code></pre> <p>现在,应用程序将正确编译、运行并调用 API。太好了!但如果您在某些浏览器(尤其是 Chrome)上尝试,则不会通过任何数据。如果您打开浏览器开发者工具或 JavaScript 控制台,您会看到一个 CORS 警告,因为 Angular 应用程序和 Express API 正在不同的端口上运行。</p> <h5 id="在-express-中允许-cors-请求">在 Express 中允许 CORS 请求</h5> <p>CORS 问题不能从浏览器端修复;它必须在服务器端完成。您需要暂时改变方向,回到 Express。</p> <p>允许跨源请求很简单,幸运的是。对于发送到 API 的每个请求,您需要添加两个 HTTP 头:<code>Access-Control-Allow-Origin</code>和<code>Access-Control-Allow-Headers</code>。这些头中的第一个可以包含一个特定的 URL,您将从该 URL 允许请求,或者使用<code>*</code>作为通配符以接受来自任何域的请求。您将通过指定 URL 和端口来限制对 Angular 开发应用程序的请求。</p> <p>返回到应用程序根目录下的 app.js 文件,在路由使用之前添加以下加粗字体行:</p> <pre><code>app.use('/api', (req, res, next) => { res.header('Access-Control-Allow-Origin', 'http://localhost:4200'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); next(); }); app.use('/', indexRouter); app.use('/api', apiRouter); </code></pre> <p>此代码将两个头和它们的值添加到所有请求 API 路由的响应中。如果你仍然在端口 3000 上运行你的 Express 应用程序,在端口 4200 上运行你的 Angular 应用程序,你应该会看到你的数据通过浏览器进入,如图 8.13 所示。</p> <h5 id="图-813-你的-angular-组件现在正在显示从-api-中获取的数据">图 8.13. 你的 Angular 组件现在正在显示从 API 中获取的数据。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig13_alt.jpg" alt="图片 8.13 替代文本" loading="lazy"></p> <p>这太棒了!你轻松地构建了一个小巧的、自包含的 Angular 应用程序。这并不是一个糟糕的开始,特别是考虑到你还在本章中掌握了 TypeScript。在下一节中,你将完成这个应用程序并将其嵌入到你的 Express 应用程序中。</p> <h3 id="84-将-angular-应用程序投入生产">8.4. 将 Angular 应用程序投入生产</h3> <p>到目前为止,你一直在开发模式下使用 Angular 构建你的小应用程序。但当你停止 <code>ng serve</code> 运行后,你只剩下一些源文件,没有任何可以包含在网站中的内容。现在你需要为生产环境构建你的应用程序并将其添加到你的主页上。</p> <h4 id="841-构建用于生产的-angular-应用程序">8.4.1. 构建用于生产的 Angular 应用程序</h4> <p>在本章中,你一直在使用 <code>ng serve</code> 命令来自动重建你的应用程序并从内存中提供编译后的文件。现在你将使用 <code>ng build</code> 命令一次性编译文件并将它们保存到磁盘上。</p> <p><code>ng build</code> 命令生成所有应用程序文件并将它们放入一个名为 dist 的文件夹中。这个文件夹与 src 文件夹处于同一级别,这将是很好的,但如果你之后再次运行 <code>ng serve</code>,它将删除 dist 文件夹,这并不 helpful,正如你可以想象的那样。但你可以通过在运行命令时使用 <code>--output-path</code> 选项来更改目标文件夹。如果你这样做,你的目标文件夹在下次你决定运行 <code>ng serve</code> 时不会意外地被删除。</p> <p>构建选项太多,我们在这里无法一一介绍(你可以在终端中运行 <code>ng help</code> 来查看它们),你现在唯一需要知道的是指定你想要生产构建(而不是开发构建)的选项。你可以通过在命令中添加 <code>--prod</code> 标志来指定这一点。</p> <p>要在 app_public 文件夹中创建应用程序的生产构建版本,在终端中运行以下命令:</p> <pre><code>$ ng build --prod --output-path build </code></pre> <p>此命令启动构建过程。如果你收到一个关于找不到 <code>AppComponent</code> 的错误,那可能是因为引用被从 app.module.ts 中移除,但文件没有被删除。修复方法是删除旧的 app.component 文件,因为你不再使用它们了。</p> <p>就这样:应用程序已经为生产环境准备好了!现在你需要将其包含到 Express 应用程序中。</p> <h4 id="842-从-express-网站使用-angular-应用程序">8.4.2. 从 Express 网站使用 Angular 应用程序</h4> <p>要在主页中使用 Angular 应用,你需要在 Express 中做一些小事情。首先,你将 app_public 文件夹设置为静态路径,这意味着你可以轻松地从浏览器中引用构建文件夹中的文件。为了完成第二部分,更新 Pug 模板以包含构建文件夹中的 JavaScript 文件。</p> <p>简单,对吧?现在就来做吧!</p> <h5 id="为-angular-应用定义静态路径">为 Angular 应用定义静态路径</h5> <p>你已经看到 Express 如何定义用于静态资源的文件夹,因为生成器自动将 public 文件夹定义为静态。你可以通过在应用程序根目录中的 app.js 中复制该行来为 app_public 文件夹做同样的事情,并将名称设置为 app_public:</p> <pre><code>app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'app_public'))); </code></pre> <p>现在 Express 将从 public 或 app_public 文件夹中提供静态资源。为什么定义整个 app_public 文件夹而不是只定义 build 子文件夹为静态资源?嗯,build 文件夹还包含一个 index.html 文件。如果这个文件被包含为静态资源,它将作为主页出现,因为静态资源在检查其他 Express 路由之前被检查。这个特性将在接下来的章节中很有用,当你创建完整的 Angular 应用时,但这不是你现在想要的。现在,你想要在现有的网站上使用 Angular 应用 <em>内部</em>,因为你正在替换主页的部分。</p> <h5 id="从-html-中引用编译后的-angular-javascript-文件">从 HTML 中引用编译后的 Angular JavaScript 文件</h5> <p>你只想在主页上引用 Angular 文件,而不是在其他页面上。目前的问题是,你只能在 layout.pug 模板中包含脚本文件;所有其他模板都扩展了这个小的嵌套 HTML 部分。没有地方可以放置新的脚本标签。</p> <p>解决这个问题的简单方法是在 layout.pug 模板中创建一个新的 <code>block</code>。然后任何扩展此布局的其他页面都将有包含页面特定脚本的选择。</p> <p>在 layout.pug 中,在底部添加以下行以定义一个新的 <code>block</code>,称为 <code>scripts</code>:</p> <pre><code>block scripts </code></pre> <p>确保缩进与文件中最后的 <code>script</code> 标签相同;期望的结果是任何特定页面的脚本都将添加到 HTML 的 <code>body</code> 底部。</p> <p>接下来,在 locations-list.pug 文件中使用这个新的 <code>block</code>,并从 app_public/build 文件夹中引用所有三个 JavaScript 文件。代码看起来可能像这样,但文件名会有所不同:</p> <pre><code>block scripts script(src='/build/runtime.f0178fcd0cc34a5688b1.js') script(src='/build/polyfills.682313b6b06f69a5089e.js') script(src='/build/main.ad6de91d9e2170cae9d4.js') </code></pre> <p>你几乎完成了!你只需要在 HTML 中添加一个标签来绑定应用。</p> <h5 id="将-html-标签添加到绑定-angular-应用">将 HTML 标签添加到绑定 Angular 应用</h5> <p>如果你回想起本章前面的内容或检查源代码,你会记得你的应用被引导到一个名为 <code>app-home-list</code> 的 HTML 标签中。你现在只想用你的新持有标签替换主页的部分列表。</p> <p>在<code>locations-list.pug</code>文件中,找到<code>each location in locations</code>部分,要么删除它,要么将其注释掉以供参考。在其位置添加<code>app-home-list</code>,确保缩进正确。这部分模板看起来应该像这样:</p> <pre><code>.row .col-12.col-md-8 .error= message app-home-list </code></pre> <p>现在你已经完成了!前往浏览器;回到 localhost:3000;检查主页,现在包括你的 Angular 应用程序,它正在从你的 API 获取数据。</p> <p>如果你一切操作正确,页面应该看起来和之前一样。为了证明主页正在使用 Angular 应用程序,检查列表中的一个元素;你会看到 app-home-list 标签以及所有 Angular 相关的内容(见图 8.14)。</p> <h5 id="图-814-验证主页列表是否使用-angular-模块">图 8.14. 验证主页列表是否使用 Angular 模块</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/08fig14_alt.jpg" alt="" loading="lazy"></p> <p>我们非常喜欢这些内容!所有部件如何相互配合并协同工作真是太棒了。现在你正在迈向 MEAN。在第九章中,你将开始构建 Loc8r 作为完整的 Angular SPA。</p> <h3 id="概述-2">概述</h3> <p>在本章中,你学习了</p> <ul> <li> <p>如何使用 Angular CLI 生成应用程序模板、组件等</p> </li> <li> <p>如何使用 TypeScript 类、导入和导出,以及如何使用它们为变量定义类型</p> </li> <li> <p>如何使用 Angular 生命周期钩子控制代码执行流程</p> </li> <li> <p>如何创建和使用一些 Angular 构建块来组装应用程序,包括模块、组件、管道和服务</p> </li> <li> <p>如何使用 Angular CLI 针对生产环境进行目标定位</p> </li> </ul> <h2 id="第九章-使用-angular-构建单页应用程序基础">第九章. 使用 Angular 构建单页应用程序:基础</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>使用 Angular 路由在页面之间导航</p> </li> <li> <p>SPA 的架构最佳实践</p> </li> <li> <p>通过多个组件构建视图</p> </li> <li> <p>将 HTML 注入到绑定中</p> </li> <li> <p>利用浏览器本地的地理位置功能</p> </li> </ul> <p>你在第八章中看到了如何使用 Angular 为现有页面添加功能。在本章和第十章中,你将通过使用 Angular 创建单页应用程序(SPA)将 Angular 提升到下一个层次。你将不再在服务器上使用 Express 运行整个应用程序逻辑,而是将在浏览器中使用 Angular 运行它。关于使用 SPA 而不是传统方法的一些好处和考虑因素,请参阅第二章。到本章结束时,你将拥有 SPA 的框架,通过使用 Angular 路由到主页并显示内容,第一部分将运行起来。</p> <p>图 9.1 显示了你在整体计划中的位置,将主应用程序作为 Angular SPA 重新创建。</p> <h5 id="图-91-本章将-loc8r-应用程序重新创建为-angular-spa将应用程序逻辑从后端移动到前端">图 9.1. 本章将 Loc8r 应用程序重新创建为 Angular SPA,将应用程序逻辑从后端移动到前端。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig01_alt.jpg" alt="" loading="lazy"></p> <p>在正常的开发过程中,你可能不会在服务器上创建整个应用程序,并将其重新创建为 SPA。理想情况下,你的早期规划阶段就定义了你是否想要一个 SPA,这样你就可以从适当的技术开始。对于你现在正在经历的学习过程,这是一个很好的方法;你已经熟悉了网站的功能,布局也已经创建。这种方法让你可以专注于更令人兴奋的展望,即了解如何构建完整的 Angular 应用程序。</p> <p>在本章中,你将首先添加 Angular 路由以在页面之间导航;然后,你将创建主页和关于页面,并添加地理位置功能。随着你添加更多组件和功能,你将探索各种最佳实践,例如创建可重用组件和构建模块化应用程序。</p> <h3 id="91-在-angular-spa-中添加导航">9.1. 在 Angular SPA 中添加导航</h3> <p>在本节中,你将添加关于页面的轮廓并启用此新页面与主页之间的导航。本节的主要重点是导航;你将在第 9.4 节完成关于页面。</p> <p>你可能记得,当你配置 Express 应用程序时,你定义了 URL 路径(路由)并使用 Express 路由将路由映射到特定的功能。在 Angular 中,你将做同样的事情,但使用 Angular 路由。</p> <p>使用 Angular 路由的一个重大区别是,整个应用程序已经加载到浏览器中,因此当你导航到不同页面时,浏览器不需要每次都完全下载所有 HTML、CSS 和 JavaScript。导航对用户来说变得更快;他们通常只需要等待的是 API 调用的数据以及任何新的图片。</p> <p>第一步是将 Angular 路由导入到应用程序中。</p> <h4 id="911-导入-angular-路由并定义第一个路由">9.1.1. 导入 Angular 路由并定义第一个路由</h4> <p>Angular 路由需要导入到 app.module.ts 中,这也是你将定义路由的地方。路由是从 <code>@angular/router</code> 导入的,作为 <code>RouterModule</code>,它应该放在 app.module.ts 顶部的其他 Angular 导入中。</p> <h5 id="列表-91-在-appmodulets-中将-routermodule-添加到导入列表中">列表 9.1. 在 app.module.ts 中将 <code>RouterModule</code> 添加到导入列表中</h5> <pre><code>import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { HttpClientModule } from '@angular/common/http'; import { RouterModule } from '@angular/router'; *1* </code></pre> <ul> <li><strong><em>1</em> 导入 Angular RouterModule</strong></li> </ul> <p>在同一文件中,在 <code>@NgModule</code> 装饰器中,所有这些模块都在导入部分列出。你需要用 <code>RouterModule</code> 做同样的事情,但在这个情况下,你还需要传递你想要的路由配置。</p> <h4 id="912-路由配置">9.1.2. 路由配置</h4> <p>路由配置是一个对象数组,每个对象指定一个路由。每个路由的属性包括</p> <ul> <li> <p><strong><code>path</code>—</strong> 匹配的 URL 路径</p> </li> <li> <p><strong><code>component</code>—</strong> 要使用的 Angular 组件名称</p> </li> </ul> <p><code>path</code> 属性不应该包含任何前导或尾随斜杠,所以例如,你会有 <code>about</code> 而不是 <code>/about/</code>。它也可以是一个空字符串,表示主页。记住,<code>base href</code> 是在 index.html 文件中设置的?你将其设置为 <code>"/"</code>,因为你希望所有内容都在顶级运行,即使你设置了值,这个值也不会对路由配置产生影响。在你的路由配置中,你应该省略在 <code>base href</code> html 标签中设置的任何内容。</p> <p>你首先添加主页的配置,所以 <code>path</code> 是一个空字符串,<code>component</code> 是你现有的组件名称:<code>HomeListComponent</code>。配置被传递给 <code>RouterModule</code> 的 <code>forRoot</code> 方法。</p> <h5 id="列表-92-在-appmodulets-的装饰器中添加路由配置">列表 9.2. 在 app.module.ts 的装饰器中添加路由配置</h5> <pre><code>@NgModule({ declarations: [ HomeListComponent, DistancePipe ], imports: [ BrowserModule, HttpClientModule, RouterModule.forRoot([ *1* { path: '', *2* component: HomeListComponent *3* } ]) ], providers: [], bootstrap: [HomeListComponent] }) </code></pre> <ul> <li> <p><strong><em>1</em> 将 RouterModule 添加到 imports 中,并调用 forRoot 方法</strong></p> </li> <li> <p><strong><em>2</em> 将主页路由定义为空字符串</strong></p> </li> <li> <p><strong><em>3</em> 指定 HomeListComponent 作为此路由的组件</strong></p> </li> </ul> <p>你已经将 Angular 的 <code>RouterModule</code> 导入到你的应用程序中,并告诉它使用哪个组件作为主页。然而,你无法测试它,因为你还在指定相同的组件作为默认组件。注意 列表 9.2 中的行 <code>bootstrap: [HomeListComponent]</code>。你需要做的是创建一个新的默认组件,你将使用它来包含导航。</p> <h4 id="913-创建框架和导航的组件">9.1.3. 创建框架和导航的组件</h4> <p>为了包含导航元素,你需要创建一个新的组件并将其作为应用程序的默认组件。你还将使用此组件来包含所有框架 HTML,就像你在 Express 中的 layout.pug 所做的那样。实际上,框架 HTML 是三件事:导航、内容容器和页脚。</p> <p>首先,通过在 app_public 目录的终端中运行以下命令创建一个名为 <code>framework</code> 的新组件:</p> <pre><code>$ ng generate component framework </code></pre> <p>此命令在 app_public/src/app/ 内部创建一个新的框架文件夹,并生成你需要的所有文件。找到 framework.component.html 文件,并添加以下列表中显示的所有 HTML,这基本上就是 layout.pug 转换为 HTML 的内容。</p> <h5 id="列表-93-在-frameworkcomponenthtml-中添加框架的-html">列表 9.3. 在 framework.component.html 中添加框架的 HTML</h5> <pre><code><nav class="navbar fixed-top navbar-expand-md navbar-light"> *1* <div class="container"> <a href="/" class="navbar-brand">Loc8r</a> <button type="button" data-toggle="collapse" data-target= "#navbarMain"class="navbar-toggler"> <span class="navbar-toggler-icon"></span> </button> <div id="navbarMain" class="navbar-collapse collapse"> <ul class="navbar-nav mr-auto"> <li class="nav-item"> <a href="/about/" class="nav-link">About</a> </li> </ul> </div> </div> </nav> <div class="container content"> *2* <footer> *3* <div class="row"> <div class="col-12"> <small>© Getting Mean - Simon Holmes/Clive Harber 2018</small> </div> </div> </footer> </div> </code></pre> <ul> <li> <p><strong><em>1</em> 设置导航部分</strong></p> </li> <li> <p><strong><em>2</em> 创建主容器</strong></p> </li> <li> <p><strong><em>3</em> 将页脚嵌套在主容器内</strong></p> </li> </ul> <p>现在你已经设置了组件,你需要告诉应用程序使用它作为默认组件,并告诉它在 HTML 中的位置。</p> <p>要将新的 <code>framework</code> 组件设置为默认组件,更新 app.module.ts 中的 bootstrap 值如下,将 <code>HomeListComponent</code> 替换为 <code>FrameworkComponent</code>:</p> <pre><code>bootstrap: [FrameworkComponent] </code></pre> <p>最后,你需要更新 index.html 以使用此组件的正确标签而不是 <code>home-list</code>。打开 framework.component.ts,并在装饰器中找到选择器,它给你提供了应该使用的 HTML 标签的名称:</p> <pre><code>@Component({ selector: 'app-framework', templateUrl: './framework.component.html', styleUrls: ['./framework.component.css'] }) </code></pre> <p>因此,<code>app-framework</code>是你需要在<code>index.html</code>中拥有的标签,这样 Angular 就知道在哪里放置<code>框架</code>组件。更新<code>index.html</code>以看起来如下。</p> <h5 id="列表-94-更新indexhtml文件以使用新的framework组件">列表 9.4. 更新<code>index.html</code>文件以使用新的<code>framework</code>组件</h5> <pre><code><body> <app-framework></app-framework> *1* </body> </code></pre> <ul> <li><strong><em>1</em> 替换 app-framework 的 home-list 组件</strong></li> </ul> <p>现在您的<code>框架</code>组件已创建并链接到 HTML,您可以在浏览器中查看它,如图 图 9.2 所示。如果您还没有这样做,请记住从应用程序的根目录运行<code>nodemon</code>以启动 API,并从<code>app_public</code>文件夹运行<code>ng serve</code>以启动 Angular 应用程序的开发版本。</p> <h5 id="图-92-默认显示框架组件而不是列表">图 9.2. 默认显示<code>框架</code>组件而不是列表</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig02_alt.jpg" alt="" loading="lazy"></p> <p>你可以看到页头正在显示,所以你某种程度上是成功的。你的新组件工作!但你没有看到任何内容,即使你处于主页路由。如果你在浏览器中打开 JavaScript 控制台,你会看到一个错误:<code>Cannot find primary outlet to load 'HomeListComponent'</code>。</p> <p>你已经告诉应用程序为主页路由加载<code>HomeListComponent</code>,但没有指定它应该在 HTML 中的位置。</p> <h4 id="914-使用router-outlet定义显示内容的位置">9.1.4. 使用<code>router-outlet</code>定义显示内容的位置</h4> <p>指定路由组件的目标就像在 HTML 中添加一个空标签对一样简单,你希望它去的位置。这个特殊标签是<code><router-outlet></code>。Angular 将路由组件添加到这个标签之后,而不是像你熟悉 AngularJS 时预期的那样放在里面。</p> <p>将这个空标签对添加到框架 HTML 的正确位置——即你在 layout.pug 中放置块内容的位置——看起来如下。</p> <h5 id="列表-95-将router-outlet添加到frameworkcomponenthtml">列表 9.5. 将<code>router-outlet</code>添加到<code>framework.component.html</code></h5> <pre><code><div class="container"> <router-outlet></router-outlet> *1* <footer> <div class="row"> <div class="col-12"><small>© Getting Mean - Simon Holmes/Clive Harber 2018</small></div> </div> </footer> </div> </code></pre> <ul> <li><strong><em>1</em> 路由器出口;Angular 使用 URL 来查找组件并将其注入此处。</strong></li> </ul> <p>如果你现在查看浏览器,你会看到列表信息和框架。如图 图 9.3 所示,检查元素显示 <code><router-outlet></code> 保持为空,并且 <code><app-home-list></code> 在之后被注入。</p> <h5 id="图-93-路由组件列表信息现在正在主页路由上显示html-被注入到router-outlet标签之后">图 9.3. 路由组件——列表信息——现在正在主页路由上显示,HTML 被注入到<code><router-outlet></code>标签之后。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig03_alt.jpg" alt="" loading="lazy"></p> <p>你可以看到主页的框架和列表,但这并不是你熟悉和喜爱的主页。它缺少了页眉和侧边栏。你将在 9.2 节中回到这个页面。首先,你需要了解导航是如何工作的。</p> <h4 id="915-在页面间导航">9.1.5. 在页面间导航</h4> <p>要看到导航的实际效果,更新 Angular 应用程序,以便可以在主页和关于页面之间切换。如果你现在点击链接,它们将不会工作。为了使导航工作,你需要创建一个<code>about</code>组件,定义<code>about</code>路由,并将导航中的链接更改为 Angular 可以使用的内容。</p> <p>使用 Angular CLI 创建<code>about</code>组件现在应该很熟悉了。在终端中,在 app_public 文件夹中,运行以下<code>generate</code>命令:</p> <pre><code>$ ng generate component about </code></pre> <p>此命令在 app_public/src/app/about 中创建新的组件。现在您可以保持它不变,以便专注于导航。在第 9.4 节中,您将返回到 About 页面并完全构建它。</p> <h5 id="定义新的路由">定义新的路由</h5> <p>与主页路由一样,您需要在 app.module.ts 中配置 About 页面的路由。您需要指定路由的路径以及组件的名称。路径是<code>'about'</code>。请记住,您不需要任何前导或尾随斜杠。</p> <p>为了确保您正确地得到了组件的名称,您可以打开 about.component.ts 以在导出行中找到它:<code>export class AboutComponent implements OnInit</code>。</p> <p>知道路径和组件名称后,您可以在 app.module.ts 中添加新的路由。</p> <h5 id="列表-96-在-appmodulets-中定义新的about路由">列表 9.6. 在 app.module.ts 中定义新的<code>about</code>路由</h5> <pre><code>RouterModule.forRoot([ { path: '', component: HomeListComponent }, { path: 'about', component: AboutComponent } ]) </code></pre> <p>如果您直接在浏览器中打开 localhost:4200/about,您会得到 About 页面,但导航链接目前还不能正常工作。您将在下一节中修复它们。</p> <h5 id="设置-angular-导航链接">设置 Angular 导航链接</h5> <p>当您使用路由中定义的链接时,Angular 不希望在<code><a></code>标签中看到<code>href</code>属性;相反,它寻找一个名为<code>routerLink</code>的指令。Angular 使用您提供给<code>routerLink</code>的值来创建<code>href</code>属性。</p> <p>定义路由路径的规则也适用于设置<code>routerLink</code>的值。您不需要包含前导或尾随斜杠,并且请记住,您不需要重复在<code>base href</code>中设置的任何内容。</p> <p>根据这些规则,更新 framework.component.html 中的导航链接看起来像下面的列表。将<code>href</code>属性替换为<code>routerLink</code>指令,确保值与在 app.module.ts 中的路由定义匹配。</p> <h5 id="列表-97-在-frameworkcomponenthtml-中定义导航-router-链接">列表 9.7. 在 framework.component.html 中定义导航 router 链接</h5> <pre><code><a routerLink="" class="navbar-brand">Loc8r</a> *1* <div id="navbarMain" class="navbar-collapse collapse"> <ul class="navbar-nav mr-auto"> <li class="nav-item"> <a routerLink="about" class="nav-link">About</a> *2* </li> </ul> </div> </code></pre> <ul> <li> <p><strong><em>1</em> 空的 routerLink 路径指向默认组件</strong></p> </li> <li> <p><strong><em>2</em> about 路径用于导航到 about 组件</strong></p> </li> </ul> <p>将此代码放置并保存后,您可以在两个链接之间点击,如图图 9.4 所示。</p> <h5 id="图-94-使用导航按钮在主页和-about-页面之间切换一个-angular-单页应用">图 9.4. 使用导航按钮在主页和 About 页面之间切换——一个 Angular 单页应用!</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig04_alt.jpg" alt="图片" loading="lazy"></p> <p>注意,浏览器中的 URL 会像往常一样改变,但在页面之间切换时页面不会重新加载或闪烁。如果您在切换这两个页面时检查网络流量,您将只会看到对 API 的调用。您还可以使用浏览器中的后退和前进按钮,网站将像传统网站一样工作。恭喜您——您已经构建了一个单页应用!</p> <p>在继续之前,快速通过添加活动样式来改进导航。</p> <h4 id="916-添加活动导航样式">9.1.6. 添加活动导航样式</h4> <p>在网页设计中,将<code>active</code>类应用于导航项是标准做法,这样当前页面的链接看起来会略有不同——一个简单的视觉提示,告诉用户他们所在的位置。你的导航中只有一个链接,但这个过程仍然值得。</p> <p>Twitter Bootstrap 定义了辅助类来创建活动导航状态;你在活动链接上设置<code>active</code>类。由于这是一个常见的需求,Angular 也有一个辅助工具:一个名为<code>routerLinkActive</code>的指令。</p> <p>在包含路由链接的<code><a></code>标签上,你可以添加<code>routerLinkActive</code>指令并指定你想要用于活动链接的类名。你将在<code>framework.component.html</code>中使用<code>active</code>类:</p> <pre><code><a routerLink="about" routerLinkActive="active" class="nav-link">About</a> </code></pre> <p><code>routerLinkActive</code>属性的位置很重要。如果它似乎不起作用,请确保你在<code>class</code>属性之前包含了它。</p> <p>现在,当你访问关于页面时,<code><a></code>标签被添加了一个额外的<code>active</code>类,Bootstrap 将其显示为更强烈的白色,正如你在图 9.5 中可以看到的那样。</p> <h5 id="图-95-查看active类的实际应用angular-在导航更改时向链接添加和移除它">图 9.5. 查看<code>active</code>类的实际应用;Angular 在导航更改时向链接添加和移除它。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig05_alt.jpg" alt="图片描述" loading="lazy"></p> <p>有了这些,你已经涵盖了 Angular 路由的基础知识,为你的 SPA 创建了工作导航。你可以看到视图显然需要一些工作,所以你将在下一两个部分中关注这一点。</p> <h3 id="92-使用多个嵌套组件构建模块化应用">9.2. 使用多个嵌套组件构建模块化应用</h3> <p>在本节中,你将专注于在 Angular 中构建熟悉的主页。为了设定成功的基础——并遵循 Angular 架构最佳实践——你将通过创建几个新组件并根据需要嵌套它们来实现这一点。这个过程为你提供了一个模块化应用程序,因此你可以在应用程序的不同地方重用这些部分。</p> <p>主页有三个主要部分:</p> <ul> <li> <p>页面标题</p> </li> <li> <p>位置列表</p> </li> <li> <p>侧边栏</p> </li> </ul> <p>你已经将位置列表构建为一个组件;那就是你的<code>home-list</code>组件。你需要创建标题和侧边栏作为两个新的组件。</p> <p>你还需要将这三个组件都包裹在主主页组件中,以确保它们可以一起工作,具有正确的布局,并且可以通过 Angular 路由进行导航。图 9.6 显示了这些组件如何在主页设计之上叠加。你有一个外部的<code>framework</code>组件,它包含了一切。在这个组件内部嵌套的是<code>homepage</code>组件,用于控制内容区域,其中包含页面标题、列表和侧边栏组件。</p> <h5 id="图-96-将主页布局分解为组件使用两层嵌套">图 9.6. 将主页布局分解为组件,使用两层嵌套</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig06_alt.jpg" alt="图片描述" loading="lazy"></p> <p>这就是你要构建的内容。你将从<code>homepage</code>组件开始。</p> <h4 id="921-创建主主页组件">9.2.1. 创建主主页组件</h4> <p><code>homepage</code> 组件包含主页的所有 HTML 和信息——从标题到页脚之间的所有内容。这个组件是你将在 Angular 的路由器中引用的,以便在任何人请求主页时使用。</p> <p>首先,使用 Angular CLI 以熟悉的方式生成组件(从 app_public 文件夹的终端中):</p> <pre><code>$ ng generate component homepage </code></pre> <p>接下来,通过更新 app.module.ts 来告诉路由器使用此组件作为默认主页路由,如下所示:</p> <pre><code>RouterModule.forRoot([ { path: '', component: HomepageComponent }, { path: 'about', component: AboutComponent } ]) </code></pre> <p>在 homepage.component.html 中,在浏览器中检查它之前,暂时放置 <code>home-list</code> 组件的选择器:</p> <pre><code><app-home-list></app-home-list> </code></pre> <p>如果你通过浏览器查看应用程序,它看起来和之前一样,有导航栏、页脚和中间的列表部分。</p> <p>但你现在想看到主页的所有内容;这就是页面标题、主要内容以及侧边栏。将 Pug 模板中的框架代码转换为 HTML 的样子如下所示。请注意,你在这里放置了 <code>app-home-list</code> 组件以显示列表部分。</p> <h5 id="列表-98-将主页内容的-html-放入-homepagecomponenthtml">列表 9.8. 将主页内容的 HTML 放入 homepage.component.html</h5> <pre><code><div class="row banner"> *1* <div class="col-12"> <h1>Loc8r <small>Find places to work with wifi near you!</small> </h1> </div> </div> <div class="row"> <div class="col-12 col-md-8"> *2* <div class="error"></div> <app-home-list></app-home-list> </div> <div class="col-12 col-md-4"> *3* <p class="lead">Looking for wifi and a seat? Loc8r helps you find places to work when out and about. Perhaps with coffee, cake or a pint? Let Loc8r help you find the place you're looking for.</p> </div> </div> </code></pre> <ul> <li> <p><strong><em>1</em> 页面标题</strong></p> </li> <li> <p><strong><em>2</em> 主页列表组件的容器</strong></p> </li> <li> <p><strong><em>3</em> 侧边栏</strong></p> </li> </ul> <p>现在,当你通过浏览器查看页面时,你会得到类似图 9.7 的东西——你熟悉的老主页!</p> <h5 id="图-97-在-angular-中主页组件中硬编码了页面标题和侧边栏">图 9.7. 在 Angular 中,主页组件中硬编码了页面标题和侧边栏</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig07_alt.jpg" alt="" loading="lazy"></p> <p>所有的东西都在那里并且工作正常,包括嵌套在 <code>homepage</code> 组件内的 <code>home-list</code> 组件。但你可以做得更好。页面标题和侧边栏在其他页面上重复出现,尽管文本内容不同。你可以遵循一些架构最佳实践,并尝试通过创建可重用组件来避免代码重复。</p> <h4 id="922-创建和使用可重用子组件">9.2.2. 创建和使用可重用子组件</h4> <p>你将创建页面标题和侧边栏作为新组件,这样你就不需要将 HTML 复制到多个视图中。如果网站增长到有数十或数百个页面,你就不想在每个布局中重复相同的 HTML。如果你需要在未来更新 HTML,这种情况会更糟。在一个地方更新 HTML 要容易得多,而且也更不容易出错或遗漏。</p> <p>你将使组件“智能”,以便你可以传递不同的内容来显示。在你的情况下,可重用组件都是关于 HTML 而不是内容。从页面标题开始。</p> <h5 id="创建页面标题组件">创建页面标题组件</h5> <p>第一步是发出熟悉的组件生成命令(在终端中):</p> <pre><code>$ ng generate component page-header </code></pre> <p>在执行上述命令后,从主页 HTML 中复制标题内容并将其粘贴到 page-header.component.html:</p> <pre><code><div class="row banner"> <div class="col-12"> <h1>Loc8r <small>Find places to work with wifi near you!</small> </h1> </div> </div> </code></pre> <p>然后,你需要在家页面的 .component.html 中引用此内容,而不是目前存在的完整 HTML。为此,你需要正确的标签,你可以通过查找 page-header.component.ts 文件中的选择器来找到它。在这种情况下,选择器是 app-page-header,所以你将在 <code>homepage</code> 组件 HTML 中使用它。</p> <h5 id="列表-99-替换-homepagecomponenthtml-中的页面头部-html">列表 9.9. 替换 homepage.component.html 中的页面头部 HTML</h5> <pre><code><app-page-header></app-page-header> <div class="row"> <div class="col-12 col-md-8"> <div class="error"></div> <app-home-list>Loading...</app-home-list> </div> <div class="col-12 col-md-4"> <p class="lead">Looking for wifi and a seat? Loc8r helps you find places to work when out and about. Perhaps with coffee, cake or a pint? Let Loc8r help you find the place you\'re looking for.</p> </div> </div> </code></pre> <p>很好的开始。你已经创建了新的 <code>page-header</code> 组件,但它仍然有硬编码的内容。接下来,你将从 <code>homepage</code> 组件传递数据到页面头部。</p> <h5 id="在主页上定义页面头部组件的数据">在主页上定义页面头部组件的数据</h5> <p>你想在 <code>homepage</code> 组件内部设置 <code>page-header</code> 组件的 <code>homepage</code> 实例的数据,以便你可以传递它。</p> <p>定义数据很简单。在 <code>homepage</code> 组件类定义中,你创建一个新的成员来保存数据。你将创建一个名为 <code>pageContent</code> 的成员,并将头部嵌套在其中,如下一列表所示。类成员是一个简单的 JavaScript 对象,包含文本数据。请注意,在这个片段中,<code>strapline</code> 内容被缩短以节省树木。</p> <h5 id="列表-910-在-homepagecomponentts-中定义主页页面头部内容">列表 9.10. 在 homepage.component.ts 中定义主页页面头部内容</h5> <pre><code>export class HomepageComponent implements OnInit { constructor() { } ngOnInit() { } public pageContent = { *1* header: { title: 'Loc8r', strapline: 'Find places to work with wifi near you!' } }; } </code></pre> <ul> <li><strong><em>1</em> 创建一个新的类成员来保存页面头部内容</strong></li> </ul> <p><code>header</code> 被嵌套在 <code>pageContent</code> 中,因为不久你还将添加侧边栏内容,将它们都放在同一个成员中会使代码更整洁。接下来,你将此数据传递给 <code>page-header</code> 组件。</p> <h5 id="将数据传递到页面头部组件">将数据传递到页面头部组件</h5> <p>主页类的成员 <code>pageContent</code> 现在可供主页 HTML 使用,但你不想直接使用这些数据,而是想通过 <code>page-header</code> 组件传递它。数据通过 HTML 中的特殊绑定传递给嵌套组件。绑定的名称是在嵌套组件中定义的一个属性,所以可以是任何你想要的。</p> <p>你将绑定页面头部内容到名为 <code>content</code> 的属性。(这个属性目前还不存在;你将在下一步中定义它。)在 homepage.component.html 中,更新 <code><app-page-header></code> 以包括绑定:</p> <pre><code><app-page-header [content]="pageContent.header"></app-page-header> </code></pre> <p>注意,尽管方括号可能不是有效的 HTML,但在这里没关系,因为 Angular 在将 HTML 传递给浏览器之前会移除它们。浏览器实际接收到的 HTML 可能类似于 <code><app-page-header_ngcontent-c6="" _nghost-c2=""></code>,这是有效的 HTML。</p> <p>你现在正在从 <code>homepage</code> 组件传递数据到嵌套的 <code>page-header</code> 组件;你需要更新页面头部以接受和使用这些数据。</p> <h5 id="在组件中接受和显示传入的数据">在组件中接受和显示传入的数据</h5> <p>你需要告诉 <code>pageHeader</code> 组件 <code>content</code> 应该作为一个属性存在,并从外部获取值。技术上讲,<code>content</code> 是组件的一个 <em>输入</em>。</p> <p>类的任何属性都需要定义,这个属性也不例外。它与之前你所看到的不同之处在于,它需要被定义为输入属性。为了做到这一点,你需要从 Angular 核心导入<code>Input</code>,并在定义<code>content</code>成员时将其用作装饰器。</p> <h5 id="列表-911-告诉page-headercomponentts接受内容作为input">列表 9.11. 告诉<code>page-header.component.ts</code>接受内容作为<code>Input</code></h5> <pre><code>import { Component, OnInit, Input } from '@angular/core'; *1* @Component({ selector: 'app-page-header', templateUrl: './page-header.component.html', styleUrls: ['./page-header.component.css'] }) export class PageHeaderComponent implements OnInit { @Input() content: any; *2* constructor() { } ngOnInit() { } } </code></pre> <ul> <li> <p><strong><em>1</em> 从 Angular 核心导入 Input</strong></p> </li> <li> <p><strong><em>2</em> 将内容定义为类成员,该成员接受任何类型的数据输入</strong></p> </li> </ul> <p>当完成这些操作后,组件将理解从<code>homepage</code>组件发送给它的数据,你将能够显示它。将<code>page-header.component.html</code>中的硬编码文本替换为相关的 Angular 数据绑定。</p> <h5 id="列表-912-在page-headercomponenthtml中放置数据绑定">列表 9.12. 在<code>page-header.component.html</code>中放置数据绑定</h5> <pre><code><div class="row banner"> <div class="col-12"> <h1>{{ content.title }} <small>{{ content.strapline }}</small> </h1> </div> </div> </code></pre> <p>现在你有一个完全可重用的页眉组件,它可以显示从父组件发送给它的数据。这个组件是 Angular 应用程序架构的重要构建块。你将通过为侧边栏做同样的事情来巩固这个过程,以便你可以完成主页,但在过程中你会遇到一点小麻烦。</p> <h5 id="创建侧边栏组件">创建侧边栏组件</h5> <p>我们不会过多地讨论设置侧边栏组件的步骤,因为你已经在本章早期完成了页眉的设置。</p> <p>首先,生成组件:</p> <pre><code>$ ng generate component sidebar </code></pre> <p>第二,从<code>homepage.component.html</code>中获取侧边栏 HTML,并将其粘贴到<code>sidebar.component.html</code>中。当你这样做时,将文本内容替换为对<code>content</code>的绑定:</p> <pre><code><div class="col-12 col-md-4"> <p class="lead">{{ content }}</p> </div> </code></pre> <p>第三,通过从 Angular 核心导入<code>Input</code>并定义类型为<code>string</code>的<code>content</code>属性,使用<code>@Input</code>装饰器允许侧边栏组件接收数据:</p> <pre><code>import { Component, OnInit, Input } from '@angular/core'; @Component({ selector: 'app-sidebar', templateUrl: './sidebar.component.html', styleUrls: ['./sidebar.component.css'] }) export class SidebarComponent implements OnInit { @Input() content: string; constructor() { } ngOnInit() { } } </code></pre> <p>第四,更新<code>homepage.component.ts</code>中的<code>pageContent</code>成员以包含侧边栏数据:</p> <pre><code>public pageContent = { header : { title : 'Loc8r', strapline : 'Find places to work with wifi near you!' }, sidebar : 'Looking for wifi and a seat? Loc8r helps you find places to work when out and about. Perhaps with coffee, cake or a pint? Let Loc8r help you find the place you\'re looking for.' }; </code></pre> <p>第五,更新<code>homepage.component.html</code>以使用新的侧边栏组件,并通过<code>content</code>传递数据:</p> <pre><code><app-page-header [content]="pageContent.header"></app-page-header> <div class="row"> <div class="col-12 col-md-8"> <div class="error"></div> <app-home-list>Loading...</app-home-list> </div> <app-sidebar [content]="pageContent.sidebar"></app-sidebar> </div> </code></pre> <p>所有工作都完成了!但是,它真的完成了吗?如果你在浏览器中查看这个页面,你会注意到无论你如何调整浏览器窗口的宽度,侧边栏总是位于内容下方(见图 9.8)。</p> <h5 id="图-98-新的侧边栏组件已经添加并正在工作但它位于主内容下方而不是它应该所在的位置">图 9.8. 新的侧边栏组件已经添加并正在工作,但它位于主内容下方,而不是它应该所在的位置。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig08_alt.jpg" alt="图片 9.8" loading="lazy"></p> <p>侧边栏的位置由<code><div class="col-12 col-md-4"></code>元素中的类定义。但通过将此内容放入组件中,你将其包裹在一个新的标签<code><app-sidebar></code>中,因此 Bootstrap 将侧边栏作为新的一行抛到下方。</p> <p>这个问题是需要注意的,尤其是在你嵌套组件时。但它是很容易修复的。</p> <h5 id="使用-angular-元素和-bootstrap-布局类进行工作">使用 Angular 元素和 Bootstrap 布局类进行工作</h5> <p>你遇到的问题是浏览器现在看到以下 HTML 标记生成:</p> <pre><code><div class="col-12 col-md-8"> <app-home-list>Loading...</app-home-list> </div> <app-sidebar [content]="pageContent.sidebar"> <div class="col-12 col-md-4"> <p class="lead">{{ content }}</p> </div> </app-sidebar> </code></pre> <p>侧边栏的 Bootstrap <code>col</code> 类在层次结构中的级别错误,因此 <code><app-sidebar></code> 不论浏览器大小都被视为全宽列。你需要做的就是将 sidebar.component.html 中的 <code><div></code> 类移动到 homepage.component.html 中的 <code><app-sidebar></code>,这样 homepage.component.html 就会看起来像以下这样。</p> <h5 id="列表-913将侧边栏类移动到-homepagecomponenthtml">列表 9.13。将侧边栏类移动到 homepage.component.html</h5> <pre><code><app-page-header [content]="pageContent.header"></app-page-header> <div class="row"> <div class="col-12 col-md-8"> <app-home-list>Loading...</app-home-list> </div> <app-sidebar class="col-12 col-md-4" [content]="pageContent.sidebar"> </app-sidebar> </div> </code></pre> <p>完成这些后,你不再需要侧边栏标记中的 <code><div></code>;你可以保留 <code><p></code> 和内容。现在 sidebar.component.html 看起来是这样的:</p> <pre><code><p class="lead">{{ content }}</p> </code></pre> <p>通过这个修复,主页应该看起来一切正常,如图 9.9 所示。主页看起来不错!不过到目前为止,还有一些东西缺失。如果 Loc8r 能够告诉你在哪里,并找到附近的地方,那不是很好吗?你将在下一节中将地理位置添加到主页中。</p> <h5 id="图-99完成的主页渲染正确由多个嵌套组件构成">图 9.9。完成的主页渲染正确,由多个嵌套组件构成</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig09_alt.jpg" alt="图片 9.9 的替代文本" loading="lazy"></p> <h3 id="93添加地理位置以查找附近的地方">9.3。添加地理位置以查找附近的地方</h3> <p>Loc8r 的主要前提是它具有位置感知能力,能够找到靠近用户的地方。到目前为止,你通过将地理坐标硬编码到 API 请求中来进行欺骗。你现在将通过添加 HTML5 地理位置来改变这一点。</p> <p>要使地理位置正常工作,你需要做以下几件事:</p> <ul> <li> <p>将对 HTML5 位置 API 的调用添加到你的 Angular 应用程序中。</p> </li> <li> <p>查询 Express API 以确定是否提供位置详情。</p> </li> <li> <p>将坐标传递给你的 Angular 数据服务,移除硬编码的位置。</p> </li> <li> <p>输出消息,让用户知道发生了什么。</p> </li> </ul> <p>从顶部开始,你将通过创建一个新的服务来添加地理位置 JavaScript 函数。</p> <h4 id="931创建-angular-地理位置服务">9.3.1。创建 Angular 地理位置服务</h4> <p>能够找到用户的位置感觉像是一种可重用的功能,在这个和其他项目中。为了将其作为独立的功能分离出来,你将创建另一个服务来持有它。一般来说,任何与 API 交互、运行逻辑或执行操作的代码都应该外部化到服务中。让组件控制服务而不是执行功能。</p> <p>要创建地理位置服务的框架,请在终端中从 app_public 运行以下命令:</p> <pre><code>$ ng generate service geolocation </code></pre> <p>我们现在不会深入探讨 HTML5/JavaScript 地理位置 API 的工作细节。现代浏览器在 <code>navigator</code> 对象上有一个你可以调用的方法来找到用户的坐标。用户必须为此操作提供权限。该方法接受两个参数(一个成功回调和一个错误回调),如下所示:</p> <pre><code>navigator.geolocation.getCurrentPosition(cbSuccess, cbError); </code></pre> <p>您需要在一个公开的方法中公开标准的地理位置脚本,以便您可以使用它作为服务。当您在这里时,您还将错误处理针对当前浏览器可能不支持此功能的可能性。以下列表显示了 geolocation.service.ts 的完整代码,提供了一个公开的 <code>getPosition</code> 方法,其他组件可以调用。</p> <h5 id="列表-914-使用回调获取当前位置创建-geolocation-服务">列表 9.14. 使用回调获取当前位置创建 <code>geolocation</code> 服务</h5> <pre><code>import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class GeolocationService { constructor() { } public getPosition(cbSuccess, cbError, cbNoGeo): void { *1* if (navigator.geolocation) { *2* navigator.geolocation.getCurrentPosition(cbSuccess, cbError); *2* } else { *3* cbNoGeo(); *3* } } } </code></pre> <ul> <li> <p><strong><em>1</em> 定义一个名为 getPosition 的公共成员,该成员接受三个回调函数,用于成功、错误和不支持</strong></p> </li> <li> <p><strong><em>2</em> 如果支持地理位置,则调用本地方法,传递成功和错误回调</strong></p> </li> <li> <p><strong><em>3</em> 如果不支持地理位置,则调用不支持回调</strong></p> </li> </ul> <p>那段代码为您提供了一个地理位置服务,其中有一个公开的方法 <code>getPosition</code>,您可以传递三个回调函数。此服务检查浏览器是否支持地理位置,然后尝试获取坐标。然后,根据地理位置是否受支持以及是否能够获取坐标,服务会调用三个不同的回调之一。</p> <p>下一步是将服务添加到应用程序中。</p> <h4 id="932-将地理位置服务添加到应用程序">9.3.2. 将地理位置服务添加到应用程序</h4> <p>要使用您的新地理位置服务,您需要将其导入 <code>home-list</code> 组件中,就像您导入数据服务时做的那样。您需要执行以下操作:</p> <ul> <li> <p>将服务导入组件中。</p> </li> <li> <p>将服务添加到装饰器中的提供者。</p> </li> <li> <p>将服务添加到类构造函数中。</p> </li> </ul> <p>以下列表以粗体突出显示您需要添加到 <code>home-list</code> 组件定义中以便导入和注册地理位置服务的修改。</p> <h5 id="列表-915-更新-home-listcomponentts-以引入地理位置服务">列表 9.15. 更新 home-list.component.ts 以引入地理位置服务</h5> <pre><code>import { Component, OnInit } from '@angular/core'; import { Loc8rDataService } from '../loc8r-data.service'; import { GeolocationService } from '../geolocation.service'; *1* export class Location { _id: string; name: string; distance: number; address: string; rating: number; facilities: string[]; } @Component({ selector: 'app-home-list', templateUrl: './home-list.component.html', styleUrls: ['./home-list.component.css'] }) export class HomeListComponent implements OnInit { constructor( private loc8rDataService: Loc8rDataService, private geolocationService: GeolocationService *2* ) { } </code></pre> <ul> <li> <p><strong><em>1</em> 导入地理位置服务</strong></p> </li> <li> <p><strong><em>2</em> 将服务传递到类构造函数</strong></p> </li> </ul> <p>完成此操作后,您将能够在 <code>home-list</code> 组件内部使用地理位置服务。</p> <h4 id="933-从-home-list-组件使用地理位置服务">9.3.3. 从 home-list 组件使用地理位置服务</h4> <p><code>home-list</code> 组件现在可以访问地理位置服务,所以请使用它!记住,服务中的 <code>getPosition</code> 方法接受三个回调函数,因此您在调用该方法之前需要创建这些函数。</p> <p>由于地理位置过程可能需要几秒钟才能开始搜索数据库中的位置,因此您还希望向用户提供一些有用的消息,以便他们知道正在发生什么。</p> <p>您已经在 HTML 中有一个用于消息的元素,但它目前位于 homepage.component.html 中,您需要将其放在 home-list.component.html 中。在 homepage HTML 中找到 <code><div class="error"></div></code> 并将其删除。然后,将其粘贴到 home-list.component.html 的顶部,添加一个绑定,以便您可以显示如下消息:</p> <pre><code><div class="error">{{message}}</div> <div class="card" *ngFor="let location of locations"> </code></pre> <p>使用此代码,您将能够使用消息绑定来让用户了解正在发生的事情。现在您已经准备好创建回调函数。</p> <h5 id="创建地理位置回调函数">创建地理位置回调函数</h5> <p>在组件内部创建三个新的私有成员,每个成员对应可能的地理位置结果:</p> <ul> <li> <p>地理位置尝试成功</p> </li> <li> <p>地理位置尝试失败</p> </li> <li> <p>地理位置不受支持</p> </li> </ul> <p>您还将更新显示给用户的消息,让他们知道系统正在做什么。这条消息尤其重要,因为地理位置可能需要一秒钟或两秒钟。</p> <p>成功回调是现有的<code>getLocations</code>方法,其中添加了一些额外的消息设置:其他两个设置错误消息,如下所示列表。由于您将在这些新函数内部使用消息绑定,您还需要将其定义为类的字符串类型属性。</p> <h5 id="列表-916-在-home-listcomponentts-中设置地理位置回调函数">列表 9.16. 在 home-list.component.ts 中设置地理位置回调函数</h5> <pre><code>export class HomeListComponent implements OnInit { constructor( private loc8rDataService: Loc8rDataService, private geolocationService: GeolocationService ) { } public locations: Location[]; public message: string; *1* private getLocations(position: any): void { this.message = 'Searching for nearby places'; *2* this.loc8rDataService .getLocations() .then(foundLocations => { this.message = foundLocations.length > 0 ? '' : 'No locations found'; *2* this.locations = foundLocations; }); } private showError(error: any): void { *3* this.message = error.message; *3* }; *3* private noGeo(): void { *4* this.message = 'Geolocation not supported by this browser.'; *4* }; *4* ngOnInit() { this.getLocations(); } } </code></pre> <ul> <li> <p><strong><em>1</em> 定义了类型为字符串的消息属性</strong></p> </li> <li> <p><strong><em>2</em> 在现有的 getLocations 成员内部设置一些消息</strong></p> </li> <li> <p><strong><em>3</em> 如果地理位置受支持但未成功时运行的函数</strong></p> </li> <li> <p><strong><em>4</em> 浏览器不支持地理位置时的运行函数</strong></p> </li> </ul> <p>您已经有了三个回调函数,用于成功、失败和错误。现在您需要使用您的地理位置服务,而不是在组件的<code>ngOnInit()</code>上调用<code>getLocations()</code>。</p> <h5 id="调用地理位置服务">调用地理位置服务</h5> <p>要调用您的地理位置服务的<code>getPosition</code>方法,您需要在<code>home-list</code>组件中创建一个新成员,并在<code>init</code>时调用它,而不是直接调用<code>getLocations</code>方法。</p> <p>您的地理位置服务接受三个回调参数——成功、错误和不受支持——因此您可以在 home-list.component.ts 中添加一个名为<code>getPosition</code>的新成员,该成员调用您的服务,并通过您的回调函数传递。该成员应如下所示:</p> <pre><code>private getPosition(): void { this.message = 'Getting your location...'; this.geolocationService.getPosition( this.getLocations, this.showError, this.noGeo); } </code></pre> <p>然后,您需要在组件初始化时调用此成员,而不是<code>getLocations</code>方法,因此将<code>ngOnInit</code>中的调用替换为这个新成员:</p> <pre><code>ngOnInit() { this.getPosition(); } </code></pre> <p>保存此代码,然后转到浏览器。您应该会看到类似图 9.10 的内容,其中浏览器要求您允许访问您的位置。</p> <h5 id="图-910-成功调用您的地理位置服务是通过浏览器请求了解您的位置来标记的">图 9.10. 成功调用您的地理位置服务是通过浏览器请求了解您的位置来标记的。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig10_alt.jpg" alt="" loading="lazy"></p> <p>好消息——直到您点击允许,屏幕在“获取您的位置”信息上挂起,在后台悄悄抛出一个 JavaScript 错误。您得到的错误说“无法设置 null 的属性'message'”,看起来像图 9.11。</p> <h5 id="图-911-尝试在地理位置回调中设置消息时显示的错误信息">图 9.11. 尝试在地理位置回调中设置消息时显示的错误信息</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig11_alt.jpg" alt="" loading="lazy"></p> <p>这条消息告诉您问题是什么以及它发生在哪里,这有助于您修复它。</p> <h5 id="在组件和服务之间使用回调中的-this">在组件和服务之间使用回调中的 this</h5> <p>你可以从图 9.11 中的错误中看到,它不能在<code>getLocations</code>回调中设置<code>this.message</code>,因为<code>this</code>是 null。当通过回调函数传递类成员时,你失去了<code>this</code>的上下文,它是类的实例本身。</p> <p>幸运的是,修复很简单。你可以通过绑定<code>this</code>到每个回调函数来发送上下文。在每个回调函数被传递的地方,添加<code>.bind(this)</code>到末尾。</p> <h5 id="列表-917-在-home-listcomponentts-中将this绑定到地理位置回调函数">列表 9.17. 在 home-list.component.ts 中将<code>this</code>绑定到地理位置回调函数</h5> <pre><code>private getPosition(): void { this.message = 'Getting your location...'; this.geolocationService.getPosition( this.getLocations.bind(this), this.showError.bind(this), this.noGeo.bind(this) ); } </code></pre> <p>现在你正在将<code>this</code>的上下文绑定到回调函数,以便在需要时存在。当你再次访问浏览器时,你成功了!在显示了一些消息并获取了你的位置后,浏览器再次显示了<code>home-list</code>。</p> <p>但你还没有使用这个位置。你获取了它,但没有做什么。你将在下一部分改变这种情况。</p> <h5 id="使用地理位置坐标查询-api">使用地理位置坐标查询 API</h5> <p>在<code>home-list.component.ts</code>中,<code>getPosition</code>方法调用你的地理位置服务以获取坐标。当它成功时,它调用<code>getLocations</code>方法——再次在<code>home-list.component.ts</code>中——作为一个回调,传递位置作为参数。你需要更新这个回调以接收位置。然后这个回调调用你的数据服务以搜索位置。你需要将坐标传递给服务,然后更新服务以在调用 API 时使用这些值。</p> <p>你有两件事要更新。从<code>home-list.component.ts</code>中的<code>getLocations()</code>开始,你需要更新它以接受位置参数,从中提取坐标,并将它们传递到数据服务,如下面的列表所示。</p> <h5 id="列表-918-更新-home-listcomponentts-以使用地理位置位置">列表 9.18. 更新 home-list.component.ts 以使用地理位置位置</h5> <pre><code>private getLocations(position: any): void { *1* this.message = 'Searching for nearby places'; const lat: number = position.coords.latitude; *2* const lng: number = position.coords.longitude; *2* this.loc8rDataService .getLocations(lat, lng) *3* .then(foundLocations => { this.message = foundLocations.length > 0 ? '' : 'No locations found'; this.locations = foundLocations; }); } </code></pre> <ul> <li> <p><strong><em>1</em> 接受位置作为参数</strong></p> </li> <li> <p><strong><em>2</em> 从位置中提取纬度和经度坐标</strong></p> </li> <li> <p><strong><em>3</em> 将坐标传递给数据服务调用</strong></p> </li> </ul> <p>你现在正在从地理位置服务获取位置,提取纬度和经度坐标,并将它们传递给数据服务。为了使最后一部分到位,你需要更新数据服务以接受坐标参数并使用它们而不是硬编码的值。</p> <h5 id="列表-919-更新-loc8r-dataservicets-以使用地理位置坐标">列表 9.19. 更新 loc8r-data.service.ts 以使用地理位置坐标</h5> <pre><code>public getLocations(lat: number, lng: number): Promise<Location[]> { *1* const maxDistance: number = 20000; *2* const url: string = `${this.apiBaseUrl}/locations?lng=${lng}&lat=${lat}& maxDistance=${maxDistance}`; return this.http .get(url) .toPromise() .then(response => response.json() as Location[]) .catch(this.handleError); } </code></pre> <ul> <li> <p><strong><em>1</em> 接受 lat 和 lng 参数,类型为数字</strong></p> </li> <li> <p><strong><em>2</em> 删除之前为 lat 和 lng 设置的硬编码值</strong></p> </li> </ul> <p>现在,坐标正在从地理位置服务找到其路径到 API 调用,所以你现在正在使用 Loc8r 来查找你附近的地点!如果你在浏览器中查看——如果你添加了你所在位置 20 公里范围内的地点——你应该能看到它们被列出,如图 9.12 所示。你可能注意到距离坐标有轻微的变化,这取决于你的测试数据有多准确。</p> <h5 id="图-912-作为-angular-应用的-loc8r-主页使用地理位置从你的-api-中查找附近的地点">图 9.12. 作为 Angular 应用的 Loc8r 主页,使用地理位置从你的 API 中查找附近的地点</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig12_alt.jpg" alt="" loading="lazy"></p> <p>这就是主页的最后一块拼图。Loc8r 现在可以找到你的当前位置,并列出附近的地点,这正是从开始时的整个想法。在本章的最后,你将整理关于页面,在这个过程中,你将探索通过 Angular 绑定注入 HTML 的挑战。</p> <h3 id="94-安全地绑定-html-内容">9.4. 安全地绑定 HTML 内容</h3> <p>在 Angular SPA 中,关于页面的当前状态是它仅作为一个默认骨架页面存在,正如你创建它来演示 Angular 中的导航和路由。在本节中,你将完成这个页面。</p> <h4 id="941-将关于页面内容添加到应用中">9.4.1. 将关于页面内容添加到应用中</h4> <p>关于页面应该是相当直接的。你将内容添加到组件定义中,并创建简单的标记,通过绑定来显示它。简单,对吧?</p> <p>首先向组件定义中添加内容。在下面的列表中,你可以看到 about.component.ts 中的类定义。你正在定义一个 <code>pageContent</code> 成员来保存所有文本信息,就像你之前做的那样。我们已经裁剪了主要内容区域中的文本,以节省墨水和树木。</p> <h5 id="列表-920-为关于页面创建-angular-控制器">列表 9.20. 为关于页面创建 Angular 控制器</h5> <pre><code>export class AboutComponent implements OnInit { constructor() { } ngOnInit() { } public pageContent = { header : { title : 'About Loc8r', strapline : '' }, content : 'Loc8r was created to help people find places to sit down and get a bit of work done.\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit.' }; } </code></pre> <p>就组件而言,这个很简单。这里没有发生任何魔法。注意,尽管如此,你仍然有 <code>\n</code> 字符来表示换行。</p> <p>接下来,你需要创建 HTML 布局。从你的原始 Pug 模板中,你知道标记需要是什么;你需要一个页面标题和几个 <code><div></code> 来存放内容。对于页面标题,你可以重用你之前创建的 <code>pageHeader</code> 组件,并像为主页那样传递数据。其余的标记并不多。about.component.html 的全部内容如下所示:</p> <pre><code><app-page-header [content]="pageContent.header"></app-page-header> <div class="row"> <div class="col-12 col-lg-8">{{ pageContent.content }}</div> </div> </code></pre> <p>再次,这里没有什么不同寻常的——只有页面标题、一些 HTML 和标准的 Angular 绑定。如果你在浏览器中查看这个页面,你会看到内容正在通过,但换行符没有显示,如图 图 9.13 所示。</p> <h5 id="图-913-关于页面的内容是从控制器传来的但换行符被忽略了">图 9.13. 关于页面的内容是从控制器传来的,但换行符被忽略了。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig13_alt.jpg" alt="" loading="lazy"></p> <p>这种情况并不理想。你希望文本可读,并按原意显示。如果你可以通过使用管道更改主页上显示的距离,为什么不做同样的事情来修复换行符?试一试,创建一个新的管道。</p> <h4 id="942-创建一个管道来转换换行符">9.4.2. 创建一个管道来转换换行符</h4> <p>你想创建一个管道,它接受提供的文本,并将每个 <code>\n</code> 实例替换为 <code><br/></code> 标签。你已经在 Pug 中通过使用 JavaScript <code>replace</code> 命令解决了这个问题,如下面的代码片段所示:</p> <pre><code>p !{(content).replace(/\n/g, '<br/>')} </code></pre> <p>使用 Angular,你不能这样做内联。相反,你需要创建一个管道并将其应用于绑定。</p> <h5 id="创建-htmllinebreaks-管道">创建 htmlLineBreaks 管道</h5> <p>如您所见,最佳创建管道的方式是通过 Angular CLI,因此请在终端中运行以下命令以生成文件并将管道注册到应用程序中:</p> <pre><code>$ ng generate pipe html-line-breaks </code></pre> <p>管道本身相当简单。它需要接受作为字符串值的传入文本。将每个 <code>\n</code> 替换为 <code><br/></code>,然后返回一个字符串值。将 html-line-breaks.html 的主要内容更新如下所示:</p> <pre><code>export class HtmlLineBreaksPipe implements PipeTransform { transform(text: string): string { return text.replace(/\n/g, '<br/>'); } } </code></pre> <p>您完成之后,尝试使用它。</p> <h5 id="将管道应用于绑定">将管道应用于绑定</h5> <p>将管道应用于绑定很简单;您已经做过几次了。在 HTML 中,在绑定的数据对象之后添加管道字符(<code>|</code>),然后跟随着过滤器的名称,如下所示:</p> <pre><code><div class="col-12 col-lg-8">{{ pageContent.content | htmlLineBreaks }}</div> </code></pre> <p>简单吗?但如果您在浏览器中尝试,结果可能并不如您所愿。如图 9.14 图 9.14 所示,换行符被 <code><br/></code> 替换,但它们被显示为文本而不是作为 HTML 渲染。</p> <h5 id="图-914-您使用过滤器插入的-br-标签被渲染为文本而不是-html-标签">图 9.14. 您使用过滤器插入的 <code><br/></code> 标签被渲染为文本而不是 HTML 标签。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig14.jpg" alt="" loading="lazy"></p> <p>嗯嗯,这并不是您想要的,但至少管道似乎在正常工作。这种输出的原因很好:安全性。Angular 通过防止 HTML 注入数据绑定来保护您和您的应用程序免受恶意攻击。例如,当您允许访客为地点撰写评论时,请考虑这一点。如果他们可以添加任何他们想要的 HTML,有人可以轻松地插入一个 <code><script></code> 标签并运行一些 JavaScript,从而劫持页面。</p> <p>但有一种方法可以让 HTML 标签的子集通过绑定,您将在下一节中看到。</p> <h4 id="943-使用属性绑定安全地绑定-html">9.4.3. 使用属性绑定安全地绑定 HTML</h4> <p>如果您使用属性绑定而不是通常用于内容的默认绑定,Angular 允许您传递一些 HTML 标签。此技术仅适用于 HTML 标签的子集,以防止 XSS 漏洞、攻击和弱点。将属性绑定视为“单向”绑定。组件不能读取数据并将其用于其他地方,但它可以更新它并更改绑定中的数据。</p> <p>当您将数据传递到嵌套组件时,您已经使用了属性绑定。记得您构建关于页面吗?在那里,您将数据绑定到嵌套组件中定义的属性,您称之为 <code>content</code>。在这里,您绑定到一个标签的本地属性——在这种情况下,<code>innerHTML</code>。</p> <p>属性绑定通过将它们括在方括号中来表示,然后传递值。您可以在 about.component.html 中删除内容绑定并使用属性绑定:</p> <pre><code><div class="col-12 col-lg-8" [innerHTML]="pageContent.content | htmlLineBreaks"></div> </code></pre> <p>注意,您也可以将管道应用于此类绑定,因此您仍在使用您的 <code>htmlLineBreaks</code> 管道。最后,当您在浏览器中查看关于页面时,您将看到换行符正确显示,就像 图 9.15 所示。</p> <h5 id="图-915-使用-htmllinebreaks-管道与属性绑定结合您现在可以看到换行符按预期渲染">图 9.15. 使用 <code>htmlLineBreaks</code> 管道与属性绑定结合,您现在可以看到换行符按预期渲染。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/09fig15_alt.jpg" alt="" loading="lazy"></p> <p>成功!你已经在构建 Loc8r 作为 Angular SPA 方面取得了良好的开端。你已经有了几个页面,一些路由和导航,地理位置,以及一个优秀的模块化应用程序架构。继续前进吧!</p> <h3 id="95-挑战">9.5. 挑战</h3> <p>使用你到目前为止学到的 Angular 知识,创建一个新的组件名为<code>rating-stars</code>。这个组件将在主页列表部分以及其他显示评分星号的地方使用,你将在下一节构建这些部分。</p> <p>这个新组件应该</p> <ul> <li> <p>接受传入的数字值(评分)</p> </li> <li> <p>根据评分显示正确的实心星号数量</p> </li> <li> <p>在单个页面上多次可重用</p> </li> </ul> <p>作为提示,你的元素应该看起来像这样:</p> <pre><code><app-rating-stars [rating]="location.rating"></app-rating-stars> </code></pre> <p>祝你好运!如果你需要,代码(如果需要)可在 GitHub 上找到,位于 chapter-09 分支。</p> <p>在第十章中,你将继续构建 Angular SPA,遇到更复杂的页面布局和模态弹出窗口,并通过表单接受用户输入。</p> <h3 id="摘要-5">摘要</h3> <p>在本章中,你学习了</p> <ul> <li> <p>Angular 有一个路由器以及它是如何工作的</p> </li> <li> <p>如何构建一个功能网站并使用网站导航</p> </li> <li> <p>使用嵌套组件创建模块化和可扩展的应用程序是最佳实践</p> </li> <li> <p>如何与外部接口如浏览器的地理位置功能一起工作</p> </li> </ul> <h2 id="第十章-使用-angular-构建单页应用程序下一个层次">第十章. 使用 Angular 构建单页应用程序:下一个层次</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>在 Angular 中使用 URL 参数进行路由</p> </li> <li> <p>使用 URL 参数数据查询 API</p> </li> <li> <p>构建更复杂的布局和处理表单提交</p> </li> <li> <p>创建一个单独的路由器配置文件</p> </li> <li> <p>用 Angular 应用替换 Express UI</p> </li> </ul> <p>在本章中,你将继续在第九章中开始的工作,构建一个单页应用程序(SPA)。到本章结束时,Loc8r 应用程序将是一个使用你的 API 获取数据的单个 Angular 应用程序。</p> <p>图 10.1 显示了你在整体计划中的位置,仍然在重建主要应用程序作为 Angular SPA。</p> <h5 id="图-101-本章继续你在第九章中开始的工作将-loc8r-应用程序作为-angular-spa-重建将应用程序逻辑从后端移到前端">图 10.1. 本章继续你在第九章中开始的工作:将 Loc8r 应用程序作为 Angular SPA 重建,将应用程序逻辑从后端移到前端。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/10fig01_alt.jpg" alt="" loading="lazy"></p> <p>你将从创建缺失的页面和功能开始,了解如何在路由中使用 URL 参数,包括在查询 API 时使用它们。当你已经实现了大部分功能后,你将构建一个表单来添加新的评论,但与 Express 中的单独页面不同,你将内联包含该表单,并能够在不离开详情页的情况下添加评论。这种技术是一种 SPA 方式,可以消除额外的服务器往返。当一切运行正常时,你将查看几种改进架构的方法,以遵循一些 Angular 和 TypeScript 的最佳实践。</p> <p>最后,你将使用你的 Angular 应用程序作为 Loc8r 的前端,消除 Express 应用程序公共部分的需求。</p> <h3 id="101-使用更复杂的视图和路由参数">10.1. 使用更复杂的视图和路由参数</h3> <p>在本节中,你将向 Angular SPA 添加详情页面。一个关键方面是从 URL 参数中检索位置 ID,以确保你得到正确的数据。以这种方式使用 URL 参数是常见的做法,并且是任何框架中都知道的有用技术。你还将不得不更新数据服务,以请求 API 的具体位置详情。当你将 Pug 视图转换为 Angular 模板时,你还将发现 Angular 提供的一些额外功能,以帮助你创建所需的多种布局。</p> <p>你有很多事情要做,所以在你开始有趣的部分之前,你最好先规划一下。</p> <h4 id="1011-规划布局">10.1.1. 规划布局</h4> <p>与你在 Angular 中迄今为止制作的页面相比,详情页面有更多内容,但正如你所知它看起来是什么样子,你可以从高层次开始规划。完成这个之后,添加细节会更容易。</p> <p>通过查看布局和已经完成的工作,你可以开始看到你需要的不同组件以及如何嵌套它们。当然,你将保留现有的框架组件在外部,包含导航和页脚。在可路由区域,你将有一个新的详情页面组件,包含页面标题、侧边栏和主要内容。图 10.2 显示了在详情页面截图上叠加的这种布局规划草图。</p> <h5 id="图-102-规划在-angular-中构建详情页面所需的组件和嵌套">图 10.2. 规划在 Angular 中构建详情页面所需的组件和嵌套</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/10fig02_alt.jpg" alt="图片" loading="lazy"></p> <p>这个规划让你对需要构建的内容和可以重用的内容有了很好的了解。我们希望你现在开始明白为什么创建可重用组件是个好主意!在此阶段,请注意,你需要在三个组件中使用一些位置数据:页面标题、位置详情组件和侧边栏。在编写页面代码时,你需要考虑这个事实。</p> <p>在计划中的五个组件中,你需要创建两个:用于组织其他所有组件的详情页面组件,以及用于显示实际详情的位置详情组件。你将接下来创建这些组件的基本版本,以便有一个可以路由到的页面。</p> <h4 id="1012-创建所需的组件">10.1.2. 创建所需的组件</h4> <p>你知道你想要一个包含位置详情、侧边栏和标题的详情页面组件。位置详情组件缺失,所以你将首先创建它的骨架。然后你可以创建准备路由的框架组件。</p> <p>使用 Angular CLI 创建位置详情组件;在 app_public 文件夹的终端中运行以下命令:</p> <pre><code>$ ng generate component location-details </code></pre> <p>你可以先在这个新组件中保留默认内容,因为你很快就会正确地构建它。接下来,创建详情页面组件,并向其中添加骨架布局。在终端中,再次使用 Angular CLI,使用以下命令:</p> <pre><code>$ ng generate component details-page </code></pre> <p>你将向这个组件添加一些内容,因为它将包含页面的其他组件:页面标题、位置详情和侧边栏。列表 10.1 显示了你在 details-page.component.html 中想要如何详细布局这些组件。你还将添加页面标题和侧边栏的<code>content</code>绑定,以便你可以从这个组件传递信息。</p> <h5 id="列表-101-details-pagecomponenthtml-的基本布局">列表 10.1. details-page.component.html 的基本布局</h5> <pre><code><app-page-header [content]="pageContent.header"></app-page-header> *1* <div class="row"> <div class="col-12 col-md-8"> <app-location-details></app-location-details> *2* </div> <app-sidebar class="col-12 col-md-4" [content]="pageContent.sidebar"> </app-sidebar> *3* </div> </code></pre> <ul> <li> <p><strong><em>1</em> 页面标题组件,包括属性绑定</strong></p> </li> <li> <p><strong><em>2</em> 位置详情组件</strong></p> </li> <li> <p><strong><em>3</em> 侧边栏组件,包括属性绑定</strong></p> </li> </ul> <p>为了让你能够看到标题和侧边栏中的内容,你将创建一些默认内容。在详情页面组件的 HTML 中,你将使用绑定<code>pageContent.header</code>和<code>pageContent.sidebar</code>,因此,在组件类中,你将创建一个相应的<code>pageContent</code>成员,包含<code>header</code>和<code>sidebar</code>属性。以下列表显示了在 details-page.component.ts 中的样子,同时也为内容属性提供了一些默认文本。</p> <h5 id="列表-102-在-details-pagecomponentts-中详情页面的起始内容">列表 10.2. 在 details-page.component.ts 中详情页面的起始内容</h5> <pre><code>export class DetailsPageComponent implements OnInit { constructor() { } ngOnInit() { } public pageContent = { *1* header: { *2* title: 'Location name', strapline: '' }, sidebar: 'is on Loc8r because it has accessible wifi and space to sit down with your laptop and get some work done.\n\nIf you\'ve been and you like it - or if you don\'t - please leave a review to help other people just like you.' *3* }; } </code></pre> <ul> <li> <p><strong><em>1</em> 包含 . . . 的新 pageContent 成员</strong></p> </li> <li> <p><strong><em>2</em> 标题详情和 . . .</strong></p> </li> <li> <p><strong><em>3</em> . . . 侧边栏内容</strong></p> </li> </ul> <p>现在你已经有了你的详情页面组件,其中包含你需要来布局页面的三个嵌套组件。你甚至已经将一些起始数据传递给了两个嵌套组件。</p> <p>你已经准备好设置路由,以便你可以看到页面。</p> <h4 id="1013-使用-url-参数设置和定义路由">10.1.3. 使用 URL 参数设置和定义路由</h4> <p>在 Angular 中使用 URL 参数定义路由与在 Express 中一样简单。甚至语法也是相同的——这在编程世界中并不常见!</p> <p>你的应用路由在 app.module.ts 中定义,所以你将在那里添加新的路由。因为你想要接受一个 URL 参数,所以你将以与在 Express 中相同的方式定义路由:在路径末尾放置一个<code>locationId</code>变量,前面加冒号。</p> <h5 id="列表-103-在-appmodulets-中添加详情页面路由">列表 10.3. 在 app.module.ts 中添加详情页面路由</h5> <pre><code>RouterModule.forRoot([ { path: '', component: HomepageComponent }, { path: 'about', component: AboutComponent }, { path: 'location/:locationId',, *1* component: DetailsPageComponent } ]) </code></pre> <ul> <li><strong><em>1</em> 通过在前面加冒号来在路由中定义一个‘locationId’ URL 参数</strong></li> </ul> <p>这样一来,你就可以在浏览器中访问 location/<em>something</em>,Angular 会路由你到详情页面组件。目前,这个组件看起来像图 10.3。</p> <h5 id="图-103-测试新的位置详情路由并查看你添加到组件中的默认内容">图 10.3. 测试新的位置详情路由并查看你添加到组件中的默认内容</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/10fig03_alt.jpg" alt="" loading="lazy"></p> <p>如果您还记得您原始布局,这个页面侧边栏的内容应该分为两段,所以您的换行符不会显示出来。幸运的是,您已经为这个目的创建了一个管道。您需要更新侧边栏组件以使用它。在 sidebar.component.html 中,将 Angular 绑定更改为<code>innerHTML</code>属性绑定,传递内容和<code>htmlLineBreaks</code>管道,如下所示:</p> <pre><code><p class="lead" [innerHTML]="content | htmlLineBreaks"></p> </code></pre> <p>现在侧边栏内容的<code>\n</code>部分被转换为<code><br/></code>标签并作为 HTML 渲染,看起来像图 10.4。</p> <h5 id="图-104-通过使用您自定义的管道启用侧边栏中的换行">图 10.4. 通过使用您自定义的管道启用侧边栏中的换行</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/10fig04.jpg" alt="" loading="lazy"></p> <p>通用页面布局看起来不错,您可以看到它在工作。在构建它之前,使用 URL 中的真实位置 ID 导航到这个页面会有所帮助。为此,您需要更新主页列表中的链接。</p> <h5 id="创建指向详情页面的-angular-链接">创建指向详情页面的 Angular 链接</h5> <p>首页列表当前显示指向此页面的链接,如果您尝试它们,它们会带您去那里。但您可能会注意到,当您这样做时,页面会闪烁。这是因为链接是标准<code>href</code>属性,在<code><a></code>标签中,所以浏览器像正常链接一样跟随它们。结果是页面完全重新加载并重新加载 Angular 应用程序——这不是您在 SPA 中想要的!</p> <p>您希望 Angular 捕获这些链接的点击并处理导航和路由。当您创建导航时,您在<code><a></code>标签中使用了<code>routerLink</code>而不是<code>href</code>,您需要在这里做同样的事情。在 home-list.component.html 中找到位置链接,并替换<code>href</code>属性:</p> <pre><code><a routerLink="/location/{{location._id}}">{{location.name}}</a> </code></pre> <p>其余的代码可以保持不变。通过这个简单的更改,您已经使您的应用更像一个真正的 SPA。现在您可以使用页面中的 URL 参数了。</p> <h4 id="1014-在组件和服务中使用-url-参数">10.1.4. 在组件和服务中使用 URL 参数</h4> <p>计划获取位置 ID URL 参数并在调用 API 以获取特定位置详情时使用它。当数据返回时,您想在页面上显示它。</p> <p>这个逻辑的最佳位置在哪里?任何可路由区域的组件都可以配置为获取 URL 参数并调用 API,但您希望显示所有三个嵌套组件中的数据。所以您将采用使用“父”详情页面组件获取数据然后传递给三个子组件的方法。首先,您需要向数据服务添加一个方法来通过 ID 获取单个位置。</p> <h5 id="创建调用-api-的数据服务">创建调用 API 的数据服务</h5> <p>您在第八章中创建的数据服务目前只有一个方法:<code>getLocations</code>。这个方法在给定一对坐标时检索位置列表。您需要的新方法具有类似的构造,所以请在 loc8r-data.service.ts 中复制此方法并命名为<code>getLocationById</code>。</p> <p>您需要做一些小的调整才能使此方法正常工作:</p> <ol> <li> <p>将期望的输入参数更改为单个 <code>locationId</code> 类型为 <code>string</code>。</p> </li> <li> <p>将返回类型更改为单个 <code>Location</code> 实例而不是数组。</p> </li> <li> <p>将 API URL 更改为使用 <code>locationId</code> 作为 URL 参数进行调用。</p> </li> <li> <p>将 JSON 响应设置为单个 <code>Location</code> 实例。</p> </li> </ol> <p>下面的列表展示了在 loc8r-data.service.ts 中的代码中这个方法的样子。</p> <h5 id="列表-104-在-loc8r-dataservicets-中添加一个通过-id-获取位置的方法">列表 10.4. 在 loc8r-data.service.ts 中添加一个通过 ID 获取位置的方法</h5> <pre><code>public getLocationById(locationId: string): Promise<Location> { *1* const url: string = `${this.apiBaseUrl}/locations/${locationId}`; *2* return this.http .get(url) .toPromise() .then(response => response as Location) *3* .catch(this.handleError); } </code></pre> <ul> <li> <p><strong><em>1</em> 设置正确的输入参数和期望的返回类型,都是单个项目</strong></p> </li> <li> <p><strong><em>2</em> 将 API URL 更改为使用位置 ID 作为 URL 参数</strong></p> </li> <li> <p><strong><em>3</em> 将 JSON 响应设置为单个 Location 实例</strong></p> </li> </ul> <p>数据服务方法准备好后,你可以将其导入到详情页面组件中,准备使用。</p> <h5 id="将数据服务导入到组件中">将数据服务导入到组件中</h5> <p>你之前已经将一个服务导入到组件中过——将数据服务导入到 <code>home-list</code> 组件中——所以我们在这里不会过多地讨论这个过程。你需要将数据服务导入到详情页面组件中,将其添加到提供者中,然后在类构造函数中声明它以使其可用。</p> <p>当你在这里时,你还将从 <code>home-list</code> 组件中导入 <code>Location</code> 类并清空默认页面内容。以下列表显示了 details-page.component 的所有这些更新。</p> <h5 id="列表-105-在-details-pagecomponentts-中详细导入数据服务">列表 10.5. 在 details-page.component.ts 中详细导入数据服务</h5> <pre><code>import { Component, OnInit } from '@angular/core'; import { Loc8rDataService } from '../loc8r-data.service'; *1* import { Location } from '../home-list/home-list.component'; *2* @Component({ selector: 'app-details-page', templateUrl: './details-page.component.html', styleUrls: ['./details-page.component.css'], }) export class DetailsPageComponent implements OnInit { constructor(private loc8rDataService: Loc8rDataService) { } *3* ngOnInit(): void { } public pageContent = { *4* header : { title : '', strapline : '' }, sidebar : '' }; } </code></pre> <ul> <li> <p><strong><em>1</em> 导入你的数据服务</strong></p> </li> <li> <p><strong><em>2</em> 导入 Location 类定义</strong></p> </li> <li> <p><strong><em>3</em> 创建数据服务的私有本地实例</strong></p> </li> <li> <p><strong><em>4</em> 清除默认页面内容</strong></p> </li> </ul> <p>这里唯一需要小心的是构造函数中的 <code>loc8rDataService</code> 的情况:类的类型定义有一个大写的 <code>L</code>,而本地实例是用小写的 <code>l</code> 定义的。</p> <p>现在你可以将 URL 参数获取到组件中。</p> <h5 id="在组件中使用-url-参数">在组件中使用 URL 参数</h5> <p>由于在应用程序中使用 URL 参数是一个常见的需求,这个过程出人意料地复杂。你需要三个新的功能组件:</p> <ul> <li> <p>从 Angular 路由器中导入 <code>ActivatedRoute</code> 以从组件内部获取当前路由的值</p> </li> <li> <p>从 Angular 路由器中导入 <code>ParamMap</code> 以获取活动路由的 URL 参数作为可观察对象</p> </li> <li> <p>从 RxJS 中导入 <code>switchMap</code> 以从 <code>ParamMap</code> 可观察对象中获取值,并使用它们来调用你的 API,创建第二个可观察对象</p> </li> </ul> <p>以下片段以粗体显示了在 details-page.component.ts 中需要导入这些功能组件的添加内容:</p> <pre><code>import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, ParamMap } from '@angular/router'; import { Loc8rDataService } from '../loc8r-data.service'; import { Location } from '../home-list/home-list.component'; import { switchMap } from 'rxjs/operators'; </code></pre> <p>你还需要通过在构造函数中定义一个类型为 <code>ActivatedRoute</code> 的私有成员 <code>route</code> 来使激活的路由对组件可用:</p> <pre><code>constructor( private loc8rDataService: Loc8rDataService, private route: ActivatedRoute ) { } </code></pre> <p>现在是复杂的部分。完成以下步骤以从 URL 参数中获取位置 ID 并将其转换为 API 中的位置数据:</p> <ol> <li> <p>当组件初始化时,使用 <code>switchMap</code> 订阅到激活路由的 <code>paramMap</code> 可观察对象。</p> </li> <li> <p>当 <code>paramMap</code> <code>Observable</code> 返回一个 <code>ParamMap</code> 对象时,获取 <code>locationId</code> URL 参数的值。</p> </li> <li> <p>调用你的数据服务的 <code>getLocationsById</code> 方法,并传入 ID。</p> </li> <li> <p>返回 API 调用,使其返回一个 <code>Observable</code>。</p> </li> <li> <p>订阅以监听当 <code>Observable</code> 从你的 API 返回数据时。结果应该是一个类型为 <code>Location</code> 的单个对象。</p> </li> <li> <p>使用从 API 返回的位置名称设置页眉和侧边栏的内容。</p> </li> </ol> <p>呼!对于一个看似简单的流程来说,这些步骤真是太多了。所有这些都在 <code>details-page.component.ts</code> 中的 <code>ngOnInit</code> 生命周期钩子中完成。接下来的列表显示了代码的样子。</p> <h5 id="列表-106-在-details-pagecomponentts-中获取和使用-url-参数">列表 10.6. 在 <code>details-page.component.ts</code> 中获取和使用 URL 参数</h5> <pre><code>ngOnInit(): void { this.route.paramMap *1* .pipe( *2* switchMap((params: ParamMap) => { *3* let id = params.get('locationId'); *4* return this.loc8rDataService.getLocationById(id); *5* }) ) .subscribe((newLocation: Location) => { *6* this.pageContent.header.title = newLocation.name; *7* this.pageContent.sidebar = `${newLocation.name} is on Loc8r because it has accessible wifi and space to sit down with your laptop and get some work done.\n\nIf you\'ve been and you like it - or if you don\'t - please leave a review to help other people just like you.`; }); } </code></pre> <ul> <li> <p><strong>1</strong> 获取激活路由的 <code>paramMap</code> <code>Observable</code>。</p> </li> <li> <p><strong>2</strong> 使用管道操作符组合一系列将作用于 <code>Observable</code> 的操作。</p> </li> <li> <p><strong>3</strong> 使用 <code>switchMap</code> 从 <code>ParamMap</code> 中提取所需元素,并返回一个 <code>Observable</code>。</p> </li> <li> <p><strong>4</strong> 使用 <code>.get</code> 方法从 <code>ParamMap</code> 中获取 <code>locationId</code> URL 参数的值。</p> </li> <li> <p><strong>5</strong> 调用你的新数据服务方法,将其作为 <code>Observable</code> 返回。</p> </li> <li> <p><strong>6</strong> 订阅 API 调用 <code>Observable</code>,期望返回一个 <code>Location</code>。</p> </li> <li> <p><strong>7</strong> 将位置名称发送到页眉和侧边栏。</p> </li> </ul> <p>这段代码相当密集;在几行和命令中发生了很多事情。我们建议多次阅读计划和注释代码,以拼凑出所有内容。它很强大,与之前看到的不同,并且在这本书中你将看到的复杂度相当。特别是注意两个链式 <code>Observable</code>:首先,<code>paramMap</code> 被订阅,由 <code>switchMap</code> 返回第二个。</p> <p>好消息是,当你完成时,你的 Details 页面会在页眉和侧边栏中显示位置名称,如图 [图 10.5 所示。</p> <h5 id="图-105-在从-url-获取位置-id-并将其发送到-api-后在页眉和侧边栏中显示位置名称">图 10.5. 在从 URL 获取位置 ID 并将其发送到 API 后,在页眉和侧边栏中显示位置名称</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/10fig05_alt.jpg" alt="图片 10.5" loading="lazy"></p> <p>现在,你正在使用 URL 中的位置 ID 来查询数据库,并将返回的数据的一部分传递给页面上的两个组件。在构建 Details 页面的主要部分之前,确保最终组件正在获取它所需的数据。</p> <h4 id="1015-将数据传递给-details-页面组件">10.1.5. 将数据传递给 Details 页面组件</h4> <p>要将位置数据从 Details 页面组件传递到嵌套的位置详情组件,你需要做三件事:</p> <ol> <li> <p>在从数据服务获取位置数据后,将一个类成员添加到 Details 页面组件中,用于保存位置数据。</p> </li> <li> <p>使用 HTML 中的属性绑定将数据传递给子组件。</p> </li> <li> <p>更新位置详情组件以接受传入的数据。</p> </li> </ol> <p>首先,如 列表 10.7 所示,在 <code>details-page.component.ts</code> 中定义一个新的 <code>Location</code> 类成员 <code>newLocation</code>,并在从 API 调用获取位置后为其赋值。</p> <h5 id="列表-107-在-details-pagecomponentts-中公开找到的位置详情">列表 10.7. 在 details-page.component.ts 中公开找到的位置详情</h5> <pre><code>newLocation: Location; ngOnInit(): void { this.route.paramMap .switchMap((params: ParamMap) => { let id = params.get('locationId'); return this.loc8rDataService.getLocationById(id); }) .subscribe((newLocation: Location) => { this.newLocation = newLocation; *1* this.pageContent.header.title = newLocation.name; this.pageContent.sidebar = `${newLocation.name} is on Loc8r because it has accessible wifi and space to sit down with your laptop and get some work done.\n\nIf you\'ve been and you like it - or if you don\'t – please leave a review to help other people just like you.`; }); } </code></pre> <ul> <li><strong><em>1</em> 更新本地 newLocation 以接收来自 Observable 的数据</strong></li> </ul> <p>通过这个 <code>newLocation</code> 类成员公开位置详情,你可以通过在 details-page.component.html 中的元素上添加属性绑定将其传递给嵌套组件:</p> <pre><code><app-location-details [location]="newLocation"></app-location-details> </code></pre> <p>你之前已经见过这种设置。属性绑定将把 Details 页面组件中的 <code>newLocation</code> 内容传递给位置详情组件的 <code>location</code> 成员。</p> <p>你的位置详情组件还没有位置成员,所以你需要将其添加到组件定义中,并设置它为类型为 <code>Location</code> 的输入成员。你之前已经执行过这些操作,所以下面的列表作为方便的提醒,展示了在 location-details.component.ts 中的所有内容。</p> <h5 id="列表-108-在-location-detailscomponentts-中接受传入的位置数据">列表 10.8. 在 location-details.component.ts 中接受传入的位置数据</h5> <pre><code>import { Component, OnInit, Input} from '@angular/core'; *1* import { Location } from '../home-list/home-list.component'; *2* @Component({ selector: 'app-location-details', templateUrl: './location-details.component.html', styleUrls: ['./location-details.component.css'] }) export class LocationDetailsComponent implements OnInit { @Input() location: Location; *3* public googleAPIKey: string = '<Put your Google Maps API Key here>'; *4* constructor() { } ngOnInit() { } } </code></pre> <ul> <li> <p><strong><em>1</em> 从 Angular 核心导入 ‘Input’</strong></p> </li> <li> <p><strong><em>2</em> 导入你的 ‘Location’ 类定义</strong></p> </li> <li> <p><strong><em>3</em> 将 ‘location’ 定义为类型为 ‘Location’ 的输入成员</strong></p> </li> <li> <p><strong><em>4</em> 不要忘记 Google API 密钥。(你在第二章中得到了一个,不是吗?)</strong></p> </li> </ul> <p>页面仍然在正常工作,看起来和之前一样,但现在详情页面组件正在从数据库获取数据并将其传递给所有三个嵌套组件。是时候构建嵌套视图了。</p> <h4 id="1016-构建-details-页面视图">10.1.6. 构建 Details 页面视图</h4> <p>对于位置详情,你已经有一个带有 Pug 数据绑定的 Pug 模板,你需要将这个模板转换为带有 Angular 绑定的 HTML。你需要放置相当多的绑定,以及一些使用 Angular 的 <code>*ngFor</code> 构造的循环。你将使用你在第九章末尾挑战中创建的 <code>rating-stars</code> 组件来显示总体评分和每个评论的评分。如果你还没有创建这个组件,请参考 GitHub 上的书籍代码仓库。你还需要通过使用 <code>htmlLineBreaks</code> 管道来允许评论文本中的换行。</p> <h5 id="放置主要模板">放置主要模板</h5> <p>列表 10.9 展示了所有内容,其中绑定以粗体显示。这段代码应该构成 location-details.component.html 的全部内容。我们省略了一些部分,例如营业时间,你将在代码就位并测试后填写这些内容。</p> <h5 id="列表-109-location-detailscomponenthtml-中的位置详情的-angular-模板">列表 10.9. location-details.component.html 中的位置详情的 Angular 模板</h5> <pre><code><div class="row"> <div class="col-12 col-md-6"> <app-rating-stars [rating]="location.rating"></app-rating-stars> *1* <p>{{ location.address }}</p> <div class="card card-primary"> <div class="card-block"> <h2 class="card-title">Opening hours</h2> <!-- Opening times to go here --> </div> </div> <div class="card card-primary"> <div class="card-block"> <h2 class="card-title">Facilities</h2> <span *ngFor="let facility of location.facilities" class="badge *2* badge-warning"> *2* <i class="fa fa-check"></i> *2* {{facility}} *2* </span> *2* </div> </div> </div> <div class="col-12 col-md-6 location-map"> <div class="card card-primary"> <div class="card-block"> <h2 class="card-title">Location map</h2> <img src="https://maps.googleapis.com/maps/api/staticmap? center={{location.coords[1]}},{{location.coords[0]}} &zoom=17&size=400x350&sensor=false&markers={{location coords[1]}},{{location.coords[0]}}&key= {{googleAPIKey}}&scale=2" class="img-fluid rounded"/> *3* </div> </div> </div> </div> <div class="row"> <div class="col-12"> <div class="card card-primary review-card"> <div class="card-block"><a href="/location/{{location._id}} /review/new" class="btn btn-primary float-right">Add review</a> <h2 class="card-title">Customer reviews</h2> <div *ngFor="let review of location.reviews" class="row review"> *4* <div class="col-12 no-gutters review-header"> <app-rating-stars [rating]="review.rating"> </app-rating-stars> *5* <span class="reviewAuthor">{{ review.author }}</span> <small class="reviewTimestamp">{{ review.createdOn }}</small> </div> <div class="col-12"> <p [innerHTML]="review.reviewText | htmlLineBreaks"></p> *6* </div> </div> </div> </div> </div> </div> </code></pre> <ul> <li> <p><strong><em>1</em> 使用 rating-stars 组件来显示位置的平均评分。</strong></p> </li> <li> <p><strong><em>2</em> 遍历设施。</strong></p> </li> <li> <p><strong><em>3</em> 不要忘记 Google Maps API 密钥。</strong></p> </li> <li> <p><strong><em>4</em> 遍历评论循环。</strong></p> </li> <li> <p><strong><em>5</em> 使用 rating-stars 组件来显示每个评论的评分。</strong></p> </li> <li> <p><strong><em>6</em> 将 htmlLineBreaks 管道应用于评论文本并将其绑定为 HTML。</strong></p> </li> </ul> <p>那段代码列表很长,但这是可以预料的,因为详情页面有很多事情在进行。如果你现在在浏览器中查看页面,看起来是正确的。你有一些事情要修复,但你已经知道了。</p> <p>虽然页面看起来不错,但如果你打开 JavaScript 控制台,你会看到页面抛出了很多类似<code>Cannot read property 'rating' of undefined'</code>的错误。这些错误是绑定错误,发生的原因是嵌套的位置详情组件在页面加载时试图绑定数据,但你还没有数据直到 API 调用完成。</p> <h5 id="隐藏组件以防止过早绑定错误">隐藏组件以防止过早绑定错误</h5> <p>绑定错误发生是因为组件试图在数据提供之前绑定数据。你该如何阻止这种情况发生?一个不错的方法是在 HTML 中隐藏组件,直到从 API 接收数据并且你有准备好的位置详情可以显示。</p> <p>Angular 包含一个有用的原生指令<code>*ngIf</code>,你可以将其添加到 HTML 中的元素。<code>*ngIf</code>接受一个表达式。如果表达式解析为<code>true</code>,则显示该元素;否则,它会被隐藏。</p> <p>对于你的情况,你只想在存在位置数据时显示位置详情组件。因此,你可以在 details-page.component.html 中的位置详情元素上添加一个<code>*ngIf</code>指令,如下所示:</p> <pre><code><div class="col-12 col-md-8"> <app-location-details *ngIf="newLocation" [location]="newLocation"> </app-location-details> </div> </code></pre> <p>通过这个小小的改动,你不再会有绑定错误了!</p> <p>现在转向修复剩余的页面模板问题。因为你还没有显示营业时间,所以评论是按时间顺序显示的,评论的数据需要格式化。</p> <h5 id="使用ngswitchcase添加-if-else-样式逻辑以显示营业时间">使用<code>ngSwitchCase</code>添加 if-else 样式逻辑以显示营业时间</h5> <p>在模板中想要某种类型的<code>if-else</code>逻辑以根据某个参数显示不同的 HTML 块并不罕见。对于每个营业时间,你想要显示日期范围以及一个关闭消息或营业和关闭时间。在你的 Pug 模板中,你有一点点逻辑,一个简单的<code>if</code>语句检查<code>closed</code>是否为<code>true</code>:</p> <pre><code>if time.closed | closed else | #{time.opening} - #{time.closing} </code></pre> <p>你想在你的 Angular 模板中做类似的事情。你已经看到了<code>*ngIf</code>如何适用于一次性情况,但对于<code>if-else</code>逻辑,Angular 按照 JavaScript 的<code>switch</code>方法工作。使用这种方法,你可以在顶部定义你想要检查的条件,然后根据条件的值提供不同的选项。</p> <p>这里关键的部分是一个用于定义切换条件的<code>[ngSwitch]</code>绑定,一个用于提供特定值的<code>*ngSwitchCase</code>指令,以及一个用于提供备份选项的<code>*ngSwitchDefault</code>指令。你可以在以下列表中看到所有这些部分的实际应用,其中你将营业时间添加到 location-details.component.html 中。</p> <h5 id="列表-1010-在-location-detailscomponenthtml-中使用ngswitch">列表 10.10. 在 location-details.component.html 中使用<code>ngSwitch</code></h5> <pre><code><div class="card card-primary"> <div class="card-block"> <h2 class="card-title">Opening hours</h2> <p class="card-text" *ngFor="let time of location.openingTimes" [ngSwitch]="time.closed"> *1* {{ time.days }} : <span *ngSwitchCase="true">Closed</span> *2* <span *ngSwitchDefault>{{ time.opening + " - " + time.closing}} </span> *3* </p> </div> </div> </code></pre> <ul> <li> <p><strong><em>1</em> 根据 time.closed 的值运行 switch</strong>*</p> </li> <li> <p><strong><em>2</em> 当 time.closed 为 true 时,输出 closed</strong>*</p> </li> <li> <p><strong><em>3</em> 否则,默认操作是输出开放和关闭时间</strong></p> </li> </ul> <p>现在模板中有一点点逻辑。注意,由于所有的<code>ngSwitch</code>命令都是属性绑定和指令,它们需要添加到 HTML 标签中。</p> <p>好的,现在是时候让评论以最新的顺序显示了。</p> <h5 id="通过使用自定义管道更改列表的显示顺序">通过使用自定义管道更改列表的显示顺序</h5> <p>如果你熟悉 AngularJS,你可能期望更新旧的<code>orderBy</code>过滤器,它可以用来神奇地以几乎任何可想象的方式重新排列重复的列表。它是灵活且强大的,但有一个缺点:对于大型数据集,这种灵活的过滤器变得很慢。因此,Angular 团队决定不在新版本中包含它。</p> <p>由于没有本地方式可以更改列表的顺序,选项是编写组件中的代码或创建一个新的管道。管道通常是最好的选择——特别是如果你认为你可能想在其他地方重用该功能——而且你也知道如果数据发生变化,管道总是会应用。</p> <p>创建一个新的管道,专门按日期顺序排列评论,最新的排在前面。你将按照正常方式创建新的管道,在终端中运行以下命令,在 app_public 文件夹中:</p> <pre><code>$ ng generate pipe most-recent-first </code></pre> <p>当管道生成后,将其添加到在 location-details.component.html 中循环评论的<code>*ngFor</code>指令中,如下所示:</p> <pre><code><div *ngFor="let review of location.reviews | mostRecentFirst" class="row review"> </code></pre> <p>接下来,你将编写管道本身。记住,它包含一个接受值并返回值的<code>transform</code>钩子。就你的目的而言,你想要接受并返回一个数组,因为评论是从数据库作为数组返回的。</p> <p>由于你正在处理数组,你可以使用 JavaScript 的本地数组<code>sort</code>方法,它接受一个函数作为参数。这个函数一次从数组中取两个项目,并可以按你编写的方式比较它们。函数的返回值应该是正数或负数。负数表示顺序保持不变;正数表示顺序改变。</p> <p>你正在比较日期,并希望最新的日期排在前面。在比较运算符方面,较新的日期是“大于”较旧的日期。所以如果第一个参数的日期大于(比第二个参数的日期更近),你返回一个负数以保持顺序不变。否则,返回一个正数以交换它们的位置。这比写代码要复杂得多!</p> <p>下一个列表显示了管道代码的样子,创建了一个名为<code>compare</code>的比较函数,并使用它来对评论数组进行排序,然后返回更新后的数组。</p> <h5 id="列表-1011-创建-most-recent-firstpipets-以更改评论的显示顺序">列表 10.11. 创建 most-recent-first.pipe.ts 以更改评论的显示顺序</h5> <pre><code>export class MostRecentFirstPipe implements PipeTransform { private compare(a, b) { *1* const createdOnA = a.createdOn; *2* const createdOnB = b.createdOn; *2* let comparison = 1; *3* if (createdOnA > createdOnB) *3* comparison = -1; *3* } *3* return comparison; *3* } transform(reviews: any[]): any[] { *4* if (reviews && reviews.length > 0) { return reviews.sort(this.compare); *5* } return null; } } </code></pre> <ul> <li> <p><strong><em>1</em> 你的比较函数,从数组中取两个值</strong></p> </li> <li> <p><strong><em>2</em> 获取每个评论的创建日期</strong></p> </li> <li> <p><strong><em>3</em> 如果 a 比 b 更近,则返回-1;否则,返回 1</strong></p> </li> <li> <p><strong><em>4</em> transform 方法,接受并返回评论数组</strong></p> </li> <li> <p><strong><em>5</em> 使用你的比较函数来排序数组,返回重新排序的版本</strong></p> </li> </ul> <p>如果你重新加载页面,你应该看到你的审查按照正确的顺序显示:最新的首先显示。然而,由于日期格式并不完全符合用户友好性,所以这有点难以判断。你将在下一节中解决这个问题。</p> <h5 id="通过使用日期管道修复日期格式">通过使用日期管道修复日期格式</h5> <p>幸运的是,格式化日期比按日期排序要简单得多。Angular 的默认管道之一是 <code>date</code> 管道,它可以按照你想要的方式格式化给定的日期。这个管道接受一个参数:你日期的格式。</p> <p>要应用你的格式,你需要发送一个描述你想要输出的字符串。这里有很多选项,无法在此列出,但格式很容易掌握。例如,对于格式 <em>1 September 2017</em>,发送的字符串是 <code>'d MMMM yyyy'</code>,如下面的列表所示。</p> <h5 id="列表-1012-在-location-detailscomponenthtml-中使用-date-管道进行格式化">列表 10.12. 在 location-details.component.html 中使用 <code>date</code> 管道进行格式化</h5> <pre><code><div *ngFor="let review of location.reviews" class="row review"> <div class="col-12 no-gutters review-header"> <app-rating-stars [rating]="review.rating"></app-rating-stars> <span class="reviewAuthor">{{ review.author }}</span> <small class="reviewTimestamp">{{ review.createdOn | date : 'd MMMM yyyy' }}</small> </div> <div class="col-12"> <p [innerHTML]="review.reviewText | htmlLineBreaks"></p> </div> </div> </code></pre> <p>到此为止,你已经完成了详情页面的布局和格式化,它应该看起来像图 10.6。</p> <h5 id="图-106-所有位置详情现在都在-angular-页面上显示">图 10.6. 所有位置详情现在都在 Angular 页面上显示。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/10fig06_alt.jpg" alt="" loading="lazy"></p> <p>下一个也是最后的步骤是启用添加审查功能,但你将放弃使用额外页面的概念来完成这个任务,这是你在 Express 中所做的方式。相反,你将在页面上直接进行操作,以提供更流畅的体验。</p> <h3 id="102-处理表单和处理提交的数据">10.2. 处理表单和处理提交的数据</h3> <p>在本节中,你将在 Angular 中创建添加审查页面,并将其数据提交到 API。当点击添加审查按钮时,你不会导航到单独的表单页面,而是在页面上直接显示表单。当表单提交时,Angular 将处理数据,将其提交到 API,并在列表顶部显示新的审查。你将从查看在 Angular 中创建表单所涉及的内容开始。</p> <h4 id="1021-在-angular-中创建审查表单">10.2.1. 在 Angular 中创建审查表单</h4> <p>要创建审查表单,你需要将 HTML 放置到位,添加数据绑定到输入字段,确保它们都按预期工作,最后确保表单最初是隐藏的,并且只有当按钮被点击时才显示。</p> <h5 id="将表单-html-放置到位">将表单 HTML 放置到位</h5> <p>将内联表单添加到页面中,紧接在 <code>Customer reviews<h2></code> 标签之后,如下面的列表所示。大部分布局都来自你在 Express 中使用的表单,包括表单输入名称和 ID。</p> <h5 id="列表-1013-将审查表添加到-location-detailscomponenthtml">列表 10.13. 将审查表添加到 location-details.component.html</h5> <pre><code><h2 class="card-title">Customer reviews</h2> <div></div> *1* <form action=""></form> <hr> <h4>Add your review</h4> <div class="form-group row"> <label for="name" class="col-sm-2 col-form-label">Name</label> <div class="col-sm-10"> <input id="name" name="name" required="required" class="form- control"> </div> </div> <div class="form-group row"> <label for="rating" class="col-sm-2 col-form-label">Rating</label> <div class="col-sm-10 col-md-2"> <select id="rating" name="rating" class="form-control"> <option value="5">5</option> <option value="4">4</option> <option value="3">3</option> <option value="2">2</option> <option value="1">1</option> </select> </div> </div> <div class="form-group row"> <label for="review" class="col-sm-2 col-form-label">Review</label> <div class="col-sm-10"> <textarea name="review" id="review" rows="5" class="form- control"></textarea> </div> </div> <div class="form-group row"> <div class="col-12"> <button type="submit" class="btn btn-primary float-right" style="margin-left:15px">Submit review</button> <button type="button" class="btn btn-default float- right">Cancel</button> </div> </div> <hr> </form> </div> </code></pre> <ul> <li><strong><em>1</em> 在客户审查标题之后直接添加新的 div 和表单 HTML</strong></li> </ul> <p>目前,你并没有做任何聪明的事情或要求 Angular 做任何事情。你已经在模板中放置了一些带有 Bootstrap 类的原始 HTML。在浏览器中,这看起来像图 10.7。</p> <h5 id="图-107-审查表单在详情页面中直接显示位于添加审查按钮和审查列表之间">图 10.7. 审查表单在详情页面中直接显示,位于添加审查按钮和审查列表之间。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/10fig07_alt.jpg" alt="" loading="lazy"></p> <p>基本表单已经到位。接下来,添加数据绑定。</p> <h5 id="向表单输入添加数据绑定">向表单输入添加数据绑定</h5> <p>在 Express 中,你将表单发布到另一个 URL 并在那里处理提交的数据,但使用 Angular,你不想改变页面。使用 Angular,方法是将数据绑定添加到表单中的所有字段,以便组件可以访问这些值。</p> <p>要将数据绑定添加到表单字段,使用具有特殊语法的指令,例如:<code>[(ngModel)]="bindingName"</code>。(记住括号的顺序可能很难,因此这被称为“香蕉船”,以帮助你记住!)</p> <p>要在 HTML 中使用 <code>ngModel</code>,你需要将 <code>FormsModule</code> 和 <code>ReactiveFormsModule</code> 导入到应用中 app.module.ts。将 <code>import { FormsModule, ReactiveFormsModule } from '@angular/forms';</code> 行添加到 app.module.ts,并将这两个模块名称添加到同一文件中的 <code>imports</code> 数组中。</p> <p>在你的组件中,你希望将所有提交的表单数据保存在一个单独的对象中,这样你就可以轻松地传递它。在 location-details.component.html 中定义一个新的公共成员 <code>newReview</code>,给它作者名称、评分和评论内容属性。每个属性都需要一个默认值,所以定义应该看起来像这样:</p> <pre><code>public newReview = { author: '', rating: 5, reviewText: '' }; </code></pre> <p>现在,由于 <code>newReview</code> 对象及其属性已经在组件中定义,你可以在 HTML 中使用它们。以下列表显示了如何在 location-details.component.html 中添加到表单的绑定。</p> <h5 id="列表-1014-在-location-detailscomponenthtml-中添加评论表单的数据绑定">列表 10.14. 在 location-details.component.html 中添加评论表单的数据绑定</h5> <pre><code><form action=""> <hr> <h4>Add your review</h4> <div class="form-group row"> <label for="name" class="col-sm-2 col-form-label">Name</label> <div class="col-sm-10"> <input [(ngModel)]="newReview.author" id="name" name="name" required="required" class="form-control"> *1* </div> </div> <div class="form-group row"> <label for="rating" class="col-sm-2 col-form-label">Rating</label> <div class="col-sm-10"> <select [(ngModel)]="newReview.rating" id="rating" name="rating"> *1* <option value="5">5</option> <option value="4">4</option> <option value="3">3</option> <option value="2">2</option> <option value="1">1</option> </select> </div> </div> <div class="form-group row"> <label for="reviewText" class="col-sm-2 col-form-label">Review</label> <div class="col-sm-10"> <textarea [(ngModel)]="newReview.reviewText" name="reviewText" id="reviewText" rows="5" class="form-control"></textarea> *1* </div> </div> <div class="form-group row"> <div class="col-12"> <button type="submit" class="btn btn-primary float-right" style="margin-left:15px">Submit review</button> <button type="button" class="btn btn-default float- right">Cancel</button> </div> </div> <hr> </form> </code></pre> <ul> <li><strong><em>1</em> 将“香蕉船”模型绑定添加到表单输入</strong></li> </ul> <p>这看起来不错,表面上似乎也工作得很好。但你希望评分是一个数字,而在选择选项中,<code>value="5"</code> 是包含字符 5 的字符串。</p> <h5 id="处理非字符串的-select-值">处理非字符串的 select 值</h5> <p>选择选项的 <code>value</code> 默认是一个字符串,但你的数据库需要评分是一个数字。Angular 有一种方法可以帮助你从选择字段获取不同类型的数据。</p> <p>而不是在每个 <code><option></code> 中使用 <code>value="STRING VALUE"</code>,使用 <code>[ngValue]= "ANGULAR EXPRESSION"</code>。当写出来时,<code>[ngValue]</code> 的值看起来像是一个字符串,但它是一个 Angular 表达式。这可以是一个对象或一个真正的布尔值,但你想要一个数字。</p> <p>在 location-details.component.html 中,更新每个 <code><option></code> 标签以使用 <code>[ngValue]</code> 而不是 <code>value</code>:</p> <pre><code><option [ngValue]="5">5</option> <option [ngValue]="4">4</option> <option [ngValue]="3">3</option> <option [ngValue]="2">2</option> <option [ngValue]="1">1</option> </code></pre> <p>现在 Angular 将 <code><select></code> 的值作为一个数字而不是字符串来获取。当将数据提交到 API 时,这项技术将非常有用。接下来,你默认隐藏表单,因为你不想让它一直显示。</p> <h5 id="设置表单的可见性">设置表单的可见性</h5> <p>你不希望在页面加载时显示“添加评论”部分;你希望“添加评论”按钮显示它,当表单显示时,你希望“取消”按钮再次隐藏它。</p> <p>要显示和隐藏表单,你可以使用你的老朋友 <code>*ngIf</code>。<code>*ngIf</code> 需要一个布尔值来决定是否显示它所应用的元素,因此你需要在组件中定义一个。</p> <p>在 location-details.component.ts 中,定义一个新的公共成员 <code>formVisible</code>,其类型为 <code>boolean</code>,默认值为 <code>false</code>:</p> <pre><code>public formVisible: boolean = false; </code></pre> <p>你已经将默认值设置为 <code>false</code>,因为你希望表单默认隐藏。要使用这个布尔值来设置表单的可见性,在 location-details.component.html 中找到包围 <code><form></code> 的 <code><div></code>,并像这样添加 <code>*ngIf</code> 指令:</p> <pre><code><h2 class="card-title">Customer reviews</h2> <div *ngIf="formVisible"> <form action=""> </code></pre> <p>现在当页面加载时,表单默认是隐藏的。</p> <h5 id="切换表单的可见性">切换表单的可见性</h5> <p>要更改表单的可见性,你需要一种方法,在点击添加评论和取消按钮时更改 <code>formVisible</code> 的值。不出所料,Angular 有一个你可以用来跟踪元素点击并执行某些操作的点击处理程序。</p> <p>Angular 的点击处理程序通过在元素上添加 <code>(click)</code> 并提供一个 Angular 表达式来访问。这个表达式可以是调用组件类中的公共成员或任何其他类型的有效表达式。当点击添加评论按钮时,你想将 <code>formVisible</code> 设置为 <code>true</code>,当点击取消按钮时设置为 <code>false</code>。</p> <p>在 location-details.component.html 中,将添加评论按钮从 <code><a></code> 标签更改为 <code><button></code>,移除 <code>href</code> 属性,并用设置 <code>formVisible</code> 为 <code>true</code> 的 <code>(click)</code> 事件替换它:</p> <pre><code><button (click)="formVisible=true" class="btn btn-primary float-right">Add review</button> </code></pre> <p>以类似的方式,给取消按钮添加 <code>(click)</code> 事件,将 <code>formVisible</code> 设置为 <code>false</code>:</p> <pre><code><button type="button" (click)="formVisible=false" class="btn btn-default float-right">Cancel</button> </code></pre> <p>在这些点击处理程序就位后,你可以使用这两个按钮来显示和隐藏评论表单,使用组件的 <code>formVisible</code> 属性来跟踪状态。你需要做的最后一件事是将表单连接起来,以便在提交时添加评论。</p> <h4 id="1022-将提交的表单数据发送到-api">10.2.2. 将提交的表单数据发送到 API</h4> <p>现在是时候让你的评论表单工作,并在提交时将评论添加到数据库中。为了达到这个目的,你必须完成几个步骤:</p> <ol> <li> <p>在你的数据服务中添加一个新成员来 <code>POST</code> 新评论到 API。</p> </li> <li> <p>当表单提交时,让 Angular 处理。</p> </li> <li> <p>验证表单,以确保只接受完整的数据。</p> </li> <li> <p>将评论数据发送到你的服务。</p> </li> <li> <p>将评论推送到详情页面的列表中。</p> </li> </ol> <p>你将从第一步开始。</p> <h5 id="第一步更新数据服务以接受新的评论">第一步:更新数据服务以接受新的评论</h5> <p>要使用表单发布评论数据,你需要在数据服务中添加一个方法,该方法与正确的 API 端点通信并可以发布数据。你将调用这个新方法 <code>addReviewByLocationId</code>,并让它接受两个参数:位置 ID 和评论数据。</p> <p>方法的内容与其他方法相同,但你将使用 <code>post</code> 而不是 <code>get</code> 来调用 API。以下列表显示了要添加到 loc8r-data.service.ts 中的新方法。</p> <h5 id="列表-1015-在-loc8r-dataservicets-中添加用于添加评论的新公共成员">列表 10.15. 在 loc8r-data.service.ts 中添加用于添加评论的新公共成员</h5> <pre><code>public addReviewByLocationId(locationId: string, formData: any): Promise<any> { const url: string = `${this.apiBaseUrl}/locations/${locationId}/reviews`; return this.http .post(url, formData) .toPromise() .then(response => response as any) .catch(this.handleError); } </code></pre> <p>太棒了;现在当你处理完表单处理时,你将能够从组件中使用这种方法。现在继续到第二步。</p> <h5 id="第二步添加表单提交处理程序">第二步:添加表单提交处理程序</h5> <p>当在 HTML 中使用表单时,你通常有一个动作来告诉浏览器将数据发送到何处,以及一个方法来描述使用哪个 HTTP 动词。如果你想在数据发送之前使用 JavaScript 对表单数据进行一些操作,你可能还会有一个<code>onSubmit</code>事件处理器。</p> <p>在 Angular SPA 中,你不想让表单提交到不同的 URL,从而跳转到新页面。你希望 Angular 处理一切,所以你会移除表单元素的<code>action=""</code>属性,并用 Angular 的<code>ngSubmit</code>事件处理器来调用组件中的公共成员。以下代码片段显示了如何使用事件处理器,将其添加到表单定义中,调用你即将编写的组件中的函数:</p> <pre><code><form (ngSubmit)="onReviewSubmit()"> </code></pre> <p>这行代码在表单提交时调用组件上的一个公共方法<code>onReviewSubmit</code>。你需要创建这个方法,所以你将在 location-details.component.ts 中添加一个简单的方法,当表单提交时创建一个控制台日志:</p> <pre><code>public onReviewSubmit(): void { console.log(this.newReview); } </code></pre> <p>由于你将所有表单字段绑定到了<code>newReview</code>的属性上,这个控制台日志输出了所有提交的数据。现在你可以捕获表单数据了,你将在第 3 步添加一些验证,以确保只接受完整的数据。</p> <h5 id="第-3-步验证提交的表单数据">第 3 步:验证提交的表单数据</h5> <p>在你盲目地将每个表单提交发送到 API 以保存到数据库之前,你想要进行一些快速验证,以确保所有字段都已填写。如果有任何字段未填写,你将显示一个错误消息。你的浏览器可能阻止带有空必填字段的表单提交;如果你遇到这种情况,暂时从表单字段中移除<code>required</code>属性以测试 Angular 验证。</p> <p>当表单提交时,你将首先删除任何现有的错误消息,然后检查表单中的每个数据项是否为真值(即任何形式的<code>true</code>值)。如果任何检查返回<code>false</code>——即字段没有数据——你将在组件中设置一个表单错误消息。如果所有数据都存在,你将继续像以前一样将其记录到控制台。</p> <p>以下列表显示了你需要添加到 location-details.component.ts 中的新验证成员以及你需要如何更改<code>onReviewSubmit</code>函数以使用它。</p> <h5 id="列表-1016-在-location-detailscomponentts-中添加对评论表单的验证">列表 10.16. 在 location-details.component.ts 中添加对评论表单的验证</h5> <pre><code>public formError: string; *1* private formIsValid(): boolean { *2* if (this.newReview.author && this.newReview.rating && this.newReview.reviewText) { return true; } else { return false; } } public onReviewSubmit():void { this.formError = ''; *3* if (this.formIsValid()) { *4* console.log(this.newReview); *4* } else { *5* this.formError = 'All fields required, please try again'; *5* } } </code></pre> <ul> <li> <p><strong><em>1</em> 声明 formError 变量</strong></p> </li> <li> <p><strong><em>2</em> 私有成员以检查所有表单字段是否有内容</strong></p> </li> <li> <p><strong><em>3</em> 重置任何现有的错误消息</strong></p> </li> <li> <p><strong><em>4</em> 如果表单验证通过,将数据记录到控制台</strong></p> </li> <li> <p><strong><em>5</em> 否则,设置错误消息</strong></p> </li> </ul> <p>现在你正在创建一个错误消息,你希望在它生成时将其显示给用户。对于这个任务,你将在表单模板中添加一个新的 Bootstrap alert <code>div</code>并将消息绑定为其内容。你只想在需要显示错误消息时显示<code>div</code>,所以使用熟悉的<code>*ngIf</code>指令来处理这个任务,检查<code>formError</code>是否有值。</p> <p>需要添加到评论表单模板中的内容,在表单顶部附近添加警报,看起来像这样:</p> <pre><code><h4>Add your review</h4> <div *ngIf="formError" class="alert alert-danger" role="alert"> {{ formError }} </div> <div class="form-group row"> </code></pre> <p>现在,如果您在表单中没有添加详细信息就点击提交按钮,您将收到一个警报,类似于图 10.8。</p> <h5 id="图-108-当用户尝试提交不完整的表单时会显示错误消息">图 10.8. 当用户尝试提交不完整的表单时,会显示错误消息。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/10fig08_alt.jpg" alt="图片" loading="lazy"></p> <p>因此,您已经覆盖了无效数据。接下来,您将处理有效数据,并将其发送到 API。</p> <h5 id="第-4-步将表单数据发送到数据服务">第 4 步:将表单数据发送到数据服务</h5> <p>您的表单数据正在提交,并且您已经准备好将数据发送到 API 的数据服务。现在将这两个任务连接起来。您将像之前一样使用数据服务;使用这个新方法没有不同。</p> <p>但首先,您需要将数据服务导入到 location-details.component.ts 中,并将其添加到装饰器中。</p> <h5 id="列表-1017-在-location-detailscomponentts-中导入并提供数据服务">列表 10.17. 在 location-details.component.ts 中导入并提供数据服务</h5> <pre><code>import { Component, Input, OnInit } from '@angular/core'; import { Location } from '../home-list/home-list.component'; import { Loc8rDataService } from '../loc8r-data.service'; @Component({ selector: 'app-location-details', templateUrl: './location-details.component.html', styleUrls: ['./location-details.component.css'] }) </code></pre> <p>在同一文件中,您还需要将服务添加到构造函数中,以便可以使用它:</p> <pre><code>constructor(private loc8rDataService: Loc8rDataService) { } </code></pre> <p>现在服务已在组件中可用,您可以调用新的<code>addReviewByLocationId</code>方法。该方法期望位置 ID 和评论详情,并解决一个 Promise,它返回数据库中保存的评论记录,如下一列表所示。为了验证它是否正常工作,您还将添加一个控制台日志输出返回的评论。</p> <h5 id="列表-1018-在-location-detailscomponentts-中向服务发送新的评论">列表 10.18. 在 location-details.component.ts 中向服务发送新的评论</h5> <pre><code>public onReviewSubmit():void { this.formError = ''; if (this.formIsValid()) { console.log(this.newReview); this.loc8rDataService.addReviewByLocationId(this.location._id, this.newReview) *1* .then(review => { *2* console.log('Review saved', review); *3* }); } else { this.formError = 'All fields required, please try again'; } } </code></pre> <ul> <li> <p><strong><em>1</em> 调用数据服务方法,传递位置 ID 和新的评论数据</strong></p> </li> <li> <p><strong><em>2</em> 该方法解决了一个承诺,返回保存的评论。</strong></p> </li> <li> <p><strong><em>3</em> 记录保存的评论数据</strong></p> </li> </ul> <p>现在您可以发送评论到数据库,并像图 10.9 中演示的那样查看控制台日志。注意控制台日志中的<code>createdOn</code>和<code>_id</code>,这是 Mongoose 在记录保存时生成的。</p> <h5 id="图-109-控制台日志验证评论是否被添加到数据库">图 10.9. 控制台日志验证评论是否被添加到数据库</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/10fig09_alt.jpg" alt="图片" loading="lazy"></p> <p>最后一件事情:将提交的评论推送到表单下方的列表中。当评论发送时,您希望隐藏表单并将评论添加到用户可以看到的列表中。</p> <h5 id="第-5-步更新评论列表并隐藏表单">第 5 步:更新评论列表并隐藏表单</h5> <p>显示新的评论是一个简单的任务,幸运的是。您已经有了评论列表作为数组,它已经按最新排序。现在您需要使用原生的 JavaScript <code>unshift</code>方法将新的评论添加到数组的第一个位置。</p> <p>要隐藏表单,您可以更改<code>formVisible</code>为<code>false</code>,因为这是控制表单上的<code>*ngIf</code>的。同时,您还可以重置表单的值,使其再次变为空白。以下列表显示了您需要在 location-details.component.ts 中添加的所有内容。</p> <h5 id="列表-1019-在-location-detailscomponentts-中隐藏表单并显示评论">列表 10.19. 在 location-details.component.ts 中隐藏表单并显示评论</h5> <pre><code>private resetAndHideReviewForm(): void { *1* this.formVisible = false; this.newReview.author = ''; this.newReview.rating = 5; this.newReview.reviewText = ''; } public onReviewSubmit():void { this.formError = ''; if (this.formIsValid()) { console.log(this.newReview); this.loc8rDataService.addReviewByLocationId(this.location._id, this.newReview) .then(review => { console.log('Review saved', review); let reviews = this.location.reviews.slice(0); *2* reviews.unshift(review); *2* this.location.reviews = reviews; *2* this.resetAndHideReviewForm(); *3* }) } else { this.formError = 'All fields required, please try again'; } } </code></pre> <ul> <li> <p><strong><em>1</em> 一个新的私有成员来隐藏和重置表单</strong></p> </li> <li> <p><strong><em>2</em> 更新位置对象中的评论,更改数组引用,Angular 更新页面。如果你直接操作数组,页面不会更新。</strong></p> </li> <li> <p><strong><em>3</em> 调用私有成员以隐藏并重置表单</strong></p> </li> </ul> <p>就这样,几乎完成了。这段代码在<code>reviews</code>位于<code>Location</code>类型的类定义中之前不会工作,所以你需要在 home-list.component.ts 中将它作为一个类型为<code>any</code>的数组添加,如下所示:</p> <pre><code>export class Location { _id: string; name: string; distance: number; address: string; rating: number; facilities: string[]; reviews: any[]; } </code></pre> <p>真的是这样。你的 Angular SPA 已经完成并且完全可用。做得好!但你可以做一些事情来改进架构并遵循一些最佳实践。</p> <h3 id="103改进架构">10.3。改进架构</h3> <p>你已经拥有了一个完全功能化的 SPA,这真是太棒了!但在你使用它代替 Express 前端之前,你可以通过将路由配置从 app.module.ts 文件中移出,以及将位置类定义从 home-list.component.ts 文件中移出,来改进架构。</p> <h4 id="1031使用单独的路由配置文件">10.3.1。使用单独的路由配置文件</h4> <p>你提高架构并遵循 Angular 最佳实践的第一次任务是,将路由配置移动到一个单独的文件中。为什么这是一个最佳实践?这主要归因于关注点的分离。app.module.ts 文件的目的就是告诉 Angular 编译器有关应用程序及其所需文件的所有信息。如果你只有几个路由,将它们保留在 app.module.ts 文件中是可以的,但如果你添加了更多路由,它们最终会占据整个文件,掩盖其原始目的。</p> <p>目前你的应用程序中有三个路由,但你会通过将路由配置移动到一个单独的文件中来探索这个最佳实践。当你查看第十一章(chapter 11)中的认证时,你将向这个文件添加更多内容。</p> <h5 id="创建路由配置文件">创建路由配置文件</h5> <p>你可以使用 Angular CLI 生成路由配置文件,这次使用<code>module</code>模板。在 app_public 文件夹的终端中运行以下命令:</p> <pre><code>$ ng generate module app-routing </code></pre> <p>此命令生成一个包含 app-routing.module.ts 文件的 app-routing 文件夹(在 src/app 中)。你之前没有见过这样的文件,所以下一个列表显示了该文件的默认内容。</p> <h5 id="列表-1020app-routingmodulets-的默认模块模板">列表 10.20。app-routing.module.ts 的默认模块模板</h5> <pre><code>import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; @NgModule({ imports: [ CommonModule ], declarations: [] }) export class AppRoutingModule { } </code></pre> <p>要将应用程序路由添加到这个文件中,你需要执行以下操作:</p> <ol> <li> <p>从 Angular 路由器导入 router 模块和 routes 类型定义。</p> </li> <li> <p>导入用于每个路由的组件。</p> </li> <li> <p>定义路由的路径和组件。</p> </li> <li> <p>将路由(使用<code>routerModule.forRoot</code>)添加到模块导入中。</p> </li> <li> <p>导出<code>RouterModule</code>以便可以使用设置。</p> </li> </ol> <p>这个过程看起来有很多步骤,但它没有使用你之前没有见过的任何东西。你已经使用过路由模块并定义过路由;现在你只是将它们放在了不同的地方。以下列表显示了 app-routing.module.ts 的所有更新。</p> <h5 id="列表-1021在-app-routingmodulets-中完成路由配置">列表 10.21。在 app-routing.module.ts 中完成路由配置</h5> <pre><code>import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule, Routes } from '@angular/router'; *1* import { AboutComponent } from '../about/about.component'; *2* import { HomepageComponent } from '../homepage/homepage.component'; *2* import { DetailsPageComponent } from '../details-page/details- *2* page.component'; *2* const routes: Routes = [ *3* { path: '', component: HomepageComponent }, { path: 'about', component: AboutComponent }, { path: 'location/:locationId', component: DetailsPageComponent } ]; @NgModule({ imports: [ CommonModule, RouterModule.forRoot(routes) *4* ], exports: [RouterModule], *5* declarations: [] }) export class AppRoutingModule { } </code></pre> <ul> <li> <p><strong><em>1</em> 导入路由模块和路由类型定义</strong></p> </li> <li> <p><strong><em>2</em> 导入路由的组件</strong></p> </li> <li> <p><strong><em>3</em> 将路由定义为类型为 Routes 的数组 . . .</strong></p> </li> <li> <p><strong><em>4</em> . . . 并使用路由模块导入它们</strong></p> </li> <li> <p><strong><em>5</em> 导出路由模块</strong></p> </li> </ul> <p>这就是路由配置文件的全部内容。接下来,你需要更新主<code>app.module.ts</code>文件,使用这个文件而不是内联路由定义。</p> <h5 id="整理app-modulets文件">整理<code>app .module.ts</code>文件</h5> <p>你不需要或想要在两个文件中保留路由定义,因此你可以从主模块文件中删除它们。你也不需要从 Angular 路由导入路由,因此你也可以删除那行。你的新路由配置文件处理导入。</p> <p>虽然你在删除路由,但你确实需要保留所有组件的导入。这些导入仍然由<code>app.module.ts</code>文件需要,因为这个文件告诉编译器使用什么以及在哪里找到源文件。</p> <p>最后,你需要将新的路由文件导入,而不是使用内联配置。这个导入通常放置在核心导入和组件导入之间,这样在查看文件时很容易找到。此外,将路由文件添加到装饰器的<code>导入</code>部分。</p> <p>以下列表显示了添加和删除所有更改后的最终<code>app.module.ts</code>。</p> <h5 id="列表-1022-从appmodulets中移除内联路由定义">列表 10.22. 从<code>app.module.ts</code>中移除内联路由定义</h5> <pre><code>import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/http'; import { AppRoutingModule } from './app-routing/app-routing.module'; *1* import { HomeListComponent } from './home-list/home-list.component'; import { RatingStarsComponent } from './rating-stars/rating-stars.component'; import { DistancePipe } from './distance.pipe'; import { FrameworkComponent } from './framework/framework.component'; import { AboutComponent } from './about/about.component'; import { HomepageComponent } from './homepage/homepage.component'; import { PageHeaderComponent } from './page-header/page-header.component'; import { SidebarComponent } from './sidebar/sidebar.component'; import { HtmlLineBreaksPipe } from './html-line-breaks.pipe'; import { LocationDetailsComponent } from './location-details/location-details.component'; import { DetailsPageComponent } from './details-page/details-page.component'; import { MostRecentFirstPipe } from './most-recent-first.pipe'; @NgModule({ declarations: [ HomeListComponent, RatingStarsComponent, DistancePipe, FrameworkComponent, AboutComponent, HomepageComponent, PageHeaderComponent, SidebarComponent, HtmlLineBreaksPipe, LocationDetailsComponent, DetailsPageComponent, MostRecentFirstPipe ], imports: [ BrowserModule, FormsModule, ReactiveFormsModule, HttpClientModule, AppRoutingModule *2* ], providers: [], bootstrap: [FrameworkComponent] }) export class AppModule { } </code></pre> <ul> <li> <p><strong><em>1</em> 导入你的新路由模块,包含应用程序的路由配置</strong></p> </li> <li> <p><strong><em>2</em> 将其作为对应用程序模块的导入</strong></p> </li> </ul> <p>就这些了。应用程序将像以前一样工作,但我们确信你会同意,通过这次更改,路由配置和主应用程序模块文件都得到了改进。对于小型应用程序,你可能不需要或想要这样做,但如果你有更大的计划,这绝对值得。</p> <p>接下来,你将改进<code>location</code>类的定义。</p> <h4 id="1032-改进location类的定义">10.3.2. 改进<code>location</code>类的定义</h4> <p>你对<code>location</code>类的定义目前保存在<code>home-list.component.ts</code>中。这个定义源于你在第八章中创建的主页列表组件;它是应用程序中唯一做任何事情组件。现在,你在应用程序的许多地方导入<code>location</code>类的定义;它已经成为应用程序本身的一个关键部分。因此,将其分离到自己的文件中是有意义的。</p> <p>当你这样做时,你也会添加缺失的属性,因为它目前只定义了在主页列表中使用的属性;例如评论和营业时间等属性是缺失的。此外,你将为评论创建一个嵌套类,你可以在类定义和应用程序中直接处理评论时使用它。</p> <p>当这一切都完成时,你将拥有一个更好的 TypeScript 应用程序。</p> <h5 id="在其自己的文件中定义location类">在其自己的文件中定义<code>Location</code>类</h5> <p>第一步是创建类定义的文件,再次使用 Angular CLI:</p> <pre><code>$ ng generate class location </code></pre> <p>此命令在应用程序的 src 文件夹中生成一个名为 location.ts 的文件。而且它很稀疏!它看起来应该像这样:</p> <pre><code>export class Location { } </code></pre> <p>这有点令人失望,但至少它没有复杂或意外的地方。你所需要做的就是从 home-list.component.ts 获取 <code>Location</code> 定义并将其粘贴进去。</p> <h5 id="列表-1023-在-locationts-中添加基本的-location-类定义">列表 10.23. 在 location.ts 中添加基本的 <code>Location</code> 类定义</h5> <pre><code>export class Location { _id: string; name: string; distance: number; address: string; rating: number; facilities: string[]; reviews: any[]; } </code></pre> <p>这仍然相当简单。<code>Location</code> 类的定义现在在其自己的文件中。你最好开始使用它。</p> <h5 id="在需要的地方使用新的类文件">在需要的地方使用新的类文件</h5> <p>首次使用新的类定义文件的地方是 home-list.component.ts,因为这是它最初定义的地方。为此,请从该文件中删除原始的内联定义,并用一个简单的导入命令替换:</p> <pre><code>import { Component, OnInit } from '@angular/core'; import { Loc8rDataService } from '../loc8r-data.service'; import { GeolocationService } from '../geolocation.service'; import { Location } from '../location'; </code></pre> <p>这替换了主页列表中的位置定义,这是一个好的开始。但如果你此时仍在运行 <code>ng serve</code>,你将得到类似以下的 Angular 编译错误:</p> <pre><code>Failed to compile. /FILE/PATH/TO/LOC8R/app_public/src/app/location-details/location- details.component.ts (3,10): Module '"/FILE/PATH/TO/LOC8R/app_public/src/app/home-list/home-list.component"' has no exported member 'Location'. </code></pre> <p>这告诉你 location-details.component.ts 正在使用从 <code>home-list</code> 导出的 <code>Location</code> 类,因此你需要更新它。更改你导入 <code>Location</code> 的文件:</p> <pre><code>import { Component, Input, OnInit } from '@angular/core'; import { Location } from '../location'; import { Loc8rDataService } from '../loc8r-data.service'; </code></pre> <p>当你完成时,在其他从 <code>Location</code> 导入的地方也做同样的事情:details-page.component.ts 和 loc8r-data.service.ts。记住,当你将 <code>Location</code> 导入到 loc8rdata.service.ts 时,路径前面有一个点而不是两个点,这是由于这些文件的相对位置。</p> <p>接下来,添加缺失的属性。</p> <h5 id="为-location-类定义添加缺失的路径">为 Location 类定义添加缺失的路径</h5> <p>当你在应用程序中使用你在类定义中未声明的类属性时,你可能会在构建时遇到问题,尽管它可能在 <code>ng serve</code> 下运行良好。</p> <p>你目前从你的类定义中遗漏了 <code>coords</code> 和 <code>openingTimes</code>。<code>coords</code> 是一个简单的添加——一个数字数组。而 <code>openingTimes</code> 是一个不同的情况,因为它本身就是一个复杂对象。</p> <p>记得 Mongoose 如何使用嵌套模式来定义子文档吗?(如果你不记得,请参阅第五章。)嗯,你可以在 TypeScript 中的类上做同样的事情。列表 10.24 展示了如何更新 location.ts 文件以定义一个名为 <code>OpeningTimes</code> 的类,以及如何在 <code>Location</code> 类上定义同名属性,使其成为 <code>OpeningTimes</code> 类型的数组。它还添加了 <code>coords</code> 属性。</p> <h5 id="列表-1024-向-locationts-添加缺失的属性和嵌套类定义">列表 10.24. 向 location.ts 添加缺失的属性和嵌套类定义</h5> <pre><code>class OpeningTimes { *1* days: string; opening: string; closing: string; closed: boolean; } export class Location { _id: string; name: string; distance: number; address: string; rating: number; facilities: string[]; reviews: any[]; coords: number[]; *2* openingTimes: OpeningTimes[]; *3* } </code></pre> <ul> <li> <p><strong><em>1</em> 定义一个新的 OpeningTimes 类</strong></p> </li> <li> <p><strong><em>2</em> 为 Location 添加缺失的 coords 属性</strong></p> </li> <li> <p><strong><em>3</em> 将 openingTimes 属性添加到 Location 类,使其成为 OpeningTimes 类的数组</strong></p> </li> </ul> <p>看起来不错。类定义包含了你需要和使用的所有属性。请注意,<code>OpeningTimes</code> 类本身不可导入到其他文件中,因为它没有被声明为 <code>export</code>。尽管这已经包含了你需要的一切,但你还可以改进 <code>reviews</code> 属性定义。</p> <h5 id="定义评论类避免使用-any-类型">定义评论类,避免使用 ‘any’ 类型</h5> <p>你将 <code>reviews</code> 定义为一个类型为 <code>any</code> 的数组。这应该是一个警告标志,因为 TypeScript 中的最佳实践是尽可能避免使用 <code>any</code>,因为它会削弱类结构。</p> <p>在这里,你可以避免使用 <code>any</code>,因为你知道评论的架构,并且你已经看到了如何定义和使用嵌套类。与 <code>OpeningTimes</code> 定义不同,你希望在应用程序的其他地方使用 <code>Review</code> 类定义,所以你将这个声明为 <code>export</code>。</p> <p>下面的列表显示了如何定义 <code>Review</code> 类,导出它,并在 <code>Location</code> 类定义中使用它。请注意,源代码还应包括 <code>OpeningTimes</code> 定义,但我们为了简洁起见省略了它。</p> <h5 id="列表-1025-在-locationsts-中定义使用和导出评论类">列表 10.25. 在 locations.ts 中定义、使用和导出评论类</h5> <pre><code>export class Review { *1* author: string; rating: number; reviewText: string; } export class Location { _id: string; name: string; distance: number; address: string; rating: number; facilities: string[]; reviews: Review[]; *2* coords: number[]; openingTimes: OpeningTimes[]; } </code></pre> <ul> <li> <p><strong><em>1</em> 定义并导出评论类的定义</strong></p> </li> <li> <p><strong><em>2</em> 声明位置评论为类型 Review</strong></p> </li> </ul> <p>现在你的 <code>Location</code> 类已经完成。你有一个用于评论的嵌套类,它可以在其他地方使用,还有一个用于营业时间的嵌套类,它只适用于此文件。为了更严格地使用 <code>Location</code> 类,你需要在应用程序中使用 <code>Review</code> 类。</p> <h5 id="在需要的地方显式导入和使用评论类">在需要的地方显式导入和使用评论类</h5> <p>你有两个地方可以使用 <code>Review</code> 类:在位置详情组件中,你使用表单添加新的评论,以及在你数据服务中,你将新的评论数据推送到 API。</p> <p>在这些组件的文件(location-details.component.ts 和 loc8rdata.service.js)中,更新 <code>Location</code> 导入以同时导入 <code>Review</code> 类,如下所示:</p> <pre><code>import { Location, Review } from '../location'; </code></pre> <p>在位置详情组件中,有两个地方可以使用 <code>Review</code> 定义来为你的变量添加类型,如下所示。第一个地方是在你定义 <code>newReview</code> 并为其提供默认值时,第二个地方是在从 API 返回保存的评论时。</p> <h5 id="列表-1026-更新-location-detailscomponentts-以使用新的-review-类型">列表 10.26. 更新 location-details.component.ts 以使用新的 Review 类型</h5> <pre><code>public newReview: Review = { *1* author: '', rating: 5, reviewText: '' }; public onReviewSubmit():void { this.formError = ''; if (this.formIsValid()) { console.log(this.newReview); this.loc8rDataService.addReviewByLocationId(this.location._id, this.newReview) .then((review: Review) => { *2* console.log('Review saved', review); let reviews = this.location.reviews.slice(0); reviews.unshift(review); this.location.reviews = reviews; this.resetAndHideReviewForm(); }) } else { console.log('Not valid'); this.formError = 'All fields required, please try again'; } } </code></pre> <ul> <li> <p><strong><em>1</em> 将 Review 类型添加到 newReview 定义中</strong></p> </li> <li> <p><strong><em>2</em> 从 API 返回的保存评论也应为类型 Review。</strong></p> </li> </ul> <p>以类似的方式,你可以通过指定输入和输出应为类型 <code>Review</code> 来收紧你数据服务中的 <code>addReviewByLocationId</code> 方法,将它们从 <code>any</code> 改变。以下列表显示了这三个更改。</p> <h5 id="列表-1027-在-loc8r-dataservicets-中使用-review-类型来收紧定义">列表 10.27. 在 loc8r-data.service.ts 中使用 Review 类型来收紧定义</h5> <pre><code>public addReviewByLocationId(locationId: string, formData: Review) : Promise<Review> { *1* const url: string = `${this.apiBaseUrl}locations/${locationId}/reviews`; return this.http .post(url, formData) .toPromise() .then(response => response.json() as Review) *2* .catch(this.handleError); } </code></pre> <ul> <li> <p><strong><em>1</em> 入站表单数据应与 Review 类型相同,方法预期的返回值也应如此。</strong></p> </li> <li> <p><strong><em>2</em> API 的响应类型也应为 Review,而不是任何类型。</strong></p> </li> </ul> <p>这并不太痛苦,现在你有一个遵循一些良好的 TypeScript 和 Angular 最佳实践的更紧密的应用程序。使用类型定义有助于防止在传递数据时出现意外的错误;很容易忘记哪个参数应该是字符串或数组,对象应该有哪些属性,等等。这种方法可以让你避免这些问题,当其他人试图阅读你的代码或你在休息后返回并忘记细节时,这尤其有帮助。</p> <p>现在,你已经对你的 SPA 满意,并希望将其用作 Loc8r 应用程序的前端,取代当前的 Express 版本。</p> <h3 id="104-使用单页应用spa而不是服务器端应用">10.4. 使用单页应用(SPA)而不是服务器端应用</h3> <p>在本章的最后部分,你将构建你的 Angular 应用程序以用于生产,并将 Express 更新为作为前端而不是 Pug 模板来提供此应用程序。在这个过程中,你将进行调整以确保应用程序中深层次 URL 的直接访问,同时不损害 API 路由。</p> <p>在进行任何操作之前,你需要通过更新 environments/environment 文件来为你的生产环境准备应用程序。如果你查看 environments 文件夹,你会看到两个文件:environment.ts 和 environment.prod.ts。这两个文件都需要更新。在 environment.ts 中,进行以下更改(加粗)。</p> <h5 id="列表-1028-在开发环境中添加环境变量">列表 10.28. 在开发环境中添加环境变量</h5> <pre><code>export const environment = { apiBaseUrl: 'http://localhost:3000/api', *1* production: false }; </code></pre> <ul> <li><strong><em>1</em> 新的环境变量用于开发</strong></li> </ul> <p>你还需要在 environments/environment.prod.ts 中进行类似的更改。</p> <h5 id="列表-1029-在生产环境中添加环境变量">列表 10.29. 在生产环境中添加环境变量</h5> <pre><code>export const environment = { apiBaseUrl: <Heroku API URL>, *1* production: true }; </code></pre> <ul> <li><strong><em>1</em> 将 Heroku URL 添加到 API 端点(附加 /api 的 Heroku URL)</strong></li> </ul> <p>与开发过程中一直使用的 localhost 地址不同,你将使用部署应用程序的 Heroku URL。完成此操作后,你将更新 loc8r-data.service.ts 以使用新创建的环境变量。</p> <p>在 loc8r-data.service.ts 文件顶部的 <code>import</code> 块中,添加以下内容:</p> <pre><code>import { environment } from '../environments/environment'; </code></pre> <p>这意味着你现在可以替换</p> <pre><code>private apiBaseUrl = 'http://localhost:3000/api'; </code></pre> <p>with</p> <pre><code>private apiBaseUrl = environment.apiBaseUrl; </code></pre> <p>通过此更改,Angular 将在构建时选择正确的环境,你现在可以准备好构建用于部署的应用程序。就像你在 第八章 的结尾所做的那样,在终端中运行 app_public 文件夹中的 <code>ng build</code> 命令,指定选项将其标记为生产构建,输出文件夹为 build:</p> <pre><code>$ ng build --prod --output-path build </code></pre> <p>当代码运行完成后,你将在 app_public/build 文件夹中找到一个编译后的 SPA 版本。这个文件夹包含了 SPA 运行所需的一切,包括 HTML 页面、JavaScript 文件、CSS 和字体。</p> <p>接下来,你将告诉 Express 使用它。</p> <h4 id="1041-将-express-请求路由到构建文件夹">10.4.1. 将 Express 请求路由到构建文件夹</h4> <p>要让 Express 为前端服务 Angular 应用,你需要做两件事:禁用所有之前的前端应用路由,并告诉 Express 你的 Angular 构建文件夹应该服务静态文件。</p> <p>要禁用前端基于 Express 的路由,在 app.js 中找到这两行,并删除它们或将其注释掉:</p> <pre><code>const indexRouter = require('./app_server/routes/index'); </code></pre> <p>和</p> <pre><code>app.use('/', indexRouter); </code></pre> <p>你也不再需要 /public 文件夹来服务静态文件,因为 Angular 应用所需的所有文件都位于 Angular 构建文件夹内。不过,请不要删除那行代码,因为你需要 Express 来服务构建文件夹的内容作为静态文件。相反,在 app.js 中找到以下行,</p> <pre><code>app.use(express.static(path.join(__dirname, 'public'))); </code></pre> <p>并在其下方添加类似的行来使用 app_public/build 文件夹,如下所示:</p> <pre><code>app.use(express.static(path.join(__dirname, 'app_public', 'build'))); </code></pre> <p>如果 Express 应用尚未在 <code>nodemon</code> 下运行,请运行 Express 应用,然后在浏览器中转到 localhost:3000。你现在看到的一切都是 Angular 应用,你可以通过检查页面元素来验证它,如图 10.10 所示。</p> <h5 id="图-1010-运行中的-express-应用的主页现在提供了-angular-spa">图 10.10. 运行中的 Express 应用的主页现在提供了 Angular SPA。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/10fig10_alt.jpg" alt="" loading="lazy"></p> <p>通过这些更改,当请求主页时,Express 服务第一个匹配的资源,即 app_public/build 文件夹中的 index.html 文件。它不再匹配 Express 路由并使用 Pug 模板。</p> <p>这对于主页来说效果很好,你可以顺利地浏览应用。但如果你将关于页面或详情页的 URL 复制到地址栏,你会得到一个 <code>404</code> 错误。你需要修复无法直接访问深层 URL 的问题,因为如果你只允许人们通过主页进入,那么这个网站将没有用。</p> <h4 id="1042-确保深层-url-可以正常工作">10.4.2. 确保深层 URL 可以正常工作</h4> <p>这个路由问题不应该让你感到惊讶。你已经告诉 Express 为主页服务一个静态文件,但在构建文件夹内没有 about 文件夹,所以 Express 不可能知道要显示 Angular 应用。</p> <p>解决这个问题的简单方法是通过让 Express 尝试匹配它所知道的所有路由,然后在最后添加一个通配符路由来服务尚未匹配的任何内容。这个通配符路由可以通过使用 * 作为未匹配的 GET 请求的通配符来定义,并且应该通过发送 Angular 应用的 index.html 文件来响应。</p> <p>以下代码片段显示了如何在 app.js 中所有其他路由匹配语句之后添加通配符路由,在这种情况下是在 API 路由定义之后:</p> <pre><code>app.use('/api', apiRoutes); app.get('*', function(req, res, next) { res.sendFile(path.join(__dirname, 'app_public', 'build', 'index.html')); }); </code></pre> <p>在此代码到位后,如果任何 URL 在 Angular 构建文件夹中的 API 路由中没有匹配到 Express,它将响应 Angular 应用的首页。这是好的,但你可以使它更好。</p> <p>而不是使用*来匹配所有内容,你可以使用正则表达式来定义一个模式以匹配你想要应用路由的 URL(或一组 URL)。匹配/about 路由的正则表达式很简单;你需要添加起始和结束字符串定界符,并转义正斜杠,使其看起来像<code>^/about$</code>。</p> <p>由于位置 ID,Details 页面的正则表达式要复杂一些。位置 ID 是一个 24 字符的、看似随机的数字和小写字母混合的 MongoDB <code>ObjectId</code>。匹配这些字符的正则表达式是<code>[a-z0-9]{24}</code>。使用与 About 页面正则表达式相同的方法,位置详情页面的完整正则表达式是<code>^/location/[a-z0-9]{24}$</code>。</p> <p>以下代码片段显示了如何使用组合正则表达式更新 app.js 中的 catchall 路由,以匹配 About 页面或位置详情页面:</p> <pre><code>app.get(/(\/about)|(\/location\/[a-z0-9]{24})/, function(req, res, next) { res.sendFile(path.join(__dirname, 'app_public', 'build', 'index.html')); }); </code></pre> <p>这是一个好的变化,因为现在只有当输入有效的 URL 时,Express 才会发送 Angular 应用程序作为响应。</p> <p>这样,你的 SPA 现在完全工作,由 Express 提供服务,并与 Express API 通信,而 Express API 则从 MongoDB 中获取数据。你拥有一个完整的 MEAN 堆栈应用程序。恭喜!</p> <p>在第十一章和第十二章中,你将看到如何通过添加用户在提交评论前注册和登录的能力来管理认证会话。</p> <h3 id="摘要-6">摘要</h3> <p>在本章中,你学习了</p> <ul> <li> <p>可以使用 URL 参数将数据从路由传递到组件和服务</p> </li> <li> <p>服务用于查询 API</p> </li> <li> <p>Angular 模板如何以<code>*ngIf</code>和<code>ngSwitch</code>的形式具有显示逻辑</p> </li> <li> <p>如何创建自定义管道并使用它们</p> </li> <li> <p>关于将路由配置放置在单独的文件中以改进架构的最佳实践</p> </li> <li> <p>关于创建独立类定义的最佳实践,包括嵌套类,以及通过应用程序改进自定义类型定义的使用</p> </li> <li> <p>如何让 Express 为某些 URL 请求发送 Angular 应用程序而不是服务器端路由</p> </li> </ul> <h2 id="第四部分管理认证和用户会话">第四部分。管理认证和用户会话</h2> <p>识别个别用户的能力是大多数 Web 应用程序的关键功能之一。访客应该能够注册他们的详细信息,以便他们可以在以后日期以回头客的身份重新登录。当用户注册并登录后,应用程序应该能够利用这些数据。</p> <p>在第十一章中,你将了解 MEAN 堆栈中认证的工作方式。重点是创建一个认证 API,你将使用它来为 Angular SPA 的用户中心部分提供动力。</p> <p>第十二章通过集成在第十一章(kindle_split_024.xhtml#ch11)中创建的 API,并更新 Angular 应用程序以利用引入的新功能来结束本章。我们还扩展了一些在第十章(kindle_split_022.xhtml#ch10)中介绍的主题和模式。</p> <h2 id="第十一章认证用户管理会话和确保-api-安全">第十一章。认证用户、管理会话和确保 API 安全</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>在 MEAN 堆栈中添加认证</p> </li> <li> <p>使用 Passport.js 在 Express 中管理认证</p> </li> <li> <p>在 Express 中生成 JSON Web Tokens</p> </li> <li> <p>注册和登录用户</p> </li> <li> <p>在 Express 中保护 API 端点</p> </li> </ul> <p>在本章中,你将通过让用户在发表评论之前先登录来改进现有的应用程序。这个主题非常重要,因为许多 Web 应用程序需要让用户登录并管理会话。</p> <p>图 11.1 显示了你在整体计划中的位置,现在正在与 MongoDB 数据库、Express API 和 Angular 单页应用程序(SPA)一起工作。</p> <h5 id="图-111本章向应用程序添加了认证系统该系统影响架构的大部分部分例如数据库api-和前端-spa">图 11.1。本章向应用程序添加了认证系统,该系统影响架构的大部分部分,例如数据库、API 和前端 SPA。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig01_alt.jpg" alt="" loading="lazy"></p> <p>你的第一步是概述如何在 MEAN 堆栈应用程序中处理认证,在更新 Loc8r 时,一次更新一个部分,从后端到前端逐步处理架构。你将在升级 API 和最终修改前端之前更新数据库和数据模式。到本章结束时,你将能够注册新用户、登录他们、维护会话,并执行只有登录用户才能完成的操作。</p> <h3 id="111-如何在-mean-堆栈中处理认证">11.1. 如何在 MEAN 堆栈中处理认证</h3> <p>如何在 MEAN 应用程序中管理认证被视为堆栈中的伟大奥秘之一,尤其是在使用 SPA 时,这主要是因为整个应用程序代码都发送到浏览器。那么,你如何隐藏其中的一部分?你如何定义谁可以看到或做什么?</p> <h4 id="1111-传统的基于服务器的应用程序方法">11.1.1. 传统的基于服务器的应用程序方法</h4> <p>大部分困惑源于人们对应用程序认证和用户会话管理的传统方法很熟悉。</p> <p>在传统的设置中,应用程序代码坐在服务器上运行。为了登录,用户在一个表单中输入他们的用户名和密码,该表单被发送到服务器。</p> <p>然后服务器会检查数据库以验证登录详情。假设登录是正常的,服务器会在服务器上的用户会话中设置一个标志或会话参数,以声明用户已登录。</p> <p>服务器可能会也可能不会在用户的浏览器上设置一个包含会话信息的 cookie。这是常见的,但技术上并不需要管理认证会话;维护关键会话信息的是服务器。这个流程在图 11.2 中得到了说明。</p> <h5 id="图-112在一个传统的服务器应用程序中服务器验证存储在数据库中的用户凭据并将它们添加到服务器上的用户会话中">图 11.2。在一个传统的服务器应用程序中,服务器验证存储在数据库中的用户凭据,并将它们添加到服务器上的用户会话中。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig02_alt.jpg" alt="图片 11.2 替代" loading="lazy"></p> <p>在这个初始握手和建立的会话之后,当用户请求安全资源或尝试向数据库提交一些数据时,服务器验证他们的会话并决定他们是否可以继续。图 11.3 展示了传统的服务器设置如何通过验证用户会话来管理对受保护资源的访问,当确定授权状态时返回请求的资源。</p> <h5 id="图-113在一个传统的服务器应用程序中服务器在继续进行安全请求之前验证用户的会话">图 11.3。在一个传统的服务器应用程序中,服务器在继续进行安全请求之前验证用户的会话。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig03_alt.jpg" alt="图片 11.3 替代" loading="lazy"></p> <p>图 11.4 继续这个主题,其中用户请求访问读取/更新/删除应用程序数据库中包含的资源,使用提供的数据,并且有一个有效的会话。</p> <h5 id="图-114在一个传统的服务器应用程序中服务器在将数据推送到数据库之前验证用户的会话">图 11.4。在一个传统的服务器应用程序中,服务器在将数据推送到数据库之前验证用户的会话。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig04_alt.jpg" alt="图片 11.4 替代" loading="lazy"></p> <p>那就是传统方法的样子,但它适用于 MEAN 堆栈吗?</p> <h4 id="1112-在-mean-堆栈中使用传统方法">11.1.2. 在 MEAN 堆栈中使用传统方法</h4> <p>这种传统的方法并不完全适合 MEAN 堆栈。这种方法依赖于服务器为每个用户保留一些资源,以便它可以维护会话信息。你可能还记得,从第一章开始,Node 和 Express 不会为每个用户维护会话;所有用户的整个应用程序都在单个线程上运行。</p> <p>也就是说,如果你使用基于 Express 的服务器端应用程序,比如你在第七章中构建的那个,你可以在 MEAN 堆栈中使用这种方法的版本。Express 而不是使用服务器资源来维护会话信息,可以使用数据库来存储数据。MongoDB 可以使用;另一个流行的选择是 Redis,它是一个闪电般的键值存储。</p> <p>我们不会在本书中介绍这种方法。相反,我们将探讨更复杂的场景,即在 SPA 中添加对数据 API 的认证。</p> <h4 id="1113-完整的-mean-堆栈方法">11.1.3. 完整的 MEAN 堆栈方法</h4> <p>在本节中,你将看到身份验证如何适应 MEAN 堆栈,以及使用 JSON Web Tokens 和 Passport.js 等中间件是多么容易。</p> <p>MEAN 堆栈中的身份验证提出了两个问题:</p> <ul> <li> <p>API 是无状态的,因为 Express 和 Node 没有用户会话的概念。</p> </li> <li> <p>应用程序逻辑已经发送到浏览器,因此无法限制要发送的代码。</p> </li> </ul> <p>解决这些问题的逻辑解决方案是在浏览器中维护某种类型的会话状态,并让应用程序决定它可以向当前用户显示什么以及不可以显示什么。这是方法上的唯一基本变化。一些技术差异仍然存在,但这只是主要的转变。</p> <p>在浏览器中安全地保存用户数据以维持会话的一个好方法是使用 JSON Web Token (JWT)。在本节中,我们将使用 <em>JWT</em> 和 <em>token</em> 互换。当您开始创建它们时,您将在第 11.4 节中详细了解这些内容,并在第十二章节中进一步了解,当您在 Angular 应用程序中消费它们时。本质上,JWT 是一个加密成字符串的 JSON 对象,对人类眼睛来说没有意义,但可以被应用程序和服务器解码和理解。</p> <p>下一个部分将介绍这一过程在高级别上的表现,从登录过程开始。</p> <h5 id="管理登录过程">管理登录过程</h5> <p>图 11.5 展示了登录过程的流程。用户将他们的凭据发送到服务器(通过 API);服务器通过使用数据库验证这些凭据,并将令牌返回到浏览器。浏览器将此令牌保存以供以后重用。</p> <h5 id="图-115-mean-应用程序中的登录流程在服务器验证用户凭据后向浏览器返回-json-web-token">图 11.5. MEAN 应用程序中的登录流程,在服务器验证用户凭据后向浏览器返回 JSON Web Token</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig05_alt.jpg" alt="图片" loading="lazy"></p> <p>这种方法与传统方法类似,但不同之处在于,不是将用户的会话数据存储在服务器上,而是存储在浏览器中。</p> <h5 id="在认证会话期间更改视图">在认证会话期间更改视图</h5> <p>当用户处于会话中时,他们需要能够更改页面或视图,并且应用程序需要知道他们应该被允许看到什么。因此,如图 11.6 所示,应用程序将解码 JWT 并使用这些信息向用户显示适当的数据。</p> <h5 id="图-116-使用-jwt-内部的数据spa-可以确定用户可以使用或查看哪些资源">图 11.6. 使用 JWT 内部的数据,SPA 可以确定用户可以使用或查看哪些资源。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig06_alt.jpg" alt="图片" loading="lazy"></p> <p>这就是传统方法改变明显的地方。服务器在用户需要访问 API 和数据库之前,对用户的行为一无所知。</p> <h5 id="安全地调用-api">安全地调用 API</h5> <p>如果应用程序的部分内容仅限于认证用户,那么很可能某些数据库操作只能由认证用户使用。由于 API 是无状态的,除非您告诉它,否则它不知道是谁在发起每个调用。JWT 在这里再次发挥作用。如图 11.7 所示,该令牌被发送到 API 端点,该端点在验证用户是否有权进行该调用之前会解码令牌。</p> <h5 id="图-117-当调用认证的-api-端点时浏览器会发送-jwt-以及数据服务器解码令牌以验证用户的请求">图 11.7. 当调用认证的 API 端点时,浏览器会发送 JWT 以及数据;服务器解码令牌以验证用户的请求。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig07_alt.jpg" alt="图片" loading="lazy"></p> <p>这就涵盖了高层次的方法,您对目标有了很好的了解。您将通过设置 MongoDB 以存储用户详细信息,将第一步添加到 Loc8r 应用程序的认证机制中。</p> <h3 id="112-为-mongodb-创建用户模式">11.2. 为 MongoDB 创建用户模式</h3> <p>用户名和密码自然必须存储在数据库中。在您的案例中,您将使用用户集合。要在 MEAN 堆栈中做到这一点,您需要创建一个 Mongoose 模式。密码绝对不应该以纯文本形式存储在数据库中,因为这样做如果数据库遭到破坏,将呈现一个巨大的安全漏洞。在生成模式时,您必须做些其他事情。</p> <h4 id="1121-单向密码加密哈希和盐">11.2.1. 单向密码加密:哈希和盐</h4> <p>这里要做的就是在密码上运行单向加密。单向加密可以防止任何人解密密码,同时仍然可以轻松验证正确的密码。当用户尝试登录时,应用程序可以加密一个给定的密码并查看它是否与存储的值匹配。</p> <p>加密还不够。如果几个人使用“password”作为他们的密码(这种情况确实会发生!),每个的加密都是相同的。任何查看数据库的黑客都可以看到这个模式并识别出潜在的弱密码。</p> <p>这就是盐的概念出现的地方。盐是一个由应用程序为每个用户生成的随机字符串,在加密之前与密码结合。生成的加密值称为哈希,如图 11.8 所示。</p> <h5 id="图-118-通过将用户的密码与随机盐结合并加密创建一个哈希">图 11.8. 通过将用户的密码与随机盐结合并加密,创建一个哈希。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig08_alt.jpg" alt="图片" loading="lazy"></p> <p>盐和哈希都存储在数据库中,而不仅仅是单个密码字段。在这种方法中,所有哈希都应该唯一,密码得到了很好的保护。</p> <h4 id="1122-构建-mongoose-模式">11.2.2. 构建 Mongoose 模式</h4> <p>您将首先创建一个将包含模式并将其<code>require</code>到应用程序中的文件。在 app_api/models/文件夹中,创建一个名为 users.js 的新文件。</p> <p>接下来,您将通过在相同文件夹中的 db.js 文件中引用它,将该文件拉入应用程序。它应该与现有行一起<code>require</code>,该行引入了位置模型,如下面的代码片段所示,位于文件底部:</p> <pre><code>// BRING IN YOUR SCHEMAS & MODELS require('./locations'); require('./users'); </code></pre> <p>现在您已经准备好构建基本模式。</p> <h4 id="1123-基本用户模式">11.2.3. 基本用户模式</h4> <p>您想在用户模式中包含什么?您知道您需要一个显示名称来显示在评论中,还需要密码的哈希和盐。在本节中,您还将添加电子邮件地址并将其作为用户登录的唯一标识符。</p> <p>在新的 user.js 文件中,您将<code>require</code> Mongoose 并定义一个新的<code>userSchema</code>。</p> <h5 id="列表-111-用户的基本-mongoose-模式">列表 11.1. 用户的基本 Mongoose 模式</h5> <pre><code>const mongoose = require('mongoose'); const userSchema = new mongoose.Schema({ email: { *1* type: String, *1* unique: true, *1* required: true *1* }, name: { *2* type: String, *2* required: true *2* }, hash: String, *3* salt: String *3* }); mongoose.model('User', userSchema); </code></pre> <ul> <li> <p><strong><em>1</em> 电子邮件应该是必需的且唯一的。</strong></p> </li> <li> <p><strong><em>2</em> 名称也是必需的,但不一定是唯一的。</strong></p> </li> <li> <p><strong><em>3</em> 散列和盐都是字符串。</strong></p> </li> </ul> <p>电子邮件和名称都是从注册表单设置的,但散列和盐都是由系统创建的。当然,散列是从盐派生的,密码是通过表单提供的。</p> <p>接下来,您将看到如何通过使用我们尚未涉及过的 Mongoose 功能:方法来设置盐和散列。</p> <h4 id="1124-使用-mongoose-方法设置加密路径">11.2.4. 使用 Mongoose 方法设置加密路径</h4> <p>Mongoose 允许您向模式添加方法,这些方法作为模型方法公开。这些方法使代码能够直接访问模型属性。</p> <p>理想的结果是能够执行以下伪代码中的操作。</p> <h5 id="列表-112-使用-mongoose-设置密码的伪代码">列表 11.2. 使用 Mongoose 设置密码的伪代码</h5> <pre><code>const User = mongoose.model('User'); *1* const user = new User(); *2* user.name = "User's name"; *3* user.email = "test@example.com"; user.setPassword("myPassword"); *4* user.save(); *5* </code></pre> <ul> <li> <p><strong><em>1</em> 实例化用户模型</strong></p> </li> <li> <p><strong><em>2</em> 创建新用户</strong></p> </li> <li> <p><strong><em>3</em> 设置名称和电子邮件值</strong></p> </li> <li> <p><strong><em>4</em> 调用 setPassword 方法来设置密码。此方法允许您以受控和安全的方式处理密码散列。</strong></p> </li> <li> <p><strong><em>5</em> 保存新用户</strong></p> </li> </ul> <p>接下来,您将看到如何向 Mongoose 添加一个方法以实现此目的。</p> <h5 id="向-mongoose-模式添加方法">向 Mongoose 模式添加方法</h5> <p>方法可以在定义模式后、在编译模型之前添加,因为它是常规 JavaScript。在应用程序代码中,方法设计为在实例化模型后使用。</p> <p>通过连接到模式的<code>.methods</code>对象来向模式添加方法。传递参数也很容易。例如,以下片段是实际<code>setPassword</code>方法的轮廓:</p> <pre><code>userSchema.methods.setPassword = function (password) { this.salt = SALT_VALUE; this.hash = HASH_VALUE; }; </code></pre> <p>对于 JavaScript 片段来说,Mongoose 方法中的<code>this</code>通常指的是模型本身。因此,在上面的例子中,在方法中设置<code>this.salt</code>和<code>this.hash</code>就是在模型中设置它们。</p> <p>然而,在您能够保存任何内容之前,您需要生成一个随机的盐值并加密散列。幸运的是,有一个本地的 Node 模块可用于此目的:<code>crypto</code>。</p> <h5 id="使用-crypto-模块进行加密">使用 crypto 模块进行加密</h5> <p>加密是一个如此常见的需求,Node 有一个内置的模块叫做<code>crypto</code>。此模块包含用于管理数据加密的几个方法。在本节中,我们将查看以下两个:</p> <ul> <li> <p><code>randomBytes</code>—用于生成一个用于盐的加密强数据字符串。</p> </li> <li> <p><code>pbkdf2Sync</code>—从密码和盐创建散列。<em>pbkdf2</em>代表<em>基于密码的密钥派生函数 2</em>,这是行业标准。</p> </li> </ul> <p>您将使用这些方法来创建用于盐的随机字符串,并将密码和盐加密到散列中。第一步是在 users.js 文件顶部<code>require crypto</code>:</p> <pre><code>const mongoose = require( 'mongoose' ); const crypto = require('crypto'); </code></pre> <p>其次,更新 <code>setPassword</code> 方法以设置用户的盐和哈希。要设置盐,你将使用 <code>randomBytes</code> 方法生成一个随机的 16 字节字符串。然后,你将使用 <code>pbkdf2Sync</code> 方法从密码和盐创建加密哈希。以下列表显示了如何结合使用这两个函数。</p> <h5 id="列表-113-在-user-模型中设置密码">列表 11.3. 在 <code>User</code> 模型中设置密码</h5> <pre><code>userSchema.methods.setPassword = function (password) { this.salt = crypto.randomBytes(16).toString('hex'); *1* this.hash = crypto .pbkdf2Sync(password, this.salt, 1000, 64, 'sha512') .toString('hex'); *2* }; </code></pre> <ul> <li> <p><strong><em>1</em> 生成一个随机字符串作为盐</strong></p> </li> <li> <p><strong><em>2</em> 创建一个加密哈希</strong></p> </li> </ul> <p>现在,当调用 <code>setPassword</code> 方法并提供一个密码时,将为用户生成盐和哈希,并将它们添加到模型实例中。密码永远不会被保存到任何地方,甚至不会存储在内存中。</p> <h4 id="1125-验证提交的密码">11.2.5. 验证提交的密码</h4> <p>存储密码的另一个方面是当用户尝试登录时能够检索它;你需要能够验证他们的凭据。由于已经加密了密码,你不能解密它,所以你需要做的是使用与用户尝试登录时使用的密码相同的加密,并查看它是否与存储的值匹配。</p> <p>你可以在简单的 Mongoose 方法中执行哈希和验证。将以下方法添加到 users.js 中。它将在找到具有给定电子邮件地址的用户时从控制器中调用,并根据哈希是否匹配返回 <code>true</code> 或 <code>false</code>。</p> <h5 id="列表-114-验证提交的密码">列表 11.4. 验证提交的密码</h5> <pre><code>userSchema.methods.validPassword = function (password) { const hash = crypto .pbkdf2Sync(password, this.salt, 1000, 64, 'sha512') .toString('hex'); *1* return this.hash === hash; *2* }; </code></pre> <ul> <li> <p><strong><em>1</em> 对提供的密码进行哈希处理</strong></p> </li> <li> <p><strong><em>2</em> 将密码哈希与存储的哈希进行比较</strong></p> </li> </ul> <p>就这些了。简单,对吧?当你生成 API 控制器时,你将看到这些方法在行动。控制器需要帮助做的最后一件事是生成一个 JWT 以包含一些模型数据。</p> <h4 id="1126-生成-json-web-token">11.2.6. 生成 JSON Web Token</h4> <p>JWT(发音为 <em>jot</em>)用于在服务器上的 API 和浏览器中的 SPA 之间传递数据。生成令牌的服务器还可以使用 JWT 在后续请求中验证用户。</p> <p>下一个部分简要介绍了 JWT 的组成部分。</p> <h5 id="jwt-的三个部分">JWT 的三个部分</h5> <p>JWT 由三个看起来随机的、点分隔的字符串组成。这些字符串可能很长。以下是一个现实世界的例子:</p> <pre><code>eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJfaWQiOiI1NTZiZWRmNDhmOTUzOTViMTlhNjc1 ODgiLCJlbWFpbCI6InNpbW9uQGZ1bGxzdGFja3RyYWluaW5nLmNvbSIsIm5hbWUiOiJTaW1v biBIb2xtZXMiLCJleHAiOjE0MzUwNDA0MTgsImlhdCI6MTQzNDQzNTYxOH0.GD7UrfnLk295 rwvIrCikbkAKctFFoRCHotLYZwZpdlE </code></pre> <p>这个字符串对人类眼睛来说没有意义,但你应该能够找到两个点,因此是三个独立的部分。这三个部分是</p> <ul> <li> <p><em><strong>头部——</strong></em> 包含类型和所使用的哈希算法的编码 JSON 对象</p> </li> <li> <p><em><strong>有效载荷——</strong></em> 包含数据的编码 JSON 对象,是令牌的真实主体</p> </li> <li> <p><em><strong>签名——</strong></em> 使用只有原始服务器才知道的密钥对头部和有效载荷进行加密的哈希值</p> </li> </ul> <p>注意,前两部分没有被加密;它们是<em>编码</em>的,因此浏览器——或者实际上,其他应用程序——可以轻松地解码它们。大多数现代浏览器都有一个名为 <code>atob()</code> 的原生函数,可以解码 Base64 字符串。还有一个名为 <code>btoa()</code> 的姐妹函数,可以将数据编码为 Base64 字符串。</p> <p>第三部分,签名是加密的。要解密它,你需要使用在服务器上设置的密钥。这个密钥应该保留在服务器上,永远不要在公共场合透露。</p> <p>好消息是,有库可以处理这个过程中的所有复杂部分。在下一节中,你将安装其中一个库到你的应用程序中,并创建一个用于生成 JWT 的模式方法。</p> <h5 id="从-express-生成-jwt">从 Express 生成 JWT</h5> <p>生成 JWT 的第一步是在命令行中包含一个名为 <code>jsonweb-token</code> 的 npm 模块:</p> <pre><code>$ npm install --save jsonwebtoken </code></pre> <p>然后,你 <code>require</code> 它在 users.js 文件的顶部:</p> <pre><code>const mongoose = require('mongoose'); const crypto = require('crypto'); const jwt = require('jsonwebtoken'); </code></pre> <p>最后,你创建一个模式方法,你将调用 <code>generateJwt</code>。要生成 JWT,你需要提供有效载荷——即数据——和一个密钥值。在有效载荷中,你将发送用户的不 <code>_id</code>、<code>email</code> 和 <code>name</code>。你还应该为令牌设置一个过期日期,在此之后,用户将不得不再次登录以生成一个新的令牌。你将使用 JWT 有效载荷中的一个保留字段,<code>exp</code>,它期望过期数据是一个 UNIX 数字值。</p> <p>要生成 JWT,请在 <code>jsonwebtoken</code> 库上调用一个 <code>sign</code> 方法,将有效载荷作为 JSON 对象发送,并将密钥作为字符串发送。此方法返回一个令牌,你可以从方法中返回它。下一个列表显示了所有内容。</p> <h5 id="列表-115-创建一个用于生成-jwt-的模式方法">列表 11.5. 创建一个用于生成 JWT 的模式方法</h5> <pre><code>userSchema.methods.generateJwt = function () { const expiry = new Date(); expiry.setDate(expiry.getDate() + 7); *1* return jwt.sign({ *2* _id: this._id, *3* email: this.email, *3* name: this.name, *3* exp: parseInt(expiry.getTime() / 1000, 10), *4* }, 'thisIsSecret' ); *5* }; </code></pre> <ul> <li> <p><strong><em>1</em> 创建一个过期日期对象,并设置为七天</strong></p> </li> <li> <p><strong><em>2</em> 调用 jwt.sign 方法,并返回它返回的内容</strong></p> </li> <li> <p><strong><em>3</em> 将有效载荷传递给方法</strong></p> </li> <li> <p><strong><em>4</em> 将 exp 包含为秒的 UNIX 时间</strong></p> </li> <li> <p><strong><em>5</em> 将密钥发送给散列算法使用</strong></p> </li> </ul> <p>当调用这个 <code>generateJwt</code> 方法时,它会使用当前用户模型中的数据来创建一个唯一的 JWT 并返回它,如图 11.9 所示。</p> <h5 id="图-119-通过结合一个基于你想要存储的信息的签名对象和一个密钥散列来创建-jwt签名被创建并作为-jwt-返回">图 11.9. 通过结合一个基于你想要存储的信息的签名对象和一个密钥散列来创建 JWT。签名被创建并作为 JWT 返回。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig09_alt.jpg" alt="" loading="lazy"></p> <p>这段代码有一个问题:密钥不应该可见,这会引发安全问题。你将在下一节中解决这个问题。</p> <h5 id="使用环境变量保持密钥的秘密">使用环境变量保持密钥的秘密</h5> <p>如果你打算在版本控制中推送你的代码(例如在 GitHub 中),你不想将密钥发布出去。泄露你的密钥会大大削弱你的安全模型。有了你的密钥,任何人都可以发布假令牌,你的应用程序会认为它们是真实的。为了保持秘密的秘密,通常将它们设置为环境变量是一个好主意。</p> <p>这是一种简单的技术,让你可以在机器上的代码中跟踪环境变量。首先,在项目的根目录中创建一个名为 .env 的文件,并按如下设置密钥:</p> <pre><code>JWT_SECRET=thisIsSecret </code></pre> <p>在这种情况下,密钥是 <code>thisIsSecret</code>,但它可以是您想要的任何字符串,只要它是一个字符串。接下来,您需要确保这个文件不会被包含在任何 Git 提交中,通过在项目的 <code>.gitignore</code> 文件中添加一行来实现。如果您正在跟随 GitHub 上的代码,这一行已经就位;如果没有,您需要添加它。至少,<code>.gitignore</code> 文件应该包含以下内容:</p> <pre><code># Dependency directory node_modules # Environment variables .env </code></pre> <p>为了读取和使用这个新文件来设置环境变量,您需要安装并使用一个名为 <code>dotenv</code> 的新 npm 模块。在终端中使用以下命令:</p> <pre><code>$ npm install dotenv --save </code></pre> <p><code>dotenv</code> 模块应该作为文件的第一行被 <code>require</code> 到 <code>app.js</code> 文件中,如下所示:</p> <pre><code>require('dotenv').load(); const express = require('express'); </code></pre> <p>现在剩下的就是更新用户模式,用环境变量替换硬编码的密钥,如下列所示,并加粗显示。</p> <h5 id="列表-116-使用环境设置更新-generatejwt">列表 11.6. 使用环境设置更新 <code>generateJwt</code></h5> <pre><code>userSchema.methods.generateJwt = () => { const expiry = new Date(); expiry.setDate(expiry.getDate() + 7); return jwt.sign({ _id: this._id, email: this.email, name: this.name, exp: parseInt(expiry.getTime() / 1000), }, process.env.JWT_SECRET); *1* }; </code></pre> <ul> <li><strong><em>1</em> 不要在代码中保留密钥;使用环境变量代替。</strong></li> </ul> <p>您的生产环境也需要了解这个环境变量。您可能还记得在 Heroku 上设置数据库 URI 时的命令。这里也是一样,所以在终端中运行以下命令:</p> <pre><code>$ heroku config:set JWT_SECRET=thisIsSecret </code></pre> <p>这就是最后一步。</p> <p>在 MongoDB 和 Mongoose 方面处理完毕后,接下来您将查看如何使用 Passport 来管理身份验证。</p> <h3 id="113-使用-passport-创建身份验证-api">11.3. 使用 Passport 创建身份验证 API</h3> <p>Passport 是由 Jared Hanson 开发的一个 Node 模块,旨在使 Node 中的身份验证变得简单。其关键优势之一是它可以适应多种身份验证方法,称为 <em>策略</em>。这些策略的例子包括</p> <ul> <li> <p>Facebook</p> </li> <li> <p>Twitter</p> </li> <li> <p>OAuth</p> </li> <li> <p>本地用户名和密码</p> </li> </ul> <p>您可以在 npm 网站上通过搜索 <em>passport</em> 找到更多策略。使用 Passport,您可以轻松地使用这些方法之一或多个,让用户登录到您的应用程序。对于 Loc8r,您将使用 <em>local</em> 策略,因为您在数据库中存储用户名和密码散列。您将首先安装模块。</p> <h4 id="1131-安装和配置-passport">11.3.1. 安装和配置 Passport</h4> <p>Passport 被分离成一个核心模块和针对每个策略的独立模块。您将通过 npm 安装核心模块和本地策略模块,在终端中使用以下命令:</p> <pre><code>$ npm install –-save passport passport-local </code></pre> <p>当这两个模块都安装完毕后,您可以创建本地策略的配置。</p> <h5 id="创建-passport-配置文件">创建 Passport 配置文件</h5> <p>这是您应用程序中将使用 Passport 的 API,因此您将在 <code>app_api</code> 文件夹内创建配置。在 <code>app_api</code> 内,创建一个名为 <code>config</code> 的新文件夹,并在该文件夹内创建一个名为 <code>passport.js</code> 的新文件。</p> <p>在此文件顶部,<code>require</code> Passport 和本地策略模块,以及 Mongoose 和用户模型:</p> <pre><code>const passport = require('passport'); const LocalStrategy = require('passport-local').Strategy; const mongoose = require('mongoose'); const User = mongoose.model('User'); </code></pre> <p>现在您可以配置本地策略了。</p> <h5 id="配置本地策略">配置本地策略</h5> <p>要设置 Passport 策略,您使用<code>passport.use</code>方法并传递一个新的策略构造函数。此构造函数接受一个选项参数和一个执行大部分工作的函数。使用 Passport 策略的骨架如下所示:</p> <pre><code>passport.use(new LocalStrategy({}, (username, password, done) => { } )); </code></pre> <p>默认情况下,Passport 本地策略期望并使用字段<code>username</code>和<code>password</code>。您有<code>password</code>,所以这一点没问题,但您使用的是<code>email</code>而不是<code>username</code>。Passport 允许您在选项对象中覆盖用户名字段,如下面的代码片段所示:</p> <pre><code>passport.use(new LocalStrategy({ usernameField: 'email' }, (username, password, done) => { } )); </code></pre> <p>接下来是主函数,这是一个 Mongoose 调用,用于根据函数中提供的用户名和密码查找用户。您的 Mongoose 函数需要执行以下操作:</p> <ul> <li> <p>根据提供的电子邮件地址查找用户。</p> </li> <li> <p>检查密码是否有效。</p> </li> <li> <p>如果找到用户且密码有效,则返回用户对象。</p> </li> <li> <p>否则,返回一个说明错误的消息。</p> </li> </ul> <p>由于电子邮件地址在模式中设置为唯一,您可以使用 Mongoose 的<code>findOne</code>方法。另一个需要注意的有趣点是,您将使用您之前创建的<code>validPassword</code>模式方法来检查提供的密码是否正确。</p> <p>以下列表显示了本地策略的完整内容。</p> <h5 id="列表-117-完整的-passport-本地策略定义">列表 11.7. 完整的 Passport 本地策略定义</h5> <pre><code>passport.use(new LocalStrategy({ usernameField: 'email' }, (username, password, done) => { User.findOne({ email: username }, (err, user) => { *1* if (err) { return done(err); } if (!user) { *2* return done(null, false, { *2* message: 'Incorrect username.' *2* }); *2* } if (!user.validPassword(password)) { *3* return done(null, false, { *4* message: 'Incorrect password.' *4* }); *4* } return done(null, user); *5* }); } )); </code></pre> <ul> <li> <p><strong><em>1</em> 在 MongoDB 中搜索提供的电子邮件地址的用户</strong></p> </li> <li> <p><strong><em>2</em> 如果没有找到用户,返回 false 和消息</strong></p> </li> <li> <p><strong><em>3</em> 调用 validPassword 方法,传递提供的密码</strong></p> </li> <li> <p><strong><em>4</em> 如果密码不正确,返回 false 和消息</strong></p> </li> <li> <p><strong><em>5</em> 如果您已经到达了末尾,您可以返回用户对象。</strong></p> </li> </ul> <p>现在您已经安装了 Passport 并配置了策略,您需要将其注册到应用程序中。</p> <h5 id="将护照和配置添加到应用程序中">将护照和配置添加到应用程序中</h5> <p>要将您的护照设置添加到应用程序中,您需要在 app.js 中执行以下三个步骤:</p> <ul> <li> <p>需要 Passport。</p> </li> <li> <p>需要策略配置。</p> </li> <li> <p>初始化 Passport。</p> </li> </ul> <p>这些事情都没有什么复杂的;重要的是它们在 app.js 中的位置。</p> <p>Passport 应该在模型定义之前需要策略,在模型定义之后配置配置。两者都应该在定义路由之前就位。如果您稍微重新组织 app.js 的顶部,就可以引入 Passport 和配置,如下面的列表所示。</p> <h5 id="列表-118-将-passport-引入-express">列表 11.8. 将 Passport 引入 Express</h5> <pre><code>require('dotenv').load(); const createError = require('http-errors'); const express = require('express'); const path = require('path'); const favicon = require('serve-favicon'); const logger = require('morgan'); const cookieParser = require('cookie-parser'); const bodyParser = require('body-parser'); const passport = require('passport'); *1* require('./app_api/models/db'); require('./app_api/config/passport'); *2* </code></pre> <ul> <li> <p><strong><em>1</em> 在模型定义之前需要 Passport</strong></p> </li> <li> <p><strong><em>2</em> 在模型定义之后需要策略</strong></p> </li> </ul> <p>策略需要在模型定义之后定义,因为它需要用户模型存在。</p> <p>Passport 应该在定义了静态路由之后和将要使用身份验证的路由(在您的案例中是 API 路由)之前在 app.js 中初始化,以便 Express 可以按需应用身份验证中间件。以下列表显示了已放置的<code>passport</code>中间件。</p> <h5 id="列表-119-添加passport中间件">列表 11.9. 添加<code>passport</code>中间件</h5> <pre><code>app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'app_public', 'build'))); app.use(passport.initialize()); *1* ... app.use('/api', apiRouter); </code></pre> <ul> <li><strong><em>1</em> 初始化 Passport 并将其添加为中间件</strong></li> </ul> <p>你需要做的最后一件事是更新 <code>Access-Control-Allow-Headers</code>,以确保应用程序这两个部分之间 CORS 正确运行。</p> <h5 id="列表-1110-更新-cors-设置">列表 11.10. 更新 CORS 设置</h5> <pre><code>app.use('/api', (req, res, next) => { res.header('Access-Control-Allow-Origin', 'http://localhost:4200'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); *1* next(); }); </code></pre> <ul> <li><strong><em>1</em> 添加授权为可接受的头信息</strong></li> </ul> <p>这样一来,Passport 就已安装、配置并初始化在你的应用程序中。接下来,你将创建允许用户注册和登录的 API 端点。</p> <h4 id="1132-创建返回-jwt-的-api-端点">11.3.2. 创建返回 JWT 的 API 端点</h4> <p>为了使用户能够通过你的 API 登录和注册,你需要两个新的端点。你需要添加两个新的路由定义和两个新的相应控制器。当你有了端点,你可以使用 Postman 测试它们,并使用 Mongo shell 查看数据库来验证注册端点是否工作。首先,你将添加路由。</p> <h5 id="添加身份验证路由定义">添加身份验证路由定义</h5> <p>API 的路由定义存储在 <code>app_api/routes</code> 目录下的 index.js 文件中,所以你将从那里开始。你的控制器被分成逻辑集合——目前是位置和评论。添加一个用于身份验证的第三个集合是有意义的。以下片段显示了在文件顶部添加了这个集合:</p> <pre><code>const ctrlLocations = require('../controllers/locations'); const ctrlReviews = require('../controllers/reviews'); const ctrlAuth = require('../controllers/authentication'); </code></pre> <p>你还没有创建这个 controllers/authentication 文件;你将在编写相关控制器时创建它。</p> <p>接下来,将路由定义本身添加到文件的末尾(但在 <code>module.exports</code> 行之前)。你需要两个,一个用于注册,一个用于登录,分别创建在 /api/register 和 /api/login:</p> <pre><code>router.post('/register', ctrlAuth.register); router.post('/login', ctrlAuth.login); </code></pre> <p>这些定义必须是 <code>post</code> 动作,因为它们正在接受数据。还请记住,你不需要指定路由中的 /api 部分,这部分是在 app.js 中引入路由时添加的。</p> <p>现在你需要添加控制器,然后才能进行测试。</p> <h5 id="创建注册控制器">创建注册控制器</h5> <p>我们首先看看 <code>register</code> 控制器。首先,你将创建路由定义中指定的文件。在 <code>app_api/controllers</code> 文件夹中,创建一个名为 authentication.js 的新文件,并输入以下内容以引入你需要的东西。</p> <h5 id="列表-1111-导入-register-控制器所需的依赖">列表 11.11. 导入 <code>register</code> 控制器所需的依赖</h5> <pre><code>const passport = require('passport'); const mongoose = require('mongoose'); const User = mongoose.model('User'); </code></pre> <p>注册过程完全不使用 Passport。你可以使用 Mongoose 做你需要的事情,因为你已经在模式上设置了各种辅助方法。</p> <p><code>register</code> 控制器需要执行以下操作:</p> <ol> <li> <p>验证是否已发送所需的字段。</p> </li> <li> <p>创建 <code>User</code> 的新模型实例。</p> </li> <li> <p>设置用户的姓名和电子邮件地址。</p> </li> <li> <p>使用 <code>setPassword</code> 方法创建并添加盐和散列。</p> </li> <li> <p>保存用户。</p> </li> <li> <p>保存时返回 JWT。</p> </li> </ol> <p>这个列表看起来有很多事情要做,但幸运的是,一切都很简单;你已经通过创建 Mongoose 方法完成了艰苦的工作。现在,你需要将一切联系起来。以下列表显示了 <code>register</code> 控制器的完整代码。</p> <h5 id="列表-1112-register-控制器用于-api">列表 11.12. <code>register</code> 控制器用于 API</h5> <pre><code>const register = (req, res) => { if (!req.body.name || !req.body.email || !req.body.password) { *1* return res *1* .status(400) *1* .json({"message": "All fields required"}); *1* } *1* const user = new User(); *2* user.name = req.body.name; *2* user.email = req.body.email; *2* user.setPassword(req.body.password); *3* user.save((err) => { *4* if (err) { res .status(404) .json(err); } else { const token = user.generateJwt(); *5* res *5* .status(200) *5* .json({token}); *5* } }); }; module.exports = { register }; </code></pre> <ul> <li> <p><strong><em>1</em> 如果找不到所有必需的字段,则返回错误状态</strong></p> </li> <li> <p><strong><em>2</em> 创建一个新的用户实例,并设置名称和电子邮件</strong></p> </li> <li> <p><strong><em>3</em> 使用<code>setPassword</code>方法设置盐和哈希</strong></p> </li> <li> <p><strong><em>4</em> 将新用户保存到 MongoDB</strong></p> </li> <li> <p><strong><em>5</em> 使用模式方法生成 JWT,并将其发送到浏览器</strong></p> </li> </ul> <p>在这段代码中,没有什么特别新颖或复杂,但它突出了 Mongoose 方法的力量。如果所有内容都内联编写,这个注册控制器可能会很复杂,如果你从这里开始而不是从 Mongoose 开始,这可能会很有吸引力。但正如它现在所展示的,控制器易于阅读和理解,这正是你想要的代码。</p> <p>接下来,你将创建登录控制器。</p> <h5 id="创建登录控制器">创建登录控制器</h5> <p><code>login</code> 控制器依赖于 Passport 来完成困难的工作。你将首先验证是否已填写所有必需的字段,然后将一切交给 Passport。Passport 会执行其操作——尝试使用你指定的策略来验证用户,然后告诉你是否成功。如果成功,你可以再次使用<code>generateJwt</code>模式方法来创建 JWT,然后再将其发送到浏览器。</p> <p>所有这些,包括启动<code>passport.authenticate</code>方法所需的语法,都在下一个列表中展示。这段代码应添加到新的 authentication.js 文件中。</p> <h5 id="列表-1113-login-控制器用于-api">列表 11.13. <code>login</code> 控制器用于 API</h5> <pre><code>const login = (req, res) => { if (!req.body.email || !req.body.password) { *1* return res *1* .status(400) *1* .json({"message": "All fields required"}); *1* } passport.authenticate('local', (err, user, info) => { *2* let token; if (err) { *3* return res *3* .status(404) *3* .json(err); *3* } if (user) { *4* token = user.generateJwt(); *4* res *4* .status(200) *4* .json({token}); *4* } else { *5* res *5* .status(401) *5* .json(info); *5* } })(req, res); *6* }; </code></pre> <ul> <li> <p><strong><em>1</em> 验证是否已提供所有必需的字段</strong></p> </li> <li> <p><strong><em>2</em> 将策略名称和回调传递给认证方法</strong></p> </li> <li> <p><strong><em>3</em> 如果 Passport 返回错误,则返回错误</strong></p> </li> <li> <p><strong><em>4</em> 如果 Passport 返回了一个用户实例,则生成并发送 JWT</strong></p> </li> <li> <p><strong><em>5</em> 否则,返回一个信息消息(为什么认证失败)</strong></p> </li> <li> <p><strong><em>6</em> 确保 req 和 res 对 Passport 可用</strong></p> </li> </ul> <p>在文件的底部将<code>login</code>函数添加到模块导出中,在<code>register</code>函数下方:</p> <pre><code>module.exports = { register, login }; </code></pre> <p>在<code>login</code>控制器中,你可以看到,所有复杂的工作再次被抽象出来,这次主要是通过 Passport。代码易于阅读、遵循和理解,这应该是你编码时的一个目标。现在,你已经构建了 API 中的这两个端点,你应该测试它们。</p> <h5 id="测试端点并检查数据库">测试端点并检查数据库</h5> <p>当你在第六章中构建 API 的大部分内容时,你使用 Postman 测试了端点。你在这里也可以这样做。图 11.10 显示了测试注册端点及其如何返回 JWT。要测试的 URL 是 <a href="http://localhost:3000/api/register%EF%BC%8C%E5%88%9B%E5%BB%BA" target="_blank">http://localhost:3000/api/register,创建</a><code>name</code>、<code>email</code>和<code>password</code>的表单字段。请记住选择<code>x-www-form-urlencoded</code>表单类型。</p> <h5 id="图-1110-在-postman-中尝试apiregister-端点成功时返回-jwt">图 11.10. 在 Postman 中尝试/api/register 端点,成功时返回 JWT</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig10_alt.jpg" alt="" loading="lazy"></p> <p>图 11.11 显示了登录端点的测试,包括返回 Passport 错误消息以及成功时的 JWT。此测试的 URL 为 <a href="http://localhost:3000/api/login%EF%BC%8C%E9%9C%80%E8%A6%81" target="_blank">http://localhost:3000/api/login,需要</a><code>email</code>和<code>password</code>表单字段。</p> <h5 id="图-1111-使用-postman-中的-apilogin-端点测试正确的凭据">图 11.11. 使用 Postman 中的 api/login 端点,测试正确的凭据</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig11_alt.jpg" alt="" loading="lazy"></p> <p>除了在浏览器中看到当预期时返回 JWT 之外,你还可以查看数据库以查看用户是否已被创建。你将回到 Mongo shell,这是你一段时间没有使用过的:</p> <pre><code>$ mongo > use Loc8r > db.users.find() </code></pre> <p>或者,你可以通过指定电子邮件地址来查找特定用户:</p> <pre><code>> db.users.find({email : "simon@fullstacktraining.com"}) </code></pre> <p>无论你使用哪种方法,你都应从数据库中看到一个或多个用户文档返回,看起来像以下列表。</p> <h5 id="列表-1114-可能的数据库响应">列表 11.14. 可能的数据库响应</h5> <pre><code>{ "hash" : "1255e9df3daa899bee8d53a42d4acf3ab8739fa758d533a84da5eb1278412f7a7bdb36e 888aeb80a9eec4fb7bbe9bcef038f01fbbf4e6048e2f4494be44bc3d5", "salt" : "40368d9155ea690cf9fc08b49f328e38", "email" : "simon@fullstacktraining.com", "name" : "Simon Holmes", "_id" : ObjectId("558b95d85f0282b03a603603"), "__v" : 0 } </code></pre> <p>我们将路径名称加粗以使其在打印时更容易识别,但你应该能够看到所有预期的数据。</p> <p>现在你已经创建了端点以使用户能够注册和登录,接下来你要查看的是如何限制某些端点只对认证用户开放。</p> <h3 id="114-确保相关-api-端点安全">11.4. 确保相关 API 端点安全</h3> <p>在 Web 应用程序中,限制对 API 端点的访问仅限于认证用户是一个常见需求。例如,在 Loc8r 中,你想要确保只有注册用户可以留下评论。这个过程有两个部分:</p> <ul> <li> <p>只允许发送有效 JWT 的用户调用新的评论 API。</p> </li> <li> <p>在控制器内部,验证用户是否存在并且可以创建评论。</p> </li> </ul> <p>你将从向 Express 中的路由添加认证开始。</p> <h4 id="1141-向-express-路由添加认证中间件">11.4.1. 向 Express 路由添加认证中间件</h4> <p>在 Express 中,你可以将中间件添加到路由中,你很快就会看到。这个中间件位于路由和控制器之间。当调用路由时,中间件在控制器之前被激活,可以阻止控制器运行或更改发送的数据。</p> <p>你想要使用中间件来验证提供的 JWT,然后提取有效载荷数据并将其添加到<code>req</code>对象中供控制器使用。不出所料,有一个 npm 模块可用于此目的:<code>express-jwt</code>。现在,在终端中使用以下命令安装它:</p> <pre><code>$ npm install --save express-jwt </code></pre> <p>现在,你可以在路由文件中使用此模块。</p> <h5 id="设置中间件">设置中间件</h5> <p>要使用<code>express-jwt</code>,你需要<code>require</code>它并对其进行配置。当包含时,<code>express-jwt</code>会暴露一个函数,你可以传递一个选项对象,你将使用它来发送密钥,并指定你想要添加到<code>req</code>对象中以保存有效载荷的属性名称。</p> <p>默认添加到<code>req</code>的属性是<code>user</code>,但在你的代码中,<code>user</code>是 Mongoose <code>User</code>模型的实例,因此将其设置为<code>payload</code>以避免混淆并保持一致性。毕竟,<code>user</code>是 Passport 和 JWT 中的称呼。</p> <p>打开 API 路由文件,app_api/routes/index.js,并将设置添加到文件顶部,如下所示,加粗显示。</p> <h5 id="列表-1115-将-jwt-添加到-app_apirouteslocationsjs">列表 11.15. 将 JWT 添加到 app_api/routes/locations.js</h5> <pre><code>const express = require('express'); const router = express.Router(); const jwt = require('express-jwt'); *1* const auth = jwt({ secret: process.env.JWT_SECRET, *2* userProperty: 'payload' *3* }); </code></pre> <ul> <li> <p><strong><em>1</em> 需要 express-jwt 模块</strong></p> </li> <li> <p><strong><em>2</em> 使用与之前相同的环境变量设置密钥</strong></p> </li> <li> <p><strong><em>3</em> 在 req 上定义一个属性作为负载</strong></p> </li> </ul> <p>现在中间件已配置,你可以将身份验证添加到路由中。</p> <h5 id="将身份验证中间件添加到特定路由">将身份验证中间件添加到特定路由</h5> <p>将中间件添加到路由定义中很简单。在路由和控制器之间引用路由命令。它确实位于中间!</p> <p>以下代码片段显示了如何将中间件添加到<code>post</code>、<code>put</code>和<code>delete</code>评论方法中,同时保留<code>get</code>开放;评论应该对公众可读。</p> <h5 id="列表-1116-更新routing以使用jwt模块">列表 11.16. 更新<code>routing</code>以使用<code>jwt</code>模块</h5> <pre><code>router .route('/locations/:locationid/reviews') .post(auth, ctrlReviews.reviewsCreate); *1* router .route('/locations/:locationid/reviews/:reviewid') .get(ctrlReviews.reviewsReadOne) .put(auth, ctrlReviews.reviewsUpdateOne) *1* .delete(auth, ctrlReviews.reviewsDeleteOne); *1* </code></pre> <ul> <li><strong><em>1</em> 将身份验证中间件添加到路由定义中</strong></li> </ul> <p>因此,中间件已配置并应用。一会儿你将看到如何在控制器中使用它,但首先,你将看到如何处理中间件拒绝的无效令牌。</p> <h5 id="处理身份验证拒绝">处理身份验证拒绝</h5> <p>当提供的令牌无效或可能不存在时,中间件抛出错误以防止代码继续执行。你需要捕获这个错误并返回未授权消息和状态(401)。</p> <p>将新错误处理器添加到 app.js 中的最佳位置是与其他错误处理器一起。你将将其添加为第一个错误处理器,以便通用处理器不会拦截它。以下列表显示了要添加到 app.js 中的新错误处理器。</p> <h5 id="列表-1117-捕获错误">列表 11.17. 捕获错误</h5> <pre><code>// error handlers // Catch unauthorised errors app.use((err, req, res, next) => { if (err.name === 'UnauthorizedError') { *1* res .status(401) .json({"message" : err.name + ": " + err.message}); } }); </code></pre> <ul> <li><strong><em>1</em> 确保你正在处理未授权错误</strong></li> </ul> <p>在配置就绪并重新启动应用程序后,你可以再次使用 Postman 测试拒绝是否发生,这次提交一个评论。你可以使用首次测试 API 时使用的相同<code>POST</code>请求,其结果如图 11.12 所示。</p> <h5 id="图-1112-尝试在没有有效-jwt-的情况下添加评论现在会导致-401-响应">图 11.12. 尝试在没有有效 JWT 的情况下添加评论现在会导致 401 响应。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/11fig12_alt.jpg" alt="" loading="lazy"></p> <p>如预期的那样,尝试调用没有包含有效 JWT 的新受保护 API 端点将返回未授权状态和消息,这正是你想要的。接下来,你将进入当请求被中间件授权并继续到控制器时会发生什么。</p> <h4 id="1142-在控制器中使用-jwt-信息">11.4.2. 在控制器中使用 JWT 信息</h4> <p>在本节中,你将了解如何使用 Express 中间件提取的 JWT 数据,并将其添加到<code>req</code>对象中。你将使用电子邮件地址从数据库中获取用户名并将其添加到评论文档中。</p> <h5 id="只有当用户存在时才运行主控制器代码">只有当用户存在时才运行主控制器代码</h5> <p>如列表 11.18 所示,首先需要做的是获取<code>reviewsCreate</code>控制器,并将内容包装在一个新的函数中,你将调用它为<code>getAuthor</code>。这个新函数应该接受<code>req</code>和<code>res</code>对象,以及现有的控制器代码作为一个回调。</p> <p><code>getAuthor</code>函数的整个目的是验证用户是否存在于数据库中,并返回用户名以供控制器使用。因此,你可以将其作为<code>userName</code>传递给回调,然后将其传递给<code>app_api/controllers/review.js</code>中的<code>doAddReview</code>函数。</p> <h5 id="列表-1118-更新创建评论控制器以首先获取用户名">列表 11.18. 更新创建评论控制器以首先获取用户名</h5> <pre><code>const reviewsCreate = (req, res) => { getAuthor(req, res, callback) => { (req, res, userName) => { *1* const locationId = req.params.locationid; if (locationId) { Loc .findById(locationId) .select('reviews') .exec((err, location) => { if (err) { return res .status(400) .json(err); } else { doAddReview(req, res, location, userName); *2* } }); } else { res .status(404) .json({message": "Location not found"}); } }); *3* }; </code></pre> <ul> <li> <p><strong><em>1</em> 调用 getAuthor 函数,并将原始控制器代码作为回调传递;将用户名传递给回调</strong></p> </li> <li> <p><strong><em>2</em> 将用户名传递给 doAddReview 函数</strong></p> </li> <li> <p><strong><em>3</em> 关闭 getAuthor 函数</strong></p> </li> </ul> <p>查看这个列表可以突出显示你仍然需要做的两件事:编写<code>getAuthor</code>函数,并更新<code>doAddReview</code>函数。首先,你将编写<code>getAuthor</code>函数,这样你就可以看到如何获取 JWT 数据。</p> <h5 id="验证用户并返回名称">验证用户并返回名称</h5> <p><code>getAuthor</code> 函数的想法是验证电子邮件地址是否与系统中的用户相关联,并返回要使用的名称。它需要执行以下操作:</p> <ul> <li> <p>检查<code>req</code>对象中是否有电子邮件地址。</p> </li> <li> <p>使用电子邮件地址查找用户。</p> </li> <li> <p>将用户名发送到回调函数。</p> </li> <li> <p>捕获错误并发送适当的消息。</p> </li> </ul> <p><code>getAuthor</code> 函数的完整代码在列表 11.19 中。首先需要检查<code>req</code>上的<code>payload</code>属性,然后检查它是否具有<code>email</code>属性。记住,<code>payload</code>是你在向 Express 路由添加身份验证时指定的属性。之后,使用<code>req.payload.email</code>在 Mongoose 查询中,如果成功,将用户名传递给回调函数。</p> <h5 id="列表-1119-使用-jwt-数据查询数据库">列表 11.19. 使用 JWT 数据查询数据库</h5> <pre><code>const User = mongoose.model('User'); *1* const getAuthor = (req, res, callback) => { if (req.payload && req.payload.email) { *2* User .findOne({ email : req.payload.email }) *3* .exec((err, user) => { if (!user) { return res .status(404) .json({"message": "User not found"}); } else if (err) { console.log(err); return res .status(404) .json(err); } callback(req, res, user.name); *4* }); } else { return res .status(404) .json({"message": "User not found"}); } }; </code></pre> <ul> <li> <p><strong><em>1</em> 确保用户模型可用</strong></p> </li> <li> <p><strong><em>2</em> 验证 JWT 信息是否在请求对象上</strong></p> </li> <li> <p><strong><em>3</em> 使用电子邮件地址查找用户</strong></p> </li> <li> <p><strong><em>4</em> 运行回调,传递用户名</strong></p> </li> </ul> <p>现在当回调被调用时,它运行控制器中的原始代码,找到一个位置并将信息传递给<code>doAddReview</code>函数。它现在也将用户名传递给函数,所以快速更新<code>doAddReview</code>以使用用户名并将其添加到评论文档中。</p> <h5 id="在评论中设置用户的名称">在评论中设置用户的名称</h5> <p><code>doAddReview</code> 函数的更改很简单,如列表 11.20 所示。你之前已经保存了评论的<code>author</code>,从<code>req.body .author</code>获取数据。现在,你有一个参数被传递给函数,可以使用这个参数。更新内容以粗体显示。</p> <h5 id="列表-1120-在评论中保存用户名">列表 11.20. 在评论中保存用户名</h5> <pre><code>const doAddReview = (req, res, location, author) => { *1* if (!location) { res .status(404) .json({"message": "Location not found"}); } else { const {rating, reviewText} = req.body; *2* location.reviews.push({ author, *2* rating, reviewText }); location.save((err, location) => { if (err) { return res .status(400) .json(err); } else { updateAverageRating(location._id); const thisReview = location.reviews.slice(-1).pop(); res .status(201) .json(thisReview); } }); } }; </code></pre> <ul> <li> <p><strong><em>1</em> 在函数定义中添加一个作者参数</strong></p> </li> <li> <p><strong><em>2</em> 作者现在是从数据库而不是表单中获取的</strong></p> </li> </ul> <p>这个简单的更改使你完成了后端工作。你已经创建了一个新的用户模式,生成和消费 JWT,创建了一个身份验证 API,并保护了一些其他 API 路由。这已经很多了!</p> <p>在第十二章中,你将处理前端并将其集成到 Angular 应用中。</p> <h3 id="摘要-7">摘要</h3> <p>在本章中,你学习了</p> <ul> <li> <p>如何在 MEAN 栈中处理身份验证</p> </li> <li> <p>使用散列和盐加密密码</p> </li> <li> <p>使用 Mongoose 模型方法向模式添加函数</p> </li> <li> <p>如何使用 Express 创建 JSON Web Token</p> </li> <li> <p>使用 Passport 在服务器上管理身份验证</p> </li> <li> <p>仅对经过身份验证的用户在 Express 中提供路由</p> </li> </ul> <h2 id="第十二章-在-angular-应用中使用身份验证-api">第十二章. 在 Angular 应用中使用身份验证 API</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>使用本地存储和 Angular 管理用户会话</p> </li> <li> <p>在 Angular 中管理用户会话</p> </li> <li> <p>在 Angular 应用中使用 JWT</p> </li> </ul> <p>在本章中,你将集成在第十一章(kindle_split_024.xhtml#ch11)中完成的通过 API 进行身份验证的工作,并在你的 Angular 应用中使用 API 端点。具体来说,你将了解如何使用 Angular HTTP 客户端库和 <code>localStorage</code>。</p> <h3 id="121-创建-angular-身份验证服务">12.1. 创建 Angular 身份验证服务</h3> <p>在 Angular 应用中,就像任何其他应用一样,很可能会在整个应用中需要身份验证。显然的做法是创建一个可以在任何需要的地方使用的身份验证服务。这个服务应该负责与身份验证相关的一切,包括保存和读取 JWT、返回关于当前用户的信息,以及调用登录和注册 API 端点。</p> <p>你将从查看如何管理用户会话开始。</p> <h4 id="1211-在-angular-中管理用户会话">12.1.1. 在 Angular 中管理用户会话</h4> <p>假设用户刚刚登录,API 返回了一个 JWT。你应该对令牌做什么?因为你正在运行一个单页应用(SPA),你可以将其保存在浏览器的内存中。这种方法是可以的,除非用户决定刷新页面,这会重新加载应用程序,丢失内存中的所有内容——这不是理想的情况。</p> <p>接下来,你将考虑将令牌保存在一个更健壮的地方,以便应用程序在需要时可以读取它。问题是是否使用 cookies 或本地存储。</p> <h5 id="cookies-与本地存储的比较">Cookies 与本地存储的比较</h5> <p>在 Web 应用中保存用户数据的传统方法是将 cookie 保存下来,这当然是一个选择。但 cookies 是供服务器应用使用的,每次向服务器发送请求时,都会在 HTTP 头部发送 cookies 以供读取。在 SPA 中,你不需要 cookies;API 端点是无状态的,不会获取或设置 cookies。</p> <p>你需要另寻他处,转向本地存储,它是为客户端应用程序设计的。使用本地存储,数据会保留在浏览器中,并且不会像 cookies 那样自动与请求一起传输。</p> <p>使用 JavaScript,本地存储也很容易使用。看看下面的代码片段,它将设置和获取一些数据:</p> <pre><code>window.localStorage['my-data'] = 'Some information'; window.localStorage['my-data']; // Returns 'Some information' </code></pre> <p>对了,这样我们就确定了;你将在 Loc8r 中使用本地存储来保存 JWT。如果 <code>localStorage</code> 不熟悉,请访问 Mozilla 开发者文档 <a href="http://mng.bz/0WKz" target="_blank"><code>mng.bz/0WKz</code></a> 以获取更多信息。</p> <p>为了便于在 Angular 应用程序中使用 <code>localStorage</code>,你首先创建一个名为 <code>BROWSER_STORAGE</code> 的 <code>Injectable</code>,你可以在组件中使用它。你将连接到 <code>localStorage</code>,但你将通过一个工厂服务来实现,该服务被注入到需要访问 <code>localStorage</code> 的组件中。</p> <p>首先,生成类文件</p> <pre><code>$ ng generate class storage </code></pre> <p>并将以下代码放入其中。</p> <h5 id="列表-121-storagets">列表 12.1. storage.ts</h5> <pre><code>import { InjectionToken } from '@angular/core'; *1* export const BROWSER_STORAGE = new InjectionToken<Storage> *2* ('Browser Storage',{ *2* providedIn: 'root', factory: () => localStorage *3* }); </code></pre> <ul> <li> <p><strong><em>1</em> 使用 InjectionToken 类</strong></p> </li> <li> <p><strong><em>2</em> 创建新的 InjectionToken</strong></p> </li> <li> <p><strong><em>3</em> 包装 localStorage 的工厂函数</strong></p> </li> </ul> <h5 id="创建一个服务以在本地存储中保存和读取-jwt">创建一个服务以在本地存储中保存和读取 JWT</h5> <p>你将通过创建将 JWT 保存到本地存储并再次读取的方法来开始构建身份验证服务。你已经看到了在 JavaScript 中与 <code>localStorage</code> 一起工作是多么容易,所以现在你需要将其包装在一个 Angular 服务中,该服务公开两个方法:<code>saveToken()</code> 和 <code>getToken()</code>。这里没有真正的惊喜,但 <code>saveToken()</code> 方法应该接受要保存的值,而 <code>getToken()</code> 应该返回一个值。</p> <p>首先,在 Angular 应用程序中生成一个名为 <code>Authentication</code> 的新服务:</p> <pre><code>$ ng generate service authentication </code></pre> <p>以下列表显示了新服务的内 容,包括前两个方法。</p> <h5 id="列表-122-使用前两个方法创建-authentication-服务">列表 12.2. 使用前两个方法创建 <code>authentication</code> 服务</h5> <pre><code>import { Inject, Injectable } from '@angular/core'; import { BROWSER_STORAGE } from './storage'; @Injectable({ providedIn: 'root' }) export class AuthenticationService { constructor(@Inject(BROWSER_STORAGE) private storage: Storage) { } *1* public getToken(): string { *2* return this.storage.getItem('loc8r-token'); *2* } public saveToken(token: string): void { *3* this.storage.setItem('loc8r-token', token); *3* } } </code></pre> <ul> <li> <p><strong><em>1</em> 注入 BROWSER_STORAGE 包装器</strong></p> </li> <li> <p><strong><em>2</em> 创建获取令牌的函数</strong></p> </li> <li> <p><strong><em>3</em> 创建 saveToken 函数</strong></p> </li> </ul> <p>这样你就有一个简单的服务来处理将 <code>loc8r-token</code> 保存到 <code>localStorage</code> 并再次读取出来。接下来,你将查看登录和注册。</p> <h4 id="1212-允许用户注册登录和登出">12.1.2. 允许用户注册、登录和登出</h4> <p>要使用该服务让用户注册、登录和登出,你需要添加三个更多的方法。从注册和登录开始。</p> <h5 id="调用-api-进行注册和登录">调用 API 进行注册和登录</h5> <p>你需要两个方法来注册和登录,这两个方法将表单数据发送到本章前面创建的 <code>register</code> 和 <code>login</code> API 端点。当成功时,这两个端点都返回 JWT,因此你可以使用 <code>saveToken</code> 方法来保存它们。</p> <p>为了准备,你需要生成两个简单的辅助类来帮助管理你在应用程序中需要的数据——一个 <code>User</code> 类 (列表 12.3) 和一个 <code>AuthResponse</code> 类 (列表 12.4):</p> <pre><code>$ ng generate class user $ ng generate class authresponse </code></pre> <p>以下两个列表显示了您将使用来维护给定数据的简单类。列表 12.3 提供了您的 <code>User</code> 类定义,这是一个简单的类,用于存储名称和电子邮件作为字符串。</p> <h5 id="列表-123-userts">列表 12.3. user.ts</h5> <pre><code>export class User { email: string; *1* name: string; *1* } </code></pre> <ul> <li><strong><em>1</em> 告诉 TypeScript 你在这里需要字符串</strong></li> </ul> <p>列表 12.4 提供了您的 <code>AuthResponse</code> 对象的定义,此时它包含令牌字符串。</p> <h5 id="列表-124-authresponsets">列表 12.4. authresponse.ts</h5> <pre><code>export class AuthResponse { token: string; *1* } </code></pre> <ul> <li><strong><em>1</em> 将令牌设置为字符串</strong></li> </ul> <p>使用这些类,您可以将前面提到的 <code>register()</code> 和 <code>login()</code> 方法添加到身份验证服务中,如下一个列表所示。由于这些方法依赖于 Loc8rDataService,您也将注入它。</p> <h5 id="列表-125-authenticationservicets">列表 12.5. authentication.service.ts</h5> <pre><code>import { Inject, Injectable } from '@angular/core'; import { BROWSER_STORAGE } from './storage'; import { User } from './user'; *1* import { AuthResponse } from './authresponse'; *1* import { Loc8rDataService } from './loc8r-data.service'; *1* @Injectable({ providedIn: 'root' }) export class AuthenticationService { constructor( @Inject(BROWSER_STORAGE) private storage: Storage, private loc8rDataService: Loc8rDataService *2* ) { } ... public login(user: User): Promise<any> { *3* return this.loc8rDataService.login(user) .then((authResp: AuthResponse) => this.saveToken(authResp.token)); } public register(user: User): Promise<any> { *4* return this.loc8rDataService.register(user) .then((authResp: AuthResponse) => this.saveToken(authResp.token)); } } </code></pre> <ul> <li> <p><strong><em>1</em> 导入相关类和服务</strong></p> </li> <li> <p><strong><em>2</em> 注入数据服务</strong></p> </li> <li> <p><strong><em>3</em> 登录函数</strong></p> </li> <li> <p><strong><em>4</em> 注册函数</strong></p> </li> </ul> <p>快速查看您添加的两个方法。您正在做的是为即将编写的 <code>login()</code> 和 <code>register()</code> 方法提供一个包装器,并确保返回一个 Promise,以便可以将数据传递回 UI。您不必关心 Promise 中的内容——只需确保它被返回。然后,使用已存在的函数保存 <code>AuthResponse</code> 对象中的 <code>token</code>。</p> <p>最后,您需要将前面提到的、与 API 端点通信所需的方法添加到 <code>Loc8rDataService</code> 中。下一个列表中的更改以粗体显示。</p> <h5 id="列表-126-loc8rdataservice-的更改">列表 12.6. <code>Loc8rDataService</code> 的更改</h5> <pre><code>import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Location, Review } from './location'; import { User } from './user'; *1* import { AuthResponse } from './authresponse'; *1* @Injectable({ providedIn: 'root' }) export class Loc8rDataService { ... public login(user: User): Promise<AuthResponse> { *2* return this.makeAuthApiCall('login', user); } public register(user: User): Promise<AuthResponse> { *3* return this.makeAuthApiCall('register', user); } private makeAuthApiCall(urlPath: string, user: User): Promise<AuthResponse> { *4* const url: string = `${this.apiBaseUrl}/${urlPath}`; return this.http .post(url, user) *5* .toPromise() *5* .then(response => response as AuthResponse) .catch(this.handleError); } ... } </code></pre> <ul> <li> <p><strong><em>1</em> User 和 AuthResponse 类的导入</strong></p> </li> <li> <p><strong><em>2</em> 登录方法返回 AuthResponse Promise</strong></p> </li> <li> <p><strong><em>3</em> 注册方法返回 AuthResponse Promise</strong></p> </li> <li> <p><strong><em>4</em> 实际调用。登录和注册足够相似,可以做到 DRY。</strong></p> </li> <li> <p><strong><em>5</em> 使用 HttpClient POST 请求 Observable,并将其转换为 Promise 对象</strong></p> </li> </ul> <p>在 <code>login</code> 和 <code>register</code> 的两种情况下对 API 的调用基本上是相同的调用;唯一的区别是您需要击中的 URL 以执行所需的操作。在 列表 12.6 中,您 <code>POST</code> 一个包含您尝试使用的用户详细信息的有效负载,并在成功时返回 <code>AuthResponse</code> 对象或在失败时处理错误。为此,您有一个私有方法 (<code>makeAuthApiCall()</code>) 来管理调用,以及公共方法 <code>login()</code> 和 <code>register()</code> 来处理您想要调用的特定 API 端点 URL 的具体细节。</p> <p>使用这些方法,您可以处理注销。</p> <h5 id="删除-localstorage-以注销">删除 localStorage 以注销</h5> <p>在 Angular 应用程序中,用户会话是通过在 <code>localStorage</code> 中保存 JWT 来管理的。如果令牌存在,有效,并且尚未过期,则可以说用户已登录。您无法从 Angular 应用程序内部更改令牌的过期日期;只有服务器才能这样做。您可以做的只是删除它。</p> <p>为了让用户能够注销,您可以在认证服务中创建一个新的<code>logout</code>方法来删除 Loc8r JWT。</p> <h5 id="列表-127-从位置存储中删除令牌">列表 12.7. 从位置存储中删除令牌</h5> <pre><code> public logout(): void { this.storage.removeItem('loc8r-token'); *1* } </code></pre> <ul> <li><strong><em>1</em> 从 localStorage 中删除令牌</strong></li> </ul> <p>此代码从浏览器的<code>localStorage</code>中删除<code>loc8r-token</code>项。</p> <p>现在您有了从服务器获取 JWT、将其保存到<code>localStorage</code>、从<code>localStorage</code>读取它以及删除它的方法。下一个问题是如何在应用程序中使用它来查看用户是否已登录以及从中获取数据。</p> <h4 id="1213-在-angular-服务中使用-jwt-数据">12.1.3. 在 Angular 服务中使用 JWT 数据</h4> <p>存储在浏览器<code>localStorage</code>中的 JWT 是您用来管理用户会话的。JWT 用于验证用户是否已登录。如果用户已登录,应用程序还可以读取存储在其中的用户信息。</p> <p>首先,添加一个方法来检查某人是否已登录。</p> <h5 id="检查登录状态">检查登录状态</h5> <p>要检查用户是否当前已登录到应用程序,您需要检查<code>loc8r-token</code>是否存在于<code>localStorage</code>中。您可以使用<code>getToken()</code>方法来完成此任务。但令牌的存在并不足够。请记住,JWT 中嵌入有过期数据,所以如果令牌存在,您还需要检查它。</p> <p>JWT 的过期日期和时间是有效载荷的一部分,这是数据的第二部分。请记住,这部分是一个编码的 JSON 对象;它是编码而不是加密的,所以您可以解码它。实际上,我们已经讨论了执行此操作的功能:<code>atob</code>。</p> <p>将一切组合在一起,您想要创建一个方法</p> <ol> <li> <p>获取存储的令牌</p> </li> <li> <p>从令牌中提取有效载荷</p> </li> <li> <p>解码有效载荷</p> </li> <li> <p>验证过期日期是否已过</p> </li> </ol> <p>将此方法添加到<code>AuthenticationService</code>后,如果用户已登录则应返回<code>true</code>,如果没有则返回<code>false</code>。下一个列表显示了在名为<code>isLoggedIn()</code>的方法中的此行为。</p> <h5 id="列表-128-认证服务的isloggedin方法">列表 12.8. 认证服务的<code>isLoggedIn</code>方法</h5> <pre><code>public isLoggedIn(): boolean { const token: string = this.getToken(); *1* if (token) { *2* const payload = JSON.parse(atob(token.split('.')[1])); *2* return payload.exp > (Date.now() / 1000); *3* } else { return false; } } } </code></pre> <ul> <li> <p><strong><em>1</em> 从存储中获取令牌</strong></p> </li> <li> <p><strong><em>2</em> 如果令牌存在,获取有效载荷,解码它并将其解析为 JSON</strong></p> </li> <li> <p><strong><em>3</em> 验证过期是否已过</strong></p> </li> </ul> <p>这段代码不多,但功能很多。在您在服务中的<code>return</code>语句中引用它之后,应用程序可以快速在任何时候检查用户是否已登录。</p> <p>要添加到认证服务的下一个和最后一个方法是从 JWT 中获取一些用户信息。</p> <h5 id="从-jwt-中获取用户信息">从 JWT 中获取用户信息</h5> <p>您希望应用程序能够从 JWT 中获取用户的电子邮件地址和姓名。您在<code>isLoggedIn()</code>方法中看到了如何从令牌中提取数据,而您的新方法正是做同样的事情。</p> <p>创建一个名为 <code>getCurrentUser()</code> 的新方法。此方法首先通过调用 <code>isLoggedIn()</code> 方法验证用户是否已登录。如果用户已登录,它通过调用 <code>getToken()</code> 方法获取令牌,然后在提取和解码有效负载后返回所需的数据。以下列表显示了其外观。</p> <h5 id="列表-129-getcurrentuser-方法authenticationservicets">列表 12.9. <code>getCurrentUser()</code> 方法(authentication.service.ts)</h5> <pre><code>public getCurrentUser(): User { *1* if (this.isLoggedIn()) { *2* const token: string = this.getToken(); const { email, name } = JSON.parse(atob(token.split('.')[1])); return { email, name } as User; *3* } } </code></pre> <ul> <li> <p><strong><em>1</em> 返回用户类型</strong></p> </li> <li> <p><strong><em>2</em> 确保用户已登录</strong></p> </li> <li> <p><strong><em>3</em> 将对象类型转换为 User 类型</strong></p> </li> </ul> <p>完成这些后,Angular 身份验证服务就完成了。回顾一下代码,您可以看到它是通用的,并且很容易从一个应用程序复制到另一个应用程序。您可能需要更改的只是令牌的名称和 API 网址,因此您有一个很好的、可重用的 Angular 服务。</p> <p>现在服务已添加到应用程序中,您可以使用它。继续前进,创建登录和注册页面。</p> <h3 id="122-创建注册和登录页面">12.2. 创建注册和登录页面</h3> <p>您到目前为止所做的一切都很棒,但没有一种方式让网站访客注册和登录,那就毫无用处。所以这就是您现在要解决的问题。</p> <p>在功能方面,您想要一个注册页面,新用户可以在其中设置他们的详细信息并注册,以及一个登录页面,用户可以在其中输入他们的用户名和密码。当用户完成这两个过程中的任何一个并且成功认证后,应用程序应将他们送回到他们开始过程时的页面。</p> <p>在以下部分的末尾,您会期望您的注册页面看起来非常像图 12.1。</p> <h5 id="图-121-注册页面">图 12.1. 注册页面</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/12fig01_alt.jpg" alt="图片 12.1" loading="lazy"></p> <p>登录页面应类似于图 12.2。您将从注册页面开始。</p> <h5 id="图-122-登录页面">图 12.2. 登录页面</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/12fig02_alt.jpg" alt="图片 12.2" loading="lazy"></p> <h4 id="1221-构建注册页面">12.2.1. 构建注册页面</h4> <p>为了开发一个可工作的注册页面,您需要做几件事情:</p> <ol> <li> <p>创建 <code>register</code> 组件并将其添加到路由中。</p> </li> <li> <p>构建模板。</p> </li> <li> <p>完善组件主体,包括重定向。</p> </li> </ol> <p>当然,您完成之后会想要测试页面。</p> <p>第 1 步是创建组件。使用 Angular 生成器:</p> <pre><code>$ ng generate component register </code></pre> <p>完成这些后,通过向 app_routing/app_routing.module.ts 中添加条目来修改应用程序路由。如以下列表所示,将 <code>register</code> 组件指向 /register 路由。</p> <h5 id="列表-1210-注册路由">列表 12.10. 注册路由</h5> <pre><code>import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule, Routes } from '@angular/router'; import { AboutComponent } from '../about/about.component'; import { HomepageComponent } from '../homepage/homepage.component'; import { DetailsPageComponent } from '../details-page/details-page. component'; import { RegisterComponent } from '../register/ register.component'; *1* const routes: Routes = [ ... { *2* path: 'register', component: RegisterComponent } ]; ... }) export class AppRoutingModule { } </code></pre> <ul> <li> <p><strong><em>1</em> 导入新创建的注册组件</strong></p> </li> <li> <p><strong><em>2</em> 添加路径信息</strong></p> </li> </ul> <p>完成这些后,查看组件模板的详细信息以及将此模板链接到您之前构建的服务的方法。</p> <h5 id="构建注册模板">构建注册模板</h5> <p>好的,现在您将构建注册页面的模板。除了正常的页眉和页脚外,您还需要一些其他东西。主要的是,您需要一个表单,允许访客输入他们的姓名、电子邮件地址和密码。在此表单中,您还应该有一个区域来显示任何错误。您还应该加入一个链接到登录页面,以防用户意识到他们已经注册了。</p> <p>下一个列表显示了拼接在一起的模板。请注意,输入字段通过 <code>ngModel</code> 绑定到视图模型中的 <code>credentials</code>。</p> <h5 id="列表-1211-注册页面完整模板registerregistercomponenthtml">列表 12.11. 注册页面完整模板(register/register.component.html)</h5> <pre><code><app-page-header [content]="pageContent.header"></app-page-header> <div class="row"> <div class="col-12 col-md-8"> <p class="lead">Already a member? Please <a routerLink="/login"> log in</a> instead</p> *1* <form (submit)="onRegisterSubmit()"> <div role="alert" *ngIf="formErrors" class="alert alert-danger"> {{ formError }}</div> *2* <div class="form-group"> <label for="name">Full Name</label> <input class="form-control" id="name" name="name" placeholder= "Enter your name" [(ngModel)]="credentials.name"> *3* </div> <div class="form-group"> <label for="email">Email Address</label> <input type="email" class="form-control" id="email" name="email" placeholder="Enter email address" [(ngModel)]= "credentials.email"> *4* </div> <div class="form-group"> <label for="password">Password</label> <input type="pasword" class="form-control" id="password" name="password" placeholder="e.g 12+ alphanumerics" [(ngModel)]="credentials.password"> *5* </div> <button type="submit" role="button" class="btn btn-primary">Register!</button> </form> </div> <app-sidebar [content]="pageContent.sidebar" class= "col-12 col-md-4"></app-sidebar> </div> </code></pre> <ul> <li> <p><strong><em>1</em> 切换到登录页面的链接</strong></p> </li> <li> <p><strong><em>2</em> 用于显示错误的 <div></strong></p> </li> <li> <p><strong><em>3</em> 用户名输入</strong></p> </li> <li> <p><strong><em>4</em> 电子邮件地址输入</strong></p> </li> <li> <p><strong><em>5</em> 密码输入</strong></p> </li> </ul> <p>再次,需要注意的是,用户的姓名、电子邮件和密码绑定在对象 <code>credentials</code> 中的视图模型中。</p> <p>接下来,您查看另一面并编写组件方法。</p> <h5 id="创建注册组件骨架">创建注册组件骨架</h5> <p>根据模板,您将在注册组件中设置一些事情。您需要页面标题的文本以及一个 <code>onRegisterSubmit()</code> 函数来处理表单提交。您还将为所有 <code>credentials</code> 属性提供一个默认空字符串值。</p> <p>下一个列表显示了初始设置。</p> <h5 id="列表-1212-开始-register-组件">列表 12.12. 开始 <code>register</code> 组件</h5> <pre><code>import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; *1* import { AuthenticationService } from '../authentication.service'; *2* @Component({ selector: 'app-register', templateUrl: './register.component.html', styleUrls: ['./register.component.css'] }) export class RegisterComponent implements OnInit { public formError: string = ''; *3* public credentials = { *4* name: '', email: '', password: '' }; public pageContent = { *5* header: { title: 'Create a new account', strapline: '' }, sidebar: '' }; constructor( private router: Router, private authenticationService: AuthenticationService ) { } ngOnInit() { } </code></pre> <ul> <li> <p><strong><em>1</em> 从 Router 中导入所需的服务</strong></p> </li> <li> <p><strong><em>2</em> 导入认证服务</strong></p> </li> <li> <p><strong><em>3</em> 错误字符串初始化</strong></p> </li> <li> <p><strong><em>4</em> 保存模型数据的 <code>credentials</code> 对象</strong></p> </li> <li> <p><strong><em>5</em> 页面内容对象,用于常规页面数据</strong></p> </li> </ul> <p>这里没有新的内容——几个公共属性来管理组件的内部数据,以及注入您在组件中需要使用的服务。</p> <p>将下一个列表的内容添加到您创建的组件中。</p> <h5 id="列表-1213-注册提交处理程序">列表 12.13. 注册提交处理程序</h5> <pre><code> public onRegisterSubmit(): void { *1* this.formError = ''; if ( !this.credentials.name || *2* !this.credentials.email || *2* !this.credentials.password *2* ) { this.formError = 'All fields are required, please try again'; *3* } else { this.doRegister(); } } private doRegister(): void { *4* this.authenticationService.register(this.credentials) .then(() => this.router.navigateByUrl('/')) .catch((message) => this.formError = message); } </code></pre> <ul> <li> <p><strong><em>1</em> 提交事件处理程序</strong></p> </li> <li> <p><strong><em>2</em> 检查您是否已收到所有相关信息</strong></p> </li> <li> <p><strong><em>3</em> 发生错误时返回消息</strong></p> </li> <li> <p><strong><em>4</em> 执行注册</strong></p> </li> </ul> <p>在此代码到位后,您可以通过启动应用程序并转到 <a href="http://localhost:4200/register" target="_blank">http://localhost:4200/register</a> 来尝试注册页面和功能。</p> <p>当您完成此操作并成功注册为用户后,打开浏览器开发工具,查找资源。如图 12.3 所示,您应该在本地存储文件夹下方看到 <code>loc8r-token</code>。</p> <h5 id="图-123-在浏览器中找到-loc8r-token">图 12.3. 在浏览器中找到 <code>loc8r-token</code></h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/12fig03_alt.jpg" alt="" loading="lazy"></p> <p>您已添加了新用户注册的功能。接下来,您将启用返回用户登录。</p> <h4 id="1222-构建-登录-页面">12.2.2. 构建 登录 页面</h4> <p>登录页面的方法与注册页面的方法类似。这里不应该有任何不熟悉的内容,所以您会快速浏览。</p> <p>首先,生成新的组件:</p> <pre><code>$ ng generate component login </code></pre> <p>将以下内容添加到路由对象中(app-routing/app-routing.module.ts):</p> <pre><code>{ path: 'login', component: LoginComponent } </code></pre> <p>在此代码到位后,你可以构建组件模板文件:login/login-component.html。你可以从路由中看到你希望此文件所在的位置。它与 <code>register</code> 模板类似,所以复制并编辑该模板可能最容易。你只需要删除名称输入框并更改一些文本。以下列表以粗体突出显示你需要在 <code>login</code> 模板中进行的更改。</p> <h5 id="列表-1214-对-login-模板-的更改">列表 12.14. 对 <code>login 模板</code> 的更改</h5> <pre><code><app-page-header [content]="pageContent.header"></app-page-header> <div class="row"> <div class="col-12 col-md-8"> <p class="lead">Not a member? Please <a routerLink="/register">register</a> first </p> *1* <form (ngSubmit)="onLoginSubmit(evt)"> *2* <div role="alert" *ngIf="formError" class="alert alert-danger"> {{ formError }} *3* </div> <div class="form-group"> <label for="email">Email Address</label> <input type="email" class="form-control" id="email" name="email" placeholder="Enter email address" [(ngModel)]= "credentials.email"> </div> <div class="form-group"> <label for="password">Password</label> <input type="pasword" class="form-control" id="password" name="password" placeholder="e.g 12+ alphanumerics" [(ngModel)]="credentials.password"> </div> <button type="submit" role="button" class="btn btn-default"> Sign in!</button> *4* </form> </div> <app-sidebar [content]="pageContent.sidebar" class="col-12 col-md-4"> </app-sidebar> </div> </code></pre> <ul> <li> <p><strong><em>1</em> 更改注册链接</strong></p> </li> <li> <p><strong><em>2</em> 更新提交事件函数调用</strong></p> </li> <li> <p><strong><em>3</em> 注意已移除名称输入框。</strong></p> </li> <li> <p><strong><em>4</em> 更改按钮上的文本</strong></p> </li> </ul> <p>最后,你将对 <code>login</code> 组件进行更改,这与 <code>register</code> 组件类似。你需要做的更改如下:</p> <ul> <li> <p>更改组件控制器的名称。</p> </li> <li> <p>更改页面标题。</p> </li> <li> <p>删除对名称字段的引用。</p> </li> <li> <p>将 <code>doRegisterSubmit()</code> 重命名为 <code>doLoginSubmit()</code>,并将 <code>doRegister</code> 更改为 <code>doLogin</code>。</p> </li> <li> <p>调用 <code>AuthenticationService</code> 的 <code>login()</code> 方法而不是 <code>register()</code> 方法。</p> </li> </ul> <p>将组件类代码的主体从 register/register-component.ts 复制过来,并做出以下更改。下一个列表显示了文件内容,并以粗体突出显示更改。</p> <h5 id="列表-1215-对-login-组件所需的更改">列表 12.15. 对 <code>login</code> 组件所需的更改</h5> <pre><code>import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { AuthenticationService } from '../authentication.service'; @Component({ selector: 'app-login', *1* templateUrl: './login.component.html', *1* styleUrls: ['./login.component.css'] *1* }) export class LoginComponent implements OnInit { *2* public formError: string = ''; public credentials = { name: '', email: '', password: '' }; public pageContent = { header: { title: 'Sign in to Loc8r', *3* strapline: '' }, sidebar: '' }; constructor( private router: Router, private authenticationService: AuthenticationService ) { } ngOnInit() { } public onLoginSubmit(): void { *4* this.formError = ''; if (!this.credentials.email || !this.credentials.password) { this.formError = 'All fields are required, please try again'; } else { this.doLogin(); } } private doLogin(): void { *5* this.authenticationService.login(this.credentials) .then( () => this.router.navigateByUrl('/')) .catch( (message) => { this.formError = message }); } } </code></pre> <ul> <li> <p><strong><em>1</em> 更新组件定义块</strong></p> </li> <li> <p><strong><em>2</em> 更改组件名称</strong></p> </li> <li> <p><strong><em>3</em> 更改页面标题</strong></p> </li> <li> <p><strong><em>4</em> 更改提交事件方法</strong></p> </li> <li> <p><strong><em>5</em> 将 doRegister 方法更改为 doLogin 并更新身份验证服务调用</strong></p> </li> </ul> <p>这很简单!不需要在这个组件上过多停留,因为从功能上讲,它的工作方式与注册控制器相同。</p> <p>现在,你将进入最终阶段,并在 Angular 应用中使用已验证的会话。</p> <h3 id="123-在-angular-应用中处理身份验证">12.3. 在 Angular 应用中处理身份验证</h3> <p>当你有验证用户的方法时,下一步是利用这些信息。在 Loc8r 中,你会做两件事:</p> <ul> <li> <p>根据访客是否登录更改导航。</p> </li> <li> <p>在创建评论时使用用户信息。</p> </li> </ul> <p>你将首先处理导航。</p> <h4 id="1231-更新导航">12.3.1. 更新导航</h4> <p>当前导航中缺少一个登录链接,因此你将在传统位置添加一个:屏幕的右上角。但是,当用户登录时,你不想显示登录消息;更好的做法是显示用户的姓名,并给他们提供一个注销选项。</p> <p>这是你将在本节中要做的事情,首先向导航栏添加一个右侧部分。</p> <h4 id="1232-向导航添加右侧部分">12.3.2. 向导航添加右侧部分</h4> <p>Loc8r 的导航是在框架组件中设置的,该组件充当每个页面的布局。你可能记得从第九章 chapter 9 中,这是定义路由出口的根组件;文件位于 app_public/src/app/framework。以下列表以粗体突出显示需要添加到模板(framework.component.html)中以在右侧放置登录链接的标记。</p> <h5 id="列表-1216-框架组件的更改">列表 12.16. 框架组件的更改</h5> <pre><code><div id="navbarMain" class="navbar-collapse collapse"> <ul class="navbar-nav mr-auto"> <li class="nav-item" routerLinkActive="active"> <a routerLink="about" class="nav-link">About</a> </li> </ul> <ul class="navbar-nav justify-content-end"> *1* <li class="nav-item" routerLinkActive="active"> <a routerLink="login" class="nav-link">Sign in</a> *2* </li> <li class="nav-item dropdown" routerLinkActive="active"> <a class="nav-link dropdown-toggle" data-toggle="dropdown">Username</a> *3* <div class="dropdown-menu"> <a class="dropdown-item">Logout</a> *4* </div> </li> </ul> </div> </div> </code></pre> <ul> <li> <p><strong><em>1</em> 在页眉中添加一个导航栏,并将其推到右侧</strong></p> </li> <li> <p><strong><em>2</em> 登录链接</strong></p> </li> <li> <p><strong><em>3</em> 登录时用户名区域</strong></p> </li> <li> <p><strong><em>4</em> 注销链接</strong></p> </li> </ul> <p><code>login nav</code> 选项导航到您刚刚构建的 <code>login</code> 组件。</p> <p>然而,目前下拉菜单中添加的链接不起作用,注销链接需要进一步完善。</p> <p>要使此链接工作,你需要将 <code>Authentication</code> 服务注入到 <code>Framework</code> 组件中。你还需要添加三个方法:</p> <ul> <li> <p>触发注销(<code>doLogout()</code>)的点击事件</p> </li> <li> <p>检查当前用户登录状态的方法</p> </li> <li> <p>获取当前用户名的方法</p> </li> </ul> <p>以下列表显示了如何完成此操作。</p> <h5 id="列表-1217-对-framework-进行注销更改">列表 12.17. 对 <code>Framework</code> 进行注销更改</h5> <pre><code>import { Component, OnInit } from '@angular/core'; import { AuthenticationService } from '../authentication.service'; *1* import { User } from '../user'; *2* @Component({ selector: 'app-framework', templateUrl: './framework.component.html', styleUrls: ['./framework.component.css'] }) export class FrameworkComponent implements OnInit { constructor( private authenticationService: AuthenticationService *3* ) { } ngOnInit() { } public doLogout(): void { *4* this.authenticationService.logout(); } public isLoggedIn(): boolean { *5* return this.authenticationService.isLoggedIn(); } public getUsername(): string { *6* const user: User = this.authenticationService.getCurrentUser(); return user ? user.name : 'Guest'; } } </code></pre> <ul> <li> <p><strong><em>1</em> 导入认证服务</strong></p> </li> <li> <p><strong><em>2</em> 导入 User 类进行类型检查</strong></p> </li> <li> <p><strong><em>3</em> 注入导入的服务</strong></p> </li> <li> <p><strong><em>4</em> 认证服务注销方法的 doLogout 包装器</strong></p> </li> <li> <p><strong><em>5</em> isLoggedIn 包装器</strong></p> </li> <li> <p><strong><em>6</em> 获取用户名包装器</strong></p> </li> </ul> <p>当这些功能就绪时,你将它们添加到框架 HTML 模板中。你需要添加一个 <code>*ngIf</code> 来根据 <code>isLoggedIn()</code> 的结果切换用户名下拉菜单的显示。当 <code>isLoggedIn()</code> 返回 <code>true</code> 时,你希望在 HTML 中显示用户的姓名。最后,你需要将 <code>doLogout()</code> 函数连接到注销链接的点击事件。</p> <h5 id="列表-1218-框架组件模板的更改">列表 12.18. 框架组件模板的更改</h5> <pre><code> <ul class="navbar-nav justify-content-end"> <li class="nav-item" routerLinkActive="active"> <a routerLink="login" class="nav-link" *ngIf="!isLoggedIn()"> Sign in</a> *1* </li> <li class="nav-item dropdown" routerLinkActive="active" *ngIf="isLoggedIn()"> *2* <a class="nav-link dropdown-toggle" data-toggle="dropdown"> {{ getUsername() }} *3* </a> <div class="dropdown-menu"> <a class="dropdown-item" (click)="doLogout()">Logout</a> </div> </li> </ul> </code></pre> <ul> <li> <p><strong><em>1</em> 登录时不显示</strong></p> </li> <li> <p><strong><em>2</em> 登录时显示</strong></p> </li> <li> <p><strong><em>3</em> 如果可用则显示用户名</strong></p> </li> </ul> <p>由于已经实现了注销功能,现在是考虑用户体验问题的好时机。目前,<code>login</code> 和 <code>register</code> 组件在成功响应时将用户重定向到主页,这对用户来说并不是一个好的体验。你要做的是将用户返回到他们登录或注册之前所在的页面。</p> <p>要做到这一点,创建一个利用 Angular 路由 <code>events</code> 属性的服务。<code>events</code> 属性记录了用户在导航应用程序时发生的路由事件。首先,生成一个名为 <code>history</code> 的服务:</p> <pre><code>$ ng generate service history </code></pre> <p>将此新服务添加到框架组件中,以便在填充 <code>history</code> 服务的主体之前设置好引用。</p> <h5 id="列表-1219-将历史服务添加到框架组件">列表 12.19. 将历史服务添加到框架组件</h5> <pre><code>import { Component, OnInit } from '@angular/core'; import { AuthenticationService } from '../authentication.service'; import { HistoryService } from '../history.service'; *1* import { User } from '../user'; @Component({ selector: 'app-framework', templateUrl: './framework.component.html', styleUrls: ['./framework.component.css'] }) export class FrameworkComponent implements OnInit { constructor( private authenticationService: AuthenticationService, private historyService: HistoryService *2* ) { } ... </code></pre> <ul> <li> <p><strong><em>1</em> 导入服务</strong></p> </li> <li> <p><strong><em>2</em> 将其注入到组件中</strong></p> </li> </ul> <p>在此代码到位后,填写 <code>HistoryService</code> 的逻辑。您需要做几件事情来跟踪用户的导航历史:</p> <ul> <li> <p>导入 Angular <code>Router</code> 模块。</p> </li> <li> <p>订阅到 <code>events</code> 属性以跟踪每个导航事件。</p> </li> <li> <p>创建一个公共方法以获取对导航历史的访问权限。</p> </li> </ul> <p>下一个列表显示了这一操作。</p> <h5 id="列表-1220-添加历史服务">列表 12.20. 添加历史服务</h5> <pre><code>import { Injectable } from '@angular/core'; import { Router, NavigationEnd } from '@angular/router'; *1* import { filter } from 'rxjs/operators'; *2* @Injectable({ providedIn: 'root' }) export class HistoryService { private urls: string[] = []; constructor(private router: Router) { this.router.events *3* .pipe(filter(routerEvent => routerEvent instanceof NavigationEnd)) .subscribe((routerEvent: NavigationEnd) => { const url = routerEvent.urlAfterRedirects; this.urls = [...this.urls, url]; }); } ... } </code></pre> <ul> <li> <p><strong><em>1</em> 导入 Router 和 NavigationEnd 类</strong></p> </li> <li> <p><strong><em>2</em> 引入来自 rxjs 的 filter</strong></p> </li> <li> <p><strong><em>3</em> 事件属性订阅</strong></p> </li> </ul> <p>在 列表 12.20 中给出的构造函数功能可能需要更仔细地查看。路由器的 <code>events</code> 属性返回一个 Observable,它发出多个事件类型,但您只对从 <code>@angular/router</code> 导入的 <code>NavigationEnd</code> 事件感兴趣。</p> <p>要从可观察对象(事件流)获取这些事件类型,您需要过滤它们,这正是 RxJS <code>filter</code> 函数发挥作用的地方。此函数通过可观察对象的 <code>pipe</code> 方法连接到您的事件流。由于本书不涉及 RxJS,我们建议阅读 <em>RxJS in Action</em> (<a href="https://www.manning.com/books/rxjs-in-action" target="_blank"><code>www.manning.com/books/rxjs-in-action</code></a>) 以获取更多详细信息。</p> <p>在您 <code>subscribe</code> 到这些事件后,该管道的事件类型为 <code>NavigationEnd</code>,这正是您所需要的。<code>NavigationEnd</code> 事件有一个 <code>urlAfterRedirects</code> 属性,这是一个字符串,您可以将其推送到您在 <code>HistoryService</code> 中持有的 <code>urls</code> 数组。</p> <p>最后,您需要添加一个方法,该方法可以从收集到的 URL 历史中返回上一个 URL。将以下方法添加到 <code>HistoryService</code>。</p> <h5 id="列表-1221-getpreviousurl-函数">列表 12.21. <code>getPreviousUrl</code> 函数</h5> <pre><code>public getPreviousUrl(): string { const length = this.urls.length; return length > 1 ? this.urls[length – 2] : '/'; *1* } </code></pre> <ul> <li><strong><em>1</em> 返回默认位置,如果没有其他条目</strong></li> </ul> <p>现在您已经有了跟踪用户在登录或注册之前位置的历史服务,将其作为 <code>login</code> 和 <code>register</code> 组件的一部分实现。</p> <p>您将按照下一个列表所示将其添加到 <code>register</code> 组件中,并在稍后作为练习更改 <code>login</code> 组件,因为操作是相同的。解决方案可在 GitHub 上找到。</p> <h5 id="列表-1222-在注册组件中需要更改的内容">列表 12.22. 在注册组件中需要更改的内容</h5> <pre><code>import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { AuthenticationService } from '../authentication.service'; import { HistoryService } from '../history.service'; *1* ... constructor( private router: Router, private authenticationService: AuthenticationService, private historyService: HistoryService *2* ) { } ... private doRegister(): void { this.authenticationService.login(this.credentials) .then( () => { this.router.navigateByUrl(this.historyService.getPreviousUrl()); *3* }) .catch( (message) => { this.formError = message }); } ... </code></pre> <ul> <li> <p><strong><em>1</em> 导入历史服务</strong></p> </li> <li> <p><strong><em>2</em> 在构造函数中注入历史服务</strong></p> </li> <li> <p><strong><em>3</em> 使用提供的 getPreviousUrl 函数通过路由器进行重定向</strong></p> </li> </ul> <p>完成此更改后,可能通过一些测试,您可能会注意到 <code>register</code> 组件返回您的页面是登录页面——这不是您想要的。在注册后,作为用户,您希望返回到登录之前的页面,因为那是您进入登录/注册循环的地方。从用户的角度来看,这不是一个好的体验。</p> <p>为了避免这种体验,向 <code>history</code> 服务添加一个新方法,该方法返回在执行所需操作之前遇到的最后一个 URL。这样,用户在执行操作之前在这两个页面之间多次往返都没有关系。</p> <p>你将通过在已导航的 URL 列表中使用过滤器来实现这一点,移除所有与排除列表中匹配的 URL。然后选择最后一个,放心地知道你已经移除了所有注册和登录项。</p> <h5 id="列表-1223-getlastnonloginurl">列表 12.23. <code>getLastNonLoginUrl()</code></h5> <pre><code>public getLastNonLoginUrl(): string { const exclude: string[] = ['/register', '/login']; *1* const filtered = this.urls.filter(url => !exclude.includes(url)); *2* const length = filtered.length; return length > 1 ? filtered[length – 1] : '/'; *3* } </code></pre> <ul> <li> <p><strong><em>1</em> 需要排除的字符串列表</strong></p> </li> <li> <p><strong><em>2</em> 过滤收集到的 URL 列表,并仅返回不在排除列表中的那些</strong></p> </li> <li> <p><strong><em>3</em> 返回过滤后的数组的最后一个元素或默认值</strong></p> </li> </ul> <p>将此代码添加到<code>history</code>服务中,并将<code>login.component.ts</code>中的<code>doLogin()</code>函数和<code>register.component.ts</code>中的<code>doRegister()</code>函数更改为使用它,如下所示(来自 register.component.ts)。</p> <h5 id="列表-1224-更新-doregister-函数">列表 12.24. 更新 <code>doRegister</code> 函数</h5> <pre><code>private doRegister(): void { this.authenticationService.register(this.credentials) .then( () => { this.router.navigateByUrl( this.historyService.getLastNonLoginUrl() *1* ); }) .catch( (message) => { this.formError = message }); } </code></pre> <ul> <li><strong><em>1</em> 将 getPreviousUrl() 更改为 getLastNonLoginUrl()</strong></li> </ul> <p>现在,你可以享受登录的好处。你将向 location-details.component.ts 注入 <code>authentication</code> 服务,以便检查用户是否已登录并相应地提供功能。</p> <p>你将要进行几件事情:</p> <ul> <li> <p>将身份验证服务注入到组件中,以检查用户的登录状态。</p> </li> <li> <p>修改组件以利用登录状态。</p> </li> </ul> <p>首先,导入必要的<code>AuthenticationService</code>,然后将其注入到组件的<code>constructor</code>中。</p> <h5 id="列表-1225-location-detailscomponentts-的更改">列表 12.25. location-details.component.ts 的更改</h5> <pre><code>import { Component, OnInit, Input } from '@angular/core'; import { Location, Review } from '../location'; import { Loc8rDataService } from '../loc8r-data.service'; import { AuthenticationService } from '../authentication.service'; *1* ... constructor( private loc8rDataService: Loc8rDataService, private authenticationService: AuthenticationService *2* ) { } ngOnInit() {} ... } </code></pre> <ul> <li> <p><strong><em>1</em> 导入 AuthenticationService</strong></p> </li> <li> <p><strong><em>2</em> 将 AuthenticationService 注入到组件中</strong></p> </li> </ul> <p>接下来,添加一些利用 <code>AuthenticationService</code> 提供的功能的方法。将 列表 12.26 中的两个方法添加到 <code>location-details</code> 组件中。</p> <h5 id="列表-1226-需要添加到-location-detailscomponentts-的方法">列表 12.26. 需要添加到 location-details.component.ts 的方法</h5> <pre><code>public isLoggedIn(): boolean { *1* return this.authenticationService.isLoggedIn(); } public getUsername(): string { *2* const { name } = this.authenticationService.getCurrentUser(); return name ? name : 'Guest'; *3* } </code></pre> <ul> <li> <p><strong><em>1</em> 从 AuthenticationService 获取 isLoggedIn 的包装函数</strong></p> </li> <li> <p><strong><em>2</em> 从 AuthenticationService 获取 getCurrentUser 的包装函数</strong></p> </li> <li> <p><strong><em>3</em> 如果名称不可用,则返回 Guest</strong></p> </li> </ul> <p>为了完成这个练习的部分,你需要通过更新模板来实现。</p> <ul> <li> <p>确保用户已验证才能留下评论</p> </li> <li> <p>在撰写评论时,无需输入作者名称</p> </li> <li> <p>在提交评论时,从身份验证服务提供用户名作为作者,并防止验证失败</p> </li> </ul> <p>首先,更改模板,以便在注销状态下,显示一个按钮邀请用户登录以发布评论。当用户登录时,页面显示一个按钮,允许他们添加评论。</p> <p>按照以下所示更改<code>location-details</code>模板(location-details.component.html)。</p> <h5 id="列表-1227-location-detailscomponenthtml-的更改">列表 12.27. location-details.component.html 的更改</h5> <pre><code><div class="row"> <div class="col-12"> <div class="card card-primary review-card"> <div class="card-block" [ngSwitch]="isLoggedIn()"> *1* <button (click)="formVisible=true" class="btn btn-primary float-right"*ngSwitchCase="true">Add review</button> *2* <a routerLink="/login" class="btn btn-primary float-right" *ngSwitchDefault>Log in to add review</a> *3* <h2 class="card-title">Customer reviews</h2> <div *ngIf="formVisible"> </code></pre> <ul> <li> <p><strong><em>1</em> ngSwitch 绕过登录状态</strong></p> </li> <li> <p><strong><em>2</em> 显示用户是否已登录</strong></p> </li> <li> <p><strong><em>3</em> 默认状态</strong></p> </li> </ul> <p><code>ngSwitch</code> 指令检查用户是否已登录,并显示相应的行动号召。两种状态都在 图 12.4 中展示。</p> <h5 id="图-124-根据用户是否登录添加评论按钮的两种状态">图 12.4. 根据用户是否登录,添加评论按钮的两种状态</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/12fig04_alt.jpg" alt="" loading="lazy"></p> <p>现在用户需要登录才能发表评论,因此不再需要用户在评论表单中输入他们的名字,因为现在可以从 JWT 中检索此数据。因此,您需要从 location-details.component.html 模板中删除代码。请参阅以下列表以了解要删除的元素。</p> <h5 id="列表-1228-从-location-detailscomponenthtml-中删除的代码">列表 12.28. 从 location-details.component.html 中删除的代码</h5> <pre><code><div class="form-group row"> <label for="name" class="col-sm-2 col-form-label">Name</label> <div class="col-sm-10"> <input [(ngModel)]="newReview.author" id="name" name="name" required="required" class="form-control"> </div> </div> </code></pre> <p>没有表单字段时,您需要从您之前方便创建的 <code>getUsername()</code> 函数中提取作者名称。列表 12.29 以粗体突出显示 <code>location-details.component.ts</code> 中的 <code>onReviewSubmit()</code> 需要更改的部分。图 12.5 展示了最终的审查表单。</p> <h5 id="图-125-没有姓名字段的最终审查表单">图 12.5. 没有姓名字段的最终审查表单</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/12fig05_alt.jpg" alt="" loading="lazy"></p> <h5 id="列表-1229-从-location-detailscomponentts-中删除姓名验证">列表 12.29. 从 location-details.component.ts 中删除姓名验证</h5> <pre><code>public onReviewSubmit(): void { this.formError = ''; this.newReview.author = this.getUsername(); *1* if (this.formIsValid()) { this.loc8rDataService.addReviewByLocationId(this.location._id, this.newReview) .then((review: Review) => { console.log('Review saved', review); let reviews = this.location.reviews.slice(0); reviews.unshift(review); this.location.reviews = reviews; this.resetAndHideReviewForm(); }); } else { this.formError = 'All fields required, please try again'; } } ... } </code></pre> <ul> <li><strong><em>1</em> 从组件中获取用户名</strong></li> </ul> <p>如果您现在尝试这样做,您仍然会遇到问题。如果您检查网络浏览器的开发控制台,您会看到 API 返回了 401 未授权的响应,因为您还没有更新评论提交 API 调用以允许 API 接受请求。</p> <p>为了使这起作用,您需要获取存储在 <code>localStorage</code> 中的 JWT 访问权限,并将其作为 <code>Bearer</code> 令牌传递给 <code>Authorization</code> 请求标头。</p> <h5 id="列表-1230-将-authenticationservice-添加到-loc8r-dataservicets">列表 12.30. 将 <code>AuthenticationService</code> 添加到 loc8r-data.service.ts</h5> <pre><code>import { Injectable, Inject } from '@angular/core'; ... import { AuthResponse } from './authresponse'; import { BROWSER_STORAGE } from './storage'; *1* @Injectable({ providedIn: 'root' }) export class Loc8rDataService { constructor( private http: HttpClient, @Inject(BROWSER_STORAGE) private storage: Storage *2* ) { } </code></pre> <ul> <li> <p><strong><em>1</em> 导入 AuthenticationService</strong></p> </li> <li> <p><strong><em>2</em> 将导入的服务注入到组件中</strong></p> </li> </ul> <p>最后,您需要更新 <code>addReviewByLocationId()</code> 函数,以便在提交到 API 的请求中包含 <code>Authorization</code> 标头。以下列表显示了更改。</p> <h5 id="列表-1231-向-api-调用添加-authorization-标头">列表 12.31. 向 API 调用添加 <code>Authorization</code> 标头</h5> <pre><code>public addReviewByLocationId(locationId: string, formData: Review): Promise<Review> { const url: string = `${this.apiBaseUrl}/locations/${locationId}/ reviews`; const httpOptions = { *1* headers: new HttpHeaders({ 'Authorization': `Bearer ${this.storage.getItem('loc8r-token')}` *2* }) }; return this.http .post(url, formData, httpOptions) *3* .toPromise() .then(response => response as Review) .catch(this.handleError); } </code></pre> <ul> <li> <p><strong><em>1</em> 创建一个用于 HttpHeaders 的 httpOptions 对象</strong></p> </li> <li> <p><strong><em>2</em> 这里使用的是字符串模板</strong></p> </li> <li> <p><strong><em>3</em> 向 API 调用添加 httpOptions</strong></p> </li> </ul> <p>通过这次更新,您已经完成了身份验证部分。用户必须登录才能添加评论,并且通过身份验证系统,评论将被赋予正确的用户名。</p> <p>这就结束了本书的内容。到现在为止,您应该对 MEAN 堆栈的强大功能和能力有了很好的了解,并能够开始构建一些酷炫的东西!</p> <p>您拥有构建 REST API、服务器端 Web 应用程序和基于浏览器的单页应用程序的平台。您可以创建数据库驱动的网站、API 和应用程序,然后将它们发布到实时 URL。</p> <p>当开始你的下一个项目时,记得花点时间思考最佳架构和用户体验。花点时间规划,让你的开发时间更加高效和愉快。并且永远不要害怕在开发过程中重构和改进你的代码和应用程序。</p> <p>你只是触及了这些令人惊叹的技术所能提供的表面。所以请深入探索,构建事物,尝试新事物,持续学习,并且(最重要的是)享受乐趣!</p> <h3 id="摘要-8">摘要</h3> <p>在本章中,你学习了</p> <ul> <li> <p>如何使用本地存储在浏览器中管理用户会话</p> </li> <li> <p>如何在 Angular 中使用 JWT 数据</p> </li> <li> <p>如何通过 HTTP 头部从 Angular 传递 JWT 到 API</p> </li> </ul> <h2 id="附录-a-安装堆栈">附录 A. 安装堆栈</h2> <p><em>本附录涵盖</em></p> <ul> <li> <p>安装 Node 和 npm</p> </li> <li> <p>全局安装 Express</p> </li> <li> <p>安装 MongoDB</p> </li> <li> <p>安装 Angular</p> </li> </ul> <p>在您可以在 MEAN 堆栈上构建任何内容之前,您需要安装运行它的软件。在 Windows、macOS 和流行的 Linux 发行版(如 Ubuntu)上,这项任务都很简单。</p> <p>由于 Node 是堆栈的基础,因此这是开始的最佳位置。Node 随附 npm,这对于安装其他软件将很有用。</p> <h2 id="安装-node-和-npm">安装 Node 和 npm</h2> <p>安装 Node 和 npm 的最佳方式取决于您的操作系统。只要可能,我们建议您从 Node 网站下载安装程序,网址为 <a href="https://nodejs.org/download" target="_blank"><code>nodejs.org/download</code></a>。该位置总是有 Node 核心团队维护的最新版本。</p> <h4 id="node-的长期支持版本">Node 的长期支持版本</h4> <p>我们建议使用 Node 的长期支持(LTS)版本。这些版本具有偶数的主版本号,例如 Node 8 和 Node 10。这些版本是 Node 的稳定分支,将在 18 个月内进行维护和修补,不会引入破坏性更改。本书中的应用程序是针对 Node 11 构建的,因此最佳 LTS 版本是 10。本书中使用的功能在版本之间没有不兼容之处,因此请随意使用任何一个。</p> <h4 id="在-windows-上安装-node">在 Windows 上安装 Node</h4> <p>Windows 用户应从 Node 网站下载安装程序。</p> <h4 id="在-macos-上安装-node">在 macOS 上安装 Node</h4> <p>对于 macOS 用户来说,从 Node 网站下载安装程序是最佳选择。或者,您可以使用 Homebrew 软件包管理器安装 Node 和 npm,具体细节请参考 Joyent 的 Node 维基,网址为 <a href="https://github.com/joyent/node/wiki/Installing-Node.js-via-package-manager" target="_blank"><code>github.com/joyent/node/wiki/Installing-Node.js-via-package-manager</code></a>。</p> <h4 id="在-linux-上安装-node">在 Linux 上安装 Node</h4> <p>对于 Linux 用户来说,没有安装程序,但如果您熟悉它们,可以从 Node 网站下载二进制文件。</p> <p>或者,Linux 用户可以从软件包管理器中安装 Node。软件包管理器并不总是有最新版本,请注意这一点。一个特别过时的例子是 Ubuntu 上流行的 APT 系统。您可以在 GitHub 上 Joyent 的 Node 维基上找到使用各种软件包管理器的说明,包括 Ubuntu 上 APT 的修复,网址为 <a href="https://github.com/joyent/node/wiki/Installing-Node.js-via-package-manager" target="_blank"><code>github.com/joyent/node/wiki/Installing-Node.js-via-package-manager</code></a>。</p> <h4 id="通过检查版本来验证安装">通过检查版本来验证安装</h4> <p>在您安装了 Node 和 npm 之后,您可以使用几个终端命令来检查您拥有的版本:</p> <pre><code>$ node --version $ npm --version </code></pre> <p>这些命令会输出您机器上安装的 Node 和 npm 版本。本书中的代码是用 Node 11.2.0 和 npm 6.4.1 构建的。</p> <h2 id="全局安装-express">全局安装 Express</h2> <p>要能够从命令行即时创建新的 Express 应用程序,您需要安装 Express 生成器。您可以使用 npm 从命令行执行此操作。在终端中,运行以下命令:</p> <pre><code>$ npm install -g express-generator </code></pre> <p>如果此命令因权限错误而失败,你需要以管理员身份运行它。在 Windows 上,右键单击命令提示符图标,从上下文菜单中选择“以管理员身份运行”。然后,在生成的窗口中再次尝试前面的命令。</p> <p>在 macOS 和 Linux 上,你可以在命令前加上 <code>sudo</code>,如下面的代码片段所示;你将被提示输入密码。</p> <pre><code>$ sudo npm install -g express-generator </code></pre> <p>当生成器完成 Express 的安装后,你可以通过在终端检查版本号来验证它:</p> <pre><code>$ express --version </code></pre> <p>本书代码示例中使用的 Express 版本为 4.16.4。</p> <p>如果你在安装过程中遇到任何问题,Express 的文档可在其网站上找到,网址为 <a href="http://expressjs.com" target="_blank"><code>expressjs.com</code></a>。</p> <h2 id="安装-mongodb">安装 MongoDB</h2> <p>MongoDB 也可用于 Windows、macOS 和 Linux。有关所有以下选项的详细说明,请参阅文档中的<a href="https://docs.mongodb.com/manual/administration/install-community" target="_blank"><code>docs.mongodb.com/manual/administration/install-community</code></a>。</p> <h4 id="在-windows-上安装-mongodb">在 Windows 上安装 MongoDB</h4> <p>根据你运行的 Windows 版本,一些直接下载可在<a href="https://docs.mongodb.org/manual/installation" target="_blank"><code>docs.mongodb.org/manual/installation</code></a>找到。</p> <h4 id="在-macos-上安装-mongodb">在 macOS 上安装 MongoDB</h4> <p>对于 macOS,最简单的安装 MongoDB 的方法是使用 Homebrew 软件包管理器,但如果你愿意,你也可以选择手动安装 MongoDB。</p> <h4 id="在-linux-上安装-mongodb">在 Linux 上安装 MongoDB</h4> <p>所有主流 Linux 发行版都提供了相应的软件包,具体详情请参阅<a href="https://docs.mongodb.org/manual/installation" target="_blank"><code>docs.mongodb.org/manual/installation</code></a>。如果你运行的 Linux 版本中没有提供 MongoDB 软件包,你可以选择手动安装它。</p> <h4 id="将-mongodb-作为服务运行">将 MongoDB 作为服务运行</h4> <p>在你安装 MongoDB 后,你可能希望将其作为服务运行,以便在重启时自动重启。同样,你可以在 MongoDB 安装文档中找到相关说明。</p> <h4 id="检查-mongodb-版本号">检查 MongoDB 版本号</h4> <p>MongoDB 不仅安装了自身,还安装了一个 Mongo shell,这样你就可以通过命令行与 MongoDB 数据库进行交互。你可以独立检查 MongoDB 和 Mongo shell 的版本号。要检查 shell 版本,请在终端运行以下命令:</p> <pre><code>$ mongo --version </code></pre> <p>要检查 MongoDB 的版本,请运行以下命令:</p> <pre><code>$ mongod --version </code></pre> <p>本书使用 MongoDB 和 Mongo shell 的版本均为 4.0.4。</p> <h2 id="安装-angular">安装 Angular</h2> <p>只要你已经安装了 Node 和 npm,Angular 就很容易安装。你实际上安装的是 Angular CLI 作为全局 npm 软件包。为此,请在终端运行以下命令:</p> <pre><code>$ npm install -g @angular/cli Currently, this command installs Angular CLI version 7.0.6, which covers Angular 7.1.0. </code></pre> <h2 id="附录-b-安装和准备支持角色">附录 B. 安装和准备支持角色</h2> <p><em>本附录涵盖</em></p> <ul> <li> <p>添加 Twitter Bootstrap 和一些自定义样式</p> </li> <li> <p>使用 Font Awesome 提供一套现成的图标</p> </li> <li> <p>安装 Git</p> </li> <li> <p>安装 Docker 并使用包含的容器设置</p> </li> <li> <p>安装合适的命令行界面</p> </li> <li> <p>在 Heroku 上注册</p> </li> <li> <p>安装 Heroku CLI</p> </li> </ul> <p>几种技术可以帮助您在 MEAN 堆栈上进行开发,从前端布局到源代码控制和部署工具。本附录涵盖了本书中使用的支持技术的安装和设置。由于实际的安装说明可能会随时间而变化,本附录将您指向获取说明和需要注意的事项的最佳位置。</p> <h2 id="twitter-bootstrap">Twitter Bootstrap</h2> <p>Bootstrap 并不是直接安装,而是添加到您的应用程序中。这个过程就像下载库文件、解压它们并将它们放置在应用程序中一样简单。</p> <p>第一步是下载 Bootstrap。本书使用的是版本 4.1,目前这是官方发布版本。您可以从<a href="https://getbootstrap.com" target="_blank"><code>getbootstrap.com</code></a>获取。请确保您下载的是“ready to use files”,而不是源代码。分发压缩包包含两个文件夹:css 和 js。</p> <p>当您下载并解压文件后,将每个文件夹中的一个文件移动到您的 Express 应用程序的 public 文件夹中,如下所示:</p> <ol> <li> <p>将 bootstrap.min.css 复制到您的 public/stylesheets 文件夹中。</p> </li> <li> <p>将 bootstrap.min.js 复制到您的 public/js 文件夹中。</p> </li> </ol> <p>图 B.1 显示了您的应用程序中公共文件夹应有的样子。</p> <h5 id="图-b1-添加-bootstrap-后公共文件夹的结构和内容">图 B.1. 添加 Bootstrap 后公共文件夹的结构和内容</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/bfig01.jpg" alt="" loading="lazy"></p> <p>这将为您提供 Bootstrap 的默认外观和感觉,但您可能希望您的应用程序在人群中脱颖而出。您可以通过添加一个主题或一些自定义样式来实现这一点。</p> <h4 id="添加一些自定义样式">添加一些自定义样式</h4> <p>本书中的 Loc8r 应用程序使用了一些我们创建的自定义样式。这个应用程序足够简单,不需要主题,但基于 Bootstrap 4.1。</p> <p>要添加自定义样式,请编辑 public/stylesheets 文件夹中的 style.css 文件。列表 B.1 显示了一个良好的起点,并提供了本书中使用的 CSS。</p> <h5 id="列表-b1-给-loc8r-添加更独特外观的自定义样式">列表 B.1. 给 Loc8r 添加更独特外观的自定义样式</h5> <pre><code>@import url("//fonts.googleapis.com/css?family=Lobster|Cabin:400,700"); h1, h2, h3, h4, h5, h6 { font-family: 'Lobster', cursive; } legend { font-family: 'Lobster', cursive; } .navbar { background-color: #ad1d28; border-color: #911821; } .navbar-light .navbar-brand { font-family: 'Lobster', cursive; color: #fff; } .navbar-light .navbar-toggler { color: white; border-color: white; } .navbar-light .navbar-toggler-icon { background-image: url("data:image/svgxml;charset=utf8,%3Csvg viewBox='0 0 30 30'xmlns='http://www.w3.org/2000/svg'%3E% 3Cpath stroke='white' stroke-width='2'stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E") } .navbar-light .navbar-nav .nav-link, .navbar-light .navbar-nav .nav-link:focus, .navbar-light .navbar-nav .nav-link:hover { color: white; } .card { background-color: #469ea8; padding: 1rem; } .card-primary { border-color: #a2ced3; margin-bottom: 0.5rem; } .banner { margin-top: 4em; border-bottom: 1px solid #469ea8; margin-bottom: 1.5em; padding-bottom: 0.5em; } .review-header { background-color: #31727a; padding-top: 0.5em; padding-bottom: 0.5em; margin-bottom: 0.5em; } .review { margin-right: -16px; margin-left: -16px; margin-bottom: 0.5em; } .badge-default, .btn-primary { background-color: #ad1d28; border-color: #911821; } h4 a, h4 a:hover { color: #fff; } h4 small { font-size: 60%; line-height: 200%; color: #aaa; } h1 small { color: #aaa; } .address { margin-bottom: 0.5rem; } .facilities span.badge { margin-right: 2px; } p { margin-bottom: 0.65rem; } a { color: rgba(255, 255, 255, 0.8) } a:hover { color:#fff } body { font-family: "Cabin", Arial, sans-serif; color: #fff; background-color: #108a93; } </code></pre> <p>为了让您免于输入所有这些代码,您可以从 GitHub 上的项目仓库<a href="https://github.com/cliveharber/gettingMean-2" target="_blank"><code>github.com/cliveharber/gettingMean-2</code></a>获取此文件。它在第四章分支中介绍。</p> <h2 id="font-awesome">Font Awesome</h2> <p>Font Awesome 通过使用字体和 CSS 而不是图像来获取可缩放图标的一种极好的方式。与 Bootstrap 一样,需要下载一些文件并将它们放在正确的位置。</p> <p>首先,前往 <a href="https://fontawesome.com/how-to-use/on-the-web/setup/hosting-font-awesome-yourself" target="_blank"><code>fontawesome.com/how-to-use/on-the-web/setup/hosting-font-awesome-yourself</code></a>,并点击下载按钮以下载 zip 文件。(按钮目前是一个大蓝色按钮,但当你到达那里时可能已经改变。)在这本书中,我们使用了版本 5.2.0。zip 文件包含大量的文件夹。对于这本书来说,最重要的文件夹是 css 和 webfonts。</p> <p>当 Font Awesome 下载并解压后,请按照以下两个步骤操作:</p> <ol> <li> <p>将整个 webfonts/folder 复制到你的应用程序的 public 文件夹中。</p> </li> <li> <p>将 css 文件夹中的 all.min.css 文件复制到 public/stylesheets。</p> </li> </ol> <p>当完成这些操作后,如果你已经安装了 Bootstrap,你的 public 文件夹应该看起来像 图 B.2。</p> <h5 id="图-b2-添加-font-awesome-后-public-文件夹的结构和内容">图 B.2. 添加 Font Awesome 后 public 文件夹的结构和内容</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/bfig02.jpg" alt="" loading="lazy"></p> <p>注意,在使用 Font Awesome 时,字体文件夹相对于 all.min.css 文件的名字和位置很重要。CSS 文件通过使用相对路径 ../webfonts/ 来引用字体,所以如果这个路径损坏,字体图标在你的应用程序中将无法工作。</p> <p>如果你没有耐心做所有这些,GitHub 仓库中已经提供了这些内容。</p> <h2 id="安装-git">安装 Git</h2> <p>这本书的源代码是用 Git 管理的,所以最简单的方法是使用 Git 来访问它。此外,Heroku 依赖于 Git 来管理部署过程并将代码从你的开发机器推送到生产环境。如果你还没有安装 Git,你需要安装它。</p> <p>你可以通过一个简单的终端命令来验证你是否已经安装了它:</p> <pre><code>$ git --version </code></pre> <p>如果这个命令响应了一个版本号,那么你已经安装了它,可以移动到下一节。如果没有,你需要安装 Git。</p> <p>对于新接触 Git 的 macOS 和 Windows 用户来说,一个好的起点是下载并安装 GitHub 用户界面,网址为 <a href="https://help.github.com/articles/setup-git" target="_blank"><code>help.github.com/articles/setup-git</code></a>。</p> <p>虽然不需要图形用户界面,但你可以通过遵循主 Git 网站上的说明来单独安装 Git,网址为 <a href="https://git-scm.com/downloads" target="_blank"><code>git-scm.com/downloads</code></a>。</p> <h2 id="安装-docker">安装 Docker</h2> <p>在这个版本中,我们包括了运行应用程序针对本地 Docker 环境的能力。那些细心的读者可能已经注意到了仓库中的 Docker 文件。</p> <p>要运行 Docker 容器,你需要在本地安装 Docker。(我们使用了 Docker Desktop。)如果你使用的是 macOS 或 Windows 机器,请访问 <a href="https://www.docker.com/products/docker-desktop" target="_blank"><code>www.docker.com/products/docker-desktop</code></a>,并安装适合你机器的版本。</p> <p>要在容器中运行应用程序,导航到克隆的仓库,并输入 <code>make build</code>。每个分支都有一个 Docker 文件,它设置了一个适合运行该章节代码的环境。如果你需要关闭容器,请使用 <code>make destroy</code>。</p> <p>如果你想在没有 Docker 的情况下本地运行代码,那也很好。</p> <h2 id="安装合适的命令行界面">安装合适的命令行界面</h2> <p>您可以通过使用命令行界面(CLI)来最大限度地发挥 Git 的作用,即使您已经下载并安装了图形用户界面(GUI)。一些 CLI 比其他的好,而且您不能使用原生的 Windows 命令提示符,所以如果您使用的是 Windows,您肯定需要运行其他的东西。以下是我们在一些建议的环境中使用的工具:</p> <ul> <li> <p>macOS Mavericks 及更高版本:原生终端</p> </li> <li> <p>macOS pre-Mavericks(10.8.5 及更早版本):iTerm</p> </li> <li> <p>Windows:GitHub shell(这个与 GitHub GUI 一起安装)</p> </li> <li> <p>Ubuntu:原生终端</p> </li> </ul> <p>Visual Studio Code 编辑器内置了一个不错的命令行终端,这也是一个很好的跨平台选项。如果您有其他偏好并且 Git 命令可以工作,那么您当然可以使用您已经熟悉并习惯使用的东西。</p> <h2 id="设置-heroku">设置 Heroku</h2> <p>本书使用 Heroku 在实时生产环境中托管 Loc8r 应用程序。您也可以这样做——免费——只要您注册,安装 CLI 并通过终端登录即可。</p> <h4 id="在-heroku-上注册">在 Heroku 上注册</h4> <p>要使用 Heroku,您需要注册一个账户。对于您将通过本书构建的应用程序,免费账户就足够了。请访问 <a href="https://www.heroku.com" target="_blank"><code>www.heroku.com</code></a>,并按照说明进行注册。</p> <h4 id="安装-heroku-cli">安装 Heroku CLI</h4> <p>Heroku CLI 包含 Heroku 命令行外壳和一个名为 Heroku Local 的实用工具。外壳是您将通过终端使用来管理您的 Heroku 部署的东西,而 Local 对于确保您在机器上构建的内容能够在 Heroku 上正确运行非常有用。您可以从 <a href="https://devcenter.heroku.com/articles/heroku-cli" target="_blank"><code>devcenter.heroku.com/articles/heroku-cli</code></a> 下载适用于 macOS、Windows 和 Linux 的工具包。</p> <h4 id="使用终端登录-heroku">使用终端登录 Heroku</h4> <p>在您注册了账户并在您的机器上安装了 CLI 之后,最后一步是从终端登录到您的账户。输入以下命令:</p> <pre><code>$ heroku login </code></pre> <p>此命令会提示您输入 Heroku 登录凭证。登录后,您就设置好了,并准备好使用 Heroku。</p> <h2 id="附录-c-处理所有视图">附录 C. 处理所有视图</h2> <p><em>本附录涵盖</em></p> <ul> <li> <p>从所有视图(除了主页)中移除数据</p> </li> <li> <p>将数据移动到控制器中</p> </li> </ul> <p>第四章 讨论了设置控制器和视图以创建静态、可点击的原型。该章节更详细地介绍了“如何”和“为什么”,因此本附录专注于结果。</p> <h2 id="将数据从视图移动到控制器">将数据从视图移动到控制器</h2> <p>此过程的一部分包括将数据从视图移动回 MVC 流程,即从视图移动到控制器。第四章中的示例处理了 Loc8r 主页中的此任务,但还需要对其他页面执行此操作。从详情页面开始。</p> <h4 id="详情页面">详情页面</h4> <p>详情页面是页面中最大、最复杂的,数据需求也最多。第一步是设置控制器。</p> <h5 id="设置控制器">设置控制器</h5> <p>此页面的控制器称为 <code>locationInfo</code>,位于 <code>app_server/controllers</code> 中的 <code>locations.js</code> 文件。当您在视图中分析数据并将其整理成 JavaScript 对象时,您的控制器将类似于以下列表。</p> <h5 id="列表-c1-locationinfo-控制器">列表 C.1. <code>locationInfo</code> 控制器</h5> <pre><code>const locationInfo = function(req, res){ res.render('location-info', { title: 'Starcups', pageHeader: {title: 'Starcups'}, sidebar: { context: 'is on Loc8r because it has accessible wifi and space to sit down with your laptop and get some work done.', callToAction: 'If you\'ve been and you like it - or if you don\'t - please leave a review to help other people just like you.' }, location: { name: 'Starcups', address: '125 High Street, Reading, RG6 1PS', rating: 3, facilities: ['Hot drinks', 'Food', 'Premium wifi'], coords: {lat: 51.455041, lng: -0.9690884}, *1* openingTimes: [{ *2* days: 'Monday - Friday', opening: '7:00am', closing: '7:00pm', closed: false },{ days: 'Saturday', opening: '8:00am', closing: '5:00pm', closed: false },{ days: 'Sunday', closed: true }], reviews: [{ *3* author: 'Simon Holmes', rating: 5, timestamp: '16 July 2013', reviewText: 'What a great place. I can\'t say enough good things about it.' },{ author: 'Charlie Chaplin', rating: 3, timestamp: '16 June 2013', reviewText: 'It was okay. Coffee wasn\'t great, but the wifi was fast.' }] } }); }; </code></pre> <ul> <li> <p><strong><em>1</em> 包含用于在 Google 地图图像中使用的纬度和经度坐标</strong></p> </li> <li> <p><strong><em>2</em> 添加了开放时间数组,允许不同日期有不同的数据</strong></p> </li> <li> <p><strong><em>3</em> 存储其他用户留下的评论的数组</strong></p> </li> </ul> <p>注意发送的纬度和经度。您可以从 <a href="https://www.where-am-i.net" target="_blank"><code>www.where-am-i.net</code></a> 获取您的当前纬度和经度。您可以从 <a href="https://www.latlong.net/convert-address-to-lat-long.html" target="_blank"><code>www.latlong.net/convert-address-to-lat-long.html</code></a> 将地址地理编码——即获取其纬度和经度。您的视图将使用 <code>lat</code> 和 <code>lng</code> 来显示正确位置的 Google 地图图像,因此在原型阶段这样做是值得的。</p> <h5 id="更新视图-1">更新视图</h5> <p>由于这个页面是最复杂、数据最丰富的页面,因此可以合理地推断它将拥有最大的视图模板。您已经看到了主页布局中的大多数技术细节,例如循环数组、引入包含文件以及定义和调用混入。不过,在这个模板中还有几样额外的东西需要注意,这两者都已被注释并用粗体突出显示。</p> <p>首先,此模板使用了一个 <code>if</code>-<code>else</code> 条件语句。这个语句看起来像是没有花括号的 JavaScript。其次,模板使用 JavaScript <code>replace</code> 函数将评论文本中的所有换行符替换为 <code><br/></code> 标签。您通过使用简单的正则表达式,查找文本中所有 <code>\n</code> 字符的出现来完成此操作。以下列表显示了 <code>location-info.pug</code> 视图模板的完整内容。</p> <h5 id="列表-c2-location-infopug-视图模板位于-app_serverviews">列表 C.2. <code>location-info.pug</code> 视图模板位于 <code>app_server/views</code></h5> <pre><code>extends layout include _includes/sharedHTMLfunctions *1* block content .row.banner .col-12 h1= pageHeader.title .row .col-12.col-lg-9 .row .col-12.col-md-6 p.rating +outputRating(location.rating) *2* p 125 High Street, Reading, RG6 1PS .card.card-primary .card-block h2.card-title Opening hours each time in location.openingTimes *3* p.card-text *3* | #{time.days} : *3* if time.closed *3* | closed *3* else *3* | #{time.opening} - #{time.closing} .card.card-primary .card-block h2.card-title Facilities each facility in location.facilities span.badge.badge-warning i.fa.fa-check | #{facility} | .col-12.col-md-6.location-map .card.card-primary .card-block h2.card-title Location map img.img-fluid.rounded(src=`http://maps.googleapis.com/ maps/api/staticmap?center=${location.coords.lat}, ${location.coords.lng}&zoom=17&size=400x350&sensor= false&markers=${location.coords.lat},${location.coords. lng}&key={googleAPIKey}&scale=2`) *4* .row .col-12 .card.card-primary.review-card .card-block a.btn.btn-primary.float-right(href='/location/review/new') Add review h2.card-title Customer reviews each review in location.reviews *5* .row.review .col-12.no-gutters.review-header span.rating +outputRating(review.rating) *5* span.reviewAuthor #{review.author} small.reviewTimestamp #{review.timestamp} .col-12 p !{(review.reviewText).replace(/\n/g, '<br/>')} *6* .col-12.col-lg-3 p.lead #{location.name} #{sidebar.context} p= sidebar.callToAction </code></pre> <ul> <li> <p><strong><em>1</em> 引入了包含 <code>sharedHTMLfunctions</code> 的文件,其中包含 <code>outputRating</code> 混入</strong></p> </li> <li> <p><strong><em>2</em> 调用<code>outputRating</code>混合,传递当前位置的评分</strong></p> </li> <li> <p><strong><em>3</em> 遍历开放时间数组,使用内联 if-else 语句检查位置是否关闭</strong></p> </li> <li> <p><strong><em>4</em> 构建 Google Maps 静态图像的 URL,使用 ES2015 模板字面量插入 lat 和 lng。请记住,你需要你的 Google Maps API 密钥。</strong></p> </li> <li> <p><strong><em>5</em> 遍历每个评论,再次调用<code>outputRating</code>混合以生成星级标记</strong></p> </li> <li> <p><strong><em>6</em> 将评论文本中的任何换行符替换为<br/>标签,以便按作者的意图渲染</strong></p> </li> </ul> <p>可能会出现的疑问是,为什么每次都要将换行符替换为<code><br/></code>标签?为什么不直接在保存数据时使用<code><br/></code>标签呢?那样的话,你只需要在数据保存时运行一次<code>replace</code>函数。答案是,HTML 只是渲染文本的一种方法;碰巧你在这里使用的是这种方法。将来,你可能希望将此信息拉入原生移动应用程序。你不想源数据被在该环境中不使用的 HTML 标记污染。处理这种情况的方法是保持数据干净。</p> <h4 id="添加评论页面">添加评论页面</h4> <p>目前“添加评论”页面很简单,其中只包含一条数据:页面标题中的标题。更新控制器不应该引起太多问题。请参阅以下列表,以获取<code>addReview</code>控制器在<code>app_server/controllers</code>文件夹中<code>locations.js</code>的完整代码。</p> <h5 id="列表-c3-addreview-控制器">列表 C.3. <code>addReview</code> 控制器</h5> <pre><code>const addReview = function(req, res){ res.render('location-review-form', { title: 'Review Starcups on Loc8r', pageHeader: { title: 'Review Starcups' } }); }; </code></pre> <p>这里没有太多可说的;你已经更新了标题内的文本。以下列表显示了相应的视图,<code>location-review-form.pug</code>,在<code>app_server/views</code>。</p> <h5 id="列表-c4-location-review-formpug-模板">列表 C.4. <code>location-review-form.pug</code> 模板</h5> <pre><code>extends layout block content .row.banner .col-12 h1= pageHeader.title .row .col-12.col-md-8 form(action="/location", method="get", role="form") .form-group.row label.col-10.col-sm-2.col-form-label(for="name") Name .col-12.col-sm-10 input#name.form-control(name="name") .form-group.row label.col-10.col-sm-2.col-form-label(for="rating") Rating .col-12.col-sm-2 select#rating.form-control.input-sm(name="rating") option 5 option 4 option 3 option 2 option 1 .form-group.row label.col-sm-2.col-form-label(for="review") Review .col-sm-10 textarea#review.form-control(name="review", rows="5") button.btn.btn-primary.float-right Add my review .col-12.col-md-4 </code></pre> <p>再次强调,这里没有复杂或新的内容,所以你可以继续到“关于”页面。</p> <h4 id="关于页面">关于页面</h4> <p>“关于”页面也不包含大量的数据,只有标题和一些内容。将其从视图中提取到控制器中。请注意,视图中的内容目前包含一些<code><br/></code>标签,所以在将其放入控制器时,需要将每个<code><br/></code>标签替换为<code>\n</code>。这些标签在以下列表中用粗体突出显示。<code>about</code>控制器位于<code>app_server/controllers/others.js</code>。</p> <h5 id="列表-c5-about-控制器">列表 C.5. <code>about</code> 控制器</h5> <pre><code>const about = function(req, res){ res.render('generic-text', { title: 'About Loc8r', content: 'Loc8r was created to help people find places to sit down and get a bit of work done.<br/><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc sed lorem ac nisi digni ssim accumsan. Nullam sit amet interdum magna. Morbi quis faucibus nisi. Vestibulum mollis purus quis eros adipiscing tristique. Proin posuere semper tellus, id placerat augue dapibus ornare. Aenean leo metus, tempus in nisl eget, accumsan interdum dui. Pellentesque sollicitudin volutpat ullamcorper.' }); }; </code></pre> <p>除了从内容中移除 HTML 之外,这里没有太多的事情发生。快速查看视图,你就可以完成了。以下列表显示了用于<code>app_server/views</code>中“关于”页面的最终通用文本视图。视图必须使用与评论部分相同的代码来替换<code>\n</code>换行符为 HTML <code><br/></code>标签。</p> <h5 id="列表-c6-generic-textpug-模板">列表 C.6. <code>generic-text.pug</code> 模板</h5> <pre><code>extends layout .row.banner .col-12 h1= title .row .col-12.col-lg-8 p !{(content).replace(/\n/g, '<br/>')} *1* </code></pre> <ul> <li><strong><em>1</em> 在渲染 HTML 时将所有换行符替换为<br/>标签</strong></li> </ul> <p>这个模板是一个简单、小巧、可重复使用的模板,用于在页面上输出文本时使用。</p> <h2 id="从-promise-切换到-observables">从 Promise 切换到 Observables</h2> <p>在 第八章 中,我们简要讨论了 Observables 和 Promises,然后继续在应用程序中使用 Promises。不过,将应用程序更改为使用 Observables 并不难,本节简要介绍了基础知识,以给你一个完整的了解,了解这项任务可能如何完成。通常,SPA 会根据要解决的问题使用 Observables 和 Promises。</p> <p>查看位于 loc8r-data.service.ts 中的 <code>getLocations()</code> 方法。</p> <h5 id="列表-c7-loc8r-dataservicets">列表 C.7. loc8r-data.service.ts</h5> <pre><code>public getLocations(lat: number, lng: number): Promise<Location[]> { const maxDistance: number = 20000; const url: string = `${this.apiBaseUrl}/locations?lng=${lng}&lat=${lat}&maxDistance=$ {maxDistance}`; return this.http .get(url) .toPromise() *1* .then(response => response as Location[]) .catch(this.handleError); } </code></pre> <ul> <li><strong><em>1</em> 将 Observable 转换为 Promise</strong></li> </ul> <p>如你所见,你正在将 <code>HttpClient get()</code> 方法返回的 Observable 转换为 Promise。</p> <p>要将此方法切换为返回 Observable,你首先需要从 <code>rxjs</code> 中导入 Observable,然后让函数直接返回 <code>get()</code> 方法的结果。</p> <h5 id="列表-c8-修改-loc8r-dataservicets-以返回-observables">列表 C.8. 修改 loc8r-data.service.ts 以返回 Observables</h5> <pre><code>import { Observable } from 'rxjs' ... public getLocations(lat: number, lng: number) : Observable<Location []> { const maxDistance: number = 20000; const url: string = `${this.apiBaseUrl}/locations?lng=${lng}&lat=${lat}&maxDistance= ${maxDistance}`; return this.http.get<Location[]>(url); *1* } </code></pre> <ul> <li><strong><em>1</em> 返回 Observable 类型转换为 Location[]</strong></li> </ul> <p>在这一点上,你没有捕获响应(Observable);为了做到这一点,你需要一个订阅者。这个函数用于 home-list 组件中。</p> <h5 id="列表-c9-getlocations-来自-home-listcomponentts">列表 C.9. <code>getLocations()</code> 来自 home-list.component.ts</h5> <pre><code>private getLocations(position: any): void { this.message = 'Searching for nearby places'; const lat: number = position.coords.latitude; const lng: number = position.coords.longitude; this.loc8rDataService .getLocations(lat, lng) .then(foundLocations => { *1* this.message = foundLocations.length > 0 ? '' : 'No locations found'; this.locations = foundLocations; }); } </code></pre> <ul> <li><strong><em>1</em> 响应 Promise</strong></li> </ul> <p>要使用 Observable,你需要修改之前的列表。</p> <h5 id="列表-c10-订阅-observables-的更改">列表 C.10. 订阅 Observables 的更改</h5> <pre><code>private getLocations(position: any): void { this.message = 'Searching for nearby places'; const lat: number = position.coords.latitude; const lng: number = position.coords.longitude; this.loc8rDataService .getLocations(lat, lng) .subscribe( *1* (foundLocations: Location[]) => { this.message = foundLocations.length > 0 ? '' : 'No locations found'; this.locations = foundLocations; }, error => this.handleError(error) *2* ); </code></pre> <ul> <li> <p><strong><em>1</em> Observable 订阅者</strong></p> </li> <li> <p><strong><em>2</em> 错误处理程序</strong></p> </li> </ul> <p>如你所见,切换方法并不困难。方法的选择取决于具体情况。不过,为了参考,使用 Observables 正在成为标准做法。</p> <h2 id="附录-d-重新介绍-javascript">附录 D. 重新介绍 JavaScript</h2> <p><em>本附录涵盖</em></p> <ul> <li> <p>在编写 JavaScript 时应用最佳实践</p> </li> <li> <p>有效使用 JSON 传递数据</p> </li> <li> <p>检查如何使用回调以及如何逃离回调地狱</p> </li> <li> <p>使用闭包、模式和 JavaScript 类编写模块化 JavaScript</p> </li> <li> <p>采用函数式编程原则</p> </li> </ul> <p>JavaScript 是 MEAN 堆栈(即使您使用 TypeScript 编写 Angular 部分)的一个基本组成部分,因此我们将花一些时间来探讨它。我们需要打好基础,因为成功的 MEAN 开发依赖于它。JavaScript 是一种如此常见的语言(独特的是,JavaScript 几乎在地球上每台计算机上都有运行时),以至于似乎每个人都了解一些。部分原因是 JavaScript 易于入门,并且它的编写方式宽容。不幸的是,这种宽松性和低门槛可能会鼓励不良习惯,这可能导致意外结果。</p> <p>本附录的目的是不是从头开始教授 JavaScript;您应该已经掌握了基础知识。如果您对 JavaScript 一无所知,您可能会感到困难重重。像所有事物一样,JavaScript 也有一个学习曲线。另一方面,并不是每个人都需要详细阅读这个附录,尤其是经验丰富的 JavaScript 开发者。如果您有幸认为自己属于经验丰富的行列,那么浏览这个附录以寻找新内容可能仍然是有价值的。</p> <p>尽管我们并未涵盖 TypeScript,但我们希望第八章至第十二章的详细内容能够让您对它感到满意。</p> <p>在我们认真开始之前,还有最后一件事。当您在网上寻找 JavaScript 相关的信息时,您很可能会遇到 ES2015、ES2016、ES5、ES6、ES7 等称呼。</p> <p>ES5 是 JavaScript 的一个长期可用的版本,它始于遥远的过去,包括 Firefox 4 浏览器;Chrome 浏览器的诞生;以及臭名昭著的 Internet Explorer 6 的漫长而痛苦的死亡。幸运的是,那些日子已经一去不复返,但该规范仍然存在,并且大多数浏览器(大多数)都遵循它。</p> <p>正式来说,截至 2015 年,JavaScript(或者如果您愿意,ECMAScript [ES])规范的版本已经通过年份来表示:ES2015、ES2016 等等。对于 ES5 之后的单数字版本,如 ES6,这种说法是不正确的。在这本书中,我们一直小心翼翼地确保我们正确地命名了事物。互联网上的许多作者并没有如此勤奋,他们继续传播错误的命名方案。</p> <p>目前的情况是,大多数浏览器都遵循了 ES2015 规范中 JavaScript 所做的大多数更改,一些浏览器还提供了一些后续迭代(2016 年、2017 年等)的功能。采用的步伐和实施速度有时比我们作为开发者所希望的慢,因此有了像 Babel 这样的转换器。JavaScript 转换器广泛地将使用更现代思想编写的代码转换为旧浏览器能够理解的格式。它们在旧与新、不同语言之间架起了一座桥梁。TypeScript、CoffeeScript、Elm 和 ReasonML 都是转换为 JavaScript 的。</p> <h2 id="每个人都知道-javascript对吧">每个人都知道 JavaScript,对吧?</h2> <p>并非每个人都了解 JavaScript,但绝大多数开发者都在某个时候以某种形式使用过它。自然地,存在不同水平和经验。作为一个测试,看看下面的代码列表。该列表包含一段 JavaScript 代码,其目的是向控制台输出消息。如果你理解代码的编写方式,正确地确定输出消息将是什么,以及(更重要的是)为什么它们是这样的,你很可能适合快速阅读。</p> <h5 id="列表-d1-故意带有错误的示例-javascript">列表 D.1. 故意带有错误的示例 JavaScript</h5> <pre><code>const myName = { first: 'Simon', last: 'Holmes' }; var age = 37, country = 'UK'; console.log("1:", myName.first, myName.last); const changeDetails = (function () { console.log("2:", age, country); var age = 35; country = 'United Kingdom'; console.log("3:", age, country); const reduceAge = function (step) { age = age - step; console.log("4: Age:", age); }; const doAgeIncrease = function (step) { for (let i = 0; i <= step; i++) { window.age += 1; } console.log("5: Age:", window.age); }, increaseAge = function (step) { const waitForIncrease = setTimeout(function () { doAgeIncrease(step); }, step * 200); }; console.log("6:", myName.first, myName.last, age, country); return { reduceAge: reduceAge, increaseAge: increaseAge }; })(); changeDetails.increaseAge(5); console.log("7:", age, country); changeDetails.reduceAge(5); console.log("8:", age, country); </code></pre> <p>你对那件事的处理如何?列表 D.1 有几个故意引入的错误,如果你不小心,JavaScript 会允许你犯这些错误。然而,所有这些 JavaScript 都是有效和合法的,并且可以在不抛出错误的情况下运行;如果你喜欢,可以在浏览器中运行它来测试。这些错误突出了意外结果是多么容易发生,以及如果你不知道你在寻找什么,它们可能多么难以被发现。</p> <p>想知道那段代码的输出结果是什么吗?如果你自己没有运行过,你可以在下面的列表中看到结果。</p> <h5 id="列表-d2-列表-d1-的输出">列表 D.2. 列表 D.1 的输出</h5> <pre><code>1: Simon Holmes 2: undefined UK *1* 3: 35 United Kingdom 6: Simon Holmes 35 United Kingdom 7: 37 United Kingdom *2* 4: Age: 30 *3* 8: 37 United Kingdom 5: Age: 43 *4* </code></pre> <ul> <li> <p><strong><em>1</em> 年龄因作用域冲突和变量提升而未定义。</strong></p> </li> <li> <p><strong><em>2</em> 国家没有改变,但年龄因变量作用域而改变。</strong></p> </li> <li> <p><strong><em>3</em> 调用时运行,而不是定义时运行;使用局部变量而不是全局变量</strong></p> </li> <li> <p><strong><em>4</em> 由于 setTimeout 而稍后运行;由于 for 循环中的错误,年龄不正确</strong></p> </li> </ul> <p>此外,这段代码片段展示了私有闭包暴露公共方法、变量作用域和副作用问题、在预期时未定义的变量、函数和词法作用域的混合、异步代码执行的影响,以及在<code>for</code>循环中容易犯的一个错误。阅读代码时有很多东西需要吸收。</p> <p>如果你不确定其中的一些含义或者没有得到正确的结果,请阅读本附录。</p> <h2 id="好习惯或坏习惯">好习惯或坏习惯</h2> <p>JavaScript 是一种易于学习的语言。你可以从互联网上抓取一段代码并将其放入你的 HTML 页面,然后你就可以开始你的旅程了。它易于学习的一个原因是,在某些方面,它并不像它应该的那样严格。它允许你做一些它可能不应该做的事情,这会导致坏习惯。在本节中,我们将探讨一些这些坏习惯,并展示如何将它们转变为好习惯。</p> <h4 id="变量作用域和函数">变量、作用域和函数</h4> <p>第一步是查看<em>变量</em>、<em>作用域</em>和<em>函数</em>,它们都是紧密相连的。JavaScript 有三种作用域类型:<em>全局</em>、<em>函数</em>(使用 <code>var</code> 关键字)和<em>词法</em>(使用 <code>let</code> 或 <code>const</code> 关键字)。JavaScript 还具有<em>作用域继承</em>。如果你在全局作用域中声明一个变量,它对所有内容都是可访问的;如果你在函数内部使用 <code>var</code> 声明一个变量,它只对该函数及其内部的内容可访问;如果你在代码块中使用 <code>let</code> 或 <code>const</code> 声明一个变量,它只在该花括号及其内部的内容中可访问,但与 <code>var</code> 不同,访问不会渗透到周围的功能块。</p> <p><strong>ES2015 及以后的 <code>var</code> 关键字</strong></p> <p>现代实践往往不赞成使用 <code>var</code> 关键字,它最终将被弃用。<code>var</code> 带有大量的负担,如果你来自其他语言,其作用域可能难以处理,甚至可能让最有经验的开发者陷入困境。尽管如此,我们在这里讨论它,因为大量的 JavaScript 都是用 <code>var</code> 编写的。</p> <p>随着 ES2015 的推出,语言规范引入了 <code>let</code> 和 <code>const</code> 关键字,它们是词法(块)作用域的。这些关键字与其他变量定义方案有更大的相似性。差异将在以下章节中更详细地解释。</p> <h4 id="处理作用域和作用域继承">处理作用域和作用域继承</h4> <p>从一个简单的例子开始,其中作用域被错误地使用。</p> <h5 id="列表-d3-作用域示例">列表 D.3. 作用域示例</h5> <pre><code>const firstname = 'Simon'; *1* const addSurname = function () { const surname = 'Holmes'; *2* console.log(firstname + ' ' + surname); *3* }; addSurname(); console.log(firstname + ' ' + surname); *4* </code></pre> <ul> <li> <p><strong><em>1</em> 在全局作用域中声明的变量</strong></p> </li> <li> <p><strong><em>2</em> 在局部词法作用域中声明的变量</strong></p> </li> <li> <p><strong><em>3</em> 输出“Simon Holmes”</strong></p> </li> <li> <p><strong><em>4</em> 抛出错误,因为 surname 未定义</strong></p> </li> </ul> <p>这段代码会抛出错误,因为它试图在全局作用域中使用变量 <code>surname</code>,但它是在函数 <code>addSurname()</code> 的局部作用域中定义的。可视化作用域概念的一个好方法是画一些嵌套的圆圈。在图 D.1 中,外圈表示全局作用域;中间圈表示函数作用域;内圈表示词法作用域。你可以看到全局作用域可以访问变量 <code>firstname</code>,而函数 <code>addSurname()</code> 的局部作用域可以访问全局变量 <code>firstname</code> 和局部变量 <code>surname</code>。在这种情况下,词法作用域和函数作用域重叠。</p> <h5 id="图-d1-表示全局作用域与局部作用域以及作用域继承的圆圈图">图 D.1. 表示全局作用域与局部作用域以及作用域继承的圆圈图</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/dfig01_alt.jpg" alt="" loading="lazy"></p> <p>如果你想要全局作用域输出全名,同时在局部作用域中保持姓氏私有,你需要一种方法将值推送到全局作用域。从作用域圈的角度来看,你希望看到图 D.2 中的内容。你想要一个新的变量<code>fullname</code>,你可以在全局和局部作用域中使用它。</p> <h5 id="图-d2-使用额外的全局变量从局部作用域返回数据">图 D.2. 使用额外的全局变量从局部作用域返回数据</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/dfig02_alt.jpg" alt="" loading="lazy"></p> <h4 id="从局部作用域到全局作用域的推送错误的方法">从局部作用域到全局作用域的推送:错误的方法</h4> <p>你可以这样做的一种方式——现在我们就警告你,这是一种不好的做法——是在局部作用域内定义一个与全局作用域相关的变量。在浏览器中,全局作用域是<code>window</code>对象;在 Node.js 中,它是<code>global</code>。现在我们继续使用浏览器示例,以下列表显示了如果你更新代码以使用<code>fullname</code>变量,它会是什么样子。</p> <h5 id="列表-d4-全局-fullname-变量">列表 D.4. 全局 <code>fullname</code> 变量</h5> <pre><code>const firstname = 'Simon'; const addSurname = function () { const surname = 'Holmes'; window.fullname = firstname + ' ' + surname; *1* console.log(fullname); }; addSurname(); console.log(fullname); *2* </code></pre> <ul> <li> <p><strong><em>1</em> 全局对象中定义了<code>fullname</code>变量。</strong></p> </li> <li> <p><strong><em>2</em> 全局作用域可以输出全名。</strong></p> </li> </ul> <p>这种方法允许你从局部作用域向全局作用域添加变量,但这并不是理想的。问题有两个。首先,如果<code>addSurname()</code>函数出现错误并且变量未定义,当全局作用域尝试使用它时,你会得到一个错误。第二个问题在你代码增长时变得明显。假设你有数十个函数向不同的作用域添加内容。你如何跟踪它们?你如何测试它们?你如何向别人解释正在发生的事情?所有这些问题的答案都是<em>非常困难</em>。</p> <h4 id="从局部作用域到全局作用域的推送正确的方法">从局部作用域到全局作用域的推送:正确的方法</h4> <p>如果在局部作用域中声明全局变量是错误的,那么正确的方法是什么?一般来说,<em>总是将变量声明在它们所属的作用域中</em>。如果你需要一个全局变量,你应该在全局作用域中定义它,如下面的列表所示。</p> <h5 id="列表-d5-声明全局作用域变量">列表 D.5. 声明全局作用域变量</h5> <pre><code>var firstname = 'Simon', fullname; *1* var addSurname = function () { var surname = 'Holmes'; window.fullname = firstname + ' ' + surname; console.log(fullname); }; addSurname(); console.log(fullname); </code></pre> <ul> <li><strong><em>1</em> 在全局作用域中声明的变量,即使尚未分配值</strong></li> </ul> <p>这里,很明显全局作用域现在包含了<code>fullname</code>变量,这使得当你再次查看代码时更容易阅读。</p> <h4 id="从局部作用域引用全局变量">从局部作用域引用全局变量</h4> <p>你可能已经注意到,在函数内部,代码仍然通过使用完全限定的<code>window.fullname</code>来引用全局变量。在从局部作用域引用全局变量时,这样做是一种最佳实践。再次强调,这种做法使得你的代码更容易回顾和调试,因为你可以明确地看到正在引用哪个变量。代码应该看起来像以下列表。</p> <h5 id="列表-d6-在局部作用域中使用全局变量">列表 D.6. 在局部作用域中使用全局变量</h5> <pre><code>var firstname = 'Simon', fullname; var addSurname = function () { var surname = 'Holmes'; window.fullname = window.firstname + ' ' + surname; *1* console.log(window.fullname); *1* }; addSurname(); console.log(fullname); </code></pre> <ul> <li><strong><em>1</em> 当在局部作用域中使用全局变量时,始终使用完全限定的引用。</strong></li> </ul> <p>这种方法可能会让你的代码增加一些字符,但它使得你引用的变量及其来源非常明显。还有另一个原因支持这种方法,尤其是在给变量赋值时。</p> <h4 id="隐含的全局作用域">隐含的全局作用域</h4> <p>JavaScript 允许你不用 <code>var</code> 就声明一个变量,这实际上是一个坏习惯。更糟糕的是,如果你不用 <code>var</code> 声明一个变量,JavaScript 会将其创建在全局作用域中,如下面的列表所示。</p> <h5 id="列表-d7-不使用-var-声明">列表 D.7. 不使用 <code>var</code> 声明</h5> <pre><code>var firstname = 'Simon'; var addSurname = function () { surname = 'Holmes'; *1* fullname = firstname + ' ' + surname; *1* console.log(fullname); }; addSurname(); console.log(firstname + surname); *2* console.log(fullname); *2* </code></pre> <ul> <li> <p><strong><em>1</em> surname 和 fullname 都是通过隐含定义在全局作用域中的。</strong></p> </li> <li> <p><strong><em>2</em> 它们可以在全局作用域中使用。</strong></p> </li> </ul> <p>我们希望你能看到这可能会造成混淆,并且是一个坏习惯。关键是要<em>始终在变量所属的作用域中声明变量,使用 <code>var</code> 语句</em>。</p> <h4 id="变量提升的问题">变量提升的问题</h4> <p>你可能听说过,在使用 JavaScript 时,你应该始终在顶部声明你的变量。这是正确的,原因是因为变量提升。在 <em>变量提升</em> 中,JavaScript 无论如何都会在顶部声明所有变量,而不会告诉你,这可能会导致一些意外的结果。</p> <p>以下代码列表显示了变量提升可能如何表现出来。在 <code>addSurname()</code> 函数中,你想要使用全局的 <code>firstname</code> 值,然后声明一个局部作用域的值。</p> <h5 id="列表-d8-遮蔽示例">列表 D.8. 遮蔽示例</h5> <pre><code>var firstname = 'Simon'; var addSurname = function () { var surname = 'Holmes'; var fullname = firstname + ' ' + surname; *1* var firstname = 'David'; console.log(fullname); *2* }; addSurname(); </code></pre> <ul> <li> <p><strong><em>1</em> 你期望这会使用一个全局变量。</strong></p> </li> <li> <p><strong><em>2</em> 实际的输出是“undefined Holmes。”</strong></p> </li> </ul> <p>为什么输出是错误的?JavaScript “提升”了所有变量声明到它们作用域的顶部。你看到的是列表 D.8 中的代码,但 JavaScript 看到的是列表 D.9 中的代码。</p> <h5 id="列表-d9-提升示例">列表 D.9. 提升示例</h5> <pre><code>var firstname = 'Simon'; var addSurname = function () { var firstname, *1* surname, *1* fullname; *1* surname = 'Holmes'; fullname = firstname + ' ' + surname; *2* firstname = 'David'; console.log(fullname); }; addSurname(); </code></pre> <ul> <li> <p><strong><em>1</em> JavaScript 已经将所有变量声明移动到了顶部。</strong></p> </li> <li> <p><strong><em>2</em> 在使用之前没有赋值,所以它是未定义的。</strong></p> </li> </ul> <p>当你看到 JavaScript 在做什么时,错误就更加明显了。JavaScript 在作用域的顶部声明了变量 <code>firstname</code>,但它没有值可以赋给它,所以当你第一次尝试使用它时,JavaScript 将变量留为未定义。</p> <p>当你编写代码时,你应该记住这个事实。JavaScript 看到的应该是你看到的。如果你能从相同的视角看待事物,你犯错误和意外问题的空间就会小很多。</p> <h4 id="词法作用域">词法作用域</h4> <p><em>词法作用域</em>有时也称为<em>块作用域</em>。在一系列花括号之间定义的变量仅限于这些花括号的作用域。因此,作用域可以被限制在循环和流程逻辑结构中。</p> <p>JavaScript 定义了两个提供词法作用域的关键字:<code>let</code> 和 <code>const</code>。为什么是两个?这两个的功能略有不同。</p> <p><code>let</code> 有点像 <code>var</code>。它设置了一个可以在定义的作用域中更改的变量。它与 <code>var</code> 的不同之处在于,其作用域限制如前所述,并且以这种方式声明的变量不会被提升。由于它们不会被提升,它们不会被编译器以与 <code>var</code> 相同的方式跟踪;编译器在第一次遍历时将它们留在原处,因此如果你在它们定义之前尝试引用它们,编译器会通过 <code>ReferenceError</code> 投诉。</p> <h5 id="列表-d10-let-的作用">列表 D.10. <code>let</code> 的作用</h5> <pre><code>if (true) { let foo = 1; *1* console.log(foo); *2* foo = 2; *3* console.log(foo); *4* console.log(bar); *5* let bar = 'something'; *6* } </code></pre> <ul> <li> <p><strong><em>1</em> 首次声明变量</strong></p> </li> <li> <p><strong><em>2</em> 打印出值为 1</strong></p> </li> <li> <p><strong><em>3</em> 重新定义值</strong></p> </li> <li> <p><strong><em>4</em> 打印出值为 2</strong></p> </li> <li> <p><strong><em>5</em> 尝试打印尚未定义的值(ReferenceError)</strong></p> </li> <li> <p><strong><em>6</em> 未提升的变量的定义</strong></p> </li> </ul> <p><code>const</code> 与 <code>let</code> 有相同的注意事项。<code>const</code> 与 <code>let</code> 的不同之处在于,以这种方式声明的变量不允许通过重新赋值或重新声明来更改,它们被声明为不可变的。<code>const</code> 还防止了覆盖——重新定义之前定义的外部作用域变量。假设你在全局作用域中定义了一个变量(使用 <code>var</code>),然后你尝试在封闭作用域中使用 <code>const</code> 定义一个同名的变量。编译器将抛出一个 <code>Error</code>。返回的错误类型取决于你试图做什么。</p> <h5 id="列表-d11-使用-const">列表 D.11. 使用 <code>const</code></h5> <pre><code>var bar = 'defined'; *1* if (true) { const foo = 1; *2* console.log(foo); *3* foo = 2; *4* const bar = 'something else'; *5* } </code></pre> <ul> <li> <p><strong><em>1</em> 首次声明 bar</strong></p> </li> <li> <p><strong><em>2</em> 首次声明 foo 变量</strong></p> </li> <li> <p><strong><em>3</em> 打印出值为 1</strong></p> </li> <li> <p><strong><em>4</em> 尝试重新定义 foo(错误)</strong></p> </li> <li> <p><strong><em>5</em> 尝试覆盖 bar 变量</strong></p> </li> </ul> <p>由于使用 <code>let</code> 和 <code>const</code> 声明变量带来的清晰性,这种方法现在已成为首选方式。提升问题不再成为关注点,变量以更传统的方式表现,这对于熟悉其他主流语言的程序员来说更加舒适。</p> <h4 id="函数是变量">函数是变量</h4> <p>你可能已经注意到,在前面的代码片段中,<code>addSurname()</code> 函数被声明为一个变量。同样,这也是一种最佳实践。首先,JavaScript 本身就是这样看待的,其次,这清楚地表明了函数所在的作用域。</p> <p>尽管你可以按照以下格式声明一个函数</p> <pre><code>function addSurname() {} </code></pre> <p>JavaScript 解释如下:</p> <pre><code>const addSurname = function() {} </code></pre> <p>因此,将函数定义为变量是一种最佳实践。</p> <h4 id="限制全局作用域的使用">限制全局作用域的使用</h4> <p>我们已经讨论了很多关于使用全局作用域的内容,但现实中,你应该尽量限制全局变量的使用。你的目标应该是尽可能保持全局作用域的清洁,这对于应用程序的增长变得非常重要。很可能你会添加各种第三方库和模块。如果所有这些库和模块都在全局作用域中使用相同的变量名,你的应用程序将陷入崩溃。</p> <p>全局变量并不是一些人会让你相信的“邪恶”,但使用它们时必须小心。当你真正需要全局变量时,一个好的方法是在全局作用域中创建一个容器对象,并将所有内容放在那里。通过在全局作用域中创建一个 <code>nameSetup</code> 对象并使用它来保存其他所有内容,你可以看到这样做是如何实现的。</p> <h5 id="列表-d12-使用-const-在全局范围内定义函数">列表 D.12. 使用 <code>const</code> 在全局范围内定义函数</h5> <pre><code>const nameSetup = { *1* firstname : 'Simon', fullname : '', addSurname : function () { const surname = 'Holmes'; *2* nameSetup.fullname = nameSetup.firstname + ' ' + surname; *3* console.log(nameSetup.fullname); *3* } }; nameSetup.addSurname(); *3* console.log(nameSetup.fullname); *3* </code></pre> <ul> <li> <p><strong><em>1</em> 将全局变量声明为一个对象</strong></p> </li> <li> <p><strong><em>2</em> 在函数内部仍然可以使用局部变量。</strong></p> </li> <li> <p><strong><em>3</em> 总是通过使用完全限定的引用来访问对象的值。</strong></p> </li> </ul> <p>当你这样编写代码时,所有变量都作为对象的属性一起存在,使得全局空间保持整洁。以这种方式工作还可以最大限度地减少全局变量冲突的风险。你可以在声明后向该对象添加更多属性,甚至添加新函数。在前面代码列表的基础上,你可以有下面的代码。</p> <h5 id="列表-d13-添加对象属性">列表 D.13. 添加对象属性</h5> <pre><code>nameSetup.addInitial = function (initial) { *1* nameSetup.fullname = nameSetup.fullname.replace(" ", " " + initial + " "); }; nameSetup.addInitial('D'); *2* console.log(nameSetup.fullname); *3* </code></pre> <ul> <li> <p><strong><em>1</em> 在全局对象内部定义一个新函数</strong></p> </li> <li> <p><strong><em>2</em> 调用一个函数并传递一个参数</strong></p> </li> <li> <p><strong><em>3</em> 输出结果为“Simon D Holmes。”</strong></p> </li> </ul> <p>以这种方式工作可以让你控制 JavaScript,并减少你的代码给你带来不愉快惊喜的机会。请记住,在适当的范围内并在正确的时间声明变量,并在可能的情况下将它们组合到对象中。</p> <h2 id="箭头函数">箭头函数</h2> <p>到目前为止,我们还没有讨论 JavaScript 的 <code>this</code> 变量。<code>this</code> 是一个相当大的话题,可能会引起很多困惑。简单来说,<code>this</code> 的值取决于其使用的上下文。对于在对象上下文外部定义的函数,<code>this</code> 在严格模式下指的是函数定义时的执行上下文;如果不处于严格模式,则默认为当前执行上下文,因此它的值会根据使用的时间而变化。</p> <p>此外,如果使用了 <code>call()</code> 或 <code>apply()</code> 原型函数,<code>this</code> 可以绑定到不同的执行上下文。</p> <p>如果一个函数被定义为对象的方法,<code>this</code> 指的是周围的对象上下文。当在事件处理器中使用时,<code>this</code> 指的是触发事件的 DOM 对象。</p> <p><em>箭头函数表达式</em>(或箭头函数)通过在创建时不定义 <code>this</code> 变量来消除一些这种困惑,这与 <code>function</code> 关键字发生的情况不同。还有一些其他与上下文相关的内容也不可用,但 <code>this</code> 是最重要的。相反,它将 <code>this</code> 绑定到周围的词法上下文,使其非常适合非方法函数,如事件处理器、回调和全局函数。</p> <p>以下列表提供了箭头函数的一般形式和一些变体。</p> <h5 id="列表-d14-箭头函数格式">列表 D.14. 箭头函数格式</h5> <pre><code>(param, param2, ..., paramN) => { <function body> } *1* (param, param2, ..., paramN) => expression *2* singleParam => { <function body> } *3* singleParam => expression *4* () => { <function body> } *5* </code></pre> <ul> <li> <p><strong><em>1</em> 最一般的形式:括号中的参数 (=>),函数体(在大括号内)</strong></p> </li> <li> <p><strong><em>2</em> 使用单个表达式,可以省略花括号,但得到一个相当于 => { return expression; } 的隐式返回。</strong></p> </li> <li> <p><strong><em>3</em> 如果你有一个单个参数,你可以省略括号。</strong></p> </li> <li> <p><strong><em>4</em> 无参数的箭头函数需要括号。</strong></p> </li> <li> <p><strong><em>5</em> 如果你有一个单个参数和一个单个表达式,可以省略括号和花括号;请记住隐式返回。</strong></p> </li> </ul> <p>箭头函数提供了一种更简单、更干净的语法,这反过来又促进了更短、更紧凑、更具有表现力的函数,尤其是在与解构赋值结合使用时。本书中提供了许多示例,展示了如何使用箭头函数。有关 <code>this</code> 的更多信息,请参阅 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this" target="_blank"><code>developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this</code></a>;有关箭头函数的更多信息,请参阅 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions" target="_blank"><code>developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions</code></a>.</p> <h2 id="解构">解构</h2> <p>类似于在某些函数式编程语言中使用的模式匹配的概念,解构允许将数组值和对象属性解包到不同的变量中。如果你将对象传递给函数,解构意味着你可以明确指出你想要使用参数对象中的哪些属性。</p> <p>要使用解构,在赋值运算符(<code>=</code>)的左侧放置方括号以解构数组或花括号以解构对象;然后,添加你想要值的变量名。对于数组,变量根据索引顺序分配值。对于对象,你应该使用对象的键,但这不是严格必要的。</p> <p>以下列表详细说明了如何解构数组。</p> <h5 id="列表-d15-解构数组">列表 D.15. 解构数组</h5> <pre><code>let fst, snd, rest; const data = ['first', 'second', 'third', 'fourth', 'fifth']; [fst, snd, ...rest] = data; *1* [, fst, snd] = data; *2* const shortArr = [1]; [fst, snd = 10] = shortArr; *3* let a = 3, b = 4; [a, b] = [b, a]; *4* </code></pre> <ul> <li> <p><strong><em>1</em> 使用剩余操作符 (...) 将 'first' 赋给 fst,'second' 赋给 snd,并将剩余的值赋给 rest</strong></p> </li> <li> <p><strong><em>2</em> 忽略第一个值,将 'second' 和 'third' 分别拉入 fst 和 snd,而不关心其他任何东西</strong></p> </li> <li> <p><strong><em>3</em> 在解构中,如果赋值返回 undefined,则变量可以分配默认值。这里,snd 将是 10。</strong></p> </li> <li> <p><strong><em>4</em> 交换变量;a 变为 4,b 变为 3。</strong></p> </li> </ul> <p>解构对象需要更多的注意;你需要知道对象具有哪些属性,以便可以解包。</p> <p>请参阅以下列表以获取使用示例。</p> <h5 id="列表-d16-对象解构">列表 D.16. 对象解构</h5> <pre><code>const obj = {a: 10, b: 100, c: 1000}; const {a, c} = obj; *1* const {a: ten, c: hundred} = obj; *2* const {a, d = 50} = obj; *3* const shape = {type: 'square', sides: {width: 10, height: 10}}; *4* const areaOfSquare = ({side: {width}}) => width * width; *5* areaOfSquare(shape); *6* </code></pre> <ul> <li> <p><strong><em>1</em> 从对象中解包属性 a 和 c</strong></p> </li> <li> <p><strong><em>2</em> 解包 a 和 c,并将值赋给 10 和 100</strong></p> </li> <li> <p><strong><em>3</em> 解包 a,如果提供对象中没有 d,则将默认值赋给 d</strong></p> </li> <li> <p><strong><em>4</em> 带有嵌套对象结构的新对象</strong></p> </li> <li> <p><strong><em>5</em> 解构提供的对象的箭头函数;最终获取宽度值并在函数中使用它</strong></p> </li> <li> <p><strong><em>6</em> 使用该函数;打印出 100</strong></p> </li> </ul> <p>解构是一种只能应用于赋值结果的操作,通常用于函数返回值和正则表达式匹配,但也可以在函数参数列表和<code>for</code>...<code>of</code>迭代中使用。更多示例和信息可在<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment" target="_blank"><code>developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment</code></a>找到。</p> <p>我们在 Loc8r 代码库的多个地方使用这种技术,以减少函数或回调可以处理的数据量。</p> <h2 id="逻辑流程和循环">逻辑流程和循环</h2> <p>现在我们将快速查看常用<code>if</code>语句和<code>for</code>循环模式的最佳实践。本文假设你在某种程度上熟悉这些元素。</p> <h4 id="条件语句使用-if">条件语句:使用 if</h4> <p>JavaScript 在<code>if</code>语句中很有帮助。如果你在<code>if</code>块中只有一个表达式,你不需要用大括号<code>{}</code>包裹它。你甚至可以跟一个<code>else</code>。以下列表中的代码是有效的 JavaScript。</p> <h5 id="列表-d17-无花括号的if不良实践">列表 D.17. 无花括号的<code>if</code>(不良实践)</h5> <pre><code>const firstname = 'Simon'; let surname, fullname; if (firstname === 'Simon') surname = 'Holmes'; *1* else if (firstname === 'Sally') surname = 'Panayiotou'; *1* fullname = `${firstname} ${surname}`; console.log(fullname); </code></pre> <ul> <li><strong><em>1</em> 不良实践!省略单表达式<code>if</code>块周围的{ }。</strong></li> </ul> <p>是的,你可以在 JavaScript 中这样做,但最好不要!这样做依赖于代码的布局易于阅读,这并不理想。更重要的是,如果你想在<code>if</code>块内添加一些额外的行,会发生什么?首先给 Sally 一个中间名。请参阅以下代码列表,了解你可能如何逻辑上尝试这样做。</p> <h5 id="列表-d18-展示无花括号的if问题">列表 D.18. 展示无花括号的<code>if</code>问题</h5> <pre><code>const firstname = 'Simon', initial = ''; let surname, fullname; if (firstname === 'Simon') surname = 'Holmes'; else if (firstname === 'Sally') initial = 'J'; *1* surname = 'Panayiotou'; fullname = `${firstname} ${initial} ${surname}`; console.log(fullname); *2* </code></pre> <ul> <li> <p><strong><em>1</em> 将行添加到 if 块中</strong></p> </li> <li> <p><strong><em>2</em> 输出是“Simon Panayiotou。”</strong></p> </li> </ul> <p>这里出问题的原因是,没有使用花括号,只有第一个表达式被认为是块的一部分,而任何跟在后面的都是块外部的。所以在这里,如果<code>firstname</code>是 Sally,<code>initial</code>变为 J,但<code>surname</code>总是变为 Panayiotou。</p> <p>以下代码列表显示了正确编写此代码的方式。</p> <h5 id="列表-d19-正确格式化的if">列表 D.19. 正确格式化的<code>if</code></h5> <pre><code>const firstname = 'Simon'; let surname, fullname, initial = ''; if (firstname === 'Simon') { *1* surname = 'Holmes'; } else if (firstname === 'Sally') { *1* initial = 'J'; surname = 'Panayiotou'; } *1* fullname = `${firstname} ${initial} ${surname}`; console.log(fullname); </code></pre> <ul> <li><strong><em>1</em> 最佳实践!始终使用{ }来定义 if 块。</strong></li> </ul> <p>通过明确说明,你可以看到 JavaScript 解释器看到的内容,并减少意外错误的风险。让你的代码尽可能明确是一个好目标,不要留下任何可以解释的空间。这种做法有助于提高代码的质量,并有助于你在一年后回到它时理解它。</p> <p><strong>使用多少个等号符号</strong></p> <p>在这里提供的代码片段中,你会注意到在每个<code>if</code>语句中,都使用了<code>===</code>来检查匹配。这不仅是一种最佳实践,而且也是一种很好的习惯。</p> <p><code>===</code>(身份)运算符比 <code>==</code>(相等)运算符要严格得多。<code>===</code> 仅在两个操作数类型相同(如数字、字符串和布尔值)时提供正匹配。<code>==</code> 尝试类型强制转换以查看值是否相似但类型不同,这可能会导致一些有趣且意外的结果。</p> <p>看看以下代码片段中的一些可能让你陷入困境的有趣情况:</p> <pre><code>let number = ''; number == 0; *1* number === 0; *2* number = 1; number == '1'; *1* number === '1'; *2* </code></pre> <ul> <li> <p><strong><em>1</em> 正确</strong></p> </li> <li> <p><strong><em>2</em> 错误</strong></p> </li> </ul> <p>在某些情况下,这可能会显得很有用,但最好清楚地具体说明你认为是正匹配的内容,而不是 JavaScript 解释为正匹配的内容。如果你的代码不关心 <code>number</code> 是字符串还是数字类型,你可以匹配其中一个:</p> <pre><code>number === 0 || number === ''; </code></pre> <p>关键是始终使用精确运算符 <code>===</code>。对于不等于运算符也是如此:你应该始终使用精确的 <code>!==</code> 而不是松散的 <code>!=</code>。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h4 id="运行循环使用-for">运行循环:使用 <code>for</code></h4> <p>遍历项目集合的最常见方法是 <code>for</code> 循环。JavaScript 处理这个任务相当不错,但你应该注意一些陷阱和最佳实践。</p> <p>首先,与 <code>if</code> 语句一样,JavaScript 允许你在其中只有一个表达式时省略围绕块的括号 <code>{}</code>。我们希望你现在知道这是一个坏主意,就像在 <code>if</code> 语句中一样。以下代码示例显示了一些可能不会产生你期望结果的合法 JavaScript 代码。</p> <h5 id="列表-d20-无花括号的-for-循环不良实践">列表 D.20. 无花括号的 <code>for</code> 循环(不良实践)</h5> <pre><code>for (let i = 0; i < 3; i++) console.log(i); console.log(i * 5); *1* // Output in the console // 0 // 1 // 2 // Uncaught ReferenceError: i is not defined *1* </code></pre> <ul> <li><strong><em>1</em> 第二个语句在循环外部,所以它只运行一次;并且因为 i 在 <code>for</code> 中定义,所以它出错。</strong></li> </ul> <p>从这种方式编写和布局来看,你可能会期望两个 <code>console.log()</code> 语句在循环的每次迭代中都会运行。为了清晰起见,前面的代码片段应该写成以下列表。</p> <h5 id="列表-d21-向-for-循环添加花括号">列表 D.21. 向 <code>for</code> 循环添加花括号</h5> <pre><code>for (let i = 0; i < 3; i++) { console.log(i); } console.log(i*5); </code></pre> <p>我们知道我们一直在谈论这个问题,但确保你的代码以与 JavaScript 解释相同的方式阅读对你有帮助!考虑到这个事实和声明变量的最佳实践,你永远不会在 <code>for</code> 条件语句中看到 <code>let</code>。将前面的代码片段更新为符合这一最佳实践,你将得到以下列表。</p> <h5 id="列表-d22-提取变量声明">列表 D.22. 提取变量声明</h5> <pre><code>let i; *1* for (i = 0; i < 3; i++) { console.log(i); } console.log(i*5); </code></pre> <ul> <li><strong><em>1</em> 变量应在 <code>for</code> 语句外部声明。</strong></li> </ul> <p>由于变量声明应位于作用域的顶部,因此它和变量在循环中首次使用之间可能会有许多行代码。JavaScript 解释器会像变量已经在那里定义了一样行事,所以它应该放在那里。</p> <p><code>for</code> 循环的常见用途是遍历数组的内容,因此接下来,我们将介绍一些最佳实践和需要注意的问题。</p> <h4 id="使用数组与循环">使用数组与循环</h4> <p>使用 <code>for</code> 循环遍历数组的要点是记住数组是零索引的:数组中的第一个对象位于位置 0。连锁反应是数组中最后一个项目的位置比长度少 1。这听起来比实际情况要复杂。一个简单的数组可以这样分解:</p> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/p0429-01_alt.jpg" alt="" loading="lazy"></p> <p>声明类似这样的数组并遍历它的典型代码如下所示。</p> <h5 id="列表-d23-更多的-for-循环">列表 D.23. 更多的 <code>for</code> 循环</h5> <pre><code>let i; const myArray = ["one","two","three"]; for (i = 0; i < myArray.length; i++) { *1* console.log(myArray[i]); } </code></pre> <ul> <li><strong><em>1</em> 从 0 开始计数;当计数小于长度时循环。</strong></li> </ul> <p>这段代码运行良好,正确地遍历了数组,从位置 0 开始,到最后的 2 位置。有些人更喜欢在他们的代码中排除使用 <code>i++</code> 自动递增,因为这可能会使代码难以理解。个人而言,我们认为 <code>for</code> 循环是这一规则的例外,并且实际上使代码更容易阅读,而不是在循环内部手动递增。</p> <p>你可以采取一个方法来提高这段代码的性能。每次循环时,JavaScript 都会检查 <code>myArray</code> 的长度。如果 JavaScript 是检查一个变量,这个过程会更快,因此更好的做法是声明一个变量来保存数组的长度。你可以在下面的代码列表中看到这个解决方案的实际应用。</p> <h5 id="列表-d24-替代的-for-循环声明">列表 D.24. 替代的 <code>for</code> 循环声明</h5> <pre><code>let i, arrayLength; *1* const myArray = ["one","two","three"]; for (i = 0, arrayLength = myArray.length; i < arrayLength; i++) { *2* console.log(myArray[i]); } </code></pre> <ul> <li> <p><strong><em>1</em> 声明 arrayLength 变量与其他变量一起</strong></p> </li> <li> <p><strong><em>2</em> 在设置循环时将数组的长度赋给 arrayLength</strong></p> </li> </ul> <p>现在有一个新变量 <code>arrayLength</code>,在循环开始时被赋予要遍历的数组的长度。脚本只需要检查一次数组的长度,而不是每次循环都检查。</p> <h2 id="了解-json">了解 JSON</h2> <p>JavaScript 对象表示法 (JSON) 是一种基于 JavaScript 的数据交换方法。它比 XML 小得多,更灵活,也更易于阅读。JSON 基于 JavaScript 对象的结构,但它是语言无关的,可以用于在所有类型的编程语言之间传输数据。</p> <p>我们在这本书的示例代码中使用了对象,因为 JSON 是基于 JavaScript 对象的,所以我们在这里简要讨论一下。</p> <h4 id="javascript-对象字面量">JavaScript 对象字面量</h4> <p>在 JavaScript 中,除了最简单的数据类型(字符串、数字、布尔值、null 和 undefined)之外的所有内容都是对象,包括数组和函数。对象字面量是大多数人认为的 JavaScript 对象;它们通常用于存储数据,但也可以包含函数,就像你之前看到的。</p> <h5 id="查看-javascript-对象的内容">查看 JavaScript 对象的内容</h5> <p>JavaScript 对象是一系列键值对的集合,这些键值对是对象的属性。每个键都必须有一个值。</p> <p>键的规则很简单:</p> <ul> <li> <p>键必须是一个字符串。</p> </li> <li> <p>如果字符串是 JavaScript 保留字或非法 JavaScript 名称,则必须用双引号括起来。</p> </li> </ul> <p>值可以是任何 JavaScript 值,包括函数、数组和嵌套对象。以下列表显示了基于这些规则的合法 JavaScript 对象字面量。</p> <h5 id="列表-d25-一个-javascript-对象字面量的示例">列表 D.25. 一个 JavaScript 对象字面量的示例</h5> <pre><code>const nameSetup = { firstname: 'Simon', *1* fullname: '', age: 37, married: true, "clean-shaven": null, *2* addSurname: function () { *3* const surname = 'Holmes'; this.fullname = `${this.firstname} ${surname}`; *4* }, children: [ *5* { firstname: 'Erica' }, { firstname: 'Isobel' } ] }; </code></pre> <ul> <li> <p><strong><em>1</em> 一个简单的键值对</strong></p> </li> <li> <p><strong><em>2</em> 被双引号包围的复杂键</strong></p> </li> <li> <p><strong><em>3</em> 一个函数</strong></p> </li> <li> <p><strong><em>4</em> 函数中的 <code>this</code> 指向周围的对象,因为函数关键字;在这里,箭头函数将指向全局作用域。</strong></p> </li> <li> <p><strong><em>5</em> 在对象中将数组设置为值</strong></p> </li> </ul> <p>在这里,对象中的所有键都是字符串,但值是多种类型的混合:字符串、数字、布尔值、null、函数和数组。</p> <h5 id="访问对象字面量的属性">访问对象字面量的属性</h5> <p>访问属性的推荐方式是使用点表示法 (<code>.</code>)。例如:</p> <pre><code>nameSetup.firstname nameSetup.fullname </code></pre> <p>这些示例可以用来获取或设置属性值。如果你尝试 <em>获取</em> 不存在的属性,JavaScript 返回 <code>undefined</code>。如果你尝试 <em>设置</em> 不存在的属性,JavaScript 会将其添加到对象中,并为你创建它。</p> <p>当键名是保留字或非法 JavaScript 名称时,不能使用点表示法。要访问这些属性,需要将键字符串用方括号 <code>[]</code> 括起来。以下是一些示例:</p> <pre><code>nameSetup["clean-shaven"] nameSetup["var"] </code></pre> <p>再次,这些引用可以用来获取或设置值。</p> <p>接下来,我们将看看 JSON 是如何相关的。</p> <h4 id="与-json-的区别">与 JSON 的区别</h4> <p>JSON 基于 JavaScript 对象字面量的表示法,但由于它被设计成语言无关,有一些重要的区别:</p> <ul> <li> <p>所有键名和字符串都必须用双引号括起来。</p> </li> <li> <p>函数不是一个支持的数据类型。</p> </li> </ul> <p>这两个区别主要发生是因为你不知道什么会解释它。其他编程语言将无法处理 JavaScript 函数,并且可能有一组不同的保留名称和名称限制。如果你将所有名称作为字符串发送,你可以绕过这个问题。</p> <h5 id="json-中允许的数据类型">JSON 中允许的数据类型</h5> <p>你不能使用 JSON 发送函数,但作为数据交换格式,这并不是一件坏事。你可以发送的数据类型是</p> <ul> <li> <p>字符串</p> </li> <li> <p>数字</p> </li> <li> <p>对象</p> </li> <li> <p>数组</p> </li> <li> <p>布尔值</p> </li> <li> <p>值 <code>null</code></p> </li> </ul> <p>通过查看此列表并将其与 列表 D.25 中的 JavaScript 对象进行比较,如果你移除 <code>function</code> 属性,你应该能够将其转换为 JSON。</p> <h5 id="格式化-json-数据">格式化 JSON 数据</h5> <p>与 JavaScript 对象不同,我们不是将数据赋给一个变量;我们也不需要尾随的分号。通过将所有键名和字符串用双引号括起来——它们确实必须是双引号——我们可以生成以下列表。</p> <h5 id="列表-d26-正确格式化的-json-示例">列表 D.26. 正确格式化的 JSON 示例</h5> <pre><code>{ "firstname": "Simon", *1* "fullname": "", *2* "age": 37, *3* "married": true, *4* "has-own-hair": null, *5* "children": [ { *6* "firstname": "Erica" *6* }, *6* { *6* "firstname": "Isobel" *6* } *6* ] *6* } *6* </code></pre> <ul> <li> <p><strong><em>1</em> 在 JSON 中,你可以发送字符串。</strong></p> </li> <li> <p><strong><em>2</em> 空字符串</strong></p> </li> <li> <p><strong><em>3</em> 数字</strong></p> </li> <li> <p><strong><em>4</em> 布尔值</strong></p> </li> <li> <p><strong><em>5</em> Null</strong></p> </li> <li> <p><strong><em>6</em> 其他 JSON 对象的数组</strong></p> </li> </ul> <p>此列表显示了一些有效的 JSON。这些数据可以在应用程序和编程语言之间无问题地交换。它也易于人类眼睛阅读和理解。</p> <p><strong>发送包含双引号的字符串</strong></p> <p>JSON 规定所有字符串必须用双引号括起来。如果你的字符串中包含双引号怎么办?解释器遇到的第一个双引号将被视为字符串的结束分隔符,因此当下一个项目不是有效的 JSON 时,它很可能会抛出错误。</p> <p>以下代码片段展示了示例。字符串中有两个双引号,这不是有效的 JSON,会导致错误:</p> <pre><code>"line": "So she said "Hello Simon"" </code></pre> <p>解决这个问题的方法是使用反斜杠字符(<code>\</code>)转义嵌套的双引号。应用这种技术会产生以下结果:</p> <pre><code>"line": "So she said \"Hello Simon\"" </code></pre> <p>此转义字符告诉 JSON 解释器,接下来的字符不应被视为代码的一部分;它是值的一部分,可以忽略。</p> <h5 id="缩小-json-以在网络中传输">缩小 JSON 以在网络中传输</h5> <p>列表 D.26 中的间距和缩进纯粹是为了帮助人类可读性;编程语言不需要它们。在发送代码之前,如果你移除不必要的空白,可以减少传输的信息量。</p> <p>以下代码片段展示了 列表 D.26 的最小化版本,这更符合你期望在应用程序之间交换的内容:</p> <pre><code>{"firstname":"Simon","fullname":"","age":37,"married":true,"has-own- hair":null,"children":[{"firstname":"Erica"},{"firstname":"Isobel"}]} </code></pre> <p>内容与 列表 D.26 完全相同,但更加紧凑。</p> <h4 id="为什么-json-如此之好">为什么 JSON 如此之好?</h4> <p>JSON 作为数据交换格式的流行早于 Node 的开发。随着浏览器运行复杂 JavaScript 的能力增强,JSON 开始蓬勃发展。拥有一个(几乎)原生支持的数据格式非常有帮助,并且极大地简化了前端开发者的工作。</p> <p>之前首选的数据交换格式是 XML。与 JSON 相比,XML 更难一眼看懂,结构更严格,发送到网络上的数据量也更大。正如你在 JSON 示例中看到的,JSON 在语法上几乎不浪费空间。JSON 使用最少的字符来准确存储和结构化数据,而不是更多。</p> <p>当涉及到 MEAN 栈时,JSON 是在栈层之间传递数据的理想格式。MongoDB 将数据存储为二进制 JSON(BSON)。Node 和 Express 可以原生地解释它,并将其推送到 Angular,Angular 也原生地使用 JSON。MEAN 栈的每个部分,包括数据库,都使用相同的数据格式,因此你无需担心数据转换。</p> <h2 id="格式化实践">格式化实践</h2> <p>本书中的代码示例使用了一些我们个人的代码布局偏好。其中一些做法是必要的最佳实践;其他做法增加了可读性。如果你有不同的偏好,只要代码保持正确,那就绝对没问题;重要的是要保持一致性。</p> <p>人们对格式化感到担忧的主要原因是</p> <ul> <li> <p>确保 JavaScript 的语法正确</p> </li> <li> <p>确保代码在压缩后仍能正确运行</p> </li> <li> <p>提高自己和/或团队其他成员的代码可读性</p> </li> </ul> <p>从简单的格式化实践开始:缩进。</p> <h4 id="缩进代码">缩进代码</h4> <p>唯一真正的原因是缩进你的代码是为了使它对普通人来说更容易阅读。JavaScript 解释器并不关心它,并且会高兴地运行没有缩进或换行的代码。</p> <p>最好的缩进实践是使用空格而不是制表符,因为目前还没有关于制表符位置的标准。你选择多少空格由你决定;我们个人更喜欢两个空格。我们发现使用一个空格会使代码难以一目了然,因为差异并不大。四个空格会使你的代码显得过于宽(再次,这是我们的观点)。我们喜欢在缩进的易读性增益和最大化你能在屏幕上同时看到代码量的好处之间取得平衡——嗯,出于这个原因,以及不喜欢水平滚动。</p> <h4 id="函数和代码块的大括号位置">函数和代码块的大括号位置</h4> <p>你应该养成的一个最佳实践是将代码块的开括号放在开始该块的语句的末尾。什么?到目前为止的所有代码片段都是这样写的。下面的代码列表显示了正确和错误放置大括号的方法。</p> <h5 id="列表-d27-大括号放置">列表 D.27. 大括号放置</h5> <pre><code>const firstname = 'Simon'; let surname; if (firstname === 'Simon') { *1* surname = 'Holmes'; *1* console.log(`${firstname} ${surname}`); *1* } if (firstname === 'Simon') *2* { *2* surname = 'Holmes'; *2* console.log(`${firstname} ${surname}`); *2* } </code></pre> <ul> <li> <p><strong><em>1</em> 正确方法:括号与语句在同一行</strong></p> </li> <li> <p><strong><em>2</em> 错误方法:括号单独一行</strong></p> </li> </ul> <p>至少 99%的时间,第二种方法不会给你带来问题。第一种方法 100%的时间都不会给你带来问题。我们宁愿选择节省时间调试,你呢?</p> <p>有 1%的时间,错误的方法会导致问题?考虑一个使用<code>return</code>语句的代码片段:</p> <pre><code>return { name : 'name' }; </code></pre> <p>如果你将你的开括号放在不同的行上,JavaScript 会假设你遗漏了<code>return</code>命令后面的分号,并为你添加一个。JavaScript 这样评估:</p> <pre><code>return; *1* { name:'name' }; </code></pre> <ul> <li><strong><em>1</em> JavaScript 在 return 语句后插入分号,因此忽略了后面的代码。</strong></li> </ul> <p>由于 JavaScript 的自动分号插入,它没有返回你打算返回的对象;相反,JavaScript 返回<code>undefined</code>。</p> <p>接下来,我们将更详细地探讨分号的使用和 JavaScript 的分号插入。</p> <h4 id="正确使用分号">正确使用分号</h4> <p>JavaScript 使用分号字符表示语句的结束。它试图提供帮助,通过使这个字符可选,并在运行时认为有必要时注入它自己的分号,这根本不是一件好事。</p> <p>当使用分号分隔语句时,你应该回到在代码中看到 JavaScript 解释器看到的内容的目标,而不是让它做出任何假设。我们将分号视为非可选的,并且我们现在处于一个如果它们不存在,代码看起来就不正确的点。</p> <p>你的大部分 JavaScript 代码行都以分号结尾,但并非所有;那会太简单了!以下列表中的所有语句都应该以分号结尾。</p> <h5 id="列表-d28-分号使用示例">列表 D.28. 分号使用示例</h5> <pre><code>const firstname = 'Simon'; let surname; *1* surname = 'Holmes'; *1* console.log(`${firstname} ${surname}`); *1* const addSurname = function () {}; *1* alert('Hello'); *1* const nameSetup = { firstname : 'Simon', fullname : ''}; *1* </code></pre> <ul> <li><strong><em>1</em> 在大多数语句的末尾使用分号</strong>。</li> </ul> <p>但代码块不应该以分号结束。我们说的是与<code>if</code>、<code>switch</code>、<code>for</code>、<code>while</code>、<code>try</code>、<code>catch</code>和<code>function</code>(当不是赋值给变量时)相关的代码块。以下列表展示了几个例子。</p> <h5 id="列表-d29-使用不带分号的代码块">列表 D.29. 使用不带分号的代码块</h5> <pre><code>if (firstname === 'Simon') { ... } *1* function addSurname () { ... } *1* for (let i = 0; i < 3; i++) { ... } </code></pre> <ul> <li><strong><em>1</em> 代码块末尾没有使用分号</strong></li> </ul> <p>这条规则并不像在花括号后“不要使用分号”那样简单明了。当将函数或对象赋值给变量时,你<em>确实</em>需要在花括号后使用分号。你已经看到了几个例子,这些例子我们在整本书中都在使用。</p> <h5 id="列表-d30-赋值块的分号放置">列表 D.30. 赋值块的分号放置</h5> <pre><code>const addSurname = function () { ... }; *1* const nameSetup = { firstname : 'Simon' }; *1* </code></pre> <ul> <li><strong><em>1</em> 在将值赋给变量时在花括号后使用分号</strong></li> </ul> <p>在代码块后放置分号可能需要一点时间来习惯,但这是值得的,最终会变得自然而然。</p> <h4 id="在列表中放置逗号">在列表中放置逗号</h4> <p>当你在作用域顶部定义一个长变量列表时,最常见的方法是每行写一个变量名。这种做法使得一眼就能看到你设置了哪些变量。分隔变量的逗号经典位置是在行尾。</p> <h5 id="列表-d31-逗号后放置">列表 D.31. 逗号后放置</h5> <pre><code>let firstname = 'Simon', *1* surname, *1* initial = '', *1* fullname; </code></pre> <ul> <li><strong><em>1</em> 每行末尾使用逗号,将其与下一个变量声明分开</strong></li> </ul> <p>这种方法西蒙更喜欢,因为他已经使用了大约 15 年。另一方面,克莱夫主张将逗号放在每行的开头。</p> <h5 id="列表-d32-逗号优先放置">列表 D.32. 逗号优先放置</h5> <pre><code>let firstname = 'Simon' , surname *1* , initial = '' *1* , fullname; *1* </code></pre> <ul> <li><strong><em>1</em> 每行开头使用逗号,将其与下一个变量声明分开</strong></li> </ul> <p>这段 JavaScript 完全有效,当压缩成一行时,与第一个代码片段读取完全相同。西蒙试图习惯它,但他做不到;对他来说看起来不对。克莱夫认为逗号优先是一个好主意,但他也认为 Elm 很棒。</p> <p>对于这两种方法都有赞成和反对的论点。你的选择取决于个人喜好。关键是要有一个标准并坚持它。</p> <h4 id="不要害怕空白">不要害怕空白</h4> <p>在花括号组之间添加一些空白可以帮助提高可读性,并且不会对 JavaScript 产生任何问题。同样,你已经在所有之前的代码片段中看到了这种方法。你还可以在许多 JavaScript 运算符之间添加或删除空白。请看以下代码片段,展示了带有和没有额外空白的相同代码片段。</p> <h5 id="列表-d33-空白格式化示例">列表 D.33. 空白格式化示例</h5> <pre><code>const firstname = 'Simon'; *1* let surname; *1* if (firstname === 'Simon') { *1* surname = 'Holmes'; *1* console.log(`${firstname} ${surname}`); *1* } *1* const firstname='Simon'; *2* let surname; *2* if(firstname==='Simon'){ *2* surname='Holmes'; *2* console.log(firstname+" "+surname); *2* } *2* </code></pre> <ul> <li> <p><strong><em>1</em> 使用空白提高可读性的 JavaScript 片段</strong></p> </li> <li> <p><strong><em>2</em> 删除空白后的相同代码片段(不包括缩进)</strong></p> </li> </ul> <p>作为人类,我们通过使用空白作为单词的分隔符来阅读,我们阅读代码的方式也是如此。是的,你可以弄清楚这里代码片段的第二部分,因为许多语法指针充当分隔符,但阅读和理解第一部分更快、更容易。JavaScript 解释器不会注意到这些地方的空白,如果你担心基于浏览器的代码文件大小增加,你可以在将其推送到生产环境之前将其最小化。</p> <h4 id="帮助你编写良好-javascript-的工具">帮助你编写良好 JavaScript 的工具</h4> <p>几个在线代码质量检查器,称为 JSHint 和 ESLint,检查你代码的质量和一致性。更好的是,大多数 IDE 和好的文本编辑器都有一个或多个插件或扩展,因此你的代码可以在编写过程中进行质量检查。这些工具对于发现偶尔遗漏的分号或位置错误的逗号非常有用。</p> <p>在这两个工具中,ESLint 更侧重于检查 ES2015 代码。TypeScript 有自己的代码检查器,即 TSLint,Angular 默认安装。</p> <h2 id="字符串格式化">字符串格式化</h2> <p>ES2015 引入了一种类似于许多不同语言中的字符串插值的字符串格式化方法。JavaScript 将这种格式称为 <em>模板字符串</em>。</p> <p>模板字符串用反引号表示,在通常使用单引号或双引号定义字符串的地方。要执行插值,你希望插入到字符串中的元素(变量或函数调用结果)需要被 <code>'${}'</code> 包裹。下面的列表显示了这是如何工作的。</p> <h5 id="列表-d34-使用模板字符串">列表 D.34. 使用模板字符串</h5> <pre><code>const value = 10; const square = x => x * x; console.log(`Squaring the number ${value} gives a result of ${square(value)}`); *1* // Squaring the number 10 gives a result of 100 *2* </code></pre> <ul> <li> <p><strong><em>1</em> 模板字符串</strong></p> </li> <li> <p><strong><em>2</em> 插值结果</strong></p> </li> </ul> <h2 id="理解回调">理解回调</h2> <p>我们接下来要探讨的 JavaScript 编程的下一个方面是 <em>回调</em>。回调一开始可能看起来很复杂,但如果你深入了解,你会发现它们相当简单。很可能你已经使用过它们了。</p> <p>回调通常用于在某个事件发生后运行一段代码。无论这个事件是链接被点击、数据写入数据库还是其他代码执行完毕,这并不重要,因为事件可以是几乎任何东西。回调函数本身通常是一个 <em>匿名函数</em>——一个没有名称的函数,它作为参数直接传递给接收函数。现在如果这听起来像是术语,请不要担心;我们很快就会看到代码示例,你会看到它是多么简单。</p> <h4 id="使用-settimeout-在稍后运行代码">使用 <code>setTimeout</code> 在稍后运行代码</h4> <p>大多数时候,你使用回调在某个事件发生后运行代码。为了熟悉这个概念,你可以使用 JavaScript 中内置的函数:<code>setTimeout()</code>。你可能已经使用过它。简而言之,<code>setTimeout()</code> 在你声明的毫秒数后运行回调函数。使用它的基本结构如下:</p> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/p0439-01_alt.jpg" alt="" loading="lazy"></p> <p><strong>取消 <code>setTimeout</code></strong></p> <p>如果一个<code>setTimeout</code>声明已经被分配给一个变量,你可以使用这个变量来清除超时并停止它完成,前提是它还没有完成。你使用<code>clearTimeout()</code>函数,它的工作方式如下:</p> <pre><code>const waitForIt = setTimeout(function () { console.log("My name is Simon Holmes"); }, 2000); clearTimeout(waitForIt); </code></pre> <p>这个代码片段不会向日志输出任何内容,因为<code>waitForIt</code>计时器在它有机会完成之前就被清除了。</p> <p>首先,<code>setTimeout()</code>被声明在一个变量中,这样你就可以再次访问它来取消它,如果你想要的话。正如我们之前提到的,回调通常是一个未命名的匿名函数。如果你想在你名字在 2 秒后出现在 JavaScript 控制台,你可以使用这个代码片段。</p> <h5 id="列表-d35-捕获settimeout引用">列表 D.35. 捕获<code>setTimeout</code>引用</h5> <pre><code>const waitForIt = setTimeout(function () { console.log("My name is Simon"); }, 2000); </code></pre> <h5 id="注意-6">注意</h5> <p>回调是异步的。它们在需要时运行,不一定是在你代码中出现的顺序。</p> <p>考虑到这种异步特性,你预计以下代码片段的输出会是什么?</p> <pre><code>console.log("Hello, what's your name?"); const waitForIt = setTimeout(function () { console.log("My name is Simon"); }, 2000); console.log("Nice to meet you Simon"); </code></pre> <p>如果你从上到下阅读代码,控制台日志语句看起来似乎是有意义的。但是,因为<code>setTimeout()</code>回调是异步的,它不会阻止代码的处理,所以你最终得到的是这样的结果:</p> <pre><code>Hello, what's your name? Nice to meet you Simon My name is Simon </code></pre> <p>作为一次对话,这个结果显然没有流畅地进行。在代码中,拥有正确的流程是至关重要的;否则,你的应用程序很快就会崩溃。</p> <p>因为这种异步方法对于使用 Node 来说非常基础,所以我们将更深入地探讨它。</p> <h4 id="异步代码">异步代码</h4> <p>在你查看更多代码之前,提醒自己一下第一章中的银行柜员类比。图 D.3 展示了银行柜员如何通过将任何耗时任务转交给其他人来处理多个请求。</p> <h5 id="图-d3-处理多个请求">图 D.3. 处理多个请求</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/dfig03_alt.jpg" alt="" loading="lazy"></p> <p>银行柜员能够响应萨莉的请求,因为她将西蒙的请求的责任转给了保险柜经理。柜员不关心保险柜经理是如何做他的工作或需要多长时间。这种方法是异步的。</p> <p>你可以通过使用<code>setTimeout()</code>函数在 JavaScript 中模拟这种方法来演示异步方法。你所需要的只是一些<code>console.log()</code>语句来演示银行柜员的操作,以及几个超时来表示委托的任务。你可以在以下代码列表中看到这种方法,其中假设西蒙的请求需要 3 秒(3,000 毫秒),而萨莉的请求需要 1 秒。</p> <h5 id="列表-d36-异步流程">列表 D.36. 异步流程</h5> <pre><code>console.log("Taking Simon's request"); *1* const requestA = setTimeout(function () { console.log("Simon: money's in the safe, you have $5000"); }, 3000); console.log("Taking Sally's request"); *2* const requestB = setTimeout(function () { console.log("Sally: Here's your $100"); }, 1000); console.log("Free to take another request"); *3* // ** console.log responses, in order ** // Taking Simon's request // Taking Sally's request // Free to take another request // Sally: Here's your $100 *4* // Simon: money's in the safe, you have $5000 *5* </code></pre> <ul> <li> <p><strong><em>1</em> 处理第一个请求</strong></p> </li> <li> <p><strong><em>2</em> 处理第二个请求</strong></p> </li> <li> <p><strong><em>3</em> 准备处理下一个请求</strong></p> </li> <li> <p><strong><em>4</em> 萨莉的响应在 1 秒后出现。</strong></p> </li> <li> <p><strong><em>5</em> 西蒙的响应在另外 2 秒后出现。</strong></p> </li> </ul> <p>这段代码有三个不同的块:首先从西蒙那里接收请求并将其发送出去 <em><strong>1</strong></em>;然后从萨利那里接收请求并将其发送出去 <em><strong>2</strong></em>;以及准备好接受另一个请求 <em><strong>3</strong></em>。如果这段代码是像在 PHP 或 .NET 中看到的同步代码,你会在 3 秒后处理萨利的请求之前,先处理西蒙的请求的全部内容。</p> <p>使用异步方法,代码不需要等待一个请求完成后再处理另一个请求。你可以在浏览器中运行此代码片段以查看其工作方式。将其放入一个 HTML 页面并运行,或者直接在 JavaScript 控制台中输入。</p> <p>我们希望你能看到这段代码如何模仿我们在本节开始时讨论的场景。西蒙的请求首先到来,但由于需要一些时间来完成,所以响应没有立即返回。当有人在处理西蒙的请求时,萨利的请求被接受。当萨利的请求正在被处理时,银行出纳员再次可用以接受另一个请求。由于萨利的请求完成得更快,她首先得到了响应,而西蒙则需要等待更长的时间才能得到他的响应。萨利和西蒙都没有因为对方而受阻。</p> <p>现在进一步看看 <code>setTimeout()</code> 函数内部可能发生的情况。</p> <h4 id="运行回调函数">运行回调函数</h4> <p>我们不会在这里展示 <code>setTimeout()</code> 的源代码,但会展示一个使用回调的框架函数。声明一个新的函数名为 <code>setTimeout()</code>,它接受 <code>callback</code> 和 <code>delay</code> 参数。名称并不重要;它们可以是任何你想要的。以下代码列表展示了这个函数。(注意,你无法在 JavaScript 控制台中运行此函数。)</p> <h5 id="列表-d37-settimeout-框架">列表 D.37. <code>setTimeout</code> 框架</h5> <pre><code>const setTimeout = (callback, delay) => { ... *1* ... callback(); *2* }; const requestB = setTimeout (() => { *3* console.log("Sally: Here's your $100"); *3* }, 1000); *3* </code></pre> <ul> <li> <p><strong><em>1</em> 处理指定毫秒数的延迟</strong></p> </li> <li> <p><strong><em>2</em> 运行回调函数</strong></p> </li> <li> <p><strong><em>3</em> 发送匿名函数并延迟</strong></p> </li> </ul> <p><code>callback</code> 参数预期是一个函数,该函数可以在 <code>setTimeout()</code> 函数的特定点被调用 <em><strong>1</strong></em>。在这种情况下,你传递给它一个简单的匿名函数 <em><strong>3</strong></em>,该函数将消息写入控制台日志。当 <code>setTimeout()</code> 函数认为适当的时候,它会调用回调函数,并将消息记录到控制台。这并不难,对吧?</p> <p>如果 JavaScript 是你的第一门编程语言,你可能无法理解这种传递匿名函数的概念对那些来自不同背景的人来说是多么奇怪。但以这种方式操作的能力是 JavaScript 的一个巨大优势。</p> <p>通常,你不会查看运行回调函数的函数内部,无论是 <code>setTimeout()</code>、jQuery 的 <code>ready()</code> 还是 Node 的 <code>createServer()</code>。这些函数的文档会告诉你预期的参数是什么以及可能返回的参数。</p> <p><strong>为什么 setTimeout() 是不寻常的</strong></p> <p><code>setTimeout()</code>函数的独特之处在于你指定了一个延迟,之后回调将被触发。在更典型的用例中,函数本身决定何时触发回调。在 jQuery 的<code>ready()</code>方法中,这是 jQuery 说 DOM 已加载的时候;在 Node 的<code>save()</code>操作中,这是数据已保存到数据库并返回确认的时候。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="回调作用域">回调作用域</h5> <p>在以这种方式传递匿名函数时需要注意的一点是,回调不会继承其传入函数的作用域。回调函数不是在目标函数内部声明的,而是从它那里调用的。<em>回调函数继承其定义的作用域</em>。</p> <p>图 D.4 展示了作用域圆圈。在这里,你可以看到回调在其全局作用域内部有自己的局部作用域,这是<code>requestB</code>被定义的地方。如果你的回调只需要访问其继承的作用域,那么这很好。但是,如果你想让它更智能呢?如果你想在回调中使用异步函数中的数据呢?</p> <h5 id="图-d4-回调有自己的局部作用域">图 D.4. 回调有自己的局部作用域。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/dfig04_alt.jpg" alt="图片" loading="lazy"></p> <p>目前,示例回调函数中有一个硬编码的金额,但如果你想让这个值是动态的——成为一个变量怎么办?假设这个值在<code>setTimeout()</code>函数中设置,你如何将其传递给回调?你可以将其保存到全局作用域,但正如你所知,这样做是错误的。你需要将这个值作为参数传递给回调函数。你应该得到类似于图 D.5 中所示的作用域圆圈。</p> <p>在代码中,这看起来像以下代码列表。</p> <h5 id="列表-d38-settimeout传递数据">列表 D.38. <code>setTimeout</code>传递数据</h5> <pre><code>const setTimeout = (callback, delay) => { const dollars = 100; *1* ... callback(dollars); *2* }; const requestB = setTimeout((dollars) => { *3* console.log("Sally: Here's your $" + dollars); *3* }, 1000); </code></pre> <ul> <li> <p><strong><em>1</em> 在函数作用域中声明一个变量</strong></p> </li> <li> <p><strong><em>2</em> 将变量作为参数传递给回调</strong></p> </li> <li> <p><strong><em>3</em> 在回调中将变量作为参数接受并使用</strong></p> </li> </ul> <h5 id="图-d5-设置变量并将其传递给回调">图 D.5. 设置变量并将其传递给回调</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/dfig05_alt.jpg" alt="图片" loading="lazy"></p> <p>这段代码片段输出到控制台的消息与您之前看到的相同。现在的主要区别是美元的价值是在<code>setTimeout()</code>函数中设置的,并被传递给回调。</p> <p>理解这种方法非常重要,因为互联网上绝大多数的 Node 代码示例都是使用这种方式进行异步回调。但是,这种方法存在一些潜在问题,尤其是在你的代码库变得更大、更复杂时。过度依赖传递匿名回调函数可能会使代码难以阅读和跟踪,尤其是当你发现你有多个嵌套回调时。这也会使对代码进行测试变得困难,因为你无法通过名称调用这些函数;它们都是匿名的。本书中不涵盖单元测试,但简而言之,其理念是每一块代码都可以单独进行测试,并得到可重复和预期的结果。</p> <p>让我们看看如何使用命名回调实现这个结果。</p> <h4 id="命名回调">命名回调</h4> <p><em>命名回调</em>与内联回调在本质上有一个区别。不是直接将您想要运行的代码放入回调中,而是将代码放在一个定义好的函数中。然后,而不是直接作为匿名函数传递代码,您可以传递函数名。而不是传递代码,您传递的是代码运行的<em>引用</em>。</p> <p>持续使用当前的例子,添加一个名为<code>onCompletion()</code>的新函数,它将成为回调函数。图 D.6 显示了该函数在作用域圆圈中的样子。</p> <h5 id="图-d6-使用命名回调时的作用域变化">图 D.6. 使用命名回调时的作用域变化</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/dfig06_alt.jpg" alt="" loading="lazy"></p> <p>这个图看起来和前面的例子类似,除了回调作用域有一个名字。和匿名回调一样,命名回调可以在没有任何参数的情况下被调用,如图 D.6 所示。下面的代码片段展示了如何声明和调用一个命名回调,将图 D.6 中的内容转化为代码。</p> <h5 id="列表-d39-命名回调">列表 D.39. 命名回调</h5> <pre><code>const setTimeout = (callback, delay) => { const dollars = 100; ... callback(); }; const onCompletion = () => { *1* console.log("Sally: Here's your $100"); *1* }; *1* const requestB = setTimeout( onCompletion, *2* 1000 ); </code></pre> <ul> <li> <p><strong><em>1</em> 在不同的作用域中声明一个命名函数</strong></p> </li> <li> <p><strong><em>2</em> 将函数名作为回调发送</strong></p> </li> </ul> <p>命名函数<em><strong>1</strong></em>现在作为一个实体存在,创建了它自己的作用域。请注意,不再有匿名函数,但函数名<em><strong>2</strong></em>作为引用传递。</p> <h5 id="传递变量">传递变量</h5> <p>列表 D.39 再次在控制台日志中使用硬编码的美元值。和匿名回调一样,从一个作用域传递变量到另一个作用域是直接的。您可以将所需的参数传递到命名函数中。图 D.7 显示了在作用域圆圈中的样子。</p> <h5 id="图-d7-将所需的参数传递到新的函数作用域">图 D.7. 将所需的参数传递到新的函数作用域</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/dfig07_alt.jpg" alt="" loading="lazy"></p> <p>您需要将变量<code>dollars</code>从<code>setTimeout()</code>传递到<code>onCompletion()</code>回调函数中。您可以通过不更改请求中的任何内容来实现这一点,如下面的代码片段所示。</p> <h5 id="列表-d40-settimeout变量传递">列表 D.40. <code>setTimeout</code>变量传递</h5> <pre><code>const setTimeout = function (callback, delay) { const dollars = 100; ... callback(dollars); *1* }; const onCompletion = function (dollars) { *2* console.log("Sally: Here's your $" + dollars); *2* }; *2* const requestB = setTimeout( onCompletion, *3* 1000 ); </code></pre> <ul> <li> <p><strong><em>1</em> 将美元变量作为参数发送到回调</strong></p> </li> <li> <p><strong><em>2</em> 命名函数接受并使用参数</strong></p> </li> <li> <p><strong><em>3</em> 发送回调时没有进行任何更改。</strong></p> </li> </ul> <p>在这里,<code>setTimeout()</code>函数将<code>dollars</code>变量作为参数发送到<code>onCompletion()</code>函数中。您通常无法控制发送到回调的参数,因为像<code>setTimeout()</code>这样的异步函数是按原样提供的。但您通常希望在回调中使用来自其他作用域的变量,而不是异步函数提供的变量。接下来,我们将看看如何将您想要的参数发送到回调。</p> <h5 id="使用不同作用域中的变量">使用不同作用域中的变量</h5> <p>假设您希望输出中的名字作为一个参数传递。更新后的函数看起来如下:</p> <pre><code>const onCompletion = function (dollars, name) { console.log(name + ": Here's your $" + dollars); }; </code></pre> <p>问题在于 <code>setTimeout()</code> 函数只传递了一个参数,<code>dollars</code>,到回调函数。你可以通过再次使用匿名函数作为回调来解决此问题,记住它继承了其定义的作用域。为了演示这个函数在全局作用域之外,将请求包裹在一个新的函数 <code>getMoney()</code> 中,该函数接受一个参数,<code>name</code>。</p> <h5 id="列表-d41-settimeout-中的变量作用域">列表 D.41. <code>setTimeout</code> 中的变量作用域</h5> <pre><code>const getMoney = function (name) { const requestB = setTimeout(function (dollars) { *1* onCompletion(dollars, name); *2* }, 1000); }; getMoney('Simon'); </code></pre> <ul> <li> <p><strong><em>1</em> 匿名函数只接受美元参数</strong></p> </li> <li> <p><strong><em>2</em> 命名回调从匿名函数接收美元,从 <code>getMoney</code> 作用域接收名称</strong></p> </li> </ul> <p>在作用域圆圈中,此代码看起来像图 D.8。</p> <p>下一个列表将所有代码放在一起,以保持完整性。</p> <h5 id="列表-d42-完整的-settimeout-示例">列表 D.42. 完整的 <code>setTimeout</code> 示例</h5> <pre><code>const setTimeout = (callback, delay) => { const dollars = 100; ... callback(dollars); *1* }; const onCompletion = (dollars, name) => { console.log(name + ": Here's your $" + dollars); }; const getMoney = (name) => { const requestB = setTimeout((dollars) => { *2* onCompletion(dollars, name); *3* }, 1000); }; getMoney('Simon'); </code></pre> <ul> <li> <p><strong><em>1</em> 向 <code>setTimeout</code> 函数发送回调</strong></p> </li> <li> <p><strong><em>2</em> 向回调函数发送包含美元变量的回调</strong></p> </li> <li> <p><strong><em>3</em> 通过命名函数调用传递美元和名称参数</strong></p> </li> </ul> <h5 id="图-d8-从不同作用域向命名回调函数发送变量的过程">图 D.8. 从不同作用域向命名回调函数发送变量的过程</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/dfig08_alt.jpg" alt="图片" loading="lazy"></p> <p>简单来说,从匿名回调内部调用命名函数使你能够从父作用域(在这种情况下是 <code>getMoney()</code>)捕获你需要的内容,并将其明确传递给命名函数(<code>onCompletion()</code>)。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>观察流程的实际操作</strong></p> <p>如果你想看到这个流程的实际操作,你可以添加一个调试语句,在浏览器中运行它,并逐步执行函数以查看哪些变量和值在哪里以及何时被设置。总的来说,你会有如下所示的内容:</p> <pre><code>const mySetTimeout = function (callback, delay) { const dollars = 100; callback(dollars); }; const onCompletion = function (dollars, name) { console.log(name + ": Here's your $" + dollars); }; const getMoney = function (name) { debugger; const requestB = mySetTimeout(function (dollars) { onCompletion(dollars,name); }, 1000); }; getMoney('Simon'); </code></pre> <p>注意,当添加调试语句时,你可能需要更改 <code>setTimeout()</code> 函数的名称,以免干扰原生函数。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>记住,你通常无法访问调用回调函数的函数内部的代码,并且回调通常使用一组固定的参数(或没有,如 <code>setTimeout()</code>)来调用。你需要添加的任何额外内容都必须添加在匿名回调内部。</p> <h5 id="更适合阅读和测试">更适合阅读和测试</h5> <p>以这种方式定义一个命名函数使得函数的作用域和代码更容易一眼看懂,尤其是如果你给函数取了好的名字。在这样一个简单的小例子中,当你将代码移动到自己的函数中时,你可能会认为流程更难理解,你确实有这个观点。但是当代码变得更加复杂,你有多层嵌套的回调函数时,你肯定会看到这样做的好处。</p> <p>能够轻松地看到 <code>onCompletion()</code> 函数应该做什么,以及它期望和需要哪些参数,这使得函数更容易测试。现在你可以这样说:“当 <code>onCompletion()</code> 函数传递一个金额和一个名字时,它应该向控制台输出一条消息,包括这个金额和名字。”这是一个简单的例子,但我们希望你能看到它的价值。</p> <p>这就结束了从代码角度讨论回调的内容。现在你已经对回调的定义和使用有了很好的理解,那么看看 Node,了解为什么回调如此有用。</p> <h4 id="node-中的回调">Node 中的回调</h4> <p>在浏览器中,许多事件都是基于用户交互的,等待代码无法控制的事情发生。在服务器端,等待外部事情发生的概念是相似的。在服务器端的区别在于,事件更多地关注服务器上发生的事情,或者确实是在不同的服务器上发生的事情。在浏览器中,代码等待事件,如鼠标点击或表单提交,而服务器端代码等待事件,如从文件系统读取文件或将数据保存到数据库。</p> <p>最大的区别在于,在浏览器中,通常是由单个用户来启动事件,并且只有那个用户在等待响应。在服务器端,通常是中心代码启动事件并等待响应。正如在第一章中讨论的那样,Node 中只有一个线程在运行,所以如果中心代码需要停止并等待响应,那么每个访问者都会受到影响——这不是好事!这就是为什么理解回调很重要,因为 Node 使用回调将等待委托给其他进程,使其异步。</p> <p>接下来,我们将通过一个示例来了解在 Node 中使用回调函数。</p> <h5 id="node-回调">Node 回调</h5> <p>在 Node 中使用回调与在浏览器中使用并没有什么不同。如果你想保存一些数据,你不想让主 Node 进程来做这件事,就像你不想让银行出纳员和保险库管理员一起去等待响应一样。你想要使用一个带有回调的异步函数。所有 Node 的数据库驱动程序都提供了这种能力。关于如何创建和保存数据的具体细节将在书中介绍,所以现在我们用一个简化的例子来说明。下面的代码片段展示了使用 <code>mySafe</code> 对象的 <code>save()</code> 方法异步保存数据,并在数据库完成并返回响应时向控制台输出确认信息。</p> <h5 id="列表-d43-基本-node-回调">列表 D.43. 基本 Node 回调</h5> <pre><code>mySafe.save( function (err, savedData) { console.log(`Data saved: ${savedData}`); } ); </code></pre> <p>在这里,<code>save</code> 函数期望一个可以接受两个参数的回调函数,一个错误对象(<code>err</code>),以及保存后从数据库返回的数据(<code>savedData</code>)。在回调中通常还有更多功能,但基本结构很简单。</p> <h5 id="依次运行回调">依次运行回调</h5> <p>你已经了解了运行回调的概念,但如果你想在回调完成后运行另一个异步操作怎么办?回到银行比喻,假设你在将存款存入保险箱后,想要从西蒙的所有账户中获取总价值。西蒙不需要知道涉及多个步骤和多人,银行出纳员也不需要知道直到一切完成。你希望创建一个如图 D.9 所示的流程。</p> <h5 id="图-d9-使用两个依次进行的异步操作所需的流程">图 D.9. 使用两个依次进行的异步操作所需的流程</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/dfig09.jpg" alt="" loading="lazy"></p> <p>显然,需要两个操作,还需要另一个对数据库的异步调用。根据我们之前讨论的内容,你知道不能在 <code>save</code> 函数之后将其放入代码中,如下代码片段所示。</p> <h5 id="列表-d44-节点回调问题">列表 D.44. 节点回调问题</h5> <pre><code>mySafe.save( function (err, savedData) { console.log(`Data saved: ${savedData}`); } ); myAccounts.findTotal( *1* function (err, accountsData) { console.log(`Your total: ${accountsData}`); } ); // ** console.log responses, in probable order ** // Your total: 4500 // Data saved: {dataObject} </code></pre> <ul> <li><strong><em>1</em> 第二个函数将在保存函数完成之前触发,因此返回的 accountsData 可能是不正确的。</strong></li> </ul> <p>这是不行的,因为 <code>myAccounts.findTotal()</code> 函数将立即运行,而不是在 <code>mySafe.save()</code> 函数完成后运行。返回值可能是不正确的,因为它不会考虑到被添加到保险箱的价值。你需要确保第二个操作在你知道第一个操作已经完成时运行。解决方案很简单:从第一个回调函数内部调用第二个函数,这个过程被称为 <em>嵌套</em> 回调。</p> <p>嵌套回调用于依次运行异步函数。将第二个函数放在第一个回调函数内部,如下所示。</p> <h5 id="列表-d45-嵌套回调">列表 D.45. 嵌套回调</h5> <pre><code>mySafe.save( function (err, savedData) { console.log(`Data saved: ${savedData}`); myAccounts.findTotal( *1* function (err, accountsData) { console.log(`Your total: ${accountsData.total}`); } ); } ); // ** console.log responses, in order ** // Data saved: {dataObject} // Your total: 5000 </code></pre> <ul> <li><strong><em>1</em> 第二个异步操作嵌套在第一个回调函数内部</strong></li> </ul> <p>现在你可以确信 <code>myAccounts.findTotal()</code> 函数将在适当的时间运行,这反过来意味着你可以预测响应。</p> <p>这种能力很重要。Node 本身是异步的,从一个请求跳转到另一个请求,从网站访客跳转到另一个访客。但有时,你需要按顺序做事。嵌套回调通过使用原生 JavaScript 给你提供了一个很好的方法来实现这一点。</p> <p>嵌套回调的缺点是复杂性。你可能已经注意到,在一级嵌套的情况下,代码已经有点难以阅读,而且跟随顺序流程需要更多的脑力。当代码变得更加复杂,并且你最终有多个级别的嵌套回调时,这个问题会成倍增加。这个问题如此严重,以至于它已经成为“回调地狱”的代名词。回调地狱是为什么有些人认为 Node(和 JavaScript)特别难以学习和维护,并且他们将其作为反对这项技术的论据。公平地说,你可以在网上找到的许多代码示例确实存在这个问题,这并没有多少帮助来反驳这种观点。在开发 Node 时,你很容易陷入回调地狱,但如果你从正确的方式开始,你也同样容易避免。</p> <p>我们已经讨论了回调地狱的解决方案:使用命名回调。接下来,我们将向你展示命名回调如何帮助解决这个问题。</p> <h5 id="使用命名回调避免回调地狱">使用命名回调避免回调地狱</h5> <p>命名回调可以帮助你避免嵌套回调地狱,因为你可以使用它们将每个步骤分离成独立的代码或功能。人类往往发现这种类型的代码更容易阅读和理解。</p> <p>要使用命名回调,你需要将回调函数的内容提取出来,并声明为一个单独的函数。嵌套回调示例有两个回调,因此你需要两个新函数:一个用于<code>mySafe.save()</code>操作完成时,另一个用于<code>myAccounts.findTotal()</code>操作完成时。如果这些函数分别命名为<code>onSave()</code>和<code>onFindTotal()</code>,你可以创建一些如下所示的代码。</p> <h5 id="列表-d46-回调代码重构">列表 D.46. 回调代码重构</h5> <pre><code>mySafe.save( function (err, savedData) { onSave(err, savedData); *1* } ); const onSave = function (err, savedData) { console.log(`Data saved: ${savedData}`); myAccounts.findTotal( *2* function (err, accountsData) { onFindTotal(err, accountsData); *3* } ); }; const onFindTotal = function (err, accountsData) { console.log(`Your total: ${accountsData.total}`); }; </code></pre> <ul> <li> <p><strong><em>1</em> 从 mySafe.save 操作中调用第一个命名函数</strong></p> </li> <li> <p><strong><em>2</em> 在第一个命名回调内部启动第二个异步操作</strong></p> </li> <li> <p><strong><em>3</em> 调用第二个命名函数</strong></p> </li> </ul> <p>现在将每个功能部分分离成独立的函数后,更容易单独查看每个部分并理解它在做什么。你可以看到它期望的参数和预期的结果。实际上,结果可能比简单的<code>console.log()</code>语句更复杂,但你可以理解这个概念。你还可以相对容易地跟踪流程,并看到每个函数的作用域。</p> <p>通过使用命名回调,你可以降低 Node 的感知复杂性,并使你的代码更容易阅读和维护。第二个重要优势是,单个函数更适合单元测试。每个部分都有定义的输入和输出,具有预期的可重复行为。</p> <h2 id="承诺和异步await">承诺和异步/await</h2> <p>Promise 就像一份合同:它声明当长时间运行的操作完成时,将会有一个值在未来可用。本质上,Promise 代表了异步操作的结果。当这个值被确定后,Promise 将执行给定的代码或处理任何与未收到预期值相关的错误。</p> <p>Promises 是 JavaScript 规范的一等公民。它们有三个状态:</p> <ul> <li> <p><strong><em>Pending</em>—</strong> Promise 的初始状态</p> </li> <li> <p><strong><em>Fulfilled</em>—</strong> 异步操作成功解析</p> </li> <li> <p><strong><em>Rejected</em>—</strong> 异步操作未成功解析</p> </li> </ul> <h4 id="promises">Promises</h4> <p>当一个 Promise 被解析,无论是成功还是失败,其值都不能改变;它变成了不可变的。我们将在本附录后面的函数式编程部分讨论不可变性。</p> <p>要设置一个 Promise,你创建一个接受两个回调函数的函数:一个在成功时执行,一个在失败时执行。这些回调函数在 Promise 执行时被调用。然后,回调函数的执行转移到成功时的 <code>then()</code> 函数(可链式调用)或失败时的 <code>catch()</code> 函数。</p> <h5 id="列表-d47-设置使用-promise">列表 D.47. 设置/使用 Promise</h5> <pre><code>const promise = new Promise((resolve, reject) => { *1* // set up long running, possibly asynchronous operation, // like an API query if (/* successfully resolved */) { resolve({data response}); *2* } else { reject(); *3* } }); promise .then((data) => {/* execute this on success */}) *4* .then(() => {/ * chained next function, and so on */}) *5* .catch((err) => {/* handle error */}); *6* </code></pre> <ul> <li> <p><strong><em>1</em> 创建一个 Promise,传入预期的回调函数</strong></p> </li> <li> <p><strong><em>2</em> 在成功时,调用 resolve() 函数,可选地传递数据</strong></p> </li> <li> <p><strong><em>3</em> 在失败时,调用 reject() 函数,可选地传递数据或 Error 对象</strong></p> </li> <li> <p><strong><em>4</em> 调用 then() 函数;它执行所需的操作,可选地返回一个值给下一个 then()。</strong></p> </li> <li> <p><strong><em>5</em> 链中的下一个 then() 函数,其长度可以是必要的</strong></p> </li> <li> <p><strong><em>6</em> 捕获错误。如果这是 then() 函数链的末尾,任何抛出的错误都会被这个处理程序捕获。</strong></p> </li> </ul> <p>我们在 Loc8r 应用程序中使用 Promises,但不是以复杂的方式。Promises API 提供了一些静态函数,这些函数有助于你尝试执行多个 Promises。</p> <p><code>Promise.all()</code> 接受一个 Promise 的可迭代对象,并在数组中的所有项目都满足或拒绝时返回一个 Promise。<code>resolve()</code> 回调函数接收一个响应数组:按满足顺序排列的类似 Promise 对象和其他对象。如果执行中的任何一个 Promise 拒绝,<code>reject()</code> 回调函数接收一个单一值。</p> <h5 id="列表-d48-promiseall">列表 D.48. <code>Promise.all()</code></h5> <pre><code>const promise1 = new Promise((resolve, reject) => resolve() ); const promise2 = new Promise((resolve, reject) => resolve() ); const promise3 = new Promise((resolve, reject) => reject() ); const promise4 = new Promise((resolve, reject) => resolved() ); Promise.all([ promise1, promise2, promise3, promise4 ]) .then(([]) => {/* process success data iterable */}) *1* .catch(err => console.log(err)); *2* </code></pre> <ul> <li> <p><strong><em>1</em> Promise 3 拒绝了,所以在这个例子中会被忽略。</strong></p> </li> <li> <p><strong><em>2</em> Promise 3 上的 reject() 调用最终会到这里,尽管所有 Promise 都被执行了。</strong></p> </li> </ul> <p><code>Promise.race()</code> 也接受一个可迭代对象,但 <code>Promise.race()</code> 的输出是不同的。<code>Promise.race()</code> 执行所有提供的 Promises,并返回它接收到的第一个响应值,无论这个值是满足还是拒绝。</p> <h5 id="列表-d49-promiserace">列表 D.49. <code>Promise.race()</code></h5> <pre><code>const promise1 = new Promise((resolve, reject) => setTimeout(resolve, 1000, 'first') ); const promise2 = new Promise((resolve, reject) => setTimeout(reject, 200, 'second') ); Promise.race([promise1, promise2]) .then(value => console.log(value)) .catch(err => console.log(err)); *1* </code></pre> <ul> <li><strong><em>1</em> 这里预期的响应是第二个,因为拒绝发生在 promise1 解析之前。</strong></li> </ul> <p>由于 Promises 依赖于回调,由于它们的异步性质,如果嵌套多个回调,你可能会陷入混乱。发现自己处于深层嵌套的回调结构中通常被称为回调地狱。Promises 通过提供结构和使异步性明确来在一定程度上缓解了这个问题。</p> <h4 id="asyncawait">async/await</h4> <p>Promises 有其缺点。它们难以以同步方式使用,通常在到达好东西之前,你必须浏览一大堆样板代码。</p> <p><code>async/await</code> 函数旨在简化同步使用 Promises 的行为。<code>await</code> 表达式仅在 <code>async</code> 函数中有效;如果在不属于 <code>async</code> 函数的上下文中使用,代码会抛出 <code>SyntaxError</code>。当声明 <code>async</code> 函数时,其定义返回一个 <code>AsyncFunction</code> 对象。该对象通过 JavaScript 事件循环异步操作,并返回一个隐式的 Promise 作为其结果。语法的使用方式和它允许代码的结构化方式给人一种使用 <code>async</code> 函数就像使用同步函数的印象。</p> <p><strong>await</strong></p> <p><code>await</code> 表达式会导致 <code>async</code> 函数的执行暂停,并等待传入的 Promise 解决。然后,函数执行继续。</p> <p>有一点需要指出的是,<code>await</code> 与 <code>Promise.then()</code> 并不相同。因为 <code>await</code> 会暂停执行,使代码以同步方式执行,所以它不能像 <code>Promise.then()</code> 那样进行链式调用。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>下一个列表展示了 <code>async</code>/<code>await</code> 的使用。</p> <h5 id="列表-d50-asyncawait">列表 D.50. <code>async</code>/<code>await</code></h5> <pre><code>function resolvePromiseAfter2s () { return new Promise(resolve => setTimeout(() => resolve('done in 2s'), 2000)); } const resolveAnonPromise1s = () => new Promise(resolve => setTimeout(() => resolve('done in 1s'), 1000)); async function asyncCall () { *1* const result1 = await resolvePromiseAfter2s(); *2* console.log(result1); *3* const result2 = await resolveAnonPromise1s(); *4* console.log(result2); *5* } asyncCall(); *6* </code></pre> <ul> <li> <p><strong><em>1</em> 定义一个 async 函数</strong></p> </li> <li> <p><strong><em>2</em> 在 Promise 解决时暂停执行 2 秒</strong></p> </li> <li> <p><strong><em>3</em> result1 打印‘done in 2s’</strong></p> </li> <li> <p><strong><em>4</em> 在 Promise 解决时暂停执行 1 秒</strong></p> </li> <li> <p><strong><em>5</em> result2 打印‘done in 1s’</strong></p> </li> <li> <p><strong><em>6</em> 调用 async 函数。此函数总共暂停执行 3 秒。</strong></p> </li> </ul> <p>你可以在 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function" target="_blank"><code>developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function</code></a> 找到更多关于 <code>async</code>/<code>await</code> 的详细信息。</p> <h2 id="编写模块化-javascript">编写模块化 JavaScript</h2> <p>有人在匿名推文中分享了一句精彩的话:</p> <blockquote> <p><em>在 JavaScript 中编写大型应用程序的秘诀不是编写大型应用程序。编写许多小型应用程序,这些应用程序可以相互通信。</em></p> </blockquote> <p>这句话在许多方面都很有道理。许多应用程序共享一些功能,例如用户登录和管理、评论、评论等。你越容易将一个应用程序中的一个功能提取出来并放入另一个应用程序中,你的效率就越高,尤其是当你已经(我们希望)在隔离的情况下测试了该功能,因此你知道它有效。</p> <p>这就是模块化 JavaScript 发挥作用的地方。JavaScript 应用程序不必是一个永无止境的文件,其中包含函数、逻辑和全局变量四处飞散。你可以在封装的模块中包含功能。</p> <h4 id="闭包">闭包</h4> <p><em>闭包</em>基本上在你完成函数并返回后,让你访问函数中设置的变量。然后闭包为你提供了一种避免将变量推入全局作用域的方法。它还提供了一定程度的变量和其值的保护,因为你不能像全局变量那样覆盖它。</p> <p>听起来有点奇怪?看看一个例子。以下列表展示了你如何向函数发送一个值,并在稍后检索它。</p> <h5 id="列表-d51-闭包示例">列表 D.51. 闭包示例</h5> <pre><code>const user = {}; const setAge = function (myAge) { return { *1* getAge: function () { *1* return myAge; *1* } *1* }; }; user.age = setAge(30); *2* console.log(user.age); *3* console.log(user.age.getAge()); *4* </code></pre> <ul> <li> <p><strong><em>1</em> 返回一个返回参数的函数</strong></p> </li> <li> <p><strong><em>2</em> 调用函数,将返回值赋给用户的年龄属性</strong></p> </li> <li> <p><strong><em>3</em> 输出“Object {getAge: function}”</strong></p> </li> <li> <p><strong><em>4</em> 使用 getAge() 方法获取值;输出“30”</strong></p> </li> </ul> <p>这里发生的事情是,<code>getAge()</code> 函数作为 <code>setAge()</code> 函数的方法返回。<code>getAge()</code> 方法可以访问其创建的作用域。所以 <code>getAge()</code>,仅 <code>getAge()</code>,可以访问 <code>myAge()</code> 参数。正如你在附录中较早看到的那样,当创建一个函数时,它也会创建自己的作用域。这个函数之外没有任何东西可以访问这个作用域。</p> <p><code>myAge()</code> 并不是一个一次性共享变量。你可以再次调用该函数——创建第二个新的函数作用域——来设置(和获取)第二个用户的年龄。你可以在前一个代码片段之后愉快地运行以下代码片段,创建第二个用户并赋予他们不同的年龄。</p> <h5 id="列表-d52-继续闭包示例">列表 D.52. 继续闭包示例</h5> <pre><code>const usertwo = {}; usertwo.age = setAge(35); *1* console.log(usertwo.age.getAge()); *2* console.log(user.age.getAge()); *3* </code></pre> <ul> <li> <p><strong><em>1</em> 将 setAge() 函数赋给具有不同年龄的新用户</strong></p> </li> <li> <p><strong><em>2</em> 输出“usertwo’s age: 35”</strong></p> </li> <li> <p><strong><em>3</em> 输出原始用户的年龄:30</strong></p> </li> </ul> <p>每个用户都有不同的年龄,他们不会意识到或受到其他用户年龄的影响。闭包保护了值免受外部干扰。这里的重要启示是<em>返回的方法可以访问其创建的作用域</em>。</p> <p>这种闭包方法是一个很好的开始,但它已经演变成更有用的模式。例如,看看模块模式。</p> <h4 id="模块模式">模块模式</h4> <p><em>模块模式</em>扩展了闭包概念,通常将一组代码、函数和功能封装到一个模块中。这个想法是模块是自包含的,只使用显式传递给它的数据,并且只暴露直接请求的数据。</p> <p><strong>立即执行函数表达式</strong></p> <p>模块模式使用所谓的立即执行函数表达式(IIFE)。我们在这本书中直到现在所使用的函数都是函数声明,创建可以在代码的稍后部分调用的函数。IIFE 创建一个函数表达式并立即调用它,通常返回一些值和/或方法。</p> <p>立即执行函数表达式(IIFE)的语法是将函数用括号括起来,并通过使用另一对括号立即调用它(参见此代码片段中的粗体部分):</p> <pre><code>const myFunc = (function () { *1* return { myString: "a string" }; })(); console.log(myFunc.myString); *2* </code></pre> <ul> <li> <p><strong><em>1</em> 将 IIFE 赋值给变量</strong></p> </li> <li> <p><strong><em>2</em> 将返回的方法作为变量的属性访问</strong></p> </li> </ul> <p>这个示例是一个典型用法,但并非唯一。立即函数表达式(IIFE)已被分配给变量 <em><strong>1</strong></em>。当你这样做时,函数返回的方法成为变量的属性 <em><strong>2</strong></em>。</p> <p>这是通过使用立即函数表达式(IIFE)实现的。(参见本节侧边栏,了解更多关于 IIFE 的信息。)与基本闭包类似,模块模式将函数和变量作为分配给变量的属性返回。与基本闭包不同,模块模式不需要手动启动;模块一旦定义,就会立即调用自己。</p> <p>下面的列表展示了模块模式的一个小但实用的示例。</p> <h5 id="列表-d53-模块模式示例">列表 D.53. 模块模式示例</h5> <pre><code>const user = {firstname: "Simon"}; const userAge = (function () { *1* let myAge; *2* return { setAge: function (initAge) { *3* myAge = initAge; *3* }, *3* getAge: function () { *4* return myAge; *4* } *4* }; })(); userAge.setAge(30); *5* user.age = userAge.getAge(); *5* console.log(user.age); *6* </code></pre> <ul> <li> <p><strong><em>1</em> 将模块分配给变量</strong></p> </li> <li> <p><strong><em>2</em> 在模块作用域中定义变量</strong></p> </li> <li> <p><strong><em>3</em> 定义一个可以接受参数并修改模块变量的返回方法</strong></p> </li> <li> <p><strong><em>4</em> 定义一个可以访问模块变量的返回方法</strong></p> </li> <li> <p><strong><em>5</em> 调用模块变量的 set 和 get 方法</strong></p> </li> <li> <p><strong><em>6</em> 输出“30”</strong></p> </li> </ul> <p>在这个例子中,<code>myAge</code> 变量存在于模块的作用域内,并且永远不会直接暴露给外部。你只能通过暴露的方法与 <code>myAge</code> 变量交互。在 列表 D.53 中,你可以获取和设置,但也可以直接修改年龄属性。你可以向 <code>userAge</code> 模块添加一个 <code>happyBirthday()</code> 方法,该方法将 <code>myAge</code> 的值增加 1 并返回新值。下面的代码列表展示了新的部分,并用粗体标出。</p> <h5 id="列表-d54-向模块添加-happybirthday-方法">列表 D.54. 向模块添加 <code>happyBirthday</code> 方法</h5> <pre><code>const user = {firstname: "Simon"}; const userAge = (function () { let myAge; return { setAge: function (initAge) { myAge = initAge; }, getAge: function () { return myAge; }, happyBirthday: function () { *1* myAge += 1; *1* return myAge; *1* } *1* }; })(); userAge.setAge(30); user.age = userAge.getAge(); console.log(user.age); user.age = userAge.happyBirthday(); *2* console.log(user.age); *3* user.age = userAge.getAge(); console.log(user.age); *3* </code></pre> <ul> <li> <p><strong><em>1</em> 新方法,用于将 myAge 增加 1 并返回新值</strong></p> </li> <li> <p><strong><em>2</em> 调用新方法并将其分配给 user.age</strong></p> </li> <li> <p><strong><em>3</em> 输出“31”</strong></p> </li> </ul> <p>新的 <code>happyBirthday()</code> 方法将 <code>myAge</code> 的值增加 1 并返回新值。这是可能的,因为 <code>myAge</code> 变量存在于模块函数的作用域内,同样,返回的 <code>happyBirthday()</code> 函数也是如此。<code>myAge</code> 的新值继续在模块作用域内保持。</p> <h4 id="揭示模块模式">揭示模块模式</h4> <p>我们在模块模式中看到的内容正接近揭示模块模式。<em>揭示模块模式</em> 实质上是一些语法糖,它包装了模块模式。目的是使公开的部分和模块内部保持私有的部分一目了然。</p> <h5 id="将声明从返回语句中移除">将声明从返回语句中移除</h5> <p>以上述方式提供返回值也是一种风格约定,但同样有助于你在休息后回来时理解你的代码。当你使用这种方法时,<code>return</code> 语句包含一个函数列表,你将返回这些函数,而不包含任何实际代码。代码在 <code>return</code> 语句上方声明,尽管在同一个模块内。下面的代码列表展示了示例。</p> <h5 id="列表-d55-揭示模块模式简短示例">列表 D.55. 揭示模块模式,简短示例</h5> <pre><code>const userAge = (function () { let myAge; const setAge = function (initAge) { *1* myAge = initAge; *1* }; *1* return { setAge *2* }; })(); </code></pre> <ul> <li> <p><strong><em>1</em> <code>setAge</code>函数已被移出<code>return</code>语句</strong></p> </li> <li> <p><strong><em>2</em> 现在的<code>return</code>语句引用了<code>setAge</code>函数,且不包含任何代码</strong></p> </li> </ul> <p>在如此小的示例中,你无法看到这种方法的益处。我们很快将看到一个更长的示例,这将帮助你走上一半的路,但当你有一个运行到几百行代码的模块时,你将看到其益处。由于在作用域顶部收集所有变量使得使用哪些变量变得明显,将代码从<code>return</code>语句中移除使得一眼就能看出哪些函数被公开。如果你有十几个或更多的函数被返回,每个函数有十行或更多的代码,那么你很可能无法在不滚动的情况下看到整个<code>return</code>语句。</p> <p>在<code>return</code>语句中重要的是什么,你将寻找的是什么,那就是哪些方法被公开。在<code>return</code>语句的上下文中,你对每个方法的内部工作不感兴趣。以这种方式分离你的代码是有意义的,并为你编写出色、可维护和可理解的代码奠定了基础。</p> <h5 id="模式的完整示例">模式的完整示例</h5> <p>在本节中,我们将通过使用<code>userAge</code>模块来查看该模式的更大示例。以下列表展示了揭示模块模式的一个示例,以及从<code>return</code>语句中移除代码。</p> <h5 id="列表-d56-揭示模块模式完整示例">列表 D.56. 揭示模块模式,完整示例</h5> <pre><code>const user = {}; const userAge = (function () { let myAge; *1* const setAge = function (initAge) { myAge = initAge; }; const getAge = function () { return myAge; }; const addYear = function () { *2* myAge += 1; *2* }; *2* const happyBirthday = function () { addYear(); *3* return myAge; }; return { setAge, *4* getAge, *4* happyBirthday *4* }; })(); userAge.setAge(30); user.age = userAge.getAge(); user.age = userAge.happyBirthday(); *5* </code></pre> <ul> <li> <p><strong><em>1</em> 有下划线,因为它从未直接在模块外部公开</strong></p> </li> <li> <p><strong><em>2</em> 未公开的私有函数</strong></p> </li> <li> <p><strong><em>3</em> 可以通过一个公开的函数来调用</strong></p> </li> <li> <p><strong><em>4</em> <code>return</code>语句充当公开方法的参考。</strong></p> </li> <li> <p><strong><em>5</em> user.age 和 myAge 现在是 31。</strong></p> </li> </ul> <p>这展示了几个有趣的事情。首先,请注意变量<code>myAge</code> <em><strong>1</strong></em> 本身从未在模块外部公开。变量的值通过各种方法返回,但变量本身仍然保持模块的私有性。</p> <p>除了私有变量外,你还可以在列表中有私有函数,如<code>addYear()</code> <em><strong>2</strong></em>。私有函数可以很容易地通过公开方法 <em><strong>3</strong></em> 被调用。</p> <p><code>return</code>语句 <em><strong>4</strong></em> 被保持得既简洁又简单,现在是对该模块公开的方法的直观参考。</p> <p>严格来说,模块内函数的顺序并不重要,只要它们位于<code>return</code>语句之上。位于<code>return</code>语句之下的任何内容都不会运行。在编写大型模块时,你可能发现将相关函数分组更容易。如果你觉得这样做合适,你也可以创建一个嵌套模块,甚至是一个独立的模块,该模块有一个公开的方法暴露给第一个模块,以便它们可以相互通信。</p> <p>记住本节开头引用的引言:</p> <blockquote> <p><em>在 JavaScript 中编写大型应用程序的秘诀不是编写大型应用程序。编写许多可以相互通信的小应用程序。</em></p> </blockquote> <p>这句话不仅适用于大型应用程序,也适用于模块和函数。如果你能保持你的模块和函数小而精,你就朝着编写优秀代码的道路前进了。</p> <h2 id="类">类</h2> <p>JavaScript 的模块化扩展是 ES2015 引入的类语法。类是 JavaScript 原型继承模型的语法糖,但如果你有面向对象编程(OOP)经验,它们的工作方式就像你预期的那样。</p> <p>但是,请注意,JavaScript 类,至少在 ES2017 之前,有公共属性和公共和静态方法。私有和受保护的类可见性将在某个未确定的时间点添加到规范中。它们确实有一个使用<code>extends</code>关键字继承的继承层次结构,但没有接口。从父类访问函数涉及<code>super</code>函数,初始化使用构造函数。</p> <p>我们不会涵盖面向对象编程(OOP)的来龙去脉,这是一个最好留给你自己练习的练习。(可以从<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes" target="_blank"><code>developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes</code></a>开始了解。)在这里,我们将介绍语法的基础。</p> <h5 id="列表-d57-类语法示例">列表 D.57. 类语法示例</h5> <pre><code>// Parent class class Rectangle { width = 0; height = 0; constructor (width, height) { this.width = width; this.height = height; } get area() { return this.determineArea(); } determineArea () { return this.width * this.height; } } // Child class of Rectangle class Square extends Rectangle { constructor (side) { super(side, side); } } const square = new Square(10); console.log(`Square area: ${square.area()}`); // prints Square area: 100; </code></pre> <p>类的概念远不止这些,在这本书中,你将主要使用 Angular 中的 TypeScript 类来构建组件。</p> <h2 id="函数式编程概念">函数式编程概念</h2> <p>作为一种概念,函数式编程比面向对象编程存在的时间更长。长期以来,这个概念被归入学术界,因为一些使用的语言学习曲线陡峭,人为地提高了入门的门槛。谁愿意花时间去学习那些晦涩难懂的概念,结果却因为语法而感到困惑,而你只想从你网站的访客那里获取信息并将其推入数据库呢?</p> <p>然而,最近,所有主流的面向对象语言都在吸收并整合函数式编程语言的概念,因为这些概念提供了数据的安全性,减少了认知负担,并允许功能的组合。</p> <p>你可以将以下概念应用到你的 JavaScript 工作中,包括不可变性、纯净性、声明式风格和函数组合。</p> <p>根据你使用的语言版本,可能有一些其他功能可用或不可用。我们将逐一介绍这些概念。</p> <h4 id="不可变性">不可变性</h4> <p>虽然不可变性在语言级别上并不是强制执行的,但通过一点前瞻性规划和一些严谨性,你可以简单而有效地实现它。请注意,有 npm 包可以帮助实现,例如 Facebook 的 immutable.js (<a href="https://github.com/facebook/immutable-js" target="_blank"><code>github.com/facebook/immutable-js</code></a>)。</p> <p>重点是你在操作的数据/状态不会被修改。修改是在原地进行的操作,可能是难以追踪的 bug 的原因。</p> <p>这个概念在 JavaScript 中的应用意味着状态没有被改变;它是被复制、转换并分配给一个替代变量。这个概念也可以应用于数据集合和对象;尽管需要稍微更严格的处理,但结果应该是相同的。</p> <p>对于简单的标量类型变量,应用不可变性很简单:使用 <code>const</code> 声明。这样,JavaScript 执行上下文就不能覆盖变量,如果你不小心尝试这样做,它会抛出一个异常。我们之前已经讨论过这个话题。</p> <p>对于对象类型(<code>Array</code>s、<code>Object</code>s、<code>Map</code>s、<code>Set</code>s),使用 <code>const</code> 并没有太大帮助。问题是 <code>const</code> 创建了对创建的对象的引用。由于它是一个引用,对象内部的数据可以被修改。这就是严格性的所在。不要使用 <code>for</code> 等循环结构直接操作集合,而应使用该类型提供的迭代器;它们是原型方法,应该在浏览器和 Node.js 中都可用。对于你想要的功能,如果库如 Lodash.js 和 Ramda.js 没有提供,你总是可以找到。</p> <h5 id="列表-d58-应用不可变概念的一些示例">列表 D.58. 应用不可变概念的一些示例</h5> <pre><code>const names = ['s holmes', 'c harber', 'l skywalker', 'h solo']; *1* const uppercasedNames = names.map(name => name.toUpperCase()); *2* const shortNames = names.filter(name => name.length < 10); *3* const values = [1, 2, 3, 4, 5, 6, 7, 8, 9]; *4* const total = values.reduce((value, acc) => acc + value, 0); *5* const product = values.reduceRight((value, acc) => value * acc, 1); *6* </code></pre> <ul> <li> <p><strong><em>1</em> 将四个名称简单收集并分配给常量</strong></p> </li> <li> <p><strong><em>2</em> 使用映射函数遍历集合中的名称并将其分配给新变量</strong></p> </li> <li> <p><strong><em>3</em> 使用过滤器函数从集合中移除不符合给定条件的项</strong></p> </li> <li> <p><strong><em>4</em> 一个整数数组</strong></p> </li> <li> <p><strong><em>5</em> 通过将值相加将值归约为一个单一的总值</strong></p> </li> <li> <p><strong><em>6</em> 从右向左进行归约,创建提供列表中值的乘积</strong></p> </li> </ul> <h4 id="纯度">纯度</h4> <p><em>纯函数</em> 是不显示副作用或使用未提供的数据的函数。副作用是指对程序状态的改变,这种改变超出了函数的范围,并且与函数的返回值不同。典型的副作用包括改变全局变量的值、向屏幕发送文本和打印。其中一些副作用是不受欢迎的,是有害的,但其中一些是不可避免的,也是必要的。作为 JavaScript 程序员,我们应该尽可能减少副作用。这样,程序状态是可预测的,因此如果出现错误,就更容易推理。</p> <p>函数应该只操作它们被提供的那些数据。除非绝对必要,否则不应更改外部数据,例如全局窗口状态,并且即使在这种情况下,也只应由专门的函数以受控的方式进行更改。如果你的代码依赖于全局状态,那可能是一个你应该调查的坏代码信号。</p> <p>纯函数是可预测的,而且往往表现出一种称为 <em>幂等性</em> 的属性:给定一组输入,函数的预期输出总是相同的。</p> <p>一个简单、有些牵强的例子是一个将两个数字相加的函数:</p> <pre><code>const sum = (a, b) => a + b; </code></pre> <p>如果你向这样的函数提供 <code>1</code> 和 <code>2</code>,你总是期望返回 <code>3</code>。</p> <p>如果这个函数还依赖于函数外部维护的值——例如 <code>const sumWithGlobal = (a, b) => a + b + window.c</code>——并且这个值(<code>window.c</code>)通常为 <code>0</code>,但有时为 <code>1</code> 或可能是像字符串这样的随机值?当你提供 <code>1</code> 和 <code>2</code> 作为函数参数时,你期望在这个情况下会发生什么?你无法依赖结果为 <code>3</code>;它可能是 <code>4</code> 或其他截然不同或甚至抛出异常的结果。</p> <p>这个例子很简单,但如果它涉及到数千行代码呢?正如你所见,这会使问题的规模大得多。尽量保持函数的纯净;能够预测输出使每个人的生活都更容易。</p> <h4 id="声明式代码风格">声明式代码风格</h4> <p>我们不想代表所有人发言,但我们可以猜测你写的代码大多数是命令式风格的。你一行一行地设定你想要计算机做什么,就像一个食谱。你可能会在这个代码上叠加面向对象的注释,但仍然是一个食谱。这种做法没有错;它有效,并且大多数情况下效果很好。</p> <p>在声明式编程中,你声明你想要实现的逻辑,但将执行细节留给计算机。本质上,你不在乎你的程序的结果是如何实现的,只要它实现了。</p> <p>在这种 JavaScript 风格中,代码应该优先考虑以下内容:</p> <ul> <li> <p>数组迭代器在 <code>for</code> 循环中</p> </li> <li> <p>递归</p> </li> <li> <p>部分应用和可组合的函数</p> </li> <li> <p>使用三元运算符代替 <code>if</code> 语句以确保返回值</p> </li> <li> <p>避免改变状态、修改数据和副作用</p> </li> </ul> <p>我们强调“应该”,因为 JavaScript 由于内部栈帧限制不支持像尾递归这样的功能。此外,部分应用和函数组合是你构建到代码中的东西,而不是原生支持的东西。</p> <h5 id="列表-d59-声明式编程示例">列表 D.59. 声明式编程示例</h5> <pre><code>const compose = (...fns) => fns.reduce((f, g) => (...args) => f(g(...args))); *1* const url = '...'; const parse = item => JSON.parse(item); const fetchDataFromApi = url => data => fetch(url, data); const convertData = item => item.toLowerCase(); const convert = (...data) => data.map(item => convertData(item)); const items = [...dataList]; *2* const getProcessableList = compose( parse, fetchDataFromApi(url), convert ); *3* const list = getProcessableList(items); *4* </code></pre> <ul> <li> <p><strong><em>1</em> 创建一个组合函数</strong></p> </li> <li> <p><strong><em>2</em> 创建一个项目列表</strong></p> </li> <li> <p><strong><em>3</em> 组合函数</strong></p> </li> <li> <p><strong><em>4</em> 通过传递数据执行组合函数</strong></p> </li> </ul> <p>在这段代码中,重要的是对 <code>getProcessableList()</code> 的指令。所有其他元素都是为展示这个虚构示例所需的样板代码。重点是意图是声明的,但实现方式没有声明。</p> <h4 id="部分应用和函数组合">部分应用和函数组合</h4> <p>纯函数提供可预测的结果。如果你可以预测结果,你可以以创新的方式组合你的函数。较小的函数可以成为较大函数的组成部分,你不必担心中间结果。为了帮助你理解函数组合,我们将讨论部分应用。</p> <p><em>部分应用</em>,或<em>柯里化</em>,意味着向函数传递的参数少于它所需的参数,每次返回一个新的函数,因此推迟执行直到所有参数都可用。</p> <p>不幸的是,JavaScript 没有原生的支持函数柯里化,但通过使用语法,你可以模拟这个功能。下面的列表展示了如何做到这一点。</p> <h5 id="列表-d60-柯里化示例">列表 D.60. 柯里化示例</h5> <pre><code>const simpleSum (x, y) => x + y; *1* const curriedSum x => y => x + y; *2* const simpleResult = simpleSum(2, 3); *3* const curriedResult = curriedSum(2)(3); *4* const intermediary = curriedSum(2); *5* const finalCurried = intermediary(3); *6* </code></pre> <ul> <li> <p><strong><em>1</em> 简单的标准非柯里化函数</strong></p> </li> <li> <p><strong><em>2</em> 柯里化等价</strong></p> </li> <li> <p><strong><em>3</em> 所有参数一起收集并一次性应用</strong></p> </li> <li> <p><strong><em>4</em> 柯里化需要多次函数调用。</strong></p> </li> <li> <p><strong><em>5</em> 在这里,中间调用将 2 应用于 x 参数,返回一个需要另一个参数来创建结果(y => 2 + y)的函数。</strong></p> </li> <li> <p><strong><em>6</em> 应用最后一个必需的参数以返回预期的 5 值</strong></p> </li> </ul> <p>柯里化并不特殊。你所做的就是取一个多参数函数,并在应用一个参数后返回一个新的函数。</p> <p>在具备这些知识的基础上,你可以看看组合。<em>组合</em>是将多个函数组合起来以创建复杂的流程。这种技术允许你避免使用类似指令流的结构化循环代码。相反,通过将操作组合成简单、描述性的函数,你可以抽象出处理的复杂性。</p> <p>为了正常工作,函数需要小而纯,没有副作用。正在组合的函数需要输入和输出匹配,因此应用柯里化是有帮助的,但不是必需的。输入和输出匹配意味着一个接受整数的函数不应该与一个接受字符串的函数组合。尽管由于语言能够隐式类型转换,输入不匹配在技术上是可以接受的,但它可能是一个难以追踪的错误的来源。</p> <p>一种简单的方式来观察这一点是举一个例子。接下来的列表从上一个列表中获取了 <code>curriedSum()</code> 函数。</p> <h5 id="列表-d61-简单的组合">列表 D.61. 简单的组合</h5> <pre><code>const add = x => y => x + y; *1* const multiplyFactor = fac => num => num * fac; *2* const multiplyBy10 = multiplyFactor(10); const result = multiplyBy10(add(2)(5)); *3* </code></pre> <ul> <li> <p><strong><em>1</em> 添加函数</strong></p> </li> <li> <p><strong><em>2</em> 简单的可柯里化因子函数</strong></p> </li> <li> <p><strong><em>3</em> 组合函数以返回 100 的结果</strong></p> </li> </ul> <p>这个例子很简单,但说明了这个观点。</p> <p>一些库提供了一个名为 <code>compose</code> 的函数,它允许你以更优雅的方式处理组合,尽管这个函数并不难手动构建。基本原理是简单应用数学公式 <code>g(f(x))</code>。</p> <h5 id="列表-d62-compose-函数">列表 D.62. <code>compose</code> 函数</h5> <pre><code>const compose = (g, f) => x => g(f(x)); *1* const composedCompute = compose( *2* multiplyBy10, add(2) ); const result = composedCompute(5); *3* </code></pre> <ul> <li> <p><strong><em>1</em> 简单的组合函数</strong></p> </li> <li> <p><strong><em>2</em> 使用组合函数</strong></p> </li> <li> <p><strong><em>3</em> 获取结果</strong></p> </li> </ul> <p>在这些小例子之外,组合是一种可以使你的代码更简洁、更容易理解的工具。</p> <h2 id="最后的想法">最后的想法</h2> <p>JavaScript 是一种宽容的语言,这使得它容易学习,但也容易养成坏习惯。如果你在代码中犯了一个小错误,JavaScript 有时会想,“嗯,我想你可能想这么做,所以我会这么做。”有时候它是正确的,有时候是错误的。这对好代码来说是不可以接受的,因此,明确你的代码应该做什么很重要,你应该尝试以 JavaScript 解释器看到的方式编写你的代码。</p> <p>理解 JavaScript 的强大之处的一个关键在于理解作用域:全局作用域、函数作用域和词法作用域。JavaScript 中没有其他类型的作用域。你应尽可能避免使用全局作用域,当你确实需要使用它时,尽量以干净和封装的方式使用。作用域继承自全局作用域向下级联,因此如果不小心,维护起来可能会很困难。</p> <p>JSON 起源于 JavaScript,但并非 JavaScript;它是一种独立于语言的数据交换格式。JSON 不包含 JavaScript 代码,可以非常愉快地在 PHP 服务器和.NET 服务器之间传递;不需要 JavaScript 来解释 JSON。</p> <p>回调对于运行成功的 Node 应用程序至关重要,因为它们允许中央进程有效地委托那些可能阻碍其运行的任务。换句话说,回调使你能够在异步环境中使用顺序同步操作。但回调并非没有问题。很容易陷入回调地狱,有多个嵌套的回调和重叠的继承作用域,使得代码难以阅读、测试、调试和维护。幸运的是,只要你记住命名回调不像它们的内联匿名回调那样继承作用域,你就可以在所有层面上使用命名回调来解决这个问题。</p> <p>闭包和模块模式提供了在项目间编写自包含且可重用代码的方法。闭包允许你在其自身的独立作用域内定义一组函数和变量,你可以通过暴露的方法返回并与之交互。这导致了揭示性模块模式的出现,该模式遵循惯例来明确区分私有和公共部分。模块非常适合编写自包含的代码片段,这些代码片段可以与其他代码良好地交互,不会因为作用域冲突而出现问题。</p> <p>JavaScript 规范最近的变更,例如添加类语法和更加强调函数式编程,丰富了可用的工具集,以适应你想要使用的任何代码风格。</p> <p>JavaScript 规范中许多其他新增内容在此未涵盖:例如剩余操作符、展开操作符和生成器等。这是一个与 JavaScript 语言一起工作的激动人心的时刻。</p> <h2 id="nodejs-应用程序使用的各种方法的数据库集成差异">Node.js 应用程序使用的各种方法的数据库集成差异</h2> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/gt-mean/img/ifcfig01_alt.jpg" alt="" loading="lazy"></p>



浙公网安备 33010602011771号