MERN 技术栈高级教程(一)

原文:Pro MERN Stack

协议:CC BY-NC-SA 4.0

一、简介

Web 应用开发已经今非昔比,甚至几年前也是如此。今天,有这么多的选择,门外汉往往不知道什么对他们有好处。有很多选择;不仅仅是广泛的(所使用的各种层或技术),还包括用于帮助开发的工具。这本书声称 MERN 堆栈对于开发一个完整的 web 应用是非常棒的,并带领读者完成所有必要的工作。

在这一章中,我将对 MERN 堆栈所包含的技术进行概述。在这一章中,我不会详细介绍细节或例子,相反,我将只介绍高层次的概念。我将关注这些概念如何影响对 MERN 是否是您下一个 web 应用项目的好选择的评估。

MERN 是什么?

任何 web 应用都是使用多种技术构建的。这些技术的组合被称为“堆栈”,因 LAMP stack 而流行,LAMP stack 是 Linux、Apache、MySQL、PHP 的缩写,这些都是开源软件。随着 web 开发的成熟和交互性的出现,单页面应用变得越来越流行。SPA 是一种 web 应用范例,它避免了从服务器获取整个 web 页面的内容来显示新内容。相反,它使用对服务器的轻量级调用来获取一些数据或片段,并更改网页。与完全重新加载页面的旧方法相比,结果看起来相当漂亮。这带来了前端框架的兴起,因为很多工作都是在前端完成的。几乎在同一时间,虽然完全不相关,NoSQL 数据库也开始流行起来。

MEAN (MongoDB,Express,AngularJS,Node.js)栈是早期的开源栈之一,集中体现了向 SPAs 和 NoSQL 的转变。基于模型视图控制器(MVC)设计模式的前端框架 AngularJS 锚定了这个堆栈。MongoDB 是一个非常流行的 NoSQL 数据库,用于持久数据存储。Node.js 是一个服务器端 JavaScript 运行时环境,Express 是一个构建在 Node.js 之上的 web 服务器,它们构成了中间层,即 web 服务器。几年前,这种堆栈可以说是任何新的 web 应用最流行的堆栈。

不完全竞争,但 React 是脸书创造的替代前端技术,越来越受欢迎,并提供了 AngularJS 的替代方案。因此,它将 MEAN 中的“A”替换为“R ”,得到 MERN 堆栈。我说“不完全是”,因为 React 不是一个成熟的 MVC 框架。它是一个用于构建用户界面的 JavaScript 库,所以在某种意义上,它是 MVC 的视图部分。

尽管我们选择了一些定义技术来定义一个堆栈,但这些不足以构建一个完整的 web 应用。需要其他工具来帮助开发过程,并且需要许多库来补充 React。这本书是关于基于 MERN 堆栈和所有这些相关的工具和库来构建一个完整的 web 应用。

谁应该读这本书

除了 MERN 堆栈之外,有任何 web 应用堆栈经验的开发人员和架构师会发现这本书对于了解这种现代堆栈很有用。要求事先了解 web 应用如何工作。还需要 JavaScript 知识。还假设读者了解 HTML 和 CSS 的基础知识。如果你也熟悉版本控制工具 git,那会有很大帮助;您可以通过克隆保存了本书中描述的所有源代码的 git 存储库,并通过检查一个分支来运行每个步骤,来试验代码。

书中的代码使用了 JavaScript (ES2015+)的最新特性,并且假设你对这些特性如类、胖箭头函数、const关键字等非常熟悉。每当我第一次使用这些现代 JavaScript 特性时,我会用注释指出来,这样您就知道这是一个新特性。如果你不熟悉某个特定的特性,你可以在遇到它的时候仔细阅读。

如果你已经决定你的新应用将使用 MERN 堆栈,那么这本书是一个完美的推动者,让你快速起步。即使你没有,读这本书也会让你对 MERN 感到兴奋,并让你有足够的知识为未来的项目做出选择。你将学到的最重要的东西是把多种技术放在一起,构建一个完整的、功能性的 web 应用,你可以被称为 MERN 的全栈开发者或架构师。

这本书的结构

虽然这本书的重点是让您学习如何构建一个完整的 web 应用,但这本书的大部分内容都围绕 React 展开。这只是因为,就像大多数现代水疗中心一样,前端代码构成了主体。在这种情况下,React 用于前端。

这本书的基调是教程式的,是为边做边学而设计的。在本书的过程中,我们将构建一个 web 应用。我使用“我们”这个术语,因为您将需要编写代码,就像我向您展示将作为大量代码清单的一部分编写的代码一样。除非你和我一起自己写代码,并解决练习,否则你不会得到这本书的全部好处。我鼓励你而不是复制粘贴;相反,请键入代码。我发现这在学习过程中非常有价值。非常小的细微差别(例如,引号的类型)可能会造成很大的差异。当你输入代码时,你会比仅仅阅读代码时更能意识到这一点。

有时,你可能会遇到你输入的东西不工作的情况。在这种情况下,您可能希望复制粘贴以确保代码是正确的,并克服您可能犯下的任何打字错误。在这种情况下,不要从书的电子版本中复制粘贴而不是,因为排版可能不符合实际代码。我在 https://github.com/vasansr/pro-mern-stack-2 创建了一个 GitHub 库,供你比较,在不可避免的情况下,可以复制粘贴。

我还在每一个可以单独测试的更改后添加了一个检查点(实际上是一个 git 分支),这样您就可以在线查看两个检查点之间的确切差异。存储库的主页(自述文件)中列出了检查点和到 diffs 的链接。您可能会发现这比查看整个源代码,甚至是本书正文中的清单更有用,因为 GitHub 的差异远比我在印刷品中展示的更有表现力。

我采用了一种更实际、更能解决问题的方法,而不是每一节都涵盖一个主题或技术。到本书结束时,我们将已经开发出一个成熟的工作应用,但是我们将从一个 Hello World 示例开始。就像在一个真实的项目中一样,随着我们的进展,我们将为应用添加更多的功能。当我们这样做时,我们会遇到需要额外的概念或知识才能继续的任务。对于其中的每一个,我将介绍可以使用的概念或技术,我将详细讨论这一点。

因此,您可能不会发现每一章或每一节都专门针对一个主题或技术。一些章节可能集中在一项技术上,而其他章节可能针对我们希望在应用中实现的一系列目标。随着我们的进步,我们将在技术和工具之间切换。

我已经尽可能地包括练习,这使你要么思考,要么在互联网上查找各种资源。这是为了让您知道在哪里可以获得本书中没有涉及的其他信息,通常是非常高级的主题或 API。

我选择了一个问题跟踪应用作为我们将一起构建的应用。这是大多数开发人员都能理解的,同时具有任何企业应用都会有的许多属性和要求,通常称为“CRUD”应用(CRUD 代表数据库记录的创建、读取、更新和删除)。

约定

书中使用的许多约定非常明显,所以我不会一一解释。我将只讨论一些关于如何构建各部分以及如何显示代码更改的约定,因为这不是很明显。

每章有多个部分,每个部分都致力于一组代码更改,这些代码更改会产生一个可以运行和测试的工作应用。一个部分可以有多个清单,但是它们中的每一个都不能单独测试。每个部分在 GitHub 存储库中都会有一个相应的条目,在那里您可以看到该部分结束时应用的完整源代码,以及前一部分和当前部分之间的差异。您会发现 difference 视图对于识别该部分中所做的更改非常有用。

所有代码更改将出现在该部分的列表中,但请不要依赖它们的准确性。可以在 GitHub 资源库中找到可靠且有效的代码,这些代码甚至可能在最后一刻发生了变化,无法及时印刷出版。所有列表都有一个列表标题,其中包括被更改或创建的文件的名称。

您可以使用 GitHub 资源库来报告印刷书籍中的问题。但是在你这样做之前,一定要检查现有的问题列表,看看是否有其他人报告了同样的问题。我会监控这些问题并发布解决方案,如果有必要,还会在 GitHub 库中更正代码。

如果一个列表包含一个文件、一个类、一个函数或一个完整的对象,那么它就是一个完整的列表。一个完整的列表也可以包含两个或更多的类、函数或对象,但不能包含多个文件。在这种情况下,如果实体不连续,我将使用省略号来表示未更改的代码块。

清单 1-1 是一个完整清单的例子,是整个文件的内容。

const express = require('express');

const app = express();
app.use(express.static('static'));

app.listen(3000, function () {
  console.log('App started on port 3000');
});

Listing 1-1.server.js: Express Server

另一方面,部分列表不会列出完整的文件、函数或对象。它将以省略号开始和结束,中间可能会有省略号以跳过未更改的代码块。添加的新代码将以粗体突出显示,未更改的代码将以正常字体显示。清单 1-2 是部分清单的一个例子,有一些小的增加。

