Web-应用同构指南-全-
Web 应用同构指南(全)
原文:Isomorphic Web Applications
译者:飞龙
第一部分。第一步
了解什么是同构应用以及为什么想要构建一个同构应用是了解同构架构的重要第一步。本书的第一部分从宏观角度探讨了同构应用的原因和方式,为你提供了理解后面章节中呈现的具体实现细节所需的环境。
在第一章中,你将了解构建同构应用的所有原因。本章还为你概述了本书后面将构建的 All Things Westies 应用。在第二章中,你将使用本书中使用的技术(React、Node.js、webpack 和 Babel)构建一个示例应用。本章不是涵盖所有细节,而是让你看到这些部分是如何结合在一起的。
第一章。同构 Web 应用架构简介
本章涵盖
-
区分同构、服务器端渲染和单页应用
-
服务器端渲染以及从服务器端渲染到单页应用体验的转换步骤
-
了解同构 Web 应用的优缺点
-
使用 React 的虚拟 DOM 构建同构 Web 应用
-
使用 Redux 处理业务逻辑和数据流
-
通过 webpack 将具有依赖关系的模块捆绑在一起
本书旨在为寻求扩展其架构工具集并更好地了解构建 Web 应用的选项的 Web 开发者而编写。如果你曾经构建过单页或服务器端渲染的 Web 应用(比如,使用 Ruby on Rails),你将更容易理解本书中的内容。理想情况下,你对 JavaScript、HTML 和 CSS 感到舒适。如果你是 Web 开发的初学者,这本书可能不适合你。
从历史上看,Web 应用和网站有两种形式:服务器端渲染和单页应用(SPAs)。服务器端渲染应用通过向服务器发送新请求来处理用户采取的每个操作。相比之下,SPAs在浏览器中完全处理内容的加载和对用户交互的响应。同构 Web 应用是这两种方法的结合。
本书旨在将复杂的应用程序架构分解成可重复和可理解的片段。到本书结束时,你将能够使用以下技术创建内容网站或电子商务 Web 应用:
-
通过使用 React 实现快速感知性能并在服务器上渲染任何页面,以实现搜索引擎优化(SEO)爬虫(如 Googlebot)的完全渲染。
-
选择不在服务器上渲染某些功能。了解如何使用 React 生命周期来实现这一点。
-
在服务器和浏览器上处理用户会话。
-
使用 Redux 实现单向数据流,使在服务器上预取数据并在浏览器中渲染成为可能。
-
使用 webpack 和 Babel 来启用现代 JavaScript 工作流程。
1.1. 同构 Web 应用概述
我的团队和我遇到了一个大问题:我们的 SEO 渲染系统非常脆弱,消耗了宝贵的时间。我们不是在构建新功能,而是在排查为什么谷歌机器人看到的我们应用版本与用户看到的版本不同。该系统复杂,涉及第三方提供商,并且没有很好地扩展以满足我们的需求,因此我们决定向前迈进,开发一种新的应用类型——同构应用。
同构应用是一种将服务器渲染的网络应用与单页应用结合在一起的网络应用。一方面,我们希望利用服务器提供的快速感知性能和 SEO 友好的渲染。另一方面,我们希望在浏览器中处理复杂的用户操作(例如,打开模态窗口)。我们还想利用浏览器推送历史记录和 XMLHttpRequest(XHR)。这些技术使我们能够在每次交互时避免向服务器发送请求。
要开始理解所有这些内容,你将使用一个名为“所有西高地白梗”(All Things Westies)的示例网络应用(你将在本书的第四章中构建此应用)。在这个网站上,你可以找到各种为你的西高地白梗(一种小型白色犬)购买的产品。你可以购买狗用品和带有西高地白梗图案的产品(如袜子、杯子、T 恤等)。如果你不是宠物主人,你可能会觉得这个例子很荒谬。即使是宠物主人,我也觉得它有些过分。但事实是,像杯子这样的狗用品已经成为一大热门。如果你不相信我,可以在谷歌上搜索“pug mugs”。
因为这是一个电子商务应用,我们非常重视良好的搜索引擎优化(SEO)。我们还希望我们的客户在使用应用时能获得极佳的体验。这使得同构架构成为理想的应用场景。
1.1.1. 理解其工作原理
请看图 1.1,这是“所有西高地白梗”应用的线框图。它有一个标准的页眉,右侧有一些主要网站导航。在页眉下方,主要内容区域推广产品和社交媒体的存在。
图 1.1. 展示“所有西高地白梗”应用主页的线框图,这是一个同构网络应用

第一次访问该网站时,应用内容使用 Node.js 的服务器渲染技术渲染在服务器上。在服务器渲染后,内容被发送到浏览器并显示给用户。当用户在页面间导航,寻找狗杯或用品时,每个页面都是由浏览器中运行的 JavaScript 和 SPA 技术渲染的。
All Things Westies 应用依赖于尽可能在服务器和浏览器之间重用代码。该应用依赖于 JavaScript 在多个环境中的运行能力:JavaScript 在浏览器中运行,通过 Node.js 在服务器上运行。虽然 JavaScript 也可以在其他环境中运行(例如,在物联网设备和通过 React Native 在移动设备上),但这里的重点是运行在浏览器中的 Web 应用。
这本书中的许多概念可以在不编写所有 JavaScript 代码的情况下应用。历史上,在没有代码重用能力的情况下运行同构应用,其复杂性一直是阻碍因素。虽然使用 Java 或 Ruby 进行服务器端渲染并在之后过渡到单页应用是可能的,但这并不常见,因为它需要在两种语言中复制大量代码。这需要更多的维护。
要查看这个流程的实际操作,请看图 1.2。它展示了 All Things Westies 的代码是如何部署到服务器和浏览器的。服务器代码被打包并在 Node.js 网络服务器上运行,而浏览器代码则打包成一个文件,稍后在浏览器中下载。因为我们利用了 JavaScript 在两个环境中的运行,所以运行在浏览器并与我们的 API 或数据源通信的相同代码也在服务器上运行,以与后端通信。
图 1.2. 同构应用在两个环境中构建和部署相同的 JavaScript 代码。

1.1.2. 构建我们的堆栈
构建像 All Things Westies 这样的应用需要组合几种知名技术。这本书中的许多概念都是通过开源库来实现的。虽然你可以使用很少或没有库来构建同构应用,但我强烈建议利用 JavaScript 社区在这个领域的努力。
提示
确保你包含在同构应用中的任何库都支持在服务器和浏览器环境中运行。查看第十章了解需要注意的事项以及如何处理环境差异。如果你打算只在服务器上使用库,则不需要检查浏览器兼容性。
显示产品(视图)的 HTML 组件将使用 React 构建(在第十二章中,你将探索如何使用其他流行的框架,包括 Angular 2 和 Ember,来实现同构架构)。你将通过 Redux 实现单向数据流,Redux 是 React 应用中当前社区标准的数据管理方式。你将使用 webpack 来编译在浏览器中运行的代码,并启用在浏览器中运行 Node.js 包。
在服务器端,你将使用 Express 构建 Node.js 服务器来处理路由。你将利用 React 在服务器上渲染的能力,并使用它构建一个完整的 HTML 响应,可以提供给浏览器。表 1.1 显示了所有这些组件是如何组合在一起的。
表 1.1. 同构应用程序中使用的技术及其运行的环境
| 库(版本) | 服务器 | 浏览器 | 构建工具 |
|---|---|---|---|
| Node.js (6.9.2) | ✓ | ||
| Express (4.15.3) | ✓ | ||
| React (15.6.1) | ✓ | ✓ | |
| React Router (3.0.5) | ✓ | ✓ | |
| Redux (3.7.2) | ✓ | ✓ | |
| Babel (6.25.0) | ✓ | ✓ | ✓ |
| webpack (3.4.1) | ✓ | ✓ |
为了使我们的应用程序在所有地方都能工作,你将使用 React Router 为你的路由构建数据预取。你还将通过为服务器和浏览器构建单独的代码入口点来处理环境差异。如果代码只能在浏览器中运行,你将控制代码或利用 React 生命周期来确保代码不会在服务器上运行。我在第三章介绍了 React,并在第七章中介绍了服务器逻辑的细节。
1.2. 架构概述
在本章的早期,我告诉你们同构应用程序是服务器端渲染的应用程序和单页应用程序结合的结果。为了更好地理解如何连接服务器端渲染应用程序和单页应用程序的概念,请参阅图 1.3。此图显示了获取同构应用程序渲染并响应用户输入的所有步骤,如单页应用程序,从用户输入网页地址开始。
图 1.3. 从初始浏览器请求到 SPA 循环的同构应用程序流程

1.2.1. 理解应用程序流程
每个网络应用程序会话都是在用户导航到网络应用程序或输入 URL 到浏览器窗口时启动的。对于 allthingswesties.com,当用户从电子邮件或从 Google 搜索中点击应用程序链接时,服务器端的流程将经过以下步骤(数字与图 1.3 中的数字相匹配):
1. 浏览器发起请求。
2. 服务器接收请求。
3. 服务器确定需要渲染的内容。
4. 服务器收集我们请求的应用程序部分所需的数据。如果请求是 allthingswesties.com/product/mugs,应用程序将请求通过网站销售的礼品项目列表。在进入渲染步骤之前,收集了所有要显示的信息(名称、描述、价格、图片)列表。
5. 服务器使用为杯子页面收集的数据生成我们的网页的 HTML。
6. 服务器响应 allthingswesties.com/product/mugs 的请求,并返回完整的 HTML。
应用程序周期的下一部分是浏览器中的初始加载。我们区分用户首次加载应用程序与随后的请求,因为在此首次加载期间,只会发生一次会话中的几个事情。
定义
初始加载 是用户首次与您的网站互动的时刻。这意味着用户首次在谷歌搜索或社交媒体中点击您的网站链接,或者直接在网页地址栏中输入它。
浏览器的首次加载开始于从服务器接收 HTML 响应并且 DOM 能够被处理的那一刻。在此点,单页应用程序流程接管,应用程序响应用户输入、浏览器事件和计时器。用户可以向购物车添加产品、在网站上导航,并与表单交互。
7. 浏览器渲染从服务器接收到的标记。
8. 应用程序现在能够响应用户输入。
9. 当用户将项目添加到购物车时,代码会响应并运行必要的业务逻辑。
10. 如果需要,浏览器会与后端通信以获取数据。
11. React 渲染组件。
12. 进行更新,并执行任何必要的重绘。例如,用户的购物车图标更新以显示已添加项目。
13. 每次用户与应用程序互动时,步骤 9–12 会重复。
1.2.2. 处理服务器端请求
现在,让我们更详细地看看当服务器接收到渲染页面的初始请求时会发生什么。看看在服务器上渲染的网站部分。图 1.4 与 图 1.1 类似,但它的 Twitter 小部件没有渲染。Twitter 小部件设计为在浏览器中加载,因此它不会在服务器上渲染。
图 1.4. All Things Westies 首页的服务器渲染版本

服务器执行三项重要任务。首先,它获取视图所需的数据。然后,它使用这些数据来渲染 DOM。最后,它将数据附加到 DOM 上,以便浏览器可以读取应用程序状态。图 1.5 展示了服务器上的流程。
图 1.5. 初始服务器渲染的应用程序流程

让我们逐步分析流程:
1. 服务器接收请求。
2. 服务器获取该请求所需的所需数据。这可以来自持久数据存储,如 MySQL 或 NoSQL 数据库,或来自外部 API。
3. 在接收到数据后,服务器可以构建 HTML。它通过 React 的
renderToString方法使用 React 的虚拟 DOM 生成标记。4. 服务器将步骤 2 中的数据注入到您的 HTML 中,以便浏览器稍后可以访问它。
5. 服务器以你完全构建的 HTML 响应请求。
1.2.3. 浏览器中的渲染
现在我们更详细地看看浏览器中发生了什么。图 1.6 显示了浏览器中的流程,从浏览器接收 HTML 到引导应用的过程:
1. 浏览器立即开始渲染茶杯页面,因为服务器发送的 HTML 已经完全形成,包含了你在服务器上生成的所有内容。这包括应用的页眉和页脚以及可购买的茶杯列表。应用目前不会响应用户输入。像将茶杯添加到购物车或查看特定茶杯的详细页面这样的操作将无法工作。
2. 当浏览器达到我们应用的 JavaScript 入口时,应用开始引导。
3. React 重新创建了虚拟 DOM。因为服务器发送了应用状态,这个虚拟 DOM 与当前的 DOM 相同。
4. 没有发生任何事!React 没有在 DOM 和它构建的虚拟 DOM 之间找到差异(虚拟 DOM 在第三章中有详细解释)。用户已经在浏览器中看到了茶杯列表。现在应用可以响应用户输入,例如将茶杯添加到购物车。
图 1.6. 浏览器渲染和引导—在第 1 步和第 4 步之间,应用不会响应用户输入。

这时,单页应用流程再次启动。这是最直接的部分。它处理用户事件,进行 XHR 调用,并根据需要更新应用。
1.3. 同构应用架构的优势
在这一点上,你可能觉得这听起来很复杂。你可能想知道为什么这种构建 Web 应用的方法会值得。有多个令人信服的理由选择这条路径:
-
简化和改进的 SEO—爬虫和爬虫可以在页面加载时读取所有数据。
-
用户感知的性能提升。
-
维护收益。
-
提高可访问性,因为用户可以在没有 JavaScript 的情况下查看应用。
同构应用架构也面临挑战和权衡。在管理和部署在多个环境中运行的代码时,复杂性增加。调试和测试更加复杂。通过 Node.js 和 React 服务器渲染的 HTML 对于具有许多组件的视图可能会很慢。例如,显示许多销售项目的页面可能会迅速变成数百个 React 组件。随着这个数字的增加,React 在服务器上构建这些组件的速度会下降。首先,我将介绍构建同构应用的优点。让我们从讨论 SEO 开始。
1.3.1. SEO 优势
我们的示例应用“所有西施犬”,是一个电子商务网站,因此为了成功,它需要购物者!并且它需要良好的 SEO 来最大化从搜索引擎来到应用的人数。单页应用对搜索引擎爬虫来说很难爬取,因为它们在浏览器中的 JavaScript 运行之后才会加载数据。同构应用在 JavaScript 运行后也需要引导启动,但由于它们的内容是由服务器渲染的,用户和爬虫都不需要等待应用引导启动才能看到网站的内容。
定义
引导启动一个应用意味着运行设置好一切所需的代码。此代码仅在应用的初始加载时运行一次,并从浏览器应用的入口点运行。
在 All Things Westies 应用上,您想要确保所有与 SEO 相关的内容都在服务器上获取,这样您就不依赖于 SEO 爬虫尝试渲染您的页面。爬虫(无论是谷歌或必应等搜索引擎爬虫还是 Facebook 等分享爬虫)要么无法运行所有这些代码,要么不愿意等待足够长的时间来运行这些代码。例如,谷歌会尝试运行 JavaScript,但会惩罚加载内容过慢的网站。这可以在图 1.7 中显示的警告中看到。当您将单页应用的 URL 输入到 Google PageSpeed Insights 工具中时,就会出现这个警告。
图 1.7. Google PageSpeed Insights 为单页应用提供了一个警告。该应用在页面初始加载后进行了过多的 AJAX 调用以获取可见内容。

Google PageSpeed Insights 工具
Google 的 PageSpeed Insights 工具帮助测量您的页面在 0 到 100 分的范围内表现如何。您会得到与速度相关的问题(如图片大小、JavaScript 大小、放大、往返次数等)和 UI(例如点击区域大小)的分数。在您的 Web 应用上测试它,请访问developers.google.com/speed/pagespeed/insights。
Google 还有一个 Lighthouse 工具(作为 Chrome 扩展或命令行工具提供),它将对您网站上的页面进行深入分析。它对性能、使用服务工作者以允许离线使用、提高屏幕阅读器的可访问性等方面提出建议。您可以在developers.google.com/web/tools/lighthouse/了解更多关于 Lighthouse 的信息。
如果你没有处理这个警告,你可能会得到较低的排名和更少的客户。此外,没有保证任何依赖于 API 调用的页面内容都会被爬虫运行。为了解决单页应用的问题,已经出现了专门的服务。开发团队投入时间开发系统来爬取和预渲染他们的页面。然后,他们将机器人重定向到这些预渲染的页面。这些系统复杂且脆弱,难以维护。
个人来说,我迫不及待地希望有一天爬虫和机器人能够获取到我们所有的内容,无论数据是在服务器上还是在浏览器中获取。直到那一天,服务器渲染初始内容在单页应用渲染方面具有很大的优势。这对于页眉以上的内容以及任何具有 SEO 优势的其他内容尤其如此。
定义
页眉以上 是来自报纸行业的术语。它指的是当报纸折叠成一半并放在报摊上时,出现在首页上的所有内容。对于 Web 应用来说,这个术语用于指代当应用加载时,用户屏幕上可查看区域内的所有内容。要查看页眉以下的内容,用户必须滚动。
除了 SEO 爬虫之外,许多允许内联网站预览的社会网站和应用(例如,Facebook、Twitter、Slack 或 WhatsApp)也使用不运行 JavaScript 的机器人。这些网站假设所有可用于构建社交卡片或内联预览的内容都将可在服务器渲染的页面上获得。同构应用非常适合处理社交机器人用例。
在本节的开始,我提到过,机器人和用户都不需要等待同构应用启动以查看动态内容。另一种说法是,同构 Web 应用的感知性能很快。下一节将详细描述这一点。
1.3.2. 性能优势
用户希望立即看到 All Things Westies 的内容。否则,他们会变得不耐烦,在看到所有产品和信息之前就离开。加载 SPA 对于用户来说可能是一个缓慢的过程(尤其是在手机上)。尽管浏览器可能快速连接到你的应用,但运行启动代码和获取内容需要时间,这会让用户等待。在最佳情况下,SPAs 会显示加载指示器和用户消息。在最坏的情况下,没有视觉反馈,用户会感到困惑,不知道是否有什么事情发生。
图 1.8 显示了如果 All Things Westies 是一个单页应用,在初始渲染时的样子。你不会立即看到所有内容,而是在所有内容区域都会看到加载旋转器。
图 1.8. 在 All Things Westies 的单页应用版本中,第一次加载时会显示旋转器,而不是真实内容。

服务器端渲染的页面在浏览器接收并渲染 HTML 后立即将内容(您网站的所有 HTML、图像、CSS 和数据)显示给用户。这导致用户比在 SPA 中看到内容快几秒钟。尽管网站仍然需要在用户交互之前加载和执行 JavaScript,但这种快速加载使用户能够快速开始视觉处理您的内容。这被称为感知性能。应用内容快速呈现给用户。用户没有意识到后台正在运行 JavaScript。
当这个过程执行得当,用户将永远不会知道在视图渲染之后加载的 JavaScript。从所有实际目的来看,用户会有很好的体验,因为他们认为应用加载得很快。这大大减少了在应用首次加载时需要加载旋转器或其他等待状态的需求。这导致用户更加满意。图 1.9 展示了单页应用和同构应用之间的差异。
图 1.9. 用户看到 Web 应用内容时的比较。同构应用比单页应用更早地显示其内容。

现在,我将详细向您介绍单页应用和同构场景。您也可以在图 1.9 中看到这些流程。
首先,看看示例 1。想象一下,您访问我们的示例 Web 应用,屏幕上显示空白六秒钟。您会怎么做?您有多可能感到沮丧并放弃使用该 Web 应用?如果您想买一双威士忌袜子,您可能会放弃 All Things Westies,并将业务转移到其他地方。
现在想象一下,Web 应用仍然需要六秒钟来加载(如示例 2 所示),但这次它显示了一个基本结构(一个加载旋转器),让您知道 Web 应用正在做某事,但您还不能与之交互,就像之前图 1.8 中所示的那样。您愿意等待这个网站加载吗?
最后,让我们想象一下,当您来到 All Things Westies 时,内容在两秒钟内显示出来,如图例 3 所示。这个流程与本章开头图 1.1(kindle_split_011_split_001.xhtml#ch01fig01)中的流程相匹配。这次,您的头脑开始处理显示的信息。您感觉不到需要等待。在后台,应用仍在加载并工作以设置一切,但您不需要等待这一切完成才能看到内容。
注意,该应用能够在页面加载流程中更早地显示内容。尽管所有三种方法中页面加载时间按性能指标衡量将是相同的,但用户感知同构应用的性能要快得多。
1.3.3. 没有 JavaScript?没问题!
同构应用程序架构的另一个用户端好处是,你可以不要求 JavaScript 即可提供网站的部分内容。无法或不想运行 JavaScript 的用户仍然可以在网站以同构方式构建时从中受益。因为你向浏览器提供了一个完整的页面,用户至少可以看到你的内容,尽管他们无法与应用程序交互。
这让你能够使用渐进增强来更好地为各种浏览器和设备上的用户提供服务。尽管遇到没有运行 JavaScript 的用户可能不太可能,但还有其他很好的理由从服务器加载整个页面。例如,如果你支持旧版浏览器或设备,同构应用程序是提供跨多种浏览器/设备/操作系统组合的最佳体验的好工具。
我们已经介绍了同构应用程序的用户端好处。接下来,我们将探讨这种架构带来的开发者好处。
1.3.4. 维护和开发者好处
在构建同构应用程序时,大部分代码可以在服务器和浏览器上运行。如果你想渲染一个视图,你只需要编写一次代码。如果你想为应用程序中的常见任务编写辅助函数,你只需要编写一次这个逻辑,它将在两个地方运行。
这比那些服务器端代码用一种语言编写,浏览器端代码用 JavaScript 编写的应用程序具有优势。开发者可以保持专注,无需在语言之间切换。构建、环境管理和依赖项都得到了简化,这使得你的整体工作流程更加清晰。
这并不是说构建同构应用程序很容易。用一种语言编写一切会带来它自己的一套问题。
1.3.5. 挑战和权衡
选择使用同构 Web 架构构建应用程序并非没有权衡。首先,它需要一种新的思维方式,这需要时间来适应。好消息是,这正是本书要教你的内容。其中一些挑战包括以下内容:
-
处理 Node.js 和浏览器之间的差异
-
调试和测试的复杂性
-
在服务器上管理性能
处理服务器和浏览器之间的差异
Node.js 没有窗口或文档的概念。浏览器不知道 Node.js 环境变量,也不知道请求或响应对象是什么。这两个环境都知道 cookie,但它们处理它们的方式不同。在第十章中,你将了解处理这些环境紧张关系的策略。
调试和测试的复杂性
你的所有代码都需要测试两次:直接从服务器加载,以及作为单页流程的一部分。调试需要掌握浏览器和服务器调试工具,并知道错误是在服务器、浏览器还是在两个环境中发生的。此外,还需要一个彻底的单元测试策略,其中测试是在适当的环境中编写和运行的。仅服务器端代码应在 Node.js 中测试,但共享代码应在它最终运行的任何环境中进行测试。
管理服务器端的性能
服务器端的性能也提出了挑战,因为 React 提供的renderToString方法在具有许多组件的复杂页面上执行缓慢。在第十一章(kindle_split_023_split_000.xhtml#ch11)中,我将向你展示如何尽可能优化你的代码而不破坏 React 的最佳实践。我们还将讨论缓存作为减少服务器性能问题的工具。
在这一点上,你已经理解了同构应用架构带来的好处和权衡。接下来,让我们深入探讨如何执行一个同构应用。
1.4. 使用 React 构建视图
React 是构建同构 Web 应用可能性的组成部分之一。React是由 Facebook 开源的用于创建用户界面(应用中的视图层)的库。React 通过 HTML 和 JavaScript 使表达视图变得容易。它提供了一个简单且易于启动的 API,但设计成可组合的,以便快速高效地构建用户界面。像许多其他视图库和实现一样,React 提供了一个模板语言(JSX)并钩入 DOM 和 JavaScript 的常用部分。
React 还通过遵循从顶级组件到底层子组件的单向数据流来利用函数式概念。对于同构应用来说,使其吸引人的是它如何使用虚拟 DOM 来管理应用程序的变化和更新。
React 不是一个像 Angular 或 Ember 那样的框架。它只提供你用来编写视图组件的代码。它可以很容易地适应模型-视图-控制器(MVC)风格的架构作为视图。但书中会介绍构建复杂 React 应用的推荐方法。
虚拟 DOM是用 JavaScript 编写的浏览器 DOM 的表示。在核心上,React 由 React 元素组成。自从 React 将虚拟 DOM 引入到 Web 社区以来,这个想法已经开始出现在许多主要的库和框架中。甚至有些人正在编写他们自己的虚拟 DOM 实现。
就像浏览器 DOM 一样,虚拟 DOM 是一个由根节点及其子节点组成的树。虚拟 DOM 创建后,React 将虚拟树与当前树进行比较,并计算出需要更新浏览器 DOM 的更新。如果没有变化,则不进行更新。如果发生了变化,React 只更新浏览器 DOM 中发生变化的部分。图 1.10 展示了这一点。在左侧,虚拟 DOM 已更新以删除带有<div>标签的右子树,其子节点是一个<img>标签和一个<a>标签。这导致这些相同的子节点从浏览器 DOM 中删除。
图 1.10. 比较 DOM 树:虚拟 DOM 的变化与浏览器 DOM 进行比较。然后 React 根据计算出的差异智能地更新浏览器 DOM 树。

React 使用 JavaScript 来表示 DOM 节点。在 JavaScript 中,这被写成如下所示:
let myDiv = React.createElement('div');
当 React 渲染发生时,每个组件返回一系列 React 元素。它们一起形成虚拟 DOM,这是 DOM 树的 JavaScript 表示。
由于虚拟 DOM 是浏览器 DOM 的 JavaScript 表示,并且不依赖于浏览器提供的对象(尽管某些代码路径可能依赖于这些项目),它可以在服务器上渲染。但是,在服务器上渲染 DOM 是不行的。相反,React 提供了一种将渲染的 DOM 作为字符串输出(ReactDOM.renderToString)的方法。这个字符串可以用来构建一个完整的 HTML 页面,该页面从你的服务器发送到用户。
1.5. 商业逻辑和模型:Redux
在现实世界的 Web 应用中,你需要一种管理数据流的方法。Redux 提供了一个与应用程序状态很好地配合 React 工作的实现。需要注意的是,你不必在 React 中使用 Redux,反之亦然,但它们的理念相得益彰,因为它们都使用了函数式编程的思想。同时使用 Redux 和 React 也是社区的最佳实践。
与 React 一样,Redux 遵循单方向的数据流。Redux 在其存储中保存了应用程序的状态,为你的应用程序提供了一个单一的真实来源。为了更新这个存储,动作(代表应用程序状态离散变化的 JavaScript 对象)从视图中发出。这些动作反过来又触发还原器。还原器是纯函数(没有副作用的功能),它接受一个变化并返回在响应变化后的新存储。图 1.11 展示了这个流程。
图 1.11. 视图(React)使用 Redux 在用户采取动作时更新应用程序状态。Redux 然后让视图知道何时根据新的应用程序状态进行更新。

关于 Redux,需要记住的关键点是只有 reducers 可以更新存储。所有其他组件只能从存储中读取。此外,存储是不可变的。这是通过 reducers 强制执行的。我在第二章中再次提到了这一点,并在第六章中进行了完整的 Redux 解释。
在同构应用中,在服务器和浏览器之间传输状态的能力非常重要。Redux 的存储提供顶级状态。通过依赖于单个根对象来保存你的应用程序状态,你可以轻松地将状态序列化在服务器上,并将其发送到浏览器进行反序列化。第七章更详细地介绍了这个主题。应用的最后一部分是构建工具。下一节将概述 webpack。
1.6. 构建应用:webpack
Webpack 是一个强大的构建工具,它使得将代码打包成一个单一捆绑包变得容易。它有一个以加载器形式存在的插件系统,允许简单地访问工具,如 Babel 用于 ES6 编译或 Less/Sass/PostCSS 编译。它还允许你将 Node.js 模块代码(npm 包)打包成将在浏览器中运行的捆绑包。
定义
当前和未来的 JavaScript 版本(ES6、ES2015、ES2016、ES7、ES Next)有许多名称。为了保持一致性,我将尚未在浏览器中完全采用的现代 JavaScript 称为 ES6。
这对于我们的同构应用至关重要。通过使用 webpack,你可以将所有依赖项捆绑在一起,并利用 npm(Node 包管理器)提供的库生态系统。这允许你几乎在两个环境中共享应用程序中的所有代码——浏览器和服务器。
注意
你不会为我们的 Node.js 代码使用 webpack。这是不必要的,因为你可以编写大多数 ES6 代码,Node.js 已经可以利用环境变量和 npm 包。
Webpack 还允许你在捆绑代码中使用环境变量。这对于我们的同构应用非常重要。尽管你希望尽可能在两个环境中共享代码,但浏览器中的一些代码在服务器上无法运行,反之亦然。在 Node.js 服务器上,你可以利用这样的环境变量:
if (NODE_ENV.IS_BROWSER) { // execute code }
但这段代码在浏览器中无法运行,因为它没有 Node.js 环境变量的概念。你可以使用 webpack 将一个 NODE_ENV 对象注入到你的 webpacked 代码中,这样这段代码就可以在两种环境中运行。第五章深入介绍了这个概念。
摘要
在本章中,你了解到同构 Web 应用是服务器渲染的 HTML 页面与单页应用架构相结合的结果。这样做有几个优点,但确实需要学习一种新的关于 Web 应用架构的思考方式。下一章将提供一个同构应用的概述。
-
同构 Web 应用将服务器端架构和单页应用架构结合起来,为用户提供更好的整体体验。这导致感知性能的提高、SEO 的简化以及开发者的好处。
-
能够在服务器(Node.js)和浏览器中运行 JavaScript 允许你编写一次代码,并将其部署到两个环境中。React 的虚拟 DOM 允许你在服务器上渲染 HTML。
-
Redux 帮助你管理应用程序状态,并轻松地将此状态序列化以从服务器发送到浏览器。
-
通过使用 webpack 构建你的应用程序,你可以在浏览器中使用 Node.js 代码,并标记代码仅在浏览器中运行。
第二章. 一个同构应用的示例
本章涵盖
-
设置你的构建以在服务器和浏览器上工作
-
渲染视图
-
使用 Redux 获取数据
-
在服务器上处理请求
-
在服务器上序列化数据
-
在浏览器中反序列化数据
在本章中,我将带你了解使用 React、Redux、Babel 和 webpack 构建的同构应用程序的所有关键部分。将本章视为在全面投入之前试水的机会。你不需要理解所有细节,但到本章结束时,你将有一个关于所有组件如何融入应用程序的感觉,这将为你理解本书的其余部分提供背景。
如果你已经精通构建 React 应用程序,那么本章以及第七章和第八章将帮助你入门。如果你对 React 还不熟悉,我将在第三章至第六章中带你了解 React 和应用程序的其他构建块。
2.1. 本章将构建的内容:食谱示例应用
首先,让我们看看你将在本章中构建的应用程序。图 2.1 显示了你要构建的食谱应用程序。在本章中,你将构建应用程序的主页,它将显示顶级食谱和特色食谱。将你的第一个同构应用程序的所有组件组合在一起是一个复杂的过程,因此对于构建同构应用程序的第一次尝试,我将保持最终目标简单。
图 2.1. 你将在本章中构建的食谱应用的主屏幕

这个应用将只有一个路由,不会处理任何用户交互。同构架构对于这样一个简单的应用来说过于复杂,但它的简单性将使我能够展示核心概念。在后面的章节(从第四章开始),我将教你如何构建一个更复杂的具有路由和用户交互的应用程序。
在第一章中,我们讨论了同构应用中的三个主要步骤:服务器渲染、初始浏览器渲染和单页应用行为。在本章中,你将学习如何创建一个可以利用这种渲染流程的应用程序。你将构建服务器,序列化数据,在浏览器中加载数据,并渲染浏览器视图。图 2.2 展示了本章中你将构建的各个部分是如何组合在一起的。
图 2.2. 本章中你将构建的应用程序流程——从服务器和浏览器进行初始渲染

定义
序列化发生在你将 JSON 转换为字符串时。这个字符串易于在应用程序之间发送,可以作为服务器响应的一部分发送到浏览器。加载数据(或反序列化)意味着将字符串转换回一个可以被浏览器中的应用程序使用的 JSON 对象。
2.1.1. 构建块:库和工具
要编写食谱应用程序并使其作为同构应用程序运行,你将使用几个 JavaScript 库:
-
Babel 和 webpack— 编译和构建工具。Babel 将代码编译成 JavaScript 编译器可以理解的版本,无论浏览器实现如何。Webpack 将允许你为浏览器捆绑代码,包括通过 npm(Node 包管理器)安装的库。
-
Express— 使服务器端路由简单化,用于渲染视图。
-
React 和 Redux— 视图和业务逻辑。你将使用名为 JSX 的模板语言编写你的 React 组件,这是 React 的标准。它看起来很像 HTML,但允许你在视图代码中插入逻辑和变量。
-
Semantic UI— 通过提供一组标准的类来简化 CSS。本书的重点不是 CSS,这将使你在各种示例中更容易跟随。
Semantic UI 用于布局和 CSS
Semantic UI 是一个 CSS 库,它提供了基本的样式和预定义的布局、组件和网格。我在食谱示例应用的视图布局中使用了 Semantic UI 的 CSS。有关 Semantic UI 的文档可以在semantic-ui.com找到。
在你开始构建和运行代码之前,让我们看看代码的哪些部分在服务器上运行,哪些部分在浏览器上运行,以及哪些部分在两个环境中都运行。图 2.3 将应用程序的各个部分(React 组件、Redux 动作和减少器、服务器和浏览器的入口点)映射到它们运行的相应环境中。一些代码(例如 React 和 Redux)将在两个环境中运行。其他代码是针对服务器或浏览器的特定代码(例如,Express 用于服务器)。
图 2.3. 概述了各种库和构建工具如何在代码运行的两个环境中(服务器和浏览器)中使用。这里列出的文件可以在代码示例中找到。下一节将提供下载代码的说明。

图表还演示了哪些构建工具用于哪些环境。Webpack 将用于构建仅浏览器代码。服务器代码将使用 npm 脚本来构建。Babel 在两个环境中都使用。
2.1.2. 下载示例代码
您可以从 GitHub 下载此示例的代码:github.com/isomorphic-dev-js/chapter2-a-sample-isomorphic-app.git。我建议您这样做,因为所有必需的包和代码都已经为您设置好了,您可以轻松地跟随操作。
要从 GitHub 检查代码,请在您想要克隆项目的目录中运行以下命令:
$ git clone https://github.com/isomorphic-dev-js/chapter2-a-sample-
isomorphic-app.git
小贴士
如果您需要帮助开始使用 Git,Rick Umali 的《一个月午餐时间学习 Git》(Manning, 2015)是一本很好的资源。
查看应用中的关键文件夹和文件。图 2.4 显示了应用的核心文件夹结构(其他文件和文件夹也位于仓库中,但图表指出了本章相关的部分)。您可以将这些映射到图 2.3 中显示的环境。服务器(app.es6)和浏览器(main.jsx)的入口点尤为重要,因为所有特定于环境的代码都将放入这些文件中。
图 2.4. 文件夹组织以及顶级构建和配置文件。食谱应用的文件夹结构子集视图,显示了哪些文件与构建和工具、服务器和浏览器相关。

2.2. 工具
在您克隆了仓库之后,就是时候运行应用了。本章的代码包括一个简单的 Node.js 服务器,该服务器将渲染食谱应用的首页。Node.js 服务器还将提供食谱数据。在这个例子中,食谱将从 JSON 文件中加载。在现实世界中,您可能希望使用数据库或 API 来持久化数据。
为了让食谱应用正常运行,您将学习以下内容:
-
使用 npm 设置开发环境和安装包
-
使用 Babel 编译和运行服务器代码
-
使用 webpack 构建浏览器代码
-
处理多个代码入口点
2.2.1. 设置环境和安装包
您将使用 Node.js 来运行 Web 服务器。它适用于许多用例,但特别适合同构应用,因为它允许您使用 JavaScript 编写整个应用栈。
Node.js 下载和文档
本章假设您对 Node.js 有基本的了解,并且已经将其安装到您的机器上。要获取 Node.js 的最新版本并保持文档更新,请访问 nodejs.org。Node.js 随 npm 一起提供。
我正在运行 Node.js 版本 6.9.2。如果您运行的版本低于 6 的主要版本,您可能需要额外的 Babel 包,这些包在本书中没有涵盖。如果您运行的版本高于 6 的主要版本,您可能不需要包含的所有 Babel 包。
在您开始运行服务器之前,您需要安装此示例的所有 npm 包。您可以在 www.npmjs.com 找到所有 npm 包的列表以及 表 2.1 和 2.2 中列出的包的文档。食谱应用所需的包已在项目的 package.json 中提供。要安装它们,请在您的终端中运行以下命令:
$ npm install
安装两组包:
-
devDependencies 包含构建工具,如 Babel 和 webpack。当
NODE_ENV变量设置为production时,package.json 中的 devDependencies 部分的包不会安装。有关更多信息,请参阅 表 2.1。 -
dependencies 包含运行应用程序所需的任何库。有关更多信息,请参阅 表 2.2。
表 2.1. devDependencies 列表(用于构建和编译应用程序)
| Package | 描述 |
|---|---|
| babel-core | 主要的 Babel 编译器包。更多信息请访问 babeljs.io。 |
| babel-cli | Babel 的命令行工具。用于编译服务器代码。 |
| babel-loader | Webpack 用于使用 Babel 与 webpack 的加载器。 |
| babel-preset-es2015, babel-preset-react, babel-plugin-transform-es2015-destructuring, babel-plugin-transform-es2015-parameters, babel-plugin-transform-object-rest-spread | Babel 有许多预设选项,因此我们包括与本项目相关的选项。这些包包括 React、ES6 和 JSX 编译的规则。 |
| css-loader | Webpack 用于在 webpacked 文件中使用 CSS 的加载器。 |
| style-loader | Webpack 用于在 webpacked 文件中使用 CSS 的加载器。 |
| webpack | 用于编译 JavaScript 代码的构建工具。使 ES6 和 JSX 在浏览器中使用,以及使用 Node.js 编写的包(只要它们是同构的)。更多信息请访问 webpack.js.org。 |
表 2.2. 食谱应用的核心依赖
| Package | 描述 |
|---|---|
| express | 一个提供通过中间件进行路由和路由处理工具的 Node.js 网络框架。更多信息请访问 github.com/expressjs。 |
| isomorphic-fetch | 启用在浏览器和服务器中使用 fetch API。 |
| react | 主要的 React 包。更多信息请访问 facebook.github.io/react/. |
| react-dom | 浏览器和服务器特定的 DOM 渲染实现。 |
| redux | 核心 Redux 代码。 |
| react-redux | 为连接 React 和 Redux 提供支持。 |
| redux-thunk | Redux 中间件。 |
| redux-promise-middleware | 支持承诺的 Redux 中间件。 |
在您的编辑器中打开代码并找到 package.json 文件。您将看到前面表格中列出的所有库。现在您已经了解了示例应用的依赖关系,您可以设置并运行服务器。
2.2.2. 运行服务器
要使服务器运行(以便您可以测试 API,如图 2.5 所示),您首先需要使用 Babel 构建服务器代码。您可能想知道为什么需要为在运行时解释的语言编译代码。这一步骤需要两个原因:
-
使用 ES6 语言特性编写最新和最优秀的代码— JavaScript 也被称为 ECMAScript (ES)。ES6 是一个较新的版本,它添加了许多语言特性,包括类、映射和承诺。ES6 的大部分规范已经在 Node.js 6.9.2 或更高版本上运行。但如果你想要使用来自 ES7(JavaScript 的下一个版本)的新功能或使用
import语句而不是require语句,仍然需要进行编译。本书中的示例利用了import语句。 -
因为服务器将渲染组件,所以您需要在服务器上运行 JSX— JSX 是 React 用于声明视图的模板语言。Node.js 不理解如何运行 JSX,因此您需要在使用服务器之前将 JSX 编译成 JavaScript。我将在本章后面讨论 JSX。
图 2.5. 服务器运行后 recipes API 端点的预期输出

注意
我在项目中使用两种文件扩展名而不是 .js。对于用 ES6 编写的文件,我使用 .es6 扩展名来表示需要使用 Babel 编译文件。对于包含 React 组件的文件,我使用 .jsx 扩展名来表示存在 JSX 模板。这使得我们只需将想要传递给 Babel 编译器的文件传递过去,同时也使得区分工作文件和编译文件变得容易。.jsx 扩展名也被一些编辑器和 IDE 识别为使用不同语法高亮的信号。
要构建和运行服务器,您使用在 npm 包中设置的 Babel 工具和配置。还需要两段额外的代码来使这一切工作。首先,要使用 Babel,您需要一个 Babel 配置。最好的方法是创建一个 .babelrc 配置文件。在 .babelrc 中,我已经为编译器指定了两个预设:es2015 和 react。以下列表显示了此代码,该代码已包含在仓库中。
列表 2.1. Babel 配置—babelrc
{
"presets": ["es2015", "react"], *1*
"plugins": [ *2*
"transform-es2015-destructuring", *3*
"transform-es2015-parameters", *3*
"transform-object-rest-spread" *3*
]
}
-
1 包含包含插件组的包,使配置更容易、更快
-
2 插件是 Babel 的基本单位,每个插件负责一种更新类型。
-
3 三个插件允许使用扩展运算符 (...),这样你可以轻松地处理和更新对象。
这里列出的预设与你在本章早期安装的预设包相对应。这将确保 ES6 代码和 JSX 模板代码被正确编译。
注意
babel-cli 和相关工具功能强大且灵活。访问 babeljs.io 了解 Babel 还能做什么。例如,Babel 支持编译文件的 sourcemaps。此外,如果你更喜欢不同的构建工具,你可以使用 Babel 与大多数流行的 JavaScript 构建工具一起使用。
对于其他所需的代码片段,我已经设置了这个项目在服务器上以开发模式使用 Babel 内联。你不需要预先编译任何代码就可以在服务器上运行它。server.js 文件只有两行代码。以下列表显示了代码,它已经包含在仓库中。
列表 2.2. 使用 Babel 运行服务器——src/server.js
require('babel-register'); *1*
require('./app.es6'); *2*
-
1 包含 Babel——它将解析其后的所有代码(不推荐用于生产环境)。
-
2 包含服务器根应用程序代码。
在一切配置和设置完成后,你只需运行以下命令即可启动 Node.js 服务器:
$ npm start
Node.js 服务器现在正在本地主机 3000 端口上运行。加载 http://localhost:3000/recipes,你将看到一个包含多个菜谱的 JSON 对象。示例输出将类似于 图 2.5 中的 JSON 对象。记住,服务器在菜谱应用中扮演两个角色:它渲染初始视图并提供数据 API。
接下来,我们将探讨 webpack 如何使用 Babel 创建浏览器代码。
2.2.3. 使用 webpack 构建浏览器代码
每次我学习一个新的构建工具时,我都会花费数小时感到沮丧,想知道为什么我还在学习另一个可能或可能不会给我带来长期工作流程改进的库。尽管 webpack 有一个陡峭的学习曲线,但我投入学习它的时间是非常值得的。每次我遇到需要用构建脚本完成的新任务时,我都会发现 webpack 可以完成这项工作。此外,它有一个强大的社区,并已成为现代网络应用的首选之一。
Webpack 是一个可以从命令行运行并可通过 JavaScript 配置文件进行配置的构建工具。它支持广泛的功能:
-
使用加载器编译 ES6 和 JSX 代码,并通过加载器加载静态资源
-
智能捆绑代码到更小包的代码拆分
-
能够为服务器或浏览器构建代码
-
开箱即用的 sourcemaps
-
Webpack 开发服务器
-
内置的监视选项
你将使用 webpack 来构建浏览器包。与 Node.js 不同,浏览器对最新版 JavaScript 的支持不一致。为了编写 ES6 代码,我需要将其编译成浏览器可以读取的格式(ES5)。同样,在服务器上,JSX 也必须编译成 JavaScript 编译器可以理解的格式。为此,你将利用 webpack 配置,然后通过上一节中看到的 npm 脚本运行该配置。要运行 webpack 脚本,你还需要运行以下命令:
$ npm start
package.json 包含一个预启动脚本,该脚本运行 webpack 命令。
注意
虽然可以使用 webpack 构建你的 Node.js 服务器,但这将为构建和测试带来挑战,并需要你运行两个 Node.js 服务器。最好只使用 webpack 来构建浏览器代码。
就像在服务器上一样,你将使用 Babel 来编译代码。webpack 配置文件位于项目的顶层,是一个 JavaScript 模块。代码已经包含在仓库中。以下列表解释了它是如何工作的。
列表 2.3. Webpack 配置—webpack.config.js
module.exports = {
entry: "./src/main.jsx", *1*
output: {
path: __dirname + '/src/', *2*
filename: "browser.js" *3*
},
module: {
rules: [
{
test: /\.(jsx|es6)$/, *4*
exclude: /node_modules/, *5*
loader: "babel-loader" *6*
},
]
},
resolve: {
extensions: ['.js', '.jsx', '.css', '.es6'] *7*
}
};
-
1 定义输入起点或入口路径—文件仅在浏览器上运行
-
2 输出文件夹—构建所有其他文件的 dist 目录
-
3 输出文件名
-
4 用于告诉加载器应用加载器到哪些文件的正则表达式
-
5 排除 node_modules,因为这些文件已经编译并准备好用于生产
-
6 定义应用于匹配文件的加载器,以便 Babel 编译 ES6 和 JSX
-
7 支持的文件扩展名—空文件扩展名允许没有扩展名的导入语句(你的源文件有一个扩展名,但编译后的文件有不同的扩展名)
你也可以通过 webpack 包含的文件加载任何所需的 CSS。为此,你需要定义一个加载器来处理包含 .css 扩展名的任何 require 语句。因为我们的应用是同构的,而且你没有使用 webpack 为服务器,所以只包含将在浏览器中加载的文件中的 CSS 很重要。在这种情况下,CSS 包含将在 main.jsx 中:
{test: /\.css$/,loaders: ['style-loader', 'css-loader']}
目前你需要的就是这些。要全面了解 webpack,请确保阅读第五章。
2.3. 视图
本节和以下部分将探讨将应用连接在一起所使用的特定技术。图 2.6 展示了每个部分如何适应应用生命周期。
图 2.6. React 和 Redux 如何适应应用流程

这里的主要收获是应用的生命周期是单向的。任何时间应用状态更新,视图都会收到更新并将其显示给用户(步骤 4)。当视图接收到用户输入时,它会通知应用状态(Redux)进行更新(步骤 2)。视图不关心业务逻辑的实现,应用状态也不关心它将如何显示。
2.3.1. React 和组件
在构建应用程序时,用户界面是最重要的部分。我喜欢用具有出色 UI 的应用程序。在这些应用程序中,用户可以轻松找到他们想要的东西,并且可以无挫折地与应用程序交互。React 使这个过程变得更简单。我发现它的概念很好地映射到我对构建良好 UI 的思考方式。
为了构建食谱应用程序的视图,我将向你展示如何利用 React 实现一个声明性视图,该视图可以在服务器和浏览器上渲染。React 提供了一个渲染周期,它允许你轻松地分离哪些代码将在服务器和浏览器上运行,哪些代码只会在浏览器上运行。此外,React 还提供了在服务器和浏览器上构建 DOM 的内置方法。
首先,让我们谈谈组件的概念。看看图 2.7 中的示例应用程序。你可以把这个整个应用程序写成一块 HTML,但将这个 UX 分解成小的组件是最佳实践。在图中,你可以看到如何将食谱应用程序分解成组件。为了保持简单,我创建了仅三个组件。在一个真实的应用程序中,一个具有许多视图的应用程序,我会创建更小的组件来增加我组合组件的能力。这也减少了代码重复并加快了开发速度。
图 2.7. 食谱应用程序被分解成三个主要组件。通过将它们组合在一起,应用程序被创建。

你使用 React 构建组件的方式是通过编写 JavaScript 模块并在 JSX 中声明你的视图。下一节提供了 JSX 的介绍。
2.3.2. 使用 JSX
React 使用一种名为 JSX 的模板语言。大部分情况下,JSX 看起来和表现就像正常的 HTML,这使得它易于学习和使用。JSX 由 HTML 标签(也可以是额外的 React 组件)和 JavaScript 代码段组成。语法在此处展示:

你可以看到,在引用 JavaScript 的点上,你必须将你的代码包裹在 {} 中。这向编译器表明,括号内的代码是可执行的。JSX 由 Babel 编译成纯 JavaScript。你可以使用基本的 React 函数编写你的组件,但这会更慢且可读性较差。
组件可以显示通过它们的属性传递进来的数据,这些属性称为 props。Props 类似于 HTML 属性,并且可以写在任何 JSX 元素的开始标签中。你可以在第三章中找到更多信息,该章节涵盖了 JSX 和 React 的属性和状态。
食谱应用程序有四个 React 组件:渲染 HTML 包装器的组件(仅在服务器上使用),应用程序包装组件(React 树的根),以及两个名为 Featured 和 Recipes 的视图组件。
2.3.3. 应用程序包装组件
首先,我们将查看 main.jsx 和 app.jsx 以获取应用程序设置的根。如果您想在本节中跟随,可以将分支切换到 react-components 分支(git checkout react-components)。章节的起始分支提供了一个骨架示例,您将添加代码列表。如果您想查看本节的完整代码,可以将分支切换到 react-components-complete 分支(git checkout react-components-complete)。
要在浏览器中渲染组件,您需要在 main.jsx 代码中设置 React。以下列表显示了您需要添加的内容以使组件在浏览器中渲染。将代码添加到 src/main.jsx 中。
列表 2.4. 浏览器入口点—src/main.jsx
import React from 'react'; *1*
import ReactDOM from 'react-dom'; *1*
import App from './components/app.jsx'; *2*
require('./style.css'); *3*
ReactDOM.render( *4*
<App />, *4*
document.getElementById('react-content') *4*
);
-
1 导入 React 依赖项
-
2 包含根 App 组件
-
3 包含样式
-
4 将 App 组件渲染到 DOM 中—第二个参数指示 React 应渲染到的 DOM 元素
App 是一个容器组件。它了解其子组件所需的企业规则和数据。更重要的是,它了解应用程序的状态。在这种情况下,这意味着它将在本章的后面连接到 Redux。以下列表显示了 App 组件。将代码库中的占位符代码(在 src/components/app.jsx 中)替换为列表中的代码。
列表 2.5. App(顶级组件)—src/components/app.jsx
import React from 'react';
import Recipes from './recipes';
import Featured from './featured';
class App extends React.Component { *1*
render() { *2*
return (
<div>
<div className="ui fixed inverted menu">
<div className="ui container">
<a href="/" className="header item">
Recipes Example App
</a>
</div>
</div>
<div className="ui grid">
<Recipes {...this.props}/> *3*
<Featured {...this.props.featuredRecipe}/> *3*
</div>
<div className="ui inverted vertical footer segment">
Footer
</div>
</div>
);
}
}
export default App;
-
1 要声明一个使用状态的 React 组件,创建一个扩展基本组件类的类。
-
2 每个具有渲染函数的组件必须返回 null 或有效的 JSX。
-
3 组件定义其子组件的布局。
App 组件渲染页眉和页脚,但它还包括两个额外的 React 组件,它将它们作为子组件包含。Recipes 显示从/recipes 端点返回的食谱列表。Featured 仅显示从服务器通过/featured 返回的特色食谱。这些子组件需要从父组件获取信息,这些信息以属性的形式传递下来。
正在传递的数据来自 API,并由 Redux 获取并存储在应用程序状态中。在添加 app.jsx 代码后运行npm start,您将在 http://localhost:3000/index.html 看到页眉、页脚以及一些用于食谱和特色的占位符字符串。
2.3.4. 构建子组件
两个子组件显示它们接收到的属性。它们对应用程序的其他部分,如 Redux,没有任何意识。这使得它们可重用,并且与应用程序中的业务逻辑松散耦合。以下列表显示了特色食谱组件。将此代码添加到 src/components/featured.jsx 中,替换占位符代码。
列表 2.6. 特色组件—src/components/featured.jsx
import React from 'react';
const Featured = (props) => {
const buildIngredients = (ingredients) => { *1*
const list = [];
ingredients.forEach((ingredient, index) => {
list.push(
<li className="item"
key={`${ingredient}-${index}`}>
{ingredient}
</li>
);
});
return list;
}
const buildSteps = (steps) => { *2*
const list = [];
steps.forEach((step, index) => {
list.push(
<li className="item"
key={`${step}-${index}`}>
{step}
</li>
);
});
return list;
}
return (
<div className="featured ui container segment six wide column">
<div className="ui large image">
<img src={`http://localhost:3000/assets/${props.thumbnail}`} />
</div> *3*
<h3>{props.title}</h3> *3*
<div className="meta">
Cook Time: {props.cookTime}
</div> *3*
<div className="meta">
Difficulty: {props.difficulty}
</div> *3*
<div className="meta">
Servings: {props.servings}
</div> *3*
<div className="meta">
Tags: {props.labels.join(', ')}
</div> *3*
<h4>Ingredients</h4>
<div className="ui bulleted list">
{buildIngredients(props.ingredients)} *4*
</div>
<h4>Steps</h4>
<div className="ui ordered list">
{buildSteps(props.steps)} *5*
</div>
</div>
);
}
Featured.defaultProps = { *6*
labels: [],
ingredients: [],
steps: []
}
export default Featured;
-
1 函数将配料数组转换为列表项数组;这从渲染函数中调用。
-
2 函数接收步骤数组并将其转换为列表项,从渲染函数中调用。
-
3 Featured 组件是一个容器,用于渲染关于特色食谱的信息;它通过 props 渲染传入的食谱。
-
4 函数将配料数组转换为列表项数组;这从渲染函数中调用。
-
5 函数接受步骤数组并将其转换为列表项,从渲染函数中调用。
-
6 将属性设置为默认值,以便在没有数据的情况下,组件仍然可以渲染。
添加此代码后,您将看到显示的特色食谱,但没有数据(您还没有将其连接到任何数据)。要显示完整的首页,还需要进行一个步骤:添加 Recipes 组件代码。
下一个 Recipes 组件处理比 Featured 更复杂的数据。它在这一点上与 Featured 类似,因为它只显示食谱数据,并且没有意识到应用程序的其他部分。以下列表显示了 Recipes 列表组件。您需要将此代码添加到 src/components/recipes.jsx 中,以替换占位符代码。
列表 2.7. Recipes 组件——src/components/recipes.jsx
import React from 'react';
const Recipes = (props) => {
const renderRecipeItems = () => { *1*
let items = [];
if (!props.recipes) {
return items;
}
props.recipes.forEach((item, index) => { *2*
if (!item.featured) {
items.push(
<div key={item.title+index} className="item"> *3*
<div className="ui small image"><img src="" /></div>
<div className="content">
<div className="header">{item.title}</div>
<div className="meta">
<span className="time">{item.cookTime}</span>
<span className="servings">{item.servings}</span>
<span className="difficulty">{item.difficulty}</span>
</div>
<div className="description">{item.labels.join(' ')}</div>
</div>
</div>
)
}
});
return items;
}
return (
<div className="recipes ui items six wide column"> *4*
{renderRecipeItems()}
</div>
);
}
export default Recipes;
-
1 从 JSX 返回语句中调用的函数
-
2 因为不能直接在 JSX 中编写循环,所以构建一个可以由 JSX 渲染的项目数组。
-
3 每个食谱项在这里渲染;食谱数据通过 props 传递下来。
-
4 渲染函数是食谱列表的包装器,没有状态,就像 featured 组件一样。
在这两个组件中,属性是从父组件传入的。这些组件只有在它们的父组件收到更新时才会更新。这与本节开头讨论的单向流有关。顶级组件从应用程序状态接收更新后,可以将这些更改传递给它们的子组件。因为应用程序中没有数据,所以您在这个阶段不会看到视觉上的变化——没有要渲染的食谱!
2.3.5. HTML 容器
最终的 React 组件是服务器用来渲染完整 HTML 标记的组件。它主要是标准的 HTML 标签,但有几个地方可以插入渲染的标记和数据。以下列表显示了完整的 HTML 组件。将此代码添加到 src/components/html.jsx 中,以便在服务器上有一个渲染的容器。
列表 2.8. 服务器端仅使用的 HTML 模板组件——src/components/html.jsx
import React from 'react';
export default class HTML extends React.Component {
render() {
return (
<html> *1*
<head> *1*
<title>Chapter 2 - Recipes</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/semantic-ui/2.2.2/
semantic.min.css" /> *2*
</head>
<body> *1*
<div id="react-content"
dangerouslySetInnerHTML={{
__html: this.props.html
}}/> *3*
<script
dangerouslySetInnerHTML={{
__html: this.props.data
}}/> *4*
<script src="/browser.js"/>
</body>
</html>
);
}
}
-
1 组件创建一个有效的 HTML 页面——包括 html、head、body 标签。
-
2 从 head 标签引用的 CSS。
-
3 组件接收作为 prop 的渲染 HTML。
-
4 这里传入的 prop 是数据,一个表示服务器上应用程序当前状态的字符串化 JSON 对象——否则服务器到浏览器的传递就无法发生。
dangerouslySetInnerHTML 被使用,因为它是一个预渲染的字符串。通常,你无法在 React 组件中放置 HTML。这个特殊属性允许你绕过这个限制。它被这样命名是为了提醒在使用组件中的 HTML 时要谨慎且有意。
现在应用的所有 React 组件都已创建,你将设置食谱应用的业务逻辑。
2.4. 应用状态:Redux
在本节中,我将向你展示如何使用 Redux 为食谱应用构建业务逻辑。由于食谱应用非常简单,所以它没有太多的用户交互。但 Redux 仍然负责获取应用的数据。第六章全面介绍了 Redux,包括处理用户交互。
2.4.1. 理解 Redux
Redux 的流程大致遵循 Facebook 最初定义的 Flux 架构流程。所有对应用状态的所有更新都是单向的。当请求更改时,它由业务逻辑(动作)处理,更新到应用状态(reducer),最后作为应用状态的新副本的一部分返回到视图。图 2.8 展示了这是如何工作的。
图 2.8. Redux 概述:用户交互到商店更新到视图更新的流程

商店
Redux 基于整个应用的单个根状态对象的概念,通常称为商店。这个状态可以是多个深度嵌套对象的组合。由于食谱应用很简单,所以它将只有一个名为recipes的根对象。
商店是不可变的,这意味着对状态对象的更改总是返回一个新的状态,而不是修改现有的状态。我喜欢把它看作是应用的模式,数据存储在这里。
定义
不可变对象是只读的。要更新不可变对象,你需要克隆它。在 JavaScript 中,当你改变一个对象时,它会影响到对该对象的全部引用。可变更改可能会有意外的副作用。通过在商店中强制执行不可变性,你可以在应用中防止这种情况发生。
动作
要更新状态,你需要派发动作。动作是大多数业务逻辑发生的地方。我喜欢把动作看作是应用的控制器。
动作可以是应用中的任何东西。动作可以用来获取数据(例如getRecipes或getFeatured)。它们也可以用来更新应用状态——例如,跟踪添加到购物车的项目。将这些动作视为描述单个状态更新的离散消息。动作默认是同步的,但我们可以向 Redux 中包含中间件,以允许异步动作。
动作(通常是 JavaScript 对象)通常被封装在动作创建者中,这些是返回或派发动作的 JavaScript 函数。它们是辅助方法,通过集中创建动作对象,使代码更具可重用性。
Reducers
动作由减少器处理。一个 减少器 接收来自动作的输入,包括从服务器或 API 异步获取的任何数据,并将其插入到存储库的正确位置。减少器负责强制执行状态对象的不可变要求。通过使用减少器,动作和存储库解耦,这为应用程序提供了更大的灵活性。
我将带您了解如何设置 Redux 以及添加动作、减少器和使 Redux 和 React 一起工作的代码。
2.4.2. 动作:获取食谱数据
首先,我们将查看您需要获取以填充视图的食谱数据。对于这个单页应用,您只需要异步动作。要了解更多关于动作和动作创建者的信息,请参阅第六章(kindle_split_017_split_000.xhtml#ch06)以获取完整解释。如果您想查看本节的代码,请切换到 redux 分支(git checkout redux)。要查看本节所有代码的最终工作形式,请查看 redux-complete(git checkout redux-complete)。
在食谱应用的 action-creators 文件中,您将添加两个动作创建者。一个将获取所有食谱的列表,另一个将获取特色食谱。列表 2.9 显示了动作的实现。将此列表中的代码添加到 src/action-creators.es6 文件中。
注意
我包括了一个名为 isomorphic-fetch 的库,以帮助进行 XHR 调用。它为 Node.js 和浏览器提供了 fetch API 的实现。您可以在 developer.mozilla.org/en-US/docs/Web/API/Fetch_API 和 github.com/matthew-andrews/isomorphic-fetch 找到更多信息和相关文档。
列表 2.9. 食谱和特色数据的动作创建者—src/action-creators.es6
export const GET_RECIPES = 'GET_RECIPES'; *1*
export const GET_FEATURED_RECIPE = 'GET_FEATURED_RECIPE'; *1*
export function fetchRecipes() { *2*
return dispatch => {
return fetch('http://localhost:3000/recipes', { *3*
method: 'GET'
}).then((response) => {
return response.json().then((data) => { *4*
return dispatch({ *5*
type: GET_RECIPES, *6*
data: data.recipes *7*
});
});
})
}
}
export function fetchFeaturedRecipe() { *8*
return dispatch => {
return fetch('http://localhost:3000/featured', { *3*
method: 'GET'
}).then((response) => {
return response.json().then((data) => { *4*
return dispatch({ *9*
type: GET_FEATURED_RECIPE, *6*
data: data.recipe *10*
});
});
})
}
}
export function getHomePageData() { *11*
return (dispatch, getState) => {
return Promise.all([
dispatch(fetchFeaturedRecipe()),
dispatch(fetchRecipes())
])
}
}
-
1 最佳实践是为所有动作创建常量,以便动作创建者(此处列出的函数)和减少器可以使用它们。这样,您就不会在字符串之间出现差异。
-
2 fetchRecipes,第一个动作创建者,处理向服务器请求食谱数据的逻辑。
-
3 实现用于向适当端点发起 GET 请求的 fetch API。
-
4 在成功响应中,从响应中获取 JSON 数据—使用 promise 获取 JSON 响应是 fetch API 的标准做法。
-
5 分发动作。
-
6 类型是每个动作的唯一必需属性,使用模块顶部声明的字符串常量设置它。
-
7 在名为 data 的属性上附加 JSON 数据到动作负载。
-
8 第二个动作创建者处理从服务器请求特色食谱的逻辑。
-
9 分发动作。
-
10 在名为 data 的属性上附加 JSON 数据到动作负载。
-
11 此动作创建者组合了其他两个动作创建者—使视图和服务器请求相关数据更容易。
这些动作本身不会做任何事情。它们只负责确定应用状态中将要更新什么。然后它们将动作发送给减数。减数接收来自fetchRecipes和fetchFeaturedRecipe的动作创建者中的对象。它们返回一个新的存储副本(将状态作为不可变对象维护),并带有更新后的数据。图 2.9 展示了这个流程。
图 2.9. 分发动作触发在减数中的查找,然后更新存储。

以下列表显示了应用中的食谱减数。它还演示了如何保持应用状态不可变。将此代码添加到 src/recipe-reducer.es6 中。
列表 2.10. 减数—src/recipe-reducer.es6
import {
GET_RECIPES,
GET_FEATURED_RECIPE
} from './action-creators'; *1*
export default function recipes(state = {}, action) { *2*
switch (action.type) { *3*
case GET_RECIPES:
return {
...state, *4*
recipes: action.data *5*
};
case GET_FEATURED_RECIPE:
return {
...state, *4*
featuredRecipe: action.data *5*
};
default:
return state *6*
}
}
-
1 包含来自动作创建者的常量。
-
2 减数(reducer)是一个 JavaScript 函数,它接收当前状态和一个动作。
-
3 建议使用 switch 语句,因为许多减数最终会有超过 4 个 case 来处理——这个 switch 使用一个 type 属性来确定如何处理每个动作。
-
4 使用扩展运算符克隆状态对象以保持存储不可变。
-
5 使用动作中的数据覆盖当前状态,以便新的应用状态是带有修改后数据的旧应用状态。
-
6 如果减数被触发但没有匹配的 case,则返回当前存储状态——不需要更改,不需要创建新对象。
现在你有了动作创建者和减数,你需要初始化和配置 Redux。因为浏览器和服务器都将初始化 Redux,所以你将代码抽象到一个名为 init-redux 的模块中。你将以下列表中的代码添加到 src/init-redux.es6 中。
列表 2.11. 使用initialState启动 Redux—src/init-redux.es6
import {
createStore, *1*
combineReducers, *1*
applyMiddleware, *1*
compose } from 'redux'; *1*
import recipes from './recipe-reducer'; *2*
import thunkMiddleware from 'redux-thunk'; *3*
export default function () {
const reducer = combineReducers({ *4*
recipes *2*
});
let middleware = [thunkMiddleware]; *3*
return compose( *5*
applyMiddleware(...middleware) *5*
)(createStore)(reducer); *5*
}
-
1 包含用于创建 Redux 存储的 Redux 函数。
-
2 包含你之前创建的食谱减数。
-
3 包含 Thunk 中间件。它允许你编写可以分发额外动作并使用 promise 的动作创建者。
-
4 使用
combineReducers函数创建一个根减数(在更大的应用中,你将有许多减数)。 -
5 使用从 Redux 导入的函数初始化存储并传入中间件选项——
compose从右到左组合函数。
Redux 已经完全配置好了,但视图仍然无法访问数据。下一节将介绍如何连接 React 和 Redux。
2.4.3. React 和 Redux
在你能够正确地将 React 和 Redux 结合起来并准备好浏览器代码之前,你还需要进行几个步骤。你将使用一个名为 react-redux 的 npm 包来将你的 React 组件连接到 Redux。这个包提供了一个名为 Provider 的 React 组件,你用它来包裹你所有的其他 React 组件。这些被包裹的组件可以使用库中的另一个组件,称为 connect,来选择性地订阅 Redux 存储的更新。以下列表显示了如何在浏览器入口点文件中包含 Provider。使用加粗的代码更新 src/main.jsx。
列表 2.12. Redux 和 React 设置—src/main.jsx
import { Provider } from 'react-redux'; *1*
import initRedux from './init-redux.es6'; *2*
require('./style.css');
const store = initRedux(); *3*
ReactDOM.render(
<Provider store={store}> *4*
<App />
</Provider>,
document.getElementById('react-content')
);
-
1 在应用中包含设置 Redux 存储的 Provider 组件,以便你可以在组件中使用 connect 包装器。
-
2 包含你刚刚添加的初始化 Redux 的模块。
-
3 调用 initRedux 以设置 Redux 存储。
-
4 使用 react-redux Provider 组件包裹根应用组件,并将新创建的存储传递给 Provider 组件。
Provider 组件充当有状态的顶级组件。它知道存储何时更新,并将这些更改传递给其子组件。单个组件也可以根据需要订阅存储。以下列表显示了添加到根组件(src/components/app.jsx)的代码,以便它成为一个连接到 Redux 的组件。
列表 2.13. 将应用组件连接到 Redux—src/components/app.jsx
import React from 'react';
import { connect } from 'react-redux'; *1*
import { bindActionCreators } from 'redux'; *1*
import Recipes from './recipes';
import Featured from './featured';
import * as actionCreators from '../action-creators'; *1*
class App extends React.Component {
componentDidMount() {
this.props.actions.getHomePageData(); *2*
}
render() {}
}
function mapStateToProps(state) { *3*
let { recipes, featuredRecipe } = state.recipes; *4*
return { *5*
recipes,
featuredRecipe
}
}
function mapDispatchToProps(dispatch) { *6*
return { actions: bindActionCreators(actionCreators, dispatch) }
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(App) *7*
-
1 导入你之前添加的 Redux 依赖和动作创建者。
-
2 将组件配置为使用 Redux 后,你可以从视图中分发动作。
-
3 函数让你将应用状态转换为组件上的属性。
-
4 此组件需要从服务器获取数据,因此你需要从当前应用状态中获取 recipes 和 featuredRecipe 对象。
-
5 返回你希望在组件的 this.props 中直接访问的值。
-
6 函数让你使动作更简单易从组件中调用——而不是每次都调用 dispatch(action),视图可以调用动作而不必了解 dispatch。
-
7 而不是导出 App 组件,导出 connect 组件,它接受两个辅助函数和 App 组件作为参数。
Connect 允许你从需要了解如何显示数据和从哪里获取数据的组件中提取应用状态。现在 App 组件可以访问所有需要的属性来使视图工作。在这个时候,如果你重新启动应用,视图将填充数据!接下来,我们将介绍服务器代码。
2.5. 服务器渲染
现在你已经设置了视图和业务逻辑,是时候查看如何服务器端渲染主页了。你将为主页添加一个单独的路由。这并不非常“现实世界”——第七章介绍了一种更健壮的处理服务器的方法,包括在服务器上使用 React Router。
如果你正在跟随并想查看到目前为止的代码,你可以切换到 server-browser-rendering 分支 (git checkout server-browser-rendering)。注意,在本节中,你将不再加载 index.html。相反,加载 http://localhost:3000 的 app。
2.5.1. 在服务器上设置基本路由并使用中间件
此路由将使用 Express 中间件来处理和渲染请求。中间件还将获取必要的数据。
定义
Express 中间件 由链式函数组成,每个函数执行单一任务。中间件可以通过发送响应来终止请求,也可以转换请求并执行其他业务逻辑,包括错误处理。
列表 2.14 中的代码行需要添加到 src/app.es6 中。此代码添加了对根路由的处理程序。确保你添加它,以便服务器渲染能够工作。(我已经为你添加了其他代码,以便在所有其他示例中数据端点都能工作。)
列表 2.14. 设置根路由—src/app.es6
import renderViewMiddleware
from './middleware/renderView'; *1*
app.get('/featured', (req, res) => {});
// handle the isomorphic page render
app.get('/', renderViewMiddleware); *1*
// start the app
app.listen(3000, () => {
console.log('App listening on port: 3000');
});
- 1 添加 Express 路由以使用 renderViewMiddleware 获取主页。
2.5.2. 获取数据
接下来,让我们看看 renderViewMiddleware 并了解它是如何获取数据和渲染视图的。记住,在 recipes 应用中你只有一个路由,所以你可以假设需要分发哪个 Redux 动作。以下列表显示了渲染视图的中间件是如何工作的。将 src/middleware/renderView.jsx 中的代码替换为以下代码。
列表 2.15. 同构视图中间件数据获取—src/middleware/renderView.jsx
import initRedux from '../init-redux';
import * as actions from '../action-creators';
export default function renderView(req, res, next) { *1*
const store = initRedux(); *2*
store.dispatch(actions.getHomePageData()) *3*
.then(() => {
console.log(store.getState());
res.send("It worked!!!");
});
}
-
1 中间件函数定义—express 中间件接收一个请求对象、一个响应对象以及用于将控制传递给链中下一个中间件的回调函数。
-
2 设置 Redux 减法器和组合存储—在服务器上,它从一个空存储开始。
-
3 分发所需动作并在继续渲染之前等待其解析。
在这一点上,如果你运行 npm start 并在 http://localhost:3000 加载应用,你会得到一条消息:“成功了!!!”。在终端输出中,你应该看到当前状态,包括食谱数组和特色食谱。你已经设置了数据获取,但仍然需要渲染视图。下一节将介绍如何将 React 服务器渲染代码添加到 renderView.jsx 中。
2.5.3. 渲染视图和序列化/注入数据
对于这个单一路由,渲染逻辑很简单。奇怪的一点是,你最终在服务器上执行了两次 React 渲染。当我最初开始构建同构应用时,我们使用不同的服务器端模板语言来构建 index HTML。但这有很多缺点,包括每个团队成员在理解完整的渲染流程之前都需要掌握额外的知识。然后我们转向将路由的组件渲染到一个代表完整页面标记的 React 组件中。少了一个需要掌握的技能!
这是为了消除在服务器上使用另一个视图模板语言的需求。以下列表展示了如何实现渲染逻辑。将粗体代码添加到 renderView 中间件。
列表 2.16. 同构视图中间件视图渲染—src/middleware/renderView.jsx
import React from 'react';
import ReactDOM from 'react-dom/server';
import { Provider } from 'react-redux';
import initRedux from '../init-redux';
import * as actions from '../action-creators';
import HTML from '../components/html';
import App from '../components/app';
export default function renderView(req, res, next) {
const store = initRedux();
store.dispatch(actions.getHomePageData())
.then(() => {
let html;
const dataToSerialize = store.getState(); *1*
html = ReactDOM.renderToString( *2*
<Provider store={store}>
<App />
</Provider>
);
const renderedHTML = ReactDOM.renderToString( *3*
<HTML data={`window.__INITIAL_STATE =
${JSON.stringify(dataToSerialize)}`}
html={html} />
)
res.send(renderedHTML)
});
}
-
1 序列化数据,以便你可以将状态传递到浏览器。
-
2 通过渲染 app.jsx 并注入你在上一步中获取的数据来渲染组件。
-
3 通过渲染 html.jsx 并使用之前渲染的组件和序列化数据来渲染完整的 HTML 页面。
在视图渲染过程中必须发生的另一个关键逻辑是获取与应用状态附加到 DOM 响应。你可以在列表中的代码中看到这一点——这是必要的,以便浏览器可以使用与服务器上相同的精确应用状态进行初始渲染。
2.6. 浏览器渲染
浏览器渲染的代码是整个同构应用流程中最直接的部分之一,但同时也是最重要的部分之一,需要正确实现。如果你不在与服务器相同的状态下渲染应用,你会破坏同构渲染并毁掉你获得的所有性能提升。
2.6.1. 反序列化数据并使 DOM 充水
服务器已经完成了所有艰苦的工作,将数据传输到浏览器。为了获取这些数据,浏览器只需要指向服务器通过脚本标签设置的窗口对象。你可以在 main.jsx 中这样做。将下一列表中的代码添加到 main.jsx 中。
列表 2.17. 为 main.jsx 添加代码—src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/app.jsx';
import { Provider } from 'react-redux'; *1*
import initRedux from './init-redux.es6'; *2*
require('./style.css');
console.log("Browser packed file loaded");
const initialState = window.__INITIAL_STATE; *3*
const store = initRedux(initialState); *4*
console.log("Data to hydrate with", initialState);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('react-content')
);
-
1 包含 Provider 组件,它将成为根组件,就像在服务器上一样。
-
2 包含 Redux 初始化模块。
-
3 从窗口对象中获取服务器序列化的状态。
-
4 在服务器上启动 Redux 时,不要使用空的初始状态,而是将服务器数据传递到 Redux 设置中。
然后,在 initRedux 函数内部,使用从服务器获取的数据。列表 2.18 展示了 Redux 的配置以及如何将 initialStore 传递给它。你需要在 init-redux 文件中添加以下代码。
列表 2.18. 使用 initialState 启动 Redux—src/init-redux.es6
import {
createStore,
combineReducers,
applyMiddleware,
compose } from 'redux';
import recipes from './recipe-reducer';
import thunkMiddleware from 'redux-thunk';
export default function (initialStore={}) { *1*
const reducer = combineReducers({
recipes
});
let middleware = [thunkMiddleware];
return compose(
applyMiddleware(thunkMiddleware)
)(createStore)(reducer, initialStore); *2*
}
-
1
initialStore具有从 main.jsx 传递的值(如果没有传递,则默认为空对象)。 -
2
initialStore值被传递到 Redux createStore 函数中——现在存储已通过服务器数据充水。
现在,应用已经准备好监听用户交互,并且可以在不与服务器通信的情况下持续更新(SPA 流)。对于食谱应用,如果你想要扩展功能并添加食谱的详细页面,当用户从主页点击详细页面时,服务器不会参与加载详细页面。在 GitHub 仓库中,你可以在 server-browser-rendering-complete 或 master 分支中看到完整的应用。
摘要
在本章中,你学习了如何构建一个完整的同构应用。恭喜你——通过构建这个示例,你已经覆盖了大量的内容!接下来的几章将更深入地探讨同构应用的各个部分。在本章中,你学习了以下内容:
-
Babel 和 webpack 允许将 JavaScript 代码编译。Webpack 允许在浏览器代码中使用 npm 包。
-
React 组件构成了应用的可视部分。使用 JSX 来声明组件。
-
Redux 在同构应用中充当控制器和模型的角色。
-
Node.js 服务器使用 Express 中间件来响应请求。对于同构应用,需要自定义中间件来渲染 React。此中间件还会发送应用程序的初始序列化状态。
-
浏览器使用一个单独的入口点来加载初始状态并启动应用。
第二部分 同构应用程序基础知识
如果你在过去几年里在前端网络世界中花费了很多时间,你可能已经体验过“JavaScript 疲劳”。这种不适是由于不断有库、工具和新想法需要学习。接触到这个信息流可能会变得令人不知所措。
本书这一部分概述了构建一个在生产环境中运行的最佳实践 React 应用程序所需的每个库和工具。所需的构建块包括 React、React Router、webpack、Babel 和 Redux。本部分中的四章深入探讨了这些主题,并使你专注于当前可用的、对应用程序开发重要的 JavaScript 工具和库的小部分。
在第三章中,你将学习 React,从 JSX 和虚拟 DOM 的基础开始,然后学习如何使用属性和状态来创建 React 组件。在第四章中,你将学习如何使用 React Router 向你的应用程序添加多个路由。该章节还介绍了 React 组件的生命周期,并涵盖了高级 React 概念,包括组件组合和高阶组件。(已添加三个附录,帮助你学习 React Router 4。)这是第一个让你为 All Things Westies 应用程序编写代码的章节。在第五章中,你将了解所有关于构建工具:webpack 和 Babel 的内容。在第六章中,我们将深入探讨 Redux——你将学习编写动作、创建还原器和使用存储。
第三章 React 概述
本章涵盖
-
虚拟 DOM 的工作原理
-
React 的函数式特性
-
使用 JSX 声明 React 组件
-
使用 React 状态处理用户交互
React 是一个用于创建用户界面的库,由 Facebook 发明并使用。在过去的几年里,React 生态系统迅速扩展。现在,可以在许多类型的应用程序和架构中使用 React。本章将教你构建同构应用程序所需了解的 React 知识。我将首先教你一些 React 基础知识。下一章将涵盖 React 组件模式并介绍 React Router。
注意
在开始本章之前,我建议安装 Chrome 或 Firefox 的 React 开发者工具。这将使调试你的 React 代码变得容易得多。我在本章的示例中使用 React 15.6.1。第九章包括对 React 开发者工具的详细解释。
要全面了解 React,你可以探索关于该主题的许多书籍,例如 Azat Mardan 的《React Quickly》(Manning,2017)。如果你已经具备使用 React 的经验,可以自由跳过本章。
关于本章使用的代码和库的说明:您可以从 GitHub 仓库 github.com/isomorphic-dev-js/chapter3-react-overview 下载代码。要运行示例,请参阅 README.md。为确保一切设置正确,请运行以下命令:
$ npm install
$ npm install -g @kadira/storybook
$ npm run storybook
示例运行在 http://localhost:9001。此仓库按章节和示例编号组织成文件夹。对于大多数部分,您将参考 components/Chapter_3_X 文件夹。
注意
在发布时,本章中的代码只能在 React 15 下运行。但 Storybook 将在某个时候升级。当 Storybook 与 React 16 兼容时,您可以选择在本章的示例中使用 React 16。
3.1. React 概述
让我们一步步设置一个加载 React 并渲染单个 React 组件的 HTML 页面。本节的代码可以在 GitHub 仓库的 html/Chapter_3_1/ 路径下找到。
React 替换了 Web 应用中的视图。它提供了一个简单且易于启动的 API,但设计用于组合,以促进构建复杂用户界面。React 在构建大量小型组件并将它们组合在一起以创建完整 UI 时表现最佳。
这里是使用 React 在静态 HTML 页面上运行的基本步骤。如果您熟悉 React 渲染,可以跳到下一节:
1. 设置一个 HTML 页面
2. 包含 React 库
3. 在页面中包含一个渲染 React 组件的脚本
要开始,您将使用 React 渲染“Hello World”。这次渲染将只将字符串“我的第一个 React 组件”输出到浏览器。您可以在图 3.1 中看到这一点。
图 3.1. 当加载到浏览器中的基本 React 组件的输出

首先,您需要一个 index.html。此 HTML 文件,如列表 3.1 所示,将包含所有必需的 React 依赖项以及创建 React 组件并将其放置在 DOM 上的代码。对于这个第一个示例,HTML 页面尽可能简单。要查看图 3.1 的输出,请在浏览器中打开 index.html 文件。
列表 3.1. 第一次 React 渲染—html/Chapter_3_1/index.html
<html>
<head>
<title>React render example</title>
<script
src="https://npmcdn.com/react@15.3.1/dist/react.js">
</script> *1*
<script src="https://npmcdn.com/react-dom@15.3.1/dist/react-dom.js"> *1*
</script> *1*
</head>
<body>
<div id="render-react-into-me"></div> *2*
<script type="text/javascript"> *3*
ReactDOM.render( *4*
React.createElement(
div,
null,
"My first React component"
), *5*
document.getElementById('#render-react-into-me') *6*
);
</script>
</body>
</html>
-
1 React 和 React DOM 依赖项,为简单起见从 CDN 加载。
-
2 div 是 React 代码将被渲染的地方。
-
3 脚本标签包含将 React 元素渲染到 DOM 中的代码。
-
4 使用两个参数调用 ReactDOM 的 render 方法:要渲染的 React 元素和要附加到的 DOM 节点。
-
5 调用 React.createElement 创建包含文本的 div。
-
6 React 应该附加渲染输出的 HTML 元素
index.html 中的 JavaScript 将单个 React 元素渲染到中的空
React.createElement来自核心库(react.js),它创建一个 React 元素。ReactDOM.render来自 React DOM 库(react-dom.js),它接受由React.createElement创建的 React 元素,并将其渲染到 DOM 中。
那个例子很简单,所以让我们看看一个稍微复杂一点的例子。在列表 3.2 中,
图 3.2. 使用 React 渲染多个 HTML 标签

要渲染此按钮,你需要将文本替换为一个新的 React 元素。以下列表显示了如何更新脚本标签以渲染按钮。
列表 3.2. 渲染多个 HTML 标签—html/Chapter_3_1/button.html
<script type="text/javascript">
ReactDOM.render(
React.createElement("div", null,
React.createElement(
"button",
null,
"My First Button"
) *1*
),
document.getElementById('render-react-into-me')
);
</script>
- 1 你可以将文本放入中,而不是直接放入文本,你可以嵌套 React 元素——在这里你创建了一个带有文本的 HTML 按钮。
此按钮示例显示了如何嵌套 HTML 元素并使用 React 的声明式风格来声明你的应用程序结构。但 React 真正的力量在于其能够根据元素的变化更新 DOM。接下来,我将向您介绍虚拟 DOM。
3.2. 虚拟 DOM
在我讨论虚拟 DOM 之前,我想确保你对浏览器 DOM(DOM代表文档对象模型)有一个清晰的了解。DOM 是网页的标记表示。浏览器解释 DOM 以确定如何渲染网页。你可以将其视为浏览器读取以确定如何构建你的页面的计划或地图。例如,上一节中按钮示例中嵌套元素的渲染输出如下所示。
列表 3.3. 按钮示例的 DOM 标记
<div id="render-react-into-me"> *1*
<div data-reactroot=""> *2*
<button>My First Button</button> *3*
</div>
</div>
-
1 React 渲染的 div 占位符
-
2 根组件
-
3 由 React.createElement 创建的按钮
这段代码为浏览器生成一个蓝图,指示它渲染两个包含按钮的 div。结合一点 CSS,它会在图 3.2 中生成按钮。
虚拟 DOM 是 DOM 的一个轻量级表示,可以快速遍历和更新。它是 DOM 的 JavaScript 表示。对于按钮示例,React 在 JavaScript 中保留组件结构的版本。
传统上,操作 DOM 很慢。当需要做出更改时,整个页面都必须遍历,然后插入更新并重新渲染。想象一下,你想要更新 HTML 中的项目列表。你必须找到列表,遍历它,并根据需要做出更新和插入。随着你的应用程序增长,你拥有更多项目和更多列表,这个过程会变得更慢。虚拟 DOM 应运而生。
图 3.3 展示了 React 应用的启动流程。当初始 JavaScript 运行时,React 从你的应用中的 React 组件生成基本虚拟 DOM。然后 React 将 DOM 树附加到浏览器 DOM 上。
图 3.3. 应用启动时 React 的虚拟 DOM

React 将这个虚拟 DOM 与之前的状态和更新后的状态进行比较。React 比较虚拟 DOM 的旧版本和新版本,并计算任何变化。然后它使用针对 Web 应用优化的算法来确定在 DOM 中哪里需要更新。图 3.4 说明了这个过程。
图 3.4. React 为了保持 DOM 更新而经历的持续更新和 diff 周期

最终,React 只更新绝对必要的部分,以便内部状态(虚拟 DOM)和视图(真实 DOM)保持一致。例如,如果有一个<p>元素,你通过组件的状态增加文本,只有文本会被更新(innerHTML),元素本身不会改变。这比重新渲染整个元素集或,更不用说整个页面(服务器端渲染)的性能有所提高。
在图 3.5 中,你可以看到一个更新发生时的示例。React 查看组件树,并确定哪些部分需要更新。然后它智能地更新浏览器 DOM 以匹配当前应用的状态。在这种情况下,一个列表项被移除,一个列表项被添加。第三个列表项保持不变。React 能够对这些更改进行优化并快速更新 DOM。
图 3.5. React 根据状态变化运行的 diff 算法更新浏览器 DOM

注意
如果你想了解更多关于虚拟 DOM 的信息,你可以在网上找到额外的资源。Codecademy 有一个很好的概述(www.codecademy.com/articles/react-virtual-dom),而 Hackernoon 对这个主题进行了深入探讨(hackernoon.com/virtual-dom-in-reactjs-43a3fdb1d130)。
3.3. Todo 应用概述
在本节中,你将构建一个 Todo 应用。这个单页应用(SPA)将使用户能够存储待办事项列表,标记它们为完成,并查看还有哪些待办事项。图 3.6 展示了这个应用。为了让你专注于学习 React,这不是一个同构应用。
图 3.6. 你在本章中将要构建的 Todo 应用的线框图

第五章展示了如何为同构应用设置 webpack 和 ES6。在此同时,我将在本章中使用一个名为 Storybook 的库来帮助你开始 React 代码的学习。我希望你专注于 React——Storybook 允许你以最小的设置开始。如果你想了解更多关于它是如何工作的,你可以查看仓库中的 stories 文件夹以及附近的侧边栏。
使用 Storybook 预览 React 组件
Kadira 的 npm 包 Storybook(github.com/kadirahq/react-storybook)是一个用于构建 React 组件的工具,无需将组件连接到你的应用程序以查看其工作情况。Storybook 对你的应用程序不做任何假设。相反,它可以渲染你想要单独查看的任何 React 组件。如果你提供正确的输入,它甚至可以渲染由多个组件组成的完整用户界面。以下是 Storybook 在浏览器中的运行情况:

该库利用 iframe 来隔离每个组件,这样你可以轻松地在组件之间切换。Storybook 使用 webpack 的 hot module loading 来构建和监控代码更改,在你编辑代码时自动刷新浏览器中的更改。
要使用 Storybook,你必须全局安装它:
$ npm install -g @kadira/storybook
然后在仓库内的命令行中运行以下命令:
$ npm run storybook
要编写一个故事,你需要在 components/stories 文件夹中添加代码。每个使用故事的章节部分都有一个文件。Storybook 提供了两个函数:storiesOf和add。第一个函数storiesOf向 Storybook 添加一个新的故事,它将在左侧显示(章节示例和 Todo 应用)。add函数(部分函数)向故事添加一个特定的示例(带有属性的链接显示,列表项功能)。要查看这个功能是如何工作的,请查看以下代码:
import React from 'react';
import { storiesOf, action } from '@kadira/storybook';
storiesOf('My first story', module)
.add('list item functional', () => (
//...code
));
然后 Storybook 将在 localhost:9001 上渲染这些组件。如果你点击示例 2 中的按钮,动作记录器将记录button clicked。这就是测试组件是否正确调用预期动作或回调的方法。
Storybook 提供了手动断言测试和视觉渲染,无需将组件连接到应用程序的复杂性。这使得编写应用程序的视图部分更快,并允许你独立于业务逻辑测试组件。
3.4. 你的第一个 React 组件
到目前为止,你应该理解为什么你想使用 React 以及渲染的基础知识。现在你将学习以下最佳实践的开发技能:
-
JSX 基础
-
纯组件
-
使用属性
-
条件和循环
-
React 类
-
用户交互
到本节结束时,你将能够构建一个 Todo 应用程序的用户界面(见 图 3.1),并实现用户交互。你将使用所有构建同构应用程序所需的 React 功能。要跟随本章中的示例,请查看 GitHub 仓库中的 react-components 分支(git checkout react-components)。
注意
你可以在这个部分的文件在 components/Chapter_3_4 和 components/stories/chapter_3_4.js 中找到。
React 遵循基本的核心原则。首先,它提供了一个标准、简单的接口来构建视图。其次,它使用声明式风格来处理更新和状态变化。在 React 中,组件不关心它们的子组件如何工作。它们只关心需要传递给子组件的数据。例如,为了在 Todo 应用程序中创建列表视图,你告诉 React 为待办事项列表中的每个条目渲染一个列表项。列表视图不关心 React 如何实现更新浏览器 DOM 的底层逻辑。它只关心列表项的数据需求。为了看到这一点,你将学习如何使用 React 的模板语言 JSX 编写 Todo 应用程序的按钮组件。
3.4.1. JSX 基础
到目前为止,你已经看到了如何通过在 JavaScript 中编写它们来渲染 React 组件。但 React 使用一种名为 JSX 的模板语言,这使得编写组件几乎就像编写 HTML 一样。JSX 被设计成编译成 JavaScript,并允许你混合组件声明、HTML 和 JavaScript。
Todo 应用程序有很多按钮,所以你将创建一个可以用于各种按钮用例的可重用按钮。图 3.7 展示了它的样子。
图 3.7. 在 Storybook 内部渲染的按钮组件

JSX 中的基本按钮编写如下:
<button>Click Me!</button>
到目前为止,你已经编写了一些看起来像是 HTML 的内容。声明 HTML 元素的方式与你在 HTML 文件中编写它们的方式相同(div、a、ul、img、video 等等)。但由于你将 JSX 编译成 JavaScript,JSX 提供了指示执行 JavaScript 表达式位置的语法。
为了说明这一点,让我们假设你想要将按钮标签转换为一个变量。列表 3.4 展示了你的第一个 React 组件的代码。用列表中的代码替换 buttonBasic.jsx 中的代码。我已经为你设置了场景。在你添加了列表中的代码之后,你可以在 JSX 基本按钮选项卡下查看它,如图 图 3.7 所示。
列表 3.4. JSX 按钮组件—第三章 4 节/buttonBasic.jsx
import React from 'react'; *1*
const Button = (props) => {
let label = "Click Me!"; *2*
return <button>{label}</button>; *3*
}
export default Button; *4*
-
1 包含 React,这是每个 React 组件必需的。
-
2 将按钮标签声明为一个变量。
-
3 使用 JSX {} 语法来指示编译器一个 JavaScript 表达式。
-
4 导出组件,以便它可以被其他组件包含。
这个 JSX 将会被编译,并且会知道 label 是一个 JavaScript 表达式。你可以在 {} 中放置任何有效的 JavaScript 代码。大括号表示要执行的 JavaScript。这可能是一个变量、三元运算符、函数或其他有效的 JavaScript 代码。
为了确保你理解 JSX 如何编译成 JavaScript,让我们回顾一下本章前面提到的渲染示例,其中你将“我的第一个 React 组件”渲染到 DOM 中:
ReactDOM.render(
React.createElement("div", null, "My first React component"),
document.getElementById('render-react-into-me')
);
让我们使用你到目前为止学到的关于 JSX 的知识来重写这段 JavaScript 代码。我发现第二个例子更容易处理。它读起来像 HTML:
ReactDOM.render(
<div>My first React component</div>,
document.getElementById('render-react-into-me')
);
在第一个例子中,你必须理解每个参数的作用。你需要了解 React.createElement 的工作细节,包括要传递哪些参数。在第二个例子中,你可以立即看到 React 将渲染一个 div,并显示“我的第一个 React 组件”,因为你已经知道如何阅读 HTML。
同样,按钮组件的编译版本使用 React.createElement 并传递一组参数,指示要渲染的内容。在按钮示例中,传递了一个额外的参数:JavaScript 表达式,或者在这种情况下,名为 label 的变量:
let label = "Click Me!";
React.createElement( "div", null, label)
这个例子展示了 JSX 编译后的 JavaScript 版本。注意,因为 JSX 现在是纯 JavaScript,变量可以被 JavaScript 解释器读取。这就是 JSX 的力量。它让你可以将视图声明与 JavaScript 逻辑混合。
如果你觉得这有点奇怪,我鼓励你花五分钟时间(见 signalvnoise.com/posts/3124-give-it-five-minutes)。我第一次看到 JSX 时,认为它很糟糕。它让我想起了编写服务器端语言,如 PHP。
在我开始使用 JSX 之后,我发现它可能是最好的,如果不是最好的,内联视图选项之一,适用于网络应用。我现在非常喜欢写 JSX!它几乎和写 HTML 一样,但又不完全是 HTML。它有易于阅读和易于书写的额外好处。此外,它允许你将视图逻辑和视图结构并排编写。这让你可以移除不必要的样板代码,使开发者体验更好;它比其他选项更易于阅读。JSX 因为使得单元测试视图相对容易而获得加分。
常见 JSX 陷阱
在大多数情况下,JSX 遵循正常的 JavaScript 规则。但你应该注意一些例外。
注意
我没有空间来详细说明每个 JSX 的陷阱。好消息是 Facebook 保持其文档更新。要查看 JSX 陷阱的完整列表,请访问 JSX 陷阱页面和 React 文档中的 JSX 深入页面:facebook.github.io/react/docs/jsx-gotchas.html 和 facebook.github.io/react/docs/jsx-in-depth.html。
在 JSX 中添加 CSS 类时,编译器会忽略单词class,因为在 JavaScript 中它是保留字。记住 JSX 代码将被编译成 JavaScript。因此,你需要使用属性className来添加类:
<div className="blue">I'm blue</div>
React 也会忽略自定义属性。如果你需要添加自定义属性,请使用data-前缀代替:
<div data-custom-id="1234"></div>
最后,JSX 看起来像 HTML,但它不是 HTML。你不能写<!--HTML Comment-->。如果你这样做,你会得到编译错误。但是你仍然可以添加常规 JavaScript 注释;只需将它们放在一个表达式{}内:
{/*JavaScript comment renders here*/}
现在你对 JSX 有一些经验了,让我们为待办事项应用构建 ListItem 组件。
3.4.2. 构建可复用组件
到目前为止,我只展示了如何直接渲染到 DOM 中以及如何编写 JSX。现在你将编写完整的组件。待办事项应用可以被分解成小的、可复用的组件,如 Button、ListItem 和 Tab。看看待办事项应用组件部分的视觉表示图 3.8。
图 3.8. 每个矩形包含一个单独的组件。有些组件是嵌套的;例如,Button 组件嵌套在 ListItem 组件中,而 ListItem 组件又嵌套在 List 组件中。

在 React 视图中,每个可重复的元素都可以成为一个组件。其他组件包裹较小的可组合组件并确定布局。在图 3.8 中,如 AddItem 和 List 这样的包装组件包含较小的、可复用的组件。
如 ListItem 和 Button 这样的较小组件在用户界面中重复出现。React 允许你一次编写这些组件,然后反复使用它们。这简化了你的代码,使其更易于维护和阅读。它还允许你以声明式风格编写视图。
你将要构建的第一个可复用组件将是用于待办事项应用的 ListItem 组件。(可复用组件被认为是最佳实践,因为它们可以加快开发速度。)图 3.9 展示了 ListItem 的输出。
图 3.9. Storybook 中待办项的视图以及操作日志

此组件使用以下列表中的代码创建。此代码替换了 listItemFunctional.jsx 文件中的占位符代码。添加后,你可以在 Storybook 中看到它,因为故事已经存在。
列表 3.5. 列表项—components/Chapter_3_4/listItemFunctional.jsx
import React from 'react';
import Button from './button.jsx';
const ListItemFunctional = (props) => { *1*
return ( *2*
<div>
<div>{props.name}</div> *3*
<Button key='Delete' *4*
clickHandler={props.deleteCallback} *3*
label='Delete'></Button>
<Button key='Complete' *4*
clickHandler={props.completeCallback} *3*
label='Complete'></Button>
</div>
)
}
export default ListItemFunctional;
-
1 使用 JavaScript 函数声明创建组件——创建纯函数组件。
-
2 返回要渲染的 JSX——必须是一个单一根节点,否则你会得到错误。
-
3 父组件传递 ListItem 使用到的属性,包括名称和点击处理程序回调。
-
4 如果同一类型的多个元素是兄弟元素,请为它们提供唯一的键,这样 React 才能在后续渲染中区分它们。
在此示例中,key 属性的使用非常重要。任何时候你有两个相同类型的兄弟元素时,都必须使用 key 属性。在列表中,有两个按钮。除非你提供键,否则 React 不会知道哪个发生了变化。如果它不知道哪个发生了变化,它将替换两个项目。有了 key 属性,React 就知道哪个元素需要更新,并且可以在进行 DOM 变更时更加高效。
列表 3.5 中的 ListItem 组件使用了两个重要的 React 概念:
-
属性 (
props) -
纯函数组件
在下一节中,我们将探讨如何使用 React 的属性概念将组件转换为可重用的模板,该模板接受属性以显示内容。然后我们将查看如何声明纯函数组件。
3.4.3. 使用属性
使用 React,你想要构建可重用的组件。例如,一个可以用于任何需要显示的待办事项的 ListItem 组件会很有用。这正是 React 属性大放异彩的地方。通过 属性,你可以定义如何传递组件所需的信息。React 属性使得创建可重用组件成为可能。它们包含使组件使用独特的信息。
要使用属性,你可以传递具有值 Cleanup my desk 的 name 属性,如下所示:
<ListItem name="Cleanup my desk"/>
组件可以随后在 props 对象上访问此值:
<div>{props.name}</div>
这反过来会在浏览器 DOM 树中渲染以下 div:
<div>Cleanup my desk</div>
如果属性发生变化,将触发渲染,组件将更新。我们将在第四章中更多地讨论组件的生命周期。
将属性视为元素内的不可变值。它们允许元素具有不同的方面或属性,因此得名。
属性在其组件内部是不可变的。父组件在创建时将其属性分配给子组件。子元素不应该修改其属性(在开发中,如果你这样做,React 将抛出错误)。
定义
子 是嵌套在另一个元素内部的元素(例如,<Button/> 是 <ListItem/> 的子元素)。父组件是包裹子组件的组件。组件可以有多个子元素,这些子元素也可以有子元素。
扩展运算符
关于将props传递到组件中还有一点:假设你有一个包含大量属性的父组件。这可能包括当前路由信息、应用程序状态和应用程序数据。而不是逐个写出每个属性,你可以利用扩展运算符(...)来传递所有属性。扩展运算符将对象上的每个键“展开”成单独的属性。
让我们重新审视一下之前提到的 ListItem 组件。假设你有几个属性需要传递。使用扩展运算符,你将使每个属性在子组件的props上可用:
<ListItem {...this.props}/>
然后在组件代码中,你可以单独访问每个属性:
<div>{props.name}</div>
<Button clickHandler={props. deleteCallback} label='name'></Button>
当你构建多层嵌套子组件时,这变得很有用。根组件可能需要将属性传递给曾孙组件。每个组件都可以根据需要向下传递所有props以帮助实现这一点。但你要小心,因为无差别地将所有属性向下传递树也可能导致不必要的渲染和性能问题。
你已经看到了如何在 React 组件中使用属性;现在让我们看看功能 React 组件。
3.4.4. 功能组件
最简单的 React 组件遵循纯函数概念:给定一组输入,组件将返回一个可预测的输出(使它们易于测试)。一个基本的加法函数说明了这个概念。给定两个整数输入,add函数将始终返回相同的值:
function add(a, b) { // a = 3, b = 2
return a + b; // returns 5
}
这个 Link 组件只需要被告知如何构建链接。你可以通过属性来实现这一点。图 3.10 显示了在 Storybook 内部渲染的 Link 组件。
图 3.10. Storybook 内部渲染的 Link 组件

以下列表显示了创建 Link 组件的代码,这是一个简单的函数组件,它返回一个使用传入的 props 创建的链接。将此代码添加到 link.jsx 文件中。完成此操作后,你将能够看到它,如图 3.10 所示。
列表 3.6. Link 组件—components/Chapter_3_4/link.jsx
import React from 'react';
const Link = (props) => { *1*
return <a href={props.link} *2*
target={props.target}> *2*
{props.displayName} *2*
</a> *2*
}
export default Link;
-
1 纯函数可以使用 JavaScript 函数声明(此处使用 ES6 风格)。
-
2 Link 组件返回一个没有状态且不使用任何 React 生命周期方法的组件——给定一组输入(props),返回可预测的结果。
构建纯函数组件的方法在性能、测试(见第九章)、可维护性和开发者速度方面都有好处。
注意
这种功能组件方法不应与在 React 15 中引入的 PureComponent 混淆。React.PureComponent 在特定用例中为你提供了一些性能提升,但其他方面与 React.Component 相同。更多信息可以在facebook.github.io/react/docs/react-api.html#react.purecomponent找到。
你的许多组件将是可重用的显示组件。React 鼓励通过将其分解成小块来“干燥”你的代码。随着时间的推移,这可以快速构建新的、更复杂的组件,因为大多数部件已经可用。
定义
DRY 是 不要重复自己 的缩写。React 允许你将用户界面分解成小块,可重复使用,以便你不必重复自己。
基于这两个概念,属性和纯功能组件,你可以构建你应用程序的大部分内容。最终,你需要添加更多的复杂性。在下一节中,你将构建列表组件,并将条件逻辑添加到列表项中。
3.4.5. 条件和循环
在上一节中,你使用基础 React 概念构建了一个列表项。在本节中,你将向 JSX 添加复杂的 JavaScript 表达式,如三元和循环。你将构建一个使用列表项组件来显示多个待办事项的列表组件。本节中构建的列表组件在图 3.11 中显示。
图 3.11. 列表组件根据接收到的数据渲染多个列表项。

首先,你将使用条件语句只为未完成的项显示完成按钮。然后你将使用循环来显示项目列表。
条件
让我们来看一下你之前构建的列表项。记住,你给它添加了两个按钮:一个删除按钮 todo 和一个完成按钮。但在待办事项应用中,你将根据不同的状态显示这些项:当前或完成。如果你已经完成了待办事项,你不想显示完成按钮(参见图 3.12)。
图 3.12. 列表项组件有一个属性,表示该项是否完成。当它完成时,完成按钮不再显示。

相反,你需要添加一个检查以确定正在渲染的列表项是否之前已经被完成。列表 3.7 展示了如何将三元表达式添加到代码中。将以下加粗的代码添加到 listItemFunctional.jsx 文件中。使用三元表达式,组件检查 done 属性。如果它是 true,则不会渲染完成按钮,而是渲染一个空字符串。如果它是 false,则渲染完成按钮。
列表 3.7. 使按钮可选——components/Chapter_3_4/listItemFunctional.jsx
const ListItemFunctional = (props) => {
return (
<div>
//...other code
{props.done ?
"" :
<Button key='Complete'
clickHandler={this.complete}
label='Complete'></Button>}
</div>
)
}
export default ListItemFunctional;
要测试你添加的新属性,你可以添加一个带有 ListItemFunctional 组件和 done 属性的故事。下一个列表显示了如何更新故事。将此代码添加到 Chapter_3_4.js 的故事中。添加后,你将能够看到没有完成按钮的组件,如图 3.12 所示 figure 3.12。
列表 3.8. 测试 listItem done 属性—components/stories/chapter_3_4.js
storiesOf('Chapter 3.4 examples', module)
.add('JSX basic button', () => ())
.add('link displays with props', () => ())
.add('list item functional', () => ())
.add('list item with done prop', () => ( *1*
<ListItemFunctional completeCallback={actions.complete}
deleteCallback={actions.delete}
name="Cleanup mess"
done="true"/> *2*
))
.add('list container with list item', () =>());
-
1 使用 add() 函数添加一个故事—传递故事的标题和返回你想要渲染的组件的函数。
-
2 添加 done 属性——组件在这里检查真值,因此你可以将其设置为 true。
从逻辑角度来看,你对 ListItemFunctional 组件所做的更改很简单。如果 todo 列表项已完成,则不显示完成按钮。如果没有完成,则显示完成按钮。但在 JSX 中如何实现这一点?它不支持 if 语句,因为它只是标记。
对于像显示完成按钮这样的低复杂度条件,你可以使用三元表达式。记住,你放在 {} 中的任何内容都将作为 JavaScript 代码执行。
props.done ? "" : <button>Complete</button>
done 属性是传递给 props 的 todo 数据上的布尔值。如果为真,则不会渲染任何内容,但如果为假,则会显示按钮,以便用户能够对 todo 执行完成操作。
循环
现在你有一个 todo 项,你想要能够显示多个 todo 项的列表。List 组件是一个纯组件,它遍历提供的数据数组。它接收传递的每个对象并渲染一个子 ListItem 组件。这个 List 组件仍然是一个函数组件,因为它没有状态。它纯粹负责渲染项。以下列表显示了你需要添加到 Chapter_3_4 文件夹中的 list.jsx 文件中的代码。确保替换所有占位符代码。
列表 3.9. 列表组件—components/Chapter_3_4/list.jsx
import React from 'react';
import Item from './itemDone.jsx'
const List = (props) => {
let listItems = []; *1*
props.data.forEach((item)=>{ *2*
listItems.push(
<Item key={item.id} {...item} actions={props.actions}/> *3*
);
});
return <div>{listItems}</div>;
}
export default List;
-
1 创建一个数组以将每个子组件推入。
-
2 数据作为数组传递,遍历数组中的每个项。
-
3 将每个 Item 组件推入 listItems 数组;传递 item 的所有属性和所有 props。
与上一节一样,你可以在 JSX 部分的渲染之前处理这个循环逻辑。将传递给 List 组件的数据显示在以下列表中。我已经为你包含在这个部分的 stories 文件中。
列表 3.10. Todo 数据—components/stories/chapter_3_4.js
[
{
name: "Finish the dishes",
done: true,
id: 0
},
{
name: "Walk the dog",
done: false,
id: 1
},
{
name: "Get a haircut",
done: true,
id: 2
},
{
name: "Work on Chapter 3",
done: false,
id: 3
}
]
这份数据有四个 todo。其中两个已经被标记为 done。在 List 组件的代码示例中,你可以看到给定这个数据数组,将创建四个列表项。它们被推入一个 JSX 能够渲染的数组中。
在这一点上,列表组件将正常工作。但您需要为每个 ListItem 添加一个额外的属性才能完成此组件。在 React 中,当有多个相邻的相同类型的组件时,您必须添加一个 key 值。key 必须是唯一的。您需要修改创建项目的方式:
<ListItem key={item.id} {...item} {...props} />
记住,这很重要,因为 React 在更新时不知道列表中哪个项目发生了变化。它知道列表发生了变化,但没有 key,React 将不得不重新创建整个列表。有了 key,React 就知道哪些项目被更新了,并且可以进行智能更新和渲染。
如果您想查看最后几节中的最终代码,可以切换到 react-components-complete 分支(git checkout react-components-complete)。
3.5. 交互式组件:React 状态
到目前为止,您已经创建了显示内容的组件和布局子组件的组件。但是,Web 应用程序需要交互性和状态。在本节中,您将通过添加不同的卡片列表来为 Todo 应用程序添加交互性。这也要求应用程序具有一些应用程序状态。如果您想跟随并构建组件,可以在 GitHub 仓库中切换到 react-state 分支(git checkout react-state)。
注意
您可以在此节中找到文件 components/todo 和 components/stories/todo.js。
要做到这一点,您将创建选项卡组件。此组件通过作为属性传递的回调函数将状态更改通知其父组件。图 3.13 显示了渲染在待办事项列表上方的选项卡组件。应用程序将显示三个列表:所有、当前和完成的待办事项。
图 3.13. 添加选项卡使应用程序能够显示不同的待办事项列表。

3.5.1. 使用类
选项卡组件将具有交互性,并且需要处理点击的方法。对于不是纯函数组件的组件,React 提供了一个可以扩展的类。这为您提供了访问几个内置的 React 生命周期方法和其他 React 组件 API 方法,例如 setState 的好处。以下列表显示了选项卡组件的代码,该代码应添加到 todo 文件夹中的 tabs.jsx 文件中。
列表 3.11. 选项卡组件完成—components/todo/tabs.jsx
import React from 'react';
import classnames from 'classnames'; *1*
class Tabs extends React.Component {}
class Tab extends React.Component {
constructor(props) { *2*
super(props); *3*
this.handleClick = this.handleClick.bind(this);
}
handleClick() { *4*
this.props.actions.updateTabView(this.props.index)
}
render() { *5*
const classes = classnames(
{
active: this.props.active
},
'tab'
) *1*
return (
<div className={classes} *1*
onClick={this.handleClick}>
{this.props.name}
</div>
)
}
}
-
1 npm 包允许您构建复杂的类字符串。
-
2 构造函数方法在每次创建组件类实例时运行。
-
3 调用 super 并传递参数(props)—必须在构造函数中首先发生。
-
4 点击处理方法通知应用程序活动选项卡的变化。
-
5 渲染方法在每次渲染时运行,相当于之前为纯组件创建的函数。
如您所见,npm 包允许您构建复杂的类字符串。在这种情况下,如果您想要添加一个活动类,则活动属性必须为 true。classnames 读取传入的对象和字符串列表,并返回一个正确间隔的字符串。这个字符串被渲染到 className 属性中。
编写 React 类与编写函数组件不同。使用 ES6 JavaScript 类,您扩展 React 基类以创建一个新的组件:
class Todo extends React.Component {}
然后,您可以添加 constructor() 和 render() 方法。构造函数在类实例创建时运行。渲染方法就像您编写的用于创建 List 和 ListItem 组件的函数式方法一样。它返回组件的 JSX。
您可以添加您可能需要的任何额外的类函数或 React 生命周期函数。Tab 组件添加了一个名为 handleClick 的点击处理程序,该处理程序使用当前选项卡的索引调用动作的回调。
注意
动作对象包含 Redux 动作。目前,这些动作的实现将在您的根组件中而不是 Redux 中。在 第六章 中,我将向您展示如何连接到 Redux 动作。
要使点击处理函数正常工作,您需要通过向标签 div 添加 onClick 属性来向标签添加事件监听器。React 会为您处理所有底层的绑定和清理。大多数事件都可以这样附加:
<div className="tab" onClick={this.handleClick}>
{this.props.name}
</div>
在 React 中使用类添加事件监听器有一个陷阱。在 ES6 类中,类函数不是自动绑定到 this 上下文。如果您已经使用 JavaScript 了一段时间,这可能会让您一开始感到困惑,因为在原型结构中,原型上的每个函数都绑定到 this 上下文。在 ES6 类结构中,每个函数最终都会带有调用者的上下文(与父类相反)。
当处理事件监听器时,默认情况下,事件监听器的 this 上下文将是事件本身,而不是类。修复这个问题需要强制将事件监听器绑定到类的上下文中。您将在类的 constructor 方法中这样做:
constructor(props) {
...
this.handleClick = this.handleClick.bind(this);
}
在您向构造函数添加 bind 调用之后,您的事件处理器的 this 上下文将是类,这使得您可以调用 this.props 或 this.state。因此,最终的 Tab 组件将有一个构造函数、渲染和点击处理程序。
3.5.2. React 状态
到目前为止,我们讨论了作为纯函数编写的组件和无状态基于类的组件。React 也可以在组件内部处理状态。有时这可能是指应用程序状态。更常见的是,这种状态最终会成为用户交互状态。例如,模态是隐藏还是显示?
在 Todo 应用中,根组件将管理状态。在后面的章节中,您将使用 Redux 来管理应用程序状态。以下列表显示了 todo 根组件的代码,您将其添加到 todo.jsx 中。
列表 3.12. Todo 组件—components/todo/todo.jsx
import React from 'react';
import List from './list.jsx';
import Tabs from './tabs.jsx';
import AddItem from './addItem.jsx';
class Todo extends React.Component {
constructor(props) {
super(props);
this.state = {
tab: 0
}
}
updateTabView(index) {
this.setState({
tab: index
})
}
filterTodos() { *1*
return this.props.todos.filter((todo) => {
if (this.props.activeTab == 0) {
return true;
} else if (this.props.activeTab == 1) {
return !todo.done;
} else {
return todo.done;
}
});
}
render(){
let actions = {updateTabView: updateTabView} *2*
return (
<div className='todo-app'>
<h1>ToDo App</h1>
<Tabs {...this.props} actions={actions}/> *3*
<List {...this.props} data={this.filterTodos()} /> *4*
<AddItem {...this.props} />
</div>
)
}
}
export default Todo;
-
1 方法提供基于 activeTab 属性显示的当前待办事项列表。
-
2 将 updateTabView 方法设置为操作。
-
3 将操作传递给 Tabs 组件,以便每个标签可以回调到这个根组件以更新状态。
-
4 将计算出的待办事项列表传递给 List 组件。
每个 React 类组件都有一个状态对象,该对象在 this.state 中可访问。你需要在构造函数中初始化这个状态对象:
constructor(props) {
super(props);
this.state = {tab: 0}
}
在构造函数中初始化状态后,状态变为只读。React 提供了另一个名为 setState 的方法用于状态更新(写入)。如果你想更新 tab 值,你调用 setState:
updateTabView(index) {
this.setState({tab: index});
}
关于 setState 的重要说明:它是异步的。好消息是,如果你在确保状态更新完成后需要运行代码,你可以使用 React 提供的钩子:
updateTabView(index) {
this.setState({tab: index}, () => {
// do something after the state updates
});
}
你现在已经构建了大多数的 Todo 应用程序!你还学习了如何使用 React 的所有核心概念:
-
使用 JSX 创建组件
-
纯组件
-
属性
-
类组件
-
状态
如果你想查看本章中展示的完整代码,请查看 master 分支。在下一章中,你将学习关于 React 组件生命周期和可用于处理更复杂逻辑案例的钩子函数。
概述
在本章中,你学习了如何使用 React 开始构建。你了解了使用 React 构建的 Todo 应用程序的关键部分。我们涵盖了在构建同构应用程序之前你需要了解的所有 React 基础知识:
-
React 使用声明式风格结合函数式概念来提供一个简单的视图接口。
-
虚拟 DOM 为 React 提供了智能且快速进行更新的能力。
-
JSX 是 React 的声明式模板语言。它使用类似 HTML 的语法,并具有执行 JavaScript 的能力。
-
React 依赖于
props在组件之间进行通信。 -
在 React 中,状态是不推荐的,但在需要时,它是处理用户交互和管理应用程序状态的一个强大工具。
第四章. 应用 React
本章涵盖
-
为浏览器配置 React Router
-
通过使用
props.children以一致的方式渲染路由内容 -
构建可重用组件
-
使用高阶组件来抽象常见的业务逻辑
-
利用 React 组件生命周期
在 第三章 中,你学习了使用 React 构建视图的基础知识。现在,你将通过使用 React 探索更多高级概念来在此基础上构建技能。本章将教你构建生产级应用程序所需了解的内容。
您将使用 第一章 中描述的 All Things Westies 应用程序进行工作。这是您将构建的许多章节中的第一个。代码可以在 github.com/isomorphic-dev-js/complete-isomorphic-example.git 找到。为了开始,您应该在 branch chapter-4.1.1 (git checkout chapter-4.1.1) 上。此存储库的 master 分支包含书中所有章节的完整代码。
要运行本章的应用程序,请使用以下命令:
$ npm install
$ npm start
当服务器运行时,本章中的应用程序将从 http://localhost:3000/ 加载(尽管在 chapter-4.1.1 分支上什么也看不到)。它不是同构的,因为我希望您能专注于 React 概念。当您在 第七章 和 第八章 中构建此应用程序时,您将使应用程序成为同构应用程序。
应用程序在 图 4.1 中显示。我指出了需要添加以使此功能正常工作的组件部分。
图 4.1. 您将在本章中开始构建的 All Things Westies 示例应用程序。您将在后面的章节中构建此应用程序的各个部分。

有三个主要路由(以及一个主页路由,/):/products、/cart 和 /profile。在下一节中,您将设置路由。
4.1. React Router
要构建一个 Web 应用程序,通常需要一个路由器。路由器提供基于 URL 的路由与应加载的视图之间的映射。因为 React 是视图库,它本身不处理应用程序的路由。这就是 React Router 发挥作用的地方。
React Router 已成为 React 应用中路由选择的社区首选。它甚至支持服务器端路由,使其成为同构应用的绝佳选择(在第七章中介绍)。React Router 通过使用 JSX 让您声明路由,使得创建您的路由变得简单。React Router 是一个处理您路由逻辑的 React 组件。
React Router 版本
此应用程序和本书的其余部分使用 React Router 3(v3.0.5)。自从我开始写这本书以来,已经推出了一个新版本(v4)。最新版本是对 React Router 工作方式的完全重写。它与 React 的工作方式更一致,但它需要以新的方式思考路由如何与同构应用程序交互。
我提供了带有三个附录(A–C)中解释的应用程序版本。您将在附录 A 中找到与本章节相关的示例。我解释了如何开始使用 React Router 4 以及移除 React Router 生命周期后的主要变化。
好消息是 React Router 团队已经承诺无限期地支持 v3(因为 v4 的破坏性)。但我确实建议如果你开始一个新项目,你可以探索 v4。
4.1.1. 使用 React Router 设置应用程序
React Router 使用组件将路由引入你的应用程序,并定义子路由。在你使用路由器启动应用程序之前,你必须首先定义一组将要使用的路由。你将在 sharedRoutes.jsx 文件中设置它们。
在本节的第一部分,你将添加带有路由器的 App 组件。这将使你能够轻松支持书中稍后构建的服务器端渲染用例。以下列表显示了要添加到 sharedRoutes.jsx 中的代码。记住,如果你想跟上进度,你应该在 chapter-4.1.1 分支上。
列表 4.1. 应用程序路由——src/shared/sharedRoutes.jsx
import React from 'react'; *1*
import { Route } from 'react-router'; *2*
import App from '../components/app'; *3*
const routes = ( *4*
<Route path="/" component={App}> *5*
</Route>
);
export default routes;
-
1 包含 React,因为 React Router 使用 React 组件来实现路由器。
-
2 从 React Router 中引入 Route 组件。
-
3 包含根组件:app.jsx。
-
4 使用 JSX 语法创建路由对象。
-
5 路由组件需要两个属性,即此路由的路径和要显示的组件。这导致根路由返回 App 作为其组件。
我已经为你提供了 App 组件的框架,所以你不需要添加此代码。以下列表显示了 App 组件。
列表 4.2. App 组件——src/components/app.jsx
import React from 'react';
const App = () => {
return (
<div>
<div className="ui fixed inverted menu">
<h1 className="header item">All Things Westies</h1> *1*
<a to="/products" className="item">Products</a> *2*
<a to="/cart" className="item">Cart</a> *2*
<a to="/profile" className="item">Profile</a> *2*
</div>
<div className="ui main text container">
Content Placeholder *3*
</div>
</div>
);
};
export default App;
-
1 应用程序标题
-
2 根导航链接——每个都将在下一节中添加到 sharedRoutes 文件中。
-
3 每个路由的内容将在这里渲染——目前,这里只有占位文本。
接下来,你将设置你的应用程序以使用 React Router。以下列表显示了如何设置 main.jsx 文件。
列表 4.3. 使用 React Router 渲染应用程序——src/main.jsx
import React from 'react'; *1*
import ReactDOM from 'react-dom'; *1*
import {
browserHistory,
Router
} from 'react-router'; *2*
import sharedRoutes from './shared/sharedRoutes'; *3*
ReactDOM.render(
<Router *4*
routes={sharedRoutes} *5*
history={browserHistory} *6*
/>,
document.getElementById('react-content')
);
-
1 包含你的 React 和 ReactDOM 依赖项。
-
2 包含 Router 组件和 browserHistory 模块来自 React Router。
-
3 包含你创建的 sharedRoutes 文件。
-
4 通过声明 Router 组件作为你的根组件,将 React 应用程序渲染到 DOM 中。
-
5 Router 接收你从 sharedRoutes 中包含的路由。
-
6 路由组件需要知道它应该使用哪种历史实现——在这里使用 browser history 模块,以便应用程序可以使用内置的浏览器历史 API。
与将根组件渲染到 DOM 中不同,React Router 最终成为你的根组件。另一种思考方式是将其视为组件树中的顶级组件(见图 4.2)。
图 4.2. 带有 React Router 作为根元素的示例组件树

在底层,路由器正在使用浏览器历史对象。它挂钩到这个对象以使用 push 状态和其他浏览器路由 API。
此外,React Router 允许你传入这个历史对象。这样,它不会对它运行的环境做出任何假设。在浏览器上,你传入一个与服务器上不同的历史对象。这就是 React Router 适合同构应用程序的部分原因。传入历史对象也是一种更可测试的模式。
4.1.2. 添加子路由
为了使应用程序的其余部分正常工作,添加用户将用于在应用程序中的视图之间导航的子路由。这需要两个额外的步骤:创建子路由并设置 app.jsx 以渲染任何子路由。以下列表显示了如何将新路由添加到 sharedRoutes 文件中。如果你想跟随,本节的基代码位于分支 chapter-4.1.2(git checkout chapter-4.1.2)。
列表 4.4. 添加子路由—src/shared/sharedRoutes.jsx
//... other import statements
import Cart from '../components/cart'; *1*
import Products from '../components/products'; *1*
import Profile from '../components/profile'; *1*
const routes = (
<Route path="/" component={App}>
<Route path="/cart" component={Cart} /> *2*
<Route path="/products" component={Products} /> *2*
<Route path="/profile" component={Profile} /> *2*
</Route>
);
export default routes;
-
1 包含每个路由的组件。
-
2 通过在 App 路由内部嵌套来创建子路由。
每个子路由都将与 App 结合。React Router 将知道应该提供给 App 以供渲染的适当子组件。
React:渲染任何子组件
让子路由工作起来的下一步是设置 App 组件以显示任何任意子组件。App 组件不需要知道它正在渲染哪个子组件——只需要知道它需要渲染一个子组件。你解耦了子组件和父组件的实现。这创建了一个可重用模式,其中相同的子组件可以在多个视图中使用,反之亦然。图 4.3 显示了 React Router 和子路由的关系。
图 4.3. 使用 props.children 在运行时渲染组件

你可以通过嵌套 React 组件来传入子组件:
<MyComponent>
<ChildComponent />
</MyComponent>
然后在 MyComponent 的 render 函数中,你引用了 props 对象上的子元素:
render() {
return <div>{props.children}</div>
}
注意
React Router 通过 JavaScript 分配属性和使用低级 React API(如 createElement)来处理向下传递子组件。你不需要担心这一点,但如果你想进一步探索,请查看github.com/ReactTraining/react-router/blob/v3/docs/API.md#routercontext。
这种模式允许在运行时动态确定子组件。以下列表显示了如何更新 App 组件来完成此操作。将列表中的代码添加到已存在的 app.jsx 组件代码中。
列表 4.5. 渲染任何子组件—src/components/app.jsx
const App = (props) => {
return (
<div>
<div className="ui fixed inverted menu">
...
</div>
<div className="ui main text container">
{props.children} *1*
</div>
</div>
);
};
App.propTypes = { *2*
children: PropTypes.element *3*
};
-
1 App 组件渲染子属性
-
2 在组件上设置 propTypes
-
3 Prop children 是一个 React 元素——propTypes 对象描述了这些信息。
在组件上设置 propTypes 提供了文档,并被认为是最佳实践。它是一个对象,描述了预期的属性,包括它们是否是必需的。
路由属性
因为 Router 包装了 App 组件,所以它将几个路由对象作为 props 传递下来。许多这些对象在子组件中是必需的,但我会专注于三个:
-
location— 它反映了window.location对象,由传递给路由的历史数据构建而成。它包含了一些属性,如查询和路径名,你可以在组件中使用这些属性。 -
params— 这个对象包含了路由上的所有动态参数。如果你有一个匹配/products/treats路由的/products/:category路由,这个对象将包含一个名为 category 的属性:{ category: treats }。 -
router— 这个对象包含了许多与路由和历史交互的方法,包括低级 API。最常见的情况是,我需要使用push()方法从 JavaScript 中导航到应用的不同部分。
在下一节中,你将使用 Link 组件,该组件利用了低级路由和历史 API,因此你无需自己操作。
4.1.3. 从组件中进行路由:Link
React Router 更进一步,提供了一个 React 组件,当你想要触发导航时可以使用。这样,你就不必担心底层发生了什么。
Link 组件渲染一个 <a> 标签。要使用 Link 组件,你需要在你的组件中包含它,然后使用它需要的属性来渲染它。如果你想跟随本节内容并获取到目前为止的代码,请切换到名为 chapter-4.1.3 的分支(git checkout chapter-4.1.3)。以下列表展示了如何更新头部以使用 Link 组件而不是 app.jsx 中的标准链接。
列表 4.6. 使用 Link 组件—src/components/app.jsx
import React from 'react';
import { Link } from 'react-router'; *1*
const App = (props) => {
return (
<div>
<div className="ui fixed inverted menu">
<h1 className="header item">All Things Westies</h1>
<Link to="/products" className="item">Products</Link> *2*
<Link to="/cart" className="item">Cart</Link> *2*
<Link to="/profile" className="item">Profile</Link> *2*
</div>
<div className="ui main text container">
{props.children}
</div>
</div>
);
};
-
1 从 React Router 中包含 Link 组件
-
2 将
<a>标签转换为<Link>标签
注意,与 href 属性不同,Link 组件需要一个 to 属性。添加 Link 组件后,你的应用程序将能够在视图之间正确路由。
React Router 库还有一个重要的部分,你可能需要了解它是如何构建生产应用程序的:如何钩入路由生命周期。
4.1.4. 理解路由生命周期
React Router 提供了生命周期钩子,允许你在路由之间添加逻辑。生命周期钩子的一个常见用例是为你的应用程序添加页面视图跟踪分析,以便你知道每个路由有多少次查看。
注意
如果你使用的是 React Router 4,请查看附录 A(kindle_split_027_split_000.xhtml#app01),了解如何将此代码移动到 React 生命周期中,以及如何处理本节讨论的概念。
想象一下,如果你尝试将此逻辑添加到你的组件中。你最终会在每个顶级组件(购物车、产品、个人资料)中添加跟踪逻辑。或者你可能会尝试基于 App 组件中的属性来检测变化。这两种方法都不理想,并且留下了很多错误的空间。
相反,你想要使用 onChange 和 onEnter 生命周期事件来处理 React Router。(第三个生命周期钩子 onLeave 在这里没有涉及。)图 4.4 显示了这些处理器触发的顺序。
图 4.4. 根路由的 onEnter 处理器只触发一次,但 onChange 处理器在每次后续路由变化时都会触发。

对于每个路由,当应用从一个不同的路由跳转到该路由时,会触发 onEnter 事件。因为 / 是根路由,它只能进入一次。每次子路由发生变化时,都会触发 onChange 处理器。对于根路由,这发生在第一次路由动作之后的每个路由动作。下面的列表显示了如何在 sharedRoutes.jsx 文件中实现这些处理器。如果你正在跟随,并想查看前几节中的代码,你可以在 branch chapter-4.1.4 (git checkout chapter-4.1.4) 上找到它。
列表 4.7. 在路由器中使用 onChange—src/shared/searchRoutes.jsx
const trackPageView = () => { *1*
console.log('Tracked a pageview');
};
const onEnter = () => { *2*
console.log('OnEnter');
trackPageView();
};
const onChange = () => { *3*
console.log('OnChange');
trackPageView();
};
const routes = (
<Route path="/" component={App} onEnter={onEnter} onChange={onChange}> *4*
<Route path="/cart" component={Cart} />
<Route path="/products" component={Products} />
<Route path="/profile" component={Profile} />
</Route>
);
-
1 用于跟踪页面视图的可重用函数(在现实世界中,你会调用你的分析工具)
-
2
onEnter处理器的处理器—记录 OnEnter -
3
onChange处理器的处理器—记录 OnChange -
4 每个路由都可以有一个
onEnter和/或onChange属性。
接下来,你将探索 React 的组件生命周期,这是一个完全不同的、特定于 React 的生命周期函数集。生命周期函数让你能够更好地控制应用中事件发生的时间。
4.2. 组件生命周期
一个拥有用户账户的网站需要登录。网站的某些部分始终会被锁定,只有登录后才能查看。例如,在 All Things Westies 应用中,想要查看设置页面以更新密码或查看过去订单的用户需要登录。
这个用例与上一节中的分析用例相反。你不想在每次路由上执行某些操作,而只想在特定路由上检查登录状态。如果你愿意,你可以在路由上使用 onChange 或 onEnter 处理器来做这件事。但你也可以将这个逻辑放在适当的 React 组件中。在这个例子中,我们将使用组件生命周期。
React 提供了几个钩子,用于组件的生命周期。你已经使用过的渲染函数就是生命周期的一部分。组件的生命周期可以分为三个部分(如图 4.5 所示):
-
挂载事件— 发生在 React 元素(组件类的实例)附加到 DOM 节点时。这是你处理登录检查的地方。
-
更新事件— 发生在 React 元素更新时,无论是由于其属性或状态的新值引起的。如果你在组件中有一个计时器,你会在这些函数中管理它。
-
卸载事件—— 当 React 元素从 DOM 中分离时发生。如果你在组件中有一个计时器,你将在这里清理它.^([1])
¹
React 生命周期列表和插图概念来自 Azat Mardan 的《React Quickly》(Manning,2017,
www.manning.com/books/react-quickly)。
图 4.5。React 生命周期包括三种类型的生命周期事件。每种类型都有对应的方法钩子。

4.2.1. 利用挂载和更新来检测用户的登录状态
为了检测用户是否已登录,你将利用 React 的一个生命周期函数。这个函数在组件挂载(附加到 DOM)之前触发。列表 4.8 展示了如何在componentWillMount中添加检查到用户配置文件组件中。有一个用于 Profile 的占位符,你将想要用此代码更新它。如果你正在跟随并想查看前几节中的代码,切换到分支 4.2.1(git checkout chapter-4.2.1)。
列表 4.8。使用生命周期事件——src/components/profile.jsx
class Profile extends React.Component {
componentWillMount() {
if (!this.props.user) { *1*
this.props.router.push('/login'); *2*
}
}
render() {}
}
-
1 检查用户属性——如果不存在,假设用户需要登录。
-
2 强制用户使用路由对象路由到登录页面。
在profile.jsx文件中,你添加了对路由属性router的引用。但如果你现在运行代码并加载/profile路由,应用将抛出错误,因为你还没有传递路由对象。为此,你需要更新app.jsx以向其子组件传递属性。以下列表利用了两个 React 顶级 API 调用:React.Children和React.cloneElement。
列表 4.9。向子组件传递props——src/components/app.jsx
const App = (props) => {
return (
<div>
<div className="ui fixed inverted menu"></div>
<div className="ui main text container">
{
React.Children.map( *1*
props.children, *2*
(child) => { *3*
return React.cloneElement( *4*
child, *4*
{ router: props.router } *4*
);
}
)
}
</div>
</div>
);
};
-
1 使用 React.Children.map 顶级 API 方法遍历当前子属性。
-
2 地图函数接受
props.children作为其第一个参数。 -
3 使用 React.cloneElement 顶级 API 来复制当前子元素并传递额外的属性。
-
4 第二个参数是一个回调函数,它为每个子元素被调用。
第一个渲染周期
在同构应用中,第一个渲染周期是最重要的。在那里,你将使用生命周期事件来控制代码运行的环境。例如,一些第三方库在服务器上不可加载或不可用,因为它们依赖于window对象。或者你可能想在窗口事件上添加自定义滚动行为。你需要通过挂钩到第一个渲染周期上可用的各种生命周期方法来控制这一点。
第一个渲染生命周期由三个函数(render和两个挂载事件)组成:
-
componentWillMount()— 在渲染之前和组件挂载到 DOM 之前发生 -
render()— 渲染组件 -
componentDidMount()— 在渲染之后和组件挂载到 DOM 上之后发生
对于同构用例,需要注意componentWillMount和componentDidMount之间的一些区别。尽管这两种方法在浏览器上都会恰好运行一次,但componentWillMount在服务器上运行,而componentDidMount永远不会在服务器上运行。在先前的例子中,你不会想在componentWillMount中运行用户登录检查,因为该检查也会在服务器上运行。相反,你会在componentDidMount中放置检查,确保它仅在浏览器中发生。
componentDidMount永远不会在服务器上运行,因为 React 永远不会在服务器上附加任何组件到 DOM。相反,React 的renderToString(在服务器上代替render使用)会生成 DOM 的字符串表示形式。在下一节中,你将使用componentDidMount为模态添加计时器——你只想在浏览器中执行的操作。
4.2.2. 添加计时器
假设你想要向产品页面添加一个倒计时计时器。这个计时器在设定的时间后启动一个工具提示模态。显示了它的样子。计时器是异步的,会打断用户事件驱动的 React 更新流程。但 React 提供了几个生命周期方法,可以在 React 组件的生命周期内处理计时器。
图 4.6. 显示为用户提示的提示框

要向组件添加计时器,需要在组件挂载后启动它。此外,你还需要处理组件卸载或发生某些其他操作时的计时器清理。要查看本节的基础代码,切换到分支 chapter 4.2.2(git checkout chapter-4.2.2)。以下列表显示了如何将计时器代码添加到 products.jsx 中。基础组件已经存在,因此更新加粗的代码。
列表 4.10. 添加计时器—src/components/products.jsx
import React from 'react';
class Products extends React.Component {
constructor(props) {
super(props);
this.state = {
showToolTip: false,
searchQuery: ''
};
this.updateSearchQuery = this.updateSearchQuery.bind(this);
}
componentDidMount() {
setTimeout(() => {
this.setState({ *1*
showToolTip: true
});
}, 10000); *1*
}
updateSearchQuery() { *2*
this.setState({
searchQuery: this.search.value *3*
});
}
render() {
const toolTip = ( *4*
<div className="tooltip ui inverted">
Not sure where to start? Try top Picks.
</div>
);
return (
<div className="products">
<div className="ui search">
<div className="ui item input">
<input
className="prompt"
type="text"
value={this.state.searchQuery} *5*
ref={(input) => { this.search = input; }} *3*
onChange={this.updateSearchQuery} *2*
/>
<i className="search icon" />
</div>
<div className="results" />
</div>
<h1 className="ui dividing header">Shop by Category</h1>
<div className="ui doubling four column grid">
<div className="column segment secondary"></div>
<div className="column segment secondary"></div>
<div className="column segment secondary">
<i className="heart icon" />
<div className="category-title">Top Picks</div>
{ this.state.showToolTip ? toolTip : ''} *6*
</div>
<div className="column segment secondary"></div>
</div>
</div>
);
}
}
export default Products;
-
1 在 componentDidMount 中触发计时器——setTimeout 回调在 10 秒后将设置组件状态。
-
2 搜索输入的更改处理程序,设置 searchQuery 的状态。
-
3 搜索是通过从保存到 this.search 的输入元素中获取值来设置的。
-
4 div 元素显示工具提示(将其声明为变量可以使三元表达式更易读)。它仅在 showToolTip 为 true 时显示(在计时器触发后)。
-
5 输入值与状态绑定,因此输入使用组件状态作为其真相来源。
-
6 div 元素显示工具提示(将其声明为变量可以使三元表达式更易读)。它仅在 showToolTip 为 true 时显示(在计时器触发后)。
工具提示将在此时出现(设置为 10 秒后显示)。但让我们假设你只想在用户从未与页面交互时显示工具提示。在这种情况下,你需要一种在用户交互时清除工具提示的方法。技术上,你可以在搜索的onChange处理程序中这样做,但为了说明目的,你将在componentWillUpdate中添加这个功能。下面的列表显示了如何做到这一点。
列表 4.11. 在用户交互时清除计时器—src/components/products.jsx
class Products extends React.Component {
componentDidMount() {
this.clearTimer = setTimeout(() => { *1*
this.setState({
showToolTip: true
});
}, 10000);
}
componentWillUpdate(nextProps, nextState) { *2*
if (nextState.searchQuery.length > 0) {
clearTimeout(this.clearTimer); *3*
}
console.log('cWU'); *4*
}
updateSearchQuery() {}
}
-
1 捕获 setTimeout 的返回值,以便可以清除计时器。
-
2 当组件接收到新状态时,检查状态中是否存在搜索查询。
-
3 清除计时器。
-
4 日志显示,每当在搜索框中输入字母时,
componentWillUpdate方法都会触发(缩写为 cWU)。
如果你重启应用并在 10 秒计时器完成之前与产品页面交互,你会注意到工具提示从未出现。
更新生命周期
更新生命周期方法由几个更新方法和render方法组成,你可以在列表中看到。除了render方法外,这些方法永远不会在服务器上运行(因此访问 window 和 document 是安全的):
-
componentWillReceiveProps(nextProps)— 当组件即将接收属性时发生(仅在父组件中发生更新时运行) -
shouldComponentUpdate(nextProps, nextState) -> bool— 允许你通过确定组件何时需要更新来优化渲染周期数 -
componentWillUpdate(nextProps, nextState)— 在组件渲染之前发生 -
render()— 渲染组件 -
componentDidUpdate(prevProps, prevState)— 在组件渲染后立即发生^([2])²
基于 Azat Mardan 的 React Quickly(Manning,2017)的更新生命周期。
小贴士
记住,挂载生命周期将始终在这些方法之前运行。
卸载事件
需要对计时器进行的最后一次改进是确保在用户在计时器完成之前离开产品页面时,计时器能够被清理。如果你不这样做,你将在 10 秒后在控制台中看到 React 错误。错误解释说正在运行的代码正在尝试引用 DOM 中不再挂载的组件。这是因为你在没有关闭计时器的情况下离开了计时器所在的组件。图 4.7 是错误的截图。
图 4.7. 如果组件已卸载,但监听器或计时器没有被清理,它们最终会得到一个指向 null 组件的引用。

下面的列表显示了如何将超时清理添加到你的componentWillUnmount生命周期函数中。
列表 4.12. 清理计时器—src/components/products.jsx
class Products extends React.Component {
componentWillUpdate(nextProps, nextState) {}
componentWillUnmount() {
clearTimeout(this.clearTimer); *1*
}
updateSearchQuery() {}
}
- 1 在卸载时清除计时器。
只有一个卸载事件:componentWillUnmount()。你可以利用这个事件来清理任何手动附加的事件监听器并关闭你可能正在运行的任何计时器。此方法仅在浏览器中运行。要查看本章的所有代码,你可以查看分支 chapter-4-complete (git checkout chapter-4-complete)。
现在你已经了解了 React 生命周期,让我们来探索可以帮助你构建出色的 React 应用的组件架构模式。
4.3. 组件模式
你可以在用户界面中以两种明确的方式编写 React 组件:
-
高阶组件
-
展示组件和容器组件
在 All Things Westies 应用程序中,创建视图和业务逻辑的可重用部分是有益的。这对开发者的长期可维护性有好处,并使你的代码更容易推理。
在某些情况下,通过创建一个接受另一个组件并扩展其功能的组件来增加可重用性——这是一个装饰器。这在 Redux 中通过使用 Connect 组件包装视图组件时发生。在其他情况下,你将组件分为两种类型:关注业务逻辑的组件和关注应用程序外观的组件。例如,产品组件关注视图的业务逻辑。
4.3.1. 高阶组件
当构建模块化、组件驱动的 UI 时,你最终会有很多需要相同类型数据获取或具有相同视图但不同数据获取的组件。例如,你可能有很多使用用户数据的视图。或者你可能有很多使用列表组件但数据集不同的视图。在这些情况下,你想要一种方法来提取数据获取和操作逻辑,使其与显示数据的组件分离。
即使你还没有向 All Things Westies 应用程序添加任何数据获取功能,你最终也需要这样做。产品视图需要了解可销售的产品。想象一下,如果你想创建一个知道如何获取所有产品的组件。它看起来可能像这样:
const ProductsDataFetcher = (Component) => {
... // fetches the products data
... // ensures data is compatible with the products component
return <Component data={this.state.data} />
}
这个示例函数最重要的部分是,你将组件(在这个例子中是产品组件)传递给 ProductsDataFetcher 函数。在这种情况下,高阶组件(HOC)函数知道如何获取产品数据,然后会将这些数据传递给组件(图 4.8)。这抽象掉了从产品视图组件中任何状态或逻辑,使其专注于 UI 问题。
图 4.8. 高阶函数接受一个函数并返回一个具有附加功能的新函数。(摘自 Azat Mardan 的《React 快速入门》,Manning,2017 年。

如果你有一个组件,然后将其传递给高阶组件,你最终会得到原始组件以及额外的功能。在 React 中,这几乎总是导致将某种状态管理卸载到父 HOC。在 ListDataFetcher 示例中,HOC 了解应用状态和数据获取。这使得列表组件成为一个高度可重用的展示组件。
4.3.2. 组件类型:展示和容器
可以将 React 组件分为两个不同的类别:展示者和容器。通过遵循这种二进制类型模式,你可以最大化代码重用并最小化不必要的代码耦合和复杂性。
在本章的早期部分,你构建了 All Things Westies 应用程序的产品页面。这个页面包含一个名为 Products 的组件,它负责其应用部分的状态。在本书的后续部分,它还将负责通过 Redux 管理数据获取。这些职责使其成为一个容器组件。
另一方面,Item 和 App 组件是展示组件。它们都包含显示元素,并依赖于属性来确定其功能。展示组件决定了应用程序的外观。
表 4.1 列出了容器和展示组件的值。
表 4.1. 组件类型属性
| 容器 | 展示 |
|---|---|
| 包含状态 | 有限的状态(用户交互),理想情况下作为函数组件实现 |
| 负责应用的工作方式 | 负责应用的外观 |
| 子组件:容器和展示组件 | 子组件:容器和展示组件 |
| 连接到应用的其他部分(例如,Redux) | 不依赖于模型或控制器部分的应用(例如,Redux) |
容器组件将状态从其子组件中抽象出来。它们还处理布局,通常负责应用程序的“如何”。一些高阶组件的主要目的就是如此。它们监听数据变化,然后将该状态作为属性传递下去。Redux 提供了一个帮助实现这一功能的高阶组件(见第六章)。
展示组件只包含与用户交互相关的状态。尽可能情况下,它们应该作为纯组件实现。它们关注的是应用程序的外观。
一个重要的注意事项是,容器可以有其他容器和展示组件作为子组件。相反,展示组件可以有容器和展示组件作为子组件。这两种类型的组件嵌套应该保持灵活,以最大化代码组合。这一开始可能感觉有些奇怪,但保持两种组件类型清晰将有助于你长期发展。
摘要
在本章中,您学习了如何设置和使用 React Router 以获得完整的单页应用体验。您还通过探索组件生命周期对 React 有了更深入的了解。最后,您学习了在构建 React 应用时常用的一些关键模式。
-
React Router 使用 React 的组件概念将路由组合到任何 React 应用中。
-
React Router 抽象了历史对象,并提供了链接的实用工具。
-
React Router 具有路由钩子,允许您添加高级逻辑。
-
React 生命周期方法被用作渲染周期的钩子。
-
初始渲染周期可以用来触发计时器或锁定已登录的路由。
-
可用于以可重用和可维护的方式组合 React 组件的许多组件模式。
第五章. 工具:webpack 和 Babel
本章涵盖
-
使用 webpack 通过 npm 加载 Node.js 包,以便在浏览器代码中使用
-
使用 webpack 加载器使用 Babel 编译代码
-
使用 webpack 加载器加载 CSS
-
使用 webpack 插件准备您的代码以供生产使用
-
创建多个配置以管理多个环境下的构建
JavaScript 生态系统提供了许多优秀的库和工具,使开发者能够更快、更轻松地编写应用程序。为了利用它们,您需要具备能够编译、转换和为生产准备代码的工具。这就是 webpack,一个完全由配置驱动的构建工具。
我将完全诚实地告诉您:webpack 不是一个直观的工具。起初我发现它与工作起来很令人沮丧。但它非常强大,值得学习。Webpack 让您能够在构建中包含任何 JavaScript 代码,甚至包括那些尚未设置在浏览器中运行的库(例如,npm 包)。它还可以处理许多其他构建步骤,包括使用 Babel 编译您的代码以及为生产准备您的代码。本章涵盖了您在异构项目中拥有良好工作流程所需的所有基础知识。
注意
如果您想使用已经为 webpack 设置好的 React 项目开始,我推荐 Create React App。这个工具生成一个带有 webpack 的基础 React 应用(github.com/facebookincubator/create-react-app)。请注意,它不是异构的!
5.1. Webpack 概述
想象一下您正在启动一个新的异构 React 项目。您想要构建一个像图 5.1 中的日历提醒应用。本章是关于构建工具——所以这只是一个路标,帮助您确保一切都在加载。
图 5.1. 本章中将使用 webpack 设置的日历提醒应用

在这个示例中,您决定不从头开始构建日历。在 npm 上有许多编写良好的 React 日历包可用。为了使用这些包并在它们之上构建自己的应用,您需要一个构建工具,该工具将以浏览器理解的方式打包您的 Java-Script 模块。(此外,本章是关于构建工具,而不是制作应用,因此使用包将让您专注于学习 webpack。)
如果您想知道为什么您需要学习另一个构建工具,请给我几分钟时间来说服您。让我们讨论应用需求,以确定为什么需要构建工具。这样,您不必仅凭我的话来相信。表 5.1 给出了应用需求及其需要 webpack 的原因概述。
表 5.1. 各类应用需求概述,这些需求使得构建工具变得必要
| 需求 | Webpack 需要 | 原因 |
|---|---|---|
| 日历小部件(react-big-calendar) | 是 | 从 npm 包中导入。特别是,这是无法通过 Gulp 和 Grunt 或 npm 构建脚本实现的事情。 |
| ES6 | 是 | 需要编译才能在所有浏览器中工作。这可以通过其他工具实现,但 webpack 加载器使其变得简单直接。 |
| 加载 CSS | 可选 | 通过包含在 webpack 构建中优化开发流程。这无法通过 Gulp 或 Grunt 等工具实现。 |
| 环境特定代码 | 是 | Webpack 插件允许您注入自定义变量。这无法通过 Gulp 或 Grunt 实现。 |
此外,还有许多其他原因使得构建工具对于应用是必需的。您想使用 ES6 编写最新的 JavaScript 代码,但 ES6 在不同浏览器中的支持是混合的。为了让您能够使用所有最新的语言特性,您需要编译您的代码。
最后,为了加载 CSS,您需要 webpack 加载器。您还需要 webpack 插件来将变量注入到代码中。记住,所有代码都将运行在服务器和浏览器中!(如果这个提醒开始感觉重复,那很好——您正在朝着同构思维的方向前进。)
运行代码
本章的所有代码都位于 GitHub 上,地址为 github.com/isomorphic-dev-js/chapter5-webpack-babel。在您使用 Git 检出代码后,您需要进行 npm install:
$ npm install
要运行完整示例,请运行以下命令:
$ npm run start
然后您可以在 http://localhost:3050/ 加载日历示例。您将在本章中用到额外的脚本和示例。我会根据需要解释它们。
在我们深入 webpack 配置的具体细节之前,我将向您展示如何设置您的环境以与 webpack 一起工作,包括如何运行 webpack 命令行界面(CLI)。命令行工具对于调试问题和在小项目上工作非常有用。在本章的后面部分,您将学习如何通过 JSON 配置使用 webpack。
要使用 webpack,你需要安装它(我已经在仓库中设置了 webpack)。我建议按项目安装,而不是全局安装,这样你可以使用适合每个项目的 webpack 版本。
安装 webpack 后,你可以使用 webpack CLI 生成你的第一个 webpack 包。语法如下。
定义
包 是 webpack 转换管道输出的文件。你可以将包命名为任何你想要的。

要在仓库中运行此命令,请在你的终端中输入此命令:
$ node_modules/.bin/webpack --entry ./src/entry.js --output-filename
output.js --output-path ./
运行此命令后,你会在项目代码目录的根目录中注意到一个名为 output.js 的新文件,如列表 5.1 所示。此文件包含从 entry.js 编译的代码以及任何依赖项。首先查看 entry.js 文件的内容(使用 ES5 编写—本章后面,你将添加 Babel 来编译 ES6)。此代码已在仓库中提供。
列表 5.1. Entry.js 内容—src/entry.js
var path = require('path'); *1*
console.log("path to root", path.resolve('../')); *2*
-
1 包含的路径作为依赖项—使用 require() 而不是 import,因为此代码不是由 Babel 编译的。
-
2 使用 path.resolve 和相对路径记录根文件夹的路径
此代码的编译版本接近 400 行代码,其中一些将在下一列表中显示。这是因为 webpack 收集所有引用的文件(在这种情况下是节点模块路径)并将它们包含在打包输出中。
列表 5.2. 编译后的 webpack 输出,部分视图—output.js
/* ...additional file contents */
/******/ // Load entry module and return exports, and additional
/******/polyfills/webpack code
/* WEBPACK VAR INJECTION */}.call(
/***/ exports,
/***/__webpack_require__(1))) *1*
/***/ }),
/* 4 */
/***/ (function(module, exports, __webpack_require__) { *1*
"use strict";
module.exports = __webpack_require__(31); *2*
/***/ }),
/* 5 */ *3*
/***/ (function(module, exports, __webpack_require__) {
/* additional file contents... */
-
1 Webpack 将模块(你的代码,任何包含的 npm 库)包裹在 JavaScript 闭包中,这使得 webpack 能够控制并重写导入语句。
-
2 路径的 require 语句编译成自定义的 webpack require 语句。路径模块位于数字键中。
-
3 添加到最终输出的可读性注释,指示每个模块的编号(有助于调试)。
打包的代码包括 webpack 库的一部分额外函数。这包括 Node.js 进程对象的浏览器友好 polyfill,这允许你安全地包含许多最初为 Node.js 编写的 npm 模块。
有一些例外。例如,Node.js 文件系统(fs)模块不适合同构使用。如果一个 npm 包依赖于 fs 模块,你不应该将其用于浏览器代码。现在你已经看到了如何使用命令行来打包你的代码,看看图 5.2。它显示了 webpack 如何处理你的代码并创建打包输出。
图 5.2. webpack 编译器流程

调试 webpack
有时 webpack 可能无法编译。你有两个有用的命令行选项用于调试。第一个选项,--debug,在命令行中显示错误。第二个选项,--display-error-details,提供了关于发生的任何错误的额外详细信息。
Webpack 也可以使用node --inspect进行调试。这将加载一个调试工具,你可以用它通过 Chrome DevTools 查看 Node.js 代码。然后你可以使用断点进行调试。有关更多--inspect资源,请参阅nodejs.org/en/docs/inspector/.
现在你已经了解了如何使用 webpack 并探索了 webpack 包的各个部分,你将学习如何使用 webpack 加载器来编译你的代码。
5.2. Babel 概述
Babel 是一个用于编译 JavaScript 的工具。它将尚未在所有 JavaScript 环境中得到支持的代码编译成浏览器可以理解的形式。如果你想使用 JavaScript 规范中最新的和最好的部分(ES6、ES7,有时也称为 ES2015、ES2016 等),你必须使用编译器。需要注意的是,Node.js 的最新版本现在支持大多数(但不是全部)JavaScript 规范。但在浏览器中,支持情况各异,并且推出速度较慢。
在上一节中,你了解到 webpack 是一个将许多加载器和插件组合在一起以创建单个捆绑代码文件的工具。Babel 是一个库,它只做一件事情:编译 JavaScript。Babel 是你可以与 webpack 一起使用的许多加载器之一。
5.2.1. 开始使用 Babel
Babel 编译成任何运行 ES5 JavaScript 的浏览器都能理解的代码。生成的输出是可读的,如以下列表所示,并包括 Babel 注入的代码,这些代码有助于将 ES6 语法转换为旧版 JavaScript 引擎可以理解的形式。
列表 5.3. Babel 示例输出
'use strict';
var _createClass = function () {/*implementation code*/}(); *1*
var _react = require('react'); *2*
var _react2 = _interopRequireDefault(_react); *3*
function _interopRequireDefault(obj) {
/*implementation code*/
} *4*
var Link = function Link(props) { *5*
return _react2.default.createElement(
'a',
{ href: props.link },
props.children
);
};
var Button = function (_React$Component) {
_{/*implementation code*/}
}
_createClass(Button, [{ *6*
key: 'render',
value: function render() {{/*implementation code*/}
}]);
return Button;
}(_react2.default.Component);
-
1 Babel 注入的函数将 ES6 类转换为 ES5
-
2 所有导入语句已转换为 requires
-
3 每个导入语句转换为两个 requires:一个是标准的 ES5 require,另一个使用特殊函数以确保 ES6 的 export default 功能正常工作。
-
4 Babel 注入的函数用于检查默认导出
-
5 将 ES6 风格的函数转换为 function() {}语法
-
6 在 ES6 中以类形式编写的按钮,因此在这里它被 Babel 的 _createClass 辅助函数包装。
列表 5.3 中的编译代码基于列表 5.4 中的 ES6 代码。请注意,基本代码要简单得多,并且不包括 Babel 添加的许多辅助函数,这些辅助函数有助于在尚未支持 ES6 的 JavaScript 环境中运行你的代码。
列表 5.4. 要编译的 ES6 代码—src/compile-me.js
import React from 'react'; *1*
import classnames from 'classnames'; *1*
const Link = (props) => { *2*
return (<a href={this.props.link}>
{this.props.children}
</a>)
}
class Button extends React.Component { *3*
constructor(props) {
super(props);
}
render() {
let classes = 'button'; *4*
if (this.props.classname) {
classes = classnames(classes, this.props.classnames);
}
return (
<Button
className={classnames}
onClick={this.props.clickHandler}> *5*
{this.props.children}
</Button>
);
}
}
-
1 目前,所有环境的导入语句(node 和浏览器)都需要编译。
-
2 ES6 函数语法具有父作用域而不是调用者作用域
-
3 类声明
-
4 令变量(或在其他情况下,const)
-
5 Babel 编译器也编译 JSX。
5.2.2. Babel CLI
Babel 可以作为一个独立的命令行工具使用。要开始并了解此工具的工作原理,您将使用 Babel CLI 处理来自 列表 5.3 的 ES6:
$ ./node_modules/.bin/babel src/
compile-me.jsx
Babel 接收输入(src/compile-me.js 中的代码),解析它,转换它,然后生成与标准浏览器和 Node.js 环境兼容的代码版本。图 5.3 展示了此编译流程。您会注意到此流程与 webpack 的流程类似。
图 5.3. Babel 编译器如何将 ES6 转换为浏览器和 Node.js 兼容的代码

本节中的命令将结果输出到命令行。稍后,您将使用 Babel 将代码作为 webpack 构建的一部分进行编译。
Babel 插件和预设
默认情况下,Babel 不知道使用哪些规则来编译您的代码。但您可以使用插件来告诉 Babel 要做什么。方便的是,这些插件通常被分组为 预设。预设和插件需要从 npm 安装。如果您想使用 Babel React 预设,您将安装以下内容:
$ npm install babel-preset-react
如果您正在使用提供的代码,本章所需的全部预设已安装。在您安装了所有想要使用的 Babel 预设后,您可以在存储库中提供的 .babelrc 文件中引用它们,如下所示列表。
列表 5.5. .babelrc 配置文件—.babelrc
{
"presets": ["es2015", "react"] *1*
}
- 1 告诉 Babel 在编译时使用哪些预设(您可以在数组中列出多个预设)。
这些预设告诉 Babel 将所有内容编译为 ES2015 并正确处理 JSX。接下来,我们将查看日历应用的代码。
5.3. 应用代码
在本章的其余部分,当您运行 webpack 时,您将编译日历应用的代码。此示例由两个文件组成:src/app.jsx 和 src/main.jsx。
webpack 构建入口文件是 main.jsx。以下列表显示了存储库中提供的代码。
列表 5.6. 入口文件—src/main.jsx
import React from 'react'; *1*
import ReactDOM from 'react-dom'; *1*
import App from './app'; *1*
ReactDOM.render(
<App></App>,
document.getElementById('attach-react') *2*
-
1 引入所有依赖,包括 React 和 App 组件
-
2 将根组件(App)附加到 DOM 上。
入口文件包含一个名为 App 的组件,来自 src/app.jsx。在下一列表中显示,此组件包含 npm 包并渲染 React Big Calendar 组件。它还包括日历的 CSS。当您学习 webpack 加载器时,我们将更详细地讨论以这种格式包含 CSS。
列表 5.7. App 组件—src/app.jsx
import React, { Component } from 'react'; *1*
import BigCalendar from 'react-big-calendar'; *1*
import moment from 'moment'; *1*
require('react-big-calendar/lib/css/
react-big-calendar'); *2*
BigCalendar.momentLocalizer(moment); *3*
class App extends Component { *4*
render() {
return (
<div className="calendar-app">
<BigCalendar *5*
events={[]}
startAccessor='startDate'
endAccessor='endDate'
timeslots={3}>
</BigCalendar>
</div>
)
}
}
export default App;
);
-
1 引入所有依赖,包括 moment(react-big-calendar 需要一个日期库)。
-
2 引入 react-big-calendar 伴随的 CSS。
-
3 初始化 Calendar 组件。
-
4 创建 App 组件。
-
5 使用所需的属性渲染 Big Calendar。
这些文件很简单,但将允许你在不受到示例干扰的情况下学习 webpack。它们需要 Babel 来编译 ES6 功能(import、class)和 JSX。你还需要正确加载 CSS。下一节将展示如何从 JavaScript 文件配置 webpack,并介绍使用加载器。
5.4. 带有加载器的 Webpack 配置
在本章前面,你通过命令行使用 webpack 捆绑了你的代码。但 webpack 也可以通过 JavaScript 配置文件进行配置。按照惯例,这个文件被称为 webpack.config.js。(在你的项目中,你可以使用任何你想要的名称。)
配置文件由webpack命令加载。默认情况下,命令将查找一个名为 webpack.config.js 的文件。要加载默认配置,请在终端中运行以下命令:
$ ./node_modules/.bin/webpack
这将加载配置文件,编译你的代码,并输出一个可以随后在浏览器中加载的捆绑文件。最基本的配置文件包括一个入口点和输出信息(如果你发现自己需要像这样简单的配置,你也可以坚持使用本章开头介绍的命令行选项),如下所示。
列表 5.8. webpack.config.js
var path = require('path');
module.exports = {
entry: path.resolve(__dirname + '/src/main.js'), *1*
output: { *2*
path: path.resolve(__dirname + '/'), *3*
filename: 'webpack-bundle.js' *4*
}
}
-
1 入口点使用 Node.js 路径模块解析相对于当前目录的路径(对持续集成工具很有帮助)。
-
2 声明输出对象。
-
3 使用路径模块解析输出路径(在本例中,为根目录)。
-
4 声明输出文件的名称。
接下来,你将添加 webpack 加载器来编译你的 ES6 和 CSS。
5.4.1. 配置 Babel 加载器
要在 webpack 中使用 Babel,你需要两样东西。首先,你仍然需要本章前面看到的.babelrc 文件。这告诉 Babel 要编译哪些预设(React 和 ES6)。其次,你需要在 webpack 配置中声明 Babel 为加载器。列表 5.9 显示了加载器的代码。
提示
请密切注意配置对象的形状——否则,你的构建将静默失败。这将会引发一系列灾难性事件,以及对所有构建工具的突然反感。非常认真地说,如果你的 webpack 构建静默失败,请检查你是否把所有属性都放在了正确的位置。你可以使用--debug和--progress选项来帮助你调试(更多信息请参阅webpack.js.org/api/cli/#debug-options)。
列表 5.9. 添加 Babel 加载器 webpack.config.js
var path = require('path');
module.exports = {
entry: path.resolve(__dirname + '/src/main.js'),
output: {
path: path.resolve(__dirname + '/'),
filename: 'webpack-bundle.js'
},
module: { *1*
rules: [ *2*
{
test: /\.(js|jsx)$/, *3*
exclude: /node_modules/, *4*
loader: "babel-loader" *5*
}
]
}
}
-
1 添加模块对象。
-
2 要使用的所有加载器数组(称为规则)
-
3 正则表达式确定哪些文件应该由这个加载器处理——对于 Babel,我们希望是 js 和 jsx 文件。
-
4 你可以告诉加载器忽略文件,Node.js 包已经编译,因此不需要再次处理它们。
-
5 声明应为此加载器配置使用哪个加载器。
加载器在 webpack 编译过程的解析步骤中应用。 展示了这如何融入整个 webpack 流程。请注意,这将会发生多次,因为每个依赖项可能通过一个或多个加载器。
图 5.4. 在 webpack 编译的解析阶段应用加载器。

使用自定义扩展
当编写 JSX 和 ES6 时,能够用除 .js 之外的其他扩展名声明你的文件很方便。这向其他开发者(在某些情况下,向你的 IDE)表明该文件是特定语法类型。
也可以很方便地不必在 import 语句上编写扩展。这对于某些测试设置正常工作可能是必需的。在 webpack 中,为了覆盖这些用例,你添加 resolve 属性并声明一个要使用的扩展数组。请参见以下列表。
列表 5.10. 扩展列表 webpack.config.js
module.exports = {
entry: path.resolve(__dirname + '/src/main.js'),
output: {},
module: {},
resolve: { *1*
extensions: ['.js', '.jsx', '.css'] *2*
}
}
-
1 添加 resolve 对象。
-
2 声明扩展数组——例如,对于日历,你需要 .js、.jsx 和 .css。
5.4.2. 配置 CSS 加载器
Webpack 可以将几乎所有内容打包到你的 JavaScript 捆绑包中,包括你的 CSS。这对于开发来说很棒,但对于许多生产用途,你仍然希望单独加载你的 CSS 和其他资源(别担心,webpack 也可以做到这一点!)请参见下一列表。
列表 5.11. 使用 webpack 包含 CSS—webpack.config.js
module.exports = {
...
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
loader: "babel-loader"
},
{
test:/\.css/, *1*
loaders: ['style-loader', 'css-loader'] *2*
}
]
}
}
-
1 对于此加载器,你需要处理的是 CSS 文件,所以正则表达式查找 .css。
-
2 此加载器使用两个 webpack 加载器,style-loader 和 CSS-loader—注意键已从 loader 更改为 loaders,因为列表被声明为字符串数组。
此代码添加了两个加载器。CSS 加载器将任何 import 和 url() 引用解释为 require。style loader 将样式包含在捆绑包中,以便你的 CSS 可在浏览器中使用。
通过包含 CSS,你可以利用组件样式的编写方式。为此,你为每个组件创建一组样式并将它们命名空间化。然后你不必担心覆盖常见的类名,如 .button 或 .active。
此外,我发现这种模块化 CSS 对于大型开发团队来说更容易理解,尽管有一些权衡。一个主要的权衡是你往往会得到更少的 DRY(不要重复自己)CSS。但如果你使用 PostCSS 或其他编译 CSS 选项(LESS、SASS 等),可以通过共享全局类或混入来解决这个问题。
其他加载器
可以使用许多其他加载器与 webpack 一起使用。你可以加载各种文件,包括 JSON、HTML 和图像资源。你还可以使用 LESS/SASS/PostCSS 预处理你首选的 CSS。
你还可以使用加载器进行代码检查。例如,如果你想在你项目中使用 ESLint,有一个为它准备的 webpack 加载器(第四章 中介绍的示例使用了 ESLint)。几乎任何你可以在 web 应用程序项目中想到的事情都有加载器!
要查看 webpack 加载器的列表,请访问 webpack.js.org/loaders/。
5.5. 开发和生产的打包
到目前为止,你只为你的开发环境使用了一个配置文件。但对于一个真实世界的应用,你需要为多个环境准备你的 webpack 配置文件。
为了简化,你将设置两个针对特定环境的配置文件,分别命名为 dev.config.js 和 prod.config.js。因为这些文件只是 JavaScript,你可以创建一个基础文件,命名为 base.config.js。所有这些文件都将位于 config 文件夹中。
基础文件与本章中已经创建的 webpack.config.js 文件相同。其他两个文件需要它,然后扩展配置。首先你将在 dev.config.js 中添加一个 webpack 插件。
5.5.1. 使用 webpack 插件
webpack 插件是你可以包含在 webpack 配置的插件数组中的额外代码模块。webpack 库附带了一些内置插件。许多插件也可以在 npm 上找到,你甚至可以编写自己的插件。
对于开发配置,你需要 html-webpack-plugin。此插件自动生成一个加载打包 JavaScript 的 HTML 文件。这已在 dev.config.js 文件中设置,如下所示。
列表 5.12. 在 config/dev.config.js 中添加插件
var baseConfig = require('./base.config.js'); *1*
var HtmlWebpackPlugin = require('html-webpack-plugin'); *2*
module.exports = Object.assign(baseConfig, { *3*
output: {
filename: 'dev-bundle.js' *4*
},
plugins: [ *5*
new HtmlWebpackPlugin({ *6*
title: "Calendar App Dev", *7*
filename: 'bundle.html' *8*
})
]
})
-
1 需要基础配置对象。
-
2 包含 html-webpack-plugin 以自动生成 HTML 文件。
-
3 使用 Object.assign 将特定环境的配置合并到 baseConfig;此配置的键将覆盖基础配置。
-
4 声明特定环境的文件名。
-
5 声明插件数组。
-
6 创建 HtmlWebpackPlugin 的新实例。
-
7 标题属性设置 HTML 模板中的
标签。</strong></p> </li> <li> <p><strong><em>8</em> 生成 HTML 的输出文件名。</strong></p> </li> </ul> <p>要使用此代码,请运行以下命令:</p> <pre><code>$ npm run dev </code></pre> <p>然后,你可以导航到 <a href="http://localhost:3050/bundle.html" target="_blank">http://localhost:3050/bundle.html</a> 来查看开发包。</p> <p>插件可以钩入 webpack 编译器的各种步骤。它们可以在编译步骤的开始处添加代码,在优化步骤中,在文件输出阶段,以及在 webpack 编译器的许多其他阶段。</p> <p>HTML webpack 插件的大部分工作是在输出步骤中完成的,因为其主要任务是创建一个文件。输出步骤是基于所有之前的编译步骤创建文件的地方。</p> <h4 id="552-创建全局变量">5.5.2. 创建全局变量</h4> <p>你也可以使用插件来定义环境变量。Webpack 内置了一个名为 definePlugin 的插件,允许你将变量注入到 webpack 模块中,如列表 5.13 所示。然后你可以在代码中访问这些变量:</p> <pre><code>console.log("current environment: ", __ENV__); </code></pre> <p>在编译步骤中,webpack 将变量转换为注入的值。在这种情况下,包中的代码将看起来像这样:</p> <pre><code>console.log("current environment: ", ("dev")); </code></pre> <h5 id="列表-513-注入全局变量configdevconfigjs">列表 5.13. 注入全局变量—config/dev.config.js</h5> <pre><code>var webpack = require('webpack'); *1* var injectEnvironment = new webpack.DefinePlugin({ *2* __ENV__: JSON.stringify("dev") *3* }); module.exports = Object.assign(baseConfig, { output: {}, plugins: [ ..., injectEnvironment *4* ] }) </code></pre> <ul> <li> <p><strong><em>1</em> 因为 Define-Plugin 是 webpack 内置的,所以需要引入 webpack。</strong></p> </li> <li> <p><strong><em>2</em> 创建 DefinePlugin 的新实例。</strong></p> </li> <li> <p><strong><em>3</em> 在这里注入任意数量的变量——在这种情况下,设置环境值。</strong></p> </li> <li> <p><strong><em>4</em> 在插件数组中加载插件。</strong></p> </li> </ul> <p>DefinePlugin 的问题之一是,对于字符串,使用 JSON.stringify 是很重要的。如果你只是分配一个字符串(<code>__ENV__: "dev"</code>),那么在打包版本中,你将得到以下输出:</p> <pre><code>console.log("current environment: ", (dev)); </code></pre> <p>这将在你的浏览器中抛出一个 <code>ReferenceError</code>,因为它会将 <code>dev</code> 作为一个 JavaScript 变量来读取。</p> <h4 id="553-使用-sourcemaps">5.5.3. 使用 sourcemaps</h4> <p>使用 webpack 开发的一个缺点是,打包后的代码不可读,并且不再类似于源代码。幸运的是,对于调试来说,webpack 提供了启用 sourcemaps 的能力。没有 sourcemaps,很难将代码错误匹配到你的文件结构中。</p> <p>在启用 sourcemaps 的情况下,webpack 会生成额外的代码(有时是内联的,有时是单独的文件),这些代码将生成的代码映射回原始文件结构。这在调试时很有帮助,因为像 Chrome DevTools 这样的工具将允许你检查原始代码而不是编译后的代码。图 5.5 展示了 Chrome DevTools 如何加载原始文件。</p> <h5 id="图-55-chrome-devtools-使用生成的-sourcemaps-将编译后的代码链接到源文件">图 5.5. Chrome DevTools 使用生成的 sourcemaps 将编译后的代码链接到源文件。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/05fig05_alt.jpg" alt="" loading="lazy"></p> <p>启用 sourcemaps 很简单,如列表 5.14 所示。你只需将 <code>devtool</code> 属性添加到 webpack 配置中,即可从 webpack 配置文件中启用 sourcemaps。根据 webpack 文档,你可以使用此 sourcemap 选项进行生产。对于更大的项目,这可能会对性能产生影响,所以请谨慎行事。</p> <h5 id="列表-514-添加-sourcemapsconfigdevconfigjs">列表 5.14. 添加 sourcemaps—config/dev.config.js</h5> <pre><code>module.exports = Object.assign(baseConfig, { output: {}, devtool: 'source-map', plugins: [] }) </code></pre> <p>webpack 有几个有效的 sourcemap 选项可用。每个选项都在性能和开发者可读性之间做出权衡。dev.config.js 中的选项输出一个单独的映射文件并加载完整的原始源代码。但它在较慢的一侧。如果你需要调整 sourcemap 选项,我建议查看 webpack 的 sourcemaps 文档<a href="https://webpack.js.org/configuration/devtool/" target="_blank"><code>webpack.js.org/configuration/devtool/</code></a>。</p> <p>接下来,我们将使用 webpack 插件创建一个生产就绪的配置。</p> <h4 id="554-准备生产构建">5.5.4. 准备生产构建</h4> <p>为了准备生产构建,你需要做一些事情:</p> <ul> <li> <p>尽可能使包尽可能小:添加用于压缩和去重代码的插件</p> </li> <li> <p>注入生产环境变量</p> </li> <li> <p>输出到不同的输出包</p> </li> </ul> <p>目标是最终生成一个尽可能小的、非人类可读的、最小化脚本的文件。它看起来可能像图 5.6。</p> <h5 id="图-56-使用额外的-webpack-插件编译的生产输出">图 5.6. 使用额外的 webpack 插件编译的生产输出</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/05fig06_alt.jpg" alt="" loading="lazy"></p> <p>为了最终生成生产输出,你将使用一些额外的插件来准备代码。以下列表展示了如何创建一个为生产准备好的 webpack 配置文件。</p> <h5 id="列表-515-生产构建configprodconfigjs">列表 5.15. 生产构建—config/prod.config.js</h5> <pre><code>var baseConfig = require('./base.config.js'); var webpack = require('webpack'); var HtmlWebpackPlugin = require('html-webpack-plugin'); var injectEnvironment = new webpack.DefinePlugin({ __ENV__: JSON.stringify("prod") *1* }); module.exports = Object.assign(baseConfig, { output: { filename: 'prod-bundle.js' *2* }, plugins: [ new webpack.optimize.UglifyJsPlugin({ *3* compress: { warnings: false, drop_console: true } }), new HtmlWebpackPlugin({ title: "Calendar App", filename: 'prod-bundle.html' *4* }) injectEnvironment ] }); </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> 修改输出 HTML 文件的文件名。</strong></p> </li> </ul> <p>插件有许多用途,可以帮助你从配置到压缩你的输出。因此,webpack 附带了一些插件,可以帮助你为生产准备你的构建。除了前面列表中显示的插件外,你还可以在 webpack 网站(<a href="https://webpack.js.org/configuration/plugins/" target="_blank"><code>webpack.js.org/configuration/plugins/</code></a>)和 npm 网站(<a href="http://www.npmjs.com/search?q=webpack+plugin" target="_blank">www.npmjs.com/search?q=webpack+plugin</a>)上了解其他 webpack 插件。截至 webpack 的最新版本,模块包含去重和发生顺序是默认行为。</p> <h3 id="摘要-3">摘要</h3> <p>在本章中,你了解到 webpack 是一个强大的构建工具,可以用来将你的项目编译成浏览器包。你学习了如何单独使用 Babel,以及作为 webpack 的一部分通过加载器使用 Babel。这两个工具在许多 JavaScript 环境中都很有用,并且是个人工具箱中很好的补充。</p> <ul> <li> <p>使用 webpack,你可以编译你的 JavaScript 代码,包括 npm 模块。</p> </li> <li> <p>使用 Babel 编译器,你可以使用 JavaScript 的最新功能,同时仍然能在所有浏览器和 Node.js 环境中运行你的代码。</p> </li> <li> <p>加载器是 webpack 的附加模块,允许你使用额外的工具,如 Babel 来打包你的代码。</p> </li> <li> <p>CSS 可以通过 webpack 使用加载器加载和编译。</p> </li> <li> <p>插件是 webpack 编译器的强大附加组件,为你提供了访问许多额外功能的方法,包括为你的 webpack 代码准备构建和自动生成 HTML 包装器。</p> </li> </ul> <h2 id="第六章-redux">第六章. Redux</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>使用 Redux 管理你的应用程序状态</p> </li> <li> <p>将 Redux 作为架构模式实现</p> </li> <li> <p>使用 actions 管理你的应用程序状态</p> </li> <li> <p>使用 reducers 强制不可变性</p> </li> <li> <p>应用中间件进行调试和异步调用</p> </li> <li> <p>使用 Redux 与 React 结合</p> </li> </ul> <p>Redux 是一个提供编写业务逻辑架构的库。在 React 应用中,你可以在根组件内处理大部分应用状态。但随着你的应用增长,你最终会得到一组复杂的回调,需要传递给所有子组件以管理应用状态更新。Redux 通过以下方式提供存储应用状态的替代方案:</p> <ul> <li> <p>在你的视图和业务逻辑之间建立清晰的通信线路</p> </li> <li> <p>允许你的视图订阅应用状态,以便每次状态更新时都能进行更新</p> </li> <li> <p>强制不可变的应用状态</p> </li> </ul> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="定义-10">定义</h5> <p>不可变对象是只读的。要更新不可变对象,你需要克隆它。当你用 JavaScript 改变一个对象时,它会影响到对该对象的任何引用。这意味着可变更改可能产生意外的副作用。通过在你的存储中强制不可变性,你可以在你的应用中防止这种情况发生。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h3 id="61-redux-简介">6.1. Redux 简介</h3> <p>Redux 规定了将应用状态更新写入单个根存储的单向流。存储可以是一个简单的或复杂的 JavaScript 对象,这取决于你的应用需求。Redux 处理将更新连接到存储。它还处理存储的任何订阅者,并在存储对象更新时通知他们。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="定义-11">定义</h5> <p>Redux 存储是一个 <em>单例</em>(每个应用只有一个实例)对象,它包含所有你的应用状态。存储可以被传递到你的视图中以便显示和更新你的应用。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>Redux 可以连接到任何视图,但它与 React 的配合尤其出色。React 的自顶向下流通过嵌套组件的 props 和状态与 Redux 的单向状态更新流配合得很好。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="注意-16">注意</h5> <p>React 的状态与 Redux 的应用状态不同!React 的状态是局部化的,位于你的应用中的每个组件。它可以在 React 生命周期内更新和受影响。它应该很少使用,但在处理用户输入的组件以及有时在容器组件中经常需要使用。第三章 更详细地解释了 React 的状态。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h4 id="611-从通知示例应用开始">6.1.1. 从通知示例应用开始</h4> <p>本章的代码可以在 <a href="https://github.com/isomorphic-dev-js/chapter6-redux" target="_blank"><code>github.com/isomorphic-dev-js/chapter6-redux</code></a> 找到。所有代码都位于 master 分支上,或者你可以跟随教程自己构建它。要运行应用:</p> <pre><code>$ npm install $ npm start </code></pre> <p>然后,应用将在 <a href="http://localhost:3000" target="_blank">http://localhost:3000</a> 运行。</p> <p>你将构建一个显示消息处于三种状态(错误、警告或成功)的通知应用程序。想法是应用程序从各种分页应用程序、持续集成构建工具和其他系统(如 GitHub、TravisCI、CircleCI、VictorOps、PagerDuty 等)接收更新。然后,它在适当的架子上显示通知。应用程序还有一个可以更新的设置面板和一个调试面板,允许你分发通知进行测试。图 6.1 展示了正在运行的应用程序。</p> <h5 id="图-61-通知更新应用程序发送和接收通知">图 6.1. 通知更新应用程序—发送和接收通知</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/06fig01_alt.jpg" alt="图片" loading="lazy"></p> <p>代码中已经设置了一些 React 组件和 webpack。我不会在这些主题上花费太多时间,这样你可以专注于学习 Redux。如果你想复习 React,可以查看第三章和第四章。对于 webpack,请查看第五章。</p> <p>还要注意,在 Node 服务器上有一个内存对象,它为这个项目的简单 CRUD(创建、读取、更新、删除)服务提供备份。如果你要在现实世界中构建这个项目,你将想要探索使用 WebSocket 连接并连接数据库。界面中的“发送通知”部分允许你模拟应用程序接收来自服务的警报,而无需将其连接到任何真实输入。</p> <h4 id="612-redux-概述">6.1.2. Redux 概述</h4> <p>在本章的第一部分,我们将遍历 Redux 中所有必需的组件,以便在你的应用程序中实现更新流动。图 6.2 在通知应用程序的上下文中回顾了 Redux 的单向更新流程,并介绍了 Redux 的三个主要部分:</p> <ul> <li> <p><strong><em>动作</em>—</strong> 实现业务逻辑,例如更新设置或向列表中添加新通知</p> </li> <li> <p><strong><em>还原器</em>—</strong> 将由动作触发的状态更改写入存储</p> </li> <li> <p><strong><em>存储</em>—</strong> 当前应用程序状态,包含通知数组和应用程序的任何设置值</p> </li> </ul> <h5 id="图-62-redux-单向流程从视图">图 6.2. Redux 单向流程从视图</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/06fig02_alt.jpg" alt="图片" loading="lazy"></p> <h5 id="连接-react-和-redux">连接 React 和 Redux</h5> <p>在本章的第二部分,你将学习如何使用 React Redux 库将你的 React 视图连接到 Redux 应用程序状态。这包括使用库提供的顶层组件 Provider,它接收存储并将其提供给另一个名为 connect 的组件。connect 组件是一个高阶组件,它包装了你的应用程序中的某些组件。这些包装组件随后能够以属性的形式接收存储更新。connect 组件具有 React 状态,因此你的其他组件不需要有 React 状态!图 6.3 展示了这些组件如何融入你的应用程序结构。</p> <h5 id="图-63-使用-react-redux-的-provider-和-connect-组件将-react-视图与应用程序状态连接起来">图 6.3. 使用 React Redux 的 Provider 和 connect 组件将 React 视图与应用程序状态连接起来</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/06fig03_alt.jpg" alt="" loading="lazy"></p> <h3 id="62-redux-作为一种架构模式">6.2. Redux 作为一种架构模式</h3> <p>通常,在构建 Web 应用程序时,你会使用模型-视图-控制器(MVC)模式。许多常见的框架都使用这种模式。在这种情况下,有一个视图,即应用程序的 HTML,一个模型,它代表应用程序状态的一种表示,以及一个控制器,它是用户与之交互的界面。业务逻辑也由控制器处理。</p> <p>例如,Angular 1 和 Ember 这样的框架各自有自己的 MVC 实现,但历史上它们都使用双向绑定来处理框架的视图控制器部分。Angular 1 的流程与传统 MVC 不同,因为视图实际上是一个视图控制器(始终与容器组件相同,如我们在第三章中讨论的)。但框架仍然试图遵循 MVC 模式。这导致流程混乱和难以调试的代码。</p> <p>让我们来看看,如果我们将这种模式应用到本章将要构建的应用程序中,会是什么样子。图 6.4 展示了在这种情况下应用程序流程的工作方式。</p> <h5 id="图-64-angular-1-中的模型-视图-控制器mvc流程">图 6.4. Angular 1 中的模型-视图-控制器(MVC)流程</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/06fig04_alt.jpg" alt="" loading="lazy"></p> <p>Redux 的实现与 MVC 有一些重叠。我喜欢将其视为 MVC 的一种演变,它更适合基于 UI 的应用程序(与服务/CRUD 应用程序相反)。有几个主要区别:</p> <ul> <li> <p>Redux 坚持单向数据流,导致代码易于跟踪且没有副作用。</p> </li> <li> <p>没有控制器。相反,视图也是控制器——称为<em>视图控制器</em>。在这种情况下,视图控制器是 React。这很好地适应了浏览器模型,其中视图由 HTML 渲染,用户事件由 DOM 处理。</p> </li> <li> <p>在 Redux 中,始终只有一个单一的根存储,它代表应用程序状态。这简化了大部分逻辑,因为视图只需要订阅根存储,然后关注它们感兴趣的特定子树。</p> </li> </ul> <p>Redux 流程依赖于存储来分发动作。<code>dispatch</code>函数是根存储的一个钩子,允许你在存储上触发动作。有时你会触发对存储的同步更新,有时你会触发一个最终会更新存储的异步调用。此外,视图能够订阅存储,并在更新完成后收到通知。图 6.5 说明了这个流程。</p> <h5 id="图-65-由用户动作触发的-redux-流程">图 6.5. 由用户动作触发的 Redux 流程</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/06fig05_alt.jpg" alt="" loading="lazy"></p> <p>Redux 实现(您将编写的代码部分)由 store、actions 和 reducers 组成。store 保存您的应用程序状态。actions 负责您的业务逻辑。reducer 被调用以更新 store。</p> <h5 id="定义-12">定义</h5> <p>Redux 中的<em>store</em>是您的应用程序模型。它保存应用程序的当前状态。我将使用<em>store</em>和<em>state</em>互换来说明 Redux 中的模型。</p> <p>回顾一下,Redux 为管理您的应用程序状态提供了一个具体模式,这对于开发者来说易于使用。它还使推理和调试您的应用程序变得简单。</p> <h3 id="63-管理应用程序状态">6.3. 管理应用程序状态</h3> <p>Redux 的主要任务是允许您的状态(或模型)和视图进行通信。这是通过允许视图订阅状态更新并触发状态更新来实现的。图 6.6 显示了在示例应用程序上下文中的此流程。</p> <h5 id="图-66-视图和-redux-之间的信息流">图 6.6. 视图和 Redux 之间的信息流</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/06fig06_alt.jpg" alt="" loading="lazy"></p> <p>Redux 状态可以是普通的 JavaScript 对象。包含状态对象的 store 有多个可以在其上调用的方法。以下是我将介绍的方法:</p> <ul> <li> <p><strong><code>dispatch(action)</code>—</strong> 触发 store 上的更新(图 6.6 中的步骤 1)。</p> </li> <li> <p><strong><code>getState()</code>—</strong> 返回当前 store 对象(列表 6.1 显示了其外观)</p> </li> <li> <p><strong><code>subscribe()</code>—</strong> 监听 store 上的变化(图 6.6 中的步骤 2)。</p> </li> </ul> <p>在将操作派发到 store 后,状态将匹配以下列表中的代码。</p> <h5 id="列表-61-示例存储对象应用程序状态">列表 6.1. 示例存储对象(应用程序状态)</h5> <pre><code>{ notifications: { *1* all: [ *2* { serviceId: 1, messageType: "success", message: "Code was pushed!" }, { serviceId: 3, messageType: "error", message: "Service unavailable in region 1" }, { serviceId: 2, messageType: "warning", message: "Warning: build is taking a long time" } ] } settings: { *3* refresh: 30 *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> refresh 属性允许用户设置更新长轮询的速率。</strong></p> </li> </ul> <p>Redux 提供了一种初始化状态(store)的方法。它管理 store 的更新流,并通知订阅者(视图)。要在您的应用程序中配置 store,您需要创建您的 reducer,然后使用它们初始化 store。以下列表显示了这是如何工作的;您可以在 repo 中的 src/init-redux.es6 中找到此代码。</p> <h5 id="列表-62-初始化-reduxsrcinit-reduxes6">列表 6.2. 初始化 Redux—src/init-redux.es6</h5> <pre><code>import { createStore, combineReducers } from 'redux'; *1* import notifications from './notifications-reducer'; *2* import settings from './settings-reducer'; *2* export default function (){ *3* const reducer = combineReducers({ *4* notifications, *4* settings *4* }); return createStore(reducer) *5* } </code></pre> <ul> <li> <p><strong><em>1</em> 从 Redux 导入辅助方法。</strong></p> </li> <li> <p><strong><em>2</em> 导入应用程序 reducer。</strong></p> </li> <li> <p><strong><em>3</em> 导出可以从其他模块调用的函数(使其可重用,因此可以从同构应用程序的浏览器和服务器调用)。</strong></p> </li> <li> <p><strong><em>4</em> 从 Redux 调用 combineReducers 辅助方法;从多个 reducer 构建 reducer 映射。</strong></p> </li> <li> <p><strong><em>5</em> 调用 createStore,传入组合的 reducer—这里您将拥有 store.notifications 和 store.settings。</strong></p> </li> </ul> <p>如果你没有使用 Redux 与 React(本章后面你将学习如何使用 redux-react 将两个库连接起来),你需要手动订阅 store 更新。<code>subscribe</code>函数像一个标准的 JavaScript 事件处理器。你传入一个函数,每次 store 更新时都会被调用。但 store 不会将其状态传递给更新处理器函数;相反,你调用<code>getState()</code>来访问当前状态。以下列表显示了此代码的示例,你可以在 main.jsx 中找到它。</p> <h5 id="列表-63-使用-react-redux-之外的订阅-storesrcmainjsx">列表 6.3. 使用 React Redux 之外的订阅 store—src/main.jsx</h5> <pre><code>const store = initRedux(); *1* store.subscribe(() => { *2* console.log("Store updated", store.getState()); *3* // do something here }); </code></pre> <ul> <li> <p><strong><em>1</em> 初始化 store(见列表 6.2)。</strong></p> </li> <li> <p><strong><em>2</em> 在 store 上调用 subscribe()方法,并传入一个处理更新的函数。</strong></p> </li> <li> <p><strong><em>3</em> 通过调用 getState()记录 store 的当前状态。</strong></p> </li> </ul> <p>接下来,你将编写一个 reducer,并了解如何在 Redux 中保持不可变性。</p> <h4 id="631-reducers-更新状态">6.3.1. Reducers: 更新状态</h4> <p>Reducers 有一个特殊的名字,但分解开来,它们是纯函数。每个 reducer 接收 store 和一个 action,并返回一个新的、修改后的 store。图 6.7 显示了 reducer 函数的功能性。</p> <h5 id="图-67-纯-reducer-函数的输入和输出流程">图 6.7. 纯 reducer 函数的输入和输出流程</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/06fig07_alt.jpg" alt="" loading="lazy"></p> <p>通知应用中的 reducer 是动作和 store 之间的连接线。它们是唯一应该写入 store 更新的代码部分。任何其他写入 store 的代码都是反模式。以下列表显示了 settings 的 reducer 函数。</p> <h5 id="列表-64-设置-reducerssrcsettings-state">列表 6.4. 设置 reducers—src/settings-state</h5> <pre><code>import { UPDATE_REFRESH_DELAY } from './settings-action-creators'; *1* export default function settings(state = {}, action) { *2* switch (action.type) { *3* case UPDATE_REFRESH_DELAY: return { ...state, *4* refresh: action.time }) default: *5* return state } } </code></pre> <ul> <li> <p><strong><em>1</em> 包含动作的字符串常量。</strong></p> </li> <li> <p><strong><em>2</em> 函数定义—每个 reducer 接受两个参数,store 状态和 action。如果状态不存在,默认为空对象。</strong></p> </li> <li> <p><strong><em>3</em> 使用 switch 语句声明你的 reducer 逻辑—始终根据 action.type 的值确定要运行的 case。</strong></p> </li> <li> <p><strong><em>4</em> 当刷新值更新时,使用扩展运算符来复制并创建新的 store 以保持不可变性。</strong></p> </li> <li> <p><strong><em>5</em> 如果没有匹配的 case,仍然返回 store,因为这仍然是一个纯函数。</strong></p> </li> </ul> <p>关于 reducers,有两个重要点需要理解:</p> <ul> <li> <p><strong><em>Reducer 必须始终是纯函数</em>—</strong> 它们接收值,使用这些值创建一个新的 store,然后返回一个 store。</p> </li> <li> <p><strong><em>Reducer 必须强制 store 的不可变性质</em>—</strong> 如果需要更新,函数接收到的 store 必须被克隆。</p> </li> </ul> <p>这两个概念都防止了意外的副作用。接下来的几节将解释纯函数和不可变性。</p> <h5 id="纯函数">纯函数</h5> <p>编写还原器最重要的部分之一是确保函数保持纯函数(没有副作用)。<em>纯</em>函数接受用于计算返回值的参数—they 不使用任何状态或对状态进行操作。没有副作用的代码有许多好处,包括更易于测试、更容易理解和防止难以调试的问题。让我们看看一个有副作用的函数示例,然后将其与纯函数进行比较。以下列表显示了纯函数和非纯函数之间的区别。</p> <h5 id="列表-65-纯函数示例">列表 6.5. 纯函数示例</h5> <pre><code>// side effect let result; function add(a, b) { result = a + b; *1* } add(1, 2); *2* console.log(result); // logs 3 *2* // functional – no side effects function add(a, b) { return a + b; *3* } console.log(add(1,2); // logs 3 *4* </code></pre> <ul> <li> <p><strong><em>1</em> 函数不返回任何内容,但更新 result 的值。</strong></p> </li> <li> <p><strong><em>2</em> 当在这种情况下调用 add 时,您可以记录结果以查看发生了什么(全局状态)。</strong></p> </li> <li> <p><strong><em>3</em> 在这个函数中,返回 add 的结果。</strong></p> </li> <li> <p><strong><em>4</em> 这次记录调用 add 函数的结果——没有状态。</strong></p> </li> </ul> <h5 id="强制不可变存储">强制不可变存储</h5> <p>另一种使代码易于理解和调试的方法是确保应用状态(或存储)始终是不可变的。不强制不可变性的风险是您最终会遇到难以追踪的问题,这些问题是由代码其他部分的变化引起的。通过每次创建一个新对象,您确保其他代码不会意外地更改整个应用状态。</p> <p>为了在您的存储中强制不可变性,您需要注意几个事项。让我们从如何确保您的对象保持不可变开始,如下所示。</p> <h5 id="列表-66-可变对象与不可变对象">列表 6.6. 可变对象与不可变对象</h5> <pre><code>// mutation: bad function addNotification(item, key, state) { *1* return state[key] = item; *2* } //immutable: good function addNotification(item, key, state) { *1* return { ...state, key: item } *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>在这里,您可以看到不可变方式返回存储对象涉及 JavaScript 函数展开运算符。您通过展开旧对象并添加任何新或更新的键来创建一个新对象。新键将覆盖旧键。但是,如果您有一个深度嵌套的对象,您需要在这里构建完整的对象或使用辅助库来管理深度嵌套的键。</p> <p>同样,数组也需要保持不可变。对于数组,直接将项目推入数组是一个可变操作,因此需要创建一个新数组。以下列表演示了如何正确和不正确地执行此操作。</p> <h5 id="列表-67-不可变数组">列表 6.7. 不可变数组</h5> <pre><code>// bad: mutating the original array function addItem(item) { return itemsArray.push(item) *1* } // good: creating a new array function addItem(item) { return [...itemsArray, item] *2* } </code></pre> <ul> <li> <p><strong><em>1</em> 将项目推入数组,返回原始数组——这是一个可变操作。</strong></p> </li> <li> <p><strong><em>2</em> 显示不可变方法:返回包含原始数组和新项目的全新数组;使用展开运算符将项目推入数组。</strong></p> </li> </ul> <h4 id="632-动作触发状态更新">6.3.2. 动作:触发状态更新</h4> <p>在 Redux 应用程序中,动作是触发应用程序状态更新的唯一方式。这很重要,以确保你的应用程序强制执行单向流。(技术上可以直接更新 store,但你绝对不应该这样做)。只有由动作触发的 reducer 应该更新状态。</p> <p>因为默认情况下动作是同步的,所以任何需要进行的更新都可以快速发生。实际上,分发器本身是完全同步的。默认情况下,Redux 只支持同步动作。(在本章的后面,你将学习如何使用中间件与 Redux 一起使用,以便允许异步动作。)</p> <h5 id="小贴士-2">小贴士</h5> <p>你不能从 reducer 中分发动作。这会破坏 Redux 的单向流,并可能导致不希望出现的副作用。不用担心,Redux 不会让你这样做,但避免以那种方式思考更新是很重要的。</p> <p>最简单的操作是一个具有一个名为 <code>type</code> 的属性的物体:</p> <pre><code>{ type: 'UPDATE' } </code></pre> <p>动作通常是包含要更新 store 中的数据以及 <code>type</code> 属性的对象。因为你的应用程序中的大多数动作都将被多个视图重用,所以建议创建可重用的函数,称为 <em>动作创建者</em>,它返回你想要分发的动作。</p> <p>动作创建者文件也是定义你的动作字符串常量的好地方。这通过确保动作创建者分发的动作类型值与 reducer 寻找的相同来减少错误。如果你启用了静态类型检查或类似功能,这也可以在某些 IDE 中提高开发者的速度。</p> <p>你可以在下一个列表中看到这两个概念。此代码可以在存储库中找到。列表显示了一个用于更新应用程序长轮询功能的超时时间间隔的动作。</p> <h5 id="列表-68-同步动作srcsettings-action-creatorses6">列表 6.8. 同步动作——src/settings-action-creators.es6</h5> <pre><code>export const UPDATE_REFRESH_DELAY = 'UPDATE_REFRESH_DELAY'; *1* export function updateRefreshDelay(time) { *2* return { type: UPDATE_REFRESH_DELAY, *3* time: time *4* } } </code></pre> <ul> <li> <p><strong><em>1</em> 将类型值设置为常量可以减少错误</strong></p> </li> <li> <p><strong><em>2</em> 动作创建者函数声明接受一个名为 time 的参数。</strong></p> </li> <li> <p><strong><em>3</em> 返回的动作有两个属性——type 属性是必需的,其值始终是一个字符串。</strong></p> </li> <li> <p><strong><em>4</em> 将时间属性添加到动作中,以便在更新时视图可以使用该值——每个动作将具有不同的数据属性。</strong></p> </li> </ul> <p>你可以在 reducer 的第一行使用 <code>const</code> 来确保动作创建者和 reducer 指向相同的值。要向 store 分发此更新,你只需要在 <code>store</code> 上调用 <code>dispatch</code> 并传递动作。因为你在使用动作创建者,所以你调用动作创建者并将结果传递给 <code>dispatch</code>:</p> <pre><code>store.dispatch(updateRefreshDelay(5)); </code></pre> <p>然后,reducer 将被触发,并且 store 将被更新。</p> <p>接下来,你将学习如何设置 Redux 与中间件一起使用,这样你就可以包含额外的功能,例如进行异步调用。</p> <h3 id="64-将中间件应用于-redux">6.4. 将中间件应用于 Redux</h3> <p>Redux 包含一个辅助方法,允许你扩展派发器的默认功能。对于你应用到派发器的每个中间件,它都会向调用链中添加一个函数,该函数将在最终默认派发行为之前发生。以下是一个简化的示例:</p> <pre><code>middleware1(dispatchedAction).middleware2(dispatchedAction).middleware3(dispa tchedAction).dispatch(dispatchedAction) </code></pre> <p>这允许你添加调试和异步调用的功能。首先,让我们看看如何添加调试。</p> <h4 id="641-中间件基础调试">6.4.1. 中间件基础:调试</h4> <p>使用中间件可以添加改进的调试功能。一个例子是 Redux Logger 库。这个库可以帮助你在控制台中清晰地看到状态变化。图 6.8 展示了示例动作日志。</p> <h5 id="图-68-redux-logger-控制台输出">图 6.8. Redux Logger 控制台输出</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/06fig08_alt.jpg" alt="" loading="lazy"></p> <p>你在实例化 store 时添加中间件。以下列表显示了如何进行操作。代码也可以在仓库中找到。</p> <h5 id="列表-69-设置中间件srcinit-reduxes6">列表 6.9. 设置中间件—src/init-redux.es6</h5> <pre><code>export default function () { const reducer = combineReducers({...}); let middleware = [logger]; *1* return compose( *2* applyMiddleware(...middleware) *3* )(createStore)(reducer); } </code></pre> <ul> <li> <p><strong><em>1</em> 创建中间件数组,这样你可以传递任意数量的中间件并轻松控制顺序。</strong></p> </li> <li> <p><strong><em>2</em> 调用 compose 并传入 store,以便中间件应用于 store。</strong></p> </li> <li> <p><strong><em>3</em> 在中间件数组上调用 applyMiddleware 以正确设置中间件</strong></p> </li> </ul> <p>当你运行应用时,你会在控制台看到日志;这对于调试很有帮助。</p> <h4 id="642-处理异步动作">6.4.2. 处理异步动作</h4> <p>在本章的早期部分,你通过编写返回动作对象的函数来分发动作。正如之前所述,我们称这些函数为动作创建器。<em>异步动作创建器</em>应用相同的原理,但它们不会立即返回对象,而是等待某个事件发生(例如,网络调用完成)然后返回动作对象。</p> <p>要做到这一点,你需要访问动作创建器函数内部的 <code>dispatch</code> 对象。这需要另一个中间件库,称为 Redux Thunk。要使用中间件,你需要将其添加到 init-redux.es6 中的中间件数组中(参考列表 6.9)。它已经在代码仓库中的代码里了。</p> <p>然后为了利用这个中间件,你编写一个看起来像这样的动作创建器:</p> <pre><code>export const UPDATE_ACTION = 'UPDATE_ACTION'; export function actionCreator() { return dispatch => { return dispatch({ type: UPDATE_ACTION }) } } </code></pre> <p>通过添加 Thunk 中间件,你现在可以在动作创建器中的 store 内访问 <code>dispatch</code> 函数(中间件所做的只是将 <code>dispatch</code> 参数提供给你的返回函数)。请注意,你还需要导出你的动作创建器和相应的动作 <code>const</code>。这与本章早些时候创建的同步动作创建器相同。</p> <p>在通知应用中,你需要三个异步动作:添加通知、获取通知和删除通知。以下列表显示了获取通知的动作创建器。代码可以在仓库中找到,与其他动作创建器一起。</p> <h5 id="列表-610-异步动作创建器srcaction-creatorses6">列表 6.10. 异步动作创建器—src/action-creators.es6</h5> <pre><code>import request from 'isomorphic-fetch'; *1* export const FETCH_NOTIFICATIONS = 'FETCH_NOTIFICATIONS'; *2* export function fetchNotifications() { return dispatch => { *3* let headers = new Headers({ *4* "Content-Type": "application/json", }); return fetch( 'http://localhost:3000/notifications', *5* { headers: headers } ) .then((response)=>{ *6* return response.json().then(data => { *7* return dispatch({ type: FETCH_NOTIFICATIONS, *8* notifications: data }) }) }) } } </code></pre> <ul> <li> <p><strong><em>1</em> 使用 isomorphic fetch,以便服务器和浏览器都可以处理 fetch 调用。</strong></p> </li> <li> <p><strong><em>2</em> 动作类型的 Const</strong></p> </li> <li> <p><strong><em>3</em> 动作创建器返回一个函数而不是一个对象。Thunk 中间件调用此函数并注入存储中的 dispatch 方法。</strong></p> </li> <li> <p><strong><em>4</em> 创建标题以与 API 通信。</strong></p> </li> <li> <p><strong><em>5</em> 使用 URL 和选项调用 fetch。</strong></p> </li> <li> <p><strong><em>6</em> Promise 处理器</strong></p> </li> <li> <p><strong><em>7</em> 从响应中获取 JSON—因为这也是一个 promise,添加第二个 promise 处理器。</strong></p> </li> <li> <p><strong><em>8</em> 获取数据后,分发操作</strong></p> </li> </ul> <p>现在你已经看到了 Redux 红 ucer 和动作是如何工作的,让我们来看看如何将 React 和 Redux 连接起来。</p> <h3 id="65-在-react-组件中使用-redux">6.5. 在 React 组件中使用 Redux</h3> <p>在 React 应用中,操作通常是从组件中分发的。为了在组件中访问存储,你需要将你的 React 组件连接到 Redux。我建议使用 react-redux 库,这是 Redux 作者提供的官方 React 绑定。它实现了订阅和接收 Redux 存储更新的所有必要代码。</p> <p>这有两个不同的部分。一个是顶级根组件,称为 Provider。另一个是高阶组件(HOC),称为 connect。</p> <h4 id="651-使用-provider-包装你的应用">6.5.1. 使用 provider 包装你的应用</h4> <p>首先,你需要将存储传递到你的应用中。你希望将其作为 React 属性向下传递。记住,React 组件有一个名为 <code>props</code> 的属性。<code>props</code> 对象是通过从父 React 组件向下传递值到其子组件而创建的。此对象是不可变的,只能从父组件中更改。</p> <p>因为你也想能够订阅存储,所以你应该使用 React Redux 中的 Provider 组件。这个 React 组件作为你应用的根组件,使存储对 connect HOC 可用。以下列表显示了如何做到这一点。</p> <h5 id="列表-611-将-redux-连接到-reactsrcmainjsx">列表 6.11. 将 Redux 连接到 React—src/main.jsx</h5> <pre><code>import React from 'react'; import ReactDOM from 'react-dom'; import App from './components/app.jsx'; import { Provider } from 'react-redux'; *1* import initRedux from './init-redux.es6'; require('./style.css'); const initialState = window.__INITIAL_STATE; const store = initRedux(initialState); store.subscribe(() => { console.log("Store updated", store.getState()); // if not using React, do something here }); ReactDOM.render( <Provider store={store}><App /></Provider>, *2* document.getElementById('react-content') ); </code></pre> <ul> <li> <p><strong><em>1</em> 组件接收存储并将其正确传递给其子组件。</strong></p> </li> <li> <p><strong><em>2</em> 在 Provider 内部渲染 App 组件,以便它能够访问存储并将存储传递给 Provider 组件。</strong></p> </li> </ul> <p>现在你已经可以在你的组件中访问存储了。但是,你需要做几件事情来完全连接你的应用和 Redux。</p> <h4 id="652-从-react-订阅存储">6.5.2. 从 React 订阅存储</h4> <p>获取存储更新的第二部分是将你的容器组件包裹在 connect HOC 中。这个组件为你处理订阅存储。它持有所有必要的 React 状态,以向下传递属性给其子组件。</p> <p>连接 HOC 还提供了辅助方法,使将存储映射到属性和从视图中调用操作变得更加容易。将组件包裹在 connect 中然后导出以供应用使用看起来是这样的:</p> <pre><code>export default connect(mapStateToProps, mapDispatchToProps)(Component); </code></pre> <p><code>mapStateToProps</code> 和 <code>mapDispatchToProps</code> 是 connect 运行的两个辅助回调函数。第一个,<code>mapStateToProps</code>,在存储更新时运行。在其内部,你将定义哪些存储项应该映射到 React <code>props</code>。下面的列表显示了这是如何工作的。</p> <h5 id="列表-612-将-react-连接到-reduxsrccomponentsappjsx">列表 6.12. 将 React 连接到 Redux—src/components/app.jsx</h5> <pre><code>class App extends React.Component { componentDidMount() {} getSystemNotifications(id) { let items = []; if (this.props.all) { *1* this.props.all.forEach((item, index)=>{ *2* if (item.serviceId == id) { let classes = classnames("ui", "message", item.messageType); items.push( <div className={classes} key={index}> <i className="close icon" onClick={ this.dismiss.bind(this, index) }> </i> <p> {item.message} </p> </div> ) } }) } return items; } render() {} } function mapStateToProps(state) { *3* let { all } = state.notifications; *4* let { refresh } = state.settings; *4* return { *5* all, refresh } } function mapDispatchToProps(dispatch) {} export default connect( mapStateToProps, mapDispatchToProps )(App) *6* </code></pre> <ul> <li> <p><strong><em>1</em> 组件直接在 props 上访问通知。</strong></p> </li> <li> <p><strong><em>2</em> 使用通知数组,构建一个通知项数组。</strong></p> </li> <li> <p><strong><em>3</em> 该函数告诉 connect 从存储中提取特定的键并将其直接放在 props 上。</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> 将 mapStateToProps 传递给 connect 函数;它将在渲染周期中被调用。</strong></p> </li> </ul> <p>使用 <code>mapDispatchToProps</code>,你可以在组件属性中直接分发动作。通常,每次你想启动一个动作时,都需要完全写出 <code>dispatch(actionCreator())</code>。这个辅助方法让你可以使用 JavaScript 的 <code>bind</code> 来自动在视图调用动作时分发动作。下面的列表显示了这是如何工作的。注意,React Redux 提供了另一个辅助方法来自动化绑定代码。</p> <h5 id="列表-613-将-react-连接到-reduxsrccomponentsappjsx">列表 6.13. 将 React 连接到 Redux—src/components/app.jsx</h5> <pre><code>import React from 'react'; import { connect } from 'react-redux'; *1* import { bindActionCreators } from 'redux'; *2* import * as actionCreators from '../action-creators'; *3* import * as settingsActionCreators *3* from '../settings-action-creators'; *3* import CreateNotification from './create-notification'; import Settings from './settings'; import classnames from 'classnames'; let intervalId; class App extends React.Component { //...component implementation code componentDidMount() { intervalId = setInterval(() => { this.props.notificationActions. fetchNotifications(); *4* }, this.props.refresh * 1000); } } function mapDispatchToProps(dispatch) { *5* return { notificationActions: bindActionCreators(actionCreators, dispatch), *2* settingsActions: bindActionCreators(settingsActionCreators, dispatch) *2* } } export default connect(null, mapDispatchToProps)(App) *6* </code></pre> <ul> <li> <p><strong><em>1</em> Connect 是 React Redux 提供的更高阶函数。它订阅存储并将更新的存储作为 props 传递给连接的组件。</strong></p> </li> <li> <p><strong><em>2</em> bindActionCreators 是一个辅助方法,它接受一个动作或包含动作的对象,并创建一个函数,当调用该函数时,会分发请求的动作。</strong></p> </li> <li> <p><strong><em>3</em> 导入动作创建器,以便你可以在组件中调用动作。</strong></p> </li> <li> <p><strong><em>4</em> 定期调用 fetchNotifications 动作;动作通过 connect 传递为 props。</strong></p> </li> <li> <p><strong><em>5</em> 将函数传递给 connect,以便 connect 组件可以将绑定的动作作为属性传递下去—防止每次调用动作时都调用 dispatch。</strong></p> </li> <li> <p><strong><em>6</em> 调用 connect,传入 mapDispatchToProps,然后传入你想要连接到 Redux 的组件</strong></p> </li> </ul> <p>在将容器组件(App)连接到 Redux 并配置好之后,你只需要将属性传递给子组件。然后子组件可以看到你映射到 <code>props</code> 的任何状态,并调用你绑定到 <code>dispatch</code> 的任何动作。</p> <h3 id="摘要-4">摘要</h3> <p>在本章中,你学习了 Redux 的工作原理,包括如何实现单向数据流、维护不可变存储以及将 React 连接到 Redux。</p> <ul> <li> <p>Redux 实现了一种架构模式,它是传统 MVC 模式的演变。</p> </li> <li> <p>Redux 的单向数据流,其中视图分发动作并订阅存储更新,使得开发人员对系统的推理更加简单。</p> </li> <li> <p>您应用程序的 store 或状态是一个根对象,它包含您视图的所有信息。</p> </li> <li> <p>Reducers 是纯函数,它们会对 store 进行更改。它们永远不会修改 store,而是使用不可变模式来更新 store。</p> </li> <li> <p>Actions 用于触发 store 的更新。</p> </li> <li> <p>Middleware 允许在 Redux 中使用调试工具和异步操作。</p> </li> <li> <p>连接 React 和 Redux 需要由 React Redux 库提供的附加功能,该库包括一个高阶组件,该组件订阅 store 以供其子组件使用。</p> </li> </ul> <h2 id="第三部分-同构架构">第三部分. 同构架构</h2> <p>现在你已经了解了同构架构的工作原理以及构建 React 应用所需的基础技能,现在是时候深入了解同构应用的具体细节了。本部分涵盖了广泛的主题,同时着重于帮助你准备好构建一个生产就绪的同构应用。它详细考察了在第二章中引入的概念中的每一部分。它还涵盖了几个高级主题,包括测试、现实世界的应用挑战、用户会话和缓存。</p> <p>本节的前两章涵盖了同构架构的基础。在第七章(kindle_split_019_split_000.xhtml#ch07)中,你将学习如何使用 Express 以及如何在服务器上使用 React 和 React Router 来启用服务器端渲染的应用路由。在第八章(kindle_split_020_split_000.xhtml#ch08)中,你将了解如何无缝地将服务器端渲染的页面传递给浏览器。</p> <p>接下来的三章将涵盖高级主题。在第九章(kindle_split_021_split_000.xhtml#ch09)中,你将学习如何在既作为服务器端渲染的页面又作为单页应用程序的行为的应用程序上下文中考虑测试。在第十章(kindle_split_022_split_000.xhtml#ch10)中,你将学习如何处理只能在浏览器中运行的代码(因为它使用了 <code>window</code> 对象)以及如何避免重复错误处理代码。最后,在第十一章(kindle_split_023_split_000.xhtml#ch11)中,你将让你的应用为生产做好准备。你将学习性能最佳实践、缓存策略以及如何在同构应用中处理用户会话。</p> <h2 id="第七章-构建服务器">第七章. 构建服务器</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>使用 Express 设置 Node.js</p> </li> <li> <p>编写 Express 中间件</p> </li> <li> <p>在服务器上使用 React Router 处理视图路由</p> </li> <li> <p>使用 <code>renderToString</code> 函数在服务器上渲染 React</p> </li> <li> <p>使用 Redux 在服务器上获取数据</p> </li> <li> <p>在你的组件上实现静态方法以处理数据获取</p> </li> </ul> <p>本章全部关于需要在服务器上执行的代码。我将涵盖服务器特定的主题,包括使用 Express 以及在服务器上使用你的组件和路由代码。例如,你将学习如何以允许服务器在每次页面渲染时自动获取它们的方式声明你应用程序的动作。</p> <p>是的,你没有看错:你将在服务器上运行你的 React、React Router 和 Redux 代码。React 和 React Router 各自提供了针对服务器的特定 API 来实现这一点。Redux 只需要做些小的改动——大多数情况下,你将从服务器调用动作。图 7.1 展示了这是如何工作的。</p> <h5 id="图-71-react-和-react-router-服务器和浏览器代码之间的主要区别">图 7.1. React 和 React Router 服务器和浏览器代码之间的主要区别</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig01_alt.jpg" alt="Images/07fig01_alt.jpg" loading="lazy"></p> <p>为了让图 7.1 中显示的代码工作,你需要做以下几件事情:</p> <ul> <li> <p>使用 Express 设置应用路由</p> </li> <li> <p>使用 React Router 的 <code>match</code> 函数处理特定路由(例如,购物车和产品路由)</p> </li> <li> <p>使用 <code>renderToString</code> 在服务器上渲染你的 React 组件</p> </li> <li> <p>在服务器上获取组件的数据</p> </li> <li> <p>使用渲染的应用程序响应请求</p> </li> </ul> <p>这些部分构成了同构应用程序的服务器端渲染部分。这包括从用户对应用程序的初始请求到向浏览器发送渲染响应的整个过程。</p> <h3 id="rendertostring-与-render">renderToString 与 render</h3> <p>让我们回顾 <code>render</code> 和 <code>renderToString</code> 方法之间的差异,以便你更好地理解为什么我们将服务器上的渲染视为与浏览器渲染不同。表 7.1 描述了每种方法的输出和用例。</p> <h5 id="表-71-比较-render-和-rendertostring">表 7.1. 比较 <code>render</code> 和 <code>renderToString</code></h5> <table> <thead> <tr> <th></th> <th>输出</th> <th>是否运行一次?</th> <th>环境</th> </tr> </thead> <tbody> <tr> <td>render</td> <td>你的组件的 JavaScript 表示</td> <td>否。每次更新时都会运行。</td> <td>浏览器</td> </tr> <tr> <td>renderToString</td> <td>DOM 元素的字符串表示</td> <td>是。不保留任何状态。</td> <td>服务器</td> </tr> </tbody> </table> <p>图 7.2 展示了本章涵盖的同构应用流程的一部分。在本章中,你将构建的 All Things Westies 应用程序是你从 第四章 开始工作的应用程序。你将构建购物车的服务器端渲染部分,但不会构建任何浏览器特定的代码或交互。在本章中,所有数据都将从服务器模拟。<img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig03_alt.jpg" alt="图 7.3" loading="lazy"> 展示了构建完成后的应用程序的外观。(你将在后面的章节中构建应用程序的其余部分。)</p> <h5 id="图-72-同构应用流程仅服务器渲染">图 7.2. 同构应用流程—仅服务器渲染</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig02_alt.jpg" alt="图片" loading="lazy"></p> <h5 id="图-73-本章中你将在服务器上构建和渲染的应用程序部分">图 7.3. 本章中你将在服务器上构建和渲染的应用程序部分</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig03_alt.jpg" alt="图片" loading="lazy"></p> <p>你可以在 <a href="http://mng.bz/8gV8" target="_blank"><code>mng.bz/8gV8</code></a> 找到这个应用程序的代码。在拉取此代码后,你将想要切换到 chapter-7-express 分支以继续操作(<code>git checkout chapter-7-express</code>)。在你添加 <code>renderToString</code> 调用之前,首先需要设置你的应用程序服务器。让我们回顾 Express 基础知识并设置应用程序服务器。</p> <h3 id="71-express-简介">7.1. Express 简介</h3> <p>当我开始作为客户端应用程序开发者(构建应用程序的用户界面部分)时,并没有太多需要能够进行全栈开发的需求。如今,能够实现和理解 Web 服务器、基础设施和分布式系统是一项备受追捧的技能。好消息是,能够构建一个渲染同构应用程序初始页面加载的服务器将大大有助于提高你在这一领域的知识。</p> <p>Express 是一个 Node.js 框架,它使得构建 REST API 和实现视图渲染变得容易。在 All Things Westies 应用中,Express 处理传入到 Node.js 服务器的请求——例如,当用户想要访问产品页面时,请求首先由 Express 应用程序路由处理。使用 JavaScript 构建同构应用程序的一部分是处理对您的 web 服务器的初始请求;服务器处理路由、获取数据和渲染页面。然后,完全渲染的页面被发送到浏览器的响应中。</p> <h4 id="711-设置服务器入口点">7.1.1. 设置服务器入口点</h4> <p>首先,你需要启动一个基本的服务器并运行。你想要使用命令行来启动你的服务器,并在端口 3000 上运行,如图 7.4 所示。我已经为你提供了根入口点文件(server.js 和 app.es6)。</p> <h5 id="图-74-启动-nodejs-服务器">图 7.4. 启动 Node.js 服务器</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig04_alt.jpg" alt="图片" loading="lazy"></p> <p>在当前分支中,Express 已经在 package.json 中。为了在你的 Node.js 应用程序中使用它,你需要使用 npm 安装它。这将安装本节所需的所有包:</p> <pre><code>$ npm install $ npm start </code></pre> <p>当你导航到 localhost:3000 时,你会看到一个错误,如图 7.5 所示。</p> <h5 id="图-75-没有路由处理服务器会抛出错误">图 7.5. 没有路由处理,服务器会抛出错误。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig05.jpg" alt="图片" loading="lazy"></p> <p>以下列表显示了在章节-7-express 分支的基代码中已经为你提供的服务器入口文件。</p> <h5 id="列表-71-服务器入口srcappes6">列表 7.1. 服务器入口—src/app.es6</h5> <pre><code>import express from 'express'; *1* const app = express(); *2* // other code – routes for data endpoints app.listen(3000, () => { *3* console.log('App listening on port: 3000'); }); </code></pre> <ul> <li> <p><strong><em>1</em> 在项目中包含 Express 框架。</strong></p> </li> <li> <p><strong><em>2</em> 初始化 Express 并将其分配给 app。</strong></p> </li> <li> <p><strong><em>3</em> 在 app 上调用 listen 并设置端口为 3000——你可以在回调中做任何事,console.log 语句让用户知道服务器正在哪个端口上运行。</strong></p> </li> </ul> <p>现在你已经看到了服务器代码的初始设置,你将使用 Express 添加路由。</p> <h4 id="712-使用-express-设置路由">7.1.2. 使用 Express 设置路由</h4> <p>Express 路由器处理应用程序的所有传入请求,并决定对每个请求做什么。例如,如果你想要一个返回文本和 200 响应的路由 /test,你需要添加处理此路由的代码到 app.es6 文件中。图 7.6 显示了预期的输出。因为你还没有添加路由处理,所以现在这不会工作。最终,这个路由处理将允许你使用 React 渲染应用程序路由。</p> <h5 id="图-76-使用-express-路由到测试路由">图 7.6. 使用 Express 路由到测试路由</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig06.jpg" alt="图片" loading="lazy"></p> <h5 id="注意-17">注意</h5> <p>在 Express 中,每个传入的请求都由一个请求对象表示。该对象包含有关 URL、cookies 以及其他 HTTP 信息,例如发送的任何头部信息。</p> <p>您可以为任何类型的 HTTP 动词(<code>GET</code>、<code>POST</code>、<code>PUT</code>、<code>OPTIONS</code>、<code>DELETE</code>)设置特定的路由。对于应用的主要部分,您只需要实现 <code>GET</code> 请求来响应用户对单个网页应用页面的请求。列表 7.2 展示了如何为 /test 路由添加一个 <code>GET</code> 请求的路由处理程序。您需要将列表中的代码添加到 app.es6 中。</p> <h5 id="注意-18">注意</h5> <p>Express 为每个传入的请求创建一个响应对象。该对象包含将发送回浏览器的信息,例如头信息、cookie、状态码和响应体。它还具有用于设置响应体和状态码的辅助函数。</p> <h5 id="列表-72-添加路由srcappes6">列表 7.2. 添加路由—src/app.es6</h5> <pre><code>app.get('/api/blog', (req, res) => {}); app.get('/test', (req, res) => { *1* res.send('Test route success!'); *2* }); app.listen(3000, () => {}); </code></pre> <ul> <li> <p><strong><em>1</em> 获取函数接收路由 (/test) 和一个回调函数。</strong></p> </li> <li> <p><strong><em>2</em> 回调必须响应响应(否则请求将无限期挂起)。</strong></p> </li> </ul> <p>这里您向浏览器发送一个简单的字符串,表示该路由存在。响应是通过 <code>send()</code> 方法发送的,该方法位于响应对象上。</p> <h5 id="路由中的正则表达式">路由中的正则表达式</h5> <p>除了硬编码完整的字符串路径,如 /test,Express 还支持正则表达式作为路由。这对于使用 React Router 构建应用很有帮助,因为您希望将路由处理传递给 React Router 而不是在 Express 中设置单独的路由。如果您希望 Express 应用了解如 /cart 和 /products 这样的路由,您必须在 Express 和 React Router 中放置重复的逻辑。图 7.7 说明了这些差异。</p> <h5 id="图-77-效率与大量代码重复使使用-react-router-的服务器路由成为最佳选择">图 7.7. 效率与大量代码重复,使使用 React Router 的服务器路由成为最佳选择。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig07_alt.jpg" alt="" loading="lazy"></p> <p>如果你在 Express 中重新创建路由(如图 7.7 中的不良选择 figure 7.7)</p> <ul> <li> <p>您有代码重复,并且没有路由的单个真相来源。</p> </li> <li> <p>您需要以某种方式为这些路由提供相同的 React Router 接口,以便您的应用在服务器上的渲染最终与浏览器上的渲染相匹配。<em>这是一项大量工作!</em></p> </li> </ul> <p>通过重用 React Router 并利用其内置的服务器功能,您可以节省时间,并且无需担心服务器上的初始应用状态与浏览器上的初始应用状态不同。</p> <p>您想在同构应用中这样做,因为它允许您通过使用 React Router 的路由来重用更多代码。这使您可以在两个环境中使用相同的路由。图 7.8 展示了如何输入任意路由,并将该路由与成功消息一起打印出来。此路由在您添加 列表 7.3 中的代码之前不会工作。</p> <h5 id="图-78-所有路由的-get-路由处理程序允许您将任何路由传递到服务器">图 7.8. 所有路由的 <code>GET</code> 路由处理程序允许您将任何路由传递到服务器。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig08_alt.jpg" alt="" loading="lazy"></p> <p>列表 7.3 展示了如何在 app.js 文件中设置全局路由处理器。全局路由处理器始终放在最后。它将调用一个中间件函数(renderView.jsx—见下一节的列表 7.4)来使用 React Router 的匹配逻辑确定要渲染哪个视图。</p> <h5 id="列表-73-为任何视图添加路由srcappes6">列表 7.3. 为任何视图添加路由—src/app.es6</h5> <pre><code>// all other routes go before the global handler app.get('/test', (req, res) => {...}); app.get('/*', (req, res) => { res.send(`${req.url} route success!`); }); </code></pre> <p><code>get</code> 函数接受一个正则表达式。<code>*</code> 将匹配所有路由—在 <code>/*</code> 路由之前匹配的所有路由将不会被此路由处理器处理。回调响应以字符串形式打印当前 URL。如果你在此处重新启动服务器,你将看到图 7.8 中的输出。</p> <p>接下来,你将为路由处理器添加中间件。</p> <h3 id="72-为视图渲染添加中间件">7.2. 为视图渲染添加中间件</h3> <p>到目前为止,你已经设置了以单个函数结束的路由,该函数接受请求并以响应对象的形式响应。接下来,你想要实现一个中间件函数,该函数检查与应用视图之一(例如,/cart)的路径匹配。Express 可以将多个函数链接在一起来处理复杂业务逻辑。这些单个函数被称为 <em>中间件</em>。(如果你认为这听起来很像 Redux 中间件,那是对的!)</p> <p>因为我们已经决定让 React Router 处理所有视图路由,所以你会使用你在第四章中创建的相同共享路由文件。你可以查看 shared/sharedRoutes.jsx 中的代码。在根路由内部有四个路由(以及一个 <code>IndexRoute</code> 以确保在根路由上渲染某些内容):</p> <pre><code> <Route path="/" component={App} > <IndexRoute component={Products} /> <Route path="/cart" component={Cart} /> <Route path="/products" component={Products} /> <Route path="/profile" component={Profile} /> <Route path="/login" component={Login} /> </Route> </code></pre> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="注意-19">注意</h5> <p>如果你正在使用 React Router 4,请查看附录 A 以了解设置路由的概述。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>在配置了 React Router 路由后,是时候编写你的第一个 Express 中间件了,它将调用 <code>match</code> 函数并确定请求的路由是否存在于你的应用中。</p> <h4 id="721-使用匹配处理路由">7.2.1. 使用匹配处理路由</h4> <p><code>renderView</code> 中间件处理路由匹配。它使用 React Router 提供的 <code>match</code> 函数。添加后,你将能够导航到在 sharedRoutes 中创建的每个路由。图 7.9 显示了在添加了列表 7.4 和 7.5 中的代码后导航到 localhost:3000/cart 的示例输出。</p> <h5 id="图-79-中间件允许服务器根据-react-router-共享路由正确响应">图 7.9. 中间件允许服务器根据 React Router 共享路由正确响应。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig09_alt.jpg" alt="图片 7.9" loading="lazy"></p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="注意-20">注意</h5> <p>如果你正在使用 React Router 4,请查看附录 B 以了解如何在服务器上处理路由的概述。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>为了将中间件连接到上一节中设置的<code>/*</code>(所有)路由处理程序,你需要将请求处理函数替换为<code>renderView</code>中间件函数。以下列表显示了中间件的基本路由匹配逻辑。</p> <h5 id="列表-74-路由匹配中间件srcmiddlewarerenderviewjsx">列表 7.4. 路由匹配中间件—src/middleware/renderView.jsx</h5> <pre><code>import { match } from 'react-router'; *1* import routes from '../shared/sharedRoutes'; *2* export default function renderView(req, res) { *3* const matchOpts = { routes, *4* location: req.url *4* }; const handleMatchResult = ( error, redirectLocation, renderProps ) => { *5* if (!error && !redirectLocation && renderProps) { *6* res.send('Success, that is a route!'); } }; match(matchOpts, handleMatchResult); *7* } </code></pre> <ul> <li> <p><strong><em>1</em> 包含 React 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> 配置匹配函数选项。该对象需要你的共享路由以及请求的位置(请求的 URL)。</strong></p> </li> <li> <p><strong><em>5</em> 这个回调将在匹配函数确定如何处理当前路由后从匹配函数中被调用。</strong></p> </li> <li> <p><strong><em>6</em> 确保没有错误或重定向。</strong></p> </li> <li> <p><strong><em>7</em> 使用选项和回调调用匹配函数。</strong></p> </li> </ul> <p>列表中使用了多个回调。请求和响应对象被传递给每个中间件函数。<code>next</code>参数是一个回调函数,用于传递给链中的下一个中间件。React Router 的另一个回调有三个参数:一个<code>error</code>对象、一个重定向位置和<code>renderProps</code>,它代表如果路由有效则要渲染的组件。</p> <p>在设置好中间件之后,你还需要在 app.es6 中使用它。以下列表显示了如何通过将中间件作为回调传递给路由处理程序来导入和应用中间件。用列表中的代码更新 app.es6。</p> <h5 id="列表-75-使用renderview中间件处理通配符路由srcappes6">列表 7.5. 使用<code>renderView</code>中间件处理通配符路由—src/app.es6</h5> <pre><code> import renderViewMiddleware from './middleware/renderView'; *1* app.get('/*', renderViewMiddleware); *2* </code></pre> <ul> <li> <p><strong><em>1</em> 导入你的 renderView 中间件。</strong></p> </li> <li> <p><strong><em>2</em> 替换内联匿名路由处理程序(你将中间件函数传递到路由处理程序中)。</strong></p> </li> </ul> <p>图 7.10 展示了请求如何通过 Express 进入你的应用,通过 Express 中的<code>/*</code>路由器进行路由,然后通过处理 React Router 路由(如/cart 或错误)的各种中间件函数。每个中间件函数都有选择终止请求(成功或带有 HTTP 错误响应代码)或调用下一个回调的选项。调用<code>next</code>将请求传递给链中的下一个中间件函数。</p> <h5 id="图-710-请求通过-express-路由器和相关中间件使用-react-router-检查有效路由的存在的流程">图 7.10. 请求通过 Express 路由器和相关中间件(使用 React Router 检查有效路由的存在)的流程</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig10_alt.jpg" alt="" loading="lazy"></p> <p>本节介绍了如何在服务器上使用 Express 中间件和 React Router 来确定应用路由的存在。接下来,你将在服务器上渲染组件。</p> <h4 id="722-在服务器上渲染组件">7.2.2. 在服务器上渲染组件</h4> <p>呼!你已经到达了关键点——故事的顶点,可以说是。 (我看到你翻了个白眼。)本节涵盖了在服务器上渲染组件的核心。目标是得到一个 DOM 的字符串表示形式,可以将其作为响应发送到浏览器(图 7.11)。</p> <h5 id="图-711-字符串形式的-html-渲染输出">图 7.11. 字符串形式的 HTML 渲染输出</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig11_alt.jpg" alt="" loading="lazy"></p> <p>在服务器上渲染你的组件有两个部分。让我们想象一下当用户请求 /cart 路由时会发生什么。图 7.12 展示了这个流程。</p> <h5 id="图-712-渲染有效-html-路由的两步过程">图 7.12. 渲染有效 HTML 路由的两步过程</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig12_alt.jpg" alt="" loading="lazy"></p> <p>这里是步骤:</p> <blockquote> <p><strong>1</strong>. 每个视图请求都必须根据与 React Router 匹配的路由渲染 React 树。对于 /cart,这包括 App 组件、Cart 组件和 Item 组件。你在 第四章 中看到了 App 组件,我们将在本章中介绍其他组件。</p> <p><strong>2</strong>. 最终的请求必须包含一个完整的 HTML 页面,包括 head 和 body 标签。你的核心 App 组件不包括这个标记。相反,你需要创建一个 HTML.jsx 组件来处理包装标记。把这看作是你的 index.html 文件。</p> </blockquote> <p>这两个步骤要求你在服务器上渲染两次。(这种方法有替代方案,但它们都需要额外的模板语言和设置。如果你想探索这些其他选项之一,EJS 或 Pug 与 Node.js 一起工作得很好。)我工作的第一个 React 应用程序使用了 Pug。尽管这种方法没有问题,但它提出了挑战。首先,你需要对另一个库保持最新。此外,它并不像一些用于你的工作流程的酷工具那样配合得很好,例如 Webpack Dev Server。</p> <h5 id="构建-index-组件">构建 index 组件</h5> <p>首先,让我们渲染一个基本的 HTML 页面,以便你有一个容器来放置你的组件。如果你一直在跟随,你可以在当前分支中继续。如果你迷路了或者想要跳到下一个检查点,你可以切换到 chapter-7-rendering 分支(<code>git checkout chapter-7-rendering</code>)。以下列表显示了代表你的根 HTML 容器的 React 组件。将此代码添加到 html.jsx 中。</p> <h5 id="列表-76-html-容器srccomponentshtmljsx">列表 7.6. HTML 容器—src/components/html.jsx</h5> <pre><code>import React from 'react'; import PropTypes from 'prop-types'; const HTML = (props) => { *1* return ( <html lang="en"> *2* <head> *2* <title>All Things Westies</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/semantic-ui/2.2.2/semantic.min.css" /> *3* <link rel="stylesheet" href="/assets/style.css" /> </head> <body> *2* <div *4* id="react-content" dangerouslySetInnerHTML={{ __html: props. renderedToStringComponents }} /> </body> </html> ); }; HTML.propTypes = { renderedToStringComponents: PropTypes.string *5* }; export default HTML; </code></pre> <ul> <li> <p><strong><em>1</em> 只会在服务器上渲染,永远不会具有状态,可以是一个纯(无状态)组件,由一个函数表示。</strong></p> </li> <li> <p><strong><em>2</em> 使用 <html>、<head> 和 <body> 标签构建基本的 HTML 结构。</strong></p> </li> <li> <p><strong><em>3</em> 包含 SemanticUI CSS 库。</strong></p> </li> <li> <p><strong><em>4</em> 当前路由渲染的 React 组件标记将放置于此</strong></p> </li> <li> <p><strong><em>5</em> 添加 prop 类型字符串以指示渲染的组件将以字符串形式提供。</strong></p> </li> </ul> <p>渲染的 React 组件标记将以字符串形式传递。由于你正在注入 HTML,你必须使用 <code>dangerouslySetInnerHTML</code> 来插入 DOM 元素。这个 React 组件最重要的部分是它接收构成其余组件树的渲染 HTML。在下一节中,你将把主组件树渲染到 html.jsx 组件中。</p> <p>记住,组件在服务器上总是只渲染一次,因此只触发第一个 React 生命周期。仅用于服务器上的组件(如 html.jsx)如果它们不依赖于 <code>componentWillUpdate</code>,则可以是无状态的。</p> <p><strong>dangerouslySetInnerHTML</strong></p> <p><code>dangerouslySetInnerHTML</code> 属性由 React 提供,允许你将 HTML 注入到 React 组件中。一般来说,你 <em>不应该</em> 使用此属性。但有时你需要。每个规则都有例外!</p> <p>当你设置此属性时,实际上发生了什么?在底层,React 正在设置 <code>innerHTML</code> 属性。但是设置 <code>innerHTML</code> 是一个安全风险。它可能会使你面临跨站脚本(XSS)攻击。</p> <p>React 将使用 <code>dangerouslySetInnerHTML</code> 视为一个最佳实践,因为它提醒你大多数时候你不想设置 <code>innerHTML</code>。有关更多信息,请参阅 <a href="http://mng.bz/69Ne" target="_blank"><code>mng.bz/69Ne</code></a>。</p> <h4 id="723-使用-rendertostring-创建视图">7.2.3. 使用 renderToString 创建视图</h4> <p>下一步是将输出渲染到 HTML 容器中,来自 列表 7.6。在以下列表中,你可以看到如何调用 <code>renderToString</code> 两次以将主内容渲染到 HTML 页面中。使用此代码更新 <code>renderView</code> 中间件。</p> <h5 id="列表-77-在中间件中渲染-html-输出srcmiddlewarerenderviewjsx">列表 7.7. 在中间件中渲染 HTML 输出—src/middleware/renderView.jsx</h5> <pre><code>import React from 'react'; *1* import { renderToString } from 'react-dom/server'; *2* import { match} from 'react-router'; import routes from '../shared/sharedRoutes'; import HTML from '../components/html'; *3* export default function renderView(req, res, next) { const matchOpts = { routes, location: req.url }; const handleMatchResult = (error, redirectLocation, renderProps) => { if (!error && !redirectLocation && renderProps) { const app = renderToString(<div>App!</div>); *4* const html = renderToString( <HTML renderedToStringComponents ={app} /> *5* ); res.send(`<!DOCTYPE html>${html}`); *6* } else { next(); } }; match(matchOpts, handleMatchResult); } </code></pre> <ul> <li> <p><strong><em>1</em> 由于中间件中的 JSX 包含 React(这就是为什么它是 .jsx 文件)。</strong></p> </li> <li> <p><strong><em>2</em> 从 React DOM 库导入 renderToString 函数。</strong></p> </li> <li> <p><strong><em>3</em> 包含 HTML 组件。</strong></p> </li> <li> <p><strong><em>4</em> 在占位符 <div> 上调用 renderToString 方法。</strong></p> </li> <li> <p><strong><em>5</em> 在 HTML 组件上调用 renderToString 方法,将渲染的应用内容注入到组件中。</strong></p> </li> <li> <p><strong><em>6</em> 将组合后的字符串发送回响应,添加 DOCTYPE 标签以使标记有效。</strong></p> </li> </ul> <p>在占位符 <code><div></code> 上调用 <code>renderToString</code> 创建将插入到 HTML 组件中的页面内容。</p> <p>在下一节中,你将用 App 组件替换此内容。</p> <h5 id="渲染组件">渲染组件</h5> <p>最后一步是在中间件中完全渲染一个路由。这需要以下步骤:</p> <ul> <li> <p>根据路由(例如,购物车及其所有子组件)动态渲染 app.jsx 和子组件</p> </li> <li> <p>从渲染中获取字符串输出并将其作为属性传递给 html.jsx</p> </li> </ul> <p>你已经在第四章中构建了 App 组件 章节 4。它在 src/components/app.jsx 中。你现在需要添加购物车组件。</p> <p>购物车组件渲染用户购物车中的项目并显示总成本。它还有一个结账按钮。目前,此组件有占位符文本(代码检查器会抱怨,但你会很快修复这个问题)。在下一节中,你将添加 Redux 和数据获取,购物车将动态渲染传递给它的项目。你已经有了来自第四章的 cart.jsx 章节 4。用以下列表替换现有代码。</p> <h5 id="列表-78-购物车组件srccomponentscartjsx">列表 7.8. 购物车组件—src/components/cart.jsx</h5> <pre><code>import React, { Component } from 'react'; class Cart extends Component { render() { return ( <div className="cart main ui segment"> <div className="ui segment divided items"> *1* Items will go here. </div> <div className="ui right rail"> <div className="ui segment"> <span>Total: </span><span>$10</span> *2* <div></div> <button className="ui positive basic button"> *3* Checkout </button> </div> </div> </div> ); } } export default Cart; </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>现在你已经拥有了 /cart 路由的组件,你仍然需要在服务器上渲染它们。列表 7.9 展示了如何将中间件代码更新以支持动态路由。</p> <h5 id="注意-21">注意</h5> <p>如果你使用的是 React Router 4,请查看附录 B 以了解如何在服务器上处理路由的概述。</p> <h5 id="列表-79-渲染srcmiddlewarerenderviewjsx">列表 7.9. 渲染—src/middleware/renderView.jsx</h5> <pre><code>import React from 'react'; import { renderToString } from 'react-dom/server'; import { match, RouterContext } from 'react-router'; *1* import routes from '../shared/sharedRoutes'; *2* import HTML from '../components/html'; export default function renderView(req, res, next) { const matchOpts = { routes, location: req.url }; const handleMatchResult = ( error, redirectLocation, renderProps ) => { *3* if (!error && !redirectLocation && renderProps) { const app = renderToString( <RouterContext *1* routes={routes} *2* {...renderProps} *3* /> ); const html = renderToString(<HTML renderedToStringComponents={app} />); console.log(`<!DOCTYPE html>${html}`); res.send(`<!DOCTYPE html>${html}`); } else { next(); } }; match(matchOpts, handleMatchResult); } </code></pre> <ul> <li> <p><strong><em>1</em> RouterContext 是一个 React Router 组件,用于在服务器上正确渲染你的组件树。</strong></p> </li> <li> <p><strong><em>2</em> 将共享路由传递到 RouterContext 中,以便正确初始化位置并匹配浏览器渲染。</strong></p> </li> <li> <p><strong><em>3</em> 由 React Router 的匹配函数计算得出,传递到 RouterContext 中,该上下文知道如何提取正确的组件进行渲染。</strong></p> </li> </ul> <p>关键要点是使用 <code>renderProps</code> 值(从 React Router 传递到你的回调函数)。这使 Router 知道要渲染哪个组件。这也是你如何在服务器和浏览器上保持路由一致性的方法。</p> <p>到目前为止,你已经学会了如何利用 React 的 <code>renderToString</code> 在服务器上渲染你的组件。但你也需要能够在服务器上获取填充组件的数据。在下一节中,你将连接 Redux 并为你的 React 组件添加一个名为 <code>prefetchActions</code> 的静态方法,以指示在运行时需要调用哪些操作才能使单个组件正确渲染。</p> <h3 id="73-添加-redux">7.3. 添加 Redux</h3> <p>当你将 Redux 添加到应用中时,你将渲染获取的数据到视图中,如图 7.13 所示。</p> <h5 id="图-713-服务器上-redux-获取的数据将被渲染到购物车中的列表视图">图 7.13. 服务器上 Redux 获取的数据将被渲染到购物车中的列表视图。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig13_alt.jpg" alt="" loading="lazy"></p> <p>构建网络应用(或任何数据驱动的、面向用户的程序)中最棘手的部分之一是处理异步代码。这有点像我煮早餐时,预先做好的早餐香肠说需要 6-8 分钟来制作。这意味着我应该在我开始煮香肠后 4 分钟开始煮鸡蛋,还是 8 分钟?我的鸡蛋会比香肠煮得快得多,但我想让所有东西同时准备好。</p> <p>同样,当你在服务器上使用 Redux 时,你需要确保在渲染视图之前数据已经准备好,否则服务器上创建的视图和浏览器代码运行后创建的视图可能不会总是匹配。你需要确保在开始渲染视图之前,视图所需的数据是可用的。图 7.14 展示了服务器上 Redux 的流程。</p> <h5 id="图-714-服务器上的-redux-流程">图 7.14. 服务器上的 Redux 流程</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/07fig14_alt.jpg" alt="" loading="lazy"></p> <p>你需要采取几个步骤来确保你可以获取 Cart 组件(该过程可以应用于应用的其它部分)的所有必要数据:</p> <ul> <li> <p>创建购物车动作和减少器以获取购物车数据</p> </li> <li> <p>使用 <code>renderView</code> 中间件来调用动作</p> </li> <li> <p>在你的购物车组件上使用静态方法,以便中间件知道它需要调用哪些动作</p> </li> <li> <p>在购物车组件中显示获取的数据</p> </li> </ul> <h4 id="731-设置购物车动作和减少器">7.3.1. 设置购物车动作和减少器</h4> <p>All Things Westies 应用具有用户会话的概念,以及注销或登录。因此,应用可以跟踪用户购物车中的项目,然后持久化这些数据,以便用户稍后回来完成交易。(你也可以使用 cookie 或本地存储来完成此操作。)</p> <p>在本节中,你将假设用户之前已经将项目放入购物车,并直接回到购物车完成购物。你将使用购物车路由(<a href="http://localhost:3000/cart%EF%BC%89%E3%80%82" target="_blank">http://localhost:3000/cart)。</a></p> <p>首先,你需要在服务器上初始化 Redux。以下列表显示了你需要添加到 init-redux.es6 的 Redux 配置。如果你一直在跟随,你可以添加此代码,或者你可以切换到 chapter-7-adding-redux (<code>git checkout chapter-7-adding-redux</code>)。</p> <h5 id="列表-710-初始化-redux-存储srcsharedinit-reduxes6">列表 7.10. 初始化 Redux 存储—src/shared/init-redux.es6</h5> <pre><code>import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; *1* import thunkMiddleware from 'redux-thunk'; *2* import loggerMiddleware from 'redux-logger'; *3* import cart from './cart-reducer.es6'; *4* export default function (initialStore = {}) { const reducer = combineReducers({ *5* cart }); const middleware = [ thunkMiddleware, loggerMiddleware ]; *6* return compose( applyMiddleware(...middleware) )(createStore)(reducer, initialStore); *7* } </code></pre> <ul> <li> <p><strong><em>1</em> 从 Redux 导入所有需要的函数。</strong></p> </li> <li> <p><strong><em>2</em> 导入 Thunk 中间件,以便你可以使用异步动作。</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> <li> <p><strong><em>7</em> 组合中间件和减少器以创建新的存储。</strong></p> </li> </ul> <p>你还需要创建你将调用来获取购物车数据的动作。购物车需要知道用户购物车中当前有什么项目。你将此代码添加到 cart-action-creators.es6 文件中,以下列表显示了。</p> <h5 id="列表-711-购物车动作srcsharedcart-action-creatorses6">列表 7.11. 购物车动作—src/shared/cart-action-creators.es6</h5> <pre><code>import fetch from 'isomorphic-fetch'; export const GET_CART_ITEMS = 'GET_CART_ITEMS'; *1* export function getCartItems() { *2* return (dispatch) => { return fetch('http://localhost:3000/api/user/cart', { *3* method: 'GET' } ).then((response) => { return response.json().then((data) => { *4* return dispatch({ type: GET_CART_ITEMS, *5* data: data.items *5* }); }); }) }; } </code></pre> <ul> <li> <p><strong><em>1</em> 动作字符串常量</strong></p> </li> <li> <p><strong><em>2</em> 在服务器上,getCartItems 动作将由 renderView 中间件调用。</strong></p> </li> <li> <p><strong><em>3</em> 使用 fetch API 从 Node.js 服务器获取购物车数据。</strong></p> </li> <li> <p><strong><em>4</em> 在成功响应中,从响应中读取 JSON。</strong></p> </li> <li> <p><strong><em>5</em> 解析 JSON;返回动作对象。</strong></p> </li> </ul> <p>在最后一行,<code>type</code> 是字符串常量,而 <code>data</code> 是来自 JSON 的项目数组。列表 7.12 展示了这些数据的样子。</p> <p>对于 All Things Westies 应用,所有数据都将由你的 Node.js 服务器处理。所有内容都在 JSON 文件中模拟。以下列表显示了购物车数据。它已经为你提供在分支中。</p> <h5 id="列表-712-模拟购物车数据datacartjson">列表 7.12. 模拟购物车数据—data/cart.json</h5> <pre><code>{ "items": [ *1* { "name": "Mug", *2* "price": 5, *3* "thumbnail": http://localhost:3000/assets/cart-item-placeholder.jpg *4* }, { "name": "Socks", *2* "price": 10, *3* "thumbnail": http://localhost:3000/assets/cart-item-placeholder.jpg *4* }, { "name": "Dog Collar", *2* "price": 15 *3* }, { "name": "Treats", *2* "price": 15, *3* "thumbnail": http://localhost:3000/assets/cart-item-placeholder.jpg *4* } ] } </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> 每个项目都有一个缩略图,它是一个包含图像 URL 的字符串。</strong></p> </li> </ul> <p>在通过 <code>getCartItems</code> 动作从服务器获取购物车数据后,您的购物车 reducer 将数据放入 Redux 存储。列表 7.13 显示了设置购物车 reducer 所需的代码。请记住,reducer 的任务是接收当前存储和动作。然后它使用动作来更新存储并返回应用程序的新状态。将列表中的代码添加到购物车 reducer 中。</p> <h5 id="列表-713-购物车-reducerssrcsharedcart-reduceres6">列表 7.13. 购物车 reducers—src/shared/cart-reducer.es6</h5> <pre><code>import { GET_CART_ITEMS } from './cart-action-creators.es6'; *1* export default function cart(state = {}, action) { switch (action.type) { *2* case GET_CART_ITEMS: return { ...state, items: action.data *3* }; default: return state; } } </code></pre> <ul> <li> <p><strong><em>1</em> 使用动作字符串常量以确保一致性。</strong></p> </li> <li> <p><strong><em>2</em> 从传递给 reducer 的动作中读取类型,以查看是否应该在此 reducer 中处理该动作。</strong></p> </li> <li> <p><strong><em>3</em> 将动作中的数据写入当前状态。</strong></p> </li> </ul> <p>干得好!您已经创建了 Redux 所需的所有组件(设置、动作和 reducer),以便在 <code>renderView</code> 中间件中获取路由数据。接下来,您将实现数据获取逻辑,以便您可以看到加载到视图中的数据。</p> <h4 id="732-在-renderview-中间件中使用-redux">7.3.2. 在 renderView 中间件中使用 Redux</h4> <p>现在,您需要将存储包含在您的 <code>renderView</code> 中间件中。以下列表显示了如何添加此功能。</p> <h5 id="列表-714-将-redux-存储添加到您的中间件srcmiddlewarerenderviewjsx">列表 7.14. 将 Redux 存储添加到您的中间件—src/middleware/renderView.jsx</h5> <pre><code>import initRedux from '../shared/init-redux.es6'; *1* export default function renderView(req, res, next) { const matchOpts = {...}; const handleMatchResult = (error, redirectLocation, renderProps) => { if (!error && !redirectLocation && renderProps) { const store = initRedux(); *2* // ... more code } } // ... more code } </code></pre> <ul> <li> <p><strong><em>1</em> 在中间件中包含初始化代码。</strong></p> </li> <li> <p><strong><em>2</em> 调用初始化函数并将其存储在名为 store 的常量变量中。</strong></p> </li> </ul> <p>在您将存储放入中间件之后,您可以在服务器上分发动作。但您想变得聪明,只分发当前路由的动作。为此,您需要扩展您的 React 组件,以便能够在每个路由的基础上声明您的动作。</p> <h5 id="设置初始动作">设置初始动作</h5> <p>声明路由的数据需求有几种有效的方法。您可以将此信息与路由声明一起存储,或者您可以将数据声明放在组件上。我将向您展示如何将数据声明放在组件上。购物车组件知道需要调用哪些动作创建器函数来获取购物车视图所需的相关数据。稍后,<code>renderView</code> 中间件将使用这些函数引用并调用它们以获取填充存储所需的 JSON 数据响应。</p> <p>使用 React Router,您可以轻松访问在路由组件中声明的任何组件。通过在共享路由中的组件上声明数据需求,您可以在中间件中组合来自多个组件的动作列表。下一个列表显示了购物车组件如何声明其动作需求。在这种情况下,它需要通过 <code>getCartItems</code> 动作获取的数据。为了表示这一点,它存储了对动作创建器函数的引用。<code>renderView</code> 中间件将调用此动作。</p> <h5 id="列表-715-声明初始动作componentscartjsx">列表 7.15. 声明初始动作—components/cart.jsx</h5> <pre><code>import { getCartItems } from '../shared/cart-action-creators.es6'; class Cart extends Component { static prefetchActions() { *1* return [ *2* getCartItems *3* ]; } render(){ return { <div className="cart main ui segment">...</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>记住,静态函数不是类实例的一部分。它们无法访问组件实例的任何具体信息,如属性或状态。静态函数的任何上下文都需要从调用者那里传入。在这种情况下,你不需要任何上下文。</p> <p><strong>静态函数</strong></p> <p><em>静态函数</em>存在于 React 类或任何 JavaScript 类中。这些函数可以在不实例化类的情况下被调用。</p> <p>你为什么要使用静态函数呢?通常,它们被用来提供工具。在同构应用中,你可以使用静态函数来定义 React 组件需要渲染的数据调用。</p> <h4 id="733-通过中间件添加数据预取">7.3.3. 通过中间件添加数据预取</h4> <p>现在由于你的购物车组件通过定义需要调用的动作来声明其自己的数据需求,以便正确渲染,你可以使用中间件在渲染组件之前获取数据。你需要添加的第一件事是收集来自 <code>renderProps</code> 组件上的所有动作的代码。以下列表展示了你需要添加什么来使它工作。</p> <h5 id="列表-716-在组件上调用所有初始动作srcmiddlewarerenderviewjsx">列表 7.16. 在组件上调用所有初始动作—src/middleware/renderView.jsx</h5> <pre><code>export default function renderView(req, res, next) { const matchOpts = {...}; const handleMatchResult = (error, redirectLocation, renderProps) => { if (!error && !redirectLocation && renderProps) { const store = initRedux(); let actions = renderProps.components.map( component) => { *1* if (component) { if (component.displayName && component.displayName.toLowerCase().indexOf('connect') > -1 ) { *2* if (component.WrappedComponent.prefetchActions) { return component. WrappedComponent.prefetchActions(); *3* } } else if (component.prefetchActions) { return component.prefetchActions(); *3* } } return []; }); actions = actions.reduce((flat, toFlatten) => { *4* return flat.concat(toFlatten); *4* }, []); *4* }; match(matchOpts, handleMatchResult); } </code></pre> <ul> <li> <p><strong><em>1</em> 对 renderProps 返回的每个组件运行 map。</strong></p> </li> <li> <p><strong><em>2</em> 通过查找 'connect' 来检查组件是否被包裹(检查 WrappedComponent 属性并调用 prefetchActions)。</strong></p> </li> <li> <p><strong><em>3</em> 如果存在,在组件上调用 prefetchActions,应该总是返回数组。</strong></p> </li> <li> <p><strong><em>4</em> map 函数将创建一个数组数组—将其缩减以合并成一个数组。</strong></p> </li> </ul> <p>这段代码使服务器知道为路由调用哪些动作。记住,你只能在 React Router 已知的组件上调用 <code>prefetchActions</code>。</p> <p>动作数组现在是一个可以调用的动作创建者列表。接下来,你需要将它们设置好以进行分发,然后使用 <code>Promise.all</code> 等待所有初始动作完成后再渲染 React 组件。记住,你只调用当前路由所需的动作。以下列表展示了如何添加异步代码处理,以便在渲染组件之前等待获取到路由所需的所有数据。</p> <h5 id="列表-717-在组件上调用所有初始动作srcmiddlewarerenderviewjsx">列表 7.17. 在组件上调用所有初始动作—src/middleware/renderView.jsx</h5> <pre><code>import { Provider } from 'react-redux'; *1* export default function renderView(req, res, next) { const matchOpts = {...}; const handleMatchResult = (error, redirectLocation, renderProps) => { if (!error && renderProps) { const store = initRedux(); let actions = renderProps.components.map((component) => {...}); actions = actions.reduce((flat, toFlatten) => {...}, []); const promises = actions.map((initialAction) => { *2* return store.dispatch(initialAction()); *2* }); Promise.all(promises).then(() => { *3* const app = renderToString( <Provider store={store}> *4* <RouterContext routes={routes} {...renderProps} /> </Provider> ); const html = renderToString(<HTML html={app} />); return res.status(200).send(`<!DOCTYPE html>${html}`); }); } } match(matchOpts, handleMatchResult); } </code></pre> <ul> <li> <p><strong><em>1</em> 从 React Redux 导入 Provider 组件(用于在服务器上创建带有 Redux 的组件)。</strong></p> </li> <li> <p><strong><em>2</em> 在每个动作创建者上调用 store.dispatch。</strong></p> </li> <li> <p><strong><em>3</em> 在你的动作上调用 Promise.all—在它们解析后,你可以渲染组件。</strong></p> </li> <li> <p><strong><em>4</em> 将 React Router 组件包裹在 Provider 中。</strong></p> </li> </ul> <p>在将 React Router 组件包裹在 Provider 中之后,你将 store 传递给 Provider。现在,store 将会更新为你的动作获取和更新的所有数据。</p> <h5 id="在购物车中显示数据">在购物车中显示数据</h5> <p>因为应用现在正在获取路由的数据,你可以使购物车组件根据动态数据更新。以下列表显示了购物车组件中显示每个购物车项目的附加逻辑。</p> <h5 id="列表-718-完整的购物车组件componentscartjsx">列表 7.18. 完整的购物车组件—components/cart.jsx</h5> <pre><code>import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { getCartItems } from '../shared/cart-action-creators.es6'; import Item from './item'; class Cart extends Component { static prefetchActions() {} constructor(props) { super(props); this.proceedToCheckout = this.proceedToCheckout.bind(this); } getTotal() { let total = 0; const items = this.props.items; if (items) { total = items.reduce((prev, current) => { *1* return prev + current.price; }, total); } return total; } proceedToCheckout() { console.log('clicked checkout button', this.props); *2* } renderItems() { const components = []; const items = this.props.items; if (this.props.items) { this.props.items.forEach((item, index) => { *3* components.push(<Item key={index} {...item} />); *3* }); } return items; } render() { return ( <div className="cart main ui segment"> <div className="ui segment divided items"> {this.renderItems()} *3* </div> <div className="ui right rail"> <div className="ui segment"> <span>Total: </span><span>${this.getTotal()}</span> *1* <button onClick={this.proceedToCheckout} className="ui positive basic button" > Checkout </button> </div> </div> </div> ); } } Cart.propTypes = { items: PropTypes.arrayOf( PropTypes.shape({ name: PropTypes.string.isRequired, price: PropTypes.number.isRequired, thumbnail: PropTypes.string.isRequired }) ) }; function mapStateToProps(state) { const { items } = state.cart; *4* return { items }; } function mapDispatchToProps(dispatch) { return { cartActions: bindActionCreators([getCartItems], dispatch) }; } export default connect( mapStateToProps, mapDispatchToProps )(Cart); *4* </code></pre> <ul> <li> <p><strong><em>1</em> 基于属性上的购物车项目计算总额,使用 reduce 函数返回所有价格的总和。</strong></p> </li> <li> <p><strong><em>2</em> 按钮的占位符点击处理程序(因为你还没有连接浏览器代码,所以你不会看到控制台输出)。</strong></p> </li> <li> <p><strong><em>3</em> 使用 Item 组件渲染购物车项目,为 items 数组中的每个项目创建新的 Item。</strong></p> </li> <li> <p><strong><em>4</em> 将购物车连接到 Redux,以便它可以从 props 中获取购物车项目。</strong></p> </li> </ul> <p>重新启动应用。然后如果你导航到/cart,你会看到每个项目都完全渲染。但是结账按钮不起作用!当你点击它时,你不会看到任何控制台输出,因为你还没有在浏览器中重新激活 React 树。第八章将教你如何进行服务器/浏览器交接并使浏览器特定的代码工作。</p> <h3 id="摘要-5">摘要</h3> <p>在本章中,你学习了如何实现服务器端渲染。你编写了 Express 中间件来处理你的应用的路由和渲染。你还学习了如何在服务器上使用 Redux 并预取数据以在服务器上创建购物车组件。</p> <ul> <li> <p>Express 可以用来渲染你的视图。</p> </li> <li> <p>你可以在服务器上使用 React Router,这样你就不必重复你的路由代码。</p> </li> <li> <p>你创建并使用自定义 Express 中间件来确定你所在的路线并渲染当前路线的组件。</p> </li> <li> <p>React 提供了一个名为<code>renderToString</code>的方法,它允许你返回一个字符串,包括来自你的路由的标记。</p> </li> <li> <p>不要让正常的应用流程获取你的数据,而是预取你组件所需的数据。</p> </li> </ul> <h2 id="第八章-同构视图渲染">第八章. 同构视图渲染</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>创建浏览器代码的渲染 React 组件的入口点</p> </li> <li> <p>序列化数据,以便浏览器代码可以从服务器状态启动(引导)</p> </li> <li> <p>在浏览器中反序列化数据以使你的代码生效</p> </li> <li> <p>在你的序列化数据中包含来自原始服务器请求的信息,以在浏览器和服务器之间保持一致的状态</p> </li> <li> <p>转向单页应用程序(SPA)体验以处理浏览器中的用户交互</p> </li> </ul> <p>在本章中,你将构建同构视图渲染的浏览器部分。你将专注于图像的下半部分图 8.1。你已经看到这个图多次了,但我在这里重新讨论它,以提供本章的背景。</p> <h5 id="图-81-本章重点介绍流程的下半部分即服务器渲染之后发生的一切浏览器的渲染和-spa-逻辑">图 8.1. 本章重点介绍流程的下半部分,即服务器渲染之后发生的一切(浏览器的渲染和 SPA 逻辑)。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig01_alt.jpg" alt="" loading="lazy"></p> <p>本章的所有代码都在与第七章相同的 GitHub 仓库中,该仓库可以在<a href="http://mng.bz/8gV8" target="_blank"><code>mng.bz/8gV8</code></a>找到。在您拉取此代码后,切换到 chapter-8.1.1(<code>git checkout chapter-8.1.1</code>),其中包含本章第一部分的代码。每个部分提供的分支都包括从前一节需要的骨架代码,但不包括在该特定节中添加的内容。下一节的代码将包含前一节的完整代码。每次您需要切换分支时,我都会通知您。</p> <p>记住,每次您想要构建代码(以及您做出任何更改后)都需要运行<code>start</code>命令:</p> <pre><code>$ npm start </code></pre> <p>应用程序在您的浏览器中以 <a href="http://localhost:3000" target="_blank">http://localhost:3000</a> 运行。</p> <h3 id="81-设置浏览器入口点">8.1. 设置浏览器入口点</h3> <p>您需要做的第一件事是将代码在浏览器中渲染,就是设置您的浏览器入口点。这被称为 main.jsx。它将是您在浏览器中引导(初始化)React 代码的地方。您的 main.jsx 入口点最终将负责几件事情,包括以下内容:</p> <ul> <li> <p>反序列化服务器状态</p> </li> <li> <p>使用当前状态初始化 Redux</p> </li> <li> <p>设置 React Router</p> </li> <li> <p>在浏览器中渲染 React 组件</p> </li> </ul> <h4 id="811-引用浏览器代码">8.1.1. 引用浏览器代码</h4> <p>要使浏览器代码加载,您需要确保从您的 HTML 中引用它。它需要作为页面中的脚本包含在 body 的末尾。这确保了它不会阻止页面加载和渲染。因为页面已经在服务器上渲染了一次,所以您的用户不会知道脚本尚未加载!</p> <p>分支(chapter-8.1.1)中的代码已经包含了 webpack 配置。当您运行<code>npm start</code>时,webpack 会创建浏览器代码,并在应用程序中使用。打开 html.jsx 文件,并使用以下列表中的代码添加对打包的浏览器文件的引用。请注意,在生产环境中,您可能希望将此 URL 配置为静态资产文件所在的位置。</p> <h5 id="列表-81-添加您的浏览器源代码srccomponentshtmljsx">列表 8.1. 添加您的浏览器源代码—src/components/html.jsx</h5> <pre><code> <body> <div id="react-content" dangerouslySetInnerHTML={{ __html: props.html }} /> <script type="application/javascript" src="browser.js" /> *1* </body> </code></pre> <ul> <li><strong><em>1</em> 插入一个指向您的 JavaScript 包的 script 标签。</strong></li> </ul> <p>在您完成此操作后,重新启动服务器。要检查 browser.js 文件是否正确加载,请使用 Chrome DevTools 中的网络标签查看是否已加载。图 8.2 显示了您需要查找的内容。</p> <h5 id="图-82-使用-chrome-开发者工具的网络标签确认您的-javascript-代码是否在浏览器中加载">图 8.2. 使用 Chrome 开发者工具的网络标签确认您的 JavaScript 代码是否在浏览器中加载</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig02_alt.jpg" alt="" loading="lazy"></p> <p>您还应该尝试添加一个<code>console.log</code>语句或设置一个断点来验证您的脚本是否已加载。在您成功完成此操作后,您就可以在浏览器中渲染您的 React 组件了。</p> <h4 id="812-在浏览器中渲染-react">8.1.2. 在浏览器中渲染 React</h4> <p>本节介绍了如何在浏览器中渲染 React。我们已经在第三章中介绍了这一点,当时你学习了所有关于使用 React 的内容。我将在这里重新回顾核心实现细节。此外,我将演示这一步骤为什么是必要的。</p> <p>让我们从<code>/cart</code>路由开始,因为它已经在第七章中构建出来了。如果你不记得,图 8.3 展示了它在第七章结束时的样子。</p> <h5 id="图-83-在浏览器渲染或添加-spa-功能之前的应用程序购物车页面">图 8.3. 在浏览器渲染或添加 SPA 功能之前的应用程序购物车页面</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig03_alt.jpg" alt="" loading="lazy"></p> <p>到目前为止,你只在这个服务器上进行了渲染。一些事情还没有工作,比如具有附加逻辑的结账按钮。在浏览器中连接这一步骤的第一步是在浏览器中调用<code>ReactDOM.render</code>。你将添加一个简单的渲染调用,在浏览器代码执行并渲染后显示浏览器渲染消息。图 8.4 显示了它的样子。</p> <h5 id="图-84-在浏览器中渲染一个简单的div和消息后的预期输出">图 8.4. 在浏览器中渲染一个简单的<code>div</code>和消息后的预期输出</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig04_alt.jpg" alt="" loading="lazy"></p> <p>要得到这个输出,你需要使用 React 设置 main.jsx,然后调用<code>render</code>。列表 8.2 显示了你的入口文件如何渲染一个带有消息的简单<code>div</code>。将列表中的代码添加到 main.jsx 中。如果你需要赶上进度,所有这些代码都在名为 chapter-8.1.2 的分支上(<code>git checkout chapter-8.1.2</code>)。或者你也可以继续编写你到目前为止的代码。</p> <h5 id="列表-82-在浏览器中渲染-html-元素srcmainjsx">列表 8.2. 在浏览器中渲染 HTML 元素—src/main.jsx</h5> <pre><code>import React from 'react'; *1* import ReactDOM from 'react-dom'; *1* function init() { *2* ReactDOM.render( <div> *3* Browser Render </div>, document.getElementById('react-content')); *4* } init(); *2* </code></pre> <ul> <li> <p><strong><em>1</em> 你必须包含 React 和 React DOM 才能在这个文件中调用 render。</strong></p> </li> <li> <p><strong><em>2</em> 创建一个 init 函数,这样你就可以在你的入口点添加异步代码。</strong></p> </li> <li> <p><strong><em>3</em> 使用简单的 div 元素调用 render 函数。</strong></p> </li> <li> <p><strong><em>4</em> 传入 DOM 元素;React 将你的 div 渲染到其中。</strong></p> </li> </ul> <p>这段代码的明显问题是你的应用程序消失了!这不是你想要发生的。打开你的开发者工具并选择资源标签页,在 main.jsx 文件中设置断点。(使用 Cmd-P 并搜索 main.jsx—然后你将能够设置断点。)图 8.5 显示了设置断点后你的 Chrome DevTools 将看起来是什么样子。</p> <h5 id="图-85-在-chrome-开发者工具的资源标签页中设置-mainjsx-的第-5-行断点这让你可以在浏览器-javascript-运行之前查看浏览器输出">图 8.5. 在 Chrome 开发者工具的资源标签页中设置 main.jsx 的第 5 行断点。这让你可以在浏览器 JavaScript 运行之前查看浏览器输出。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig05_alt.jpg" alt="" loading="lazy"></p> <p>现在你已经设置了断点,刷新你的浏览器。代码执行将在你的断点上暂停。查看主浏览器窗口中的你的应用。它看起来应该是正确的,购物车路由已渲染(如图 8.3 所示)。如果你的应用正在加载,但你在控制台输出中注意到 React 错误,现在不用担心(类似于“React 尝试在容器中重用标记”的错误)。我将在本章后面解释这一点。</p> <p>将两个状态中的 DOM 标记进行比较,以查看浏览器渲染前后的情况。图 8.6 显示了浏览器渲染调用之前的标记。</p> <h5 id="图-86-比较服务器渲染的-dom-与-mainjsx-中渲染的div的-dom">图 8.6. 比较服务器渲染的 DOM 与 main.jsx 中渲染的<code>div</code>的 DOM</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig06_alt.jpg" alt="" loading="lazy"></p> <p>在本节中,你已经设置了浏览器代码以运行,并向浏览器添加了一个简单的 React 渲染。这说明了服务器渲染和初始浏览器渲染之间的交互。在下一节中,我们将介绍如何在浏览器中使用服务器状态。</p> <h3 id="82-首次渲染时匹配服务器状态">8.2. 首次渲染时匹配服务器状态</h3> <p>要使应用同构,你需要从服务器重新创建状态。按照以下步骤操作:</p> <blockquote> <p><strong>1</strong>. 在服务器上序列化应用状态,并将其以字符串化的 DOM 标记形式发送下来。</p> <p><strong>2</strong>. 在浏览器上反序列化状态,使其成为一个可消费的 JSON 对象。</p> <p><strong>3</strong>. 使用应用状态(JSON 对象)初始化 Redux。</p> <p><strong>4</strong>. 将初始化的 Redux 存储传递给你的 React 组件。</p> </blockquote> <p>图 8.7 显示了在 All Things Westies 应用上下文中的此流程。</p> <h5 id="图-87-状态序列化反序列化的流程">图 8.7. 状态序列化/反序列化的流程</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig07_alt.jpg" alt="" loading="lazy"></p> <p>重要的是要记住,在服务器上创建的状态需要与用于在浏览器上启动你的 React 代码的状态<strong>完全匹配</strong>。这确保了在页面初始加载期间无需更新浏览器 DOM。</p> <p>首先,让我们在服务器上设置数据——既序列化它,然后将其发送到浏览器以供消费。</p> <h4 id="821-在服务器上序列化数据">8.2.1. 在服务器上序列化数据</h4> <p>在本节中,你将更新 renderView.jsx 中的代码(在第七章中创建)。第一步是捕获和序列化当前应用状态。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="定义-13">定义</h5> <p><em>序列化</em>是将数据(通常是 JavaScript 中的 JSON)转换为可以在环境之间发送的格式的行为。在这种情况下,你通过网络请求从服务器发送到浏览器。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>到本节结束时,你将能够通过在<code>window</code>对象上放置服务器状态来在控制台中访问你的服务器状态。图 8.8 显示了在 Chrome 开发者工具中的此输出。</p> <h5 id="图-88-浏览器中的序列化状态">图 8.8. 浏览器中的序列化状态</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig08_alt.jpg" alt="" loading="lazy"></p> <p>你可以切换到 chapter-8.2.1 分支以获取本节的基础代码(<code>git checkout chapter-8.2.1</code>),或者继续使用你迄今为止添加的代码。该分支包含迄今为止的所有代码列表。以下列表指导你如何在<code>renderView</code>中间件中获取和序列化你的应用当前状态。将此新代码添加到<code>renderView</code>中间件。</p> <h5 id="列表-83-捕获并序列化当前应用状态srcmiddlewarerenderviewjsx">列表 8.3. 捕获并序列化当前应用状态—src/middleware/renderView.jsx</h5> <pre><code>Promise.all(promises).then(() => { const serverState = store.getState(); *1* const stringifiedServerState = JSON.stringify(serverState); *2* const app = renderToString( <Provider store={store}> <RouterContext routes={routes} {...renderProps} /> </Provider> ); const html = renderToString( <HTML html={app} serverState={stringifiedServerState} /> *3* ); return res.send(`<!DOCTYPE html>${html}`); </code></pre> <ul> <li> <p><strong><em>1</em> 获取 Redux 存储的当前状态——getState()是一个辅助方法,它返回存储。</strong></p> </li> <li> <p><strong><em>2</em> 通过从 JSON 创建字符串来序列化状态。</strong></p> </li> <li> <p><strong><em>3</em> 将序列化状态作为 serverState 属性传递给 HTML 组件。</strong></p> </li> </ul> <p>现在你已经创建了当前应用状态的字符串表示,你需要将其设置在 DOM 标记中。你使用<code>dangerouslySetInnerHTML</code>和 script 标签来完成这个操作。以下列表显示了要添加到 html.jsx 中的代码。</p> <h5 id="列表-84-在-dom-中设置序列化状态srccomponentshtmljsx">列表 8.4. 在 DOM 中设置序列化状态—src/components/html.jsx</h5> <pre><code><body> <div id="react-content" dangerouslySetInnerHTML={{ __html: props.html }} /> *1* <script dangerouslySetInnerHTML={{ *2* __html: ` window.__SERIALIZED_STATE__ = *3* = JSON.stringify(${props.serverState}) *4* ` }} /> *1* <script type="application/javascript" src="browser.js" /> </body> </code></pre> <ul> <li> <p><strong><em>1</em> 应放置在 body 的末尾(这样就不会阻塞渲染)但在主 JavaScript 执行之前—browser.js 依赖于状态在 window 对象上可用。</strong></p> </li> <li> <p><strong><em>2</em> 设置 script 标签的 innerHTML,以便 JSON 字符串放在 script 标签内。</strong></p> </li> <li> <p><strong><em>3</em> 在 window 的 <strong>SERIALIZED_STATE</strong> 属性上设置状态。</strong></p> </li> <li> <p><strong><em>4</em> 分配字符串化的服务器状态。</strong></p> </li> </ul> <p>重新启动服务器后,你将能够在浏览器控制台中看到你的应用状态字符串化并打印出来(如图 8.8 所示),通过运行以下命令:</p> <pre><code>window.__SERIALIZED_STATE__ </code></pre> <p>虽然你现在可以在浏览器中看到你的状态很酷,但这并没有什么用。在接下来的两个部分中,我们将介绍如何使用 Redux 和你的 React 组件来使用这个状态。</p> <h4 id="822-在浏览器中反序列化数据">8.2.2. 在浏览器中反序列化数据</h4> <p>如果这听起来复杂且令人害怕,别担心!其实很简单。实际上,这比最初序列化状态还要简单。目标是获取服务器发送下来的字符串化数据,并将其转换成应用可以处理的状态。你接收一个字符串输入,并将其转换回一个 JavaScript 对象。</p> <h5 id="定义-14">定义</h5> <p><em>反序列化</em>是指将序列化数据转换为当前环境可用的格式。在这种情况下,你将一个字符串转换为 JavaScript 对象。</p> <p>以下列表显示了你需要添加到 main.jsx 中的代码,以从 window 对象获取状态。解析<code>window.__SERIALIZED_STATE__</code>值并将其保存到变量中。</p> <h5 id="列表-85-在浏览器中获取状态srcmainjsx">列表 8.5. 在浏览器中获取状态—src/main.jsx</h5> <pre><code>import React from 'react'; import ReactDOM from 'react-dom'; const initialState = JSON.parse(window.__SERIALIZED_STATE__); *1* console.log(initialState); *2* </code></pre> <ul> <li> <p><strong><em>1</em> 将字符串解析成可用的对象。</strong></p> </li> <li> <p><strong><em>2</em> 在控制台添加日志语句以查看它是否工作。</strong></p> </li> </ul> <p>如果你查看浏览器控制台,你会看到状态。这就是反序列化应用状态的全部内容。接下来,你将这个状态注入到 Redux 中,以便你的应用以与服务器相同的状态启动。</p> <h4 id="823-储存活化">8.2.3. 储存活化</h4> <p>现在你已经反序列化了状态,你需要使用在服务器上生成的状态初始化 Redux。你不需要添加任何新的 Redux 代码;你在第七章(kindle_split_019_split_000.xhtml#ch07)中创建的所有内容在这里都工作得很好。本节的分支是 chapter-8.2.3 (<code>git checkout</code>)。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="注意-22">注意</h5> <p>如果你使用的是 React Router 4,请查看附录 A(kindle_split_027_split_000.xhtml#app01),了解设置路由和相关路由代码的概述。附录显示了与本章中列表 8.6(kindle_split_020_split_002.xhtml#ch08ex06)中的 Router 代码相比的 main.jsx 设置代码。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>在 main.jsx 中,你需要做的只是使用正确的状态初始化 Redux。以下列表显示了如何将状态传递给 init Redux 函数。你应该使用此代码更新 main.jsx(替换前几节中的占位符代码)。</p> <h5 id="列表-86-设置-redux-和组件树srcmainjsx">列表 8.6. 设置 Redux 和组件树—src/main.jsx</h5> <pre><code>import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; *1* import { browserHistory, Router } from 'react-router'; *2* import initRedux from './shared/init-redux.es6'; *3* import sharedRoutes from './shared/sharedRoutes'; *4* const initialState = JSON.parse(window.__SERIALIZED_STATE__); console.log(initialState); const store = initRedux(initialState); *5* function init() { ReactDOM.render( <Provider store={store}> *6* <Router routes={sharedRoutes} history={browserHistory} /> *7* </Provider>, document.getElementById('react-content')); } init(); </code></pre> <ul> <li> <p><strong><em>1</em> 包含 React Redux 的 Provider 组件,以便组件可以根据需要访问存储。</strong></p> </li> <li> <p><strong><em>2</em> 包含 React Router 的 Router 组件,从 sharedRoutes 配置路由,并包含浏览器环境的正确历史记录。</strong></p> </li> <li> <p><strong><em>3</em> 包含 Redux 引导代码。</strong></p> </li> <li> <p><strong><em>4</em> 包含 sharedRoutes。</strong></p> </li> <li> <p><strong><em>5</em> 根据反序列化状态创建 Redux 存储。</strong></p> </li> <li> <p><strong><em>6</em> 将存储作为属性传递给 Provider 组件。</strong></p> </li> <li> <p><strong><em>7</em> 将 sharedRoutes 和 browserHistory 作为属性传递给 Router。</strong></p> </li> </ul> <p>Main.jsx 从 initRedux.jsx 导入 initRedux。initRedux.jsx 模块中的代码接受状态并创建存储。这就是你如何使存储保持一致。你已经在第七章(kindle_split_019_split_000.xhtml#ch07)中添加了此代码。这里显示为提醒,但它已经包含在 src/shared/init-redux.es6 的代码库中:</p> <pre><code>export default function (initialStore = {}) { const reducer = combineReducers({ products, cart }); const middleware = [thunkMiddleware, loggerMiddleware]; return compose( applyMiddleware(...middleware) )(createStore)(reducer, initialStore); } </code></pre> <p>注意,传递给函数的 <code>initialStore</code> 参数在 <code>createStore</code> 函数中使用,以初始化 Redux 存储。因为这是在创建组件之前发生的,所以你的组件的第一次渲染最终使用了来自服务器的状态。</p> <p>使用来自服务器的状态,React 能够计算出初始虚拟 DOM 与提供给 render 函数的根容器附加的 DOM 匹配。它知道不需要进行任何浏览器 DOM 更新。</p> <p>现在你已经在浏览器中成功渲染了视图,让我们来探讨浏览器中第一次渲染的潜在问题。请注意,到目前为止,你只设置了处理初始渲染的代码。在本章的后面部分,你将添加在浏览器中路由时获取数据的功能。</p> <h3 id="83-执行第一次加载">8.3. 执行第一次加载</h3> <p>到目前为止,你应该清楚如何在浏览器中加载你的应用程序。了解浏览器中初始渲染过程中发生的事情也很重要,因为这与用户与应用程序交互后发生的情况不同。</p> <h4 id="831-第一次加载时的-react-生命周期">8.3.1. 第一次加载时的 React 生命周期</h4> <p>本节将带您了解 React 在浏览器中的首次渲染。当您调用 <code>ReactDOM.render</code> 时,React 开始在您的 main.jsx 文件中进行引导。每个正在启动的组件都会经过以下步骤:</p> <blockquote> <p><strong>1</strong>. 调用构造函数。</p> <p><strong>2</strong>. 触发 <code>componentWillMount</code>。</p> <p><strong>3</strong>. 调用渲染方法。</p> <p><strong>4</strong>. 调用 <code>componentDidMount</code>。</p> </blockquote> <p>记住,这从根组件开始,所有这些组件都来自第三方库(React Redux 和 React Router)。然后它继续到 React Router 计算出需要加载当前路由的组件。然后渲染它们的任何子组件。</p> <h5 id="初始渲染差异">初始渲染差异</h5> <p>重要的是要理解 <code>componentWillMount</code> 在服务器和浏览器的每个组件的首次加载时都会被调用!其中任何代码都需要真正是同构的。例如,如果您要将以下代码添加到 src/components/app.jsx 中,您的服务器将崩溃并且无法加载任何路由:</p> <pre><code>componentWillMount() { window.test = true; } </code></pre> <p>这是因为您的服务器找不到一个名为 <code>window</code> 的全局变量。<code>window</code> 对象在 Node.js 中不存在。如果您将此代码放入任何在首次渲染期间运行的方法(<code>constructor</code>、<code>componentWillMount</code> 或 <code>render</code>)中,它将在服务器上崩溃。</p> <p>相反,<code>componentDidMount</code> 在首次渲染完成后运行,仅在浏览器中调用。当您需要在到达浏览器后更新组件但不想破坏同构渲染时,这种区别变得非常有用。</p> <h4 id="832-同构渲染错误">8.3.2. 同构渲染错误</h4> <p>您可能在本章早期注意到控制台中的一个大红色警告。图 8.9 展示了它的样子。</p> <h5 id="图-89-当-react-检测到非真正同构的同构渲染时它发出的同构警告日志这发生在初始渲染周期的虚拟-dom-和浏览器-dom-不匹配时">图 8.9. 当 React 检测到非真正同构的同构渲染时,它发出的同构警告日志。这发生在初始渲染周期的虚拟 DOM 和浏览器 DOM 不匹配时。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig09_alt.jpg" alt="图片" loading="lazy"></p> <p>这不是一个有趣的错误。(公平地说,错误通常都不好玩。)第一次看到它时,感觉很难调试:“校验和无效”——这究竟是什么意思?</p> <p>这意味着 React 在浏览器中渲染了组件树,将其与已存在的 DOM 进行比较,并发现它们不同。但 React 很聪明,知道如果 DOM 元素中已经存在您告诉它渲染的子元素,这是一个同构渲染。这两个 DOM 在首次渲染时应该匹配。但出了点问题,它们没有匹配!</p> <h5 id="破坏渲染的实验">破坏渲染的实验</h5> <p>在工作中,我们经常运行 A/B 测试来尝试各种设计和 UX 实现,以找出什么最能帮助我们实现产品目标。有一天我们去测试我们的登录按钮。我们想知道是否应该将其称为“登录”或“注册”。尽管团队在处理同构应用方面经验丰富,但实验最终还是在我们头部组件的 <code>componentWillMount</code> 中进行,同构渲染警告开始被抛出。</p> <p>更糟糕的是,代码(大约在同一时间)的另一个更新改变了 <code>componentWillMount</code> 内的应用状态。结果发现,React 只会为第一次遇到的情况显示此警告,这意味着当我们解决了第一个问题时,我们发现了第二个问题。</p> <p>进入这种状况出奇地容易。在第一次渲染(在你的 <code>constructor</code>、<code>componentWillMount</code> 或 <code>render</code> 方法内部)的任何时候对浏览器中的应用状态进行微小更改都可能导致错误。我见到的进入这种状况的最常见原因是根据 cookies 改变状态。</p> <h5 id="警告">警告</h5> <p><em>Cookies</em> 是存在于你的应用代码之外的全局状态。它们是网络应用开发中强大且重要的工具。但它们可能会使你的初始应用状态复杂化。你要么应该在服务器上考虑 cookies,要么确保在浏览器中正确处理它们。第十章 Chapter 10 将更深入地介绍这一点。</p> <p>你应该关注这个警告的原因是,你将失去同构渲染的所有积极好处。你仍然有良好的感知性能——UI 和内容立即显示——但页面变得可用的过程可能会明显变长,这可能会让用户感到沮丧。</p> <p>这就是为什么 <code>componentDidMount</code> 在你的应用中成为一个强大的工具。下一节将探讨如何使用它来避免同构渲染错误。</p> <h4 id="833-使用-componentdidmount-防止同构加载错误">8.3.3. 使用 componentDidMount 防止同构加载错误</h4> <p>假设你想要在 cookies 中保存用户偏好。一个常见的用例是是否显示全站可关闭横幅。这对用户来说是有益的,因为它允许在浏览器中实现全局、易于访问的状态。但你必须警惕何时检查这些 cookies 以防止同构错误。</p> <h5 id="注意-23">注意</h5> <p>此示例和解决方案假设你不想在服务器上读取 cookies。在某些应用中,在服务器上读取 cookies 并从正确的应用状态开始可能更为实用。我们将在第十章 chapter 10 中更深入地探讨这些权衡。</p> <p>在这个例子中,你将添加一个横幅,通知用户关于半年度促销的信息。显示横幅的规则是:如果用户之前从未见过它,就显示它,直到他们将其关闭。你通过在浏览器中写入一个 cookie 来跟踪他们是否见过它。图 8.10 展示了你希望它看起来像什么。</p> <h5 id="图-810-在页面底部添加横幅">图 8.10. 在页面底部添加横幅</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig10_alt.jpg" alt="" loading="lazy"></p> <p>现在你需要添加一个横幅。如果你想查看基本代码,切换到分支 8.3.3 (<code>git checkout chapter-8.3.3</code>),其中包含了本章迄今为止添加的所有代码,并且已经为你创建了这个横幅组件(编写这个组件对于理解这个概念并不重要,但我已经为你提供了它,以便你有一些上下文)。</p> <h5 id="列表-87-横幅组件srccomponentsbannerjsx">列表 8.7. 横幅组件——src/components/banner.jsx</h5> <pre><code>class Banner extends React.Component { handleDismissBanner() { *1* // will do something } render() { return ( <div className=="banner" }> <div className="dismiss"> *2* <button className="btn-reset" onClick={this.handleDismissBanner} *3* > X </button> </div> <div className="content"> {this.props.children} *4* </div> </div> ); } } </code></pre> <ul> <li> <p><strong><em>1</em> 添加 dismiss 按钮的点击处理占位符。</strong></p> </li> <li> <p><strong><em>2</em> dismiss 按钮位于此处。</strong></p> </li> <li> <p><strong><em>3</em> onClick 处理器引用 handleDismissBanner 函数。</strong></p> </li> <li> <p><strong><em>4</em> 父组件为横幅设置子组件(使其更具可重用性)。</strong></p> </li> </ul> <p>此外,以下代码已添加到 src/components/app.jsx 的标题之后(约第 13 行):</p> <pre><code><Banner show> <h3>Check out the semi-annual sale! Up to 75% off select Items</h3> </Banner> </code></pre> <p>现在你需要添加决定页面加载时是否显示此横幅的代码。这涉及到检查一个 cookie,然后告诉横幅是可见的还是保持隐藏。以下列表显示了如何更新 banner.jsx 以使其工作。</p> <h5 id="列表-88-显示横幅组件srccomponentsbannerjsx">列表 8.8. 显示横幅组件——src/components/banner.jsx</h5> <pre><code>class Banner extends React.Component { constructor(props) { super(props); this.state = { *1* show: false }; this.handleDismissBanner = this.handleDismissBanner.bind(this); } componentDidMount() { const cookies = document.cookie; *2* const hideBanner = cookies.match('showBanner=false'); *2* if (!hideBanner) { this.setState({ *2* show: true }); } } handleDismissBanner() { document.cookie = 'showBanner=false'; *3* this.setState({ *3* show: false }); } render() { const bannerClasses = classnames( { show: this.state.show }, 'banner' ); *4* return ( <div className={bannerClasses}> *4* <div className="dismiss"> <button className="btn-reset" onClick={this.handleDismissBanner} > X </button> </div> <div className="content"> {this.props.children} </div> </div> ); } } </code></pre> <ul> <li> <p><strong><em>1</em> 在构造函数中创建初始状态——为了与服务器匹配,横幅默认应隐藏。</strong></p> </li> <li> <p><strong><em>2</em> 获取 cookies,查看用户是否之前关闭了横幅——如果没有,设置状态以显示横幅(将触发重新渲染)。</strong></p> </li> <li> <p><strong><em>3</em> 当用户关闭浏览器时,将 cookie 写入页面并将状态设置为隐藏。</strong></p> </li> <li> <p><strong><em>4</em> 使用状态生成横幅类。</strong></p> </li> </ul> <p>使用状态生成横幅类将在横幅可见时添加一个名为 <code>show</code> 的类。这个类的 CSS 将显示属性更改为块,使组件变得可见。</p> <p>通常,你不想在 <code>componentDidMount</code> 中设置状态。但在这个例子中,同构渲染使其成为更新状态的最好位置,因为我们想确保第一次渲染与服务器状态匹配。在这个函数中设置状态时要小心——你很容易导致组件不必要的重新渲染。</p> <h3 id="84-添加单页应用交互">8.4. 添加单页应用交互</h3> <p>恭喜你,你已经做到了!你的应用从服务器加载并渲染,并在浏览器中成功渲染。遗憾的是,它仍然在浏览器中没有任何操作,因为你还没有告诉它要做什么。让我们让应用在路由之间切换时在浏览器中加载数据。</p> <h4 id="841-浏览器路由数据获取">8.4.1. 浏览器路由:数据获取</h4> <p>您设置的路线将使页面顶部的路线工作。但如果你首先导航到根目录或 /products,然后点击购物车,你会发现购物车加载时没有任何项目。这是因为您还没有设置应用程序的 SPA 部分来获取任何数据。图 8.11 展示了这看起来是什么样子。</p> <h5 id="图-811-从产品页面加载购物车导致状态为空">图 8.11. 从产品页面加载购物车导致状态为空。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig11_alt.jpg" alt="" loading="lazy"></p> <p>要使应用程序在浏览器中获取数据,您将利用 <code>prefetchActions</code> 静态函数在每个路由上获取数据。React Router 为其生命周期中的各个部分提供了回调。有一个 <code>onChange</code> 回调可以配置。提供的函数将在每个路由渲染之前被调用,这为您提供了从 API 获取所需数据的路由的机会。</p> <h5 id="注意-24">注意</h5> <p>如果您使用 React Router 4,请查看附录 A [kindle_split_027_split_000.xhtml#app01],以了解设置路线和相关路由代码的概述。它还展示了如何在浏览器上预取数据以及如何在 React 生命周期中而不是在 React Router 生命周期中处理路线更改。</p> <p>如果您想查看基本代码,请切换到分支 8.4.1 (<code>git checkout chapter-8.4.1</code>)。以下列表显示了要添加到 sharedRoutes.jsx 中的代码。</p> <h5 id="列表-89-在浏览器中获取数据srcsharedsharedroutesjsx">列表 8.9. 在浏览器中获取数据—src/shared/sharedRoutes.jsx</h5> <pre><code>let beforeRouteRender = (dispatch, prevState, nextState) => { *1* const { routes } = nextState; *2* }; export const routes = (onChange = () => {}) => { *3* return ( <Route path="/" component={App} onChange={onChange} > *4* <Route path="/cart" component={Cart} /> <Route path="/products" component={Products} /> <Route path="/product/detail/:id" component={Detail} /> <Route path="/profile" component={Profile} /> </Route> ); }; const createSharedRoutes = ({ dispatch }) => { *5* beforeRouteRender = beforeRouteRender.bind(this, dispatch); *6* return routes(beforeRouteRender); *7* }; export default createSharedRoutes; *5* </code></pre> <ul> <li> <p><strong><em>1</em> 声明 <code>onChange</code> 处理器,该处理器接受来自 Redux 的 <code>dispatch</code> 函数和来自 React Router 的参数,包括 <code>prevState</code> 和 <code>nextState</code>。</strong></p> </li> <li> <p><strong><em>2</em> 从 <code>nextState</code> 中提取路线数组,以便您可以迭代组件并从 <code>prefetchActions</code> 获取操作。</strong></p> </li> <li> <p><strong><em>3</em> 创建一个返回路线的 <code>routes</code> 函数,让您传入 <code>onChange</code> 处理器。</strong></p> </li> <li> <p><strong><em>4</em> 将 <code>onChange</code> 处理器分配给顶级路由——所有子路由之间的更改都将触发此处理器。</strong></p> </li> <li> <p><strong><em>5</em> 模块的默认导出现在是一个函数,需要传入 Redux 存储。</strong></p> </li> <li> <p><strong><em>6</em> 将 <code>dispatch</code> 绑定到 <code>onChange</code> 处理器。</strong></p> </li> <li> <p><strong><em>7</em> 返回带有传入 <code>onChange</code> 处理器的 <code>routes</code> 函数。</strong></p> </li> </ul> <p>在浏览器中传递 <code>onChange</code> 处理器是必要的,因为您需要将其绑定到 <code>dispatch</code>。此函数被导出,以便服务器代码可以调用它。这里有几个重要的事项。首先,您只需要将 <code>onChange</code> 处理器添加到顶级路由。这是因为 <code>onChange</code> 会在子路由更改时触发。当用户在购物车或产品之间切换时,它会被触发。这也意味着它不会在初始渲染时触发。这将是多余的,因为数据已经从服务器状态中可用。</p> <p>这段逻辑中最复杂的部分是从组件中获取 <code>prefetchActions</code> 数组。这是 <code>nextState</code> 中的 <code>routes</code> 变量很重要的地方。<code>routes</code> 变量是一个对象数组。每个路由上都有一个组件列表。从这些中,可以收集需要为路由调用的所有动作。以下列表显示了您需要添加的最后一段代码,以便使所有这些工作。将代码添加到 sharedRoutes.jsx 中。您会注意到它与 renderView.jsx 中的代码类似。</p> <h5 id="列表-810-在路由更改时调用-prefetchactionssrcsharedsharedroutesjsx">列表 8.10. 在路由更改时调用 <code>prefetchActions</code>—src/shared/sharedRoutes.jsx</h5> <pre><code>let beforeRouteRender = (dispatch, prevState, nextState) => { const { routes } = nextState; routes.map((route) => { *1* const { component } = route; *2* if (component) { if (component.displayName && component.displayName .toLowerCase().indexOf('connect') > -1 *3* ) { if (component.WrappedComponent .prefetchActions) { *4* return component.WrappedComponent.prefetchActions (); } } else if (component.prefetchActions) { *3* return component. prefetchActions(); } } return []; }).reduce((flat, toFlatten) => { *5* return flat.concat(toFlatten); }, []).map((initialAction) => { *6* return dispatch(initialAction()); }); }; </code></pre> <ul> <li> <p><strong><em>1</em> 使用 map 遍历数组中的每个路由(根应用路由和请求的路由)。</strong></p> </li> <li> <p><strong><em>2</em> 从路由中获取组件。</strong></p> </li> <li> <p><strong><em>3</em> 检查组件是否为 HOC connect 组件——如果是,则获取组件的子属性。</strong></p> </li> <li> <p><strong><em>4</em> 如果组件有 WrappedComponent 属性,则调用 prefetchActions。</strong></p> </li> <li> <p><strong><em>5</em> 将映射的结果,可能是一个嵌套数组,缩减为单个数组。</strong></p> </li> <li> <p><strong><em>6</em> 使用 map 遍历最终的扁平化数组;对每个项目调用 dispatch 以执行操作。</strong></p> </li> </ul> <p>您可能会注意到 <code>sharedRoutes</code> 的结构改变了很多。这是因为您需要访问 Redux 的 <code>dispatch</code> 方法来触发动作。在 <code>main.jsx</code> 和 <code>renderView.jsx</code> 中,您需要更新访问共享路由的方式。以下列表显示了 <code>main.jsx</code> 中需要做出的更改。</p> <h5 id="列表-811-更新-main-以调用-sharedroutessrcmainjsx">列表 8.11. 更新 Main 以调用 sharedRoutes—src/main.jsx</h5> <pre><code>function init() { ReactDOM.render( <Provider store={store}> <Router routes={sharedRoutes(store)} history={browserHistory} /> </Provider>, document.getElementById('react-content')); } </code></pre> <p>而不是直接插入路由,默认导出现在是一个函数。在这里,您调用该函数,传入存储,以便 <code>sharedRoutes</code> 可以将 <code>dispatch</code> 传递到 <code>onChange</code> 处理器。</p> <p><code>renderView</code> 中间件的更改与 <code>main.jsx</code> 中的更改类似。但您必须首先更改导入,因为在服务器上您不需要执行 <code>onChange</code> 逻辑——它永远不会触发。以下列表展示了需要更改的内容。</p> <h5 id="列表-812-更新-renderview-以使用路由srcmiddlewarerenderviewjsx">列表 8.12. 更新 <code>renderView</code> 以使用路由—src/middleware/renderView.jsx</h5> <pre><code>...additional code import { match, RouterContext } from 'react-router'; import { routes } from '../shared/sharedRoutes'; *1* import initRedux from '../shared/init-redux.es6'; import HTML from '../components/html'; export default function renderView(req, res, next) { const matchOpts = { routes: routes(), *2* location: req.url }; ...additional code } </code></pre> <ul> <li> <p><strong><em>1</em> 包含路由而不是默认导出——服务器不需要初始化 onChange 处理器,因为它在服务器上永远不会被调用。</strong></p> </li> <li> <p><strong><em>2</em> 调用路由函数以获取共享路由。</strong></p> </li> </ul> <p>在一切配置为在服务器上加载数据后,您应该看到购物车已填充。此外,如果您查看网络标签页,您将看到从服务器获取的购物车数据。这只有在您首先通过 /products 加载应用程序,然后从导航中选择购物车时才会显示。图 8.12 展示了您要查找的内容。</p> <h5 id="图-812-现在浏览器获取数据后您可以在-chrome-开发者工具的网络标签页中看到-xhr-调用">图 8.12. 现在浏览器获取数据后,您可以在 Chrome 开发者工具的网络标签页中看到 XHR 调用。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/08fig12_alt.jpg" alt="" loading="lazy"></p> <p>现在您已经构建了代码的浏览器部分,您可以查看该章节的完整代码,分支为 chapter-8-complete (<code>git checkout chapter-8-complete</code>)。</p> <h3 id="摘要-6">摘要</h3> <p>在本章中,您学习了如何构建同构应用的浏览器部分。您添加了一个名为 main.jsx 的浏览器入口点文件,并处理了使应用在浏览器中以与服务器上渲染相同的状态启动所需的初始化逻辑。</p> <ul> <li> <p>初始服务器状态以字符串形式添加到 DOM 中,并作为服务器渲染页面的一部分发送下来。</p> </li> <li> <p>浏览器入口点处理反序列化服务器创建的状态,初始化 Redux,并将 React 组件树渲染到 DOM 中。</p> </li> <li> <p>服务器将应用的状态附加到 DOM 上作为字符串。</p> </li> <li> <p>浏览器将字符串读入一个对象,该对象可以传递给 Redux。</p> </li> <li> <p>Redux 可以使用从服务器反序列化的基本状态进行初始化。</p> </li> <li> <p>React 的组件生命周期可以决定同构渲染的成功与否。在生命周期中的正确点更新应用状态非常重要。</p> </li> <li> <p>浏览器代码还需要处理应用的单页应用部分的数据获取。</p> </li> </ul> <h2 id="第九章测试和调试">第九章:测试和调试</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>创建一个反映同构应用复杂性的测试环境</p> </li> <li> <p>使用 Enzyme 为您的 React 组件创建单元测试</p> </li> <li> <p>使用 React 开发者工具在浏览器中进行调试</p> </li> <li> <p>使用 Redux 开发者工具在浏览器中进行调试</p> </li> </ul> <p>您是否曾在拥有大量测试的代码库中工作过,但测试会随机崩溃,需要不断更新,并且从未阻止您创建回归错误?当您有超过两个开发者在一个代码库上工作时,这种情况出人意料地容易发生。拥有一个稳固的测试策略,并理解单元测试和集成测试之间的界限在哪里,对于任何应用都很重要。但是,同构应用有一个额外的复杂层次需要考虑:这是针对在服务器上运行、在浏览器上运行还是在两个环境中都运行的代码的测试吗?</p> <p>本章的第一部分探讨了如何思考测试同构应用。您将学习如何使用 Enzyme 测试 React,以及如何在多个环境中测试您的组件。本章的第二部分涵盖了有助于实际开发的调试工具。</p> <p>本章的代码可以在 GitHub 上的 complete-isomorphic-example 仓库中找到:</p> <pre><code>$ git clone https://github.com/isomorphic-dev-js/complete-isomorphic- example.git </code></pre> <p>每个部分都提供了一个分支中的基本代码。您将使用的第一个分支是 chapter-9.1.1:</p> <pre><code>$ git checkout chapter-9.1.1 $ npm install $ npm start </code></pre> <h3 id="91-测试react-组件">9.1. 测试:React 组件</h3> <p>本章前半部分的目标是指导您了解在代码可以在多个环境中运行时如何进行测试。要做到这一点,需要以下条件:</p> <ul> <li> <p>拥有在服务器和浏览器环境中运行测试的正确工具</p> </li> <li> <p>知道何时在特定环境中测试代码(例如,您不需要在浏览器中测试 Express 中间件)</p> </li> </ul> <p>测试有很多类别,包括单元测试、集成测试、契约测试、端到端测试等等。尽可能的情况下,你希望你的实际开发构建与你的测试构建方式相匹配。你还希望以允许浏览器和服务器验证的方式运行你的代码的单元测试。</p> <p>我已经为你设置了许多单元测试环境。我们将使用 Mocha 作为测试库,Karma 作为我们的测试运行器(这样调试浏览器测试就简单了)。如果你是单元测试的新手或者需要复习,我强烈推荐 Roy Osherove 的《单元测试的艺术》(Manning,2013)。这本书是我的单元测试入门,是一个无价资源。要查看 Karma 的文档,请访问 <a href="https://karma-runner.github.io/1.0/index.html" target="_blank"><code>karma-runner.github.io/1.0/index.html</code></a>。你可以在 <a href="https://mochajs.org" target="_blank"><code>mochajs.org</code></a> 上了解更多关于 Mocha 的信息。</p> <p>如果你想要看看我是如何为这一章设置单元测试配置的,你可以在根目录下的 karma.conf.js 文件中找到配置文件,以及在 package.json 下的 test:browser 下的脚本。测试是用 Karma(测试运行器)、Mocha(测试库)和 Chai(断言库)设置的。Karma 测试配置使用与开发相同的配置的 webpack 打包测试。被测试的代码是以与应用程序代码相同的方式编译的。在本节结束时,你将在终端中看到类似 figure 9.1 的测试输出。</p> <h5 id="图-91-所有测试通过时的浏览器测试输出">图 9.1. 所有测试通过时的浏览器测试输出</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/09fig01_alt.jpg" alt="" loading="lazy"></p> <p>要运行本节的测试,请运行以下命令:</p> <pre><code>$ npm run test:browser </code></pre> <p>目前还没有测试,所以你会在终端中看到“已执行 0 个,共 0 个”。注意,还会打开一个新的 Google Chrome 实例,如图 9.2 所示 figure 9.2。</p> <h5 id="图-92-karma-会为你启动-google-chrome以便你的测试在真实浏览器环境中运行">图 9.2. Karma 会为你启动 Google Chrome,以便你的测试在真实浏览器环境中运行。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/09fig02_alt.jpg" alt="" loading="lazy"></p> <p>尝试点击调试按钮。它会打开一个新标签页。你可以使用这个标签页打开 Chrome DevTools 并调试你的测试,就像你在遇到问题时调试应用程序一样。</p> <p>现在我们来学习如何使用 Enzyme 编写你的第一个 React 测试。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>测试替代方案:Jest</strong></p> <p>另一个很好的测试库是 Jest。文档可以在 <a href="https://facebook.github.io/jest/docs/en/getting-started.html" target="_blank"><code>facebook.github.io/jest/docs/en/getting-started.html</code></a> 找到。Jest 是由 Facebook 开发的一个测试框架,它提供了零配置所需的一切。在测试的世界里,这是一个很好的变化!</p> <p>默认情况下,Jest 随附 Jasmine 和它自己的模拟工具。它也开箱即用就很快,这可以在本地运行测试以及在持续集成服务器上运行测试时节省你的时间。</p> <p>当你在 webpack 配置的项目中使用 Jest 时,你将面临权衡。你可能需要进行一些额外的设置(所以,并不是零配置)。文档提供了一个有用的指南:<a href="https://facebook.github.io/jest/docs/en/webpack.html#content" target="_blank"><code>facebook.github.io/jest/docs/en/webpack.html#content</code></a>。</p> <p>此外,你不再在浏览器上下文中运行你的测试。Karma 甚至允许你在多个浏览器中运行你的测试。但你可能觉得这对你的项目并不重要,而且 Karma 比 Jest 慢得多。如果你更重视速度和易于配置,我建议尝试 Jest。</p> <h4 id="911-使用-enzyme-测试组件">9.1.1. 使用 Enzyme 测试组件</h4> <p>一个名为 Enzyme 的库已经成为测试 React 组件的流行选择(<a href="https://github.com/airbnb/enzyme" target="_blank"><code>github.com/airbnb/enzyme</code></a>)。它提供了一个 API,使得针对你的视图组件编写测试断言变得简单。</p> <p>你可以使用 Enzyme 编写的最简单的单元测试形式是渲染你正在测试的组件的浅版本。任何子组件都不会被渲染。这很重要,因为它允许你测试单个组件而不需要深入到子组件中。这使得你的测试成为真正的单元测试,从而产生更不脆弱、更干净的测试。</p> <p>你将要添加的第一个测试是针对 App 组件的。App 有多个子组件,包括来自 React Router 的 Link 组件,而我们并不想测试这个组件。下面的列表显示了如何使用浅渲染对组件进行基本断言。将列表中的代码添加到一个新的测试文件 test/components/app.spec.jsx 中。</p> <h5 id="列表-91-使用-shallow-渲染--testcomponentsappspecjsx">列表 9.1. 使用 <code>shallow</code> 渲染 —— test/components/app.spec.jsx</h5> <pre><code>import React from 'react'; *1* import { expect } from 'chai'; *2* import { shallow } from 'enzyme'; *3* import { Link } from 'react-router'; *4* import App from '../../src/components/app'; *5* describe('App Component', () => { let wrappedComponent; beforeEach(() => { *6* wrappedComponent = shallow(<App />); *5* }); afterEach(() => { wrappedComponent = null; *6* }); it('Uses Link Components', () => { expect( wrappedComponent.find(Link).length ).to.eq(3); *4* }); it('Links to /products, /cart and /profile pages', () => { expect( wrappedComponent.find({ to: '/products' }).length ).to.eq(1); *7* expect( wrappedComponent.find({ to: '/cart' }).length ).to.eq(1); *7* expect( wrappedComponent.find({ to: '/profile' }).length ).to.eq(1); *7* }); }); </code></pre> <ul> <li> <p><strong><em>1</em> 包含 React —— 你将使用 JSX 来浅加载 App 组件。</strong></p> </li> <li> <p><strong><em>2</em> 包含来自 Chai 的 expect 来编写断言 —— 你可以替换你喜欢的断言风格。</strong></p> </li> <li> <p><strong><em>3</em> 包含来自 Enzyme 的 shallow 函数,它加载组件而不加载其子组件(只加载 React 树的一级)。</strong></p> </li> <li> <p><strong><em>4</em> Enzyme 支持使用 React 组件引用来检查组件内的存在性——这里你正在检查 Link 组件的存在。</strong></p> </li> <li> <p><strong><em>5</em> App 组件通过 shallow 函数加载,返回一个包装组件,它提供了你可以用来与测试中的组件交互和断言的方法。</strong></p> </li> <li> <p><strong><em>6</em> 利用 Mocha 的 beforeEach 和 afterEach,它们在每个测试前后各执行一次。</strong></p> </li> <li> <p><strong><em>7</em> 展示了你可以与 Enzyme 一起使用的另一种类型的选择器,这里断言具有属性“to”的元素存在,每个断言都在寻找特定的路由。</strong></p> </li> </ul> <h5 id="enzyme-选择器">Enzyme 选择器</h5> <p>你刚刚添加的两个测试展示了你可以将 Enzyme 选择器应用于渲染树中查找元素的三种可能方法中的两种:</p> <ul> <li> <p>组件选择器(接受一个 React 组件引用)</p> </li> <li> <p>属性选择器(接受一个对象)</p> </li> </ul> <p>还有第三种选择器类型:CSS 选择器。它们的工作方式类似于 JQuery 选择器:要按 ID 查找元素,你查找<code>#id</code>。要按类查找组件,你查找<code>.class</code>或<code>.multiple.class</code>。</p> <p>让我们看看另一个使用类选择器查找包含被测试单元的元素的例子。在这个例子中,你正在为 Item 组件添加测试——这是一个展示组件,它的唯一任务是显示传入的属性。在列表 9.2 中,你可以看到如何使用 Enzyme 断言 Item 组件的预期部分渲染正确。请注意,你只添加了断言那些由传入的属性更改的标记部分的测试。这减少了测试的脆弱性。直接在标记上断言意味着你的单元测试可能会因为类更改而改变。将列表中的代码添加到名为<code>test/components/item.spec.jsx</code>的新文件中。</p> <h5 id="列表-92-测试一个展示组件testcomponentsitemspecjsx">列表 9.2. 测试一个展示组件—test/components/item.spec.jsx</h5> <pre><code>import { expect } from 'chai'; import { shallow } from 'enzyme'; import React from 'react'; import Item from '../../src/components/item'; describe('Item Component', () => { let testComponent; let props; beforeEach(() => { props = { *1* thumbnail: 'http://image.png', name: 'Test Name', price: 10 }; testComponent = shallow(<Item {...props} />); }); afterEach(() => { testComponent = null; props = null; }); it('Displays a thumbnail sbased on its props', () => { *2* expect( testComponent.find({ src: props.thumbnail }).length ).to.eq(1); }); it('Displays a name based on its props', () => { *3* expect( testComponent.find( '.middle.aligned.content' ).text() ).to.eq(props.name); }); it('Displays a price based on its props', () => { *4* expect( testComponent.find( '.right.aligned.content' ).text() ).to.eq(`$${props.price}`); }); }); </code></pre> <ul> <li> <p><strong><em>1</em> 对于这个测试,提供一个模拟的 props 对象。</strong></p> </li> <li> <p><strong><em>2</em> 测试断言缩略图被分配给了 src 属性。</strong></p> </li> <li> <p><strong><em>3</em> 测试断言名称被放入正确的元素中;text()返回 HTML 标签的内部内容。</strong></p> </li> <li> <p><strong><em>4</em> 测试断言价格被正确地放置在组件中。</strong></p> </li> </ul> <p>双美元符号看起来很奇怪,这是因为字符串插值。第一个美元符号是显示在视图中的字面量美元符号。另一个是字符串中的变量。</p> <p>请记住,你想要使用的选择器不太可能频繁更改。你并不总能预测这一点,但提前思考一下可以减少测试重构。</p> <p>现在你已经了解了使用 Enzyme 对 React 组件进行单元测试的基本知识,让我们看看如何编写一个测试来验证用户与组件的交互。</p> <h4 id="912-测试用户操作">9.1.2. 测试用户操作</h4> <p>使用浅渲染进行测试也允许你测试用户交互。在本节中,你将向购物车组件添加一个测试,以查看按钮点击是否正确触发了路由更新。请注意,要进行此测试,我已在<code>sharedRoutes</code>中添加了一个路由<code>/cart/payment</code>,并将<code>cart</code>中的结账按钮更新为导航到该路由:</p> <pre><code>proceedToCheckout() { this.props.router.push('/cart/payment'); } </code></pre> <p>如果你切换到<code>chapter-9.1.2</code>分支(<code>git checkout chapter-9.1.2</code>),你可以看到这段添加的代码。如果你在跟进,你需要更新列表 9.4 中的<code>proceedToCheckout</code>以通过测试。</p> <p>你还需要对购物车组件进行另一个更新。目前,该组件被 Connect 包装然后导出,这使得测试变得极其困难。这也意味着你正在测试一个组合组件而不是独立的购物车组件。以下列表显示了如何更新购物车组件以导出非包装版本进行测试。</p> <h5 id="列表-93-向购物车组件添加第二个导出srccomponentscartjsx">列表 9.3. 向购物车组件添加第二个导出—src/components/cart.jsx</h5> <pre><code>export class CartComponent extends Component { *1* //component implementation } CartComponent.propTypes = {} *2* export default connect( mapStateToProps, mapDispatchToProps )(CartComponent); *3* </code></pre> <ul> <li> <p><strong><em>1</em> 在类定义中添加一个导出并更新组件名称(允许未来使用命名导出)。</strong></p> </li> <li> <p><strong><em>2</em> 更新所有对 Cart 的引用。</strong></p> </li> <li> <p><strong><em>3</em> 更新所有对 Cart 的引用—注意你仍然导出了一个 connect 包装的组件。</strong></p> </li> </ul> <p>列表 9.4 展示了如何测试 Cart 组件正确调用 History 组件以导航到结账部分。测试导入命名导出 <code>CartComponent</code> 而不是默认包装组件。将此代码添加到名为 test/components/cart.spec.jsx 的新文件中。</p> <h5 id="列表-94-测试用户交互testcomponentscartspecjsx">列表 9.4. 测试用户交互—test/components/cart.spec.jsx</h5> <pre><code>import { expect } from 'chai'; import { shallow } from 'enzyme'; import React from 'react'; import sinon from 'sinon'; *1* import { CartComponent } from '../../src/components/cart'; describe('Cart Component', () => { let testComponent; let props; beforeEach(() => { props = { items: [ { thumbnail: 'http://image.png', name: 'Test Name', price: 10 } ], router: { push: sinon.spy() *2* } }; testComponent = shallow(<CartComponent {...props} />); }); afterEach(() => { testComponent = null; props = null; }); it('When checkout is clicked, the router push method is triggered', () => { testComponent.find('.button').simulate('click'); *3* expect(props.router.push.called).to.be.true; *4* expect( props.router.push.calledWith('/cart/payment') ).to.be.true; *5* }); }); </code></pre> <ul> <li> <p><strong><em>1</em> 要运行测试,你需要一个额外的依赖项;使用 Sinon 你可以创建在测试期间被调用的模拟函数。</strong></p> </li> <li> <p><strong><em>2</em> 使用 sinon.spy() 模拟 cart.jsx 中 proceedToCheckout() 函数中调用的 router.push() 函数。</strong></p> </li> <li> <p><strong><em>3</em> 使用 simulate() 方法生成对 Checkout 按钮的点击。</strong></p> </li> <li> <p><strong><em>4</em> 使用 sinon spy 的 called 属性来断言你的组件调用了 router.push() 方法。</strong></p> </li> <li> <p><strong><em>5</em> 使用 sinon spy 的 calledWith 属性来断言传递了正确的路由。</strong></p> </li> </ul> <p>列表使用 Sinon 创建函数模拟。要了解更多关于 Sinon 的信息,请访问他们的文档<a href="http://sinonjs.org" target="_blank"><code>sinonjs.org</code></a>。列表还使用 Enzyme 来测试事件。关于 <code>simulate</code> 有一点需要注意,它不会传播模拟事件——你必须找到具有事件监听器的元素来触发监听器。这适用于所有通过 Enzyme 加载的组件,即使是使用 <code>mount</code> 的组件。在下文中,你将学习如何使用 <code>mount</code> 编写集成测试。</p> <h4 id="913-测试嵌套组件">9.1.3. 测试嵌套组件</h4> <p>Enzyme 提供了测试组件的第二种方法,称为 <code>mount</code>。这会完全渲染你的组件及其所有子组件。它对于集成测试非常有用,因为你正在测试应用程序的更大部分。我不建议将其用于单元测试,因为 <code>mount</code> 使得测试更加脆弱。子组件的更改将破坏使用 <code>mount</code> 进行测试的任何父组件的测试。</p> <p>列表 9.5 展示了如何添加一个集成测试,该测试加载整个 React 树,包括 React Router。这允许你断言完整的 React 树,包括所有子组件。到目前为止的代码位于 chapter-9.1.3(<code>git checkout chapter-9.1.3</code>)。将列表中的代码添加到应用程序规范中。请注意,这是一个位于集成文件夹中的不同的 app.spec.jsx。</p> <h5 id="注意-25">注意</h5> <p>如果你正在使用 React Router 4,请查看附录 A(kindle_split_027_split_000.xhtml#app01)以了解设置路由和相关路由代码的概述。以下列表显示了 main.jsx 代码,这也适用于测试设置。</p> <h5 id="列表-95-使用-mount-渲染testintegrationappspecjsx">列表 9.5. 使用 <code>mount</code> 渲染—test/integration/app.spec.jsx</h5> <pre><code>import React from 'react'; import { Router, createMemoryHistory } from 'react-router'; *1* import { expect } from 'chai'; import { mount } from 'enzyme'; import { routes } from '../../src/shared/sharedRoutes'; *1* describe('App Component', () => { it('Uses Link Components', () => { const renderedComponent = mount( <Router routes={routes()} history={createMemoryHistory('/products')} *2* /> ); expect( renderedComponent.find( '.search' ).length ).to.be.above(1); *3* }); }); </code></pre> <ul> <li> <p><strong><em>1</em> 包含依赖以渲染 React Router。</strong></p> </li> <li> <p><strong><em>2</em> 而不是使用浏览器历史记录,使用 createMemoryHistory,它允许你为路由器声明一个初始路由(无需在浏览器中进行导航)。</strong></p> </li> <li> <p><strong><em>3</em> 断言搜索已加载,它仅存在于产品页面上。</strong></p> </li> </ul> <p>之前示例中的测试很简单。在真正的集成测试中,你想要测试更复杂的事情,比如在搜索框中进行搜索。在继续之前,这里有一些你可以用来编写更复杂测试用例的额外方法。</p> <h5 id="高级酶-api-方法">高级酶 API 方法</h5> <p>Enzyme 有许多可以在你的浅层或挂载的 React 组件上调用的额外方法。以下是一些最有用的方法列表:</p> <ul> <li> <p><strong><code>setProps</code>—</strong> 使用此方法向已加载组件传递更新的属性。对于测试在 React 生命周期方法中发生的逻辑很有用。</p> </li> <li> <p><strong><code>setState</code>—</strong> 使用此方法来更改已加载组件的状态。它允许你测试复杂的情况,例如 products.jsx 中的搜索输入。</p> </li> <li> <p><strong><code>debug</code>—</strong> 在任何时间点查看组件渲染的 HTML 非常有用。这对于调试很有帮助。</p> </li> <li> <p><strong><code>unmount</code>—</strong> 测试在<code>componentWillUnmount</code>中发生的任何代码。</p> </li> </ul> <h3 id="92-测试同构思考">9.2. 测试:同构思考</h3> <p>使用同构应用程序,仅编写在浏览器中运行的单元测试是不够的。你的测试目标应该是运行你的代码将在其中运行的所有环境中。</p> <p>在你的应用程序中运行在服务器上的任何代码都应该在终端中使用 Mocha 进行测试。在浏览器中运行的任何代码都应该在 Karma 浏览器环境中进行测试。但是,仅在服务器或仅在浏览器中运行的代码不需要在相反的环境中测试。表 9.1 使用在 All Things Westies 应用程序中运行的代码类型说明了这一点。</p> <h5 id="表-91-在同构应用程序中测试所有环境中的代码很重要">表 9.1. 在同构应用程序中,测试所有环境中的代码很重要</h5> <table> <thead> <tr> <th></th> <th>服务器测试</th> <th>浏览器测试</th> </tr> </thead> <tbody> <tr> <td>组件</td> <td>是</td> <td>是</td> </tr> <tr> <td>共享文件夹代码</td> <td>是</td> <td>是</td> </tr> <tr> <td>中间件</td> <td>是</td> <td>否</td> </tr> <tr> <td>服务器入口代码</td> <td>是</td> <td>否</td> </tr> <tr> <td>浏览器入口代码</td> <td>否</td> <td>是</td> </tr> </tbody> </table> <h4 id="921-在服务器上测试-react-组件">9.2.1. 在服务器上测试 React 组件</h4> <p>Enzyme 的伟大之处在于<code>shallow</code>函数不需要真实的 DOM。你可以使用 Karma 和终端中的 Mocha 运行这些测试。但是,如果你想更准确地测试你的组件,就像它们在服务器上使用的那样,你可以添加一个额外的仅服务器测试。(如果你想从这个部分开始,你可以切换到 chapter-9.2.1 分支。)</p> <p>图 9.3 展示了服务器测试的输出。服务器测试已经运行了你之前编写的一些测试。唯一没有运行的是集成测试,因为那需要有一个浏览器环境。</p> <h5 id="图-93-服务器测试输出">图 9.3. 服务器测试输出</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/09fig03_alt.jpg" alt="" loading="lazy"></p> <p>使用以下命令运行服务器测试,该命令使用 Mocha CLI:</p> <pre><code>$ npm run test:server </code></pre> <p>要了解为什么在服务器上运行测试会增加价值,请看图 9.4。它显示了由于浏览器环境代码的引用而导致服务器测试失败。</p> <h5 id="图-94-如果添加了需要浏览器环境的引用你的测试将失败">图 9.4. 如果添加了需要浏览器环境的引用,你的测试将失败。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/09fig04_alt.jpg" alt="图片 9.4" loading="lazy"></p> <p>要亲自尝试,请在 Item 组件的渲染函数中添加对<code>window</code>对象的引用,如下所示:</p> <pre><code>window.test = true; </code></pre> <p>当你在测试中运行此代码时,它将失败。如果你尝试运行应用程序,你将无法直接导航到 localhost:3000/cart。在服务器上进行测试可以防止特定环境的糟糕问题。</p> <p>测试的一个好处是,随着新成员加入项目,测试可以防止他们因不了解而造成问题。这种类型的服务器测试有助于新团队成员逐渐习惯于同构式思考。</p> <h4 id="922-测试所有事物">9.2.2. 测试所有事物</h4> <p>根据你的项目和团队或组织的大小,你可能使用 Selenium 等工具进行一些端到端或功能自动化测试。或者你可能有一些手动质量保证测试员在产品中寻找错误。或者也许你的开发者进行手动测试。</p> <p>在所有这些情况下,考虑到达应用程序每个部分的多种方式是很重要的:</p> <ul> <li> <p>从服务器加载的初始页面</p> </li> <li> <p>通过单页应用程序(SPA)流程在应用程序中导航</p> </li> </ul> <p>虽然在本书的这个阶段这对你来说可能很显然,但对于那些不从事同构式应用程序开发的人来说,这并不是一个容易理解的概念。要进行手动质量保证,你必须运行许多测试用例两次。考虑一下在这个应用程序中测试购物车。因为应用程序假设用户会话已保存,你需要测试直接从服务器加载购物车,并测试从应用程序的另一部分导航到购物车。</p> <p>为了说明这一点,图 9.5 显示了从服务器首次加载购物车页面时的网络输出,与从应用程序的另一部分(SPA 流程)加载购物车时的网络输出进行了比较。</p> <h5 id="图-95-初始加载和-spa-加载之间的差异需要测试这两种用例">图 9.5. 初始加载和 SPA 加载之间的差异需要测试这两种用例。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/09fig05_alt.jpg" alt="图片 9.5" loading="lazy"></p> <p>在本节中,你学习了如何考虑同构式测试。下一节将向你介绍调试工具。</p> <h3 id="93-使用调试工具">9.3. 使用调试工具</h3> <p>我最喜欢的新开发人员或 JavaScript 新手教学时刻之一是向他们展示如何使用 Chrome DevTools 提高他们调试和解决问题的能力。从代码中散布的 <code>console.log</code> 语句到使用断点,对于新手来说,这通常是一个令人震惊的时刻。断点很有用,因为您可以暂停并检查您的代码,逐步执行代码以找到问题。Chrome DevTools 还提供了断点的几个高级选项(在 <a href="https://umaar.com/dev-tips/28-dom-breakpoint-pane/" target="_blank"><code>umaar.com/dev-tips/28-dom-breakpoint-pane/</code></a> 找到大量开发技巧)。</p> <p>我假设您有 Chrome DevTools 的经验,但我想向您介绍两个额外的工具,它们可以使您在使用 React 和 Redux 时的工作变得更加简单。它们对于调试和手动测试您的代码很有用:</p> <ul> <li> <p><strong><em>React Chrome Extension</em>—</strong> 浏览器扩展程序,显示您标记中的 React 组件,并显示每个组件的属性和当前状态</p> </li> <li> <p><strong><em>Redux Chrome Extension</em>—</strong> 浏览器扩展程序,显示您应用程序中的 Redux 动作,并允许您重放一系列动作</p> </li> </ul> <h4 id="931-react-chrome-extension">9.3.1. React Chrome Extension</h4> <p>React Chrome Extension 加载 React Dev Tools,并让您直接了解运行中的应用程序中的 React 组件。它让您检查 HTML 结构,并查看组件是如何包装的。它还显示了每个组件上设置的属性和状态。</p> <p>您可以从 <a href="http://mng.bz/mt5P" target="_blank"><code>mng.bz/mt5P</code></a> 安装此扩展程序。安装后,加载应用程序并检查它。导航到 React 选项卡。图 9.6 展示了您应该看到的内容。</p> <h5 id="图-96-在-chrome-devtools-中打开的-react-dev-tools-选项卡">图 9.6. 在 Chrome DevTools 中打开的 React Dev Tools 选项卡</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/09fig06_alt.jpg" alt="图片" loading="lazy"></p> <p>您可以使用 React Dev Tools 做很多事情。一个有用的工具是查看树中的每个组件的能力,包括高阶组件(图 9.7)。</p> <h5 id="图-97-react-dev-tools-允许您检查在常规-html-中不显示的组件">图 9.7. React Dev Tools 允许您检查在常规 HTML 中不显示的组件。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/09fig07_alt.jpg" alt="图片" loading="lazy"></p> <p>另一个有用的功能是查看属性和状态的能力。图 9.8 展示了如何查看 Products 组件的属性和状态。</p> <h5 id="图-98-检查-products-组件的状态和属性您可以从此面板操作状态">图 9.8. 检查 Products 组件的状态和属性。您可以从此面板操作状态。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/09fig08.jpg" alt="图片" loading="lazy"></p> <p>尝试更改 <code>searchQuery</code> 的状态属性。注意,当您更改它时,它会在搜索框中更新。这种在应用程序中实时覆盖状态的能力对于调试很有帮助。</p> <h4 id="932-redux-chrome-extension">9.3.2. Redux Chrome Extension</h4> <p>Redux Chrome Extension 提供了 Redux Dev Tools 的几乎零配置实现。通过访问 <a href="http://mng.bz/NEBG" target="_blank"><code>mng.bz/NEBG</code></a> 在 Chrome 中安装它。</p> <p>Redux Dev Tools 是一个 npm 包,你可以将其包含在你的项目中,但安装扩展是快速启动和运行的最快方式。你可以在 GitHub 仓库<a href="https://github.com/zalmoxisus/redux-devtools-extension" target="_blank"><code>github.com/zalmoxisus/redux-devtools-extension</code></a>找到 Redux Dev Tools 扩展的所有文档。图 9.9 展示了 Redux Dev Tools 在你的应用中运行时的样子。</p> <h5 id="图-99-redux-dev-tools-在-chrome-devtools-内部">图 9.9. Redux Dev Tools 在 Chrome DevTools 内部</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/09fig09_alt.jpg" alt="" loading="lazy"></p> <p>要让 Redux Dev Tools 在你的应用中看到 Redux 存储,你必须更新 initialize Redux 代码中使用的<code>compose</code>函数。以下列表展示了如何进行此操作。你使用此代码来替换<code>initRedux</code>中的旧<code>compose</code>调用。</p> <h5 id="列表-96-启用-redux-dev-toolssrcsharedinitreduxes6">列表 9.6. 启用 Redux Dev Tools—src/shared/initRedux.es6</h5> <pre><code>import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; import thunkMiddleware from 'redux-thunk'; import loggerMiddleware from 'redux-logger'; import products from './products-reducer.es6'; import cart from './cart-reducer.es6'; export default function (initialStore = {}) { const reducer = combineReducers({ products, cart }); const middleware = [thunkMiddleware, loggerMiddleware]; let newCompose; if (typeof window !== 'undefined') { *1* newCompose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; *2* } const composeEnhancers = newCompose || compose; *3* return composeEnhancers( *4* applyMiddleware(...middleware) )(createStore)(reducer, initialStore); } </code></pre> <ul> <li> <p><strong><em>1</em> 更新的 compose 函数位于 window 上,但会破坏服务器—检查 window 的存在。</strong></p> </li> <li> <p><strong><em>2</em> 从 window 对象中获取 compose 函数。</strong></p> </li> <li> <p><strong><em>3</em> 如果开发者没有安装 Redux Dev Tools,则回退到基本 compose。</strong></p> </li> <li> <p><strong><em>4</em> 使用新的 compose 函数设置 Redux。</strong></p> </li> </ul> <p>现在你已经启用了 Redux 工具,让我们来了解一下它的部分功能。图 9.10 展示了如何使用 Redux Dev Tools 的一些测试功能。</p> <h5 id="图-910-使用-redux-dev-tools-的测试功能">图 9.10. 使用 Redux Dev Tools 的测试功能</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/09fig10_alt.jpg" alt="" loading="lazy"></p> <p>最后,这个调试工具最酷的功能之一是能够重放操作。图 9.11 展示了如何遍历购物车路由的各种状态。</p> <h5 id="图-911-使用-redux-dev-tools-的回放功能">图 9.11. 使用 Redux Dev Tools 的回放功能</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/09fig11_alt.jpg" alt="" loading="lazy"></p> <h3 id="摘要-7">摘要</h3> <p>在本章中,你学习了如何通过 React 特定的测试和调试工具来改进你的开发工作流程,这些工具适用于浏览器和服务器。现在你可以提高你的 React 和同构开发速度了!</p> <ul> <li> <p>Enzyme 是一个用于测试 React 组件的库。它提供了一种通过<code>shallow</code>在隔离环境中测试组件的方法,以及通过<code>mount</code>编写集成测试的方法。</p> </li> <li> <p>测试同构应用需要考虑代码将在何处运行以及这如何转化为测试。单元测试应该测试将在其中运行的代码环境(们)。端到端测试和手动测试应考虑初始加载与 SPA 体验之间的差异。</p> </li> <li> <p>React Dev Tools 可以通过 Chrome 扩展使用,它使得检查你的 React 组件变得更容易。它还允许你实时地操作状态。</p> </li> <li> <p>Redux Dev Tools 可以通过 Chrome 扩展使用。它让你可以遍历你的操作,并清楚地看到随时间推移的存储更新。</p> </li> </ul> <h2 id="第十章-处理服务器浏览器差异">第十章. 处理服务器/浏览器差异</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>将环境特定的代码隔离</p> </li> <li> <p>启用仅在浏览器或服务器上使用的路由</p> </li> <li> <p>使用静态方法向每个应用路由添加标题和页面元数据</p> </li> <li> <p>在服务器和浏览器之间实现用户代理的一致使用</p> </li> </ul> <p>本章和下一章将涵盖各种主题,这些主题将帮助你处理我在构建同构应用程序时遇到的真实世界案例。当你开始构建一个生产就绪的应用程序时,你经常会遇到需要在这种架构的上下文中进行特殊处理的使用案例。表 10.1 列出了这些情况的一些示例。</p> <h5 id="表-101-同构应用程序中的常见问题和解决方案">表 10.1. 同构应用程序中的常见问题和解决方案</h5> <table> <thead> <tr> <th>问题</th> <th>解决方案</th> <th>服务器</th> <th>浏览器</th> </tr> </thead> <tbody> <tr> <td>只能在浏览器中运行的初始化代码。</td> <td>使用环境布尔标志。</td> <td></td> <td>✓</td> </tr> <tr> <td>定义 SEO 元标签,以便在服务器上渲染。</td> <td>在顶级路由组件上使用静态函数。</td> <td>✓</td> <td></td> </tr> <tr> <td>渐进增强:浏览器和服务器之间的功能检测不同。</td> <td>仅在浏览器中进行功能检测。</td> <td></td> <td>✓</td> </tr> <tr> <td>渐进增强:如果需要用户代理检测,如何创建单一的真实来源?</td> <td>使用服务器用户代理并以标准方式将其存储在应用程序存储中。</td> <td>✓</td> <td></td> </tr> <tr> <td>错误处理重复——服务器和浏览器都有处理 404 状态的逻辑。</td> <td>将面向用户的错误保存为标准格式,以便轻松确定何时显示 404。</td> <td>✓</td> <td>✓</td> </tr> </tbody> </table> <p>本章的所有代码都可以在之前章节相同的共享 GitHub 仓库中找到:<a href="http://mng.bz/8gV8" target="_blank"><code>mng.bz/8gV8</code></a>。我们将继续使用分支系统来逐步展示本章的示例。像往常一样,你可以安装并运行代码:</p> <pre><code>$ npm install $ npm start </code></pre> <p>让我们探讨如何根据浏览器或服务器环境来控制你的代码。</p> <h3 id="101-隔离浏览器特定代码">10.1. 隔离浏览器特定代码</h3> <p>我所构建的每一个 Web 应用程序都依赖于<code>window</code>或<code>document</code>对象。有时是为了支持社交小部件,有时是为了使用特定的库(例如,分析或错误跟踪)。但在同构应用程序中,这会引发一个问题:如何在服务器上继续使用这类代码而不破坏你的应用程序?</p> <p>假设你有一个用于分析代码的可重用模块。你的分析代码不需要在服务器上运行,因为它纯粹关注于用户与页面的交互。但你希望确保六个月后,你或另一位开发者不会意外地在服务器上运行此代码。图 10.1 显示了本节分支中代码的状态,章节-10.1(<code>git checkout chapter-10.1</code>)。</p> <h5 id="图-101-在服务器上不阻止分析模块运行时的控制台输出">图 10.1. 在服务器上不阻止分析模块运行时的控制台输出</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/10fig01_alt.jpg" alt="" loading="lazy"></p> <p>在本节中,你将通过将分析模块包裹在防止其在服务器上运行的代码中来修复此错误。这需要几个步骤(图 10.2 显示了最终结果):</p> <blockquote> <p><strong>1</strong>. 通过 webpack(浏览器代码)添加环境变量。</p> <p><strong>2</strong>. 在对 <code>window</code> 的引用周围添加浏览器环境检查。</p> </blockquote> <h5 id="图-102-使用环境变量确定何时运行分析代码该代码依赖于-window-对象">图 10.2. 使用环境变量确定何时运行分析代码,该代码依赖于 <code>window</code> 对象。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/10fig02_alt.jpg" alt="" loading="lazy"></p> <p>此分析模块在 列表 10.1 中展示。我已经为你创建了分析模块的基础代码。我还在 Node.js 服务器上创建了一个模拟端点,当你访问端点时,它会返回 200。但如果你直接运行此分支中的代码,你会注意到它是出错的!</p> <h5 id="列表-101-分析模块srcanalyticses6">列表 10.1. 分析模块—src/analytics.es6</h5> <pre><code>import fetch from 'isomorphic-fetch'; window.analytics = { *1* send: (opts) => { *2* const headers = new window.fetch.Headers({ 'Content-Type': 'application/json' }); fetch({ url: 'http://localhost:3000/analytics', *3* method: 'POST', headers, data: opts }) .then((res) => { console.log('analytics result', res); *4* }) .catch((err) => { console.log('analytics err', err); *4* }); } }; const getAnalytics = () => { *5* return window.analytics; }; export const sendData = (opts) => { *6* getAnalytics().send(opts); }; </code></pre> <ul> <li> <p><strong><em>1</em> 这里,分析库是一个为你添加到 window 对象(在实际应用中,你会导入你的库)的模拟对象。</strong></p> </li> <li> <p><strong><em>2</em> 模拟分析库有一个方法,send,它接受一个选项对象。</strong></p> </li> <li> <p><strong><em>3</em> Send 向 Node.js 服务器(在实际应用中,端点将是你的分析服务)发送 POST 请求。</strong></p> </li> <li> <p><strong><em>4</em> 在此模拟示例中,结果和错误被记录。</strong></p> </li> <li> <p><strong><em>5</em> 分析模块实现了分析对象的 getter 方法。</strong></p> </li> <li> <p><strong><em>6</em> 分析模块实现了 sendData 方法,该方法在模拟分析对象上调用 send。</strong></p> </li> </ul> <p><code>sendData</code> 是一个公共方法,它将从视图模块中被调用。代码出错是因为它从共享路由中被调用,这些路由在服务器和浏览器上都会运行。因为服务器没有 <code>window</code> 对象,所以代码导致应用崩溃。列表 10.2 展示了调用分析模块的 <code>sharedRoutes</code> 代码。我已经为你提供了这个代码。</p> <h5 id="注意-26">注意</h5> <p>如果你使用 React Router 4,请查看附录 C 中的第一个列表,了解如何使用 React 生命周期来处理此示例,而不是 React Router 生命周期。</p> <h5 id="列表-102-调用分析代码srcsharedsharedroutesjsx">列表 10.2. 调用分析代码—src/shared/sharedRoutes.jsx</h5> <pre><code>import { sendData } from '../analytics.es6'; *1* let beforeRouteRender = (dispatch, prevState, nextState) => { const { routes } = nextState; routes.map((route) => {}).reduce((flat, toFlatten) => { }, []).map((initialAction) => {}); sendData({ *2* path: nextState.location.pathname, type: 'navigation' }); }; </code></pre> <ul> <li> <p><strong><em>1</em> 从分析模块导入 sendData 函数。</strong></p> </li> <li> <p><strong><em>2</em> 调用 sendData 以跟踪每个位置更新。</strong></p> </li> </ul> <p>要使此模块工作,你需要创建特定于环境的布尔标志,以检测代码是在服务器上还是在浏览器上运行。在 Node.js 中创建环境标志已经内置到系统中。但为了创建浏览器环境标志,你需要利用 webpack 的插件系统。</p> <h4 id="1011-为服务器创建环境变量">10.1.1. 为服务器创建环境变量</h4> <p>在服务器上,提供环境标志的方式与设置 <code>NODE_ENV</code> 变量的方式相同。在启动服务器之前,传递一个 <code>SERVER</code> 值。以下列表展示了如何将此添加到 package.json 的启动脚本中。</p> <h5 id="列表-103-服务器启动脚本packagejson">列表 10.3. 服务器启动脚本—package.json</h5> <pre><code>"scripts": { ... "start": "NODE_ENV=development SERVER=TRUE node src/server.js", *1* ... }, </code></pre> <ul> <li><strong><em>1</em> 在启动脚本中添加环境变量。</strong></li> </ul> <p>添加此变量后,你将设置 webpack 以提供 <code>BROWSER</code> 环境值。</p> <h4 id="1012-为浏览器创建环境变量">10.1.2. 为浏览器创建环境变量</h4> <p>在 第五章 中,我向你展示了如何创建一个自定义 webpack 插件来设置阶段环境变量。这允许你指示生产与开发者构建。要在 webpack 中创建 <code>BROWSER</code> 和 <code>SERVER</code> 变量,你遵循相同的模式。以下列表显示了你需要添加到 webpack 配置中的代码,以创建用于浏览器代码的环境变量。</p> <h5 id="列表-104-添加-webpack-插件">列表 10.4. 添加 webpack 插件</h5> <pre><code>const webpack = require("webpack"); *1* const injectVariables = new webpack.DefinePlugin({ process: { *2* env: { *2* NODE_ENV: JSON.stringify("development"), BROWSER: JSON.stringify("true"), *3* SERVER: JSON.stringify("false") } } }); module.exports = { //...other properties resolve: {}, plugins: [ injectVariables *4* ] }; </code></pre> <ul> <li> <p><strong><em>1</em> 导入 webpack 以调用 DefinePlugin。</strong></p> </li> <li> <p><strong><em>2</em> 重新创建 process.env 对象结构。</strong></p> </li> <li> <p><strong><em>3</em> 在对象中添加环境变量,包括 BROWSER 和 SERVER。</strong></p> </li> <li> <p><strong><em>4</em> 在选项中包含插件数组,并将 injectVariables 添加到数组中。</strong></p> </li> </ul> <p>在第一行,你需要使用 <code>require</code> 而不是 <code>import</code> 语句,因为此文件不是由 Babel 编译的。最后一步是在分析模块中使用 <code>BROWSER</code> 变量。</p> <h4 id="1013-使用变量">10.1.3. 使用变量</h4> <p>现在 <code>BROWSER</code> 和 <code>SERVER</code> 变量已存在于适当的环境中,你可以使用 <code>BROWSER</code> 变量来包装你的分析实现代码。将以下列表中的代码添加到分析模块中。</p> <h5 id="列表-105-检查环境srcanalyticses6">列表 10.5. 检查环境—src/analytics.es6</h5> <pre><code>if (process.env.BROWSER) { *1* window.analytics = {}; } const getAnalytics = () => { if (process.env.BROWSER) { *2* return window.analytics; } return false; }; </code></pre> <ul> <li> <p><strong><em>1</em> 在分析模拟对象周围添加 process.env.BROWSER 的检查。</strong></p> </li> <li> <p><strong><em>2</em> 在访问 window 对象的任何代码周围添加检查。</strong></p> </li> </ul> <p>现在你可以运行代码而不会出现任何问题!自己试试看。</p> <p>接下来让我们讨论如何将此策略扩展到基于环境的特性标志。</p> <h5 id="环境感知路由">环境感知路由</h5> <p>根据环境更改代码的其他常见用例存在。另一个需要知道环境的原因是控制哪些应用程序路由可用。例如,你可能需要创建一个内部访问路由——只有少数人需要访问的东西。那可能是一个管理页面或测试路由。相反,你可能希望有一些页面不能直接从服务器访问,可能是出于隐私原因,或者因为你不想让路由直接从服务器访问。</p> <p>要做到这一点,你使用与添加到分析模块中相同的 <code>process.env.BROWSER</code> 检查。在这种情况下,你会在 sharedRoutes.jsx 组件中添加检查。</p> <p>另一个常见的用例发生在你开发一个新功能,并且只想在开发和预发布环境中使路由可用时。你还可以添加对 <code>NODE_ENV</code> 值的检查。这让你可以确定路由是否只应在开发/预发布中显示,或者也应在生产中显示。以下列表显示了如何将此代码添加到 sharedRoutes 文件中。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="注意-27">注意</h5> <p>如果你使用的是 React Router 4,请查看附录 C 中的第二个列表,了解如何添加动态路由。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="列表-106-启用路由srcsharedsharedroutesjsx">列表 10.6. 启用路由—src/shared/sharedRoutes.jsx</h5> <pre><code>let developmentRoute = *1* process.env.NODE_ENV !== 'production' ? *1* <Route path="/dev-test" component={App} /> : null; export const routes = (onChange = () => {}) => { return ( <Route path="/" component={App} onChange={onChange}> ...other routes {developmentRoute} *2* </Route> ); </code></pre> <ul> <li> <p><strong><em>1</em> 添加一个将保存路由组件的变量。</strong></p> </li> <li> <p><strong><em>2</em> 渲染路由。</strong></p> </li> </ul> <p>在前几行,如果环境是生产环境,值将是 null——否则,它将是路由。为了测试此代码是否正常工作,将您的 webpack 配置中的<code>NODE_ENV</code>变量更改为<code>production</code>。然后路由将在服务器上找到,但在浏览器上不存在,因此它将返回一个空白屏幕:</p> <pre><code>const injectVariables = new webpack.DefinePlugin({ process: { env: { NODE_ENV: JSON.stringify("production"), BROWSER: JSON.stringify('true'), SERVER: JSON.stringify('false') } } }); </code></pre> <p>或者,您可以将服务器上的启动脚本中的值更改为<code>production</code>。这将导致服务器返回“Cannot GET /dev-test”错误:</p> <pre><code>"scripts": { ... "start": "NODE_ENV=production SERVER=TRUE node src/server.js", ... }, </code></pre> <p>完成后不要忘记将这些值改回来。</p> <h3 id="102-seo-和分享">10.2. SEO 和分享</h3> <p>在构建公共站点时,无论是基于内容还是如示例中的电子商务,搜索引擎优化(SEO)都是一个重要因素。良好的 SEO 会导致搜索引擎中的排名更高,这反过来又会导致您的 Web 应用程序的用户数量增加。构建同构站点的其中一个原因是为了使支持良好的 SEO 变得容易。</p> <p>从技术角度来说,这相当于添加 SEO 元标签和其他特定于搜索引擎的元数据,以便在它们爬取应用程序时可以看到。<img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/kindle_split_022_split_002.xhtml#ch10fig03" alt="图 10.3" loading="lazy">显示了谷歌搜索结果中的特色页面和高排名页面的示例。</p> <h5 id="图-103深思熟虑的-seo-实现导致在谷歌搜索中获得高排名或特色排名">图 10.3。深思熟虑的 SEO 实现导致在谷歌搜索中获得高排名或特色排名。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/10fig03_alt.jpg" alt="" loading="lazy"></p> <p>但大部分元数据都插入到了 HTML 页面的头部,这使得在 React 组件旁边实现变得困难。(记住,React 将渲染到 HTML 体中的特定标签——它对其他 HTML 没有感知。)我将向您展示如何使用静态函数和针对服务器的特定代码来处理这个用例。我已经成功使用过这种方法。在应用程序中第一次设置后,继续使用逻辑就很简单了。</p> <p>您将开始处理您尚未接触过的网站部分:产品详情页面。理想情况下,您希望您的产品在谷歌搜索中获得高排名,以便更多的人访问网站并进行购买。图 10.4 显示了用户看到的详情页面。您将添加的代码不是直接供用户消费的,而是供 Googlebot 和分享机器人(例如,Facebook 或 Twitter——这两个网站直接访问您的页面以确定应根据元标签共享哪些内容)。</p> <h5 id="图-104产品详情页面">图 10.4。产品详情页面</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/10fig04_alt.jpg" alt="" loading="lazy"></p> <p>我已经为您提供了产品详情组件。您需要切换到分支 chapter-10.2(<code>git checkout chapter-10.2</code>)。列表 10.7 显示了在检出代码时产品详情组件的状态(如果您在跟随,您需要自己添加此代码)。</p> <h5 id="列表-107-产品详情组件srccomponentsdetailjsx">列表 10.7. 产品详情组件—src/components/detail.jsx</h5> <pre><code>import React from 'react'; *1* import { bindActionCreators } from 'redux'; *1* import { connect } from 'react-redux'; *1* import cartActions *1* from '../shared/cart-action-creators.es6'; *1* import productActions *1* from '../shared/products-action-creators.es6'; *1* class Detail extends React.Component { static prefetchActions (params) {} *2* constructor(props) {} addToCart() {} render() { *3* return ( <div className="ui card middle"> <h2>{this.props.name}</h2> <img src={this.props.thumbnail} alt={this.props.description} /> <p>{this.props.description}</p> <div>Price: <span>{this.props.price}</span></div> <div>{this.props.details}</div> <button onClick={this.addToCart}>Add To Cart</button> </div> ); } } Detail.propTypes = {}; function mapStateToProps(state) { const { currentProduct } = state.products; return { ...currentProduct }; } function mapDispatchToProps(dispatch) { return { cartActions: bindActionCreators(cartActions, dispatch) }; } export default connect(mapStateToProps, mapDispatchToProps)(Detail); </code></pre> <ul> <li> <p><strong><em>1</em> 包含依赖项——这个组件是一个容器,因此它与 Redux 连接。</strong></p> </li> <li> <p><strong><em>2</em> 实现<code>prefetchActions</code>以获取详细路由的适当状态。</strong></p> </li> <li> <p><strong><em>3</em> 渲染函数显示 prefetchActions 中获取的数据。</strong></p> </li> </ul> <p>接下来,您将向组件添加必要的 SEO 元标签。</p> <h4 id="1021-设置元数据标签">10.2.1. 设置元数据标签</h4> <p>在详细页面上实现良好的 SEO 有许多部分(微数据标签、正确的页面标记等)。这些事情中的大多数都可以在 React 组件中轻松处理。但是,应该放在每个页面 head 中的元数据标签不是 React 组件的一部分。元数据标签放在 head 中,在服务器渲染后是静态的。它不会在每个 React 渲染周期中改变。</p> <p>要在 head 中创建 SEO 元标签,我建议使用<code>static</code>函数,这样您的组件可以可选地声明它们的 SEO 元数据需求。以下列表显示了您需要添加到 detail.jsx 组件中的代码,以便您可以在以后将元标签添加到 head 中。</p> <h5 id="列表-108-为创建元标签添加static函数srccomponentsdetailjsx">列表 10.8. 为创建元标签添加<code>static</code>函数——src/components/detail.jsx</h5> <pre><code>class Detail extends React.Component { static createMetatags(params, store) { *1* const tags = []; *2* const item = store.products ? store.products.currentProduct : null; if (item) { tags.push({ name: 'description', *3* content: item.description *3* }); tags.push({ property: 'og:description', content: item.description }); tags.push({ property: 'og:title', content: item.name }); tags.push({ property: 'og:url', content: `http://localhost:3000/product/detail/${item.id}` }); tags.push({ property: 'og:image', content: item.thumbnail }); } return tags; } </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>static</code>函数接受一个<code>params</code>对象(就像<code>prefetchActions</code>一样)和 store,以便它可以获取当前产品。</p> <h4 id="1022-在服务器上将元标签渲染到-head-中">10.2.2. 在服务器上将元标签渲染到 head 中</h4> <p>在服务器上,您可以利用这些<code>static</code>函数将元标签生成到渲染并返回给浏览器的 html.jsx 组件中。以下列表显示了您需要添加的代码。</p> <h5 id="列表-109-将元标签作为-htmljsx-的一部分渲染srccomponentshtmljsx">列表 10.9. 将元标签作为 html.jsx 的一部分渲染——src/components/html.jsx</h5> <pre><code>const HTML = (props) => { const metatagsArray = []; props.metatags.forEach((item) => { *1* metatagsArray.push( <meta {...item} /> ); }); return ( <html lang="en"> <head> <title>All Things Westies</title> {metatagsArray} *2* <link </code></pre> <ul> <li> <p><strong><em>1</em> 遍历元标签数组中提供的每个项目,并创建一个具有项目属性的元标签。</strong></p> </li> <li> <p><strong><em>2</em> 将元标签 HTML 添加到<head>中。</strong></p> </li> </ul> <p>最后,您需要在 renderView.jsx 中添加代码,以从单个组件中提取元标签数组。此代码与您在第七章中为<code>prefetchActions</code>添加的代码相同。我已经将此代码从第七章提取到一个可重用的函数中供您使用。</p> <h5 id="列表-1010-flattenstaticfunction代码srcmiddlewarerenderviewjsx">列表 10.10. <code>flattenStaticFunction</code>代码——src/middleware/renderView.jsx</h5> <pre><code>function flattenStaticFunction(renderProps, staticFnName, store = {}, request) { *1* let results = renderProps.components.map((component) => { if (component) { if (component.displayName && component.displayName.toLowerCase().indexOf('connect') > -1 ) { if (component.WrappedComponent[staticFnName]) { *2* return component .WrappedComponentstaticFnName; } } else if (component[staticFnName]) { *2* return componentstaticFnName; } } return []; }); results = results.reduce((flat, toFlatten) => { return flat.concat(toFlatten); }, []); return results; } </code></pre> <ul> <li> <p><strong><em>1</em> 可重用函数声明接受 renderProps 和 store,以便它可以提取适当的信息。</strong></p> </li> <li> <p><strong><em>2</em> 使用 staticFnName 变量而不是硬编码 prefetchActions 函数名称。</strong></p> </li> <li> <p><strong><em>3</em> 一些静态函数需要有关请求的信息,例如头信息。它在这里传递以提供对该信息的访问权限。</strong></p> </li> </ul> <p>可重用函数还接受一个<code>staticFnName</code>。这是可重用的部分,将允许您使用此函数进行多种类型的<code>static</code>函数(<code>prefetchActions</code>、<code>seoTags</code>等)。因为此代码已经为您准备好了,所以您需要做的只是将以下列表中的代码添加到 renderView.jsx 中。</p> <h5 id="列表-1011-获取-metatags-数组srcmiddlewarerenderviewjsx">列表 10.11. 获取 metatags 数组—src/middleware/renderView.jsx</h5> <pre><code>const seoTags = flattenStaticFunction( *1* renderProps, 'createMetatags', serverState ); const app = renderToString(); const html = renderToString( <HTML html={app} *2* serverState={stringifiedServerState} metatags={seoTags} /> ); </code></pre> <ul> <li> <p><strong><em>1</em> 使用 React Router 的 renderProps 调用 flattenStaticFunction,调用函数的名称以及存储的当前状态——返回 metatags 数组。</strong></p> </li> <li> <p><strong><em>2</em> 将这些标签传递给 HTML 组件,以便它们可以在列表 10.9 中添加的代码中使用。</strong></p> </li> </ul> <p>现在你已经看到了如何处理 SEO 和共享 metatags,让我们看看如何在浏览器和服务器上处理标题。</p> <h4 id="1023-处理标题">10.2.3. 处理标题</h4> <p>标题对于搜索引擎优化(SEO)和良好的用户体验非常重要。标题和 favicon 的组合使你的浏览器标签或窗口与其他所有标签或窗口区分开来。图 10.5 显示了在浏览器中看起来是什么样子。</p> <h5 id="图-105-浏览器中显示的标题">图 10.5. 浏览器中显示的标题</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/10fig05_alt.jpg" alt="" loading="lazy"></p> <h5 id="服务器上的标题">服务器上的标题</h5> <p>在服务器上处理标题与处理元数据类似。每个顶级组件都应该提供一个<code>static</code>函数来输出标题。此外,我建议将标题输出从接收服务器数据的函数中分离出来,这样你就可以在浏览器上重用一些逻辑。将以下列表中的代码添加到 Detail 组件中。</p> <h5 id="列表-1012-创建标题static函数srccomponentsdetailjsx">列表 10.12. 创建标题<code>static</code>函数—src/components/detail.jsx</h5> <pre><code>class Detail extends React.Component { static createTitle(props) { *1* return `${props.name} - All Things Westies`; } static getTitle(params, store) { *2* const currentProduct = store.products && store.products.currentProduct; return Detail.createTitle(currentProduct); } static createMetatags(params, store) {} //...other code } </code></pre> <ul> <li> <p><strong><em>1</em> 静态函数从属性中创建标题,这是一个抽象,将使在浏览器上创建标题更容易。</strong></p> </li> <li> <p><strong><em>2</em> 在服务器上,renderView 中间件将调用 getTitle,该函数接收存储并提取当前 Product 数据。</strong></p> </li> </ul> <p>在你有一个具有适当<code>static</code>函数的组件后,你可以在服务器上设置标题。在<code>renderView</code>代码中,你添加了一个获取标题的调用。以下列表显示了添加到 renderView.jsx 中的代码。</p> <h5 id="列表-1013-为路由添加标题srcmiddlewarerenderviewjsx">列表 10.13. 为路由添加标题—src/middleware/renderView.jsx</h5> <pre><code>const title = flattenStaticFunction( renderProps, 'getTitle', serverState ); const app = renderToString(); const html = renderToString( <HTML html={app} serverState={stringifiedServerState} metatags={seoTags} title={title} /> ); </code></pre> <p>前几行使用 React Router 的<code>renderProps</code>调用<code>flattenStaticFunction</code>,函数名称(<code>getTitle</code>)和<code>serverState</code>,以便可以获取标题详细信息。返回一个字符串标题。最后,你将标题传递到 HTML 组件中。</p> <p>最后,你需要将 html.jsx 中的标题更改为使用传入的属性。以下列表显示了你需要添加的内容。</p> <h5 id="列表-1014-渲染路由的标题srccomponentshtmljsx">列表 10.14. 渲染路由的标题—src/components/html.jsx</h5> <pre><code><head> <title dangerouslySetInnerHTML={{ __html: props.title || 'All Things Westies' }} /> {metatagsArray} // ...more code </head> </code></pre> <p>使用<code>dangerouslySetInnerHTML</code>输出标题字符串。如果你直接将标题渲染到标签中,React 将附加一个注释,然后它将显示在浏览器中。如果你运行服务器并重新加载详细页面,标题将基于产品标题。</p> <p>接下来,让我们看看如何在浏览器中更新标题。</p> <h5 id="浏览器上的标题">浏览器上的标题</h5> <p>大多数元标签只需要在初始渲染时使用,以服务于 Googlebot(以及分享机器人)。但标题标签是面向用户的,并且应该在每次路由更改时更新。这创造了一个独特的情况,你需要更新一个不由 React 控制的 DOM 部分。以下列表显示了你需要添加的内容。</p> <h5 id="列表-1015-将标题更新代码添加到-detailsrccomponentsdetailjsx">列表 10.15. 将标题更新代码添加到 Detail—src/components/detail.jsx</h5> <pre><code>class Detail extends React.Component { // ...more code componentDidMount() { document.getElementsByTagName('title')[0].innerHTMl = Detail.createTitle(this.props); *1* } componentDidUpdate() { document.getElementsByTagName ('title')[0].innerHTMl = Detail.createTitle(this.props); *1* } //...more code } </code></pre> <ul> <li><strong><em>1</em> 在<code>componentDidUpdate</code>和<code>componentDidMount</code>中添加一个调用以更新标题标签的内容。</strong></li> </ul> <p>这种情况很少见,但一旦发生,使用直接 DOM 访问是可以的。只是确保在访问 DOM 之前问自己,“我能用 React 的方式做到这一点吗?”</p> <p>为了练习本节中的技术,你可以在项目中添加其他顶级 React 组件的元标签和标题。这种策略可以应用于你需要基于顶级组件生成服务器信息的情况(例如,生成特定路由的头部信息)。</p> <h3 id="103-多个真实来源">10.3. 多个真实来源</h3> <p>当构建同构应用时,你可能会遇到可以从服务器和浏览器中获取相同信息的情况。常见的例子是用户代理和区域设置,它们都作为头部信息发送到服务器,但也可以在浏览器中访问。你如何处理这些情况?</p> <p>简单来说:选择一个单一的真实来源。你的应用首次运行代码的地方是服务器,所以如果可能的话,使用服务器作为真实来源。</p> <h4 id="1031-用户代理最佳实践">10.3.1. 用户代理最佳实践</h4> <p>我的大部分职业生涯都与视频工作有关,而网页上视频的一个挑战是它根本没有任何标准。例如,每个浏览器都支持不同的视频编码选项。Safari(桌面和移动)支持的选项与 Chrome、Firefox 和 Microsoft Edge 不同。尽管有一些内置的方式来处理这个问题(使用<code><video></code>标签和通过<code>canPlayType</code>进行功能检测),但在自定义高性能视频播放器中,你通常必须自己处理这些差异。</p> <p>这是我遇到过的在构建 Web 应用时需要持续进行用户代理检测的少数地方之一。尽管用户代理检测最好避免,但在这里介绍它是有价值的,以防你遇到类似的情况。在异构应用中使用用户代理检测时,我遵循以下两个原则:</p> <ol> <li> <p><strong><em>始终使用单一的真实来源</em>—</strong> 这意味着在服务器上解析用户代理并将其传递到浏览器。</p> </li> <li> <p><strong><em>尽可能使用最广泛的定义</em>—</strong> 而不是问“这是一个 iPhone 吗?”而是问“这是一个移动设备吗?”需要知道特定设备类别或特定浏览器的具体版本的情况极为罕见。</p> </li> </ol> <h4 id="1032-解析用户代理">10.3.2. 解析用户代理</h4> <p>您需要两样东西来解析用户代理,以便在您的应用中使用。首先,您需要添加一个动作和缩减器。其次,您需要将<code>User-Agent</code>头信息传递到服务器上的该动作。您可能想要切换到分支 chapter-10.3(<code>git checkout chapter-10.3</code>),其中包含上一节的所有代码。</p> <p>列表 10.16 显示了您需要添加到项目中的代码:在名为 app-action-creators.es6 的新动作文件中添加一个新动作。此动作将接受请求头,并使用第三方库将它们解析为可用的对象。我在这里选择了 ua-parser-js,但您可以使用任何您选择的同构库(<a href="https://github.com/faisalman/ua-parser-js" target="_blank"><code>github.com/faisalman/ua-parser-js</code></a>)。如果您正在跟随,请确保安装此包:</p> <pre><code>$ npm install ua-parser-js </code></pre> <h5 id="列表-1016-用户代理动作srcsharedapp-action-creatorses6">列表 10.16. 用户代理动作—src/shared/app-action-creators.es6</h5> <pre><code>import UAParser from 'ua-parser-js'; *1* export const PARSE_USER_AGENT = 'PARSE_USER_AGENT'; export function parseUserAgent(requestHeaders) { const uaParser = new UAParser(); *2* let userAgentObject; if (requestHeaders && requestHeaders['User-Agent']) { *3* const userAgent = requestHeaders['User-Agent']; *4* uaParser.setUA(userAgent); *4* userAgentObject = uaParser.getResult(userAgent); *5* } return { userAgent: userAgentObject, *6* type: PARSE_USER_AGENT }; } export default { parseUserAgent }; </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> <p>在您创建了动作代码之后,是时候添加缩减器了。以下列表显示了您需要添加到新文件 app-reducer.es6 中的代码。</p> <h5 id="列表-1017-用户代理缩减器srcsharedapp-reduceres6">列表 10.17. 用户代理缩减器—src/shared/app-reducer.es6</h5> <pre><code>import { PARSE_USER_AGENT } from './app-action-creators.es6'; export default function app(state = {}, action) { switch (action.type) { case PARSE_USER_AGENT: return { ...state, userAgent: action.userAgent ? action.userAgent : state.userAgent *1* }; default: return state; } } </code></pre> <ul> <li><strong><em>1</em> 在状态上设置从动作中获取的用户代理(如果动作中的用户代理未定义,则使用上一个状态)。</strong></li> </ul> <p>不要忘记,为了连接 Redux 动作和缩减器,您必须将缩减器添加到 init-redux 文件中。以下列表显示了添加的代码。</p> <h5 id="列表-1018-导入-app-缩减器srcsharedinit-reduxes6">列表 10.18. 导入 app 缩减器—src/shared/init-redux.es6</h5> <pre><code>import app from './app-reducer.es6'; *1* export default function (initialStore = {}) { const reducer = combineReducers({ products, cart, app *2* }); //...more code </code></pre> <ul> <li> <p><strong><em>1</em> 包含 app 缩减器。</strong></p> </li> <li> <p><strong><em>2</em> 将缩减器添加到 combineReducers 语句。</strong></p> </li> </ul> <p>现在您的 Redux 业务逻辑已经就绪,您需要从视图中调用此动作。与其将此动作添加到每个顶级组件,不如将其添加到根组件(App)。请记住,此组件由您的应用中最顶层的路由加载。以下列表显示了添加到 app.jsx 的代码。</p> <h5 id="列表-1019-从视图调用parseuseragentsrccomponentsappjsx">列表 10.19. 从视图调用<code>parseUserAgent</code>—src/components/app.jsx</h5> <pre><code>import { parseUserAgent } '../shared/app-action-creators.es6'; *1* class App extends React.Component { static prefetchActions(params, store, request) { return [ parseUserAgent.bind(null, request.headers) *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>prefetchActions</code>函数。这将在<code>flattenStaticFunction</code>中发生。列表 10.20 显示了添加代码。</p> <h5 id="列表-1020-将请求传递给prefetchactionssrcmiddlewarerenderviewjsx">列表 10.20. 将请求传递给<code>prefetchActions</code>—src/middleware/renderView.jsx</h5> <pre><code>function flattenStaticFunction( renderProps, staticFnName, store = {}, request *1* ) { let results = renderProps.components.map((component) => { if (component) { if (component.displayName && component.displayName.toLowerCase().indexOf('connect') > -1 ) { if (component.WrappedComponent[staticFnName]) { return component.WrappedComponentstaticFnName; } } else if (component[staticFnName]) { return componentstaticFnName; } } return []; }); results = results.reduce((flat, toFlatten) => {}, []); return results; } export default function renderView(req, res, next) { const matchOpts = {}; const handleMatchResult = (error, redirectLocation, renderProps) => { if (!error && !redirectLocation && renderProps) { const store = initRedux(); const actions = flattenStaticFunction( renderProps, 'prefetchActions', null, *3* req *4* ); //... more code </code></pre> <ul> <li> <p><strong><em>1</em> 将请求对象作为传递给 flattenStaticFunction 的属性。</strong></p> </li> <li> <p><strong><em>2</em> 将请求对象传递给被调用的静态函数(不使用它的函数将忽略它,因为它是最后面的参数)。</strong></p> </li> <li> <p><strong><em>3</em> flattenStaticFunction 的此用例不需要 store 状态来工作,传递 null。</strong></p> </li> <li> <p><strong><em>4</em> 确保在调用 prefetchActions 时将 req 对象传递给 flattenStaticFunction。</strong></p> </li> </ul> <p>现在你已经将用户代理解析到你的应用状态中,它不会在浏览器中被覆盖。在浏览器中设置断点以查看用户代理在 reducer 中将如何被定义为 undefined 并不会被覆盖。图 10.6 展示了在 app-reducer.es6 代码中设置断点并检查动作值,以查看它根本不会在浏览器上设置。</p> <h5 id="图-106-在-app-reduceres6-中设置断点并检查-actionuseragent-的值">图 10.6. 在 app-reducer.es6 中设置断点并检查 <code>action.userAgent</code> 的值。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/10fig06_alt.jpg" alt="图片" loading="lazy"></p> <h3 id="摘要-8">摘要</h3> <p>在本章中,你学习了如何处理现实应用中出现的特定环境边缘情况。你还学习了如何正确处理错误。</p> <ul> <li> <p>使用 webpack 处理浏览器和服务器专用的代码以创建 <code>process.env</code> 对象。</p> </li> <li> <p>实现服务器和浏览器特定的路由,并将相同的逻辑应用于环境路由(开发与生产)。</p> </li> <li> <p>使用静态方法根据每个路由确定 SEO 元数据标签和页面标题。</p> </li> <li> <p>在服务器上解析用户代理并将其用作浏览器中的真实来源。</p> </li> </ul> <h2 id="第十一章-为生产优化">第十一章. 为生产优化</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>为浏览器优化性能</p> </li> <li> <p>在 Node.js 上使用流来提高服务器性能</p> </li> <li> <p>使用缓存来提高服务器性能</p> </li> <li> <p>通过服务器和浏览器上的 cookie 处理用户会话</p> </li> </ul> <p>而不是深入研究任何特定主题,本章涵盖了一系列将使你的应用性能更好并提高最终用户体验的话题。这包括 React 性能、Node.js 性能和多种缓存策略。本章的最后部分涵盖了在同构应用中处理 cookie 以及这给一些缓存策略带来的权衡。</p> <p>本章继续使用 GitHub 上的完整同构示例仓库。它可以在 <a href="http://mng.bz/8gV8" target="_blank"><code>mng.bz/8gV8</code></a> 找到。第一部分使用 chapter-11.1 分支上的代码(<code>git checkout chapter-11.1</code>)。你可以在 chapter-11.complete 分支上找到本章的完整代码(<code>git checkout chapter-11-complete</code>)。</p> <p>要运行每个分支,请确保使用以下命令:</p> <pre><code>$ npm install $ npm start </code></pre> <h3 id="111-浏览器性能优化">11.1. 浏览器性能优化</h3> <p>随着我花更多的时间在 React 上,我发现虽然它开箱即用很快,但在复杂的应用中你可能会遇到性能问题。为了保持你的 React 网页应用性能,你需要随着应用的增长和更复杂的功能交互的增加,始终考虑性能。随着应用的增长,以下两个特定情况开始引起性能问题:</p> <ul> <li> <p><strong><em>JavaScript 的大小</em>—</strong> 下一节将介绍如何使用 webpack 分块来减小包大小。</p> </li> <li> <p><strong><em>不必要的渲染</em>—</strong> 在 第 11.1.2 节 中,我们将介绍使用 <code>shouldComponentRender</code> 来减少不必要的渲染的基本方法。</p> </li> </ul> <h4 id="1111-webpack-分块">11.1.1. Webpack 分块</h4> <p>在构建任何类型的应用程序时,我经常遇到以下场景:我的应用程序开始时规模较小,JavaScript 资产也足够小,可以快速加载。随着时间的推移,我添加了功能,对检查包含的包的大小变得懒惰,并且通常不关注包大小。(在大型团队中,代码库大小管理尤其困难。)然后有一天,我检查了页面的加载时间,发现我的 JavaScript 文件变得太大!这影响了应用程序的整体加载时间。然后就是恐慌的时刻!</p> <p>幸运的是,webpack 提供了一种通过将代码拆分为多个按需加载的包来解决此问题的方法。接下来的两个图解将向您介绍这个概念。</p> <h5 id="注意-28">注意</h5> <p>如果您使用的是 React Router 4,请参阅 附录 C 了解使用 webpack 进行代码拆分的信息。</p> <p>图 11.1 展示了应用程序当前是如何编译的。所有代码都被组合成一个输出文件。此文件在 html.jsx 中被引用。</p> <h5 id="图-111-webpack-的默认行为导致一个代表所有代码的单个文件">图 11.1. webpack 的默认行为导致一个代表所有代码的单个文件。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/11fig01_alt.jpg" alt="" loading="lazy"></p> <p>图 11.2 展示了本节中将实现的内容。代码在 webpack 的编译步骤中仍然被组合,但随后被拆分为多个 JavaScript 文件。这是您在代码中配置的(而不是在 webpack 配置中)。</p> <h5 id="图-112-使用代码拆分webpack-输出多个可以动态加载的文件具体文件将根据应用程序而异">图 11.2. 使用代码拆分,webpack 输出多个可以动态加载的文件。具体文件将根据应用程序而异。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/11fig02_alt.jpg" alt="" loading="lazy"></p> <p>要在您的代码中实现这一点,您需要更新导入路由的方式。这个过程有三个步骤:</p> <blockquote> <p><strong>1</strong>. 添加 Babel 插件,这些插件将处理 Node.js 服务器上的动态导入以及您的 webpack 配置中的 Babel 加载器。</p> <p><strong>2</strong>. 将动态导入添加到 <code>sharedRoutes</code>。</p> <p><strong>3</strong>. 使用 <code>chunkFilename</code> 在 webpack 中启用块。</p> </blockquote> <p>在您的终端中运行以下命令以安装和添加新的 Babel 插件:</p> <pre><code>$ npm install --save-dev babel-plugin-syntax-dynamic-import $ npm install --save-dev babel-plugin-dynamic-import-node </code></pre> <p>然后您需要更新 .babelrc。列表 11.1 展示了所需的更新。与旧版本相比,这是一个重大变化,因为您需要为 webpack 和 Node.js 使用不同的插件。稍后您将确保 webpack 指向 webpack 环境配置。</p> <h5 id="列表-111-在-babelrc-中添加插件">列表 11.1. 在 .babelrc 中添加插件</h5> <pre><code>{ "presets": ["es2015", "react"], "env": { *1* "webpack-env": { *2* "plugins": [ "syntax-dynamic-import" *3* ] }, "development": { *2* "plugins": [ "syntax-dynamic-import", *4* "dynamic-import-node" *4* ] } }, "plugins": [ *5* "transform-es2015-destructuring", "transform-es2015-parameters", "transform-object-rest-spread" ] } </code></pre> <ul> <li> <p><strong><em>1</em> 添加一个环境配置选项。</strong></p> </li> <li> <p><strong><em>2</em> 添加两个环境:开发环境(默认)和 webpack-env 用于 webpack 构建。</strong></p> </li> <li> <p><strong><em>3</em> 对于 webpack,只添加允许动态导入语法的插件。</strong></p> </li> <li> <p><strong><em>4</em> 对于 node,需要语法和实现插件。</strong></p> </li> <li> <p><strong><em>5</em> 原始的插件数组保持不变。环境选项与任何默认选项合并。</strong></p> </li> </ul> <p>Babel 配置更改的主要目标是将其分为两个版本,一个用于服务器(开发),另一个用于 webpack(webpack-env)。</p> <p>接下来,你必须添加一个动态导入。列表 11.2 展示了如何创建告诉 webpack 创建代码块的语句。这替换了 <code>sharedRoutes</code> 中组件的 <code>import</code> 语句。目前,你将应用此模式到单个路由:<code>cart</code>。但在生产应用中,我建议你根据自己的流量模式应用此模式(将高流量路由与低流量路由分开分割,或将管理或其他认证页面与公共页面分开分割)。此外,此代码可以抽象化并用于生产环境,但在此示例中,它以清晰、简洁的方式说明了更改。</p> <h5 id="列表-112-配置代码块分割srcsharedsharedroutesjsx">列表 11.2. 配置代码块分割—src/shared/sharedRoutes.jsx</h5> <pre><code>// remove import Cart from '../components/cart'; *1* <Route path="/" component={App} onChange={onChange}> <IndexRoute component={Products} /> <Route path="cart" getComponent={(location, cb) => { *2* import( *3* /* webpackChunkName: "cart" */ *4* /* webpackMode: "lazy" */ *4* './../components/cart') *3* .then((module) => { *5* cb(null, module.default); *5* onChange(null, { *6* routes: [ {component: module.default} ] }); }) .catch(error => *7* console.log('An error occurred while loading the component', error) ); }} /> </code></pre> <ul> <li> <p><strong><em>1</em> 删除对购物车组件的旧导入语句。我在这里添加了注释来演示它将被删除,但你也可以直接删除它。</strong></p> </li> <li> <p><strong><em>2</em> 使用 getComponent 属性。</strong></p> </li> <li> <p><strong><em>3</em> 使用异步导入。你将购物车组件的路径传递给它。</strong></p> </li> <li> <p><strong><em>4</em> Webpack 会读取这些注释并使用它们来确定如何处理代码块。</strong></p> </li> <li> <p><strong><em>5</em> 异步导入的行为类似于 Promise。处理成功:获取加载的模块并将其传递给 React Router 回调。</strong></p> </li> <li> <p><strong><em>6</em> 当调用 onChange 时,组件尚未加载。通过手动调用 onChange,数据仍然会被加载。</strong></p> </li> <li> <p><strong><em>7</em> 添加错误处理。</strong></p> </li> </ul> <p>你会注意到,你既用动态加载替换了默认导入,又将这个动态加载移动到了 React Router 的 <code>getComponent</code> 属性中。代码将会懒加载(<code>webpackMode: lazy</code>);它不会在用户导航到这个路由之前加载。这有优势,因为它可以防止加载用户尚未访问的功能的代码。</p> <p>最后,给你的 webpack 代码块命名是有用的。这可以在 webpack 配置文件中配置。以下列表展示了如何将此属性添加到你的 webpack 配置文件中。</p> <h5 id="列表-113-命名-webpack-代码块webpackconfigjs">列表 11.3. 命名 webpack 代码块—webpack.config.js</h5> <pre><code>module.exports = { // ... other config options output: { path: __dirname + '/src/', filename: "browser.js", chunkFilename: "browser-[name].js" *1* }, module: {} }; </code></pre> <ul> <li><strong><em>1</em> 添加 chunkFilename 选项。使用 [name] 来指示为每个代码块动态命名编译后的 js 文件。</strong></li> </ul> <p>在下一节中,我们将探讨一种提高 React 性能的方法,这种方法适用于 React 的基本性能不足以满足需求的情况。</p> <h4 id="1112-组件是否应该渲染">11.1.2. 组件是否应该渲染</h4> <p>随着你的应用增长,你可能会遇到组件不必要地运行其渲染周期的情况(我见过重渲染达到数十秒的情况)。如果这种情况发生并且对你的应用产生了可测量的影响,请使用<code>shouldComponentRender</code>来限制渲染次数。</p> <p><strong>性能测量工具</strong></p> <p>在进行性能改进之前,你应该始终对你的应用进行性能分析。记录你在当前应用版本中测量的性能指标。然后进行任何性能更新。最后,再次测量相同的性能指标以确认你的更改产生了积极的影响。</p> <p>要开始分析 Web 应用,你应该成为 Chrome DevTools 性能面板的专家:<a href="http://mng.bz/a9wf" target="_blank"><code>mng.bz/a9wf</code></a>。</p> <p>实现不引起自己未来痛苦和头痛的<code>shouldComponentRender</code>的最佳方式是确保你的属性是以不可变模式创建的。在这个应用中,这已经在 Redux 的 reducer 中得到了处理。通过创建不可变对象,对象的引用会改变,浅比较就足够用来检查两个对象是否不同。列表 11.4 展示了如何在 Detail Page 组件的上下文中实现这一点。将列表中的代码添加到 detail.jsx 中。</p> <h5 id="警告-1">警告</h5> <p>使用<code>shouldComponentRender</code>可能会让你陷入绝望的兔子洞。要谨慎使用,并明智地使用它。(你可能会得到复杂、庞大的函数,这些函数在计算是否渲染。这是不好的,应该避免。)</p> <h5 id="列表-114-块渲染shouldcomponentrendersrccomponentsdetailjsx">列表 11.4. 块渲染,<code>shouldComponentRender</code>—src/components/detail.jsx</h5> <pre><code>componentDidMount() {} shouldComponentUpdate(nextProps) { if (this.props.name === nextProps.name && *1* this.props.description === nextProps.description && this.props.details === nextProps.details && this.props.price === nextProps.price && this.props.thumbnail === nextProps.thumbnail) { return false; *2* } return true; *3* } componentDidUpdate() {} </code></pre> <ul> <li> <p><strong><em>1</em> 检查每个属性以确保它没有变化。</strong></p> </li> <li> <p><strong><em>2</em> 如果没有任何变化,则返回 false;这可以防止组件渲染。</strong></p> </li> <li> <p><strong><em>3</em> 如果有变化,则返回 true;这允许正常的渲染执行。</strong></p> </li> </ul> <p>这将适用于许多情况,但它并非没有问题。一个问题是这个实现要求你根据属性的实现细节编写许多检查。你可以将此代码的概念——比较<code>this.props</code>/<code>nextProps</code>上的每个属性——抽象成一个可以被许多组件重用的函数。Alex Reardon 的在线文章“React 应用的性能优化”涵盖了使用<code>shouldComponentUpdate</code>的更多细节。它包括一个抽象的深度等于函数的示例实现,该函数只进行引用检查。查看 GitHub 上的代码(<a href="http://mng.bz/q7yU" target="_blank"><code>mng.bz/q7yU</code></a>)。</p> <p>最后,如果你需要<code>shouldComponentUpdate</code>只对<code>props</code>对象和<code>state</code>对象进行浅比较,你可以使用 React Pure Component。这是一个由 React 提供的类。你可以在<a href="https://reactjs.org/docs/react-api.html#react.purecomponent" target="_blank"><code>reactjs.org/docs/react-api.html#react.purecomponent</code></a>找到文档。</p> <p>不幸的是,更深入地探讨 React 性能优化超出了本书的范畴。幸运的是,关于这个主题有很多其他优秀的资源!以下是一些可以让你更深入了解的资源:</p> <ul> <li> <p><em>React 性能工具</em>—<a href="https://facebook.github.io/react/docs/perf.html" target="_blank"><code>facebook.github.io/react/docs/perf.html</code></a>。使用这些工具来分析你的应用程序。当你进行性能测试时,别忘了以生产模式运行你的应用程序!(这些工具在 React 16 中已被弃用。)</p> </li> <li> <p><em>React 性能概述</em>—<a href="http://mng.bz/l5J8" target="_blank"><code>mng.bz/l5J8</code></a>和<a href="http://mng.bz/eWHH" target="_blank"><code>mng.bz/eWHH</code></a>是开始了解 React 性能的好地方。</p> </li> <li> <p>更多关于<code>shouldComponentRender</code>的内容——<a href="http://jamesknelson.com/should-i-use-shouldcomponentupdate/" target="_blank"><code>jamesknelson.com/should-i-use-shouldcomponentupdate/</code></a>深入探讨了为什么你应该避免使用<code>shouldComponentRender</code>。</p> </li> </ul> <p>在下一节中,我们将探讨可以用于提高你的 Node.js 应用程序性能的服务器端性能改进。</p> <h3 id="112-服务器性能优化">11.2. 服务器性能优化</h3> <p>在同构应用中,你服务器的性能和浏览器性能一样重要。当我们最初开始在工作中使用 React 时,它极大地简化了我们为搜索引擎构建页面。但我们很快意识到 React 的服务器渲染比我们希望的慢。为许多组件创建完整的渲染字符串输出需要时间,并且是服务器上的一个阻塞任务。这限制了服务器每秒能够处理请求数量。</p> <p>在本节的剩余部分和缓存部分,我将讨论你可以使用的策略来提高你的服务器性能时间:</p> <ul> <li> <p>使用流概念来更快地响应请求</p> </li> <li> <p>添加连接池来管理服务器上的多个 HTTP 请求</p> </li> </ul> <p>你首先需要添加的是将页面响应流式传输到浏览器的能力。如果你正在跟随并想切换到本节的 GitHub 分支,请查看第 11.2 章(<code>git checkout chapter-11.2</code>)。</p> <h4 id="1121-流式-react">11.2.1. 流式 React</h4> <p>如果你的主要目标是提高首次字节到达时间,并允许 DOM 尽快开始处理,那么流式传输渲染后的页面响应可以是一个好的解决方案。Node.js 流是一种表示大量数据并在一段时间内交付数据的方式。而不是等待整个 HTML 页面下载完成,页面可以分块在一段时间内交付(关于流的更多信息请参阅<a href="http://mng.bz/s91m" target="_blank"><code>mng.bz/s91m</code></a>)。</p> <p>通过将服务器响应转换为流,你可以提高浏览器开始下载和显示 HTML 的速度。列表 11.5 显示了如何使用 react-dom-stream 库来渲染流而不是字符串。将代码添加到 renderView.jsx 中。你还需要在运行以下命令之前:</p> <pre><code>$ npm i --save react-dom-stream </code></pre> <p>你可以在<a href="https://github.com/aickin/react-dom-stream" target="_blank"><code>github.com/aickin/react-dom-stream</code></a>找到更多关于这个包的信息。请注意,它还没有完全升级以与最新的 React 一起使用,因此你可能不想在生产环境中使用它,但它很好地说明了流的概念。</p> <h5 id="列表-115-设置流式库srcmiddlewarerenderviewjsx">列表 11.5. 设置流式库—src/middleware/renderView.jsx</h5> <pre><code>import React from 'react'; import { renderToString } from 'react-dom-stream/server'; *1* import { Provider } from 'react-redux'; const streamApp = renderToString( *2* <Provider store={store}> <RouterContext routes={routes} {...renderProps} /> </Provider> ); const streamHTML = renderToString( <HTML html={streamApp} *3* serverState={stringifiedServerState} metatags={seoTags} title={title} /> ); streamHTML.pipe(res, { end: false }); *4* streamHTML.on('end', () => { *5* res.end(); *6* }); </code></pre> <ul> <li> <p><strong><em>1</em> 不要导入 renderToString 的 React 版本,而要使用流式库的版本。</strong></p> </li> <li> <p><strong><em>2</em> 应用组件的初始渲染被转换为流的创建。为了更好的上下文,重命名变量。</strong></p> </li> <li> <p><strong><em>3</em> React DOM Stream 支持 JSX 中的嵌套流。现在我们将流传递到 HTML.jsx。</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="1122-连接池">11.2.2. 连接池</h4> <p>除了使用 React,我们在工作中还使用 GraphQL。这使我们能够从许多微服务中收集数据。更重要的是,它还允许我们请求我们视图所需的数据,而不是使用具有预定响应的 REST API。把它想象成后端 REST 服务的客户端。你可以在<a href="http://graphql.org" target="_blank"><code>graphql.org</code></a>了解更多关于 GraphQL 的信息。</p> <p>这是一个强大的设置,但 GraphQL 会进行大量的网络调用。我们遇到了一些网络调用超时的问题。我们与之通信的服务没有显示任何超时;它们显示了快速的响应时间。经过大量的调查,团队发现我们发出的请求太多,导致调用栈导致一些请求超时。调用栈变成了一个瓶颈,请求在收到任何响应之前就已经超时了。</p> <p>这也可能发生在你的 React 同构应用程序中。如果你的应用程序的一个页面为特定的视图进行了大量的网络调用,你可能会遇到这个缓慢的网络请求问题。解决这个问题的策略之一是在你的 Node.js 服务器上启用连接池。</p> <p>在 Node.js 中解决这个问题的方法是创建一个永久的连接池以减少打开连接的成本。<em>连接池</em>确保你的 Node.js 应用程序中始终有可用的套接字连接。这可以在发出请求时节省时间,因为打开套接字需要时间(更多信息,请参阅<a href="http://www.madhur.co.in/blog/2016/09/05/nodejs-connection-pooling.html" target="_blank">www.madhur.co.in/blog/2016/09/05/nodejs-connection-pooling.html</a>上的博客文章)。以下列表显示了如何将此选项添加到服务器中。</p> <h5 id="列表-116-启用连接池srcappes6">列表 11.6. 启用连接池—src/app.es6</h5> <pre><code>import http from 'http'; *1* import bodyParser from 'body-parser'; import renderViewMiddleware from './middleware/renderView'; http.Agent({ *2* keepAlive: true, keepAliveMsecs: 1500, maxFreeSockets: 1024 }); </code></pre> <ul> <li> <p><strong><em>1</em> 导入 http 模块。</strong></p> </li> <li> <p><strong><em>2</em> 在服务器上的其余代码之前,设置 http.Agent.keepAlive: true 的选项告诉服务器重用连接。其他选项可以根据您的用例进行调整。</strong></p> </li> </ul> <p>您还可以使用 GraphQL,这将大大减少您做出的网络调用次数。但这将是另一本书的主题。</p> <h5 id="node-性能">Node 性能</h5> <p>这不是一本关于 Node.js 实现和性能的书籍,但如果您想了解更多关于这个主题的信息,有很多好的资源可用。以下是一些开始的地方:</p> <ul> <li> <p><em>Nginx 关于优化 Node.js 应用的技巧</em>—<a href="http://www.nginx.com/blog/5-performance-tips-for-node-js-applications/" target="_blank">www.nginx.com/blog/5-performance-tips-for-node-js-applications/</a></p> </li> <li> <p><em>Express 在生产环境中的最佳实践</em>—<a href="https://expressjs.com/en/advanced/best-practice-performance.html" target="_blank"><code>expressjs.com/en/advanced/best-practice-performance.html</code></a></p> </li> <li> <p><em>Node 性能分析工具</em>—<a href="https://nodejs.org/en/docs/guides/simple-profiling/" target="_blank"><code>nodejs.org/en/docs/guides/simple-profiling/</code></a></p> </li> <li> <p><em>Netflix 深入剖析火焰图分析 Node 应用</em>—<a href="https://medium.com/netflix-techblog/node-js-in-flames-ddd073803aa4" target="_blank"><code>medium.com/netflix-techblog/node-js-in-flames-ddd073803aa4</code></a></p> </li> </ul> <h3 id="113-缓存">11.3. 缓存</h3> <p>另一个强大的服务器性能工具是缓存。我已经采用了不同形式的缓存,包括边缘缓存、内存缓存以及在 Redis(一个 NoSQL 数据库)中持久化缓存中保存视图。每种策略都有其权衡之处,因此了解这些权衡并选择适合您用例的正确策略非常重要。表 11.1 列出了缓存选项。</p> <h5 id="表-111-比较缓存选项">表 11.1. 比较缓存选项</h5> <table> <thead> <tr> <th></th> <th>SEO</th> <th>用户会话</th> </tr> </thead> <tbody> <tr> <td>内存</td> <td>✓</td> <td>✓</td> </tr> <tr> <td>持久化存储</td> <td>✓</td> <td>(开销较高,但可行)</td> </tr> <tr> <td>边缘缓存</td> <td>✓</td> <td></td> </tr> </tbody> </table> <h4 id="1131-服务器端缓存内存缓存">11.3.1. 服务器端缓存:内存缓存</h4> <p>最简单(也是最天真)的缓存解决方案涉及直接在内存中保存组件。对于简单的应用程序,您可以通过使用基本的 LRU 缓存(大小限制)并在组件渲染后将其序列化来实现这一点。图 11.3 显示了使用内存缓存的时间线。第一个加载页面的用户会得到一个完全渲染(但较慢)的页面版本。这也被保存在内存缓存中。所有后续用户都会得到缓存的版本,直到该页面因缓存已满而被推出缓存。</p> <h5 id="图-113-内存缓存允许某些请求从更快的响应时间中受益">图 11.3. 内存缓存允许某些请求从更快的响应时间中受益。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/11fig03_alt.jpg" alt="" loading="lazy"></p> <p>下面的列表显示了如何添加一个简单的缓存模块(抽象此代码将使更新缓存策略以匹配您的未来需求变得更容易)。您应该将此代码添加到共享目录中的新 cache.es6 文件中。</p> <h5 id="列表-117-添加内存-lru-缓存srcsharedcachees6">列表 11.7. 添加内存 LRU 缓存—src/shared/cache.es6</h5> <pre><code>import lru from 'lru-cache'; *1* // maxAge is in ms const cache = lru({ *2* maxAge: 300000, *3* max: 500000000000, *4* length: (n) => { *5* // n = item passed in to be saved (value) return n.length * 100; } }); export const set = (key, value) => { *6* cache.set(key, value); }; export const get = (key) => { *7* return cache.get(key); }; export default { get, set }; </code></pre> <ul> <li> <p><strong><em>1</em> 导入 lru 缓存。</strong></p> </li> <li> <p><strong><em>2</em> 创建 lru 缓存。</strong></p> </li> <li> <p><strong><em>3</em> maxAge 为存储在缓存中的值设置基于时间的过期时间。</strong></p> </li> <li> <p><strong><em>4</em> max 是缓存中所有项目的总允许长度。</strong></p> </li> <li> <p><strong><em>5</em> length 是每个添加的值的最大允许长度。</strong></p> </li> <li> <p><strong><em>6</em> 这是一个公共的设置方法,用于在缓存中设置键/值对。</strong></p> </li> <li> <p><strong><em>7</em> 这是一个公共的获取方法,根据键从缓存中检索值。</strong></p> </li> </ul> <p>列表 11.8 展示了如何在 renderView.jsx 中利用缓存模块。将它的代码添加到模块中。请注意,我建议使用缓存逻辑或流逻辑,但不要同时使用两者。如果你想缓存和流,你需要一个不同于本章中展示的流实现。</p> <h5 id="列表-118-保存和获取缓存的页面srcmiddlewarerenderviewjsx">列表 11.8. 保存和获取缓存的页面—src/middleware/renderView.jsx</h5> <pre><code>import cache from '../shared/cache.es6'; *1* //..other code const cachedPage = cache.get(req.url); *1* if (cachedPage) { *2* return res.send(cachedPage); *2* } const store = initRedux(); //...more code Promise.all(promises).then(() => { //...more code cache.set(req.url, `<!DOCTYPE html>${html}`); *3* return res.send(`<!DOCTYPE html>${html}`); }) </code></pre> <ul> <li> <p><strong><em>1</em> 尝试使用列表 11.7 中的缓存模块从缓存中检索值。</strong></p> </li> <li> <p><strong><em>2</em> 如果值存在,则使用它来响应请求。</strong></p> </li> <li> <p><strong><em>3</em> 如果需要完整页面的渲染,在响应请求之前保存已渲染的页面。</strong></p> </li> </ul> <p>这种策略将有效,但它有一些问题:</p> <ul> <li> <p>这个解决方案很简单,但当用例变得更加复杂时会发生什么?当你开始添加用户?或者多语言?或者你有成千上万的页面?这种方法不适用于这些用例。</p> </li> <li> <p>在 Node.js 中写入内存是一个阻塞任务,这意味着如果你试图通过使用缓存来优化性能,你是在用一个问题换取另一个问题。</p> </li> <li> <p>最后,如果你正在使用分布式扩展策略来运行你的服务器(这在当今很常见),缓存仅适用于单个盒子或容器(如果使用 Docker)。在这种情况下,你的服务器实例无法共享一个公共缓存。</p> </li> </ul> <p>接下来,我们将探讨另一种策略,即使用 Redis 进行缓存,这将允许异步和非阻塞地进行缓存。我们还将探讨使用更智能的缓存实现来缓存单个组件,这对于更复杂的应用程序来说具有更好的可扩展性。</p> <h4 id="1132-服务器端缓存持久化存储">11.3.2. 服务器端缓存:持久化存储</h4> <p>我参与开发的第一个同构 React 应用是在 Redux 和 React Router 成为稳定的社区首选库之前编写的,因此我们编写了大量的代码。结合 React 在服务器上的运行速度较慢,我们需要一个能够加快服务器渲染速度的解决方案。</p> <p>我们实现的是在 Redis 中存储完整页面的字符串存储。但是,对于较大的网站来说,在 Redis 中存储完整页面有显著的权衡。我们有可能有数百万条条目最终存储在 Redis 中。因为完整的字符串化 HTML 页面很快就会累积起来,所以我们使用了相当多的空间。</p> <p>幸运的是,社区自那时以来已经对这一想法进行了改进。Walmart Labs 发布了一个名为 electrode-react-ssr-caching 的库,该库易于使用,可以缓存您的服务器端渲染。这个库有几个强大的原因:</p> <ul> <li> <p>它附带了一个分析器,可以告诉您哪些组件在服务器上最昂贵。这允许您只缓存您需要的组件。</p> </li> <li> <p>它提供了一种模板组件的方法,这样您就可以缓存已渲染的组件,并在以后插入属性。</p> </li> </ul> <p>从长远来看,由于我们服务的页面数量以及其中以 100% 公开内容提供的页面百分比,我们最终转向了边缘缓存策略。但您的用例可能从 Walmart Labs 方法中受益。</p> <h4 id="1133-cdn边缘策略">11.3.3. CDN/边缘策略</h4> <p>Edge 缓存是我们目前在工作中的同构 React 应用程序中使用的解决方案。这是由于一些业务逻辑需要按需使内容过期(当系统其他点的某些内容发生变化时,如在 CMS 工具中)。现代 CDN,如 Fastly,提供这种功能,并使管理 TTL(生存时间)和强制使网页过期的操作变得容易得多。图 11.4 说明了这是如何工作的。</p> <h5 id="图-114-添加边缘服务器将缓存移动到服务器前面">图 11.4. 添加边缘服务器将缓存移动到服务器前面。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/11fig04_alt.jpg" alt="" loading="lazy"></p> <p>向您展示如何实现这一点超出了本书的范围。如果您有面向公众的内容,这些内容可以推动 SEO(电子商务、视频网站、博客等),那么您在技术栈中肯定需要一个 CDN。</p> <p>这种方法的一个缺点是它使用户会话管理变得复杂。下一节将探讨用户会话,并涵盖各种缓存策略的权衡。</p> <h3 id="114-用户会话管理">11.4. 用户会话管理</h3> <p>现代网络应用程序几乎无一例外地在浏览器中使用 cookie。即使您的主要产品没有直接使用 cookie,您在网站上使用的任何广告、跟踪或其他第三方工具都将利用 cookie。Cookies 让网络应用程序知道同一个人在一段时间后回来了。图 11.5 说明了这是如何工作的。</p> <h5 id="图-115-同一用户在服务器上的重复访问保存-cookie-允许您存储有关用户的信息这些信息可以在未来的会话中检索">图 11.5. 同一用户在服务器上的重复访问。保存 cookie 允许您存储有关用户的信息,这些信息可以在未来的会话中检索。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/11fig05_alt.jpg" alt="" loading="lazy"></p> <p>列表 11.9 展示了一个示例模块,该模块为您处理浏览器和服务器端 cookie 解析。它使用通用 Cookie 来帮助管理两个环境中的 cookie:<a href="http://www.npmjs.com/package/universal-cookie" target="_blank">www.npmjs.com/package/universal-cookie</a>。您需要安装这个库才能使代码正常工作:</p> <pre><code>$ npm install --save universal-cookie </code></pre> <p>将此列表中的代码添加到新的模块 src/shared/cookies.es6 中。</p> <h5 id="列表-119-使用同构-cookie-模块srcsharedcookieses6">列表 11.9. 使用同构 cookie 模块—src/shared/cookies.es6</h5> <pre><code>import Cookie from 'universal-cookie'; *1* const initCookie = (reqHeaders) => { let cookies; if (process.env.BROWSER) { *2* cookies = new Cookie(); } else if (reqHeaders.cookie) { cookies = new Cookie(reqHeaders.cookie); *3* } return cookies; }; export const get = (name, reqHeaders = {}) => { const cookies = initCookie(reqHeaders); *4* if (cookies) { return cookies.get(name); *5* } }; export const set = (name, value, opts, reqHeaders = {}) => { const cookies = initCookie(reqHeaders); *4* if (cookies) { return cookies.set(name, value, opts); *6* } }; export default { get, set }; </code></pre> <ul> <li> <p><strong><em>1</em> 导入通用 cookie 库,该库为您处理访问浏览器和服务器 cookie 之间的差异。</strong></p> </li> <li> <p><strong><em>2</em> 检查环境以确定是否需要 reqHeaders。</strong></p> </li> <li> <p><strong><em>3</em> 如果头信息中有 cookie,将其传递给 cookie 构造函数。</strong></p> </li> <li> <p><strong><em>4</em> 在 getter 和 setter 函数中,初始化 cookie 对象,传递 reqHeaders 以便在服务器上工作。</strong></p> </li> <li> <p><strong><em>5</em> 返回 cookie 查找的结果。</strong></p> </li> <li> <p><strong><em>6</em> 返回设置 cookie 的结果。除了名称和值之外,您还可以传递所有标准 cookie 选项。在大多数情况下,您将从浏览器中调用 set。</strong></p> </li> </ul> <p>现在您已经为两种环境都添加了获取和设置 cookie 的方法,您需要能够将此信息存储在应用状态中,以便您可以在应用程序中以一致的方式访问它。</p> <h4 id="1141-全局访问-cookie">11.4.1. 全局访问 cookie</h4> <p>通过使用操作获取 cookie,您可以标准化应用程序与 cookie 交互的方式。以下列表显示了如何添加一个<code>storeUserId</code>操作来获取和存储用户 ID。将此代码添加到 app-action-creators 文件中。</p> <h5 id="列表-1110在服务器上访问-cookiesrcsharedapp-action-creatorses6">列表 11.10。在服务器上访问 cookie—src/shared/app-action-creators.es6</h5> <pre><code>import UAParser from 'ua-parser-js'; import cookies from './cookies.es6'; *1* export const PARSE_USER_AGENT = 'PARSE_USER_AGENT'; export const STORE_USER_ID = 'STORE_USER_ID'; *2* export function parseUserAgent(requestHeaders) {} export function storeUserId(requestHeaders) { *3* const userId = cookies.get('userId', requestHeaders); *4* return { userId, *5* type: STORE_USER_ID *2* }; } export default { parseUserAgent, storeUserId }; </code></pre> <ul> <li> <p><strong><em>1</em> 导入 cookie 模块。</strong></p> </li> <li> <p><strong><em>2</em> 为新操作添加一个类型。</strong></p> </li> <li> <p><strong><em>3</em> 添加操作,它接受 requestHeaders 以便在服务器上工作。</strong></p> </li> <li> <p><strong><em>4</em> 将 cookie 名称和 requestHeaders 传递给 cookie 模块。</strong></p> </li> <li> <p><strong><em>5</em> 在操作上放置 userId 值。</strong></p> </li> </ul> <p>现在,您可以在应用程序中访问用户 ID 了!它将在服务器上获取,并在需要时可以在浏览器中更新。您可以将此概念应用于任何用户会话信息。整体管理用户会话超出了本章的范围。</p> <h4 id="1142-边缘缓存和用户">11.4.2. 边缘缓存和用户</h4> <p>当我开始构建同构应用程序时,用户管理看起来很简单。您使用 cookie 在浏览器中跟踪用户会话,就像在单页应用程序中一样。添加服务器使事情变得复杂,但您可以在服务器上读取 cookie。随着您添加缓存策略,这变得不那么直接。</p> <p>内存和持久化存储缓存策略与用户会话配合得更好,因为每个用户请求仍然会发送到服务器,允许收集用户信息。您可以将用户的标识信息添加到您的缓存键中。</p> <p>但边缘缓存的效果并不好。这是因为对于每个独特的用户,您必须保留每个包含特定用户数据的页面的唯一副本。如果您不这样做,您可能会向用户 2 展示用户 1 的信息。这会很糟糕!图 11.6 说明了这个概念。</p> <h5 id="图-116当边缘需要为每个用户缓存页面时重叠请求的好处就消失了">图 11.6。当边缘需要为每个用户缓存页面时,重叠请求的好处就消失了。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/11fig06_alt.jpg" alt="图片" loading="lazy"></p> <p>如果您需要使用边缘缓存并且有用户数据,您可以根据您的内容类型和流量模式采用以下策略之一(取决于您的应用程序类型和流量模式):</p> <ul> <li> <p>创建具有用户内容或通用消费内容(公共)的页面。然后仅在您的边缘服务器上缓存公共页面。</p> </li> <li> <p>保存一个 cookie,告知边缘服务器用户是否处于活跃会话中。使用此信息来决定是否提供缓存页面或将请求发送到服务器(透传)。</p> </li> <li> <p>提供带有占位符内容(显示内容加载位置的实心形状)的页面,然后决定在浏览器中加载什么内容。</p> </li> </ul> <h3 id="摘要-9">摘要</h3> <p>本章涵盖了几个将使您的同构应用在生产环境中运行得更好的主题,包括性能和缓存。您还了解了向处理用户会话的同构应用添加某些类型缓存的复杂性。</p> <ul> <li> <p>使用 webpack 分块来提高浏览器性能。</p> </li> <li> <p>使用<code>shouldComponentRender</code>优化渲染周期。</p> </li> <li> <p>通过流和连接池来提高服务器的性能。</p> </li> <li> <p>应用三种缓存策略之一(内存、持久或边缘)以提高服务器上的渲染时间。</p> </li> <li> <p>通过浏览器和服务器上的 cookie 管理用户会话。</p> </li> <li> <p>理解缓存策略对用户会话管理的影响。</p> </li> </ul> <h2 id="第四部分-使用其他工具应用同构架构">第四部分. 使用其他工具应用同构架构</h2> <p>React 是许多类型的前端应用程序的一个很好的选择,但它不是唯一的选择。本书迄今为止教授的技能是学习成为优秀的前端或全栈开发者的技能子集的一部分。最后一部分涵盖了额外的技术,如 Angular 和 Ember,并建议您如何使用它们来构建同构应用程序。它还包括一个简要的章节,专注于您应该探索的额外技能和专业知识领域,这将补充您在本书中学到的内容。</p> <h2 id="第十二章-其他框架不使用-react-实现同构">第十二章. 其他框架:不使用 React 实现同构</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>使用 Ember 的约定优于配置实现快速实现通用应用程序</p> </li> <li> <p>使用 TypeScript 在 Angular 应用程序中实现同构部分</p> </li> <li> <p>使用 Next.js 运行同构应用程序,它为您提供了内置服务器渲染的现成 React 实现</p> </li> </ul> <p>本章的每个部分都涵盖了一个框架,该框架允许您使用同构渲染开始使用。本章不会教授您这些其他技术,尽管它确实提供了链接到资源的链接,如果您想深入了解。相反,每个部分都突出了每个框架的关键部分:</p> <ul> <li> <p>在每个框架中设置和实现服务器端渲染</p> </li> <li> <p>在每个框架中启用具有服务器状态的 DOM 的水合</p> </li> <li> <p>理解每种方法的优缺点</p> </li> </ul> <h3 id="121-博客示例项目">12.1. 博客示例项目</h3> <p>在本章的每个部分中,您将使用相同的示例应用程序。<img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig01_alt.jpg" alt="图 12.1" loading="lazy">显示了博客的主页视图。这是一个带有页眉和博客帖子列表的基本主页。每个博客帖子都链接到一个帖子详情页面,该页面显示了完整的帖子正文和评论列表。</p> <h5 id="图-121-显示所有帖子的应用程序主页">图 12.1. 显示所有帖子的应用程序主页</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig01_alt.jpg" alt="" loading="lazy"></p> <p>本章的所有代码都在其自己的 GitHub 仓库中,网址为<a href="https://github.com/isomorphic-dev-js/chapter12-frameworks" target="_blank"><code>github.com/isomorphic-dev-js/chapter12-frameworks</code></a>,您可以克隆(<code>git clone https://github.com/isomorphic-dev-js/chapter12-frameworks</code>)。每个部分都有一个顶级文件夹,还有一个为所有三个应用程序提供模拟 API 的数据服务器文件夹:</p> <ul> <li> <p><strong><em>angular2</em>—</strong> 第 12.2 节的代码。这是一个完整的 Angular 应用程序。</p> </li> <li> <p><strong><em>ember-universal</em>—</strong> 第 12.3 节的代码。这是一个完整的 Ember 应用程序。</p> </li> <li> <p><strong><em>nextjs</em>—</strong> 第 12.4 节的代码。这是一个使用 Next.js 框架构建的完整的同构 React 应用程序。</p> </li> <li> <p><strong><em>server</em>—</strong> 此文件夹中的代码运行一个简单的数据 API。</p> </li> </ul> <h4 id="1211-ui-和组件分解">12.1.1. UI 和组件分解</h4> <p>在我们深入各种同构实现之前,让我们回顾一下博客是如何工作的。它由两个路由组成:主页路由 (/) 和帖子详情路由 (/post)。图 12.1 展示了主页。</p> <h5 id="图-122-带有相应评论的帖子详情页面">图 12.2. 带有相应评论的帖子详情页面</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig02_alt.jpg" alt="" loading="lazy"></p> <p>如您所见,主页很简单。一个简单的页眉包含“帖子”链接,点击后会带您回到主页。帖子列表直接位于页眉下方。当您点击主页上的任何帖子时,应用会加载相应的帖子详情页面。图 12.2 展示了该页面的样子。</p> <p>现在您已经看到了应用的样子,我们将介绍提供帖子评论数据的模拟数据服务器。</p> <h4 id="1212-共享模拟数据-api">12.1.2. 共享模拟数据 API</h4> <p>该仓库包含一个服务器,为每个示例应用提供模拟数据。博客应用中有两种数据类型:帖子评论。要运行服务器(其他应用需要服务器运行),您应该切换到服务器目录,运行 <code>npm install</code>,然后运行服务器:</p> <pre><code>$ cd server $ npm install $ npm start </code></pre> <p>在完成这些之后,您可以在提供的各种端点上获取模拟数据:</p> <ul> <li> <p><a href="http://localhost:3535/posts" target="_blank">http://localhost:3535/posts</a></p> </li> <li> <p><a href="http://localhost:3535/post/eu-eu-aute-dolore" target="_blank">http://localhost:3535/post/eu-eu-aute-dolore</a></p> </li> <li> <p><a href="http://localhost:3535/post/1/comments" target="_blank">http://localhost:3535/post/1/comments</a></p> </li> </ul> <p>这些端点的模拟数据由我已在仓库中提供的两个 JSON 文件提供。以下列表显示了 posts.json 中单个帖子的样子。</p> <h5 id="列表-121-帖子模拟数据serverdatapostsjson">列表 12.1. 帖子模拟数据—server/data/posts.json</h5> <pre><code>[ { "id": 1, *1* "image": "http://placehold.it/80x80", *2* "title": "eu eu aute dolore", *3* "urlSlug": "eu-eu-aute-dolore", *4* "body": "est ut aliqua pariatur do cillum cupidatat voluptate irure deserunt eiusmod quis anim veniam excepteur velit nulla labore sit deserunt pariatur quis fugiat ex non veniam minim nostrud do deserunt veniam ea anim aliquip dolor ex commodo proident Lorem esse pariatur dolor elit non commodo commodo fugiat..." *5* }, {}, ... ] </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> <li> <p><strong><em>4</em> 帖子的 URL 段落(带有短划线的标题—可以通过 urlSlug 查找帖子)</strong></p> </li> <li> <p><strong><em>5</em> 帖子的正文</strong></p> </li> </ul> <p>要获取所有帖子,您使用 /posts 端点。这在主页上用于显示所有帖子。服务器还可以通过 <code>urlSlug</code> 获取单个帖子,这使得 URL 可读。单个帖子在帖子详情页面上获取。</p> <p>此外,您还可以获取评论。以下列表显示了单个评论的 JSON 格式。此代码已在仓库中提供。</p> <h5 id="列表-122-注释模拟数据serverdatacommentsjson">列表 12.2. 注释模拟数据—server/data/comments.json</h5> <pre><code>[ { "message": "eu sint sunt elit amet ullamco ex reprehenderit do eiusmod exercitation dolore cillum dolor et ea est cupidatat reprehenderit...", *1* "userImage": "http://placehold.it/32x32", *2* "user": "Juliet", *3* "postId": 2, *4* "id": 0 *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> 与评论关联的帖子 ID</strong></p> </li> <li> <p><strong><em>5</em> 评论的 ID</strong></p> </li> </ul> <p>要获取帖子的所有评论,您使用 post/:id/comments 端点。这在帖子详情页面上用于显示博客帖子评论。</p> <p>现在您已经了解了应用的工作方式并学习了如何使用模拟数据服务器,让我们构建第一个版本。</p> <h3 id="122-使用-ember-fastboot-进行服务器端渲染">12.2. 使用 Ember FastBoot 进行服务器端渲染</h3> <p><em>Ember</em> 是一个流行的基于约定的网络框架。使用 Ember 及其同构实现(称为 FastBoot)非常简单。实现方式有很好的文档记录,并且几乎与你在 Ember 单页应用程序(SPA)中执行的操作相同。存在一些关键差异:</p> <ul> <li> <p>安装额外的库,如 ember-cli-fastboot,以添加对服务器渲染的支持。</p> </li> <li> <p>使用 Ember Fetch 获取数据而不是 Ember Data。如果你习惯了 Ember Data 提供的自动数据处理,这需要思维上的转变。</p> </li> </ul> <p>图 12.3 展示了你在前几章中看到的同构应用程序图。让我们再次回顾它,以便你可以看到使用 Ember 同构实现的具体细节。</p> <h5 id="图-123-使用-ember-的同构实现的应用程序流程ember-的特定内容以粗体突出显示">图 12.3. 使用 Ember 的同构实现的应用程序流程——Ember 的特定内容以粗体突出显示。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig03_alt.jpg" alt="" loading="lazy"></p> <p>要跟上进度,请查看 GitHub 仓库中的 ember-starter 分支(<code>git checkout ember-starter</code>)。要运行应用程序,你需要切换到 Ember 目录(ember-universal),安装 npm 包,并使用 Ember CLI 运行应用程序。图 12.4 展示了运行 Ember 的输出:</p> <pre><code>$ cd ember-universal $ npm install $ ember serve </code></pre> <h5 id="运行-ember-serve-来启动应用程序">运行 <code>ember serve</code> 来启动应用程序。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig04_alt.jpg" alt="" loading="lazy"></p> <p>Ember 有一个名为 Ember CLI 的工具,它让你可以快速开始(我已经为你包括了它)。你通过调用 <code>ember serve</code> 使用 Ember CLI 运行应用程序。你还可以使用它来生成 Ember 应用程序中使用的绝大多数类型的文件。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>Ember 资源</strong></p> <p>如果你想要了解更多关于使用 Ember 构建网络应用程序的信息,以下是一些帮助你入门的资源:</p> <ul> <li> <p>Ember 文档网站提供了一个入门教程,链接为 <a href="https://guides.emberjs.com/v2.14.0/tutorial/ember-cli/" target="_blank"><code>guides.emberjs.com/v2.14.0/tutorial/ember-cli/</code></a>。</p> </li> <li> <p>你也可以查看快速入门指南,链接为 <a href="https://guides.emberjs.com/v2.14.0/getting-started/quick-start/" target="_blank"><code>guides.emberjs.com/v2.14.0/getting-started/quick-start/</code></a>。</p> </li> <li> <p>你可以在 <a href="http://emberwatch.com/tutorials.html" target="_blank"><code>emberwatch.com/tutorials.html</code></a> 找到其他几个 Ember 教程。</p> </li> <li> <p>服务器渲染的 Ember 指南位于 <a href="https://ember-fastboot.com/quickstart" target="_blank"><code>ember-fastboot.com/quickstart</code></a>。</p> </li> </ul> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>应用程序将在 <a href="http://localhost:4200/" target="_blank">http://localhost:4200/</a> 上运行。请记住,同时也要在服务器文件夹中启动数据服务器。</p> <p>接下来,我们将回顾 Ember 应用的结构。</p> <h4 id="1221-ember-应用程序结构">12.2.1. Ember 应用程序结构</h4> <p>Ember 应用程序使用约定而不是配置。要添加新文件,你需要在正确的目录类型中添加它。例如:</p> <ul> <li> <p>当创建一个新的组件时,你将其添加到 components 文件夹中。</p> </li> <li> <p>如果你需要添加一个模型,你可以在 models 目录中添加它。</p> </li> </ul> <p>图 12.5 显示了 Ember 应用的应用程序目录。ember-universal 目录中还有其他文件夹和文件,但您需要了解的是 图 12.5 中的内容,以开始学习。</p> <h5 id="图-125-ember-应用程序中使用的应用程序目录和文件">图 12.5. Ember 应用程序中使用的应用程序目录和文件</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig05_alt.jpg" alt="图片" loading="lazy"></p> <p>我已经设置了应用程序的主要部分,这样您就可以专注于同构部分。</p> <p>接下来,我们将回顾路由和组件,以便您了解您正在处理的内容。</p> <h4 id="1222-ember-中的路由">12.2.2. Ember 中的路由</h4> <p>首先,让我们看看应用程序拥有的两个顶级路由。一个是根路由或主页路由,另一个是帖子详情路由 (/post/[post.urlSlug])。所有数据获取也将发生在路由上。以下列表显示了存储库中提供的路由文件。</p> <h5 id="列表-123-路由ember-universalapprouterjs">列表 12.3. 路由—ember-universal/app/router.js</h5> <pre><code>import Ember from 'ember'; *1* import config from './config/environment'; const Router = Ember.Router.extend({ *2* location: config.locationType, rootURL: config.rootURL }); Router.map(function() { this.route('post-detail', { path: '/post/:id' }); *3* }); export default Router; </code></pre> <ul> <li> <p><strong><em>1</em> 导入应用程序配置。</strong></p> </li> <li> <p><strong><em>2</em> 根据配置(这里与 ember-cli 设置的默认值相同)初始化路由器,包含根 URL 和位置类型。</strong></p> </li> <li> <p><strong><em>3</em> 为帖子详情页面添加第二个路由——包含名称和 URL 路径。</strong></p> </li> </ul> <p>除了路由器,应用程序还需要提供索引(主页)路由。以下列表显示了 index.js 中的代码。</p> <h5 id="列表-124-索引路由主页ember-universalapproutesindexjs">列表 12.4. 索引路由(主页)—ember-universal/app/routes/index.js</h5> <pre><code>import Ember from 'ember'; *1* export default Ember.Route.extend({ *2* }); </code></pre> <ul> <li> <p><strong><em>1</em> 导入 Ember。</strong></p> </li> <li> <p><strong><em>2</em> 在 Ember 中扩展路由对象。</strong></p> </li> </ul> <p>只要您遵循 Ember 命名约定并将文件放在正确的文件夹中,您就不需要做任何事情来使初始路由工作。帖子详情路由也看起来像这样。您可以在 ember-universal/app/routes/post-detail.js 中查看它。</p> <p>接下来,让我们看看构成此应用程序的组件。</p> <h4 id="1223-组件">12.2.3. 组件</h4> <p>Ember 组件由两个文件组成:一个包含组件类的 JavaScript 文件和一个提供组件视图部分的 Handlebars 模板文件。</p> <h5 id="信息">信息</h5> <p>Handlebars 是一种 JavaScript 模板语言。您可以在 <a href="http://handlebarsjs.com" target="_blank"><code>handlebarsjs.com</code></a> 上了解更多信息。</p> <p>在博客应用程序中,JavaScript 类不需要做太多。列表 12.5 显示了帖子控制器的外观。其他组件类在其自己的文件(comment-component、header-component、post-list)中看起来也像这样。所有这些代码都已经存储在存储库中。</p> <h5 id="列表-125-帖子组件ember-universalappcomponentspost-componentjs">列表 12.5. 帖子组件—ember-universal/app/components/post-component.js</h5> <pre><code>import Ember from 'ember'; *1* export default Ember.Component.extend({ *2* }); </code></pre> <ul> <li> <p><strong><em>1</em> 导入 Ember。</strong></p> </li> <li> <p><strong><em>2</em> 扩展组件以创建您的帖子组件类。</strong></p> </li> </ul> <p>所有组件都已经为您在应用程序中创建好了。如果您想添加自己的组件,而不是手动添加组件,您可以使用 CLI 生成它们:</p> <pre><code>$ ember generate component [name-of-component] </code></pre> <p>每个组件都有自己的 Handlebars 模板文件。以下列表显示了帖子组件的 Handlebars 模板文件。您可以在 /templates 目录内找到应用程序的所有模板文件。</p> <h5 id="列表-126-文章组件-handlebars-模板ember-universalapptemplatescomponentspost-componenthbs">列表 12.6. 文章组件 Handlebars 模板—ember-universal/app/templates/components/post-component.hbs</h5> <pre><code><div class="item"> <div class="ui tiny image"> <img src={{post.image}} /> *1* </div> <div class="content"> <div class="header">{{post.title}}</div> *2* <div class="description"> <p>{{truncate-text post.body limit}}</p> *3* </div> </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>文章组件在应用程序的两个路由中使用。它在主页上显示博客片段,在文章详情页上完整显示。在列表 12.6 中的 Handlebars 模板中的<code>truncate</code>辅助函数使得组件可以在多个情况下重复使用。列表 12.7 显示了文章详情路由的模板。使用 Handlebars 模板语法,在路由上放置文章组件。数据通过模型传递给每个路由,因此您可以从模型中访问它。您将在下一节中设置数据获取。</p> <h5 id="列表-127-文章详情路由模板ember-universalapptemplatespost-detailhbs">列表 12.7. 文章详情路由模板—ember-universal/app/templates/post-detail.hbs</h5> <pre><code>{{post-component post=model.post}} *1* <div class="ui comments"> <h3 class="ui dividing header">Comments</h3> *2* {{#each model.comments as |comment|}} *3* {{comment-component comment=comment}} *4* {{/each}} *5* </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> 使用 Handlebars 辅助函数 each 遍历每个评论(例如,文章数据存储在模型上)。</strong></p> </li> <li> <p><strong><em>4</em> 为模型上返回的每个评论渲染一个评论组件。</strong></p> </li> <li> <p><strong><em>5</em> 关闭 each 辅助函数。</strong></p> </li> </ul> <p>所有路由都加载到根模板:application。此模板渲染标题和动态渲染路由的占位符。以下列表显示了应用程序的模板文件。</p> <h5 id="列表-128-应用程序模板ember-universalapptemplatesapplicationhbs">列表 12.8. 应用程序模板—ember-universal/app/templates/application.hbs</h5> <pre><code>{{header-component}} *1* <div class="ui main text container offset" > {{outlet}} *2* </div> </code></pre> <ul> <li> <p><strong><em>1</em> 将标题组件放入根模板中,这将显示在每一页上。</strong></p> </li> <li> <p><strong><em>2</em> 特殊的占位符路由知道将组件传递给子组件,自动处理。</strong></p> </li> </ul> <p>到现在为止,您应该对博客应用的代码有了很好的理解。接下来,我们将使 Ember 应用同构。</p> <h4 id="1224-实现同构-ember">12.2.4. 实现同构 Ember</h4> <p>现在让我们设置这个应用的两个部分,这将使其成为同构的。图 12.6 显示了当浏览器中禁用 JavaScript 时,本节结束时您应该看到的内容。</p> <h5 id="图-126-添加所有同构组件后的服务器渲染应用">图 12.6. 添加所有同构组件后的服务器渲染应用</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig06_alt.jpg" alt="" loading="lazy"></p> <h5 id="第-1-步ember-fastboot">第 1 步:Ember FastBoot</h5> <p>首先,安装 Ember CLI FastBoot 库。运行以下命令:</p> <pre><code>$ npm install –-save-dev ember-cli-fastboot </code></pre> <p>现在如果您运行服务器并在浏览器中禁用 JavaScript,您将看到渲染的标题。要禁用 JavaScript,请打开 Chrome DevTools 窗口右上角的选项菜单并点击设置。在调试器下,选择禁用 JavaScript 复选框。图 12.7 显示了这一操作。</p> <h5 id="图-127-现在标题已在服务器上渲染">图 12.7. 现在标题已在服务器上渲染。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig07_alt.jpg" alt="" loading="lazy"></p> <p>接下来,我们将介绍如何在应用中获取数据。</p> <h5 id="第-2-步同构数据获取">第 2 步:同构数据获取</h5> <p>为了确保你可以在服务器和浏览器中获取应用的数据,你需要使用在两个环境中都工作的 fetch 实现。Ember 有一个你可以安装的库,称为 ember-fetch。现在就安装它吧:</p> <pre><code>$ npm install ember-fetch --save </code></pre> <p>安装完成后,你可以在主路由中添加一个 fetch 调用。以下列表显示了要添加到 routes/index.js 中的代码。</p> <h5 id="列表-129-索引路由主页ember-universalapproutesindexjs">列表 12.9. 索引路由(主页)—ember-universal/app/routes/index.js</h5> <pre><code>import Ember from 'ember'; import fetch from 'ember-fetch/ajax'; *1* export default Ember.Route.extend({ model() { return fetch('http://localhost:3535/posts') *2* .then((response) => { return response; *3* }); } }); </code></pre> <ul> <li> <p><strong><em>1</em> 包含 ember-fetch 的 Ajax 模块。</strong></p> </li> <li> <p><strong><em>2</em> 从 posts 端点获取帖子。</strong></p> </li> <li> <p><strong><em>3</em> 直接返回响应——数组中的 JSON 响应(视图期望数据以这种格式)。</strong></p> </li> </ul> <p>导航到主页,你会看到博客文章的列表。为了同时让帖子详情页的数据工作,将以下列表中的代码添加到帖子详情路由中。</p> <h5 id="列表-1210-帖子详情路由ember-universalapproutespost-detailjs">列表 12.10. 帖子详情路由—ember-universal/app/routes/post-detail.js</h5> <pre><code>import Ember from 'ember'; import fetch from 'ember-fetch/ajax'; export default Ember.Route.extend({ model(params) { *1* return fetch( `http://localhost:3535/post/${params.id}`) *2* .then((response) => { return fetch(`http://localhost:3535/post/${response.id}/comments`) .then((comments) => { *3* return { post: response, *4* comments: comments *4* }; }); }); } }); </code></pre> <ul> <li> <p><strong><em>1</em> 如果路由将有参数,这些参数将被传递到模型函数中。</strong></p> </li> <li> <p><strong><em>2</em> 使用 URL(URL slug)中的 ID 调用单个帖子端点。</strong></p> </li> <li> <p><strong><em>3</em> 帖子返回后,根据 ID 获取相关评论。</strong></p> </li> <li> <p><strong><em>4</em> 将帖子和评论数组发送到视图。</strong></p> </li> </ul> <p>将 Ember 应用转换为同构应用就这么简单!(如果你想看到完整的代码,它位于 ember-complete 分支。)图 12.8 展示了 Ember 同构渲染期间 DOM 中发生的情况。</p> <h5 id="图-128-ember-完全替换了-dom但对用户来说却不可察觉">图 12.8. Ember 完全替换了 DOM,但对用户来说却不可察觉。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig08_alt.jpg" alt="" loading="lazy"></p> <p>最后,让我们回顾一下使用 Ember 进行同构渲染相关的优势和成本。</p> <h4 id="1225-同构-ember-的优缺点">12.2.5. 同构 Ember 的优缺点</h4> <p>正如你在本节中看到的,设置 Ember 应用进行服务器渲染几乎和设置 Ember 应用一样简单。这意味着使用同构 Ember 启动和运行相对较快。一个额外的优点是 Ember CLI 允许你自动生成应用的基本部分。以下列表评估了使用 Ember 构建同构应用的优势和劣势。</p> <p><strong>优点</strong></p> <ul> <li> <p>设置应用的同构部分非常简单。安装两个额外的库(ember-cli-fastboot 和 ember-fetch)。</p> </li> <li> <p>Ember CLI(连同 FastBoot)使得从安装到运行应用之间的时间非常短。</p> </li> <li> <p>约定优于配置使得连接事物变得容易,并且对各个规模大小的团队都有益。</p> </li> <li> <p>Ember FastBoot 已经支持了大多数同构用例,包括不同环境中的 cookies 和基于路由的动态元标签。整体文档和用户指南做得很好(<a href="https://ember-fastboot.com/docs/user-guide" target="_blank"><code>ember-fastboot.com/docs/user-guide</code></a>)。</p> </li> </ul> <p><strong>缺点</strong></p> <ul> <li> <p>在首次渲染时替换 DOM 而不是计算是否需要 DOM 更新,这会给初始渲染增加额外的成本。</p> </li> <li> <p>Ember 对约定的关注并不适合每个人,可能不符合你的需求。</p> </li> <li> <p>掌握 Ember 需要花费时间。对于刚接触这个框架的开发者来说,许多事情都可能显得神奇。</p> </li> <li> <p>目前关于 FastBoot 的最佳实践没有 Ember Data 集成,这意味着你将失去 Ember 框架的一个强大功能。</p> </li> </ul> <h3 id="123-通用角动量">12.3. 通用角动量</h3> <p>在过去几年中,Angular 经历了相当大的演变。尽管 Angular.js(Angular 1)的大部分核心概念都得以保留,但它已经演变,吸收了在其他库和框架中变得流行的概念。幸运的是,这包括对服务器端渲染的支持。Angular 社区决定将他们的实现称为 <em>通用</em> 而不是同构。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <h5 id="注意-29">注意</h5> <p>正式来说,Angular 现在将 Angular 1 称为 <em>Angular.js</em>,将更新的版本称为 <em>Angular</em>。这使得主要版本更新更加平滑和连续,你无需记住是否应该使用 Angular 2 或 Angular 4。仓库中的代码使用 Angular 版本 4.0.0。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>在本节中,我将带你通过设置通用 Angular 应用程序。如果你是 Angular 新手,我强烈建议你首先熟悉 Angular 基础知识。Angular 文档网站有一个高质量的教程,网址为 <a href="https://angular.io/tutorial" target="_blank"><code>angular.io/tutorial</code></a>。如果你想深入了解 Angular,我推荐阅读 Jeremy Wilken(Manning,2018)的《Angular in Action》。最后,如果你想了解更多关于 Angular CLI 工具的信息,你可以查看其 GitHub 仓库 <a href="https://github.com/angular/angular-cli" target="_blank"><code>github.com/angular/angular-cli</code></a>。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p><strong>最佳拍档:TypeScript 和 Angular</strong></p> <p>Angular 使用 TypeScript 编写,它是 JavaScript 的超集,引入了强制类型信息的能力。它可以与任何版本的 JavaScript 一起使用,因此你可以用它与任何 ES3(这不是一个打字错误)或更新的版本一起使用。</p> <p>TypeScript 的基本价值在于强制变量限制在特定类型的值,例如:一个变量可能只能持有数字或字符串数组。JavaScript 有类型(不要让任何人告诉你不是这样!),但变量没有类型,所以你可以在任何变量上存储任何类型的值。这也催生了多种比较运算符,如 <code>==</code> 用于松散相等或 <code>===</code> 用于严格相等。</p> <p>TypeScript 可以帮助在它们影响你的应用程序之前捕获许多简单的语法错误。有时你可以编写有效的 JavaScript,但现实世界表明,有效的语法并不总是意味着有效的行为。以下是一个例子:</p> <pre><code>var bill = 20;? var tip = document.getElementById('tip').value; // Contains '5' console.log(bill + tip); // 205 </code></pre> <p>这个片段展示了简单的小费计算器示例:你从输入元素中获取值,并将其添加到账单中,以获取总付款金额。但这里的问题是<code>tip</code>变量是一个字符串(因为它是一个文本输入)。将数字和字符串相加可能是新 JavaScript 开发者最常见的陷阱之一,但这种情况仍然可能发生在任何人身上!如果你使用 TypeScript 来强制类型检查,这段代码可以编写为在出现这种常见错误时发出警告:</p> <pre><code>var bill: number = 20; var tip: number = document.getElementById('tip').value; // 5, error! var total: number = bill + tip; // error! </code></pre> <p>在这里,你使用 TypeScript 声明所有这些变量都必须各自持有数字值,通过使用<code>number</code>。这是一个简单的语法,位于 JavaScript 内部,用于告诉 TypeScript 变量应该持有的值类型。小费值将出错,因为它被分配了一个字符串,然后总金额将出错,因为它尝试将数字和字符串类型相加,这导致了一个字符串。</p> <p>对于经验丰富的 JavaScript 开发者来说,这看起来可能是一个明显的错误,但你有多少次让新开发者参与你的代码库的开发?你有多少次重构你的代码?你能确保在继续维护应用程序时,你的应用程序仍在传递相同的值类型吗?没有 TypeScript,你在使用每个值之前都必须进行严格的比较检查。</p> <p>许多开发者想知道为什么他们应该费心学习和使用 TypeScript。以下是我认为使用 TypeScript 的主要理由:</p> <ul> <li> <p><em><strong>它使你的代码更清晰</strong></em>—** 具有类型的变量更容易理解,因为其他开发者(或六个月后的你自己)不需要非常认真地思考变量应该是什么。</p> </li> <li> <p><em><strong>它使编辑器更智能</strong></em>—** 当你在支持 TypeScript 的编辑器中使用 TypeScript 时,你的代码将获得自动的 IntelliSense 支持。随着你编写代码,编辑器可以建议已知的变量或函数,并告诉你它期望的值类型。</p> </li> <li> <p><em><strong>它在运行代码之前捕获错误</strong></em>—** TypeScript 会在你在浏览器中运行代码之前捕获语法错误,这有助于减少你编写无效代码时的反馈循环。</p> </li> <li> <p><em><strong>它是完全可选的</strong></em>—** 当你需要时可以使用类型,并在不需要的地方选择性地省略它。</p> </li> </ul> <p>希望您已经认可了 TypeScript 的价值。如果不认可,请不要担心——我不会评判。但本书将在示例中使用它,因为它将有助于提供更多的清晰度,并进一步展示 TypeScript 的强大功能。随着我们在示例中使用功能,我将尝试提供对 TypeScript 功能和功能的额外见解。但您始终可以在<a href="http://www.typescriptlang.org/docs/tutorial.html" target="_blank">www.typescriptlang.org/docs/tutorial.html</a>上学习所有需要知道的内容。即使您选择不使用 TypeScript 在您的应用程序中进行类型检查,您也可以使用 TypeScript 来编译您的应用程序。因为 Angular CLI 已经内部使用 TypeScript,您可能在使用它时甚至都不知道。如果您决定构建自己的构建工具,TypeScript 仍然是一个值得考虑的编译器选项。</p> <table> <thead> <tr> <th></th> </tr> </thead> </table> <p>图 12.9 显示了您在之前章节中看到的同构应用程序图。我已经指出了当使用 Angular 构建同构应用程序时必须自己实现的各个部分。Angular 为您处理流程的一些部分。例如,如果您正确处理服务器初始化,数据获取将自动工作,但您仍然需要配置浏览器入口点和服务器入口点。</p> <h5 id="图-129angular-中的通用流程">图 12.9。Angular 中的通用流程</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig09_alt.jpg" alt="图片" loading="lazy"></p> <h4 id="1231-构建块组件">12.3.1. 构建块:组件</h4> <p>我已经为您设置了应用程序结构的大部分,因此您可以专注于学习如何使 Angular 成为通用。在本节中,我将简要地为您介绍此代码的结构。</p> <p>本节代码可以在 angular-starter 分支中找到,您可以通过运行<code>git checkout angular-starter</code>来切换到它。您还需要切换到 angular2 文件夹并运行<code>npm install</code>。请注意,此时这是一个单页应用程序(SPA)。您可以通过在终端中执行<code>npm start</code>命令来运行它。应用程序将在 <a href="http://localhost:4100" target="_blank">http://localhost:4100</a> 上运行。</p> <p>首先,让我们回顾一下 app 中已有的文件结构。图 12.10 显示了主要文件夹和文件。</p> <h5 id="图-1210构成-spa-的应用文件夹和文件它还包括用于通用状态传输和数据获取的辅助模块">图 12.10。构成 SPA 的应用文件夹和文件。它还包括用于通用状态传输和数据获取的辅助模块。</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig10_alt.jpg" alt="图片" loading="lazy"></p> <p>每个组件由三个文件组成:包含组件定义的 TypeScript 文件、组件 CSS 文件以及提供组件模板的 HTML 文件。应用程序组件包含 app.component.ts、app.component.css 和 app.component.html。Angular 应用程序中的所有组件都反映了 Ember 应用程序中的组件,但它们被植入 Angular 模式。</p> <p>目前,应用程序流程是这样的:</p> <blockquote> <p><strong>1</strong>. 用户导航到页面,index.html 加载 webpack 捆绑的文件。这执行了从应用程序入口点(main.ts)的代码,该代码启动应用程序。</p> <p><strong>2</strong>. 此应用有一个模块(AppModule),当应用启动时加载。</p> <p><strong>3</strong>. AppModule 包含所有组件依赖项。这些依赖项现在已加载,并且当前路由正在渲染。</p> </blockquote> <p>接下来,你想要添加服务器渲染库和逻辑。</p> <h4 id="1232-转换为通用依赖项">12.3.2. 转换为通用:依赖项</h4> <p>要使服务器端渲染工作,首先你需要安装依赖项。首先需要添加的是库:</p> <pre><code>$ npm install --save @angular/platform-server </code></pre> <p>要运行通用 Angular,你还需要提供一个状态传输模块,因为这个模块没有为你提供。你还需要提供一个与这个状态传输模块兼容的 HTTP 实现。一些包可以帮助你完成这项工作,但当前我发现最好的实现来自一个通用示例仓库:<a href="https://github.com/FrozenPandaz/ng-universal-demo" target="_blank"><code>github.com/FrozenPandaz/ng-universal-demo</code></a>。代码可以在 angular2/src/modules 文件夹中找到,因为它已经包含在仓库中。</p> <p>代码由两个模块组成:transfer-state 和 transfer-http。Transfer State 是一个处理序列化和反序列化 JSON 的模块。Transfer HTTP 是一个在尝试获取请求的数据之前从 Transfer State 模块中查找应用状态的模块。它自动使用从你的请求中推断出的键,所以你可以像使用常规 Angular HTTP 模块一样使用 Transfer HTTP 模块。</p> <h4 id="1233-转换为通用服务器和浏览器代码">12.3.3. 转换为通用:服务器和浏览器代码</h4> <p>将应用转换为通用应用的下一步是创建两个入口点,而不是当前通过 AppModule 实现的单个入口点。首先,你应该创建浏览器入口点。以下列表显示了需要在 browser.module.ts 文件中放入的代码。</p> <h5 id="列表-1211-浏览器模块angular2srcbrowsermodulets">列表 12.11. 浏览器模块—angular2/src/browser.module.ts</h5> <pre><code>import { BrowserModule } from '@angular/platform-browser'; *1* import { NgModule } from '@angular/core'; *1* import { AppComponent } from './app/app.component'; *2* import { AppModule } from './app/app.module'; *3* import { BrowserTransferStateModule } from './modules/ transfer-state/browser-transfer-state.module'; // *4* @NgModule({ imports: [ BrowserModule.withServerTransition({ *5* appId: 'ng-universal-example' }), BrowserTransferStateModule, *4* AppModule ], providers: [], bootstrap: [AppComponent] *2* }) export class AppBrowserModule { } </code></pre> <ul> <li> <p><strong><em>1</em> 导入 Angular 依赖项。</strong></p> </li> <li> <p><strong><em>2</em> 导入 app 组件,这是传递给 bootstrap 数组的根组件。</strong></p> </li> <li> <p><strong><em>3</em> 导入 AppModule 以确保依赖项正确设置以进行依赖注入。</strong></p> </li> <li> <p><strong><em>4</em> 导入 browser transfer state helper 模块。</strong></p> </li> <li> <p><strong><em>5</em> 使用服务器转换初始化 browserModule。</strong></p> </li> </ul> <p>当你初始化<code>browserModule</code>时,确保<code>appId</code>属性与服务器模块中的内容匹配(你将在列表 12.13 中添加此内容)。</p> <p>现在,你需要在 main.ts 中加载浏览器入口,这将启动应用。这只是一个更改当前导入的模块(AppModule)。以下列表显示了需要替换的代码。</p> <h5 id="列表-1212-更新-maints-文件angular2srcmaints">列表 12.12. 更新 main.ts 文件—angular2/src/main.ts</h5> <pre><code>// remove this line: import { AppModule } from './app/app.module'; import { AppBrowserModule } from './browser.module'; *1* platformBrowserDynamic().bootstrapModule(AppBrowserModule); *2* </code></pre> <ul> <li> <p><strong><em>1</em> 导入 browser 模块而不是 app.module。</strong></p> </li> <li> <p><strong><em>2</em> 使用 AppBrowserModule 启动应用。</strong></p> </li> </ul> <p>接下来,你需要创建一个服务器入口模块。这将与你的浏览器模块类似,但会设置注入服务器状态。以下列表显示了使用 app.server.module.ts 文件需要添加的内容。</p> <h5 id="列表-1213-服务器入口模块angular2srcappservermodulets">列表 12.13. 服务器入口模块—angular2/src/app.server.module.ts</h5> <pre><code>import { NgModule, APP_BOOTSTRAP_LISTENER, ApplicationRef } from '@angular/ core'; *1* import 'rxjs/Rx'; *1* import { BrowserModule } from '@angular/platform-browser'; *1* import { ServerModule } from '@angular/platform-server'; *1* import { ServerTransferStateModule } from './modules/transfer-state/server-transfer-state.module'; *2* import { TransferState } from './modules/transfer-state/transfer-state'; *3* import { AppComponent } from './app/app.component'; *4* import { AppModule } from './app/app.module'; *4* export function onBootstrap(appRef: ApplicationRef, transferState: TransferState) { return () => { appRef.isStable .filter(stable => stable) .first() .subscribe(() => { transferState.inject(); *3* }); }; } @NgModule({ providers: [ { provide: APP_BOOTSTRAP_LISTENER, useFactory: onBootstrap, multi: true, deps: [ ApplicationRef, TransferState ] } ], imports: [ ServerModule, BrowserModule.withServerTransition({ *5* appId: 'ng-universal-example' }), ServerTransferStateModule, *2* AppModule *4* ], bootstrap: [ AppComponent ] }) export class AppServerModule {} </code></pre> <ul> <li> <p><strong><em>1</em> 导入 Angular 和相关依赖项。</strong></p> </li> <li> <p><strong><em>2</em> 包含 ServerTransferState-Module,它实现了应用程序状态的服务器版本。</strong></p> </li> <li> <p><strong><em>3</em> 包含 TransferState 模块,这样你就可以在应用程序启动后将其注入到其中。</strong></p> </li> <li> <p><strong><em>4</em> 服务器模块需要导入根 AppModule 和组件。</strong></p> </li> <li> <p><strong><em>5</em> 设置服务器过渡 appId 以匹配你在浏览器模块文件中添加的 appId。</strong></p> </li> </ul> <p>最后,你需要添加一个服务器配置文件,该文件将处理传入的路由,然后加载 Angular 并渲染它。以下列表显示了需要添加到 main.server.ts 文件中的代码。</p> <h5 id="列表-1214-nodejs-服务器mainserverts">列表 12.14. Node.js 服务器—main.server.ts</h5> <pre><code>import 'reflect-metadata'; import 'zone.js/dist/zone-node'; import { platformServer, renderModuleFactory } from '@angular/platform-server'; import { enableProdMode } from '@angular/core'; import { AppServerModuleNgFactory } from './app.server.module.ngfactory'; *1* import * as express from 'express'; import { readFileSync } from 'fs'; import { join } from 'path'; const PORT = 4000; enableProdMode(); const app = express(); const template = readFileSync(join(__dirname, '..', 'dist', 'index.html')).toString(); app.engine('html', (_, options, callback) => { *2* const opts = { document: template, url: options.req.url }; renderModuleFactory(AppServerModuleNgFactory, opts) *1* .then(html => callback(null, html)); }); app.set('view engine', 'html'); *3* app.set('views', 'src'); app.get('*.*', express.static(join(__dirname, '..', 'dist'))); app.get('*', (req, res) => { res.render('index', { req }); *4* }); app.listen(PORT, () => { console.log(`listening on http://localhost:${PORT}!`); }); </code></pre> <ul> <li> <p><strong><em>1</em> 模块工厂传递给渲染函数,以便 Angular 可以正确处理路由。</strong></p> </li> <li> <p><strong><em>2</em> 设置 HTML 渲染引擎。</strong></p> </li> <li> <p><strong><em>3</em> 确保视图引擎指向 HTML。</strong></p> </li> <li> <p><strong><em>4</em> 当接收到路由时,调用 res.render。</strong></p> </li> </ul> <p>到目前为止,你可以成功运行代码。你使用与 SPA 版本不同的服务器命令。此命令构建并启动服务器和浏览器代码:</p> <pre><code>$ npm run start-isomorphic </code></pre> <p>到目前为止,应用程序已设置但尚未有数据。服务器将加载,你将看到应用程序标题但没有内容。下一节将介绍数据获取,这是加载完整内容所需的。</p> <h4 id="1234-在通用模式下获取数据">12.3.4. 在通用模式下获取数据</h4> <p>为了成功获取数据并将其传递到浏览器,你还需要在获取数据的服务的中使用 Transfer HTTP 库。以下列表显示了如何将模块导入到 AppModule 中。</p> <h5 id="列表-1215-带有-transfer-http-的-appmoduleangular2srcappappmodulets">列表 12.15. 带有 Transfer HTTP 的 AppModule—angular2/src/app/app.module.ts</h5> <pre><code>//...more imports // remove { Http } import import { TransferHttpModule } from '../modules/transfer-http/ transfer-http.module'; *1* //...more imports @NgModule({ declarations: [], imports: [ // remove HttpModule and BrowserModule TransferHttpModule, *1* CommonModule, RouterModule.forRoot([]) ], providers: [ ], bootstrap: [AppComponent] }) export class AppModule { } </code></pre> <ul> <li><strong><em>1</em> 导入模块并将其添加到 Angular 导入中——确保移除旧的 HTTP 模块和 Browser 模块。</strong></li> </ul> <p>现在你已经将模块包含到 AppModule 中,你可以在服务中导入 TransferHttp 库。以下列表显示了如何更新帖子服务。</p> <h5 id="列表-1216-更新帖子服务angular2srcappservicespostsservicets">列表 12.16. 更新帖子服务—angular2/src/app/services/posts.service.ts</h5> <pre><code>import { Injectable } from '@angular/core'; // remove the Http import import { TransferHttp } from '../../modules/transfer-http/transfer-http'; *1* const service = 'http://localhost:3535' @Injectable() export class PostsService { constructor(private http: TransferHttp) {} getPosts(): any { return this.http.get(`${service}/posts`); } getPostByUrlSlug(urlSlug): any { return this.http.get(`${service}/post/${urlSlug}`); } } </code></pre> <ul> <li><strong><em>1</em> 将 Http 导入更改为 TransferHttp 导入,然后在构造函数中使用它而不是 Http。</strong></li> </ul> <p>你还需要在评论服务中进行相同的更新。以下列表显示了在评论服务文件中需要更改的内容。</p> <h5 id="列表-1217-更新评论服务angular2srcappservicescommentsservicets">列表 12.17. 更新评论服务—angular2/src/app/services/comments.service.ts</h5> <pre><code>import { Injectable } from '@angular/core'; import { TransferHttp } from '../../modules/transfer-http/transfer-http'; *1* const service = 'http://localhost:3535' @Injectable() export class CommentsService { constructor(private http: TransferHttp) {} *1* getCommentsForPost(postId): any { return this.http.get(`${service}/post/${postId}/comments`); } } </code></pre> <ul> <li><strong><em>1</em> 将 Http 导入更改为 TransferHttp 导入,然后在构造函数中使用它而不是 Http。</strong></li> </ul> <p>到目前为止,完整的通用流程正在运行。你可以通过相同的练习进行 Ember:通过 Chrome DevTools 禁用浏览器中的 JavaScript 并观察加载的 HTML。你还可以在 angular-complete 分支中获取完整的代码。</p> <h4 id="1235-通用-angular-的优缺点">12.3.5. 通用 Angular 的优缺点</h4> <p>使用通用 Angular 有以下优缺点:</p> <p><strong>优点</strong></p> <ul> <li> <p>使用依赖注入,这允许你根据需要交换依赖项</p> </li> <li> <p>访问 Angular CLI,它允许你生成组件</p> </li> <li> <p>更少的魔法——你可以控制服务器和数据活化</p> </li> </ul> <p><strong>缺点</strong></p> <ul> <li> <p>需要实现状态转移逻辑</p> </li> <li> <p>需要设置自己的服务器</p> </li> <li> <p>需要设置自己的浏览器和服务器入口点</p> </li> </ul> <h3 id="124-nextjsreact-同构框架">12.4. Next.js:React 同构框架</h3> <p>如果您正在寻找一个使用 React 的全栈式解决方案,Next.js 是一个强大的选择。默认情况下,Next.js 使用一种约定驱动的构建 React 应用的方法。即使您决定在生产环境中不使用 Next.js,您也可能发现它是一个构建同构原型或概念验证(<em>pocs</em>)的好工具。它可以帮助您向团队或老板推销同构的想法。</p> <p>要运行 Next.js 示例,查看主分支(<code>git checkout master</code>)。切换到 nextjs 目录,安装 Node.js 包,然后运行 webpack 开发服务器:</p> <pre><code>$ cd nextjs $ npm install $ npm run dev </code></pre> <p>图 12.11 展示了 Next.js 实现的同构流程。Next.js 默认是服务器渲染的,所以您不需要进行任何配置!</p> <h5 id="图-1211-nextjs-的同构流程">图 12.11. Next.js 的同构流程</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig11_alt.jpg" alt="图片" loading="lazy"></p> <p>现在我们来浏览 Next.js 应用。我们将回顾使其开箱即用同构的部分。</p> <h4 id="1241-nextjs-结构">12.4.1. Next.js 结构</h4> <p>Next.js 使用了一种有偏见的 React 实现,它自带了许多标准功能(代码拆分、内置路由、服务器渲染、Webpack 开发服务器等)。Next.js 还提供了一套用于在生产环境中构建和提供应用的脚本。</p> <p>Next.js 项目由组件和页面组成。<em>页面</em>是获取数据和组合子组件的容器组件。您可以轻松添加如 Redux 之类的组件。标准 React 应用和 Next.js 之间的一大区别是,在 Next.js 中,他们实现了自己的路由器。图 12.12 显示了此简单应用的文件夹结构。</p> <h5 id="图-1212-构成基本-nextjs-应用的文件夹和文件">图 12.12. 构成基本 Next.js 应用的文件夹和文件</h5> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/12fig12.jpg" alt="图片" loading="lazy"></p> <p>注意,为了向应用添加路由,您需要在 pages 目录中创建一个新的 React 组件。文件名将成为路由。此应用中还有一个使用查询参数来指示博客文章 URL 别名的详细页面路由。</p> <p>Next.js 默认不支持动态路由。要添加动态路由,您必须添加自己的服务器文件。这很简单,但确实需要额外的工作。这里我不会演示这个,但您可以在<a href="https://github.com/zeit/next.js/#custom-server-and-routing" target="_blank"><code>github.com/zeit/next.js/#custom-server-and-routing</code></a>找到相关文档。</p> <h4 id="1242-nextjs-初始属性">12.4.2. Next.js 初始属性</h4> <p>在很大程度上,Next.js 是一个标准的 React 应用,但它提供了一个异步助手<code>getInitialProps</code>,它会为您获取组件的数据。这个方法的优点是 Next.js 框架会自动在服务器上处理预取,知道在浏览器上不会再次运行它,并且会在单应用流程中导航到的路由上运行它。</p> <p>下面的列表显示了应用程序索引路由中的<code>getInitialProps</code>。Next.js 应用程序完全为你构建——你不需要添加此代码。</p> <h5 id="列表-1218-索引路由数据获取nextjspagesindexjs">列表 12.18. 索引路由数据获取—nextjs/pages/index.js</h5> <pre><code>IndexPage.getInitialProps = async ({ req }) => { *1* let res, json; try { res = await fetch('http://localhost:3535/posts'); *2* json = await res.json() *3* } catch (e) { console.log("e", e); *4* } return { posts: json || [] } *5* } </code></pre> <ul> <li> <p><strong><em>1</em> 将函数定义为异步函数——Next.js Babel 预设已经配置为使用 ES7 功能。</strong></p> </li> <li> <p><strong><em>2</em> 添加等待函数以获取帖子列表。</strong></p> </li> <li> <p><strong><em>3</em> 添加等待函数以获取 JSON 响应。</strong></p> </li> <li> <p><strong><em>4</em> 如果有错误,记录它——async await 不支持 promise 错误捕获,因此使用 try/catch 块。</strong></p> </li> <li> <p><strong><em>5</em> 返回帖子结果。</strong></p> </li> </ul> <h4 id="1243-nextjs-的优缺点">12.4.3. Next.js 的优缺点</h4> <p>总体而言,如果你想要快速尝试或有一个只有几个页面的简单应用程序,Next.js 提供了一个很好的解决方案。对于具有动态路由的复杂应用程序,Next.js 需要更多的时间投资才能启动。</p> <p><strong>优点</strong></p> <ul> <li> <p>如果不需要动态路由,则开箱即用。</p> </li> <li> <p>提供所有开发构建脚本和对生产构建的支持。</p> </li> <li> <p>基于约定的路由系统。</p> </li> <li> <p>提供了同构数据获取实现。你只需添加从后端获取数据的代码。</p> </li> </ul> <p><strong>缺点</strong></p> <ul> <li> <p>更复杂的应用程序需要额外的设置工作来设置 Redux 和动态路由。</p> </li> <li> <p>框架的一些部分是自定义的,例如路由器,这意味着你不得不学习另一个路由器。</p> </li> <li> <p>最初是为较小的静态网站构建的。</p> </li> </ul> <h3 id="125-比较选项">12.5. 比较选项</h3> <p>你已经看到了使用流行的 JavaScript 框架构建同构应用程序的几种方法。表 12.1 比较了这三个框架。</p> <h5 id="表-121-emberangular-和-nextjs-的比较">表 12.1. Ember、Angular 和 Next.js 的比较</h5> <table> <thead> <tr> <th></th> <th>Ember</th> <th>Angular</th> <th>Next.js</th> </tr> </thead> <tbody> <tr> <td>框架的学习曲线</td> <td>中等:快速入门,但高级知识的学习曲线陡峭。</td> <td>中等:需要牢固掌握 Angular 的 MVC 实现。</td> <td>如果你知道 React,则简单:需要熟悉框架约定。</td> </tr> <tr> <td>容易进入生产环境吗?</td> <td>是</td> <td>比其他两种选项需要更多步骤</td> <td>是</td> </tr> <tr> <td>默认情况下是否支持同构代码?</td> <td>是的,但需要非标准的 Ember 数据获取</td> <td>需要添加代码模块来覆盖默认的 HTTP 行为</td> <td>是</td> </tr> <tr> <td>应用程序大小?</td> <td>任何</td> <td>任何</td> <td>适用于小型或静态应用程序</td> </tr> <tr> <td>浏览器应用程序的初始加载方法(服务器和浏览器之间的交接)</td> <td>完全替换</td> <td>计算替换</td> <td>使用 React 的虚拟 DOM,无替换或 DOM 更新</td> </tr> </tbody> </table> <h3 id="摘要-10">摘要</h3> <p>本章概述了三种实现同构(或通用)应用程序的方法。你学习了 Ember FastBoot、通用 Angular 和 Next.js。每个都提供了适用于你情况的良好实现。</p> <ul> <li> <p>使用 Ember 和 Ember FastBoot 构建基于约定的应用程序。Ember 为你提供了大部分的实现,因此快速启动应用程序。</p> </li> <li> <p>使用 Angular 进行应用程序的服务端渲染。Angular 需要更多的代码来正确地在服务器和浏览器之间进行状态传输。</p> </li> <li> <p>Next.js 是一个 React 框架,它默认支持同构,适用于没有动态页面的应用程序。</p> </li> </ul> <h2 id="第十三章-从哪里开始">第十三章. 从哪里开始</h2> <p><em>本章涵盖</em></p> <ul> <li> <p>额外的同构工具和框架</p> </li> <li> <p>你想要获得的技能,以便成为构建同构应用程序的专家</p> </li> <li> <p>在哪里了解更多关于相关重点领域,如 GraphQL、搜索引擎优化(SEO)和性能</p> </li> </ul> <p>在整本书中,你学习了关于许多工具、库和框架的知识。你接触到了构建同构应用程序的最佳实践。最重要的是,你习惯于以同构的方式思考:你可以导航服务器/浏览器交接,并且熟悉使这成为可能的技术。你还接触到了其他技术,如 Ember 和 Angular 如何实现服务器端渲染。你还看到了一个全功能的同构框架(Next.js)。</p> <p>现在是时候回顾一些资源,以了解更多关于同构应用程序、本书中介绍的各种技术,以及你可能需要构建实用、真实世界应用程序的相关主题。</p> <h3 id="131-额外的工具和框架">13.1. 额外的工具和框架</h3> <p>这本书涵盖了今天在 Web 开发中使用的许多最受欢迎的库。但如果你作为一名 Web 开发者已经工作了甚至一个月,你就会知道 JavaScript 社区总是在不断演变想法,而且有用的主题远远超过一本书所能涵盖的范围。本节提供了 Webpack Dev Server 的概述,建议你到哪里去了解更多关于实现它的信息,并介绍了额外的同构框架。Webpack Dev Server 是一个工具,它使得构建 webpack 配置的应用程序的开发环境更容易使用。</p> <h4 id="1311-webpack-dev-server">13.1.1. Webpack Dev Server</h4> <p>Webpack Dev Server 是 webpack 提供的发展环境。它启用了热模块替换,它接受更新的构建并自动替换运行中的应用程序中已更改的部分。以下是它的工作原理的概述:</p> <blockquote> <p><strong>1</strong>. 启动第一个构建并输出初始文件,由开发服务器提供。</p> <p><strong>2</strong>. 启用监视器。</p> <p><strong>3</strong>. 监视器输出触发构建,在开发服务器上输出新版本。</p> <p><strong>4</strong>. 使用热模块替换来更新浏览器中运行的代码。</p> </blockquote> <p>这在开发环境中变得非常有帮助,并且显著加快了你的编译和查看更改的能力。我强烈建议为你的所有 webpack 项目设置它。</p> <p>要了解 Webpack Dev Server,你可以访问 Webpack 的文档:<a href="https://webpack.js.org/guides/hot-module-replacement/" target="_blank"><code>webpack.js.org/guides/hot-module-replacement/</code></a>。你必须为 React 设置一些特定的事情。热模块替换指南的 URL 包含链接(参见文档网站 <a href="https://webpack.js.org/guides/hot-module-replacement/#other-code-and-frameworks" target="_blank"><code>webpack.js.org/guides/hot-module-replacement/#other-code-and-frameworks</code></a> 的其他代码和框架部分)。</p> <p>在同构环境中实现这一点并不简单——尤其是在使用前端 Node.js 服务器和 webpack 打包(而不是也使用 webpack 构建你的 Node.js 服务器,我不推荐这样做)的情况下。Webpack Isomorphic Tools 可以帮助你开始这个过程:<a href="https://github.com/halt-hammerzeit/webpack-isomorphic-tools" target="_blank"><code>github.com/halt-hammerzeit/webpack-isomorphic-tools</code></a>。</p> <h4 id="1312-同构框架">13.1.2. 同构框架</h4> <p>除了 Next.js,至少还有两个同构 React 框架值得一看(如果你想要使用预构建选项而不是自己构建):</p> <ul> <li> <p>Walmart Labs Electrode (<a href="http://www.electrode.io" target="_blank">www.electrode.io</a>)</p> </li> <li> <p>Redfin 的 React 服务器 (<a href="https://react-server.io" target="_blank"><code>react-server.io</code></a>)</p> </li> </ul> <p>几乎每个新的 JavaScript 框架都提供了服务器端渲染的能力。无论你想尝试 Vue.js (<a href="https://vuejs.org" target="_blank"><code>vuejs.org</code></a>)、Aurelia (<a href="https://github.com/AureliaUniversal/universal" target="_blank"><code>github.com/AureliaUniversal/universal</code></a>) 还是其他什么,它们很可能支持同构渲染。</p> <h3 id="132-上线基于同构技能构建">13.2. 上线:基于同构技能构建</h3> <p>这本书为你提供了在多个领域的坚实基础,包括 React 架构和 Node.js 的服务器端渲染。尽管你在这些技能上已经有一个良好的开端,但这一节会告诉你如何找到更多资源来继续提高这些技能。</p> <h4 id="1321-react-最佳实践">13.2.1. React 最佳实践</h4> <p>如果你打算在生产环境中构建同构 React 应用,成为 React 架构方面的专家是你想要提高知识的一个领域。幸运的是,React 拥有一个强大的社区和许多资源:</p> <ul> <li> <p>GitHub 仓库,包含关于 React 的各种博客和资源链接 (<a href="http://mng.bz/XEXE" target="_blank"><code>mng.bz/XEXE</code></a>)</p> </li> <li> <p>Egghead.io 课程——有些是免费的,有些则需要订阅 (<a href="https://egghead.io/technologies/react" target="_blank"><code>egghead.io/technologies/react</code></a>)。我是 Egghead 将课程拆分成微小可消费概念的忠实粉丝:</p> <ul> <li> <p>React/Redux 技巧表 (<a href="https://egghead.io/react-redux-cheatsheets" target="_blank"><code>egghead.io/react-redux-cheatsheets</code></a>)</p> </li> <li> <p>学习 React Router v4 (<a href="http://mng.bz/YHFN" target="_blank"><code>mng.bz/YHFN</code></a>)</p> </li> <li> <p>为 React 应用添加国际化 (<a href="http://mng.bz/g5On" target="_blank"><code>mng.bz/g5On</code></a>)</p> </li> </ul> </li> <li> <p>其他 React 书籍:</p> <ul> <li> <p>由 Mark T. Thomas 著的 <em>React in Action</em> (Manning, 2017)</p> </li> <li> <p>由 Azat Mardan 著的 <em>React Quickly</em> (Manning, 2017)</p> </li> <li> <p>由 Marc Garreau 和 Will Faurot 著的 <em>Redux in Action</em> (Manning, 2018)</p> </li> </ul> </li> </ul> <h4 id="1322-提升你的-nodejs-水平">13.2.2. 提升你的 Node.js 水平</h4> <p>如果你没有太多 Node.js 的经验,我建议通过以下方式提高你的 Node.js 技能:</p> <ul> <li> <p>了解 Node.js 的 I/O 模型 (<a href="https://nodejs.org/en/docs/guides/blocking-vs-non-blocking/" target="_blank"><code>nodejs.org/en/docs/guides/blocking-vs-non-blocking/</code></a> 和 <a href="http://mng.bz/NR1f" target="_blank"><code>mng.bz/NR1f</code></a>)</p> </li> <li> <p>使用 Node.js 构建简单的 REST API:</p> <ul> <li> <p>Loopback 框架 (<a href="https://loopback.io/doc/en/lb3/Tutorials-and-examples.html" target="_blank"><code>loopback.io/doc/en/lb3/Tutorials-and-examples.html</code></a>)</p> </li> <li> <p>使用 Node.js、Express、MongoDB、Mongoose 和 Postman 的教程 (<a href="http://mng.bz/1qSO" target="_blank"><code>mng.bz/1qSO</code></a>)</p> </li> </ul> </li> <li> <p>学习如何强化 Node.js 以创建更安全的服务器:</p> <ul> <li> <p>关于强化 Node.js 的博客文章 (<a href="https://blog.risingstack.com/node-js-security-checklist/" target="_blank"><code>blog.risingstack.com/node-js-security-checklist/</code></a>)</p> </li> <li> <p>Express 关于安全性的文档 (<a href="https://expressjs.com/en/advanced/best-practice-security.html" target="_blank"><code>expressjs.com/en/advanced/best-practice-security.html</code></a>)</p> </li> </ul> </li> </ul> <p>为了练习,我建议构建一种类型的 CRUD 应用。构建这本书中的某些示例会很好——一个允许用户上传菜谱的应用程序将是一个很好的练习应用。构建一个聊天应用也是练习的另一种好方法,因为它涵盖了几个重要主题(REST API、WebSocket、安全性,因为它需要账户,等等)。</p> <h4 id="1323-基础设施">13.2.3. 基础设施</h4> <p>由于同构应用程序始终会有一个服务器组件,因此熟练掌握构建工具和服务器管理将有助于你在运行生产应用程序时。此外,了解 CDN 并能够使用它们是任何网络开发者工具箱中的重要技能。这篇博客文章是一个很好的起点,介绍如何使用 Docker 和亚马逊网络服务(AWS)部署 React 应用程序:<a href="http://mng.bz/9Na2" target="_blank"><code>mng.bz/9Na2</code></a>。</p> <p>这里是一份你需要开始的一般技能列表:</p> <ul> <li> <p>容器:Docker (<a href="https://docs.docker.com/get-started/" target="_blank"><code>docs.docker.com/get-started/</code></a>)</p> </li> <li> <p>CI 工具:CircleCI (<a href="https://circleci.com" target="_blank"><code>circleci.com</code></a>) 或 TravisCI (<a href="https://travis-ci.org" target="_blank"><code>travis-ci.org</code></a>) 是一个好的起点</p> </li> <li> <p>云托管:AWS、Google、Heroku、Digital Ocean 以及更多</p> </li> <li> <p>CDN 的良好解释 (<a href="http://mng.bz/k6qG" target="_blank"><code>mng.bz/k6qG</code></a>)</p> </li> <li> <p>有许多 CDN 提供商你可以查看:Amazon Cloud Front、CloudFlare、Fastly、Akamai</p> </li> <li> <p>SSL/TLS (<a href="https://blog.talpor.com/2015/07/ssltls-certificates-beginners-tutorial/" target="_blank"><code>blog.talpor.com/2015/07/ssltls-certificates-beginners-tutorial/</code></a>)</p> </li> <li> <p>CORS (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS" target="_blank"><code>developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS</code></a>)</p> </li> </ul> <p>你可以练习这些技能的最好方法之一是在 AWS 上部署一个个人网站或练习应用。这将迫使你通过使用这里列出的几个工具的过程(例如,你可以使用 Docker 构建一个应用程序并将其部署到 AWS 的 Elastic Beanstalk 上)。</p> <h3 id="133-所有事情数据seo-和性能">13.3. 所有事情:数据、SEO 和性能</h3> <p>本书专注于网络开发的狭小领域。但在以下至少一个领域添加深入知识将提高你执行实际应用的能力(并使你更具可雇佣性!)。</p> <h4 id="1331-数据使用-graphql-访问服务">13.3.1. 数据:使用 GraphQL 访问服务</h4> <p>随着应用生态系统的演变和微服务架构的普及,许多工程组织开始意识到 REST 架构的局限性。(如果你想了解更多关于这个问题的信息,我推荐观看 Netflix 的技术视频<a href="https://netflix.github.io/falcor/starter/why-falcor.html" target="_blank"><code>netflix.github.io/falcor/starter/why-falcor.html</code></a>)。</p> <p>作为回应,Netflix 和 Facebook 都提出了解决方案。你可以将他们的实现——Falcor (<a href="https://netflix.github.io/falcor/" target="_blank"><code>netflix.github.io/falcor/</code></a>) 和 GraphQL (<a href="http://graphql.org/" target="_blank"><code>graphql.org/</code></a>)——视为所有后端服务的客户端服务。两者都允许客户端应用请求视图所需的数据,而无需了解底层实现。</p> <p>例如,假设我有一个聊天应用。在聊天过程中,该应用需要获取聊天数据和用户数据,这些数据可能位于 REST 应用的不同服务或端点上。这些框架允许你通过单个请求获取所有所需的数据。它们包含业务逻辑,指示从哪里获取数据,这样客户端应用就不需要这样做。</p> <h4 id="1332-搜索引擎优化">13.3.2. 搜索引擎优化</h4> <p>想要构建同构应用的一个主要原因是服务器端渲染你的应用页面以供搜索引擎爬虫使用。如果这是与你情况相关的理由,那么了解良好的 SEO 策略以及如何执行 SEO 的技术实现是必要的。</p> <h5 id="理解-seo-最佳实践">理解 SEO 最佳实践</h5> <p>整本书和网站都致力于 SEO 策略。如果你是 SEO 的新手或想提高你的技能,各种工具和资源可以帮助你开始:</p> <ul> <li> <p>Moz 的 SEO 101 指南 (<a href="https://moz.com/beginners-guide-to-seo" target="_blank"><code>moz.com/beginners-guide-to-seo</code></a>)</p> </li> <li> <p>Moz,一个 SEO 跟踪工具,订阅 (<a href="https://moz.com" target="_blank"><code>moz.com</code></a>)</p> </li> <li> <p>Moz Blog (<a href="https://moz.com/blog" target="_blank"><code>moz.com/blog</code></a>)</p> </li> <li> <p>Google Webmasters Blog (<a href="https://webmasters.googleblog.com" target="_blank"><code>webmasters.googleblog.com</code></a>)</p> </li> <li> <p>Google 趋势 (<a href="https://trends.google.com/trends/" target="_blank"><code>trends.google.com/trends/</code></a>)</p> </li> <li> <p>SERPs 关键词搜索工具,订阅 (<a href="https://serps.com/tools/keyword-research/" target="_blank"><code>serps.com/tools/keyword-research/</code></a>)</p> </li> </ul> <h5 id="seo-的技术实现">SEO 的技术实现</h5> <p>SEO 的更多技术方面涉及实施最佳实践和使用 Google 的跟踪工具来监控和改进 SEO。例如,构建网站地图也属于这一类别:</p> <ul> <li> <p>Google Search Console (<a href="http://www.google.com/webmasters/tools/home" target="_blank">www.google.com/webmasters/tools/home</a>)——在开始之前你必须验证你的网站属性。</p> </li> <li> <p>Schema.org(<a href="http://schema.org/docs/schemas.html" target="_blank"><code>schema.org/docs/schemas.html</code></a> 和 <a href="https://moz.com/learn/seo/schema-structured-data" target="_blank"><code>moz.com/learn/seo/schema-structured-data</code></a>)</p> </li> <li> <p>使用良好的标题实践(<a href="http://www.hobo-web.co.uk/headers/" target="_blank">www.hobo-web.co.uk/headers/</a>)</p> </li> <li> <p>内部链接(<a href="https://moz.com/learn/seo/internal-link" target="_blank"><code>moz.com/learn/seo/internal-link</code></a>)</p> </li> <li> <p>网站地图(<a href="https://moz.com/blog/xml-sitemaps" target="_blank"><code>moz.com/blog/xml-sitemaps</code></a>)</p> </li> </ul> <h4 id="1333-web-性能">13.3.3. Web 性能</h4> <p>构建同构应用的其他原因之一是为用户提供良好的感知性能。但你仍然需要遵循关于 Web 性能的最佳实践。在这个领域有许多东西要学习。</p> <p>了解性能的最佳方式是选择一个正在生产的应用并对其进行性能优化。在开始之前,确保你有方法来衡量你的 Web 应用性能(许多第三方工具可供选择——你可以尝试 Pingdom 或 New Relic)。</p> <p>这里有一些关于如何提高 Web 性能的资源:</p> <ul> <li> <p>Google 的 Lighthouse 工具(<a href="https://developers.google.com/web/tools/lighthouse/" target="_blank"><code>developers.google.com/web/tools/lighthouse/</code></a>)</p> </li> <li> <p>Web 性能最佳实践(<a href="http://www.manning.com/books/web-performance-in-action" target="_blank">www.manning.com/books/web-performance-in-action</a>)</p> </li> <li> <p>HTTP2(<a href="https://http2.github.io/0" target="_blank"><code>http2.github.io/0</code></a>)</p> </li> <li> <p>服务工作者(<a href="http://mng.bz/tLwh" target="_blank"><code>mng.bz/tLwh</code></a>)</p> </li> <li> <p>React 性能案例研究(<a href="http://mng.bz/sNeU" target="_blank"><code>mng.bz/sNeU</code></a>)</p> </li> </ul> <p>你还可以考虑研究 Preact(<a href="https://github.com/developit/preact" target="_blank"><code>github.com/developit/preact</code></a>)。这个库提供了一个“React 轻量级”实现,具有与你在 React 中学到的相同的虚拟 DOM 概念。如果你在寻找性能提升,这值得探索。</p> <h3 id="摘要-11">摘要</h3> <p>在本章中,我们回顾了几个有助于你继续提高 Web 应用技能并有助于你在生产环境中构建同构应用的专题:</p> <ul> <li> <p>去哪里寻找有关本书中主题的信息,包括 webpack 和更多同构框架。</p> </li> <li> <p>提高你的 Node.js 和 React 技能的资源。</p> </li> <li> <p>如果你构建同构 Web 应用,你可能希望在将来学习以下主题。这些包括数据服务,如 GraphQL、SEO 和基础设施技能。</p> </li> </ul> <h2 id="附录-a-react-router-4-基础知识">附录 A. React Router 4 基础知识</h2> <p><em>本附录涵盖</em></p> <ul> <li> <p>在您的组件中使用声明式路由</p> </li> <li> <p>使用可配置的路由创建单一事实来源</p> </li> <li> <p>将 React Router 3 的生命周期事件中的代码移动到高阶组件中</p> </li> <li> <p>通过使用高阶组件在浏览器中预取数据</p> </li> </ul> <p>React Router 4 代表了从 React Router 3 到的一种重大思维转变。它从在单个文件中定义路由的标准静态路由实现,转变为在 React 组件内部创建路由的动态实现。这也允许您将大多数生命周期逻辑从 React Router 的生命周期方法中移出,并放入 React 的生命周期中。让我们首先回顾一下如何将您的组件切换到使用 React Router 的去中心化路由模式。本附录中的代码示例假设您已审查了 React Router 3 版本,并想查看差异。每个列表都假设您熟悉相关章节中展示的代码。我已将新代码以粗体列出。</p> <h3 id="a1-使用-react-router-4-进行仅浏览器路由">A.1. 使用 React Router 4 进行仅浏览器路由</h3> <p>在本节中,我将向您展示如何在单页应用程序(SPA)架构中使路由工作,以便您理解 React Router 4 的基本原理。本节中示例的代码可以在<a href="http://mng.bz/zRGa" target="_blank"><code>mng.bz/zRGa</code></a>的完整同构示例仓库中找到。代码也可以在分支 chapter-4-react-router-basic-routes 中找到(<code>git checkout chapter-4-react-router-basic-routes</code>)。</p> <p>首先,您需要安装 React Router 4 包,以便为本节提供正确的代码。React Router 现在遵循 React 的约定,将 DOM 相关的代码分离到其自己的包中。请确保升级 react-router 并安装 react-router-dom:</p> <pre><code>$ npm install react-router@4.2.0 $ npm install react-router-dom </code></pre> <p>在完成此操作后,您需要在 main.jsx 中设置 React Router。</p> <h4 id="a11-创建应用程序">A.1.1. 创建应用程序</h4> <p>在 React Router 4 中实例化主路由的方式与版本 3 略有不同。不是将路由和历史传递给路由器,而是选择适合用例的路由器。在这种情况下,您想使用<code>BrowserRouter</code>以便在您的应用程序中利用浏览器历史 API。以下列表显示了如何更改 main.jsx 中的代码以与 React Router 4 一起工作。</p> <h5 id="列表-a1-设置路由器mainjsx">列表 A.1. 设置路由器—main.jsx</h5> <pre><code>import React from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter } from 'react-router-dom'; *1* import App from './components/app'; *2* ReactDOM.render( <BrowserRouter> *1* <App /> *2* </BrowserRouter>, *1* document.getElementById('react-content') ); </code></pre> <ul> <li> <p><strong><em>1</em> 使用 BrowserRouter 组件作为根组件</strong></p> </li> <li> <p><strong><em>2</em> App 应该是 BrowserRouter 的子组件。您将把路由放入 App 组件中。</strong></p> </li> </ul> <p>尽管您已实例化了路由器,但您仍需要更新您的组件以处理路由。下一节将向您展示如何做到这一点。</p> <h4 id="a12-组件中的路由">A.1.2. 组件中的路由</h4> <p>React Router 4 的基础知识与 React Router 3 类似。你使用 Route 组件来声明你的路由。你给它一个路径和一个要渲染的组件。最大的变化在于你声明路由的位置。在版本 3 中,你有一个单一的、集中的路由文件。在版本 4 中,你在适当的组件内部声明你的路由。以下列表显示了如何将 sharedRoutes.jsx 文件中的路由放入 app.jsx 中。</p> <h5 id="列表-a2-声明路由componentsappjsx">列表 A.2. 声明路由—components/app.jsx</h5> <pre><code>// ... other code import { Link, Route, withRouter } from 'react-router-dom'; *1* import Cart from '../components/cart'; *2* import Products from '../components/products'; *2* import Profile from '../components/profile'; *2* import Login from '../components/login'; *2* const App = (props) => { return ( <div> <div className="ui fixed inverted menu"> <h1 className="header item">All Things Westies</h1> <Link to="/products" className="item">Products</Link> <Link to="/cart" className="item">Cart</Link> <Link to="/profile" className="item">Profile</Link> </div> <div className="ui main text container"> <Route path="/" exact component={Products} /> *3* <Route path="/products" component={Products} /> *4* <Route path="/cart" component={Cart} /> *4* <Route path="/profile" component={Profile} /> *4* <Route path="/login" component={Login} /> *4* </div> </div> ); }; export default withRouter(App); *5* </code></pre> <ul> <li> <p><strong><em>1</em> 导入 Route 组件和 withRouter 高阶组件</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> 每个 Route 都需要一个路径和一个组件。</strong></p> </li> <li> <p><strong><em>5</em> 使用 HOC withRouter 包装 App。这确保你的组件可以访问历史和位置属性。</strong></p> </li> </ul> <p>现在你已经将路由移动到正确的位置,你的应用就可以工作了!注意,Link 组件在两个版本中工作方式相同。你通过为 Link 组件提供一个 <code>to</code> 属性来声明一个链接。</p> <h5 id="注意-30">注意</h5> <p>在 React Router 3 中,你可以使用 <code>/</code> 定义一个父路由,然后定义子路由时不需要前面的斜杠(例如,<code>products</code>)。这不再被支持。相反,在父路由上,你需要使用 <code>exact</code> 选项。对于你的根路由 <code>/</code>,你希望设置 <code>exact: true</code>。</p> <p>接下来,你将学习如何以适合同构应用的方式创建路由。</p> <h3 id="a2-创建单一的真实来源">A.2. 创建单一的真实来源</h3> <p>现在你已经在应用中使用了 React Router 4,让我们应用你在 React Router 3 中应用过的相同原则;目标是让你的路由有一个单一的真实来源。本节中所有的代码都可以在分支 chapter-4-react-router-v4 中找到(<code>git checkout chapter-4-react-router-v4</code>)。</p> <p>你需要一种方法来定义你的路由,这个路由可以在浏览器中立即使用,也可以在服务器端稍后使用。好消息是 React Router 的创建者已经创建了一个用于解决这个问题的测试版库:React Router Config (<a href="http://mng.bz/33Vu" target="_blank"><code>mng.bz/33Vu</code></a>)。这是一个提供两个函数的实用库,你可以使用这些函数来简化服务器渲染和匹配:</p> <ul> <li> <p><strong><code>matchRoutes(routes, pathname)</code>—</strong> 确定当前路由是否与提供的配置中的某个路由匹配。</p> </li> <li> <p><strong><code>renderRoutes(routes, extraProps)</code>—</strong> 将提供的路由渲染到函数被调用的组件中。此方法必须用于替代 Route 组件,以确保浏览器渲染与服务器端渲染匹配。</p> </li> </ul> <p>安装这个库,以便你可以导入它:</p> <pre><code>$ npm install react-router-config </code></pre> <p>接下来,你将使用配置对象设置你的路由,而不是直接在 App 组件中声明它们。</p> <h4 id="a21-路由作为配置">A.2.1. 路由作为配置</h4> <p>与在 App 组件中将路由声明为子组件不同,你现在将创建一个代表路由的 JavaScript 对象。与 React Router 3 一样,你将在 sharedRoutes 文件中这样做。为了区分和比较,我称此文件为 sharedRoutesv4.jsx。</p> <p>以下列表显示了构成此模块的代码。</p> <h5 id="列表-a3-路由配置sharedsharedroutesv4es6">列表 A.3. 路由配置—shared/sharedRoutesv4.es6</h5> <pre><code>import App from '../components/app'; *1* import Cart from '../components/cart'; *1* import Products from '../components/products'; *1* import Profile from '../components/profile'; *1* import Login from '../components/login'; *1* const routes = [ { component: App, *2* routes: [ { path: '/', *3* exact: true, *4* component: Products }, { path: '/cart', *5* component: Cart }, { path: '/products', *5* component: Products }, { path: '/profile', *6* component: Profile }, { path: '/login', *6* component: Login } ] } ] export default routes; </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> 在 App 组件的路由数组中声明子路由</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> <p>现在你已经声明了你的路由,你需要使用这个路由配置而不是上一节中的 Route 组件。</p> <h4 id="a22-在组件中配置路由">A.2.2. 在组件中配置路由</h4> <p>使用 React Router Config 库,你可以轻松地将你的应用程序配置为使用上一节中的路由。为了使一切正常工作,你需要在 main.jsx 和你的顶级 App 组件中调用此库中的 <code>renderRoutes</code> 函数。以下列表显示了如何在 main.jsx 中声明此函数。</p> <h5 id="列表-a4-使用配置的路由mainjsx">列表 A.4. 使用配置的路由—main.jsx</h5> <pre><code>import from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter } from 'react-router-dom'; import { renderRoutes } from 'react-router-config'; *1* import routes from './shared/sharedRoutesv4.es6'; *2* ReactDOM.render( <BrowserRouter> { renderRoutes(routes) } *3* </BrowserRouter>, document.getElementById('react-content') ); </code></pre> <ul> <li> <p><strong><em>1</em> 导入 renderRoutes</strong></p> </li> <li> <p><strong><em>2</em> 导入路由配置对象</strong></p> </li> <li> <p><strong><em>3</em> 调用 renderRoutes 并传入路由配置。这替换了 App 组件的声明。</strong></p> </li> </ul> <p>接下来,你还需要在 App 组件内部声明这些路由——每次你有具有子路由的组件时,你都需要声明路由。这替换了之前你做的 Route 组件声明。以下列表显示了如何更新代码。</p> <h5 id="列表-a5-使用配置的路由componentsappjsx">列表 A.5. 使用配置的路由—components/app.jsx</h5> <pre><code>import { Link } from 'react-router-dom'; import { renderRoutes } from 'react-router-config'; *1* const App = (props) => { return ( <div> // ...more code <div className="ui main text container"> { renderRoutes( props.route.routes, *2* { history: props.history } *3* ) } </div> </div> ); }; </code></pre> <ul> <li> <p><strong><em>1</em> 导入 renderRoutes</strong></p> </li> <li> <p><strong><em>2</em> 调用 renderRoutes。通过属性传入提供的路由对象,因为你在 main.jsx 中声明了它们。</strong></p> </li> <li> <p><strong><em>3</em> 将历史对象作为属性传递,以便子组件在需要时可以访问。</strong></p> </li> </ul> <p>到目前为止,你的路由应该按预期工作。接下来,我们将回顾 React 生命周期的更改。</p> <h3 id="a3-处理生命周期事件">A.3. 处理生命周期事件</h3> <p>React Router 3 和 4 之间的另一个主要变化是处理生命周期事件的方式。在 React Router 3 中,Router 有它自己的独立生命周期。你必须管理 React 的生命周期和 React Router 的生命周期,这通常变得很复杂。你在 sharedRoutes 文件中添加了代码来处理生命周期中的特定点的代码。现在你需要以不同的方式来做这件事。</p> <p>在 React Router 4 中,你使用 React 的生命周期根据路由变化来进行更新。一种促进这种更新方式的好方法是创建一个高阶组件(HOC)。</p> <h4 id="a31-使用高阶组件来管理路由更改">A.3.1. 使用高阶组件来管理路由更改</h4> <p>要创建自己的高阶组件,你创建一个返回 React 组件的函数。然后你可以挂钩到 React 生命周期并根据路由更改进行更新。以下列表展示了如何做这件事。它包括第四章的示例,其中在每次更新时添加页面跟踪。</p> <h5 id="列表-a6-onroutechange-高阶组件componentsonroutechangejsx">列表 A.6. <code>onRouteChange</code> 高阶组件—components/onRouteChange.jsx</h5> <pre><code>const onRouteChange = (WrappedComponent) => { *1* return class extends React.PureComponent { trackPageView() { *2* // In real life you would hook this up to your analytics tool of choice console.log('Tracked a pageview'); }; componentDidMount() { this.trackPageView(); *3* } componentWillReceiveProps(nextProps) { const navigated = nextProps.location !== this.props.location; *4* if (navigated) { this.trackPageView(); *5* } } render() { return <WrappedComponent {...this.props} />; } } } </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> 在 componentDidMount 中调用 trackPageView 函数。这替换了旧 sharedRoutes 文件中的 onEnter 调用。</strong></p> </li> <li> <p><strong><em>4</em> 检查路由是否已更改</strong></p> </li> <li> <p><strong><em>5</em> 如果路由已更改,则调用 trackPageView。在 componentWillReceiveProps 中调用它替换了旧 sharedRoutes 文件中的 onChange 调用。</strong></p> </li> </ul> <p>在创建了这个高阶组件(HOC)之后,你需要在根组件 App 中使用它。以下列表展示了如何导入和应用它。</p> <h5 id="列表-a7-使用配置的路由componentsappjsx">列表 A.7. 使用配置的路由—components/app.jsx</h5> <pre><code>// no longer using withRouter HOC import onRouteChange from './onRouteChange'; *1* const App = (props) => {}; // no longer using withRouter HOC export default onRouteChange(App); *2* </code></pre> <ul> <li> <p><strong><em>1</em> 导入 onRouteChange</strong></p> </li> <li> <p><strong><em>2</em> 为了让 onRouteChange HOC 管理你的路由,你需要用它在根组件周围包裹。</strong></p> </li> </ul> <p>现在你在 <code>onRouteChange</code> 中包裹了根组件,你可以轻松地添加代码来在应用程序中导航时预取数据。</p> <h4 id="a32-预取视图数据">A.3.2. 预取视图数据</h4> <p>第八章 介绍了如何在浏览器中导航网站时预取数据。随着 React Router 4 版本中生命周期事件的移除,你需要采取不同的方法。这和之前章节中处理页面跟踪事件的方式相同。</p> <p>这个示例的代码可以在 GitHub 上找到(<code>git checkout chapter-8-complete-react-router-4</code>)。</p> <p>以下列表展示了如何在导航不同路由之间时在浏览器中预取数据。它使用了之前章节中创建的 <code>onRouteChange</code> HOC。</p> <h5 id="列表-a8-预取数据componentsonroutechangejsx">列表 A.8. 预取数据—components/onRouteChange.jsx</h5> <pre><code>import { matchRoutes } from 'react-router-config'; *1* import routes from '../shared/sharedRoutesv4.es6'; *1* const onRouteChange = (WrappedComponent) => { return class extends React.PureComponent { // ... other code fetchData(nextProps) { const { route, location } = nextProps; const { routes } = route; const matches = matchRoutes( routes, location.pathname ); *2* let results = matches.map(({match, route}) => { *3* const component = route.component; if (component) { *4* if (component.displayName && component.displayName.toLowerCase().indexOf('connect') > -1 ) { let parentComponent = component.WrappedComponent; if (parentComponent.prefetchActions) { return parentComponent.prefetchActions ( location.pathname.substring(1) ); } else if (parentComponent.wrappedComponent && parentComponent.wrappedComponent().prefetchActions ) { *5* return parentComponent.wrappedComponent().prefetchActions ( location.pathname.substring(1) ); } } else if (component.prefetchActions) { return component.prefetchActions ( location.pathname.substring(1) ) } } return []; }); const actions = results.reduce(( flat, toFlatten ) => { *6* return flat.concat(toFlatten); }, []); const promises = actions.map((initialAction) => { *7* return this.props.dispatch(initialAction()); }); Promise.all(promises); *8* } componentDidMount() {} componentWillReceiveProps(nextProps) { this.fetchData(nextProps); *9* //... other code } </code></pre> <ul> <li> <p><strong><em>1</em> 导入 matchRoutes 以提取当前路由中使用的所有组件。从 sharedRoutesv4 中配置的路由获取路由。</strong></p> </li> <li> <p><strong><em>2</em> 使用 props 中的路由和位置调用 matchRoutes</strong></p> </li> <li> <p><strong><em>3</em> 遍历 matches 数组中的每个项目。这代表在这个路由上渲染的每个组件——在 sharedRoutesv4 中声明的任何内容。</strong></p> </li> <li> <p><strong><em>4</em> 这个块几乎与 第七章 中的相同。你寻找基本组件,如果函数存在,则调用 prefetchActions。</strong></p> </li> <li> <p><strong><em>5</em> 主要区别是你必须处理将 App 包裹在两个 HOC 中的情况。</strong></p> </li> <li> <p><strong><em>6</em> 将获取动作的结果扁平化成一个单一数组</strong></p> </li> <li> <p><strong><em>7</em> 通过将它们传递到 Redux 的 dispatch 函数中,准备执行动作</strong></p> </li> <li> <p><strong><em>8</em> 调用动作</strong></p> </li> <li> <p><strong><em>9</em> 你在 componentWillReceiveProps 中调用 fetchData 函数,所以除了初始加载外,它将每次运行。</strong></p> </li> </ul> <p>因为你在前面的代码中使用了<code>dispatch</code>,所以你需要将 App 组件包裹在 connect 高阶组件(HOC)中。下面的列表显示了如何做到这一点。</p> <h5 id="列表-a9-使用配置的路由组件appjsx">列表 A.9. 使用配置的路由—组件/app.jsx</h5> <pre><code>import { connect } from 'react-redux'; *1* const App = (props) => {}; export default connect()(onRouteChange(App)); *2* </code></pre> <ul> <li> <p><strong><em>1</em> 导入 connect</strong></p> </li> <li> <p><strong><em>2</em> 要在 onRouteChange 中访问 dispatch 函数,你必须首先将其包裹在 connect 高阶组件(HOC)中。</strong></p> </li> </ul> <p>现在应用将运行!它会在需要时预取数据。尝试从/products 导航到/cart 来查看这一功能的效果。</p> <h2 id="附录-b-服务器端-react-router">附录 B. 服务器端 React Router</h2> <p>从同构应用的角度来看,迁移到 React Router 4 的难点之一是维护者和整个社区尚未就数据预取模式达成一致的最佳实践。你会在文档中反复看到这种观点;例如,React Router Config 的 README 文件中提到:“有很多人实现带有数据和待处理导航的服务器端渲染的方法,我们还没有确定一个。”</p> <p>第七章 教你如何在服务器上预取数据,以便在渲染时可用。本附录展示了如何使用 React Router 4 来实现这一点。示例代码可以在 <a href="http://mng.bz/S3N0" target="_blank"><code>mng.bz/S3N0</code></a> 的仓库中找到,在分支 chapter-7-complete-react-router-4 (<code>git checkout chapter-7-complete-react-router-4</code>)。</p> <p>React Router 4 允许你在渲染之前在服务器上预取数据。在我建议的附录 A 中,你使用 React Router Config 的 <code>matchRoutes</code> 来在浏览器中预取数据。你同样在服务器上做同样的事情!这允许你在服务器和浏览器上保持一致性,因为你通过库中提供的实用函数使用相同的方法。这个函数是同步的,与 React Router 3 中的异步路由匹配函数不同。(如果你想要你的 Node.js 应用保持一致性,你可以总是将其包装以返回异步。)以下列表显示了如何更新代码以与 React Router 4 一起工作。</p> <h5 id="列表-b1-服务器端数据获取中间件renderviewjsx">列表 B.1. 服务器端数据获取——中间件/renderView.jsx</h5> <pre><code>//...other code import { StaticRouter } from 'react-router'; *1* import { matchRoutes, renderRoutes } from 'react-router-config'; *2* import routes from '../shared/sharedRoutesv4'; *2* export default function renderView(req, res, next) { const matches = matchRoutes(routes, req.path); *3* const context = {}; *4* if (matches) { const store = initRedux(); let actions = []; matches.map(({match, route}) => { *5* const component = route.component; if (component) { if (component.displayName && component.displayName.toLowerCase().indexOf('connect') > -1 ) { let parentComponent = component.WrappedComponent; if (parentComponent.prefetchActions) { actions.push(parentComponent.prefetchActions()); } else if (parentComponent.wrappedComponent && parentComponent.wrappedComponent().prefetchActions) { *6* actions.push(parentComponent.wrappedComponent().prefetchActions()); } } else if (component.prefetchActions) { actions.push(component.prefetchActions()); } } }); actions = actions.reduce((flat, toFlatten) => { return flat.concat(toFlatten); }, []); const promises = actions.map((initialAction) => { return store.dispatch(initialAction()); }); Promise.all(promises).then(() => { const app = renderToString( <Provider store={store}> <StaticRouter location={req.url} context={context}> *7* { renderRoutes(routes) } *8* </StaticRouter> </Provider> ); if (!context.url) { *9* const html = renderToString(<HTML renderedToStringComponents={app} / >); res.send(`<!DOCTYPE html>${html}`); } }); } else { next(); } } </code></pre> <ul> <li> <p><em><strong>1</strong></em> 导入依赖项</p> </li> <li> <p><em><strong>2</strong></em> 导入依赖项</p> </li> <li> <p><em><strong>3</strong></em> 调用 matchRoutes 以找出正在加载哪些组件</p> </li> <li> <p><em><strong>4</strong></em> 创建一个上下文对象,用于确定是否存在路由重定向</p> </li> <li> <p><em><strong>5</strong></em> 遍历匹配项以便你可以调用预取动作函数。这个方法的大部分内容与第七章相同。第七章。</p> </li> <li> <p><em><strong>6</strong></em> 这是代码中的主要区别。因为 App 被两个高阶组件(HOCs)包裹,所以你必须向下检查两级才能访问根组件。</p> </li> <li> <p><em><strong>7</strong></em> 在服务器上使用 StaticRouter 而不是 BrowserRouter,因为你不需要历史记录。传递位置和上下文对象。</p> </li> <li> <p><em><strong>8</strong></em> 正如 main.jsx 中的那样调用 renderRoutes</p> </li> <li> <p><em><strong>9</strong></em> 检查上下文以确保没有重定向</p> </li> </ul> <h2 id="附录-c-react-router-4-的附加用法">附录 C. React Router 4 的附加用法</h2> <p><em>本附录涵盖</em></p> <ul> <li> <p>在 React Router 4 中处理第三方库,如分析模块</p> </li> <li> <p>在运行时动态添加路由</p> </li> <li> <p>使用 webpack 和 React Router 4 进行代码拆分</p> </li> </ul> <p>第十章 和 第十一章 展示了如何执行一些会影响 React Router 代码和应用程序其余部分协同工作方式的事情。本附录通过 React Router 4 讲解了这些示例中的几个。</p> <p>本附录中的所有示例都位于主示例仓库的 react-router-4 分支(<code>git checkout react-router-4</code>)中,<a href="http://mng.bz/S3N0" target="_blank"><code>mng.bz/S3N0</code></a>。此分支中的代码也已更新,以便您可以查看 React Router 4 与代码最终版本协同工作的完整示例。这基于附录 A(kindle_split_027_split_000.xhtml#app01)和附录 B(kindle_split_028.xhtml#app02)中展示的代码更改。</p> <h3 id="c1-将分析移动到-onroutechange">C.1. 将分析移动到 onRouteChange</h3> <p>您需要做的第一个更新是将分析代码移动到 <code>onRouteChange</code> HOC。之前,这由 React Router 3 的生命周期方法处理。现在您将像处理页面跟踪和预取数据一样在 附录 A 中处理它。以下列表演示了如何进行此操作。此更改来自 第十章,第 10.1 节。</p> <h5 id="列表-c1-在-onroutechange-中添加分析componentsonroutechangejsx">列表 C.1. 在 <code>onRouteChange</code> 中添加分析—components/onRouteChange.jsx</h5> <pre><code>import { sendData } from '../analytics.es6'; *1* const onRouteChange = (WrappedComponent) => { return class extends React.PureComponent { trackPageView(location) { *2* this.sendAnalytics(location); *2* console.log('Tracked a pageview'); }; fetchData(nextProps) {} sendAnalytics(location) { *3* sendData({ *3* location: location && location.pathname, type: 'navigation' }); } componentDidMount() { this.trackPageView(this.props.location); *4* } componentWillReceiveProps(nextProps) { //...other code if (navigated) { this.trackPageView(nextProps.location); *4* } } } } </code></pre> <ul> <li> <p><strong><em>1</em> 从分析模块导入 sendData</strong></p> </li> <li> <p><strong><em>2</em> 将位置传递给函数。调用 sendAnalytics。</strong></p> </li> <li> <p><strong><em>3</em> 创建一个调用 sendData 的 sendAnalytics 函数。将位置传递给它。</strong></p> </li> <li> <p><strong><em>4</em> 每次路由更改时调用 trackPageView。确保传递正确的位置。</strong></p> </li> </ul> <p>从 <code>componentWillReceiveProps</code> 中调用 <code>sendAnalytics</code> 替换了 <code>sharedRoutes</code> 中的 <code>onEnter</code> 调用。此外,从 <code>componentDidMount</code> 中调用 <code>sendAnalytics</code> 也替换了 <code>sharedRoutes</code> 中的 <code>onEnter</code> 调用。</p> <h3 id="c2-添加动态路由">C.2. 添加动态路由</h3> <p>第十章 还演示了如何根据环境变量或其他标志添加动态路由。要在新的 sharedRoutesv4 文件中这样做,您需要在导出之前更新路由数组。以下列表显示了如何进行此操作。</p> <h5 id="列表-c2-添加动态路由sharedroutesv4es6">列表 C.2. 添加动态路由—sharedRoutesv4.es6</h5> <pre><code>const routes = [ { component: App, routes: [ //...other code ] } ] if (process.env.NODE_ENV !== 'production') { *1* routes[0].routes.push({ *2* path: '/dev-test', component: Products }); } export { routes }; export default routes; </code></pre> <ul> <li> <p><strong><em>1</em> 检查环境。如果不是生产环境,则添加此仅限开发的路由。</strong></p> </li> <li> <p><strong><em>2</em> 将新路由推入路由数组。记住,路由数组是路由对象中根 App 组件的子组件。</strong></p> </li> </ul> <p>现在您在开发中有一个额外的路由了!</p> <h3 id="c3-代码拆分react-loadable">C.3. 代码拆分:React Loadable</h3> <p>最后一个涉及 React Router 的例子是来自第十一章(kindle_split_023_split_000.xhtml#ch11)的代码拆分。好消息是,在 React Router 4 中这要容易一些。社区在这方面确实已经迎头赶上,并提供了一个库来处理您所有的代码拆分需求。这个库叫做 React Loadable,它易于使用且文档齐全。</p> <p>React 社区考虑到了方方面面,包括处理同构应用。他们在 <a href="http://mng.bz/3hJg" target="_blank"><code>mng.bz/3hJg</code></a> 提供了良好的文档。我这里不会重复所有他们的例子。</p> <p>关于 React Loadable 的一些有趣之处:</p> <ul> <li> <p>您可以提供一个在组件加载时显示的组件(<a href="http://mng.bz/518M" target="_blank"><code>mng.bz/518M</code></a>)。</p> </li> <li> <p>该库提供了一种预加载组件的选项(这在服务器上总是被使用),并且可以在浏览器中进行乐观加载(<a href="http://mng.bz/081O" target="_blank"><code>mng.bz/081O</code></a>)。</p> </li> <li> <p>而不是安装所有正确的 Babel 插件并正确设置 webpack,该库为您处理所有这些。它提供了一个 Babel 包供您使用(<a href="http://mng.bz/91mV" target="_blank"><code>mng.bz/91mV</code></a>)。它还附带了一个 webpack 插件和用于服务器端渲染的相关实用工具(<a href="http://mng.bz/34vL" target="_blank"><code>mng.bz/34vL</code></a>)。</p> </li> </ul> <h2 id="同构应用程序流程">同构应用程序流程</h2> <p>此图展示了将同构应用渲染并响应用户输入所涉及的所有步骤。它在书中出现多次,当前章节涵盖的概念被突出显示。</p> <p><img src="https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/iso-webapp/img/ifcfig01_alt.jpg" alt="图片描述" loading="lazy"></p> <h2 id="同构-web-应用的最佳实践">同构 Web 应用的最佳实践</h2> <table> <thead> <tr> <th>最佳实践</th> <th>部分</th> </tr> </thead> <tbody> <tr> <td>---</td> <td>---</td> </tr> <tr> <td>设置 webpack 以准备生产使用。5.5.2</td> <td></td> </tr> <tr> <td>构建可组合的 React 组件,提高代码的可重用性。4.3.1, 4.3.2</td> <td></td> </tr> <tr> <td>使用扩展运算符(...)模式确保在使用 Redux 时你的状态是不可变的。6.3.1</td> <td></td> </tr> <tr> <td>使用 React Router 为服务器实现路由。7.2.1</td> <td></td> </tr> <tr> <td>使用 React 的 renderToString 在服务器上渲染组件。7.2.3</td> <td></td> </tr> <tr> <td>使用静态函数来指示应用中每个容器组件所需的数据。7.3.2</td> <td></td> </tr> <tr> <td>序列化在服务器上获取的数据,并将其附加到 DOM 上。8.2.1</td> <td></td> </tr> <tr> <td>解析 DOM 中提供的数据,并使用它来初始化应用的状态。8.2.2</td> <td></td> </tr> <tr> <td>通过使用 React 的生命周期方法 componentDidMount 来避免同构渲染警告。8.3.3</td> <td></td> </tr> <tr> <td>确保你在所有将运行代码的环境中测试你的代码(服务器和浏览器)。9.1.1, 9.2.1, 9.2.2</td> <td></td> </tr> <tr> <td>使用环境变量来隔离只能在浏览器中运行的代码。Webpack 允许你将它们注入到你的浏览器代码中(process.env.BROWSER)。10.1.2, 10.1.3</td> <td></td> </tr> <tr> <td>为了管理 SEO,使用静态函数向服务器上 HTML 页面的<head>部分添加元标签。10.2.1, 10.2.2</td> <td></td> </tr> <tr> <td>以同构方式基于 cookie 处理用户会话。11.4.1</td> <td></td> </tr> </tbody> </table>


浙公网安备 33010602011771号