在本章和下一章中,我们将暂停添加特性。相反,当应用变得越来越大时,我们将会变得更有组织性。
在这一章中,我们将再次审视该架构,并使其更加灵活,以便它能够满足具有大量流量的大型应用的需求。我们将使用一个名为 dotenv 的包来帮助我们在不同的环境中运行相同的代码,为每个环境使用不同的配置,比如开发和生产。
最后,我们将添加检查,以验证我们编写的代码遵循标准和良好的实践,并在测试周期的早期捕获可能的错误。为此,我们将使用 ESLint。
到目前为止,我们并没有太关注应用的架构,并且唯一的服务器处理两个功能。Express 服务器不仅提供静态内容,还提供 API 调用。该架构如图 7-1 所示。
图 7-1
单一服务器架构
所有的请求都在同一个物理服务器上,其中有一个也是唯一的 Express 应用。然后,根据请求将请求路由到两个不同的中间件。目录中的任何请求匹配文件都由名为static的中间件进行匹配。这个中间件使用磁盘来读取文件并提供文件内容。其他匹配/graphql路径的请求由 Apollo 服务器的中间件处理。这个中间件使用解析器从 MongoDB 数据库获取数据。
这对于小型应用非常有用,但是随着应用的增长,会出现以下一种或多种情况:
-
API 有其他的消费者,不仅仅是基于浏览器的 UI。例如,API 可能会暴露给第三方或移动应用。
-
这两部分有不同的扩容要求。通常,随着 API 的消费者越来越多,您可能需要多个 API 服务器和一个负载平衡器。然而,由于大多数静态内容能够并且将会被缓存在浏览器中,所以为静态资产提供许多服务器可能是大材小用。
此外,两种功能都在同一个服务器上完成,都在同一个 Node.js 和 Express 流程中,这使得诊断和调试性能问题变得更加困难。一个更好的选择是将这两个功能分成两个服务器:一个提供静态内容,另一个只托管 API。
在后面的章节中,我将介绍服务器呈现,其中完整的页面将从服务器生成,而不是在浏览器上构建。这有助于搜索引擎正确地索引页面,因为搜索引擎机器人不一定运行 JavaScript。当我们实现服务器渲染时,如果所有的 API 代码和 UI 代码都是分开的,将会有所帮助。
图 7-2 描绘了 UI 和 API 服务器分离的新一代架构。它还展示了当我们实现服务器端渲染时,它最终将适用于何处。
图 7-2
独立的 UI 服务器架构
在图 7-2 的图中,可以看到有两个服务器:UI 服务器和 API 服务器。这些可以是物理上不同的计算机,但是出于开发的目的,我们将在同一台计算机上运行它们,但是在不同的端口上运行。这些将使用两个不同的 Node.js 进程运行,每个进程都有自己的 Express 实例。
API 服务器现在将只负责处理和API 请求,因此,它将只响应路径中匹配/graphql的 URL。因此,Apollo 服务器中间件及其对 MongoDB 数据库的请求将是 API 服务器中唯一的中间件。
UI 服务器部分现在将只包含和静态中间件。在未来,当我们引入服务器渲染时,该服务器将通过调用 API 服务器的 API 来获取必要的数据,从而负责生成 HTML 页面。目前,我们将只使用 UI 服务器来提供所有静态内容,包括index.html和包含所有 React 代码的 JavaScript 包。
浏览器将负责根据请求的类型使用适当的服务器:所有的 API 调用将被定向到 API 服务器,而静态文件将被提交到 UI 服务器。
为了实现这一点,我们要做的第一件事是创建一个新的目录结构,将 UI 和 API 代码清晰地分开。
理想情况下,UI 和 API 代码应该属于两个不同的存储库,因为它们之间没有共享。但是为了方便阅读这本书和参考 GitHub 库中的 Git diffs(https://github.com/vasansr/pro-mern-stack-2),我把代码放在一起,但是放在最顶层的不同目录中。
让我们重命名server目录api,而不是创建一个新的目录。
$ mv server api显示的命令(也可以在 GitHub 存储库(commands.md文件中的 https://github.com/vasansr/pro-mern-stack-2 )是为了在 MacOS 或基于 Linux 的发行版中的 bash shell 中执行。如果您使用的是 Windows PC,则必须使用 Windows 的等效命令。
因为我们拥有的所有脚本都只适用于 API 服务器,所以让我们将scripts目录也移到新目录api下。
$ mv scripts api对于 UI 代码,让我们在项目根目录下创建一个名为ui的新目录,并将 UI 相关的目录public和src移到这个目录下。
$ mkdir ui
$ mv public ui
$ mv src ui但是仅仅移动目录是不够的;我们需要在这些目录ui和api中各有一个package.json文件,既用于保存 npm 依赖关系,也用于创建运行服务器的便捷脚本。有了新的package.json文件并安装了所有的依赖项后,新的目录结构将如图 7-3 所示。
图 7-3
用于 UI 服务器分离的新目录结构
现在让我们在两个新目录中创建两个新的package.json文件。为了方便起见,您还可以从根项目目录中复制这个文件并进行修改。
在 API 对应的文件中,让我们在名称(例如,pro-mern-stack-2-api)和描述(例如,"Pro MERN Stack (2nd Edition) API")中使用 API 这个词。至于脚本,我们将只有一个脚本来启动服务器。由于文件的位置已经从server更改为当前目录,我们可以在这个脚本中删除nodemon的-w选项。
...
"start": "nodemon -e js,graphql server.js",
...至于依赖项,我们没有任何devDependencies,但是有运行服务器所需的所有常规依赖项。完整的package.json文件如清单 7-1 所示。
{
"name": "pro-mern-stack-2-api",
"version": "1.0.0",
"description": "Pro MERN Stack (2nd Edition) API",
"main": "index.js",
"scripts": {
"start": "nodemon -e js,graphql server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/vasansr/pro-mern-stack-2.git"
},
"author": "vasan.promern@gmail.com",
"license": "ISC",
"homepage": "https://github.com/vasansr/pro-mern-stack-2",
"dependencies": {
"apollo-server-express": "^2.3.1",
"express": "^4.16.4",
"graphql": "^0.13.2",
"mongodb": "^3.1.10",
"nodemon": "^1.18.9"
}
}
Listing 7-1api/package.json: New File尽管我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有被收入书中。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。
现在,让我们根据api目录中的新package.json文件安装所有的 npm 依赖项。
$ cd api
$ npm install因为我们将在这个新的api目录中运行服务器,所以我们需要从当前目录加载schema.graphql。因此,让我们修改server.js中的代码,从正在加载的schema.graphql的路径中删除/server/前缀。
...
const server = new ApolloServer({
typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
...我们还可以删除static中间件的加载,并在控制台消息中将新服务器称为 API 服务器,而不是应用服务器。清单 7-2 中显示了api/server.js的全套变更。
...
const server = new ApolloServer({
typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
...
const app = express();
app.use(express.static('public'));
server.applyMiddleware({ app, path: '/graphql' });
...
app.listen(3000, function () {
console.log('AppAPI server started on port 3000');
});
...
Listing 7-2api/server.js: Changes for New Location of schema.graphql此时,您应该能够使用npm start运行 API 服务器。此外,如果您使用 GraphQL Playground 测试 API,您应该会发现 API 像以前一样工作。
UI 服务器的变化有点复杂。我们需要一个新的既有服务器又有转换 npm 包的package.json,比如 Babel。让我们在 UI 目录中创建新的package.json。你可以通过从项目根目录复制或者运行npm init来完成。然后,在依赖项部分,让我们添加 Express 和 nodemon:
...
"dependencies": {
"express": "^4.16.4",
"nodemon": "^1.18.9"
},
...至于devDependencies,让我们从根目录下的package.json开始保留原设置。
...
"devDependencies": {
"@babel/cli": "^7.2.3",
"@babel/core": "^7.2.2",
"@babel/preset-env": "^7.2.3",
"@babel/preset-react": "^7.0.0"
}
...让我们安装 UI 服务器所需的所有依赖项。
$ cd ui
$ npm install现在,让我们创建一个 Express 服务器来服务目录ui中名为uiserver.js的静态文件。这与我们为 Hello World 创建的服务器非常相似。我们所需要的是带有static中间件的 Express 应用。文件内容如清单 7-3 所示。
const express = require('express');
const app = express();
app.use(express.static('public'));
app.listen(8000, function () {
console.log('UI started on port 8000');
});
Listing 7-3ui/uiserver.js: New Server for Static Content要运行这个服务器,让我们在package.json中创建一个启动它的脚本。这是您在其他服务器启动脚本中看到的常见的nodemon命令。这一次,我们将只关注uiserver.js文件,因为我们还有其他与服务器本身无关的文件。
...
"scripts": {
"start": "nodemon -w uiserver.js uiserver.js",
},
...此外,为了生成转换后的 JavaScript 文件,让我们添加compile和watch脚本,就像在原始的package.json文件中一样。该文件的完整内容,包括compile和watch脚本,如清单 7-4 所示。
{
"name": "pro-mern-stack-2-ui",
"version": "1.0.0",
"description": "Pro MERN Stack (2nd Edition) - UI",
"main": "index.js",
"scripts": {
"start": "nodemon -w uiserver.js uiserver.js",
"compile": "babel src --out-dir public",
"watch": "babel src --out-dir public --watch --verbose"
},
"repository": {
"type": "git",
"url": "git+https://github.com/vasansr/pro-mern-stack-2.git"
},
"author": "vasan.promern@gmail.com",
"license": "ISC",
"homepage": "https://github.com/vasansr/pro-mern-stack-2",
"dependencies": {
"express": "^4.16.3",
"nodemon": "^1.18.4"
},
"devDependencies": {
"@babel/cli": "^7.0.0",
"@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@babel/preset-react": "^7.0.0"
}
}
Listing 7-4ui/package.json: New File for the UI Server现在,您可以通过在每个对应的目录中使用npm start运行 UI 和 API 服务器来测试应用。至于转换,您可以在ui目录中运行npm run compile或npm run watch。但是 API 调用将会失败,因为端点/graphql在 UI 服务器中没有处理程序。因此,我们需要更改 UI 来调用 API 服务器,而不是对 UI 服务器进行 API 调用。这可以在App.jsx文件中完成,如清单 7-5 所示。
...
async function graphQLFetch(query, variables = {}) {
try {
const response = await fetch('http://localhost:3000/graphql', {
method: 'POST',
...
Listing 7-5ui/src/App.jsx: Point to a Different API Server现在,如果您测试应用,您会发现它像以前一样工作。我们也可以清理根目录。文件package.json和目录node_modules不再需要,可以删除。完成此操作的 Linux 和 MacOS 命令如下:
$ rm package.json
$ rm -rf node_modules- 打开一个新的浏览器标签,输入
http://localhost:3000。你看到了什么,为什么?我们需要对此做些什么吗?有哪些选择?提示:以类似的方式浏览到 GitHub 的 API 端点主机,在https://api.github.com。
本章末尾有答案。
我们推迟了移除硬编码的东西,比如端口号和 MongoDB URL。既然目录结构已经最终确定,现在可能是删除所有硬编码并将它们作为更容易更改的变量的好时机。
通常,有三种部署环境:开发、试运行和生产。每一个的服务器端口和 MongoDB URL 会有很大的不同。例如,API 服务器和 UI 服务器的端口都是 80。我们使用了两个不同的端口,因为两个服务器都运行在同一台主机上,并且两个进程不能在同一个端口上侦听。此外,我们使用 8000 这样的端口,因为使用端口 80 需要管理特权(超级用户权限)。
与其根据可能的部署目标(如开发、登台和生产)预先确定端口和 MongoDB URL,不如让变量保持灵活性,以便它们可以在运行时设置为任何值。提供这些的典型方式是通过环境变量,特别是对于远程目标和生产服务器。但是在开发过程中,最好能够在一些配置文件中包含这些内容,这样开发人员就不需要每次都记住设置这些内容。
让我们使用一个名为 dotenv 的包来帮助我们实现这一点。这个包可以将存储在文件中的变量转换成环境变量。因此,在代码中,我们只处理环境变量,但是环境变量可以通过真实的环境变量或配置文件来提供。
dotenv 包寻找一个名为.env的文件,它可以包含像在 shell 中定义的变量。例如,我们可以在该文件中包含以下行:
...
DB_URL=mongodb://localhost/issuetracker
...在代码中,我们要做的就是使用process.env.DB_URL查找环境变量DB_URL,并使用其中的值。这个值可以被程序启动前定义的实际环境变量覆盖,所以没有必要有这个文件。事实上,大多数生产部署只从环境变量中获取值。
现在让我们安装软件包,首先在 API 服务器中:
$ cd api
$ npm install dotenv@6要使用这个包,我们需要做的就是require它并立即调用它的config()。
...
require('dotenv').config();
...现在,我们可以通过使用process.env属性来使用任何环境变量。让我们首先在server.js中为 MongoDB URL 这样做。我们已经有了一个变量url,我们可以将它从process.env设置为DB_URL,如果没有定义的话,就将它默认为原来的本地主机值:
...
const url = process.env.DB_URL || 'mongodb://localhost/issuetracker';
...同样,对于服务器端口,我们使用一个名为API_SERVER_PORT的环境变量,并在server.js中使用一个名为port的变量,如下所示:
...
const port = process.env.API_SERVER_PORT || 3000;
...现在我们可以使用可变端口来启动服务器。
...
app.listen(3000port, function () {
console.log('API server started on port 3000');
console.log(`API server started on port ${port}`);
...请注意引号样式从单引号到反勾号的变化,因为我们使用了字符串插值。清单 7-6 显示了对api/server.js文件的一整套修改。
...
const fs = require('fs');
require('dotenv').config();
const express = require('express');
...
const url = process.env.DB_URL || 'mongodb://localhost/issuetracker';
// Atlas URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';
// mLab URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';
...
const port = process.env.API_SERVER_PORT || 3000;
(async function () {
try {
...
app.listen(3000port, function () {
console.log('API server started on port 3000');
console.log(`API server started on port ${port}`);
});
...
...
Listing 7-6api/server.js: Changes to Use Environment Variables让我们在api目录中创建一个名为.env的文件。我在 GitHub 资源库中包含了一个名为sample.env的文件,你可以从中复制并修改以适应你的环境,尤其是DB_URL。该文件的内容如清单 7-7 所示。
## DB
# Local
DB_URL=mongodb://localhost/issuetracker
# Atlas - replace UUU: user, PPP: password, XXX: hostname
# DB_URL=mongodb+srv://UUU:PPP@XXX.mongodb.net/issuetracker?retryWrites=true
# mLab - replace UUU: user, PPP: password, XXX: hostname, YYY: port
# DB_URL=mongodb://UUU:PPP@XXX.mlab.com:YYY/issuetracker
## Server Port
API_SERVER_PORT=3000
Listing 7-7api/sample.env: Sample .env File建议不要将.env文件签入任何存储库。每个开发人员和部署环境都必须根据自己的需要,在环境或该文件中专门设置变量。这是为了使对此文件的更改保留在开发人员的计算机中,而其他人的更改不会覆盖开发人员的设置。
更改nodemon命令行也是一个好主意,这样它可以监视对该文件的更改。由于当前命令行不包含 watch 规范(因为它默认为".",即当前目录),所以让我们也包含它。清单 7-8 显示了package.json中对这个脚本的修改。
...
"scripts": {
"start": "nodemon -e js,graphql -w . -w .env server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
...
Listing 7-8api/package.json: nodemon to Watch for .env现在,如果您在文件.env中将API_SERVER_PORT指定为4000并重启 API 服务器(因为 nodemon 需要知道新的观察文件),您应该会看到它现在使用端口 4000。您可以撤销这一更改,改为定义一个环境变量(不要忘记在 bash shell 中使用export来使该变量对子进程可用),并查看更改是否已经完成。注意,实际的环境变量优先于(或覆盖)在.env文件中定义的相同变量。
让我们也对api/scripts/trymongo.js做一组类似的更改,以使用环境变量DB_URL。这些变化如清单 7-9 所示。还有一些更改是在连接后打印出 URL,以交叉检查环境变量是否被使用。
require('dotenv').config();
const { MongoClient } = require('mongodb');
const url = process.env.DB_URL || 'mongodb://localhost/issuetracker';
// Atlas URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';
// mLab URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';
...
client.connect(function(err, client) {
...
console.log('Connected to MongoDB');
console.log('Connected to MongoDB URL', url);
...
await client.connect();
console.log('Connected to MongoDB');
console.log('Connected to MongoDB URL', url);
Listing 7-9api/scripts/trymongo.js: Read DB_URI from the Environment Using dotenv现在,您可以像以前一样使用命令行和 Node.js 来测试脚本,您将看到不同环境变量的效果,包括在 shell 和.env文件中。
我们需要对 UI 服务器进行类似的更改。在这种情况下,我们需要使用的变量是:
-
UI 服务器端口
-
要调用的 API 端点
UI 服务器端口更改类似于 API 服务器端口更改。让我们先把那件事做完。至于 API 服务器,我们来安装 dotenv 包。
$ cd ui
$ npm install dotenv@6然后,在ui/uiserver.js文件中,让我们要求并配置 dotenv:
...
require('dotenv').config();
...让我们也将硬编码的端口改为使用环境变量。
...
const port = process.env.UI_SERVER_PORT || 8000;
app.listen(8000 port, function () {
console.log('UI started on port 8000');
console.log(`UI started on port ${port}`);
});
...与这些变化不同,API 端点必须以 JavaScript 代码的形式到达浏览器。它不是可以从环境变量中读取的东西,因为它不会传输到浏览器。
一种方法是在构建和绑定过程中,用变量值替换代码中的预定义字符串。我将在下一节描述这个方法。尽管对许多人来说这是一个有效的首选,但我还是选择将配置设为运行时变量,而不是编译时变量。这是因为在真正的 UI 服务器上,设置服务器端口和 API 端点的方式是统一的。
为此,让生成一个 JavaScript 文件,并将其注入到index.html中。这个 JavaScript 文件将包含一个带有环境内容的全局变量。让我们称这个新的脚本文件为env.js,并将其包含在index.html中。这是本节中对index.html的唯一更改,如清单 7-10 所示。
...
<div id="contents"></div>
<script src="/env.js"></script>
<script src="/App.js"></script>
...
Listing 7-10ui/public/index.html: Include the Script /env.js现在,在 UI 服务器中,让我们生成这个脚本的内容。这应该会导致设置一个名为ENV的全局变量,其中一个或多个属性被设置为环境变量,如下所示:
...
window.ENV = {
UI_API_ENDPOINT: "http://localhost:3000"
}
...当 JavaScript 被执行时,它将初始化对象的全局变量ENV。当任何其他地方需要该变量时,可以从全局变量中引用它。现在,在 UI 服务器代码中,让我们首先为 API 端点初始化一个变量,如果找不到,就使用默认值。然后,我们将构造一个对象,只将这一个变量作为属性。
...
const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT || 'http://localhost:3000';
const env = { UI_API_ENDPOINT };
...现在,我们可以在服务器中创建一个路由来响应对env.js的 GET 调用。在该路由的处理程序中,让我们使用env对象根据需要构造字符串,并将其作为响应发送:
...
app.get('/env.js', function(req, res) {
res.send(`window.ENV = ${JSON.stringify(env)}`)
})
...清单 7-11 中显示了对ui/uiserver.js的完整更改。
require('dotenv').config();
const express = require('express');
const app = express();
app.use(express.static('public'));
const UI_API_ENDPOINT = process.env. UI_API_ENDPOINT || 'http://localhost:3000/graphql';
const env = { UI_API_ENDPOINT };
app.get('/env.js', function(req, res) {
res.send(`window.ENV = ${JSON.stringify(env)}`)
})
const port = process.env.UI_SERVER_PORT || 8000;
app.listen(8000port, function () {
console.log('UI started on port 8000');
console.log(`UI started on port ${port}`);
});
Listing 7-11ui/uiserver.js: Changes for Environment Variable Usage就像 API 服务器一样,让我们创建一个.env文件来保存两个变量,一个用于服务器的端口,另一个用于 UI 需要访问的 API 端点。您可以使用sample.env文件的副本,其内容如清单 7-12 所示。
UI_SERVER_PORT=8000
UI_API_ENDPOINT=http://localhost:3000/graphql
Listing 7-12ui/sample.env: Sample .env File for the UI Server最后,在App.jsx中,API 端点是硬编码的,让我们用来自全局ENV变量的属性替换硬编码。这一变化如清单 7-13 所示。
...
async function graphQLFetch(query, variables = {}) {
try {
const response = await fetch('http://localhost:3000/graphql', {
const response = await fetch(window.ENV.UI_API_ENDPOINT, {
...
...
}
...
Listing 7-13ui/src/App.jsx: Replace Hard-Coding of API Endpoint让我们也让 nodemon 监视.env文件中的变化。由于我们在 UI 服务器中指定了要监视的单个文件,这要求我们使用-w命令行选项添加另一个要监视的文件。对ui/package.json的更改如清单 7-14 所示。
...
"scripts": {
"start": "nodemon -w uiserver.js -w .env uiserver.js",
...
Listing 7-14ui/package.json: nodemon to Watch for Changes in .env现在,如果您使用默认端口和端点测试应用,应用应该像以前一样工作。如果您一直在控制台中运行npm run watch,对App.jsx的更改将会被自动重新编译。
您还可以通过实际的环境变量和对.env文件(如果有)的更改来确保对任何变量的更改生效。如果您通过一个环境变量来改变一个变量,那么如果您使用的是 bash shell,一定要记住导出它。此外,必须重新启动服务器,因为 nodemon 不会监视对任何环境变量的更改。
- 在浏览器中,手动键入
http://localhost:8000/env.js。你看到了什么?将环境变量UI_API_ENDPOINT设置到不同的位置,并重启 UI 服务器。检查env.js的内容。
本章末尾有答案。
如果你在测试时在开发者控制台打开了网络标签,你会注意到有两个对/graphql的调用,而不是一个。第一次调用的 HTTP 方法是OPTIONS。原因是 API 调用是针对不同于应用来源(http://localhost:8000)的主机(http://localhost:3000)。由于同源策略,这样的请求通常会被浏览器阻止,除非服务器特别允许。
同源策略的存在是为了防止恶意网站获得对应用的未授权访问。您可以在 https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy 阅读该政策的详细内容。但其要点是,由于由一个来源设置的 cookie 会自动附加到对该来源的任何请求,因此恶意网站可能会从浏览器调用该来源,并且浏览器会附加该 cookie。
假设您登录了一家银行的网站。在另一个浏览器选项卡中,您正在浏览一些运行恶意 JavaScript 的新闻网站,可能是通过网站上的广告。如果这个恶意的 JavaScript 对银行的网站进行 Ajax 调用,并将 cookies 作为请求的一部分发送出去,那么这个恶意的 JavaScript 最终会冒充您,甚至可能将资金转移到黑客的帐户上!
因此,浏览器通过要求这样的请求被明确允许来防止这种情况。可以允许的请求类型由同源策略以及由服务器控制的参数控制,服务器确定是否可以允许请求。这种机制被称为跨源资源共享或简称 CORS。默认情况下,Apollo GraphQL 服务器允许跨源的未经验证的请求。对OPTIONS请求的响应中的以下标题表明了这一点:
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Origin: *让我们禁用 Apollo 服务器的默认行为(当然,使用一个环境变量)并检查 API 服务器的新行为。让我们调用这个环境变量ENABLE_CORS并将api/.env文件设置为false(默认为true,当前行为)。
...
## Enable CORS (default: true)
ENABLE_CORS=false
...现在,在 API 的server.js中,让我们寻找这个环境变量,并根据这个变量将一个名为cors的选项设置为true或false。对api/server.js的更改如清单 7-15 所示。
...
const app = express();
const enableCors = (process.env.ENABLE_CORS || 'true') == 'true';
console.log('CORS setting:', enableCors);
server.applyMiddleware({ app, path: '/graphql', cors: enableCors });
...
Listing 7-15api/server.js: Option for Enabling CORS如果您测试应用,您会发现OPTION请求失败,HTTP 响应为 405。现在,应用不会受到恶意的跨站点攻击。但是这也意味着我们需要一些其他的机制来进行 API 调用。
我将更详细地讨论 CORS,以及为什么在应用的当前阶段启用 CORS 是安全的,因为所有的资源都是公开的,无需认证。但为了安全起见,我们也来看看替代方案。在这一节中,我们将改变 UI,甚至向 UI 服务器发出 API 请求,我们将安装一个代理,这样任何对/graphql的请求都会被路由到 API 服务器。这种新架构如图 7-4 所示。
图 7-4
基于代理的体系结构
使用http-proxy-middleware包可以很容易地实现这样的代理。让我们安装这个包:
$ cd ui
$ npm install http-proxy-middleware@0现在,代理可以用作软件包提供的中间件,安装在路径/graphql上,使用app.use()。创建中间件只需要一个选项:代理的目标,这是请求必须被代理的主机的基本 URL。让我们定义另一个名为API_PROXY_TARGET的环境变量,并使用它的值作为目标。如果这个变量是未定义的,我们可以跳过安装代理,而不是默认它。
清单 7-16 中显示了ui/uiserver.js的变更。
...
require('dotenv').config();
const express = require('express');
const proxy = require('http-proxy-middleware');
...
const apiProxyTarget = process.env.API_PROXY_TARGET;
if (apiProxyTarget) {
app.use('/graphql', proxy({ target: apiProxyTarget }));
}
const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT ||
...
Listing 7-16ui/uiserver.js: Changes to Install Proxy现在让我们更改在ui/.env中指定 API 端点的环境变量,将其设置为/graphql,这意味着/graphql在与原点相同的主机上。进一步,让我们定义代理的目标,变量API_PROXY_TARGET为http://localhost:3000。
...
UI_API_ENDPOINT=http://localhost:3000/graphql
API_PROXY_TARGET=http://localhost:3000
...现在,如果您测试应用并查看浏览器的开发人员控制台中的 Network 选项卡,您会发现对于每个 API 调用,只有一个请求发送到 UI 服务器(端口 8000 ),并成功执行。
您可以使用本节中描述的代理方法,或者让 UI 直接调用 API 服务器并在 API 服务器中启用 CORS。这两个选项都很好,您的实际选择取决于各种因素,例如您的部署环境和应用的安全需求。
出于阅读本书的目的,让我们将本节中对.env文件所做的更改还原,以便使用直接 API 调用机制。您可以将 API 和 UI 目录中的sample.env文件从 GitHub 库复制到您自己的.env文件中,这反映了 API 的直接工作方式。
一个棉绒(T2 棉绒的东西)检查可能是错误的可疑代码。它还可以检查您的代码是否符合您希望整个团队遵循的约定和标准,以使代码具有可预测的可读性。
虽然对于什么是好的标准有多种观点和争论(例如,制表符和空格),但是对于是否首先需要一个标准却没有争论。对于一个团队或者一个项目,采用一个标准远比采用正确的标准重要。
ESLint ( https://eslint.org )是一个非常灵活的 linter,可以让你定义你想要遵循的规则。但我们需要一些东西作为起点,最吸引我的规则是 Airbnb 的规则。其吸引力的部分原因是它的受欢迎程度:如果更多的人采用它,它就变得越标准化,所以更多的人最终会跟随它,成为一个良性循环。
Airbnb ESLint 配置有两个部分:基本配置适用于普通 JavaScript,常规配置也包括 JSX 和 React 的规则。在本节中,我们将只对后端代码使用 ESLint,这意味着我们只需要安装基本配置,以及基本配置所需的 ESLint 和其他依赖项:
$ cd api
$ npm install --save-dev eslint@5 eslint-plugin-import@2
$ npm install --save-dev eslint-config-airbnb-base@13ESLint 在.eslintrc文件中寻找一组规则,这是一个 JSON 规范。这些不是规则的定义,而是需要启用或禁用哪些规则的规范。规则集也可以被继承,这就是我们在配置中使用extends属性所要做的。使用一个.eslintrc文件使规则应用于该目录中的所有文件。对于单个文件中的覆盖,可以在该文件的注释中指定规则,甚至可以只在一行中指定。
配置文件中的规则在属性rules下指定,该属性是一个包含一个或多个规则的对象,由规则名标识,值是错误级别。错误等级为off、warning和error。例如,要指定规则quotes(检查字符串的单引号和双引号)应该显示警告,这就是规则需要被指定的方式:
...
rules: {
"quotes": "warning”
}
...许多规则都有选项,例如,规则quotes有一个选项,用于选择要执行的报价类型是单个还是两个。指定这些选项时,值需要是一个数组,第一个元素作为错误级别,第二个(或更多,取决于规则)是选项。下面是 quotes 规则如何选择一个选项来检查双引号:
...
"quotes": ["warning", "double"]
...先说一个基础配置,只继承了 Airbnb 的基础配置,没有任何规则。让我们使用env属性来具体说明代码将在哪里运行。因为所有的后端代码都只能在 Node.js 上运行(并且只能在 Node.js 上运行),所以这个属性对于值为true的node只有一个条目。下面是.eslintrc文件在这个阶段的样子:
{
"extends": "airbnb-base",
"env": {
"node": "true"
}
}现在,让我们在整个api目录上运行 ESLint。执行此操作的命令行如下:
$ cd api
$ npx eslint .或者,您可以在编辑器中安装一个插件,在编辑器中显示 lint 错误。流行的代码编辑器 Atom 和 Sublime 都有插件来处理这个问题;按照各自网站上的说明安装插件。然后,我们将查看每种类型的错误或警告,并处理它。
对于大多数错误,我们只是要更改代码以符合建议的标准。但在少数情况下,我们会对 Airbnb 规则进行例外处理。这可能是针对整个项目,或者在某些情况下,针对特定文件或文件中的某一行。
让我们看看每种类型的错误并修复它们。请注意,我只是在讨论 ESLint 在我们到目前为止编写的代码中可能会发现的错误。当我们写更多的代码时,我们会修复所有的 lint 错误,所以强烈推荐一个编辑器插件来报告我们输入时的错误。
JavaScript 在语法上非常灵活,所以有很多方法可以编写相同的代码。linter 规则会报告一些错误,以便您在整个项目中使用一致的样式。
-
缩进:始终期望一致的缩进;这不需要辩解。让我们解决所有的违规问题。
-
关键字间距:关键字之间的空格(
if、catch等)。)并建议使用左括号。让我们更改代码,无论哪里报告了这一点。 -
缺少分号:关于到处都有分号还是哪儿都没有分号更好,有很多争论。两者都可以工作,除了少数情况下缺少分号会导致行为改变。如果您遵循无分号标准,您必须记住那些特殊情况。还是用 Airbnb 默认的吧,就是要求处处分号。
-
字符串必须使用单引号 : JavaScript 允许单引号和双引号。为了标准化,最好始终使用一种风格。让我们使用 Airbnb 默认的单引号。
-
新行上的对象属性:一个对象的所有属性必须在一行中,或者每个属性在新行中。这只是使它更可预测,尤其是当一个新的属性必须被插入的时候。对于是将新属性附加到现有行中的一行还是新行中,没有疑问。
-
objects中 before }之后的空格:这只是为了可读性;让我们在 linter 报告错误的地方更改它。
-
Arrow 函数风格:linter 建议要么在单个参数和函数体之间使用括号,要么在参数和返回表达式之间不使用括号(即不是函数体)。让我们进行建议的修改。
这些规则与更好的做事方式有关,通常有助于避免错误。
-
函数必须命名为:省略函数名会使调试更加困难,因为堆栈跟踪无法识别函数。但这仅适用于常规函数,不适用于箭头样式的函数,因为箭头样式的函数应该是回调的小段。
-
一致返回:函数应该总是返回值或者从不返回值,不管条件如何。这提醒开发人员添加返回值或明确返回值,以防他们忘记返回条件之外的值。
-
变量必须在使用之前定义:虽然 JavaScript 提升了定义,使得它们在整个文件中都可用,但是在使用之前定义它们是个好习惯。否则,当从上到下阅读代码时,它会变得混乱。
-
控制台:特别是在浏览器中,这些通常是遗留下来的调试信息,因此不适合在客户端显示。但是这些在 Node.js 应用中是没问题的。因此,让我们在 API 代码中关闭这条规则。
-
返回作业:虽然很简洁,但是返回和作业放在一起可能会让读者感到困惑。还是回避一下吧。
考虑您可能遇到的这些错误:
-
重新声明变量:当一个变量在更高的范围内遮蔽(覆盖)另一个变量时,很难阅读和理解原编码者的意图。也不可能在更高的范围内访问变量,所以最好给变量取不同的名字。
-
未声明的变量:最好避免内部作用域中的变量与外部作用域中的同名。这是令人困惑的,它隐藏了对外部作用域变量的访问,以防需要访问它。但是在 mongo 脚本中,我们确实有真正全局的变量:
db和print。让我们在注释中将它们声明为全局变量,这样 ESLint 就知道这些不是错误:... /* global db print */ ... -
更喜欢箭头回调:当使用匿名函数时(比如当传递一个回调给另一个函数时),最好使用箭头函数风格。这具有将变量
this设置为当前上下文的额外效果,这在大多数情况下是可取的,并且语法也更简洁。如果函数很大,最好把它分成一个命名的常规函数。 -
三重等于:三重等于的使用确保了在比较之前值不会被强制。在大多数情况下,这就是我们想要的,它避免了由于强制值而导致的错误。
-
函数参数的赋值:改变传入的参数可能会导致调用者没有注意到变化,从而导致意外的行为。让我们避免更改函数参数的值,而是制作参数的副本。
-
受限全局函数 :
iNaN被认为是受限全局函数,因为它将非数字强制转换为数字。推荐使用函数Number.isNaN(),但是它只对数字有效,所以在用Number.isNaN()检查之前,让我们对日期对象做一个getTime()。另外,print()是一个受限的全局变量,但是它在 mongo 脚本中的使用是有效的,所以让我们只对 mongo 脚本关闭这个规则,如下所示:... /* eslint no-restricted-globals: "off" */ ... -
Wrap 立即调用函数表达式(life):立即调用的函数表达式是一个单独的单元。用括号把它括起来,不仅使它更清楚,而且使它成为一个表达式而不是一个声明。
API 目录下最后一个.eslintrc文件的内容如清单 7-17 所示。
{
"extends": "airbnb-base",
"env": {
"node": "true"
},
rules: {
"no-console": "off"
}
}
Listing 7-17api/.eslintrc: Settings for ESLint in the API Directory对 API 目录下 JavaScript 文件的修改如清单 7-18 到 7-20 所示。
/*
...
*/
/* global db print */
/* eslint no-restricted-globals: "off" */
db.issues.remove({});
const issuesDB = [
{
id: 1, status: 'New', owner: 'Ravan', effort: 5,
created: new Date('2019-01-15'), due: undefined,
id: 1,
status: 'New',
owner: 'Ravan',
effort: 5,
created: new Date('2019-01-15'),
due: undefined,
title: 'Error in console when clicking Add',
},
{
id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
created: new Date('2019-01-16'), due: new Date('2019-02-01'),
id: 2,
status: 'Assigned',
owner: 'Eddie',
effort: 14,
created: new Date('2019-01-16'),
due: new Date('2019-02-01'),
title: 'Missing bottom border on panel',
},
...
Listing 7-18api/scripts/init.mongo.js: Fixes for ESLint Errorsfunction testWithCallbacks(callback) {
console.log('\n--- testWithCallbacks ---');
const client = new MongoClient(url, { useNewUrlParser: true });
client.connect(function(err, client) {
client.connect((connErr) => {
if (err connErr) {
callback(errconnErr);
return;
}
console.log('Connected to MongoDB URL', url);
...
const employee = { id: 1, name: 'A. Callback', age: 23 };
collection.insertOne(employee, function(err, result) {
collection.insertOne(employee, (insertErr, result) => {
if (err insertErr) {
client.close();
callback(err insertErr);
return;
}
console.log('Result of insert:\n', result.insertedId);
collection.find({ _id: result.insertedId})
collection.find({ _id: result.insertedId })
.toArray(function(err, docs) {
.toArray((findErr, docs) => {
if (err) {
client.close();
callback(err);
return;
}
if (findErr) {
client.close();
callback(findErr);
return;
}
console.log('Result of find:\n', docs);
client.close();
callback(err);
});
console.log('Result of find:\n', docs);
client.close();
callback();
});
...
async function testWithAsync() {
...
} catch(err) {
} catch (err) {
...
}
testWithCallbacks(function(err) {
testWithCallbacks((err) => {
...
}
Listing 7-19api/scripts/trymongo.js: Fixes for ESLint Errorslet db;
let aboutMessage = "Issue Tracker API v1.0";
let aboutMessage = 'Issue Tracker API v1.0';
...
const GraphQLDate = new GraphQLScalarType({
...
parseValue(value) {
...
return isNaN(dateValue) ? undefined : dateValue;
return Number.isNaN(dateValue.getTime()) ? undefined : dateValue;
},
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
const value = new Date(ast.value);
return isNaN(value) ? undefined : value;
return Number.isNaN(value.getTime()) ? undefined : value;
}
return undefined;
},
});
...
const resolvers = {
...
};
...
function setAboutMessage(_, { message }) {
return aboutMessage = message;
aboutMessage = message;
return aboutMessage;
}
...
async function issueAdd(_, { issue }) {
...
errors.push('Field "title" must be at least 3 characters long.')
errors.push('Field "title" must be at least 3 characters long.');
...
if (issue.status == 'Assigned' && !issue.owner) {
if (issue.status === 'Assigned' && !issue.owner) {
...
const newIssue = Object.assign({}, issue);
issue newIssue.created = new Date();
issue newIssue.id = await getNextSequence('issues');
const result = await db.collection('issues').insertOne(issue newIssue);
...
}
...
const resolvers = {
...
};
...
const server = new ApolloServer({
...
formatError: error => {
formatError: (error) => {
...
});
...
const enableCors = (process.env.ENABLE_CORS || 'true') == 'true';
const enableCors = (process.env.ENABLE_CORS || 'true') === 'true';
...
(async function start() {
...
app.listen(port, function () => {
...
...
})();
}());
Listing 7-20api/server.js: Fixes for ESLint Errors最后,让我们添加一个 npm 脚本,它将 lint all 目录中的所有文件。命令行类似于我们之前使用的 lint 整个目录。对此的更改显示在package.json中的清单 7-21 中。
...
"scripts": {
"start": "nodemon -e js,graphql -w . -w .env server.js",
"lint": "eslint .",
"test": "echo \"Error: no test specified\" && exit 1"
},
...
Listing 7-21api/package.json: New Script for lint在本节中,我们将把 ESLint 检查添加到 UI 目录中。这一次,我们不仅要安装airbnb-base包,还要安装完整的 Airbnb 配置,包括 React 插件。
$ cd ui
$ npm install --save-dev eslint@5 eslint-plugin-import@2
$ npm install --save-dev eslint-plugin-jsx-a11y@6 eslint-plugin-react@7
$ npm install --save-dev eslint-config-airbnb@17接下来,让我们通过扩展airbnb-base从服务器代码的.eslintrc开始。由于这是 Node.js 代码,我们也将环境设置为node,将规则no-console设置为off,就像在 API 配置中一样。清单 7-22 显示了.eslintrc的全部内容。
{
"extends": "airbnb-base",
"env": {
"node": true
},
"rules": {
"no-console": "off"
}
}
Listing 7-22ui/.eslintrc: New ESLint Configuration for UI Server Code要运行 linter,我们可以使用当前目录(.)作为命令行参数来执行命令。但是,在当前目录上执行将导致 ESLint 也在子目录中运行,这包括在public目录下编译的文件。编译后的文件会有很多 lint 错误,因为它不是源代码。因此,让我们通过使用 ESLint 的--ignore-pattern命令行选项来排除public目录,从而将其从 ESLint 范围中排除。
$ npx eslint . --ignore-pattern public另一种忽略文件模式的方法是将它们作为行添加到名为.eslintignore的文本文件中。当有许多模式要被忽略时,这是很有用的。因为我们只需要忽略一个目录,所以我们将使用命令行选项。
在开发的这个阶段,uiserver.js文件将抛出与 API server.js类似的错误。文件。这些是样式问题,包括缺少分号、对箭头功能的偏好以及长行的换行符样式。修正这些错误后对server.js的更改如清单 7-23 所示。
...
const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT ||
'http://localhost:3000/graphql';
const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT
|| 'http://localhost:3000/graphql';
...
app.get('/env.js', function(req, res) {
app.get('/env.js', (req, res) => {
...
app.listen(port, function () {
app.listen(port, () => {
...
Listing 7-23ui/uiserver.js: Fixes for ESLint Errors现在,让我们从简单配置src目录下的 React 代码开始。.eslintrc文件将不得不扩展airbnb而不是airbnb-base。在环境中,我们可以指定对 browser 的支持,而不是 Node.js。起始的.eslintrc文件将如下所示:
...
{
"extends": "airbnb",
"env": {
"browser": true
}
}
...现在,我们可以运行 ESLint 来检查 React 代码。在 ESLint 的早期调用中,没有检查App.jsx中的 React 代码,因为默认情况下 ESLint 不匹配扩展名为jsx的文件。为了包含这个扩展,ESLint 需要在命令行选项中包含完整的扩展列表--ext.
$ npx eslint . --ext js,jsx --ignore-pattern public该命令引发的错误包括一些我们在前面部分已经讨论过的问题。这些是:
-
新行上的对象属性
-
缺少分号
-
字符串必须使用单引号
-
一致回报
-
对象定义中花括号周围的间距
-
对象中{之前和之后}的空格
-
三倍相等
让我们讨论一下 ESLint 显示的其他问题。
-
隐式箭头换行符:这是一个风格问题,为了保持换行符的一致性。建议将从箭头函数返回的表达式与箭头放在同一行。如果表达式很长,无法放在一行中,可以从同一行开始用括号括起来。让我们来做这个改变。
-
中缀操作符必须有空格:为了可读性,操作符周围需要空格。让我们按照建议做些改变。
-
“React”必须在范围内:当 ESLint 检测到 JSX 时,它期望 React 被定义。在这个阶段,我们包括来自 CDN 的 React。很快,我们将通过使用 npm 安装这些模块来使用它们。在那之前,让我们禁用这些检查。我们将内联执行这些操作,保持
.eslintrc内容没有这种临时的变通办法。让我们在App.jsx文件中添加以下注释: -
无状态函数:组件
IssueFilter目前只是一个占位符。当我们向它添加功能时,它将成为一个有状态的组件。在此之前,让我们禁用 ESLint 检查,但只针对这个组件。
...
/* eslint "react/react-in-jsx-scope": "off" */
/* globals React ReactDOM */
/* eslint "react/jsx-no-undef": "off" */
...-
更喜欢析构,尤其是属性赋值(props assignment):这种从对象中赋值变量的新方式不仅更简洁、可读性更好,还可以为那些被创建的属性保存临时引用。让我们按照建议更改代码。
-
每个文件一个组件:每个文件只声明一个组件提高了组件的可读性和可重用性。目前,我们还没有讨论如何为 React 代码创建多个文件。我们将在下一章做那件事;在此之前,让我们禁用对文件的检查。
...
// eslint-disable-next-line react/prefer-stateless-function
class IssueFilter extends React.Component {
...-
无警告:这条规则的初衷是清除未运行的调试消息。我们将在文档中把警告消息转换成风格优美的消息。在那之前,让我们禁用这种检查,但是只在我们显示错误消息的文件中。
-
缺少尾随逗号:在多行数组或对象中的最后一项需要逗号,这在插入新项时非常方便。此外,当在比如说 GitHub 中查看两个版本之间的差异时,在最后一行添加逗号的事实表明这一行发生了变化,而实际上并没有。
...
/* eslint "react/no-multi-comp": "off" */
...-
Props validation :检查传递给组件的属性的类型是一个很好的实践,既可以让组件的用户清楚地知道,又可以避免输入中的错误。虽然我会简单地讨论这个问题,但我不会在 React 代码中添加 props 验证,纯粹是为了避免代码清单中的干扰。让我们为问题跟踪器应用全局关闭此规则,但我鼓励您在自己的应用中保持启用此规则。
-
*按钮类型:*虽然一个按钮的默认类型是
submit,但是最好确保明确声明,以防这不是预期的行为,开发者遗漏了添加一个类型。让我们按照建议,将submit添加到按钮的类型中。 -
函数参数重新赋值:给一个函数参数赋值会使原参数不可访问,导致混淆行为。让我们使用一个新的变量,而不是重用函数参数。
...
"rules": {
"react/prop-types": "off",
}
...在对.eslintrc文件进行这些更改后,该文件的最终内容如清单 7-24 所示。
{
"extends": "airbnb",
"env": {
"browser": true
},
rules: {
"react/prop-types": "off"
}
}
Listing 7-24ui/src/.eslintrc: New ESLint Configuration for UI Code清单 7-25 整合了对uiserver.js的所有修改,以解决 ESLint 错误。
...
/* eslint "react/react-in-jsx-scope": "off" */
/* globals React ReactDOM */
/* eslint "react/jsx-no-undef": "off" */
/* eslint "no-alert": "off" */
...
// eslint-disable-next-line react/prefer-stateless-function
class IssueFilter extends React.Component {
...
function IssueRow(props{ issue }) {
const issue = props.issue;
...
function IssueTable(props{ issue }) {
const issueRows = props.issues.map(issue =>
const issueRows = issues.map(issue => (
<IssueRow key={issue.id} issue={issue} />
));
...
const issue = {
owner: form.owner.value, title: form.title.value,
title: form.title.value,
due: new Date(new Date().getTime() + 1000*60*60*24*10),
due: new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 10),
}
this.props.createIssue(issue);
const { createIssue } = this.props;
createIssue(issue);
form.owner.value = ""; form.title.value = "";
form.owner.value = ''; form.title.value = '';
...
<button type="submit">Add</button>
...
async function graphQLFetch(query, variables = {}) {
...
headers: { 'Content-Type': 'application/json'},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables })
body: JSON.stringify({ query, variables }),
...
if (error.extensions.code == 'BAD_USER_INPUT') {
if (error.extensions.code === 'BAD_USER_INPUT') {
...
} catch (e) {
alert(`Error in sending data to server: ${e.message}`);
return null;
}
}
class IssueList extends React.Component {
...
render() {
const { issues } = this.state;
return (
...
<IssueTable issues={this.state.issues} />
...
)
...
Listing 7-25ui/src/App.jsx: Fixes for ESLint Errors最后,为了方便起见,我们给 UI 目录中的package.json添加一个脚本,对所有相关文件执行 lint。命令行与我们之前用来检查整个目录的命令行相同。这显示在清单 7-26 中。
...
"scripts": {
"start": "nodemon -w uiserver.js -w .env uiserver.js",
"lint": "eslint . --ext js,jsx --ignore-pattern public",
...
},
...
Listing 7-26ui/package.json: Command for Running ESLint on the UI Directory现在,命令npm run lint将检查当前设置,以及将被添加到 UI 目录下的任何其他文件。在这些代码更改之后,该命令应该不会返回任何错误或警告。
在像 Java 这样的强类型语言中,参数的类型总是预先确定的,并作为函数声明的一部分来指定。这确保了调用者知道列表和参数类型,并确保传入的参数根据规范进行验证。
类似地,从一个组件传递到另一个组件的属性也可以根据规范进行验证。该规范以类中名为propTypes的静态对象的形式提供,属性的名称作为键,验证器作为值。验证器是由PropTypes导出的众多常量之一,例如PropTypes.string。当属性为必填项时,可以在数据类型后添加.isRequired。对象PropTypes作为一个名为 prop-types 的模块可用,它可以包含在 CDN 的index.html中,就像我们对 React 本身所做的那样。这一变化如清单 7-27 所示。
...
<script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/prop-types@15/prop-types.js"></script>
...
Listing 7-27ui/public/index.html: Changes to Include PropTypes LibraryIssueTable和IssueRow组件分别需要一个对象和一个对象数组作为属性。虽然PropTypes支持数组和对象等数据类型,但 ESLint 认为这些太模糊了。相反,必须描述对象的实际形状,这意味着必须指定对象的每个字段及其数据类型,以避免 ESLint 警告。
让我们添加一个更简单的检查来确保IssueAdd被传递给一个createIssue函数。我们需要定义一个IssueAdd.propTypes对象,用createIssue作为键,用PropTypes.func.isRequired作为它的类型。此外,由于PropTypes是一个全局对象(由于包含在 CDN 的脚本中),它必须声明为全局对象以避免 ESLint 错误。清单 7-28 中显示了对App.jsx的这些更改。
...
/* globals React ReactDOM PropTypes */
...
class IssueAdd extends React.Component {
...
}
IssueAdd.propTypes = {
createIssue: PropTypes.func.isRequired,
};
Listing 7-28ui/src/App.jsx: Adding PropType Validation for IssueAdd Component在运行时,仅在开发模式下检查属性验证,当任何验证失败时,控制台中会显示一条警告。如果您在构造IssueAdd组件时移除了createIssue属性的传递,您将在开发人员控制台中发现以下错误:
Warning: Failed prop type: The prop `createIssue` is marked as required in `IssueAdd`, but its value is `undefined`.
in IssueAdd (created by IssueList)
in IssueList尽管为所有组件添加基于PropTypes的验证是一个好主意,但出于本书的目的,我将跳过这一步。唯一的原因是它使代码更加冗长,可能会分散读者对主要变化的注意力。
虽然在本章中我们没有给应用添加任何特性,但是我们通过分离 UI 和 API 服务器做了一个大的架构改变。我们讨论了 CORS 的含义,并编写了一个使用代理来处理它的选项。
然后,您看到了如何使应用可配置用于不同的部署环境,如试运行和生产环境。我们还通过添加对遵循编码标准、最佳实践和验证的检查来净化代码。
在下一章中,我们将继续通过模块化代码(即,将单个大文件分割成更小的、可重用的部分)和添加对调试的支持以及开发过程中有用的其他工具来提高开发人员的生产力。
-
您应该会在浏览器中看到类似于
Cannot GET /的消息。这是 Express 服务器返回的消息,因为不存在/的路由。这本身不是问题,因为 API 的唯一消费者是 web UI,并且在我们的控制之下。另一方面,如果 API 被公开给其他 API,比如 GitHub 的 API,那么返回一条有用的消息来指明真正的 API 端点在哪里,确实会更好。另一个选择是将 API 托管在根(/)上,而不是在
/graphql上。但是将/graphql作为端点名称可以清楚地表明它是一个 GraphQL API。
env.js的内容将显示一个带有UI_API_ENDPOINT属性的对象的 JavaScript 赋值window.ENV。在对环境进行更改后重新启动 UI 服务器将导致内容反映新值。