...
  "scripts": {
    "compile": "babel src --presets react --out-dir static",
    "watch": "babel src --presets react --out-dir static --watch",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

Listing 1-2.package.json: Adding Scripts for Transformation

删除的代码将使用删除线显示,如清单 1-3 所示。

...
  "devDependencies": {
    "babel-polyfill": "⁶.13.0",
    ...
  }
...

Listing 1-3.package.json: Changes for Removing Polyfill

代码块在常规文本中用于提取代码中的变化以供讨论,通常是清单中代码的重复。这些不是清单,通常只有一两行。下面是一个例子,从清单中提取一行,突出显示一个单词:

...
const contentNode = ...
...

所有需要在控制台上执行的命令都是以$开头的代码块形式。这里有一个例子:

$ npm install express

书中使用的所有命令也可以在 GitHub 库的一个名为commands.md的文件中找到。这是为了在图书出版后纠正图书中的错误,同时也是一个更可靠的复制粘贴来源。同样,我们鼓励您不要复制粘贴这些命令,但是如果您因为发现某些东西不起作用而被迫这样做,那么请从 GitHub 库复制粘贴,而不是从书中的文本复制粘贴。

在本书后面的章节中,代码将被拆分到两个项目或目录中。为了区分应该在哪个目录下发出命令,命令块将以cd开始。例如,要在名为api的目录中执行一个命令,将使用以下命令:

$ cd api
$ npm install dotenv@6

所有需要在 MongoDB shell 中执行的命令都是以>开头的代码块形式。例如:

> show collections

这些命令也收集在一个文件中,在 GitHub 存储库中称为mongoCommands.md

你需要什么

您将需要一台能够运行您的服务器并执行其他任务(如编译)的计算机。您还需要一个浏览器来测试您的应用。我推荐一台基于 Linux 的计算机,比如 Ubuntu,或者一台 Mac 作为你的开发服务器,但是稍加改动,你也可以使用 Windows PC。

直接在 Windows 上运行 Node.js 也可以,但是本书中的代码示例假设是基于 Linux 的 PC 或 Mac。如果您选择直接在 Windows PC 上运行,您可能需要进行适当的更改,特别是在 shell 中运行命令时,使用副本而不是使用软链接,在极少数情况下,还要处理路径分隔符中的\/

一种选择是尝试使用 vagger(https://www.vagrantup.com/)运行一个 Ubuntu 服务器虚拟机(VM)。这很有帮助,因为您最终将在基于 Linux 的服务器上部署您的代码,并且最好从一开始就习惯这种环境。但是你可能会发现编辑文件很难,因为在 Ubuntu 服务器中,你只有一个控制台。Ubuntu 桌面虚拟机可能更适合你,但是它需要更多的内存。

此外,为了保持本书的简洁,我没有包括软件包的安装说明,它们对于不同的操作系统是不同的。您需要遵循软件包提供商网站上的安装说明。在许多情况下,我没有包括网站的直接链接,我请你去看看。这是由于几个原因。首先是让你自己学习如何搜索这些。第二,由于在写这本书的时候 MERN 堆栈正在经历的快速变化,我提供的任何链接可能已经转移到另一个位置。

MERN 组件

我将简要介绍构成 MERN 堆栈的主要组件,以及我们将用来构建 web 应用的其他一些库和工具。我将只谈一些突出的特点,而把细节留给更适合的其他章节。

React

React 锚定 MERN 堆栈。在某种意义上,这是 MERN 堆栈的定义组件。

React 是一个由脸书维护的开源 JavaScript 库,可用于创建以 HTML 呈现的视图。与 AngularJS 不同,React 不是一个框架。这是一个图书馆。因此,它本身并没有规定一个框架模式,比如 MVC 模式。您使用 React 来呈现视图(MVC 中的 V ),但是如何将应用的其余部分连接在一起完全取决于您。

不仅仅是脸书本身,还有许多其他公司在生产中使用 React,如 Airbnb、Atlassian、Bitbucket、Disqus、Walmart 等。GitHub 知识库上的 120,000 颗星星表明了它的受欢迎程度。

我将讨论 React 的几个突出特点。

为什么脸书发明了 React

脸书的人构建了 React 供自己使用,后来他们将其开源。现在,他们为什么要建立一个新的图书馆,而外面有成吨的图书馆呢?

React 并不是诞生于我们都看到的脸书应用,而是诞生于脸书的广告组织。最初,他们使用典型的客户端 MVC 模型,该模型具有所有常规的双向数据绑定和模板。视图会监听模型的变化,并通过更新自己来响应这些变化。

很快,随着应用变得越来越复杂,这变得非常棘手。将会发生的情况是,一个更改将导致一个更新,这将导致另一个更新(因为由于那个更新而发生了一些更改),这将导致另一个更新,以此类推。这种级联更新变得难以维护,因为根据更新的根本原因,更新视图的代码会有细微的差别。

然后他们想,当在视图中描述模型的所有代码都已经存在时,为什么我们还需要处理所有这些呢?我们不是通过添加越来越小的代码片段来管理转换来复制代码吗?为什么我们不能使用模板(也就是视图)本身来管理状态变化?

从那时起,他们开始考虑构建一些声明性的东西,而不是 ?? 命令性的东西。

宣言的

React 视图是声明性的。这实际上意味着,作为程序员,您不必担心管理视图状态或数据变化的影响。换句话说,您不必担心视图状态的变化导致的 DOM 转换或突变。声明性使得视图一致、可预测、更容易维护、更容易理解。处理过渡是别人的问题。

这是如何工作的?让我们比较一下 React 和传统方法(比如使用 jQuery)的工作原理。

给定数据,React 组件声明视图的外观。当数据发生变化时,如果您习惯了 jQuery 的工作方式,通常会进行一些 DOM 操作。例如,如果在一个表中插入了一个新行,您可以创建 DOM 元素并使用 jQuery 插入它。但不是在 React。你什么都不做!React 库计算出新视图的外观并呈现出来。

这样不会太慢吗?它不会导致每次数据更改时刷新整个屏幕吗?React 使用其虚拟 DOM 技术来处理这个问题。您声明视图的外观,React 用它构建一个虚拟表示,一个内存中的数据结构。我将在第二章中讨论更多,但是现在,只要把虚拟 DOM 看作一种中间表示,介于 HTML 和实际 DOM 之间。

当事情发生变化时,React 会基于新的事实(状态)构建一个新的虚拟 DOM,并将其与旧的(事情发生变化之前的)虚拟 DOM 进行比较。React 然后计算旧的和更改后的虚拟 DOM 之间的差异,然后将这些更改应用到实际的 DOM。

与用 jQuery 方式执行的手动更新相比,这只会增加很少的开销,因为计算虚拟 DOM 中差异的算法已经得到了最大程度的优化。因此,我们可以两全其美:不必担心实现转换,也不必担心最小变化的性能。

基于组件的

React 的基本构建块是一个维护自身状态并呈现自身的组件。

在 React 中,您所做的只是构建组件。然后,将组件放在一起,组成另一个组件来描述一个完整的视图或页面。组件封装了数据和视图的状态,或者它是如何呈现的。这使得整个应用的编写和推理变得更加容易,因为它被分割成多个组件,并且一次只关注一件事情。

组件通过以只读属性的形式将状态信息共享给它们的子组件,并通过回调它们的父组件来相互通信。我将在后面的章节中更深入地探讨这个概念,但是它的要点是 React 中的组件是非常内聚的,但是彼此之间的耦合是最小的。

没有模板

许多 web 应用框架依赖模板来自动创建重复的 HTML 或 DOM 元素。这些框架中的模板语言是开发人员必须学习和练习的。不是在 React。

React 使用一种全功能编程语言来构造重复的或有条件的 DOM 元素。这种语言正是 JavaScript。例如,当你想构造一个表格时,你可以用 JavaScript 写一个for(...)循环或者使用Arraymap()函数。

有一种中间语言来表示虚拟 DOM,那就是 JSX(JavaScript XML 的缩写),它非常像 HTML。这允许您用熟悉的语言创建嵌套的 DOM 元素,而不是使用 JavaScript 函数手工构造它们。注意,JSX 不是一种编程语言;它是一种像 HTML 一样的表示性标记。它也非常类似于 HTML,所以你不必学太多。稍后会详细介绍。

事实上,您不必使用 JSX——如果您愿意,您可以编写纯 JavaScript 来创建虚拟 DOM。但是如果你习惯于 HTML,用 JSX 会更简单。不过不用担心;这真的不是一门你需要学习的新语言。

同形的

React 也可以在服务器上运行。这就是同构的意思:相同的代码可以在服务器和浏览器上运行。这允许您在需要时在服务器上创建页面,例如,出于 SEO 目的。稍后我会在第十二章中更详细地讨论这是如何工作的,这一章是关于服务器端渲染的。但是为了能够在服务器上运行 React 代码,我们确实需要能够运行 JavaScript 的东西,这就是我介绍 Node.js 的地方。

Node.js

简单来说,Node.js 就是浏览器之外的 JavaScript。Node.js 的创建者只是采用了 Chrome 的 V8 JavaScript 引擎,并将其作为 JavaScript 运行时独立运行。如果您熟悉运行 Java 程序的 Java 运行时,您可以很容易地联想到 JavaScript 运行时:Node.js 运行时运行 JavaScript 程序。

虽然你可能会发现有人认为 Node.js 不适合生产使用,声称它是为浏览器设计的,但也有许多行业领导者选择了 Node.js。网飞、优步和 LinkedIn 是在生产中使用 Node.js 的几家公司,这应该可以作为一个健壮和可扩展的环境来运行任何应用的后端。

Node.js 模块

在浏览器中,您可以加载多个 JavaScript 文件,但是您需要一个 HTML 页面来完成所有这些工作。不能从一个 JavaScript 文件引用另一个 JavaScript 文件。但是对于 Node.js,没有 HTML 页面来启动这一切。在没有封装的 HTML 页面的情况下,Node.js 使用自己的基于 CommonJS 的模块系统将多个 JavaScript 文件放在一起。

模块就像图书馆。您可以通过使用require关键字(在浏览器的 JavaScript 中找不到)来包含另一个 JavaScript 文件的功能(假设它是按照模块的规范编写的)。因此,为了更好地组织,您可以将代码分成文件或模块,并使用require加载它们。我将在后面的章节中讨论确切的语法;在这一点上,需要注意的是,与浏览器上的 JavaScript 相比,使用 Node.js 有一种更简洁的模块化代码的方法。

Node.js 附带了许多编译成二进制文件的核心模块。这些模块提供对诸如文件系统、网络、输入/输出等操作系统元素的访问。它们还提供了一些大多数程序通常需要的实用函数。

除了您自己的文件和核心模块之外,您还可以找到大量第三方开源库,以便于安装。这就把我们带到了 npm。

Node.js 和 npm

npm 是 Node.js 的默认包管理器。您可以使用 npm 安装第三方库(包)并管理它们之间的依赖关系。npm 注册中心( www.npmjs.com )是人们出于共享目的发布的所有模块的公共存储库。

尽管 npm 最初是作为 Node.js 模块的存储库,但它很快转变为一个包管理器,用于交付其他基于 JavaScript 的模块,特别是那些可以在浏览器中使用的模块。jQuery 是目前最流行的客户端 JavaScript 库,它是一个 npm 模块。事实上,尽管 React 很大程度上是客户端代码,可以作为脚本文件直接包含在 HTML 中,但还是建议通过 npm 安装 React。但是一旦它作为一个包被安装,我们需要一些东西把所有的代码放在一起,这些代码可以包含在 HTML 中,这样浏览器就可以访问这些代码。为此,有一些构建工具,如 Browserify 或 Webpack,可以将您自己的模块以及第三方库放在一个包中,该包可以包含在 HTML 中。

在撰写本书时,npm 在模块或包存储库中名列前茅,拥有超过 450,000 个包(见图 1-1 )。Maven,两年前曾经是最大的,现在只有不到一半的数量。这表明 npm 不仅是最大的,而且是增长最快的存储库。人们常说 Node.js 的成功很大程度上归功于 npm 和围绕它涌现的模块生态系统。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1-1

各种语言的模块数量(来源: www.modulecounts.com )

npm 不仅易于创建和使用模块;它还有一个独特的冲突解决技术,允许一个模块的多个冲突版本并存,以满足依赖性。因此,在大多数情况下,npm 只是工作。

Node.js 是事件驱动的

Node.js 有一个异步的、事件驱动的、非阻塞的输入/输出(I/O)模型,而不是使用线程来实现多任务。

大多数其他语言都依赖线程来同时处理事情。但事实上,当单个处理器运行您的代码时,并不存在并发这种情况。线程给人一种同时性的感觉,让其他代码段运行,而一个代码段等待(阻塞)某个事件完成。通常,这些是 I/O 事件,例如从文件中读取或通过网络进行通信。例如,在一行中,您调用打开一个文件,在下一行中,您已经准备好了文件句柄。真正发生的是,当文件被打开时,你的代码被阻塞(什么也不做)。如果有另一个线程正在运行,操作系统或语言将会切换出这段代码,并在阻塞期间开始运行其他代码。

另一方面,Node.js 没有线程。它依靠回调来让你知道一个挂起的任务已经完成。因此,如果您编写一行代码来打开一个文件,您可以为它提供一个回调函数来接收结果—文件句柄。在下一行,您继续做其他不需要文件句柄的事情。如果你习惯了异步 Ajax 调用,你会立刻明白我的意思。由于 JavaScript 的底层语言结构,如闭包,事件驱动编程对 Node.js 来说是很自然的。

Node.js 使用一个事件循环来实现多任务。这只是一个需要处理的事件队列,以及对这些事件运行的回调。在前面的例子中,准备读取的文件将是一个事件,它将触发您在打开它时提供的回调。如果你不完全明白这一点,也不用担心。本书其余部分的例子应该会让你对它真正的工作原理感到舒服。

一方面,基于事件的方法使 Node.js 应用变得更快,并让程序员能够愉快地忘记用于同步多线程事件的信号量和锁。另一方面,编写本质上异步的代码需要一些学习和实践。

表达

Node.js 只是一个可以运行 JavaScript 的运行时环境。直接在 Node.js 上手工编写一个成熟的 web 服务器并不容易,也没有必要。Express 是一个简化编写服务器代码任务的框架。

Express 框架允许您定义路由,即当符合特定模式的 HTTP 请求到达时该做什么的规范。匹配规范是基于正则表达式(regex)的,非常灵活,就像大多数其他 web 应用框架一样。“做什么”部分只是一个函数,它被提供给解析后的 HTTP 请求。

Express 为您解析请求 URL、头和参数。在响应方面,正如预期的那样,它具有 web 应用所需的所有功能。这包括确定响应代码、设置 cookies、发送自定义标头等。此外,您可以编写 Express 中间件,即可以插入到任何请求/响应处理路径中的定制代码片段,以实现常见的功能,如日志记录、身份验证等。

Express 没有内置的模板引擎,但是它支持你选择的任何模板引擎,比如 pug、mustache 等。但是,对于 SPA,您不需要使用服务器端模板引擎。这是因为所有动态内容的生成都是在客户端完成的,而 web 服务器只通过 API 调用提供静态文件和数据。

总之,Express 是一个针对 Node.js 的 web 服务器框架。就您可以使用它实现的功能而言,它与许多其他 web 服务器框架没有太大的不同。

MongoDB

MongoDB 是 MERN 堆栈中使用的数据库。这是一个面向 NoSQL 文档的数据库,具有灵活的模式和基于 JSON 的查询语言。不仅许多现代公司(包括脸书和谷歌)在生产中使用 MongoDB,一些历史悠久的公司如 SAP 和苏格兰皇家银行也采用了 MongoDB。

我将在这里讨论 MongoDB 存在(和不存在)的几个问题。

NoSQL

NoSQL 代表“非关系型”,不管这个首字母缩略词扩展成什么。它本质上是而不是一个传统的数据库,在那里你有列和行的表,它们之间有严格的关系。我发现 NoSQL 数据库有两个区别于传统数据库的特征。

首先是它们通过将负载分布在多台服务器上进行水平扩展的能力。他们这样做是牺牲了传统数据库的一个重要方面:强一致性。也就是说,副本之间的数据不一定在很短的时间内保持一致。更多信息,请阅读“上限定理”( https://en.wikipedia.org/wiki/CAP_theorem )。但实际上,很少有应用需要 web 规模,NoSQL 数据库的这一方面也很少发挥作用。

第二,也是我认为更重要的一点,NoSQL 数据库不一定是关系数据库。你不必用表格的行和列来考虑你的对象。应用(对象)和磁盘(表格中的行)中的表示之间的差异有时被称为阻抗不匹配。这是一个从电气工程借来的术语,大概意思是,我们说的不是同一种语言。由于阻抗不匹配,我们必须使用一个层来转换或映射对象和关系。这些层被称为对象关系映射(ORM)层。

相反,在 MongoDB 中,您可以像在应用代码中一样看待持久化数据,即作为对象或文档。这有助于您避开 ORM 层,像在应用的内存中一样自然地考虑持久数据。

面向文档

与数据以关系或表的形式存储的关系数据库相比,MongoDB 是一个面向文档的数据库。存储单位(相当于一行)是一个文档,或者一个对象,多个文档存储在集合(相当于一张表)。集合中的每个文档都有一个惟一的标识符,使用这个标识符可以访问它。标识符被自动索引。

想象一下发票的存储结构,包括客户姓名、地址等。以及发票中的项目(行)列表。如果您必须将它存储在一个关系数据库中,您将使用两个表,比如说,invoiceinvoice_lines,其中的行或项目通过一个外键关系引用发票。在 MongoDB 中并非如此。您可以将整个发票存储为单个文档,获取它,并在原子操作中更新它。这不仅适用于发票中的行项目。文档可以是任何深度嵌套的对象。

现代关系数据库已经开始通过允许数组字段和 JSON 字段来支持一级嵌套,但它与真正的文档数据库并不相同。MongoDB 能够对深度嵌套的字段进行索引,这是关系数据库所不能做到的。

不利的一面是数据以非规范化的方式存储。这意味着数据有时会重复,需要更多的存储空间。此外,像重命名主(目录)条目名称这样的事情将意味着遍历数据库并更新所有重复数据。但话说回来,如今存储变得相对便宜,重命名主条目是罕见的操作。

无模式

在 MongoDB 数据库中存储对象并不一定要遵循规定的模式。集合中的所有文档不需要有相同的字段集。

这意味着,尤其是在开发的早期阶段,您不需要在模式中添加/重命名列。您可以在应用代码中快速添加字段,而不必担心数据库迁移脚本。乍一看,这似乎是一件好事,但实际上它所做的只是将数据完整性的责任从数据库转移到您的应用代码上。我发现在更大的团队和更稳定的产品中,最好有一个严格或半严格的模式。使用像 mongoose 这样的对象文档映射库(本书没有涉及)可以缓解这个问题。

基于 JavaScript

MongoDB 的语言是 JavaScript。

对于关系数据库,我们有一种叫做 SQL 的查询语言。对于 MongoDB,查询语言是基于 JSON 的。通过在 JSON 对象中指定操作,可以创建、搜索、修改和删除文档。查询语言不像英语(你不用SELECT或说WHERE),因此更容易以编程方式构建。

数据也以 JSON 格式交换。事实上,为了有效地利用空间,数据被原生存储在一个叫做 BSON 的 JSON 变体中(其中 B 代表二进制)。当您从集合中检索文档时,它作为 JSON 对象返回。

MongoDB 附带了一个构建在 Node.js 等 JavaScript 运行时之上的 shell,这意味着您拥有了一个强大且熟悉的脚本语言(JavaScript)来通过命令行与数据库进行交互。您还可以用 JavaScript 编写代码片段,这些代码片段可以保存并在服务器上运行(相当于存储过程)。

工具和库

如果不使用工具来帮助您,很难构建任何 web 应用。下面简单介绍一下除了 MERN 堆栈组件之外的其他工具,我们将使用它们来开发本书中的示例应用。

React 路由

React 只为我们提供了视图呈现功能,并有助于管理单个组件中的交互。当涉及到在组件的不同视图之间转换并保持浏览器 URL 与视图的当前状态同步时,我们需要更多的东西。

这种管理 URL 和历史的能力被称为路由。它类似于 Express 所做的服务器端路由:解析一个 URL,并根据其组成部分,将一段代码与该 URL 相关联。React-Router 不仅可以做到这一点,还可以管理浏览器的Back按钮功能,这样我们就可以在看似页面的内容之间进行转换,而无需从服务器加载整个页面。我们可以自己构建这个,但是 React-Router 是一个非常易用的库,它为我们管理这个。

ReactBootstrap

Bootstrap 是最流行的 CSS 框架,已经被改编为 React,该项目被称为 React-Bootstrap。这个库不仅为我们提供了大部分的 Bootstrap 功能,而且这个库提供的组件和部件也为我们提供了大量关于如何设计自己的部件和组件的信息。

还有其他为 React 构建的组件/CSS 库(如 Material-UI、MUI、Elemental UI 等。)和单个组件(如 react-select、react-treeview 和 react-date-picker)。所有这些都是很好的选择,取决于你想要达到的目标。但是我发现 React-Bootstrap 是最全面的单个库,并且熟悉 Bootstrap(我想大多数人已经知道了)。

网页包

当涉及到模块化代码时,这个工具是必不可少的。还有其他竞争工具,如 Bower 和 Browserify,它们也服务于模块化和捆绑所有客户端代码的目的,但是我发现 Webpack 更容易使用,并且不需要其他工具(如 gulp 或 grunt)来管理构建过程。

我们将使用 Webpack,不仅将客户端代码模块化并构建成一个包以交付给浏览器,还将“编译”一些代码。我们需要编译步骤来从用 JSX 编写的 React 代码生成纯 JavaScript。

其他图书馆

很多时候,我们会觉得需要一个库来解决我们所有人都会面临的一个看似常见的问题。在本书中,我们将使用 body-parser(以 JSON 或表单数据的形式解析 POST 数据)和 ESLint(用于确保我们的代码遵循约定)等库,所有这些都在服务器端,还有一些类似 react-select 的库在客户端。

其他流行的图书馆

尽管我们不会将这些库作为本书的一部分使用,但是一些非常受欢迎的 MERN 堆栈的补充,并且一起使用的有:

  • 这是一个状态管理库,也结合了 Flux 编程模式。它通常用在大型项目中,即使对于单个屏幕,管理状态也变得复杂。

  • mongose:如果你熟悉对象关系映射层,你可能会发现 mongose 有些类似。这个库在 MongoDB 数据库层上增加了一个抽象层次,让开发人员可以像这样查看对象。在处理 MongoDB 数据库时,该库还提供了其他有用的便利。

  • Jest :这是一个测试库,可以用来轻松测试 React 应用。

版本

尽管 MERN 堆栈目前是一个健壮的、可用于生产的堆栈,但它的每个组件都在快速改进。在写这本书的时候,我已经使用了所有可用工具和库的最新版本。

但是毫无疑问,当你读到这本书的时候,很多东西都已经改变了,最新的版本将和我写这本书的时候用的不一样。因此,在安装任何软件包的说明中,我都包括了软件包的主要版本。

注意

为了避免软件包变化带来的意外,请安装与书中提到的软件包相同的主要版本,而不是最新版本的软件包。

较小的版本变化应该是向后兼容的。例如,如果这本书使用了软件包的 4.1.0 版本,并且在安装时您获得了最新的 as 4.2.0,那么代码应该可以工作而无需任何更改。但是有很小的可能性,软件包的维护者在一个小版本升级中错误地引入了一个不兼容的变化。因此,如果您发现尽管从 GitHub 库复制粘贴了代码,但还是有问题,作为最后的手段,切换到书中使用的精确的版本,可以在库的根目录下的package.json文件中找到。

表 1-1 列出了作为本书一部分的重要工具和库的主要版本。

表 1-1

各种工具和库的版本

|

成分

|

主要版本

|

评论

|
| — | — | — |
| Node.js | Ten | 这是一个 LTS(长期支持)版本 |
| 表达 | four | - |
| MongoDB | Three point six | 社区版 |
| React | Sixteen | - |
| React 路由 | four | - |
| ReactBootstrap | Zero point three two | 现在还没有 1.0 版本,1.0 可能会有问题 |
| Bootstrap 程序 | three | 这与 React Bootstrap 程序 0(以及释放时的 1)兼容 |
| 网页包 | four | - |
| 斯洛文尼亚语 | five | - |
| 巴比伦式的城市 | seven | - |

请注意,JavaScript 规范本身有许多版本,对各种版本和特性的支持因浏览器和 Node.js 而异。在本书中,我们将使用用于编译 JavaScript 的工具所支持的所有新特性,以达到最低的公分母:ES5。这组功能包括 ES2015 (ECMAScript 2015)、ES2016 和 ES2017 中的 JavaScript 功能。这些统称为 ES2015+。

为什么是 MERN?

现在,您对 MERN 堆栈及其组成有了一个大致的了解。但是它真的远远优于其他栈吗,比如说 LAMP,MEAN 等等。?无论如何,这些栈中的任何一个对于大多数现代 web 应用来说都足够好了。总而言之,熟悉是软件生产率的关键,所以我不建议一个 MERN 初学者盲目地在 MERN 上开始他们的新项目,尤其是如果他们有一个积极的截止日期。我建议他们选择他们已经熟悉的堆栈。

但是 MERN 确实有它特殊的地方。它非常适合前端内置大量交互性的 web 应用。回过头来再读一遍“为什么脸书建造 React”这一节,它会给你一些启发。你也许可以用其他的栈来达到同样的效果,但是你会发现用 MERN 来做是最方便的。所以,如果你有选择的余地,并且有时间熟悉一下,你会发现 MERN 是个不错的选择。我将谈谈我喜欢 MERN 的几件事,这些可能有助于你做出决定。

JavaScript 无处不在

我最喜欢 MERN 的一点是,那里到处都使用单一的语言。我们对客户端代码和服务器端代码都使用 JavaScript。即使你有数据库脚本(在 MongoDB 中),你也是用 JavaScript 写的。所以,你唯一需要了解和熟悉的语言是 JavaScript。

后端的所有其他基于 MongoDB 和 Node.js 的栈都是如此,尤其是 MEAN 栈。但是让 MERN 技术栈脱颖而出的是,你甚至不需要知道一种生成页面的模板语言。在 React 方式中,以编程方式生成 HTML(实际上是 DOM 元素)的方式是使用 JavaScript。因此,您不仅避免了学习一门新语言,还获得了 JavaScript 的全部功能。这与模板语言不同,模板语言有其自身的局限性。当然,你需要了解 HTML 和 CSS,但是它们不是编程语言,你无法避免学习 HTML 和 CSS(不仅仅是标记,还有范例和结构)。

除了在编写客户端和服务器端代码时不必切换上下文的明显优势之外,跨层使用单一语言还可以让您在这些层之间共享代码。我能想到执行业务逻辑、进行验证等功能。可以分享的东西。它们需要在客户端运行,以便更好地响应用户输入,从而获得更好的用户体验。它们还需要在服务器端运行,以保护数据模型。

JSON 无处不在

当使用 MERN 堆栈时,对象表示在任何地方都是 JSON (JavaScript 对象表示法)——在数据库中,在应用服务器上,在客户机上,甚至在网络上。

我发现这通常在转换方面为我节省了很多麻烦。没有对象关系映射(ORM),不必强制将对象模型放入行和列,没有特殊的序列化和反序列化代码。像 mongoose 这样的对象文档映射器(ODM)可能有助于实施一个模式,并使事情变得更加简单,但是底线是您可以节省大量的数据转换代码。

此外,它只是让我从本地对象的角度来考虑,甚至在使用 shell 直接检查数据库时,也将它们视为自然的自我。

Node.js 性能

由于其事件驱动的架构和非阻塞 I/O,Node.js 被认为是非常快速和有弹性的 web 服务器。

虽然需要一点时间来适应,但我毫不怀疑当您的应用开始扩展并接收大量流量时,这将在削减成本和节省花费在解决服务器 CPU 和 I/O 问题上的时间方面发挥重要作用。

国家预防机制生态系统

我已经讨论了大量可供每个人免费使用的 npm 包。您面临的大多数问题都已经有了一个 npm 包作为解决方案。即使不完全符合你的需求,你也可以叉出来,自己做 npm 包。

npm 是在其他优秀的包管理人员的基础上发展起来的,因此它包含了许多最佳实践。我发现 npm 是迄今为止我用过的最容易使用和最快的包管理器。部分原因是由于 JavaScript 代码的紧凑性,大多数 npm 包都很小。

同形的

SPAs 曾经有过 SEO 不友好的问题,因为搜索引擎不会调用 Ajax 来获取数据或运行 JavaScript 代码来呈现页面。人们不得不使用变通方法,比如在服务器上运行 PhantomJS 来伪生成 HTML 页面,或者使用 Prerender.io 服务来为我们做同样的事情。这增加了复杂性。

有了 MERN 堆栈,在服务器之外提供完整的页面是很自然的事情,不需要事后才想到的工具。这是可能的,因为 React 运行在 JavaScript 上,这在客户端或服务器上都是一样的。当基于 React 的代码在浏览器上运行时,它从服务器获取数据,并在浏览器中构造页面(DOM)。这是呈现 UI 的 SPA 方式。如果我们想在服务器上为搜索引擎机器人生成相同的页面,可以使用相同的基于 React 的代码从 API 服务器获取数据,构建页面(这次是 HTML)并将其返回给客户端。这被称为服务器端渲染 (SSR)。

图 1-2 比较了这两种操作模式。使这成为可能的事实是,在服务器和浏览器中使用相同的语言来运行 UI 构造代码:JavaScript。这就是术语同构的含义:相同的代码可以在浏览器或服务器上运行。我们将在第十二章中深入讨论 SSR 及其工作原理。在这一点上,足以理解的是,相同的代码可以在浏览器和客户端上运行,以实现两种不同的操作模式。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1-2

SPA 做事方式和使用 React 的服务器端渲染的比较

事实上,React Native 将它推向了另一个极端:它甚至可以生成移动应用的 UI。我没有在本书中介绍 React Native,但是这个事实应该会让您对 React 是如何构造的以及它将来能为您做什么有所了解。

不是框架!

没有多少人喜欢或欣赏这一点,但我真的很喜欢 React 是一个库,而不是一个框架。

一个框架是固执己见的;它有一套做事的方法。这个框架要求你填写它认为我们所有人都想完成的事情。另一方面,库为您提供了构建应用的工具。从短期来看,一个框架帮助很大,因为它去掉了大部分标准的东西。但是随着时间的推移,框架的变幻莫测,它对我们想要完成的事情的假设,以及学习曲线会让你希望你能对正在发生的事情有所控制,特别是当你有一些特殊的需求时。

有了函数库,有经验的架构师可以完全自由地设计自己的应用,从函数库的功能中挑选,并构建自己的框架,以满足应用的独特需求和变化。因此,对于有经验的架构师或非常独特的应用需求,库更好,尽管框架可以让您快速入门。

摘要

这本书让你体验使用 MERN 堆栈开发一个应用需要什么,是什么样子。

在这一章中,我们讨论了使 MERN 成为任何 web 应用的令人信服的选择的原因,其中包括单一编程语言在整个堆栈中的优势、NoSQL 数据库的特性以及 React 的同构。我希望这些理由能够说服您尝试 MERN 堆栈,如果不采用它的话。

这本书鼓励你去做,去思考,去实验,而不仅仅是阅读。因此,请记住以下提示,以便您能从本书中获得最大收益:

  • 避免从书中或 GitHub 库中复制粘贴代码。相反,你可以自己输入代码。只有当你陷入困境,发现事情并不像预期的那样工作时,才求助于复制粘贴。

  • 使用 GitHub 资源库( https://github.com/vasansr/pro-mern-stack-2 )查看代码列表和变更;因为 GitHub 显示差异的方式,所以更方便。

  • 不要依赖书中代码列表的准确性,而是依赖 GitHub 库中的代码。如果你不得不复制粘贴,那么从 GitHub 库开始,而不是从书上。

  • 使用本书中使用的相同版本的包和工具,而不是最新版本。最新版本和本书中的版本可能会有差异,这可能会导致问题的出现。

  • 不要跳过练习:这些练习旨在让你思考并了解到哪里去寻找更多的资源。

最后,我希望您对了解 MERN 堆栈感到非常兴奋。因此,我们将在下一章直接进入代码,创建最基本的应用:Hello World 应用。

二、你好世界

按照惯例,我们将从 Hello World 应用开始,这是一个最简单的应用,使用了大部分 MERN 组件。任何 Hello World 的主要目的都是展示我们正在使用的技术或堆栈的基本特征,以及启动和运行它所需的工具。

在这个 Hello World 应用中,我们将使用 React 呈现一个简单的页面,并使用 Node.js 和 Express 从 web 服务器提供该页面。这将让你学习这些技术的基本原理。这也将让您对 nvm、npm 和 JSX 变换有一些基本的了解——一些我们将会经常用到的工具。

无服务器 Hello World

为了快速起步,让我们在一个 HTML 文件中编写一段简单的代码,使用 React 在浏览器上显示一个简单的页面。没有安装,下载,或服务器!你所需要的是一个现代的浏览器,可以运行我们编写的代码。

让我们开始创建这个 HTML 文件,并将其命名为index.html。您可以使用您最喜欢的编辑器,将这个文件保存在文件系统的任何地方。让我们从基本的 HTML 标签开始,比如<html><head><body>。然后,让我们包括 React 库。

毫不奇怪,React 库是一个 JavaScript 文件,我们可以使用<script>标签将它包含在 HTML 文件中。它由两部分组成:第一部分是 React 核心模块,负责处理 React 组件及其状态操作等。第二个是 ReactDOM 模块,它处理将 React 组件转换成浏览器可以理解的 DOM。这两个库可以在 unpkg 中找到,un pkg 是一个内容交付网络(CDN ),它使得所有开源 JavaScript 库都可以在线使用。让我们使用来自以下 URL 的库的开发(相对于生产)版本:

这两个脚本可以包含在<head>部分,使用如下的<script>标签:

...
  <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
...

接下来,在主体中,让我们创建一个<div>,它将最终保存我们将创建的任何 React 元素。这可以是一个空的<div>,但是它需要一个 ID,比如说content,来识别和获取 JavaScript 代码中的句柄。

...
  <div id="content"></div>
...

要创建 React 元素,需要调用 React 模块的createElement()函数。这非常类似于 JavaScript document.createElement()函数,但是有一个额外的特性,允许嵌套元素。该函数最多接受三个参数,其原型如下:

React.createElement(type, [props], [...children])

类型可以是任何 HTML 标签,比如字符串'div',或者 React 组件(我们将在下一章开始创建)。props是包含 HTML 属性或自定义组件属性的对象。最后一个参数是零个或多个子元素,也是使用createElement()函数本身创建的。

对于 Hello World 应用,让我们创建一个非常简单的嵌套元素——一个带有 title 属性的<div>(只是为了展示属性是如何工作的),它包含一个带有“Hello World!”字样的标题下面是用于创建我们的第一个 React 元素的 JavaScript 代码片段,该元素将放在主体的<script>标记中:

...
    const element = React.createElement('div', {title: 'Outer div'},
      React.createElement('h1', null, 'Hello World!')
    );
...

注意

我们在本书中使用了 es 2015+JavaScript 特性,在这个片段中,我们使用了const关键字。这应该可以在所有现代浏览器中正常工作。如果你使用的是旧版浏览器,比如 Internet Explorer 10,你需要将const改为var。在本章的最后,我们将讨论如何支持旧的浏览器,但在此之前,请使用一种现代浏览器进行测试。

React 元素(React.createElement()调用的结果)是一个 JavaScript 对象,表示屏幕上显示的内容。因为它可以是其他元素的嵌套集合,并且可以描述整个屏幕上的一切,所以它也被称为虚拟 DOM 。请注意,这还不是真正的 DOM,它在浏览器的内存中,这就是它被称为虚拟 DOM 的原因。它作为一组嵌套很深的 React 元素驻留在 JavaScript 引擎的内存中,这些元素也是 JavaScript 对象。React 元素不仅包含需要创建哪些 DOM 元素的细节,还包含一些有助于优化的关于树的附加信息。

这些 React 元素中的每一个都需要被转移到真实的 DOM 中,以便在屏幕上构建用户界面。为此,需要对应于每个 React 元素进行一系列的document.createElement()调用。当调用ReactDOM.render()函数时,ReactDOM 会这样做。该函数将需要呈现的元素和需要放置的 DOM 元素作为参数。

我们已经使用React.createElement()构建了需要呈现的元素。至于包含元素,我们在主体中创建了一个<div>,它是新元素需要放置的目标。我们可以通过调用document.getElementByID()来获得父进程的句柄,就像我们使用普通的 JavaScript 一样。让我们这样做,并呈现 Hello World React 元素:

...
    ReactDOM.render(element, document.getElementById('content'));
...

让我们把这些都放在index.html里。该文件的内容如清单 2-1 所示。

<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <title>Pro MERN Stack</title>

  <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
</head>

<body>
  <div id="contents"></div>

  <script>
    const element = React.createElement('div', {title: 'Outer div'},
      React.createElement('h1', null, 'Hello World!')
    );

    ReactDOM.render(element, document.getElementById('content'));
  </script>
</body>

</html>

Listing 2-1index.html: Server-less Hello World

您可以通过在浏览器中打开该文件来测试它。加载 React 库可能需要几秒钟的时间,但是很快你就会看到浏览器显示标题,如图 2-1 所示。您还应该能够将鼠标悬停在文本上或外部 div 边界内文本右侧的任何地方,并且应该能够看到工具提示“外部 div”弹出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2-1

用 React 写的 Hello World

练习:无服务器 Hello World

  1. 尝试向h1元素添加一个类(您还需要在<head>部分的<style>部分中定义该类,以测试它是否工作)。提示:在 stackoverflow.com 搜索“如何在 jsx 中指定类”。你能解释这个吗?

  2. 检查开发人员控制台中的element变量。你看到了什么?如果你给这棵树起个名字,你会给它起什么名字?

本章末尾有答案。

小艾

我们在上一节中创建的简单元素很容易使用React.createElement()调用来编写。但是想象一下,编写一个深度嵌套的元素和组件层次结构:它会变得非常复杂。此外,当使用函数调用时,实际的 DOM 不容易可视化,因为如果它是普通的 HTML,它是可以可视化的。

为了解决这个问题,React 有一种叫做 JSX 的标记语言,它代表了 JavaScript XML ??。JSX 看起来非常像 HTML,但也有一些不同之处。因此,代替React.createElement()调用,JSX 可以用来构建一个元素或元素层次结构,使它看起来非常像 HTML。对于我们创建的简单的 Hello World 元素,事实上,HTML 和 JSX 之间没有区别。所以,让我们把它写成 HTML 并把它赋给元素,替换掉React.CreateElement()调用:

...
    const element = (
      <div title="Outer div">
        <h1>Hello World!</h1>
      </div>
    );
...

注意,尽管它惊人地接近 HTML 语法,但它是而不是 HTML。还要注意,标记没有用引号括起来,所以它也不是一个可以用作innerHTML的字符串。它是 JSX,可以和 JavaScript 自由混合。

现在,考虑到与 HTML 相比的所有差异和复杂性,你为什么需要学习 JSX 呢?它增加了什么价值?为什么不直接编写 JavaScript 本身呢?我在导言一章中谈到的一件事是,MERN 自始至终只有一种语言;这不是与那相反吗?

随着我们进一步探索 React,你很快就会发现 HTML 和 JSX 之间的差异并不是翻天覆地的,它们非常符合逻辑。只要你理解并内化了其中的逻辑,你就不需要记很多东西,也不需要查资料。尽管直接编写 JavaScript 来创建虚拟 DOM 元素确实是一种选择,但我发现这非常繁琐,并且不能帮助我可视化 DOM。

此外,由于您可能已经知道基本的 HTML 语法,编写 JSX 可能会更好。当你阅读 JSX 时,很容易理解屏幕会是什么样子,因为它与 HTML 非常相似。因此,在本书的其余部分,我们使用 JSX。

但是浏览器的 JavaScript 引擎不理解 JSX。它必须被转换成常规的基于 JavaScript 的React.createElement()调用。为此,需要一个编译器。做这件事的编译器(事实上还可以做更多)是 Babel。理想情况下,我们应该预编译代码并将其注入到浏览器中,但出于原型设计的目的,Babel 提供了一个可以在浏览器中使用的独立编译器。像往常一样,这是一个 JavaScript 文件,可以在 unpkg 上获得。让我们将这个脚本包含在index.html<head>部分中,如下所示:

...
  <script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
...

但是编译器也需要被告知哪些脚本必须被转换。它在所有脚本中查找属性type= " text/babel",并转换和运行任何具有该属性的脚本。因此,让我们将这个属性添加到主脚本中,让 Babel 完成它的工作。下面是实现这一点的代码片段:

...
  <script type="text/babel">
...

清单 2-2 显示了使用 JSX 的一整套更改。

<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <title>Pro MERN Stack</title>

  <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>

  <script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
</head>

<body>
  <div id="contents"></div>

  <script type="text/babel">
    const element = React.createElement('div', {title: 'Outer div'},
      React.createElement('h1', null, 'Hello World!')
    );
    const element = (
      <div title="Outer div">
        <h1>Hello World!</h1>
      </div>
    );

    ReactDOM.render(element, document.getElementById('contents'));
  </script>
</body>

</html>

Listing 2-2index.html: Changes for Using JSX

注意

虽然我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有出现在书中。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。

当您测试这组更改时,您会发现页面的外观没有什么不同,但是由于 Babel 进行了编译,它可能会稍微慢一点。但别担心。我们将很快切换到在构建时而不是运行时编译 JSX,以消除性能影响。请注意,该代码还不能在较旧的浏览器上运行;您可能会在脚本babel.min.js中得到错误。

文件index.html可以在 GitHub 存储库中的目录public下找到;这是文件最终的位置。

练习:JSX

  1. 从脚本中删除type=``text/babel。当你加载index.html时会发生什么?你能解释一下为什么吗?放回type=``text/babel但是去掉巴别塔 JavaScript 库。现在会发生什么?

  2. 我们用的是缩小版的巴别塔,但不是 React 和 ReactDOM。你能猜到原因吗?切换到生产缩小版本,并在 React 中引入运行时错误(查看 unpkg.com 网站,了解这些库的生产版本的名称)。例如,在内容 Node 的 ID 中引入一个错别字,这样就没有地方安装组件了。会发生什么?

本章末尾有答案。

项目设置

无服务器设置允许您熟悉 React,而无需任何安装或启动服务器。但是您可能已经注意到了,这对开发和生产都没有好处。在开发过程中,需要额外的时间从内容交付网络或 CDN 加载脚本。如果您使用浏览器开发人员控制台的 Network 选项卡查看每个脚本的大小,您会发现 babel 编译器(即使是缩小版)非常大。在生产中,尤其是在大型项目中,JSX 到 JavaScript 的运行时编译会降低页面加载速度并影响用户体验。

所以,让我们稍微组织一下,从 HTTP 服务器提供所有文件。当然,我们将使用 MERN 堆栈的一些其他组件来实现这一点。但是在我们做所有这些之前,让我们设置我们的项目和文件夹,我们将在其中保存文件和安装库。

我们将在 shell 中输入的命令已经被收集在 GitHub 库根目录下的一个名为commands.md的文件中。

注意

如果您在键入命令时发现某些东西不能按预期工作,请在 GitHub 资源库( https://github.com/vasansr/pro-mern-stack-2 )中交叉检查这些命令。这是因为错别字可能是在书的制作过程中引入的,或者最后一刻的更正可能错过了这本书。另一方面,GitHub 库反映了最新的和经过测试的代码和命令。

非易失性存储器

首先,让我们安装 nvm。这代表 Node Version Manager,该工具使 Node.js 的多个版本之间的安装和切换变得容易。Node.js 可以在没有 nvm 的情况下直接安装,但我发现,当我不得不开始一个新项目,并且希望在那个时间点使用 Node.js 的最新和最棒的版本时,一开始安装 nvm 使我的生活变得更容易。与此同时,我不想为我的其他大型项目切换到最新版本,因为害怕破坏这些项目中的东西。

要安装 nvm,如果您使用的是 Mac OS 或任何基于 Linux 的发行版,请遵循 nvm 的 GitHub 页面上的说明。这可以在 https://github.com/creationix/nvm 找到。Windows 用户可以关注 nvm for Windows(在你喜欢的搜索引擎中搜索)或者直接安装 Node.js,不需要 nvm。一般来说,我建议 Windows 用户安装一个 Linux 虚拟机(VM),最好使用 vagger,并在 VM 内完成所有的服务器端编码。这通常效果最好,尤其是因为代码最终几乎总是部署在 Linux 服务器上,拥有相同的开发环境效果最好。

关于 nvm 的一件棘手的事情是知道它如何初始化你的路径。这在不同的操作系统上有不同的工作方式,所以一定要仔细阅读其中的细微差别。本质上,它向您的 shell 的初始化脚本添加了几行,以便您下次打开 shell 时,您的路径被初始化并执行 nvm 的初始化脚本。这让 nvm 知道所安装的 Node.js 的不同版本,以及默认可执行文件的路径。

因此,最好在安装 nvm 后立即启动一个新的 shell,而不是继续安装它。一旦你为你的 nvm 找到了正确的道路,事情就会进展顺利。

你可以选择直接安装 Node.js,不安装 nvm,这也很好。但是本章的其余部分假设您已经安装了 nvm。

Node.js

现在我们已经安装了 nvm,让我们使用 nvm 安装 Node.js。有许多版本的 Node.js 可用(请查看网站, https://nodejs.org ),但出于本书的目的,我们将选择最新的长期支持(LTS),它恰好是 10:

$ nvm install 10

LTS 版本肯定会比其他版本获得更长时间的支持。这意味着,尽管不能指望功能升级,但可以指望向后兼容的安全和性能修复。此外,新的次要版本可以安装,而不必担心破坏现有的代码。

现在我们已经安装了 Node.js,让我们将该版本作为未来的默认版本。

$ nvm alias default 10

否则,下次进入 shell 时,node 将不在路径中,或者我们选择以前安装的默认版本。您可以通过在新的 shell 或终端中键入以下内容来确认默认安装的 node 版本:

$ node --version

此外,一定要确保任何外壳也显示最新版本。(注意,Windows 版本的 nvm 不支持alias命令。每次打开一个新的 shell 时,您可能都必须执行nvm use 10。)

通过 nvm 安装 Node.js 也会安装软件包管理器 npm。如果您直接安装 Node.js,请确保您也安装了兼容版本的 npm。您可以通过记下随 Node.js 一起安装的 npm 版本来确认这一点:

$ npm --version

它应该显示版本 6 的一些内容。npm 可能会提示您有新版本的 npm,并要求您安装该版本。在任何情况下,让我们安装我们希望在本书中使用的 npm 版本,如下指定版本:

$ npm install –g npm@6

请确保您不会错过–g标志。它告诉 npm 全局安装自己*,也就是说,对所有项目都可用。要再次检查,再次运行npm --version。*

*### 项目

在我们用 npm 安装任何第三方包之前,初始化项目是个好主意。有了 npm,甚至一个应用也被认为是一个包。包定义了应用的各种属性。一个重要的属性是应用所依赖的其他包的列表。随着时间的推移,这种情况将会改变,因为随着应用的发展,我们发现需要使用库。

首先,我们至少需要一个占位符来保存和初始化这些东西。让我们创建一个名为pro-mern-stack-2的目录来托管应用。让我们从这个目录中初始化项目,如下所示:

$ npm init

这个命令问你的大多数问题应该很容易回答。默认设置也很好。从现在开始,对于所有 shell 命令,尤其是 npm 命令(我将在下面描述),您应该位于项目目录中。这将确保所有的更改和安装都本地化到项目目录中。

新公共管理

要使用 npm 安装任何东西,要使用的命令是npm install <package>。首先,因为我们需要一个 HTTP 服务器,所以让我们使用 npm 安装 Express。安装 Express 非常简单:

$ npm install express

一旦完成,你会注意到它说安装了许多软件包。这是因为它还会安装 Express 依赖的所有其他软件包。现在,让我们卸载并重新安装一个特定的版本。在本书中,我们使用版本 4,所以让我们在安装时指定该版本。

$ npm uninstall express
$ npm install express@4

注意

安装软件包时,只指定主要版本(在本例中为 4)就足够了。这意味着你可以安装一个次要版本,这个版本与你写这本书时使用的版本不同。在极少数情况下,这会导致问题,请在 GitHub 存储库中的package.json中查找包的具体版本。然后,在安装软件包时指定确切名称,例如npm install express@4.16.4

npm 是非常强大的,它的选择是巨大的。目前,我们只关心软件包的安装和一些其他有用的东西。项目目录下安装文件的位置是 npm 的制作者有意识的选择。这具有以下效果:

  1. 所有安装都位于项目目录的本地。这意味着不同的项目可以使用不同版本的任何已安装的软件包。乍一看,这似乎是不必要的,感觉像是大量的重复。但是,当您启动多个 Node.js 项目,并且不想处理一个不需要的包升级时,您将真正欣赏 npm 的这一特性。此外,您会注意到整个 Express 包(包括所有依赖项)只有 1.8MB。由于包非常小,所以磁盘使用量过大根本不是问题。

  2. 包的依赖项也在包内被隔离。因此,可以安装依赖于一个公共包的不同版本的两个包,并且它们都有自己的副本,因此可以完美地工作。

  3. 安装软件包不需要管理员(超级用户)权限。

当然,有一个全局安装包的选项,有时这样做很有用。一个用例是将命令行实用程序打包为 npm 包。在这种情况下,不管工作目录如何,让命令行可用是非常有用的。在这种情况下,npm install 的–g选项可用于全局安装包,并使其在任何地方都可用。

如果您已经通过 nvm 安装了 Node.js,全局安装将使用您自己的主目录,并使该包可用于您主目录中的所有项目。全局安装软件包不需要超级用户或管理员权限。另一方面,如果您直接安装了 Node.js,使其对您计算机上的所有用户可用,您将需要超级用户或管理员权限。

此时,最好再次查看 GitHub 存储库( https://github.com/vasansr/pro-mern-stack-2 ),尤其是查看与上一步的不同之处。在本节中,我们只添加了新文件,所以您将看到的唯一区别是新文件。

练习:项目设置

  1. package.json是什么时候创作的?如果猜不出来,就考察内容;这应该给你一个提示。还是想不通?回去重新做你的步骤。从创建项目目录开始,在每个步骤中查看目录内容。

  2. 卸载 Express,但使用选项--no-save。现在,只需输入npm install。会发生什么?这次手动添加另一个依赖项,比如 MongoDB 到package.json。使用版本作为“最新”。现在,输入npm install。会发生什么?

  3. 安装任何新软件包时使用--save-dev。你觉得package.json有什么不同?你认为会有什么不同?

  4. 您认为软件包文件安装在哪里?键入npm ls --depth=0检查所有当前安装的软件包。清理所有不需要的包。

试着安装和卸载 npm。这通常是有用的。从文档中了解关于 npm 版本语法的更多信息: https://docs.npmjs.com/files/package.json#dependencies

本章末尾有答案。

注意

虽然签入package.json.lock文件是一个很好的做法,这样安装的确切版本可以在团队成员之间共享,但是我已经将它从存储库中排除了,以保持差异的简洁和可读性。当您使用 MERN 堆栈启动一个团队项目时,您应该在您的 Git 存储库中签入这个文件。

表达

如果您还记得上一章的介绍,Express 是在 Node.js 环境中运行 HTTP 服务器的最佳方式。首先,我们将使用 Express 只服务静态文件。这是为了让我们习惯于 Express 所做的事情,而不用进入大量的服务器端编码。我们将通过 Express 提供我们在上一节中创建的index.html文件。

在上一步中,我们已经安装了 Express,但是为了确保它在那里,让我们执行 npm 命令来再次安装它。如果软件包已经安装,这个命令什么也不做,所以如果有疑问,我们可以再次运行它。

$ npm install express@4

要开始使用 Express,让我们导入模块并使用模块导出的顶级函数,以便实例化一个应用。这可以使用以下代码来完成:

...
const express = require('express');
...

require是 Node.js 特有的 JavaScript 关键字,用于导入其他模块。这个关键字不是浏览器端 JavaScript 的一部分,因为没有包含其他 JavaScript 文件的概念。所有需要的脚本都直接包含在 HTML 文件中。ES2015 规范想出了一种使用import关键字来包含其他文件的方法,但在规范出来之前,Node.js 不得不使用require发明自己的方法。它也被称为包含其他模块的常见方式。

在前一行中,我们加载了名为express的模块,并在名为express的常量中保存了该模块导出的顶层对象。Node.js 允许的东西是一个函数,一个对象,或者任何适合变量的东西。模块输出的类型和形式实际上取决于模块,模块的文档会告诉你如何使用它。

在 Express 的情况下,该模块导出一个可用于实例化应用的函数。我们只是把这个函数赋给了变量express

注意

我们使用 ES2015 const关键字来定义变量express。这使得变量在第一次声明后不可赋值。对于可能被赋予新值的变量,可以用关键字let代替const

Express 应用是监听特定 IP 地址和端口的 web 服务器。可以创建多个应用来监听不同的端口,但是我们不会这样做,因为我们只需要一台服务器。让我们通过调用express()函数来实例化这个唯一的应用:

...
const app = express();
...

现在我们已经有了应用的句柄,让我们来设置它。Express 是一个框架,它自己完成最少的工作;相反,它让名为中间件的功能来完成大部分工作。中间件是一个接受 HTTP 请求和响应对象的函数,加上链中的下一个中间件函数。该函数可以查看和修改请求和响应对象,响应请求,或者通过调用下一个中间件函数来决定继续使用中间件链。

此时,我们需要查看请求并根据请求 URL 的路径返回文件内容的东西。内置的express.static函数生成一个中间件函数来完成这个任务。它通过尝试将请求 URL 与生成器函数的参数指定的目录下的文件进行匹配来响应请求。如果文件存在,它返回文件的内容作为响应,如果不存在,它链接到下一个中间件函数。我们可以这样创建中间件:

...
const fileServerMiddleware = express.static('public');
...

static()函数的参数是中间件应该查找文件的目录,相对于应用运行的位置。对于我们将作为本书的一部分构建的应用,我们将把所有静态文件存储在项目根目录下的public目录中。让我们在项目根目录下创建这个新目录public,并将我们在上一节中创建的index.html移动到这个新目录中。

现在,为了让应用使用静态中间件,我们需要在应用上安装。Express 应用中的中间件可以使用应用的use()方法来挂载。该方法的第一个参数是要匹配的任何 HTTP 请求的基本 URL。第二个论点是中间件功能本身。因此,要使用静态中间件,我们可以这样做:

...
app.use('/', fileServerMiddleware);
...

第一个参数是可选的,如果没有指定,默认为'/',所以我们也可以跳过它。

最后,既然应用已经设置好了,我们需要启动服务器,让它为 HTTP 请求提供服务。应用的listen()方法启动服务器并永远等待请求。它将端口号作为第一个参数。让我们使用端口 3000,一个任意的端口。我们不会使用端口 80,通常的 HTTP 端口,因为要监听该端口,我们需要有管理(超级用户)权限。

listen()方法还接受另一个参数,这是一个可选的回调函数,当服务器成功启动时可以调用它。让我们提供一个匿名函数,它只打印服务器已经启动的消息,如下所示:

...
app.listen(3000, function () {
  console.log('App started on port 3000');
});
...

让我们将所有这些放在项目根目录下的一个名为server.js的文件中。清单 2-3 显示了最终的服务器代码,其中use()调用与中间件的创建合并在一行中,并跳过了可选的第一个参数,即挂载点。

const express = require('express');

const app = express();

app.use(express.static('public'));

app.listen(3000, function () {
  console.log('App started on port 3000');
});

Listing 2-3server.js: Express Server

现在,我们准备启动 web 服务器并为index.html提供服务。如果你在 GitHub 库中寻找代码,你会在一个名为server的目录下找到server.js。但是此时,该文件需要位于项目目录的根目录下。

要启动服务器,在项目的根目录下使用 Node.js 运行时运行它,如下所示:

$ node server.js

您应该会看到一条消息,说明应用已经在端口 3000 上启动。现在,打开你的浏览器,在地址栏输入http://localhost:3000/index.html。您应该会看到我们在上一节中创建的 Hello World 页面。如果你看到一个 404 错误信息,可能你还没有将index.html移动到public目录中。

静态中间件函数服务于来自public目录的index.html文件的内容,因为它匹配请求 URL。但它也足够聪明,可以将请求翻译成/(网站的根目录),并通过在目录中查找index.html来做出响应。这类似于 Apache 等其他静态 web 服务器的做法。因此,只需输入http://localhost:3000/就足以进入 Hello World 页面。

要启动服务器,我们必须向 Node.js 提供入口点的名称(server.js),这可能不容易记住或者告诉项目的其他用户。如果我们的项目中有许多文件,那么人们如何知道哪个文件是启动服务器的呢?幸运的是,所有 Node.js 项目中都使用了一个约定:npm 脚本用于执行常见任务。以下命令行是启动服务器的另一种方法:

$ npm start

执行这个命令时,npm 会查找文件server.js并使用 Node.js 运行它。因此,让我们停止服务器(在命令 shell 中使用 Ctrl+C)并使用npm start重新启动服务器。您应该会看到相同的消息,说明服务器已经启动。

但是如果我们有一个不同的服务器起点呢?事实上,我们希望所有与服务器相关的文件都放在一个名为server的目录中。因此,让我们创建该目录并将server.js移动到该目录中。

现在,如果您运行npm start,它将失败并出现错误。那是因为 npm 在根目录中寻找server.js,没有找到。为了让 npm 知道服务器的入口点是子目录server中的server.js,需要在package.jsonscripts部分添加一个条目。清单 2-4 展示了这些变化。

...
  "main": "index.js",
  "scripts": {
    "start": "node server/server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

Listing 2-4package.json: Changes for Start Script

因此,如果服务器起始点不是根目录中的server.js,那么完整的命令行必须在package.jsonscripts部分指定。

注意在package.json中还有一个名为main的字段。当我们初始化这个文件时,这个字段的值被自动设置为index.js。该字段是而不是用于指示服务器的起点。相反,如果这个包是一个模块(相对于一个应用),那么当这个项目在其他项目中使用require()作为一个模块导入时,index.js将会是要加载的文件。由于这个项目不是可以导入到其他项目中的模块,所以这个字段我们没有任何兴趣,源代码中也没有index.js

现在,我们可以使用npm start看到熟悉的应用启动消息,并最后一次测试它。在这个时候,最好检查一下 GitHub 库,看看这一部分的不同之处。特别是,看一下现有文件package.json中的更改,以熟悉如何在书中显示文件中的更改,以及如何在 GitHub 中将相同的更改视为差异。

练习:快速

  1. index.html文件的名称改为其他名称,比如说hello.html。这对应用有什么影响?

  2. 如果您希望所有静态文件都可以通过一个带前缀的 URL 来访问,例如/public,您会做什么改变?提示:在 https://expressjs.com/en/starter/static-files.html 看一下静态文件的 Express 文档。

本章末尾有答案。

单独的脚本文件

在前面的所有章节中,JSX 到 JavaScript 的转换发生在运行时。这是低效的,也是不必要的。相反,让我们将转换转移到开发中的构建阶段,这样我们就可以部署一个随时可用的应用发行版。

作为第一步,我们需要将 JSX 和 JavaScript 从一体化软件index.html中分离出来,并将其称为外部脚本。这样,我们可以将 HTML 保持为纯 HTML,并将所有需要编译的脚本保存在一个单独的文件中。让我们调用这个外部脚本App.jsx并将它放在public目录中,这样就可以从浏览器中引用它为/App.jsx。当然,新脚本文件的内容不会包含<script>标签。并且,在index.html中,让我们将内联脚本替换为对外部源的引用,如下所示:

...
  <script type="text/babel" src="/App.jsx"></script>
...

注意,仍然需要脚本类型text/babel,因为 JSX 编译是在浏览器中使用巴别塔库进行的。新修改的文件列在清单 2-5 和 2-6 中。

<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <title>Pro MERN Stack</title>

  <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>

  <script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
</head>

<body>
  <div id="contents"></div>

  <script type="text/babel" src="/App.jsx"></script>
</body>

</html>

Listing 2-5index.html: Separate HTML and JSX

const element = (
  <div title="Outer div">
    <h1>Hello World!</h1>
  </div>
);

ReactDOM.render(element, document.getElementById('contents'));

Listing 2-6App.jsx: JSX Part Separated Out from the HTML

此时,应用应该会像以前一样继续工作。如果您将浏览器指向http://localhost:3000,您应该会看到同样的 Hello World 消息。但是我们只分离了文件;我们没有把变形移到建造时间。JSX 继续通过在浏览器中执行的巴别塔库脚本进行转换。在下一节中,我们将把转换移到构建时间。

JSX 变换

现在让我们创建一个新目录来保存所有的 JSX 文件,这些文件将被转换成普通的 JavaScript 并保存到public文件夹中。让我们称这个目录为src,并将App.jsx移动到这个目录中。

对于转换,我们需要安装一些巴别塔工具。我们需要核心的 Babel 库和命令行界面(CLI)来完成转换。让我们使用以下命令来安装它们:

$ npm install --save-dev @babel/core@7 @babel/cli@7

为了确保 Babel 编译器作为命令行可执行文件可用,让我们尝试在命令行上执行命令babel,并使用--version选项检查安装的版本。由于这不是一个全球安装,巴别塔将不会出现在路径中。我们必须从它的安装位置专门调用它,如下所示:

$ node_modules/.bin/babel --version

这应该会给出与此类似的输出,但是次要版本可能会有所不同,例如,7.2.5 而不是 7.2.3:

7.2.3 (@babel/core 7.2.2)

我们可以使用 npm 的--global(或–g)选项在全球范围内安装@babel/cli。这样,我们就可以访问任何目录中的命令,而不必在路径前面加上前缀。但是正如前面所讨论的,将所有安装保持在项目的本地是一个好的做法。这是为了我们不必处理跨项目的包的版本差异。此外,npm 的最新版本给了我们一个方便的命令叫做npx,它可以解析任何可执行文件的正确的本地路径。该命令仅在 npm 版本 6 及更高版本中可用。让我们使用这个命令来检查 Babel 版本:

$ npx babel --version

接下来,要将 JSX 语法转换成常规 JavaScript,我们需要一个预置(Babel 使用的一种插件)。这是因为 Babel 能够进行许多其他变换(我们将在下一节中介绍),并且许多不属于 Babel 核心库的预设作为不同的包提供。JSX 变换预置就是这样一个叫做preset-react的预置,所以让我们安装它。

$ npm install --save-dev @babel/preset-react@7

现在我们准备将App.jsx转换成纯 JavaScript。babel命令行接受一个输入目录,其中包含适用的源文件和预置,并接受输出目录作为选项。对于 Hello World 应用,源文件在src目录中,所以我们希望转换的输出在public目录中,并且我们希望应用 JSX 转换预置@babel/react。下面是实现这一点的命令行:

$ npx babel src --presets @babel/react --out-dir public

如果您查看输出目录public,您会看到那里有一个名为App.js的新文件。如果您在编辑器中打开该文件,您可以看到 JSX 元素已经被转换为React.createElement()调用。注意,Babel 编译自动为输出文件使用了扩展名.js,这表明它是纯 JavaScript。

现在,我们需要改变index.html中的引用来反映新的扩展,并删除脚本类型规范,因为它是纯 JavaScript。此外,我们不再需要在index.html中加载运行时转换器,因此我们可以摆脱babel-core脚本库规范。这些变化如清单 2-7 所示。

...
  <script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
...
  <body>
    <div id="contents"></div
    <script src="/App.jsx" type="text/babel"></script>
    script src="/App.js"></script>
  </body>
...

Listing 2-7index.html: Change in Script Name and Type

如果您测试这一组更改,您应该看到事情像以前一样工作。为了更好地衡量,你可以使用浏览器的开发者控制台来确保获取的是App.js,而不是App.jsx。开发人员控制台可以在大多数浏览器上找到;您可能需要查看您的浏览器文档,以获得访问它的说明。

练习:JSX 变换

  1. 检查转换输出App.js的内容。你会在public目录中找到它。你看到了什么?

  2. 为什么我们在安装babel-cli的时候用了--save-dev?提示:在 https://docs.npmjs.com/cli/install 阅读用于安装 CLI 命令的 npm 文档。

本章末尾有答案。

旧浏览器支持

我之前提到过,JavaScript 代码将在所有支持 ES2015 的现代浏览器中工作。但是,如果我们需要支持旧的浏览器,例如,Internet Explorer,该怎么办呢?老版本的浏览器不支持箭头功能和Array.from()方法。事实上,在 IE 11 或更早版本中运行此时的代码应该会抛出一个控制台错误消息,说明Object.assign不是一个函数。

让我们对 JavaScript 进行一些更改,并使用这些高级 ES2015 功能。然后,让我们做一些改变,以便在旧的浏览器中也支持所有这些特性。要使用 ES2015 功能,我们不显示包含 Hello World 的消息,而是创建一个大陆数组,并构建一条包含每个大陆的消息。

...
const continents = ['Africa','America','Asia','Australia','Europe'];
...

现在,让我们使用Array.from()方法构造一个新的数组,每个大洲的名称前面有一个 Hello,末尾有一个感叹号。为此,我们将使用数组的map()方法,接受一个箭头函数。我们将使用字符串插值,而不是连接字符串。Array.from()、箭头功能和字符串插值都是 ES2015 的功能。使用新的映射数组,让我们构造消息,它只是加入新数组。下面是代码片段:

...
const helloContinents = Array.from(continents, c => `Hello ${c}!`);
const message = helloContinents.join(' ');
...

现在,让我们在 heading 元素中使用构造的message变量,而不是硬编码的 Hello World 消息。与使用反勾号的 ES2015 字符串插值类似,JSX 让我们通过将 JavaScript 表达式括在花括号中来使用它。这些将被表达式的值替换。这不仅适用于 HTML 文本 Node,也适用于属性。例如,元素的类名可以是 JavaScript 变量。让我们使用这个特性来设置要在标题中显示的消息。

...
    <h1>{message}</h1>
...

修改后的App.jsx的完整源代码如清单 2-8 所示。

const continents = ['Africa','America','Asia','Australia','Europe'];
const helloContinents = Array.from(continents, c => `Hello ${c}!`);
const message = helloContinents.join(' ');

const element = (
  <div title="Outer div">
    <h1>{message}</h1>
  </div>
);

ReactDOM.render(element, document.getElementById('contents'));

Listing 2-8App.jsx: Changes to Show the World with ES2015 Features

如果您使用 Babel 转换它,重启服务器并指向它的浏览器。你会发现它可以在大多数现代浏览器上运行。但是,如果您查看转换后的文件App.js,您会发现 JavaScript 本身并没有改变,只有 JSX 被替换为React.createElement()调用。这在既不识别箭头函数语法也不识别Array.from()方法的旧浏览器上肯定会失败。

巴贝尔再次前来救援。我谈到了 Babel 能够进行的其他转换,这包括将较新的 JavaScript 特性转换成较旧的 JavaScript,即 ES5。就像 JSX 变换的react预置一样,每个特性都有一个插件。比如有个插件叫plugin-transform-arrow-functions。我们可以安装这个插件,并在 React 预置之外使用它,如下所示:

$ npm install --no-save @babel/plugin-transform-arrow-functions@7

我们使用了--no-save安装选项,因为这是一个临时安装,我们不希望package.json因为临时安装而改变。让我们使用这个插件并像这样转换源文件:

$ npx babel src --presets @babel/react
--plugins=@babel/plugin-transform-arrow-functions --out-dir public

现在,如果您检查转换的输出,App.js,您将看到箭头函数已经被常规函数所取代。

这很好,但是如何知道哪些插件必须被使用呢?找出哪些浏览器支持什么语法以及我们必须为每种浏览器选择什么转换将是一件很乏味的事情。幸运的是,Babel 通过一个名为preset-env的预置来自动解决这个问题。这个预设让我们指定需要支持的目标浏览器,并自动应用支持这些浏览器所需的所有转换和插件。

所以,让我们卸载transform-arrow-function预置,安装包含所有其他插件的env预置。

$ npm uninstall @babel/plugin-transform-arrow-functions@7
$ npm install --save-dev @babel/preset-env@7

我们不使用命令行(如果使用的话,会很长),而是指定需要在配置文件中使用的预置。Babel 在一个名为.babelrc的文件中寻找这个。事实上,在不同的目录中可以有一个.babelrc文件,该目录中文件的设置可以在每个目录中单独指定。因为我们在名为src的目录中有所有的客户端代码,所以让我们在那个目录中创建这个文件。

.babelrc文件是一个 JSON 文件,它可以包含预置和插件。预设被指定为一个数组。我们可以将这两个预设指定为数组中的字符串,如下所示:

...
{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

预置preset-env需要进一步配置以指定目标浏览器及其版本。这可以通过使用第一个元素作为预设名称后跟其选项的数组来实现。对于preset-env,我们将使用的选项称为targets,它是一个对象,以键作为浏览器名称,以值作为其目标版本:

  ["@babel/preset-env", {
    "targets": {
      "safari": "10",
       ...
     }
  }]

让我们包括对 IE 版本 11 和其他流行浏览器稍旧版本的支持。完整的配置文件如清单 2-9 所示。

{
  "presets": [
    ["@babel/preset-env", {
      "targets": {
        "ie": "11",
        "edge": "15",
        "safari": "10",
        "firefox": "50",
        "chrome": "49"
      }
    }],
    "@babel/preset-react"
  ]
}

Listing 2-9src/.babelrc: Presets Configured for JSX and ES5 Transform

现在,babel命令可以在命令行上不指定任何预置的情况下运行:

$ npx babel src --out-dir public

如果您运行这个命令,然后检查生成的App.js,您会发现箭头函数已经被一个常规函数所取代,字符串插值也已经被一个字符串连接所取代。如果您从配置文件中取出行ie: "11"并重新运行转换,您会发现这些转换不再存在于输出文件中,因为我们的目标浏览器已经本地支持这些特性。

但是,即使进行了这些转换,如果您在 Internet Explorer 版本 11 上测试,代码仍然无法工作。这是因为不仅仅是转变;有一些内置的东西,比如Array.find(),是浏览器中没有的。请注意,再多的编译或转换也不能像Array.find()实现那样添加一堆代码。我们真的需要这些实现作为函数库在运行时可用。

所有这些功能实现都被称为 polyfills ,以补充旧浏览器中缺失的实现。Babel 转换只能处理语法变化,但是需要这些 polyfills 来添加这些新函数的实现。Babel 也提供了这些聚合填充,只需将它们包含在 HTML 文件中就可以使用这些功能。巴别塔多填充可以在 unpkg 中找到,所以让我们把它包含在index.html中。清单 2-10 显示了index.html的变化,包括多孔填料。

...
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>

  <script src="https://unpkg.com/@babel/polyfill@7/dist/polyfill.min.js"></script>
</head>
...

Listing 2-10index.html: Changes for Including Babel Polyfill

现在,代码也可以在 Internet Explorer 上运行。图 2-2 显示了新的 Hello World 屏幕应该是什么样子。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2-2

新的 Hello World 屏幕

练习:旧版浏览器支持

  1. 通过使用<br />代替空格连接各个消息,尝试将消息格式化为每行一条。你能做到吗?为什么不呢?

本章末尾有答案。

使自动化

除了能够使用npm start启动项目,npm 还能够定义其他定制命令。当该命令有许多命令行参数,并且在 shell 上输入这些参数变得很繁琐时,这一点尤其有用。(我不是说过 npm 很厉害吗?这是它做的事情之一,即使这不是真正的软件包管理器功能。)这些定制命令可以在package.json的脚本部分指定。然后可以从控制台使用npm run <script>运行这些程序。

让我们添加一个名为compile的脚本,它的命令行是 Babel 命令行来完成所有的转换。我们不需要前缀npx,因为 npm 会自动计算出命令的位置,这些命令是任何本地安装包的一部分。我们需要在package.json做的附加工作是:

...
    "compile": "babel src --out-dir public",
...

转换现在可以这样运行:

$ npm run compile

在这之后,如果您再次运行npm start来启动服务器,您可以看到App.jsx的任何变化都反映在应用中。

注意

避免使用也是 npm 第一级命令的 npm 子命令名,如buildrebuild,因为如果在 npm 命令中省略了run,会导致无声错误。

当我们处理客户端代码并频繁更改源文件时,我们必须为每次更改手动重新编译它。如果有人能为我们检测到这些变化,并将源代码重新编译成 JavaScript,那不是很好吗?嗯,Babel 通过--watch选项支持开箱即用。为了使用它,让我们在 Babel 命令行中添加另一个名为watch的脚本,并增加这个选项:

...
    "watch": "babel src --out-dir public --watch --verbose"
...

它本质上是与 compile 相同的命令,但是有两个额外的命令行选项,--watch--verbose。第一个选项指示 Babel 监视源文件中的变化,第二个选项使它每当变化导致重新编译时就在控制台中打印出一行。这只是为了保证无论何时做出更改,编译都已经发生,只要您在运行该命令的控制台上保持警惕。

通过使用名为nodemon的包装器命令,可以对服务器代码的更改进行类似的重启。每当一组文件发生更改时,该命令都会使用指定的命令重新启动 Node.js。你也可能通过搜索互联网发现forever是另一个可以用来实现同样目标的包。通常,forever用于在崩溃时重启服务器,而不是监视文件的变化。最佳实践是在开发期间使用nodemon(真正需要观察变化的地方)和在生产中使用forever(崩溃时需要重启)。那么,现在让我们安装nodemon:

$ npm install nodemon@1

现在,让我们使用nodemon来启动服务器,而不是package.jsonstart的脚本规范中的 Node.js。命令nodemon还需要一个选项来指示使用-w选项来监视哪个文件或目录的变化。因为所有的服务器文件都将放在名为server的目录中,所以当该目录中的任何文件发生变化时,我们可以使用-w servernodemon重启 Node.js。因此,package.json中启动脚本的新命令现在将是:

...
    "start": "nodemon -w server server/server.js"
...

清单 2-11 显示了在package.json中添加或更改的最后一组脚本。

...
  "scripts": {
    "start": "node server/server.js",
    "start": "nodemon -w server server/server.js",
    "compile": "babel src --out-dir public",
    "watch": "babel src --out-dir public --watch --verbose",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

Listing 2-11Package.json: Adding Scripts for Transformation

如果您现在使用npm run watch运行新命令,您会注意到它执行了一次转换,但是没有返回到 shell。它实际上是在一个永久的循环中等待,观察源文件的变化。所以,要运行服务器,需要另一个终端,在那里可以执行npm start

如果你对App.jsx做了一个小的改变并保存文件,你会看到public目录中的App.js被重新生成。而且,当您刷新浏览器时,您可以看到这些更改,而不必手动重新编译。您还可以对server.js进行任何更改,并看到服务器启动,控制台上会显示一条消息,提示服务器正在重启。

摘要

在本章中,您学习了如何构建 React 应用的基础知识。我们从运行时编译的用 React JSX 编写的一段简单代码开始,然后我们将编译和文件提供给服务器。

我们用 nvm 安装 Node.js 您看到了 npm 不仅可以用来安装 Node.js 包,还可以用来在传统的或容易发现的脚本中保存命令行指令。然后,我们使用 Babel 来 transpile ,也就是说,将语言的一种规范转换或编译成另一种规范,以支持更老的浏览器。Babel 还帮助我们将 JSX 转化为纯 JavaScript。

您还对 Node.js with Express 的功能有所了解。我们没有使用 MongoDB,即 MERN 堆栈中的 M,但是我希望您能够很好地了解堆栈的其他组件。

到目前为止,您应该已经熟悉了这本书的 GitHub 库是如何组织的,以及书中使用的约定。对于每个部分,都有一组可测试的代码,您可以将自己的代码与它们进行比较。重要的是,每一步之间的差异对于理解每一步中发生的确切变化是有价值的。请再次注意,GitHub 资源库中的代码是值得依赖的,其中的最新更改无法在印刷书籍中体现出来。如果你发现你已经一字不差地遵循了这本书,但是事情并不像预期的那样工作,请参考 GitHub 库,看看印刷的书的错误是否已经在那里被纠正。

在接下来的两章中,我们将更深入地研究 React,然后在后面的章节中讨论 API、MongoDB 和 Express。

练习答案

练习:无服务器 Hello World

  1. 要在React.createElement()中指定一个类,我们需要用{ className: <name>}代替{ class: <name>}。这是因为class是 JavaScript 中的保留字,我们不能把它作为对象中的字段名。

  2. element变量包含一个嵌套的元素树,它反映了 DOM 应该包含的内容。我称之为虚拟世界,这也是人们通常所说的。

练习:JSX

  1. 删除脚本类型将导致浏览器将其视为常规 JavaScript,我们将在控制台上看到语法错误,因为 JSX 不是有效的 JavaScript。相反,移除 Babel 编译器将导致该脚本被忽略,因为浏览器不识别类型为text/babel的脚本,它将忽略该脚本。在这两种情况下,应用都不会工作。

  2. 缩小版的 React 隐藏或缩短了运行时错误。非精简版本给出了完整的错误和有用的警告。

练习:项目设置

  1. package.json是在我们使用npm init创建项目时创建的。事实上,我们在运行npm init时对提示的所有 React 都记录在了package.json中。

  2. 当使用--no-save时,npm 保持文件package.json不变。由此可见,package.json早就保留了明示的从属关系。不带任何选项或参数运行npm install会安装package.json中列出的所有依赖项。因此,您可以手动将依赖项添加到package.json中,只需使用npm install

  3. --save-dev选项在devDependencies而不是dependencies添加包。开发依赖列表将不会被安装到产品中,这由被设置为字符串production的环境变量NODE_ENV来指示。

  4. 包文件安装在项目下的目录node_modules下。npm ls以树状方式列出所有已安装的软件包。--depth=0将树深度限制在顶层包。删除整个node_modules目录是确保您开始清理的一种方式。

练习:快速

  1. 静态文件中间件并没有像对待index.html那样特别对待hello.html,所以你将不得不像这样使用文件名来访问应用:http://localhost:3000/hello.html

  2. 为了通过不同的挂载点访问静态文件,在中间件生成的帮助函数中指定前缀作为第一个参数。比如app.use('/public', express.static('/public'))

练习:JSX 变换

  1. App.js 现在包含纯 JavaScript,所有 JSX 元素都转换成了React.createElement()调用。当转换发生在浏览器中时,我们之前看不到这种转换。

  2. 当我们部署代码时,我们将只部署应用的预构建版本。也就是说,我们将在构建服务器或我们的开发环境上转换 JSX,并将结果 JavaScript 推到我们的生产服务器上。因此,在生产服务器上,我们将不需要构建应用所需的工具。因此,我们使用了--save-dev,这样,在生产服务器上,就不需要安装这个包了。

练习:旧浏览器支持

  1. React 故意这样做,以避免跨站点脚本漏洞。插入 HTML 标记并不容易,尽管有一种使用元素的dangerouslySetInnerHTML属性的方法。正确的做法是组成一个组件数组。我们将在后面的章节中探讨如何做到这一点。*

三、React 组件

在 Hello World 示例中,我们使用纯 JSX 创建了一个非常基本的 React 本地组件。然而,在现实世界中,您想要做的远不止一个简单的单线 JSX 所能做的。这就是 React 组件的用武之地。React 组件可以使用其他组件和基本 HTML 元素组成;它们可以响应用户输入、改变状态、与其他组件交互等等。

但是,在进入所有细节之前,让我首先描述一下我们将作为本书的一部分构建的应用。在本章以及后续章节中的每一步,我们将逐一介绍需要执行的应用或任务的特性,并解决它们。我喜欢这种方法,因为当我将它们立即投入使用时,我学到了最好的东西。这种方法不仅让你欣赏和内化概念,因为你把它们用上了,而且把更有用和实用的概念带到了最前面。

我想出的这个应用是大多数开发人员都能理解的。

问题跟踪器

我相信你们大多数人都熟悉 GitHub 问题或吉拉。这些应用帮助您创建一堆问题或错误,将它们分配给人们,并跟踪它们的状态。这些本质上是管理一系列对象或实体的 CRUD 应用(创建、读取、更新和删除数据库中的记录)。CRUD 模式非常有用,因为几乎所有的企业应用都是在不同的实体或对象上围绕 CRUD 模式构建的。

在问题跟踪器的情况下,我们将只处理单个对象或记录,因为这足以描述模式。一旦您掌握了如何在 MERN 实现 CRUD 模式的基本原理,您就能够复制该模式并创建一个真实的应用。

以下是问题跟踪应用的需求列表,这是 GitHub 问题或吉拉的简化或低调版本:

  • 用户应该能够查看问题列表,并能够通过各种参数过滤列表。

  • 用户应该能够通过提供问题字段的初始值来添加新问题。

  • 用户应该能够通过更改问题的字段值来编辑和更新问题。

  • 用户应该能够删除问题。

问题应具有以下属性:

  • 总结问题的标题(自由格式的长文本)

  • 向其分配问题的所有者(自由格式短文本)

  • 状态指示器(可能的状态值列表)

  • 创建日期(自动分配的日期)

  • 解决问题所需的努力(天数,一个数字)

  • 预计完成日期或到期日期(日期,可选)

请注意,我包含了不同类型的字段(列表、日期、数字、文本),以确保您了解如何处理不同的数据类型。我们将从简单开始,一次构建一个特性,并在过程中了解 MERN 堆栈。

在本章中,我们将创建 React 类并实例化组件。我们还将通过组合较小的组件来创建较大的组件。最后,我们将在这些组件之间传递数据,并根据数据动态创建组件。就功能而言,本章的目标是展示问题跟踪器的主页:问题列表。我们将对用于显示页面的数据进行硬编码,并将从服务器检索数据的工作留到下一章。

React 类

在这一节中,我们的目标是将单行 JSX 转换成一个从 React 类实例化的简单 React 组件,以便我们稍后可以使用第一个类 React 组件的全部功能。

React 类用于创建真正的组件(与模板化的 HTML 相反,在模板化的 HTML 中,我们基于变量创建 Hello World 消息,这是我们在上一章中创建的)。这些类可以在其他组件中重用,处理事件等等。首先,让我们用一个简单的类代替 Hello World 示例,该类构成了问题跟踪器应用的起点。

React 类是通过扩展React.Component创建的,所有定制类都必须从这个基类派生。在类定义中,至少需要一个render()方法。当 React 需要在 UI 中显示组件时,它调用这个方法。

还有其他一些对 React 有特殊意义的方法可以实现,称为生命周期方法。这些提供了组件形成和其他事件的不同阶段的挂钩。我们将在后面的章节中讨论其他的生命周期函数。但是render()必须存在的一个,否则组件将没有屏幕存在。render()函数应该返回一个元素(可以是一个本地 HTML 元素,比如<div>,也可以是另一个 React 组件的实例)。

让我们将 Hello World 示例从一个简单的元素改为使用一个名为HelloWorld的 React 类,它是从React.Component扩展而来的:

...
class HelloWorld extends React.Component {
  ...
}
...

注意

我们使用 ES2015 class关键字和extends关键字来定义一个 JavaScript 类。React 建议使用 ES2015 类。如果您不熟悉 JavaScript 类,请阅读并了解从 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes 开始的类。

现在,在这个类中,需要一个render()方法,它应该返回一个元素。我们将使用与消息相同的 JSX <div>作为返回的元素。

...
  render() {
    return (
      <div title="Outer div">
        <h1>{message}</h1>
      </div>
    );
...

让我们也将所有用于消息构造的代码移到render()函数中,这样它仍然封装在需要的范围内,而不是污染全局名称空间。

...
  render() {
    const continents = ['Africa','America','Asia','Australia','Europe'];
    const helloContinents = Array.from(continents, c => `Hello ${c}!`);
    const message = helloContinents.join(' ');

    return (
      ...
    );
...

本质上,JSX 元素现在是从名为 Hello World 的组件类的render()方法返回的。Hello World 元素的 JSX 表示形式周围的括号不是必需的,但这是一种惯例,通常用于使代码更具可读性,尤其是当 JSX 跨越多行时。

正如使用形式为<div></div>的 JSX 创建一个div元素的实例一样,HelloWorld类的实例也可以这样创建:

...
const element = <HelloWorld />;
...

现在,这个元素可以用来代替<div>元素,在名为contents的 Node 中进行渲染,就像以前一样。这里值得注意的是,divh1是内置的 React 组件或元素,可以直接实例化。而HelloWorld是我们定义并随后实例化的东西。并且在HelloWorld内,我们使用了 React 内置的div组件。清单 3-1 中显示了新的变更后的App.jsx

将来,我可能会互换使用组件和组件类,就像有时我们倾向于使用类和对象一样。但是现在应该很明显,HelloWorlddiv实际上是 React 组件类,而<HelloWorld /><div />是组件类的有形组件或实例。不用说,只有一个HelloWorld类,但是基于这个类可以实例化许多HelloWorld组件。

class HelloWorld extends React.Component {
  render() {
    const continents = ['Africa','America','Asia','Australia','Europe'];
    const helloContinents = Array.from(continents, c => `Hello ${c}!`);
    const message = helloContinents.join(' ');

    return (
      <div title="Outer div">
        <h1>{message}</h1>
      </div>
    );
  }
}

const element = <HelloWorld />;

ReactDOM.render(element, document.getElementById('contents'));

Listing 3-1App.jsx: A Simple React Class and Instance

到现在为止,您应该在一个控制台中运行npm run watch,并且在一个单独的控制台中使用npm start启动服务器。因此,对App.jsx的任何修改都应该被自动编译。因此,如果你刷新你的浏览器,你应该看到所有大洲的问候,就像以前一样。

练习:React 类

  1. render函数中,不是返回一个<div>,而是尝试返回两个前后放置的<div>元素。会发生什么?为什么,解决方案是什么?确保你看着控制台运行npm run watch

  2. 通过将字符串'contents'更改为'main'或其他不能识别 HTML 中元素的字符串,为 React 库创建一个运行时错误。从哪里可以看出错误?像未定义的变量引用这样的 JavaScript 运行时错误怎么办?

本章末尾有答案。

构成组件

在上一节中,您看到了如何通过将内置的 React 组件(相当于 HTML 元素)放在一起来构建组件。也可以构建一个使用其他用户定义组件的组件,这就是我们将在本节中探讨的内容。

组件组合是 React 最强大的特性之一。这样,UI 可以被分割成更小的独立部分,这样每个部分都可以独立地编码和推理,从而更容易构建和理解复杂的 UI。使用组件而不是以整体的方式构建 UI 也鼓励重用。我们将在后面的章节中看到我们构建的组件是如何被轻松重用的,即使我们在构建组件的时候没有想到重用。

组件接受输入(称为属性),其输出是组件的呈现 UI。在本节中,我们将不使用输入,而是将细粒度的组件放在一起构建一个更大的 UI。在编写组件时,需要记住以下几点:

  • 当细粒度组件之间可能存在逻辑分离时,应该将较大的组件拆分为细粒度组件。在本节中,我们将创建逻辑上分离的组件。

  • 当有重用的机会时,可以构建从不同调用者接受不同输入的组件。当我们在第十章中为用户输入构建专门的部件时,我们将创建可重用的组件。

  • React 的哲学更喜欢组件组合,而不是继承。例如,现有组件的专门化可以通过将属性传递给通用组件而不是从它继承来完成。你可以在 https://reactjs.org/docs/composition-vs-inheritance.html 了解更多信息。

  • 一般来说,记住将组件之间的耦合保持在最低限度(耦合是指一个组件需要知道另一个组件的细节,包括它们之间传递的参数或属性)。

让我们设计应用的主页来显示问题列表,并能够过滤问题和创建新问题。因此,它将包含三个部分:一个用于选择显示哪些问题的过滤器、问题列表,以及最后一个用于添加问题的条目表单。我们现在关注的是组成组件,所以我们将只为这三个部分使用占位符。用户界面的结构和层次如图 3-1 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3-1

问题列表页面的结构

让我们定义三个占位符类——IssueFilterIssueTableIssueAdd——每个类中的<div>内只有一个占位符文本。IssueFilter组件将如下所示:

...
class IssueFilter extends React.Component {
  render() {
    return (
      <div>This is a placeholder for the issue filter.</div>
    );
  }
}
...

另外两个类——IssueTableIssueAdd——将是相似的,各有不同的占位符消息:

...
class IssueTable extends React.Component {
    ...
      <div>This is a placeholder for a table of issues.</div>
...
class IssueAdd extends React.Component {
    ...
      <div>This is a placeholder for a form to add an issue.</div>
...

为了将这些放在一起,让我们删除 Hello World 类,并添加一个名为IssueList的类。

...
class IssueList extends React.Component {
}
...

现在让我们添加一个render()方法。在这个方法中,让我们添加每个新占位符类的一个实例,用一条<hr>或水平线分隔。正如您在前面部分的练习中看到的,由于render()的返回必须是一个单一元素,所以这些元素必须包含在<div>或 React Fragment组件中。组件Fragment就像一个封闭的<div>,但是它对 DOM 没有影响。

让我们在IssueListrender()方法中使用这样一个Fragment组件:

...
  render() {
    return (
      <React.Fragment>
        <h1>Issue Tracker</h1>
        <IssueFilter />
        <hr />
        <IssueTable />
        <hr />
        <IssueAdd />
      </React.Fragment>
    );
  }
...

最后,让我们实例化IssueList类,而不是实例化一个HelloWorld类,我们将把它放在contents div 下。

...

const element = <HelloWorld />;

const element = <IssueList />;

...

理想情况下,每个组件都应该作为一个独立的文件来编写。但是目前,我们只有占位符,所以为了简洁起见,我们将所有的类保存在同一个文件中。此外,您还没有学会如何将多个类文件放在一起。在后面的阶段,当类扩展到它们的实际内容,并且我们也有方法从另一个类构建或引用一个类时,我们将把它们分离出来。

清单 3-2 显示了带有所有组件类的App.jsx文件的新内容。

class IssueFilter extends React.Component {
  render() {
    return (
      <div>This is a placeholder for the issue filter.</div>
    );
  }
}

class IssueTable extends React.Component {
  render() {
    return (
      <div>This is a placeholder for a table of issues.</div>
    );
  }
}

class IssueAdd extends React.Component {
  render() {
    return (
      <div>This is a placeholder for a form to add an issue.</div>
    );
  }
}

class IssueList extends React.Component {
  render() {
    return (
      <React.Fragment>
        <h1>Issue Tracker</h1>
        <IssueFilter />
        <hr />
        <IssueTable />
        <hr />
        <IssueAdd />
      </React.Fragment>
    );
  }
}

const element = <IssueList />;

ReactDOM.render(element, document.getElementById('contents'));

Listing 3-2App.jsx: Composing Components

这段代码的效果将是一个无趣的页面,如图 3-2 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3-2

通过组合组件跟踪问题

练习:组成组件

  1. 在开发人员控制台中检查 DOM。您看到任何对应于React.Fragment组件的 HTML 元素了吗?与使用一个<div>元素来封装各种元素相比,您认为这有什么用?

本章末尾有答案。

使用属性传递数据

组成没有任何变量的组件并不那么有趣。应该可以将不同的输入数据从父组件传递到子组件,并在不同的实例上进行不同的呈现。在问题跟踪器应用中,可以用不同输入实例化的一个这样的组件是显示单个问题的表行。根据不同的输入(问题),该行可以显示不同的数据。新的 UI 结构如图 3-3 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3-3

带有问题行的问题列表 UI 层次结构

因此,让我们创建一个名为IssueRow的组件,然后在IssueTable中多次使用它,传入不同的数据来显示不同的问题,就像这样:

...
class IssueTable extends React.Component {
  render() {
    return (
      <table>
        <thead>
          <tr>
            <th>ID</th>
            <th>Title</th>
          </tr>
        </thead>
        <tbody>
          <IssueRow /> {/* somehow pass Issue 1 data to this */}
          <IssueRow /> {/* somehow pass Issue 2 data to this */}
        </tbody>
      </table>
    );
  }
}
...

注意

JSX 本身不支持注释。为了添加注释,必须添加一个具有 JavaScript 样式注释的 JavaScript 片段。因此,表单{/* ... */}可用于在 JSX 内放置评论。像<!-- ... -->这样使用 HTML 风格的注释是行不通的。

事实上,在任何 JSX 代码片段中切换到 JavaScript 世界的方法就是使用花括号。在前一章中,我们用它来显示 Hello World 消息,这是一个使用语法{message}名为message的 JavaScript 变量。

将数据传递给子组件的最简单方法是在实例化组件时使用属性。我们在上一章中使用了title属性,但这是一个最终影响 DOM 元素的属性。任何自定义属性也可以以类似的方式从IssueTable传递:

...
    <IssueRow issue_title="Title of the first issue" />
...

我们使用名称issue_title而不是简单的title来避免这个自定义属性和 HTML title属性之间的混淆。现在,在孩子的render()方法中,属性的值可以通过一个叫做props的特殊对象变量来访问,这个变量可以通过this访问器获得。例如,issue_title的值是如何在IssueRow组件的单元格中显示的:

...
    <td>{this.props.issue_title}</td>
...

在本例中,我们传递了一个简单的字符串。其他数据类型甚至 JavaScript 对象都可以这样传递。通过使用花括号({})而不是引号,可以传递任何 JavaScript 表达式,因为花括号切换到 JavaScript 世界。

因此,让我们将问题的标题(作为字符串)、ID(作为数字)和行样式(作为对象)从IssueTable传递到IssueRow。在IssueRow类中,我们将使用这些传入的属性来显示 ID 和标题,并通过this.props访问这些属性来设置行的样式。

清单 3-3 中显示了完整的IssueRow类的代码。

class IssueRow extends React.Component {
  render() {
    const style = this.props.rowStyle;
    return (
      <tr>
        <td style={style}>{this.props.issue_id}</td>
        <td style={style}>{this.props.issue_title}</td>
      </tr>
    );
  }
}

Listing 3-3App.jsx: IssueRow Component, Accessing Passed-in Properties

我们为表格单元格使用了属性style,就像我们在常规 HTML 中使用它一样。但是请注意,这并不是真正的 HTML 属性。相反,它是一个被传递给内置 React 组件<td>属性。只是将td组件中的style属性解释并设置为 HTML style属性。大多数情况下,像style一样,属性的名称与 HTML 属性相同,但对于少数引起与 JavaScript 保留字冲突的属性,命名要求不同。因此,在 JSX,class HTML 属性需要是className。此外,HTML 属性中的连字符需要替换为骆驼大小写的名称,例如,max-length在 JSX 变成了maxLength

在位于 https://reactjs.org/docs/dom-elements.html 的 React 文档中可以找到 DOM 元素的完整列表以及如何指定这些元素的属性。

现在我们有一个IssueRow组件接收属性,让我们从父组件IssueTable传递它们。ID 和标题都很简单,但是我们需要传递的样式在 React 和 JSX 中有特殊的规范约定。

React 不需要 CSS 类型的字符串,而是需要将其指定为具有特定约定的对象,该约定包含一系列 JavaScript 键值对。这些键与 CSS 样式名相同,除了它们不是破折号(如border-collapse),而是骆驼大小写(如borderCollapse)。这些值是 CSS 样式值,就像在 CSS 中一样。指定像素值也有一种特殊的简写方式;你可以只用一个数字(比如 4)来代替字符串"4px"

让我们给这些行一个像素的银色边框和一些填充,比如说四个像素。封装该规范的样式对象如下:

...
    const rowStyle = {border: "1px solid silver", padding: 4};
...

这可以在实例化时使用rowStyle={rowStyle}传递给IssueRow组件。这个和其他变量可以传递给IssueRow,同时像这样实例化它:

...
<IssueRow rowStyle={rowStyle} issue_id={1}
  issue_title="Error in console when clicking Add" />
...

注意,我们没有对问题 ID 使用类似字符串的引号,因为它是一个数字,也没有对rowStyle使用类似字符串的引号,因为它是一个对象。我们使用花括号,这使得它成为一个 JavaScript 表达式。

现在,让我们构造IssueTable组件,它本质上是一个<table>,有一个标题行和两列(ID 和 title),以及两个硬编码的IssueRow组件。让我们也为表格指定一个内联样式来指示折叠的边框,并使用相同的rowStyle变量来指定标题行样式,使其看起来一致。

清单 3-4 显示了修改后的IssueTable组件类。

class IssueTable extends React.Component {
  render() {
    const rowStyle = {border: "1px solid silver", padding: 4};
    return (
      <table style={{borderCollapse: "collapse"}}>
        <thead>
          <tr>
            <th style={rowStyle}>ID</th>
            <th style={rowStyle}>Title</th>
          </tr>
        </thead>
        <tbody>
          <IssueRow rowStyle={rowStyle} issue_id={1}
            issue_title="Error in console when clicking Add" />
          <IssueRow rowStyle={rowStyle} issue_id={2}
            issue_title="Missing bottom border on panel" />
        </tbody>
      </table>
    );
  }
}

Listing 3-4App.jsx: IssueTable Passing Data to IssueRow

图 3-4 显示了代码中这些变化的影响。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3-4

将数据传递给子组件

练习:使用属性传递数据

  1. 尝试为表格添加一个属性border=1,就像我们在普通 HTML 中做的那样。会发生什么?为什么呢?提示:阅读 React API 参考的“DOM Elements”一节中标题为“所有支持的 HTML 属性”的部分。

  2. 为什么表格的内嵌样式中有一个双花括号?提示:与另一种风格相比,我们声明了一个变量并使用它,而不是内联指定它。

  3. 花括号是在 JSX 标记中间转义成 JavaScript 的一种方式。将这与 PHP 等其他模板语言中的类似技术进行比较。

本章末尾有答案。

使用子 Node 传递数据

还有另一种方法将数据传递给其他组件,即使用组件的类似 HTML 的 Node 的内容。在子组件中,可以使用名为this.props.children的特殊字段this.props来访问它。

就像在常规 HTML 中一样,React 组件可以嵌套。在 Hello World 示例中,我们在一个<div>中嵌套了一个<h1>元素。当组件被转换为 HTML 元素时,元素以相同的顺序嵌套。React 组件可以像<div>一样工作,接受嵌套元素。在这种情况下,JSX 表达式将需要包含开始和结束标记,并在其中嵌套元素。

但是,当父 React 组件渲染时,子组件不会自动位于其下,因为父 React 组件的结构需要确定子组件将出现的确切位置。因此,React 让父组件使用this.props.children访问子元素,并让父组件决定它需要显示在哪里。当需要将其他组件包装在父组件中时,这非常有用。例如,添加边框和填充的包装器<div>可以这样定义:

...
class BorderWrap extends React.Component {
  render() {
    const borderedStyle = {border: "1px solid silver", padding: 6};
    return (
      <div style={borderedStyle}>
        {this.props.children}
      </div>
    );
  }
}
...

然后,在呈现过程中,任何组件都可以用填充的边框包装,如下所示:

...
    <BorderWrap>
      <ExampleComponent />
    </BorderWrap>
...

因此,可以使用这种技术将其作为< IssueRow >的子内容嵌入,而不是将问题标题作为属性传递给IssueRow,如下所示:

...
  <IssueRow issue_id={1}>Error in console when clicking Add</IssueRow>
...

现在,在IssueRowrender()方法中,它将需要被称为this.props.children,而不是被称为this.props.issue_title,就像这样:

...
   <td style={borderedStyle}>{this.props.children}</td>
...

让我们修改应用,使用这种将数据从IssueTable传递到IssueRow的方法。让我们也传入一个嵌套的标题元素作为子元素,它是一个<div>,包含一段强调的文本。这一变化如清单 3-5 所示。

...
class IssueRow extends React.Component {
...
    return (
      <tr>
        <td style={style}>{this.props.issue_id}</td>
        <td style={style}>{this.props.issue_title}</td>
        <td style={style}>{this.props.children}</td>
      </tr>
    );
...
}
...
...
class IssueTable extends React.Component {
...
        <tbody>
          <IssueRow rowStyle={rowStyle} issue_id={1}
            issue_title="Error in console when clicking Add" />
          <IssueRow rowStyle={rowStyle} issue_id={2}
            issue_title="Missing bottom border on panel" />
          <IssueRow rowStyle={rowStyle} issue_id={1}>
            Error in console when clicking Add
          </IssueRow>
          <IssueRow rowStyle={rowStyle} issue_id={2}>
            <div>Missing <b>bottom</b> border on panel</div>
          </IssueRow>
        </tbody>
...

Listing 3-5App.jsx: Using Children Instead of Props

这些变化对输出的影响很小,只在第二期的标题中看到一点点格式。如图 3-5 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3-5

将数据传递给子组件

练习:使用子 Node 传递数据

  1. 什么时候以propschildren的形式传递数据比较合适?提示:想想我们想要传递的是什么。

本章末尾有答案。

动态构图

在这一节中,我们将用一系列问题中以编程方式生成的组件集替换我们的硬编码组件集IssueRow。在后面的章节中,我们将通过从数据库获取问题列表来变得更加复杂,但是现在,我们将使用一个简单的内存 JavaScript 数组来存储问题列表。

让我们将问题的范围从仅仅一个 ID 和一个标题扩展到尽可能多的领域。清单 3-6 展示了这个内存数组,它在文件App.jsx的开头被全局声明。它只有两个问题。字段due在第一条记录中未定义,以确保我们处理这是一个可选字段的事实。

const issues = [
  {
    id: 1, status: New', owner: 'Ravan', effort: 5,
    created: new Date('2018-08-15'), due: undefined,
    title: 'Error in console when clicking Add',
  },
  {
    id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
    created: new Date('2018-08-16'), due: new Date('2018-08-30'),
    title: 'Missing bottom border on panel',
  },
];

Listing 3-6App.jsx

: In-Memory Array of Issues

您可以添加更多的示例问题,但两个问题足以演示动态合成。现在,让我们修改IssueTable类来使用这个问题数组,而不是硬编码的列表。在IssueTable class’ render()方法中,让我们遍历问题数组,并从中生成一个IssueRows数组。

为此,Arraymap()方法很方便,因为我们可以将一个问题对象映射到一个IssueRow实例。此外,让我们传递issue对象本身,而不是将每个字段作为属性传递,因为有许多字段作为对象的一部分。这是一种在表体中就地实现的方法:

...
    <tbody>
      {issues.map(issue => <IssueRow rowStyle={rowStyle} issue={issue}/>)}
    </tbody>
...

如果你想使用一个for循环而不是map()方法,你不能在 JSX 中这样做,因为 JSX 并不是真正的模板语言。它只允许花括号内的 JavaScript 表达式。我们必须在render()方法中创建一个变量,并在 JSX 中使用它。出于可读性考虑,我们还是这样为问题行集创建变量:

...
  const issueRows = issues.map(issue => <IssueRow rowStyle={rowStyle} issue={issue}/>);
...

现在,我们可以将IssueTable中的两个硬编码问题组件替换为<tbody>元素中的这个变量,如下所示:

...
    <tbody>
      {issueRows}
    </tbody>
...

在其他框架和模板语言中,使用模板创建多个元素需要在模板语言中使用特殊的for循环结构(例如 AngularJS 中的ng-repeat)。但是在 React 中,常规 JavaScript 可以用于所有编程结构。这不仅为您提供了 JavaScript 操纵模板的全部能力,还减少了您需要学习和记忆的结构数量。

IssueTable类中的标题行现在需要为每个问题字段提供一列,所以让我们也这样做。但是现在,为每个单元格指定样式变得很乏味,所以让我们为表格创建一个类,将其命名为table-bordered,并使用 CSS 来为表格和每个表格单元格设置样式。这种风格需要成为index.html的一部分,清单 3-7 显示了对该文件的修改。

...
  <script src="https://unpkg.com/@babel/polyfill@7/dist/polyfill.min.js"></script>
  <style>
    table.bordered-table th, td {border: 1px solid silver; padding: 4px;}
    table.bordered-table {border-collapse: collapse;}
  </style>
</head>
...

Listing 3-7index.html: Styles for Table Borders

现在,我们可以从所有的表格单元格和表格标题中删除rowStyle。需要做的最后一件事是用一个名为key的属性来标识IssueRow的每个实例。这个键的值可以是任何值,但是它必须唯一地标识一行。React 需要这个key,这样它就可以在情况发生变化时优化差异的计算,例如,当插入一个新行时。我们可以使用问题的 ID 作为键,因为它唯一地标识了行。

清单 3-8 显示了最终的IssueTable类,它包含一组动态生成的IssueRow组件和修改后的头部。

class IssueTable extends React.Component {
  render() {
    const issueRows = issues.map(issue =>
      <IssueRow key={issue.id} issue={issue} />
    );

    return (
      <table className="bordered-table">
        <thead>
          <tr>
            <th>ID</th>
            <th>Status</th>
            <th>Owner</th>
            <th>Created</th>
            <th>Effort</th>
            <th>Due Date</th>
            <th>Title</th>
          </tr>
        </thead>
        <tbody>
          {issueRows}
        </tbody>
      </table>
    );
  }
}

Listing 3-8App.jsx: IssueTable Class with IssueRows Dynamically Generated and Modified Header

IssueRow的变化相当简单。必须删除内联样式,还需要添加几列,每个添加的字段一列。因为 React 不会在要显示的对象上自动调用toString(),所以日期必须显式地转换为字符串。toString()方法会产生一个很长的字符串,所以让我们用toDateString()来代替。由于字段due是可选的,我们需要在调用字段toDateString()之前检查它是否存在。一种简单的方法是在如下表达式中使用三元运算符? - ::

...
  issue.due ? issue.due.toDateString() : ''
...

三元运算符非常方便,因为它是一个 JavaScript 表达式,可以直接用来代替显示字符串。否则,要使用if-then-else语句,代码必须在 JSX 部分之外,在render()方法实现的开始。新的IssueRow类如清单 3-9 所示。

class IssueRow extends React.Component {
  render() {
    const issue = this.props.issue;
    return (
      <tr>
        <td>{issue.id}</td>
        <td>{issue.status}</td>
        <td>{issue.owner}</td>
        <td>{issue.created.toDateString()}</td>
        <td>{issue.effort}</td>
        <td>{issue.due ? issue.due.toDateString() : ''}</td>
        <td>{issue.title}</td>
      </tr>
    );
  }
}

Listing 3-9App.jsx: New IssueRow Class Using Issue Object Property

经过这些更改后,屏幕应该如图 3-6 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3-6

发出从数组以编程方式构造的行

练习:动态构图

  1. 我们使用问题的id字段作为键值。还有什么其他的钥匙可以用?你会选择哪一个?

  2. 在上一节中,我们将问题的每个字段作为单独的属性传递给了IssueRow。在本节中,我们传递了整个问题对象。为什么呢?

  3. 不要使用局部变量issueRows,尝试直接在<tbody>中使用映射表达式。有用吗?它告诉我们什么?

本章末尾有答案。

摘要

在本章中,我们创建了问题跟踪器主页的一个准系统版本。我们开始使用 React 类,而不是简单的元素,其中一些只是占位符,用来描述我们尚未开发的组件。我们通过编写细粒度的单个组件并将它们放在一起(组合)到一个封闭组件中来实现这一点。我们还将参数或数据从封装组件传递到其子组件,从而重用组件类并使用不同的数据对其进行不同的呈现,动态地使用map()来基于输入数据的数组生成组件。

这些组件除了根据输入数据呈现自己之外,没有做太多事情。在下一章,我们将看到用户交互如何影响数据和改变组件的外观。

练习答案

练习:React 类

  1. 编译将失败,并出现错误“相邻的 JSX 元素必须用封闭标记括起来”。render()方法只能有一个返回值,因此,它只能返回一个元素。将两个<div>放在另一个< div >中是一种解决方案,或者如错误消息所示,使用一个Fragment组件是另一种解决方案,我们将在后面的章节中讨论。

  2. 如果是 React 错误,React 会在浏览器的 JavaScript 控制台中打印错误。控制台中也会显示常规的 JavaScript 错误,但显示的代码不是原代码;就是编译好的代码。我们将在后面的章节中学习如何使用原始源代码进行调试。

练习:组成组件

  1. 不,没有封闭元素。由IssueList返回的所有元素都直接出现在contents div 下。在这种情况下,我们可以很容易地使用一个<div>来包含元素。

    But imagine a situation where a list of table-rows needs to be returned, like this:

    ...
      <tr> {/* contents of row 1 */} </tr>
      <tr> {/* contents of row 2 */} </tr>
    ...
    
    

    然后,调用组件将这些行放在一个<tbody>元素下。添加一个<div>来包含这些行会导致无效的 DOM 树,因为<tbody>中不能有<div>。在这种情况下,碎片是唯一的选择。

练习:使用属性传递数据

  1. 将不显示边框。React 解释每个元素属性的方式与 HTML 解析器不同。边框属性不是受支持的属性之一。React 完全忽略了 border 属性。

  2. 外面的大括号表示属性值是一个 JavaScript 表达式。内部大括号指定一个对象,它是属性的值。

  3. React 的花括号和 PHP 的<?php ... ?>类似,略有区别。标签内的内容是成熟的程序,而在 JSX,你只能有 JavaScript 表达式。所有像for循环这样的编程结构都是在 JSX 之外用普通 JavaScript 编写的。

练习:使用子 Node 传递数据

  1. 对于传递任何类型的数据都很灵活和有用。另一方面,children只能是一个*元素,*也可以深度嵌套。因此,如果您有简单的数据,就将其作为props传递。如果您要传递一个组件,如果它嵌套很深并且自然地出现在子组件中,您可以使用children。组件也可以作为props来传递,通常是当您想要传递多个组件或者组件不是父组件的自然子内容时。

练习:动态构图

  1. 属性的另一个选择是数组索引,因为它也是惟一的。如果键是一个像 UUID 这样的大值,您可能会认为使用数组索引更有效,但实际上并非如此。React 使用键识别该行。如果它找到了相同的键,它就假定这是同一行。如果该行没有更改,它不会重新呈现该行。

    因此,如果插入一行,如果行的键是对象的 ID,React 将更有效地移动现有的行,而不是重新呈现整个表。如果使用数组索引,它会认为插入行之后的每一行都已更改,并重新呈现每一行。

  2. 传递整个对象显然更简洁。只有当被传递的属性数量是对象的全部属性的一个小的子集时,我才会选择传递单个属性。

  3. 它是有效的,尽管事实上我们在表达式中有 JSX。花括号内的任何内容都被解析为 JavaScript 表达式。但是因为我们在 JavaScript 表达式上使用了 JSX 变换,所以这些片段也将经过变换。这是可能的嵌套更深,并使用另一套花括号内嵌套的 JSX,等等。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值