MERN 技术栈高级教程(四)
八、模块化和网络包
在前一章中,我们开始通过改变架构和增加对编码标准和最佳实践的检查来进行组织。在这一章中,我们将进一步把代码分成多个文件,并添加工具来简化开发过程。我们将使用 Webpack 来帮助我们将前端代码分割成基于组件的文件,将代码增量地注入浏览器,并在前端代码发生变化时自动刷新浏览器。
你们中的一些人可能会发现这一章不值得花时间,因为它没有在应用的真正特性上取得任何进展,并且/或者因为它没有讨论组成堆栈的任何技术。如果您不太关心所有这些,而是依赖于其他人给你一个预定义目录结构的模板,以及 Webpack 等构建工具的配置,那么这是一个完全有效的想法。这可以让您只关注 MERN 堆栈,而不必处理所有的工具。在这种情况下,您有以下选择:
-
从本书的 GitHub 库(
https://github.com/vasansr/pro-mern-stack-2)下载本章末尾的代码,并以此作为你项目的起点。 -
使用初学者工具包
create-react-app(https://github.com/facebook/create-react-app)启动您的新 React 应用,并为您的应用添加代码。但是请注意,create-react-app只处理 MERN 堆栈的 React 部分;您必须自己处理 API 和 MongoDB。 -
使用
mern.io(http://mern.io)创建整个应用的目录结构,其中包括整个 MERN 堆栈。
但是,如果您是一名架构师,或者只是为您的团队设置项目,那么理解工具如何帮助开发人员提高工作效率以及您如何能够更好地控制整个构建和部署过程是非常重要的。在这种情况下,我鼓励你而不是跳过这一章,即使你使用了这些搭建工具中的一个,这样你就可以了解在引擎盖下到底发生了什么。
后端模块
在api/server.js如何在 Node.js 文件中包含模块中,您已经看到了所有这些。安装完模块后,我们使用内置函数require()来包含它。JavaScript 中有各种各样的模块化标准,其中 Node.js 实现了 CommonJS 标准的一个微小变化。在这个系统中,本质上有两个关键元素与模块系统交互:require和exports。
元素是一个可以用来从另一个模块导入符号的函数。传递给require()的参数是模块的 ID。在 Node 的实现中,ID 是模块的名称。对于使用 npm 安装的软件包,这与软件包的名称相同,并且与安装软件包文件的node_modules目录中的子目录相同。对于同一应用中的模块,ID 是需要导入的文件的路径。
比如从与api/server.js同目录的一个名为other.js的文件中导入符号,需要传递给require()的 ID 就是这个文件的路径,也就是'./other.js',像这样:
const other = require('./other.js');
现在,由other.js导出的将在other变量中可用。这是由我们谈到的另一个因素控制的:exports。一个文件或模块导出的主符号必须设置在该文件内一个名为module.exports的全局变量中,这个变量将由对require()的函数调用返回。如果有多个符号,它们都可以被设置为一个对象中的属性,我们可以通过解引用对象或使用析构赋值来访问它们。
首先,让我们将函数GraphQLDate()从主server.js文件中分离出来,并为此创建一个名为graphql_date.js的新文件。除了整个函数本身,我们还需要新文件中的以下内容:
-
require()从其他包中导入GraphQLScalarType和Kind的语句。 -
将变量
module.exports设置到函数中,以便导入文件后可以使用。
该文件的内容如清单 8-1 所示,其中api/server.js中对原始文件的更改以粗体突出显示。
const { GraphQLScalarType } = require('graphql');
const { Kind } = require('graphql/language');
const GraphQLDate = new GraphQLScalarType({
...
});
module.exports = GraphQLDate;
Listing 8-1api/graphql_date.js: Function GraphQLDate() in a New File
现在,在文件api/server.js中,我们可以像这样导入符号GraphQLDate:
...
const GraphQLDate = require('graphql_date.js');
...
如您所见,分配给module.exports的是调用require()返回的值。现在,GraphQLDate这个变量可以像以前一样无缝地用在解析器中。但是我们还不会在server.js中做这个改变,因为我们会对这个文件做更多的改变。
我们可以分离出来的下一组函数是与 about 消息相关的函数。尽管我们为解析器about使用了一个匿名函数,现在让我们创建一个命名函数,以便它可以从不同的文件中导出。让我们创建一个新文件,它导出 API 目录中的两个函数getMessage()和setMessage()``about.js。这个文件的内容非常简单,如清单 8-2 所示。但是我们不像在graphql_date.js中那样只导出一个函数,而是将setMessage和getMessage作为一个对象的两个属性导入。
let aboutMessage = 'Issue Tracker API v1.0';
function setMessage(_, { message }) {
...
}
function getMessage() {
return aboutMessage;
}
module.exports = { getMessage, setMessage };
Listing 8-2api/about.js: Separated About Message Functionality to New File
现在,我们可以从这个文件中导入about对象,并在需要在解析器中使用它们时取消引用about.getMessage和about.setMessage,如下所示:
...
const about = require('about.js');
...
const resolvers = {
Query: {
about: about.getMessage,
...
},
Mutation: {
setAboutMessage: about.setMessage,
...
},
...
};
这个变化可能在server.js中,但是我们将把所有这些都分离到一个处理 Apollo 服务器、模式和解析器的文件中。让我们现在创建该文件,并将其命名为api/api_handler.js。让我们将resolvers对象的构造和 Apollo 服务器的创建移到这个文件中。至于实际的解析器实现,我们将从另外三个文件中导入它们— graphql_date.js、about.js和issue.js。
至于从这个文件的导出,让我们导出一个函数,它将做applyMiddleware()作为server.js的一部分所做的事情。我们可以调用这个函数installHandler(),只需在这个函数中调用applyMiddleware()。
清单 8-3 中显示了这个新文件的全部内容,与server.js中的原始代码相比有所变化。
const fs = require('fs');
require('dotenv').config();
const { ApolloServer, UserInputError } = require('apollo-server-express');
const GraphQLDate = require('./graphql_date.js');
const about = require('./about.js');
const issue = require('./issue.js');
const resolvers = {
Query: {
about: about.getMessage,
issueList: issue.list,
},
Mutation: {
setAboutMessage: about.setMessage,
issueAdd: issue.add,
},
GraphQLDate,
};
const server = new ApolloServer({
...
});
function installHandler(app) {
const enableCors = (process.env.ENABLE_CORS || 'true') === 'true';
console.log('CORS setting:', enableCors);
server.applyMiddleware({ app, path: '/graphql', cors: enableCors });
}
module.exports = { installHandler };
Listing 8-3api/api_handler.js: New File to Separate the Apollo Server Construction
我们还没有创建issue.js,这是导入与问题相关的解决方案所需要的。但在此之前,让我们将数据库连接的创建和一个将连接处理程序放入一个新文件的函数分开。issue.js文件将需要这个数据库连接,等等。
让我们调用包含所有数据库相关代码db.js的文件,并将其放在 API 目录中。让我们将函数connectToDb()和getNextSequence()以及存储连接结果的全局变量db移到这个文件中。让我们按原样导出这两个函数。至于全局连接变量,让我们通过一个叫做getDb()的 getter 函数来公开它。全局变量url现在也可以移入函数connectDb()本身。
该文件的内容如清单 8-4 所示,其中server.js中对原始文件的更改以粗体突出显示。
require('dotenv').config();
const { MongoClient } = require('mongodb');
let db;
async function connectToDb() {
const url = process.env.DB_URL || 'mongodb://localhost/issuetracker';
...
}
async function getNextSequence(name) {
...
}
function getDb() {
return db;
}
module.exports = { connectToDb, getNextSequence, getDb };
Listing 8-4api/db.js: Database Related Functions Separated Out
现在,我们准备分离与问题对象相关的功能。让我们在 API 目录下创建一个名为issue.js的文件,并移动与该文件相关的问题。此外,我们必须从db.js导入函数getDb()和getNextSequence()。去使用它们。然后,我们不得不使用getDb()的返回值,而不是直接使用全局变量db。至于导出,我们可以导出函数issueList和issueAdd,但是现在它们在模块内,它们的名字可以简化为仅仅list和add。这个新文件的内容如清单 8-5 所示。
const { UserInputError } = require('apollo-server-express');
const { getDb, getNextSequence } = require('./db.js');
async function issueListlist() {
const db = getDb();
const issues = await db.collection('issues').find({}).toArray();
return issues;
}
function issueValidatevalidate(issue) {
const errors = [];
...
}
async function issueAddadd(_, { issue }) {
const db = getDb();
validate(issue);
...
return savedIssue;
}
module.exports = { list, add };
Listing 8-5api/issue.js: Separated Issue Functions
最后,我们可以修改文件api/server.js来使用所有这些。在所有的代码都转移到单独的文件之后,剩下的只是应用的实例化,应用 Apollo 服务器中间件,然后启动服务器。清单 8-6 中列出了整个文件的内容。删除的代码没有明确显示。新代码以粗体突出显示。
require('dotenv').config();
const express = require('express');
const { connectToDb } = require('./db.js');
const { installHandler } = require('./api_handler.js');
const app = express();
installHandler(app);
const port = process.env.API_SERVER_PORT || 3000;
(async function () {
try {
await connectToDb();
app.listen(port, function () {
console.log(`API server started on port ${port}`);
});
} catch (err) {
console.log('ERROR:', err);
}
}());
Listing 8-6api/server.js: Changes After Moving Out Code To Other Files
现在,应用已经准备好进行测试了。您可以通过 Playground 以及使用 Issue Tracker 应用 UI 来确保事情像 API 服务器代码模块化之前一样工作。
前端模块和 Webpack
在这一节中,我们将处理前端,或者 UI 代码,它们都在一个叫做App.jsx的大文件中。传统上,使用分割客户端 JavaScript 代码的方法是使用多个文件,并使用主 HTML 文件中的<script>标签或index.html将它们全部(或任何需要的)包含在内。这并不理想,因为依赖关系管理是由开发人员通过维护 HTML 文件中文件的特定顺序来完成的。此外,当文件数量变大时,这变得难以管理。
Webpack 和 Browserify 等工具提供了替代方案。使用这些工具,可以使用与 Node.js 中使用的require()等价的语句来定义依赖关系。然后,这些工具不仅会自动确定应用自身的依赖模块,还会自动确定第三方库的依赖关系。然后,他们将这些单独的文件放入一个或几个纯 JavaScript 包中,这些包中包含 HTML 文件所需的所有代码。
唯一的缺点是这需要一个构建步骤。但是,应用已经有一个构建步骤,将 JSX 和 ES2015 转换成普通的 JavaScript。让构建步骤也创建一个基于多个文件的包并没有太大的变化。Webpack 和 Browserify 都是很好的工具,可以用来实现目标。但是我选择了 Webpack,因为它可以更简单地完成我们想要做的事情,它包括第三方库和我们自己的模块的独立包。它有一个单一的管道来转换、捆绑和观察变化,并尽可能快地生成新的包。
如果您选择 Browserify,您将需要其他任务运行程序(如 gulp 或 grunt)来自动观察和添加多个转换。这是因为 Browserify 只做一件事:bundle。为了将 bundle 和 transform(使用 Babel)结合起来并观察文件的变化,您需要将它们放在一起,gulp 就是这样一个工具。相比之下,Webpack(在加载器的帮助下,我们将很快探索)不仅可以捆绑,还可以做更多的事情,例如转换和监视文件的更改。你不需要额外的任务运行者来使用 Webpack。
请注意,Webpack 还可以处理其他静态资产,如 CSS 文件。它甚至可以拆分包,以便它们可以异步加载。我们将不练习 Webpack 的这些方面;相反,我们将关注能够模块化客户端代码的目标,目前主要是 JavaScript。
为了习惯 Webpack 真正做什么,让我们从命令行使用 Webpack,就像我们使用 Babel 命令行对 JSX 变换所做的那样。让我们首先安装 Webpack,它作为一个包和一个命令行界面来运行它。
$ cd ui
$ npm install --save-dev webpack@4 webpack-cli@3
我们使用选项--save-dev,因为生产中的 UI 服务器不需要 Webpack。只有在构建过程中,我们才需要 Webpack,以及我们将在本章剩余部分安装的所有其他工具。为了确保我们可以使用命令行运行 Webpack,让我们检查安装的版本:
$ npx webpack --version
这应该会打印出类似 4.23.1 的版本。现在,让我们“打包”这个App.js文件并创建一个名为app.bundle.js的包。这可以简单地通过在App.js文件上运行 Webpack 并指定输出选项app.bundle.js来完成,两者都在public目录下。
$ npx webpack public/App.js --output public/app.bundle.js
这将产生如下所示的输出:
Hash: c5a639b898efcc81d3f8
Version: webpack 4.23.1
Time: 473ms
Built at: 10/25/2018 9:52:25 PM
Asset Size Chunks Chunk Names
app.bundle.js 6.65 KiB 0 [emitted] main
Entrypoint main = app.bundle.js
[0] ./public/App.js 10.9 KiB {0} [built]
WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value. Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/concepts/mode/
为了消除警告消息,让我们在命令行中提供开发模式:
$ npx webpack public/App.js --output public/app.bundle.js --mode development
这两种模式的区别在于 Webpack 自动做的各种事情,比如删除模块名、缩小等等。在构建用于生产部署的包时,拥有所有这些优化是好的,但是这些可能会妨碍调试和高效的开发过程。
生成的文件app.bundle.js没什么意思,与App.js本身也没什么不同。还要注意,我们没有对 React 文件App.jsx运行它,因为 Webpack 本身不能处理 JSX。在这种情况下,它所做的只是缩小App.js。我们这样做只是为了确保我们已经正确安装了 Webpack,并且能够运行它并创建输出。为了让 Webpack 找出依赖关系并把多个文件放在一起,让我们把单个文件App.jsx分成两个,取出函数graphQLFetch并把它放在一个单独的文件中。
我们可以像在后端代码中一样,使用require方式导入其他文件。但是您会注意到,互联网上的大多数前端代码示例都使用 ES2015 风格的模块,并使用了import关键字。这是一种更新、可读性更强的导入方式。甚至 Node.js 也支持import语句,但是从 Node.js 的版本 10 开始,它还处于试验阶段。如果没有,它也可以用于后端代码。使用import会强制使用 Webpack。因此,让我们仅将 ES2015 风格的import用于前端代码。
要导入另一个文件,需要使用关键字import,然后是要导入的元素或变量(这可能是分配给require()结果的变量),接着是关键字from,然后是文件或模块的标识符。例如,要从文件graphQLfetch.js中导入graphQLFetch,需要做的是:
...
import graphQLFetch from './graphQLFetch.js';
...
使用新的 ES2015 风格导出函数非常简单,只需在要导出的任何内容的定义前加上关键字export即可。此外,如果正在导出单个函数,可以在export之后添加关键字default,并且它可以是import语句的直接结果(或顶级导出)。所以,让我们用从App.jsx复制过来的相同函数的内容创建一个新文件ui/src/graphQLFetch.js。我们还需要实现jsonDateReviver和函数。这个文件的内容如清单 8-7 所示,其中export default被添加到了函数的定义中。
const dateRegex = new RegExp('^\\d\\d\\d\\d-\\d\\d-\\d\\d');
function jsonDateReviver(key, value) {
if (dateRegex.test(value)) return new Date(value);
return value;
}
export default async function graphQLFetch(query, variables = {}) {
...
}
Listing 8-7ui/src/graphQLFetch.js: New File with Exported Function graphQLFetch
现在,让我们从ui/src/App.jsx中删除相同的一组行,并用一个import语句替换它们。这一变化如清单 8-8 所示。
...
/* eslint "react/no-multi-comp": "off" */
/* eslint "no-alert": "off" */
import graphQLFetch from './graphQLFetch.js';
const dateRegex = new RegExp('^\\d\\d\\d\\d-\\d\\d-\\d\\d');
function jsonDateReviver(key, value) {
if (dateRegex.test(value)) return new Date(value);
return value;
}
class IssueFilter extends React.Component {
...
}
async function graphQLFetch(query, variables = {}) {
...
}
...
Listing 8-8ui/src/App.jsx: Replace graphQLFetch with an Import
此时,ESLint 将显示一个错误,大意是import语句中的扩展名(.js是意外的,因为该扩展名可以被自动检测到。但是事实证明,import语句只能检测.js文件扩展名,我们很快也会导入.jsx文件。此外,在后端代码中,我们使用了require()语句中的扩展。让我们对这个 ESLint 规则做个例外,总是在import语句中包含扩展,当然,通过 npm 安装的包除外。清单 8-9 显示了对ui/src目录中的.eslintrc文件的更改。
...
"rules": {
"import/extensions": [ "error", "always", { "ignorePackages": true } ],
"react/prop-types": "off"
}
...
Listing 8-9ui/src/.eslintrc: Exception for Including Extensions in Application Modules
如果你正在运行npm run watch,你会发现App.js和graphQLFetch.js都是在public目录中经过巴别塔转换后创建的。如果没有,可以运行ui目录下的npm run compile。现在,让我们再次运行 Webpack 命令,看看会发生什么。
$ npx webpack public/App.js --output public/app.bundle.js --mode development
这应该会产生如下输出:
Hash: 4207ff5d100f44fbf80e
Version: webpack 4.23.1
Time: 112ms
Built at: 10/25/2018 10:21:06 PM
Asset Size Chunks Chunk Names
app.bundle.js 16.5 KiB main [emitted] main
Entrypoint main = app.bundle.js
[./public/App.js] 9.07 KiB {main} [built]
[./public/graphQLFetch.js] 2.8 KiB {main} [built]
正如您在输出中看到的,打包过程包括了App.js和graphQLFetch.js。Webpack 已经自动计算出由于import语句App.js依赖于graphQLFetch.js,并且已经将它包含在包中。现在,我们需要用app.bundle.js替换index.html中的App.js,因为新的包包含了所有需要的代码。这一变化如清单 8-10 所示。
...
<script src="/env.js"></script>
<script src="/App.js/app.bundle.js"></script>
</body>
...
Listing 8-10ui/public/index.html: Replace App.js with app.bundle.js
如果您现在测试应用,您应该会发现它和以前一样工作。为了更好地测量,您还可以在浏览器中的开发人员控制台的 Network 选项卡中检查从服务器获取的确实是app.bundle.js。
所以,现在你知道了如何在前端代码中使用多个文件,为了方便和模块化,我们可以创建更多类似于graphQLFetch.js的文件。但是这个过程并不简单,因为我们必须首先手动转换文件,然后使用 Webpack 将它们放在一个包中。任何手动步骤都容易出错:人们很容易忘记转换,最终会捆绑转换后文件的旧版本。
转换和捆绑
好消息是,Webpack 能够将这两个步骤结合起来,消除了对中间文件的需要。但它自己无法做到这一点;它需要一些叫做装载机的帮手。除了纯 JavaScript 之外的所有转换和文件类型都需要 Webpack 中的加载器。这些是分开的包裹。为了能够运行巴别塔转换,我们需要巴别塔加载器。
让我们现在安装它。
$ cd ui
$ npm install --save-dev babel-loader@8
在 Webpack 的命令行中使用这个加载器有点麻烦。为了使配置和选项更容易,可以向 Webpack 提供配置文件。它寻找的默认文件叫做webpack.config.js。Webpack 使用 Node.js require()将该文件作为一个模块加载,因此我们可以将该文件视为一个常规的 JavaScript,其中包含一个module.exports变量,该变量导出指定转换和绑定过程的属性。让我们开始在ui目录下构建这个文件,其中有一个属性:mode。让我们将它默认为 development,就像我们之前在命令行中所做的那样。
...
module.exports = {
mode: development,
}
...
entry属性指定了一个文件,该文件是可以确定所有依赖关系的起点。在问题跟踪器应用中,该文件位于src目录下的App.jsx。接下来再加上这个。
...
entry: './src/App.jsx',
...
output属性需要是具有filename和path两个属性的对象。该路径必须是绝对路径。推荐使用path模块和path.resolve函数来构建绝对路径。
...
const path = require('path');
module.exports = {
...
output: {
filename: 'app.bundle.js',
path: path.resolve(__dirname, 'public'),
},
...
加载器是在属性module下指定的,它包含一系列作为数组的规则。每个规则至少有一个test,它是一个匹配文件的正则表达式,还有一个use,它指定在查找匹配时使用的加载器。我们将使用两者都匹配的正则表达式。jsx和.js文件和 Babel 加载器,当文件匹配这个正则表达式时运行转换,如下所示:
...
{
test: /\.jsx?$/,
use: 'babel-loader',
},
...
完整的文件ui/webpack.config.js如清单 8-11 所示。
const path = require('path');
module.exports = {
mode: 'development',
entry: './src/App.jsx',
output: {
filename: 'app.bundle.js',
path: path.resolve(__dirname, 'public'),
},
module: {
rules: [
{
test: /\.jsx?$/,
use: 'babel-loader',
},
],
},
};
Listing 8-11ui/webpack.config.js: Webpack Configuration
注意,我们不需要为 Babel loader 提供任何进一步的选项,因为 Webpack 所做的只是使用现有的 Babel transformer。这使用了来自src目录中.babelrc的现有配置。
此时,您可以快速运行不带任何参数的 Webpack 命令行,并看到文件app.bundle.js已创建,而没有创建任何中间文件。您可能需要删除public目录中的中间文件App.js和graphQLFetch.js来确保这一点。执行此操作的命令行如下:
$ npx webpack
这可能需要一点时间。此外,就像 Babel 的--watch选项一样,Webpack 也附带了一个--watch选项,它增量地构建包,只转换已更改的文件。让我们试试这个:
$ npx webpack --watch
该命令不会退出。现在,如果您更改其中一个文件,比如说graphQLFetch.js,您将在控制台上看到以下输出:
Hash: 3fc38bc043fafe268e06
Version: webpack 4.23.1
Time: 53ms
Built at: 10/25/2018 11:09:49 PM
Asset Size Chunks Chunk Names
app.bundle.js 16.6 KiB main [emitted] main
Entrypoint main = app.bundle.js
[./src/graphQLFetch.js] 2.71 KiB {main} [built]
+ 1 hidden module
注意输出中的最后一行:+1 hidden module。这实际上意味着当只有graphQLFetch.js被改变时App.jsx没有被改变。这是为compile和watch修改 npm 脚本的好时机,使用 Webpack 命令代替 Babel 命令。
Webpack 有两种模式,生产和开发,它们改变了在转换过程中添加的优化类型。让我们假设在开发过程中,我们将始终使用观察脚本,为了构建一个用于部署的包,我们将使用生产模式。命令行参数覆盖配置文件中指定的内容,因此我们可以在 npm 脚本中相应地设置模式。
清单 8-12 中显示了这样做所需的更改。
...
"scripts": {
"start": "nodemon -w uiserver.js -w .env uiserver.js",
"compile": "babel src --out-dir public",
"compile": "webpack --mode production",
"watch": "babel src --out-dir public --watch --verbose"
"watch": "webpack --watch"
},
...
Listing 8-12ui/package.json: Changes to npm Scripts to Use Webpack Instead of Babel
现在,我们准备将App.jsx文件拆分成许多文件。建议将每个 React 组件放在自己的文件中,尤其是如果组件是有状态的。无状态组件可以在方便的时候与其他组件组合在一起。
所以,让我们把组件IssueList和App.jsx分开。然后,让我们将层次结构中的第一级组件——IssueFilter、IssueTable和IssueAdd——分离到它们自己的文件中。在每个项目中,我们将导出主要组件。App.jsx会导入IssueList.jsx,?? 又会导入其他三个组件。IssueList.jsx也需要导入graphQLFetch.js,因为它调用 Ajax。
让我们也将 ESLint 异常移动或复制到适当的新文件中。所有文件将有一个声明 React 为全局的异常;IssueFilter对于无状态组件也有例外。
清单 8-13 中描述了新文件IssueList.jsx。
/* globals React */
/* eslint "react/jsx-no-undef": "off" */
import IssueFilter from './IssueFilter.jsx';
import IssueTable from './IssueTable.jsx';
import IssueAdd from './IssueAdd.jsx';
import graphQLFetch from './graphQLFetch.js';
export default class IssueList extends React.Component {
...
}
Listing 8-13ui/src/IssueList.jsx: New File for the IssueList Component
新的IssueTable.jsx文件如清单 8-14 所示。注意,这包含两个无状态组件,其中只有IssueTable被导出。
/* globals React */
function IssueRow({ issue }) {
...
}
export default function IssueTable({ issues }) {
...
}
Listing 8-14ui/src/IssueTable.jsx: New File for the IssueTable Component
新的IssueAdd.jsx文件如清单 8-15 所示。
/* globals React PropTypes */
export default class IssueAdd extends React.Component {
...
}
Listing 8-15ui/src/IssueAdd.jsx: New File for the IssueAdd Component
新的IssueFilter.jsx文件如清单 8-16 所示。
/* globals React */
/* eslint "react/prefer-stateless-function": "off" */
export default class IssueFilter extends React.Component {
...
}
Listing 8-16ui/src/IssueFilter.jsx: New File for the IssueFilter Component
最后,主类App.jsx将只有很少的代码,只有一个IssueList组件的实例化并将其安装在内容<div>中,以及必要的注释行来声明 React 和 ReactDOM 作为 ESLint 的全局变量。清单 8-17 中完整显示了该文件(为简洁起见,删除的行未显示)。
/* globals React ReactDOM */
import IssueList from './IssueList.jsx';
const element = <IssueList />;
ReactDOM.render(element, document.getElementById('contents'));
Listing 8-17ui/src/App.jsx: Main File with Most Code Moved Out
如果你从ui目录运行npm run watch,你会发现所有的文件都被转换并捆绑到app.bundle.js中。如果您现在测试应用,它应该像以前一样工作。
练习:变换和捆绑
-
运行
npm run watch时,保存任何仅改变间距的 JSX 文件。Webpack 会重建包吗?为什么不呢? -
是否有必要将组件的安装(在
App.jsx中)和组件本身(IssueList)分开到不同的文件中?提示:想想我们将来还需要哪些页面。 -
如果在导出一个类时没有使用关键字
default,比如说IssueList,会发生什么?提示:在https://developer.mozilla.org/en-US/docs/web/javascript/reference/statements/export#Using_the_default_export的 JavaScriptexport语句上查找 Mozilla Developer Network (MDN)文档。
本章末尾有答案。
库捆绑包
到目前为止,为了简单起见,我们将第三方库作为 JavaScript 直接包含在 CDN 中。虽然这在大多数情况下都很有效,但我们必须依赖 CDN 服务来支持我们的应用。此外,需要包含许多库,这些库之间也有依赖关系。
在本节中,我们将使用 Webpack 创建一个包含这些库的包。如果你还记得,我讨论过 npm 不仅用于服务器端库,也用于客户端库。更重要的是,Webpack 理解这一点,可以处理通过 npm 安装的客户端库。
让我们首先使用 npm 来安装我们一直使用到现在的客户端库。这与index.html中的<script>列表相同。
$ cd ui
$ npm install react@16 react-dom@16
$ npm install prop-types@15
$ npm install whatwg-fetch@3
$ npm install babel-polyfill@6
接下来,为了使用这些已安装的库,让我们在所有需要它们的客户端文件中导入它们,就像我们在拆分App.jsx后导入应用的文件一样。所有带有 React 组件的文件都需要导入 React。App.jsx 还需要导入 ReactDOM。polyfills— babel-polyfill和whatwg-fetch—可以导入到任何地方,因为它们将被安装在全局名称空间中。让我们在App.jsx里做这个,切入点。清单 8-18 到 8-22 中显示了这一点以及其他组件的变化。
/* globals React ReactDOM */
import 'babel-polyfill';
import 'whatwg-fetch';
import React from 'react';
import ReactDOM from 'react-dom';
import IssueList from './IssueList.jsx';
...
Listing 8-18App.jsx: Changes for Importing Third-Party Libraries
-/* globals React */
-/* eslint "react/jsx-no-undef": "off" */
import React from 'react';
import IssueFilter from './IssueFilter.jsx';
...
Listing 8-19IssueList.jsx: Changes for Importing Third-Party Libraries
/* globals React */
/* eslint "react/prefer-stateless-function": "off" */
import React from 'react';
export default class IssueFilter extends React.Component {
...
Listing 8-20IssueFilter.jsx: Changes for Importing Third-Party Libraries
-/* globals React */
import React from 'react';
function IssueRow(props) {
...
Listing 8-21IssueTable.jsx: Changes for Importing Third-Party Libraries
/* globals React PropTypes */
import React from 'react';
import PropTypes from 'prop-types';
export default class IssueAdd extends React.Component {
...
Listing 8-22IssueAdd.jsx: Changes for Importing Third-Party Libraries
如果您已经运行了npm run watch,您会注意到在它的输出中,隐藏模块的数量已经从几个增加到几百个,并且app.bundle.js的大小已经从几千字节增加到 1MB 以上。Webpack 捆绑的新输出现在看起来像这样:
Hash: 2c6bf561fa9aba4dd3b1
Version: webpack 4.23.1
Time: 2184ms
Built at: 10/26/2018 11:51:01 AM
Asset Size Chunks Chunk Names
app.bundle.js 1.16 MiB main [emitted] main
Entrypoint main = app.bundle.js
[./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 492 bytes {main} [built]
+ 344 hidden modules
这个包包含了所有的库,这是一个小问题。库不会经常改变,但是应用代码会改变,尤其是在开发和测试期间。即使应用代码经历了很小的变化,整个包也会被重新构建,因此,客户机必须从服务器获取(现在很大的)包。我们没有利用这样一个事实,即当脚本没有被修改时,浏览器可以缓存脚本。这不仅影响开发过程,而且即使在生产中,用户也不会有最佳的体验。
一个更好的选择是有两个包,一个用于应用代码,另一个用于所有的库。事实证明,我们可以在 Webpack 中使用一种叫做splitChunks的优化来轻松做到这一点。为了使用这种优化并自动命名它创建的不同包,我们需要在文件名中指定一个变量。让我们使用一个命名的入口点和包的名称作为 UI 的 Webpack 配置中的文件名变量,如下所示:
...
entry: './src/App.jsx',
entry: { app: './src/App.jsx' },
output: {
filename: 'app.bundle.js',
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'public'),
},
...
接下来,让我们通过从转换中排除库来节省一些时间:它们已经在所提供的发行版中被转换了。为此,我们需要排除 Babel loader 中node_modules下的所有文件。
...
test: /\.jsx?$/,
exclude: /node_modules/,
use: 'babel-loader',
...
最后,让我们启用优化splitChunks。这个插件做了我们想要的开箱即用,也就是说,它将node_modules下的所有东西都分离到一个不同的包中。我们需要做的就是说我们需要all作为属性chunks的值。此外,为了给包起一个方便的名字,让我们在配置中给它起一个名字,就像这样:
...
splitChunks: {
name: 'vendor',
chunks: 'all',
},
...
清单 8-23 中显示了对ui目录下webpack.config.js的一整套更改。
module.exports = {
mode: 'development',
entry: './src/App.jsx',
entry: { app: './src/App.jsx' },
output: {
filename: 'app.bundle.js',
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'public'),
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: 'babel-loader',
},
],
},
optimization: {
splitChunks: {
name: 'vendor',
chunks: 'all',
},
},
};
Listing 8-23ui/webpack.config.js: Changes for Separate Vendor Bundle
现在,如果您重新启动npm run watch,它应该会输出两个包——app.bundle.js和vendor.bundle.js——如该命令的示例输出所示:
Hash: 0d92c8636ffc24747d70
Version: webpack 4.23.1
Time: 1664ms
Built at: 10/26/2018 2:32:34 PM
Asset Size Chunks Chunk Names
app.bundle.js 29.7 KiB app [emitted] app
vendor.bundle.js 1.24 MiB vendor [emitted] vendor
Entrypoint app = vendor.bundle.js app.bundle.js
[./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 489 bytes {vendor} [built]
[./src/App.jsx] 307 bytes {app} [built]
[./src/IssueAdd.jsx] 3.45 KiB {app} [built]
[./src/IssueFilter.jsx] 2.67 KiB {app} [built]
[./src/IssueList.jsx] 6.02 KiB {app} [built]
[./src/IssueTable.jsx] 1.16 KiB {app} [built]
[./src/graphQLFetch.js] 2.71 KiB {app} [built]
+ 338 hidden modules
既然捆绑包中包含了所有的第三方库,我们可以从 CDN 中删除这些库的加载。相反,我们可以包含新的脚本vendor.bundle.js。变化都在index.html中,如清单 8-24 所示。
...
<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/prop-types@15/prop-types.js"></script>
<script src="https://unpkg.com/@babel/polyfill@7/dist/polyfill.min.js"></script>
<script src="https://unpkg.com/whatwg-fetch@3.0.0/dist/fetch.umd.js"></script>
<style>
...
</style>
</head>
<body>
...
<script src="/env.js"></script>
<script src="/vendor.bundle.js"></script>
<script src="/app.bundle.js"></script>
</body>
...
Listing 8-24ui/public/index.html: Removal of Libraries Included Directly from CDN
如果您现在测试应用(当然是在启动 API 和 UI 服务器之后),您会发现应用和以前一样工作。此外,快速查看开发人员控制台中的 Network 选项卡将会发现,不再从 CDN 中获取库;相反,新脚本vendor.bundle.js是从 UI 服务器获取的。
如果您对任何一个 JSX 文件做一个小的更改并刷新浏览器,您会发现获取vendor.bundle.js会返回一个“304 Not Modified”响应,但是应用的包app.bundle.js确实被获取了。考虑到vendor.bundle.js文件的大小,这将大大节省时间和带宽。
热模块更换
Webpack 的监视模式适用于客户端代码,但是这种方法有一个潜在的缺陷。在刷新浏览器以查看更改的效果之前,您必须留意运行命令npm run watch的控制台,以确保绑定完成。如果你太快地按下刷新键,你将会得到客户端代码的前一个版本,挠头想为什么你的修改不起作用,然后花时间调试。
此外,目前,我们需要一个额外的控制台来运行 UI 目录中的npm run watch以检测更改并重新编译文件。为了解决这些问题,Webpack 有一个强大的功能,叫做热模块替换(HMR)。这在应用运行时改变了浏览器中的模块,完全消除了刷新的需要。此外,如果有任何应用状态,也将被保留,例如,如果您正在某个文本框中键入内容,由于没有页面刷新,该状态将被保留。最重要的是,它通过只更新更改的内容来节省时间,并且它消除了切换窗口和按刷新按钮的需要。
使用 Webpack 实现 HMR 有两种方法。第一个涉及一个名为webpack-dev-server的新服务器,它可以从命令行安装和运行。它读取webpack.config.js的内容,并启动一个服务于编译文件的服务器。这是没有专用 UI 服务器的应用的首选方法。但是既然我们已经有了一个 UI 服务器,最好稍微修改一下来做webpack-dev-server会做的事情:编译,观察变化,实现 HMR。
HMR 有两个可以安装在 Express 应用中的中间件包,称为webpack-dev-middleware和webpack-hot-middleware。让我们安装这些软件包:
$ cd ui
$ npm install --save-dev webpack-dev-middleware@3
$ npm install --save-dev webpack-hot-middleware@2
我们将在用于 UI 的 Express 服务器中使用这些模块,但是只有在显式启用时,因为我们不想在生产中这样做。我们必须导入这些模块,并将它们作为中间件安装在 Express 应用中。但是这些模块需要特殊的配置,不同于webpack.config.js中的默认设置。这些是:
-
他们需要额外的入口点(除了
App.jsx),以便 Webpack 可以将这个额外功能所需的客户端代码构建到包中。 -
需要安装一个插件来生成增量更新,而不是整个软件包。
与其为此创建新的配置文件,不如让我们在启用 HMR 时动态修改配置*。由于配置本身是一个 Node.js 模块,这很容易做到。但是我们确实需要在配置中做一个改变,一个不影响原始配置的改变。需要将入口点更改为数组,以便可以轻松地推送新的入口点。这一变化如清单 8-25 所示。*
...
entry: { app: './src/App.jsx' },
entry: { app: ['./src/App.jsx'] },
...
Listing 8-25ui/webpack.config.js: Change Entry to an Array
现在,让我们为 Express 服务器添加一个选项来启用 HMR。让我们使用一个名为ENABLE_HMR的环境变量,默认为true,只要它不是生产部署。这给了开发者一个机会,如果他们更喜欢webpack --watch的做事方式,就可以关掉它。
...
const enableHMR = (process.env.ENABLE_HMR || 'true') === 'true';
if (enableHMR && (process.env.NODE_ENV !== 'production')) {
console.log('Adding dev middleware, enabling HMR');
...
}
...
要启用 HMR,我们要做的第一件事是导入 Webpack 的模块和我们刚刚安装的两个新模块。我们还必须让 ESLint 知道,我们有一个特殊的情况,我们正在有条件地安装开发依赖项,因此可以禁用一些检查。
...
/* eslint "global-require": "off" */
/* eslint "import/no-extraneous-dependencies": "off" */
const webpack = require('webpack');
const devMiddleware = require('webpack-dev-middleware');
const hotMiddleware = require('webpack-hot-middleware');
...
接下来,让我们导入配置文件。这也只是一个require()调用,因为配置只是一个 Node.js 模块:
...
const config = require('./webpack.config.js');
...
在config中,让我们为 Webpack 添加一个新的入口点,它将为 UI 代码的更改安装一个监听器,并在它们更改时获取新的模块。
...
config.entry.app.push('webpack-hot-middleware/client');
...
然后,让我们为 HMR 启用插件,可以使用webpack.HotModuleReplacementPlugin()实例化它。
...
config.plugins = config.plugins || [];
config.plugins.push(new webpack.HotModuleReplacementPlugin());
...
最后,让我们从这个配置创建一个 Webpack 编译器,并创建dev中间件(它使用配置进行代码的实际编译并发送包)和hot中间件(它逐渐将新模块发送到浏览器)。
...
const compiler = webpack(config);
app.use(devMiddleware(compiler));
app.use(hotMiddleware(compiler));
...
注意,dev和hot中间件必须在静态中间件之前安装。否则,如果包存在于public目录中(因为npm run compile已经执行了一段时间),那么static模块将会找到它们并发送它们作为响应,甚至在dev和hot中间件有机会之前。
清单 8-26 中显示了对uiserver.js文件的更改。
...
const app = express();
const enableHMR = (process.env.ENABLE_HMR || 'true') === 'true';
if (enableHMR && (process.env.NODE_ENV !== 'production')) {
console.log('Adding dev middleware, enabling HMR');
/* eslint "global-require": "off" */
/* eslint "import/no-extraneous-dependencies": "off" */
const webpack = require('webpack');
const devMiddleware = require('webpack-dev-middleware');
const hotMiddleware = require('webpack-hot-middleware');
const config = require('./webpack.config.js');
config.entry.app.push('webpack-hot-middleware/client');
config.plugins = config.plugins || [];
config.plugins.push(new webpack.HotModuleReplacementPlugin());
const compiler = webpack(config);
app.use(devMiddleware(compiler));
app.use(hotMiddleware(compiler));
}
app.use(express.static('public'));
...
Listing 8-26ui/uiserver.js: Changes for Hot Module Replacement Middleware
此时,以下是启动 UI 服务器的不同方式:
-
npm run compile + npm run start:在生产模式下(变量NODE_ENV定义为生产),服务器的启动需要npm run compile已经运行,并且app.bundle.js和vendor.bundle.js已经生成并且在public目录下。 -
npm run start:在开发模式下(NODE_ENV未定义或设置为开发),这将启动默认启用 HMR 的服务器。对源文件的任何更改都会在浏览器中立即被 hot 替换。 -
npm run watch + npm run start、ENABLE_HMR=false:在开发或生产模式下,这些需要在两个控制台中运行。watch命令寻找变化并重新生成 JavaScript 包,start命令运行服务器。如果没有ENABLE_HMR,包将从public目录中提供,由watch命令生成。
让我们将这些作为注释添加到 UI 中的package.json中的脚本之前。由于 JSON 文件不能像 JavaScript 那样有注释,我们将只使用前缀为#的属性来实现这一点。对ui/package.json的更改如清单 8-27 所示。
...
"scripts": {
"#start": "UI server. HMR is enabled in dev mode.",
"start": "nodemon -w uiserver.js -w .env uiserver.js",
"#lint": "Runs ESLint on all relevant files",
"lint": "eslint . --ext js,jsx --ignore-pattern public",
"#compile": "Generates JS bundles for production. Use with start.",
"compile": "webpack --mode production",
"#watch": "Compile, and recompile on any changes.",
"watch": "webpack --watch"
},
...
Listing 8-27ui/package.json: Comments to Define Each Script
现在,如果您在 UI 中运行npm start以及在 API 服务器中运行npm start,您将能够测试应用。如果你正在运行npm run watch,你现在可以停止它。应用应该像以前一样工作。您还会在浏览器的开发人员控制台中看到以下内容,向您保证 HMR 确实已被激活:
[HMR] connected
但是当一个文件改变时,比如说IssueFilter.jsx,你会在浏览器的控制台上看到一个警告:
[HMR] bundle rebuilding
HMR] bundle rebuilt in 102ms
[HMR] Checking for updates on the server...
Ignored an update to unaccepted module ./src/IssueFilter.jsx -> ./src/IssueList.jsx -> ./src/App.jsx -> 0
[HMR] The following modules couldn't be hot updated: (Full reload needed)
This is usually because the modules which have changed (and their parents) do not know how to hot reload themselves. See https://webpack.js.org/concepts/hot-module-replacement/ for more details.
[HMR] - ./src/IssueFilter.jsx
这意味着虽然模块被重建并在浏览器中接收,但它不能被接受。为了接受对一个模块的更改,它的父模块需要使用HotModuleReplacementPlugin的accept()方法来接受它。插件的接口通过module.hot属性公开。让我们无条件地接受模块层次结构顶层的所有更改,App.jsx。对此的更改如清单 8-28 所示。
...
ReactDOM.render(element, document.getElementById('contents'));
if (module.hot) {
module.hot.accept();
}
...
Listing 8-28ui/src/App.jsx: Changes to Accept HMR
现在,如果你改变了比如说IssueFilter.jsx的内容,你会在开发人员控制台中看到,不仅仅是这个模块,所有包含链中包含这个及以上的模块都会被更新:IssueList.jsx,然后是App.jsx。这样做的一个效果是App.jsx模块被 HMR 插件再次加载(相当于import被执行)。这具有运行该文件内容中的整个代码的效果,包括以下内容:
...
const element = <IssueList />;
ReactDOM.render(element, document.getElementById('contents'));
..
因此,IssueList组件被再次构造和呈现,几乎所有的东西都被刷新。这可能会丢失本地状态。例如,如果您在IssueAdd组件的所有者和标题文本框中输入了一些内容,那么当您更改IssueFilter.jsx时,这些文本将会丢失。
为了避免这种情况,我们应该理想地在每个模块中寻找变化,并再次安装组件,但是保留本地状态。React 没有使这成为可能的方法,即使有,在每个组件中这样做也是很乏味的。为了解决这些问题,创建了react-hot-loader包。在编译时,它用代理替换组件的方法,然后调用真正的方法,如render()。然后,当一个组件的代码被更改时,它会自动引用新的方法,而不必重新挂载该组件。
这在应用中证明是有用的,在这些应用中,本地状态在刷新之间的保存确实很重要。但是对于问题跟踪器应用,让我们不要实现react-hot-loader,相反,让我们满足于当一些代码改变时重新加载整个组件层次结构。在任何情况下,它都不会花费太多时间,并且节省了安装和使用react-hot-loader的复杂性。
练习:热模块更换
- 当一个模块的代码被改变时,你如何判断浏览器没有被完全刷新?使用浏览器开发工具的网络部分,观察发生了什么。
本章末尾有答案。
排除故障
编译文件的不愉快之处在于原始源代码会丢失,如果您必须在调试器中设置断点,这几乎是不可能的,因为新代码几乎不像原始代码。创建一个包含所有源文件的包会使情况变得更糟,因为您甚至不知道从哪里开始。
幸运的是,Webpack 解决了这个问题,它能够给你源代码图,也就是你输入源代码时包含原始源代码的东西。源映射还将转换后的代码中的行号连接到原始代码。浏览器的开发工具理解源映射并将两者关联起来,这样原始源代码中的断点就变成了转换后的代码中的断点。
Webpack 配置可以指定哪种类型的源映射可以与编译后的包一起创建。一个名为devtool的配置参数完成了这项工作。可以生成的源地图的种类各不相同,但是最精确的(也是最慢的)是由值source-map生成的。对于这个应用,因为 UI 代码足够小,所以速度并不慢,所以让我们用它作为devtool的值。对 UI 目录中webpack.config.js的修改如清单 8-29 所示。
...
optimization: {
...
},
devtool: 'source-map'
};
...
Listing 8-29ui/webpack.config.js: Enable Source Map
如果您使用的是支持 HMR 的 UI 服务器,您应该会在运行 UI 服务器的控制台中看到以下输出:
webpack built dc6a1e03ee249e546ffb in 2964ms
⌈wdm⌋: Hash: dc6a1e03ee249e546ffb
Version: webpack 4.23.1
Time: 2964ms
Built at: 10/27/2018 12:08:12 AM
Asset Size Chunks Chunk Names
app.bundle.js 54.2 KiB app [emitted] app
app.bundle.js.map 41.9 KiB app [emitted] app
vendor.bundle.js 1.26 MiB vendor [emitted] vendor
vendor.bundle.js.map 1.3 MiB vendor [emitted] vendor
Entrypoint app = vendor.bundle.js vendor.bundle.js.map app.bundle.js app.bundle.js.map
[0] multi ./src/App.jsx webpack-hot-middleware/client 40 bytes {app} [built]
[./node_modules/ansi-html/index.js] 4.16 KiB {vendor} [built]
[./node_modules/babel-polyfill/lib/index.js] 833 bytes {vendor} [built]
...
如你所见,除了包包,还有附带的地图,扩展名为.map。现在,当您查看浏览器的开发人员控制台时,您将能够看到原始源代码,并能够在其中放置断点。Chrome 浏览器中的一个例子如图 8-1 所示。
图 8-1
使用源代码映射在原始源代码中设置断点
您会发现其他浏览器中的源代码大致相似,但不完全相同。你可能要四处看看才能找到它们。例如在 Safari 中,可以在 Sources-> app . bundle . js-> " "-> src 下看到源代码。
如果您使用 Chrome 或 Firefox 浏览器,您还会在控制台中看到一条消息,要求您安装 React 开发工具插件。你可以在 https://reactjs.org/blog/2015/09/02/new-react-developer-tools.html 找到这些浏览器的安装说明。这个附加组件提供了以类似 DOM inspector 的分层方式查看 React 组件的能力。例如,在 Chrome 浏览器中,你会在开发者工具中找到一个 React 标签。图 8-2 显示了这个附加组件的截图。
图 8-2
Chrome 浏览器中的 React 开发者工具
注意
在撰写本书时,React 开发人员工具与 React 版本 16.6.0 存在兼容性问题。如果你确实面临一个问题(控制台中会出现类似Uncaught TypeError: Cannot read property 'displayName' of null的错误),你可能不得不将 React 的版本降级到 16.5.2。
定义插件:构建配置
您可能不习惯我们在前端注入环境变量的机制:像env.js这样生成的脚本。首先,这比生成一个已经在需要替换的地方替换了这个变量的包效率低。另一个原因是全局变量通常不被接受,因为它会与其他脚本或包中的全局变量冲突。
幸运的是,有一个选择。我们不会使用这种机制来注入环境变量,但是我已经在这里讨论过了,所以如果方便的话,它会给你一个尝试和采用的选项。
为了在构建时替换变量,Webpack 的DefinePlugin插件派上了用场。作为webpack.config.js的一部分,下面的内容可以添加到中,定义一个预定义的字符串,其值如下:
...
plugins: [
new webpack.DefinePlugin({
__UI_API_ENDPOINT__: "'http://localhost:3000/graphql'",
})
],
...
现在,在App.jsx的代码中,可以像这样使用__UI_API_ENDPOINT__字符串,而不是硬编码这个值(注意没有引号;它由变量本身提供):
...
const response = await fetch(__UI_API_ENDPOINT__, {
...
当 Webpack 转换并创建一个包时,该变量将在源代码中被替换,结果如下:
...
const response = await fetch('http://localhost:3000/graphql', {
...
在webpack.config.js中,您可以通过使用dotenv和一个环境变量来确定变量的值,而不是在那里硬编码:
...
require('dotenv').config();
...
new webpack.DefinePlugin({
__UI_API_ENDPOINT__: `'${process.env.UI_API_ENDPOINT}'`,
})
...
虽然这种方法工作得很好,但是它的缺点是必须为不同的环境创建不同的包或构建。这也意味着一旦部署,例如,对服务器配置的更改,如果不进行另一次构建,就无法完成。出于这些原因,我选择坚持通过env.js为问题跟踪器应用注入运行时环境。
生产优化
尽管 Webpack 完成了所有必要的工作,比如当模式被指定为生产时缩小 JavaScript 的输出,但是有两件事情需要开发人员特别注意。
首先要关心的是捆绑大小。本章最后,第三方库并不多,厂商捆绑的大小在生产模式下在 200KB 左右。这个一点都不大。但是随着我们添加更多的特性,我们将使用更多的库,包的大小也必然会增加。随着我们在接下来的几章中的进展,您将很快发现,当编译用于生产时,Webpack 开始显示一个警告,提示vendor.bundle.js的包大小太大,这会影响性能。此外,还会有一个警告,即入口点app所需的所有资产的组合大小太大。
解决这些问题的方法取决于应用的类型。对于用户经常使用的应用,如问题跟踪器应用,包的大小不是很重要,因为它将被用户的浏览器缓存。除了第一次之外,包不会被获取,除非它们已经被改变。由于我们已经将应用捆绑包与库分开,我们或多或少地确保了作为供应商捆绑包一部分的大部分 JavaScript 代码不会改变,因此不需要频繁获取。因此,可以忽略 Webpack 警告。
但是对于有很多不经常使用的用户的应用,他们中的大多数是第一次访问 web 应用,或者在很长时间之后,浏览器缓存将没有任何作用。为了优化此类应用的页面加载时间,重要的是不仅要将包分成更小的部分,而且要使用一种称为延迟加载的策略仅在需要时加载包。拆分和加载代码以提高性能的实际步骤取决于应用的使用方式。例如,推迟预先加载 React 库是没有意义的,因为如果不这样做,任何页面的内容都不会显示。但是在后面的章节中,你会发现这是不正确的,当页面是使用服务器渲染和 React 构建的时候,它们确实可以被延迟加载。
对于问题跟踪器应用,我们假设它是一个经常使用的应用,因此浏览器缓存对我们来说非常有用。如果您的项目需求不同,您会发现关于代码拆分( https://webpack.js.org/guides/code-splitting/ )和惰性加载( https://webpack.js.org/guides/lazy-loading/ )的 Webpack 文档很有用。
另一个需要关注的是*浏览器缓存,*尤其是当你不想让它缓存 JavaScript 包的时候。当应用代码发生更改,并且用户浏览器缓存中的版本错误时,就会发生这种情况。大多数现代浏览器都很好地处理了这一点,通过检查服务器包是否已经改变。但是旧的浏览器,尤其是 Internet Explorer,会主动缓存脚本文件。唯一的解决方法是,如果脚本文件的内容已经更改,就更改它的名称。
这在 Webpack 中通过使用内容散列作为包名的一部分来解决,如位于 https://webpack.js.org/guides/caching/ 的 Webpack 文档中的缓存指南所述。注意,由于脚本名称已经生成,您还需要生成index.html本身来包含生成的脚本名称。这也是由一个名为 HTMLWebpackPlugin 的插件实现的。
我们不会在问题跟踪器应用中使用它,但是您可以在 Webpack ( https://webpack.js.org/guides/output-management/ )的输出管理指南和从 https://webpack.js.org/plugins/html-webpack-plugin/ 开始的 HTMLWebpackPlugin 本身的文档中了解更多关于如何做的信息。
摘要
延续前一章中编码卫生的精神,我们在本章中模块化了代码。由于 JavaScript 最初并不是为模块化而设计的,所以我们需要 Webpack 工具来将一些小的 JavaScript 文件和 React 组件放在一起并生成一些包。
我们消除了运行时库(如 React 和 polyfills)对 CDN 的依赖。同样,Webpack 帮助解决了依赖性,并为它们创建了包。您还看到了 Webpack 的 HMR 如何通过有效地替换浏览器中的模块来帮助我们提高生产率。然后,您了解了有助于调试的源映射。
在下一章,我们将回到添加特性上来。我们将探索客户端路由的一个重要概念,它将允许我们显示不同的组件或页面,并以无缝的方式在它们之间导航,即使应用实际上将继续是单页面应用(SPA)。
练习答案
练习:变换和捆绑
-
不,如果您保存的文件只有额外的空间,Webpack 不会重建。这是因为预处理或加载阶段产生了一个规范化的 JavaScript,它与原始的 JavaScript 没有什么不同。仅当规范化脚本不同时,才会触发重新绑定。
-
到目前为止,我们只有一个页面可以显示,即问题列表。接下来,我们将呈现其他页面,例如,编辑问题的页面,列出所有用户的页面,显示个人资料信息的页面,等等。然后,
App.jsx文件需要根据用户交互挂载不同的组件。因此,将应用与可能加载的每个顶级组件分开很方便。 -
不使用
default关键字会导致将类导出为导出对象的属性(而不是它本身)。这相当于在定义了可导出元素之后执行此操作:export { IssueList };In the
importstatement, you would have to do this:import { IssueList } from './IssueList.jsx';
注意 LHS 周围的析构赋值。这允许从单个文件中导出多个元素,您希望从导入中导出的每个元素用逗号分隔。当只导出一个元素时,最简单的方法是使用default关键字。
练习:热模块更换
-
浏览器控制台中有许多日志告诉您 HMR 正在被调用。此外,如果您查看网络请求,您会发现对于浏览器刷新,请求是针对所有资产的。看看这些资产的规模。通常,当客户端代码改变时,
vendor.bundle.js不会被再次获取(它会返回 304 响应),但是app.bundle.js会被重新加载。但是当 HMR 成功的时候,你会看到所有的资产都没有被取走;相反,传输的是比
app.bundle.js小得多的增量文件。*
九、React 路由
既然我们已经组织了项目并添加了开发工具以提高生产力,那么让我们回到添加更多特性到问题跟踪器上来。
在这一章中,我们将探索路由的概念,或者处理我们可能需要显示的多个页面。即使在单页面应用(SPA)中,实际上应用中也有多个逻辑页面(或视图)。只是页面加载只在第一次从服务器进行。之后,通过操作或更改 DOM 而不是从服务器获取整个页面来显示其他视图。
要在应用的不同视图之间导航,需要 routing 。路由将页面的状态链接到浏览器中的 URL。这不仅是一种根据 URL 推断页面中显示内容的简单方法,它还具有以下非常有用的属性:
-
用户可以使用浏览器的前进/后退按钮在应用的已访问页面(实际上是视图)之间导航。
-
个人网页可以加入书签,以后再访问。
-
视图链接可以与其他人共享。假设您想请某人帮助您解决某个问题,并且您想向他们发送显示该问题的链接。对于收件人来说,通过电子邮件向他们发送链接比让他们浏览用户界面要容易和方便得多。
在水疗真正成熟之前,这是相当困难的,有时甚至是不可能的。SPAs 只有一个页面,也就是说只有一个 URL。所有的导航都必须是交互式的:用户必须通过预定义的步骤浏览应用。例如,无法将特定问题的链接发送给某人。相反,他们必须被告知按照 SPA 上的一系列步骤来解决问题。但是现代水疗会优雅地处理这个问题。
在本章中,我们将探索如何使用 React Router 来简化在视图之间设置导航的任务。我们将从应用的另一个视图开始,在这个视图中,用户可以查看和编辑单个问题。然后,我们将在视图之间创建链接,以便用户可以在它们之间导航。在我们创建的超链接上,我们将添加可以传递到不同视图的参数,例如,需要显示的问题的 ID,到显示单个问题的视图。最后,我们将看到如何嵌套组件和路由。
为了影响路由,任何页面都需要连接到浏览器能够识别并指示“这是用户正在查看的页面”的东西一般来说,对于水疗中心,有两种方式来建立这种联系:
-
基于散列的 : 这使用 URL 的锚部分(跟随
#的所有内容)。这个方法很自然,因为#部分可以被解释为页面中的一个位置,并且在一个 SPA 中只有一个页面。这个位置决定了显示页面的哪个部分。在#之前的部分永远不会从组成整个应用的唯一页面(index.html)改变。这很容易理解,并且对大多数应用都很有效。事实上,在不使用路由库的情况下,我们自己实现基于散列的路由是非常简单的。但是我们不会自己做,我们会用 React 路由来做。 -
*浏览器历史:*这使用了新的 HTML5 API,让 JavaScript 处理页面转换,同时防止浏览器在 URL 改变时重新加载页面。即使有 React Router 的帮助,实现起来也有点复杂(因为它迫使您考虑当服务器收到对不同 URL 的请求时会发生什么)。但是,当我们想从服务器本身呈现一个完整的页面时,这非常方便,尤其是让搜索引擎爬虫获取页面内容并对其进行索引。
我们将从基于散列的技术开始,因为它容易理解,然后切换到浏览器历史技术,因为我们将在后面的章节中实现服务器端呈现。
简单路由
在本节中,我们将创建两个视图,一个用于我们一直在处理的问题列表,另一个(占位符)用于报告节。我们还将确保主页,即/,重定向到问题列表。首先,让我们安装将帮助我们完成这一切的包:React Router。
$ cd ui
$ npm install react-router-dom@4
让我们也为报告视图创建一个占位符组件。我们将把它和其他组件一起保存在ui/src目录中。我们称这个组件的文件为IssueReport.jsx,其全部内容在清单 9-1 中列出。
import React from 'react';
export default function IssueReport() {
return (
<div>
<h2>This is a placeholder for the Issue Report</h2>
</div>
);
}
Listing 9-1ui/src/IssueReport.jsx: New File for Report Placeholder
现在,让我们将应用的主页分成两个部分:一个标题部分包含一个导航栏,其中包含指向不同视图的超链接;一个内容部分,它将根据所选的超链接在两个视图之间切换。无论显示何种视图,导航栏都将保持不变。将来,我们可能会在内容部分看到其他视图。让我们为内容创建一个组件,并把它放在目录ui/src下名为Contents.jsx的文件中。该组件将负责视图之间的切换。
为了基于被点击的超链接实现不同组件之间的路由或切换,React Router 提供了一个名为Route的组件。它将路由需要匹配的路径和当路径与浏览器中的 URL 匹配时需要显示的组件作为属性。让我们使用路径/issues来显示问题列表,使用/report来显示报告视图。以下代码片段将实现这一点:
...
<Route path="/issues" component={IssueList} />
<Route path="/report" component={IssueReport} />
...
为了将主页重定向到/issues,我们可以进一步添加一个从/重定向到/issues的Redirect组件,如下所示:
...
<Redirect from="/" to="/issues" />
...
最后,让我们添加一条当没有匹配的路由时显示的消息。注意,当属性path没有为Route组件指定时,这意味着它匹配任何路径。
...
const NotFound = () => <h1>Page Not Found</h1>;
...
<Route component={NotFound} />
...
这四个路由需要封装在一个包装器组件中,它可以只是一个<div>。但是为了表明只需要显示这些组件中的一个,它们应该被包含在一个<Switch>组件中,以便只呈现第一个匹配的组件。在这种情况下,我们确实需要switch,因为最后一条路线将匹配任何路径。
还要注意,该匹配是一个前缀为的匹配。例如,路径/将不仅匹配/,还匹配/issues和/report。所以路线的顺序也很重要。Redirect必须在/issues和/report之后出现,并且总括路线必须在最后出现。或者,可以将exact属性添加到任何路由中,以表明它需要完全匹配。
请注意,匹配在两个方面不同于快速路由。首先,在 Express 中,默认情况下匹配是精确的,必须添加一个*来匹配后面的任何内容。其次,在 Express 中,路由匹配停止进一步的处理(除非是中间件,它可以为请求-响应过程增值并继续),而在 React Router 中,明确需要一个<Switch>来使它在第一次匹配时停止。否则,所有路径匹配的组件都会被渲染。
让我们对Redirect使用一个精确匹配,并让全包路线成为最后一个。在添加了必要的import语句和<Switch>包装器之后,Contents.jsx的最终内容如清单 9-2 所示。
import React from 'react';
import { Switch, Route, Redirect } from 'react-router-dom';
import IssueList from './IssueList.jsx';
import IssueReport from './IssueReport.jsx';
const NotFound = () => <h1>Page Not Found</h1>;
export default function Contents() {
return (
<Switch>
<Redirect exact from="/" to="/issues" />
<Route path="/issues" component={IssueList} />
<Route path="/report" component={IssueReport} />
<Route component={NotFound} />
</Switch>
);
}
Listing 9-2ui/src/Contents.jsx: New File for the Contents Section
接下来,让我们创建显示导航栏和内容组件的页面。一个像这样一个接一个地显示NavBar和Contents组件的无状态组件将完成必要的工作。(需要一个<div>来包含这两个元素,因为组件的 render 方法只能返回一个元素) :
...
<div>
<NavBar />
<Contents />
</div>
...
至于导航栏,我们需要一系列超链接。因为我们将使用HashRouter,所有的页面都将有主 URL 作为/,后面是一个以#符号开始的页面内锚,并且有路线的实际路径。例如,为了匹配Route规范中指定的/issues路径,URL 将是/#/issues,其中第一个/是 SPA 的唯一页面,#是锚点的分隔符,/issues是路由的路径。
因此,到问题列表的链接将采用/#/issues的形式,如下所示:
...
<a href="/#/issues">Issue List</a>
...
让我们有三个超链接,一个是主页,一个是问题列表,一个是报告。要用竖线(|)字符分隔它们,我们需要使用如下 JavaScript 表达式:
...
{' | '}
...
这是因为空白被 JSX 变换去除了,否则我们不能自然地添加周围有空白的条。让我们将导航栏创建为一个带有三个超链接的无状态组件,并将它与同样无状态的Page组件放在一个名为Page.jsx的新文件中。这个新文件的内容如清单 9-3 所示。
import React from 'react';
import Contents from './Contents.jsx';
function NavBar() {
return (
<nav>
<a href="/">Home</a>
{' | '}
<a href="/#/issues">Issue List</a>
{' | '}
<a href="/#/report">Report</a>
</nav>
);
}
export default function Page() {
return (
<div>
<NavBar />
<Contents />
</div>
);
}
Listing 9-3ui/src/Page.jsx: New File for Composite Page
最后,在App.jsx中,我们需要将这个页面而不是原始的IssueList组件呈现到 DOM 中。此外,页面本身需要包装在路由周围,因为所有路由功能都必须在路由中才能工作。我们将使用react-router-dom包中的HashRouter组件。清单 9-4 显示了对App.jsx的这些更改。
...
import ReactDOM from 'react-dom';
import { HashRouter as Router } from 'react-router-dom';
import IssueList from './IssueList.jsx';
import Page from './Page.jsx';
const element = <IssueList />;
const element = (
<Router>
<Page />
</Router>
);
...
Listing 9-4ui/src/App.jsx: Changes to Mount Page Instead of IssueList
注意
尽管我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有被收入书中。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。
现在,如果您通过导航到localhost:8000在浏览器中测试应用,您会发现浏览器的 URL 会自动更改为http://localhost:8000/#/issues。这是因为Redirect路线。在屏幕上,您会看到一个导航栏和其下方的常见问题列表。截图如图 9-1 所示。
图 9-1
导航栏和常见问题列表
现在,您应该能够通过单击导航栏中的超链接在三个视图之间切换。点击主页将重定向至问题列表,点击报告将显示类似报告视图的占位符,如图 9-2 所示。
图 9-2
问题报告占位符
如果键入任何其他文本而不是报告或问题,您也应该看到“未找到页面”消息。重要的是,您应该能够使用前进和后退按钮在导航历史中导航。浏览器的刷新也应该显示当前页面。
练习:简单路由
-
从
Redirect的属性列表中删除exact。会发生什么?你能解释这种行为吗?没有exact属性你能达到要求吗?(记住在此练习后恢复更改。) -
用一个
<div>替换<Switch>。现在发生了什么?你能解释一下这个吗?(在此练习之后,还原代码以恢复原始行为。) -
查看问题列表时,如果您在 URL 后面追加一些额外的文本,例如/
#/issues000,您预计会发生什么情况?尝试一下来确认你的答案。现在,尝试同样的方法,但是在额外的文本前加一个/符号,例如/#/issues/000。现在你期待什么,你看到了什么?也试着在路线中加入exact。关于匹配算法,它告诉了你什么?
本章末尾有答案。
路线参数
正如您刚才看到的(也就是说,如果您已经完成了上一节中的练习),URL 的路径和路由的路径不需要完全匹配。URL 中匹配部分后面的内容是路径的动态部分,它可以作为路由组件中的一个变量来访问。这是向组件提供参数的一种方式。另一种方法是使用 URL 的查询字符串,我们将在下一节探讨这一点。
让我们使用这个工具来显示一个允许我们编辑问题的页面。现在,我们将创建一个占位符,就像我们对报告所做的那样。我们将这个文件称为IssueEdit.jsx。稍后,我们将进行 Ajax 调用,获取问题的详细信息,并以表单的形式显示出来,供用户进行更改和保存。为了确保我们收到的是正确的问题 ID,让我们将其显示在占位符中。
在 route path 中指定参数类似于在 Express 中,使用字符:后跟将接收值的属性的名称。我们姑且称编辑一个问题的路径的基,/edit。然后,路径规范/edit/:id将匹配一个 URL 路径,如/edit/1、/edit/2等。对路线的更改以及组件的导入如清单 9-5 所示,在Contents.jsx文件中。
...
import IssueReport from './IssueReport.jsx';
import IssueEdit from './IssueEdit.jsx';
...
<Route path="/issues" component={IssueList} />
<Route path="/edit/:id" component={IssueEdit} />
...
Listing 9-5ui/src/Contents.jsx: Changes for IssueEdit Route
通过props,所有路由组件都被提供一个名为match的对象,该对象包含匹配操作的结果。其中包含一个名为params的字段,用于保存路由参数变量。因此,要访问包含id的 URL 路径的尾部,可以使用match.params.id。让我们使用它并在IssueEdit.jsx中创建占位符编辑组件。该文件的内容如清单 9-6 所示。
import React from 'react';
export default function IssueEdit({ match }) {
const { id } = match.params;
return (
<h2>{`This is a placeholder for editing issue ${id}`}</h2>
);
}
Listing 9-6ui/src/IssueEdit.jsx: New File for Placeholder IssueEdit Component
现在,你可以输入/ #/edit/1等等。在浏览器的 URL 栏中进行测试,但是为了方便起见,我们在问题列表的每一行中创建一个超链接。为此,我们将创建一个名为 Action 的新列,并用一个指向/edit/<issue id>的超链接填充它。这些变化出现在IssueTable.jsx,如清单 9-7 所示。
...
function IssueRow({ issue }) {
...
<td>{issue.title}</td>
<td><a href={`/#/edit/${issue.id}`}>Edit</a></td>
</tr>
...
}
...
export default function IssueTable({ issues }) {
...
<th>Title</th>
<th>Action</th>
</tr>
...
}
...
Listing 9-7ui/src/IssueTable.jsx
现在,如果您测试应用并转到问题列表页面,您将在表格右侧看到一个额外的列,其中有一个名为 Edit 的链接。单击此链接应该会显示用于编辑问题的占位符页面,以及您单击的问题的 ID。要返回问题列表页面,您可以使用浏览器的后退按钮,或者单击导航栏中的问题列表超链接。占位符编辑页面的截图如图 9-3 所示。
图 9-3
编辑页面占位符
查询参数
像我们在上一节中看到的那样,添加变量(如正在编辑的问题的 ID)作为路由参数是非常简单和自然的。但是会有这样的情况,变量很多,而且不一定有一定的顺序。
让我们以问题列表为例。到目前为止,我们一直在显示数据库中的所有问题。这显然不是一个好主意。理想情况下,我们将有许多方法来过滤要显示的问题。例如,我们希望根据状态、受托人等进行筛选。,能够在数据库中搜索包含特定文本的问题,并具有用于对列表进行排序和分页的附加参数。URL 的查询字符串部分是处理这些问题的理想方式。
我们现在不会实现所有可能的过滤器、排序和分页。但是为了理解查询参数是如何工作的,让我们基于 status 字段实现一个简单的过滤器,以便用户可以只列出具有特定状态的问题。让我们首先更改 List API 来接受这个过滤器。让我们从更改 GraphQL 模式开始。这是一个简单的变化;我们所需要做的就是将一个名为status的参数添加到issueList查询中,类型为StatusType。这一变化如清单 9-8 所示。
...
type Query {
about: String!
issueList(status: StatusType): [Issue!]!
}
...
Listing 9-8api/schema.graphql: Addition of a Filter to issueList API
让我们在文件issue.js的 API 实现中,在函数list()中接受这个新的参数。这个函数现在将接受一个名为status的参数,类似于add()函数。由于参数是可选的,我们将有条件地添加一个状态过滤器,并将其传递给集合的find()方法。这些变化如清单 9-9 所示,都是issue.js的一部分。
...
async function list(_, { status }) {
const db = getDb();
const filter = {};
if (status) filter.status = status;
const issues = await db.collection('issues').find(filter).toArray();
return issues;
}
...
Listing 9-9api/issue.js: Handle Filtering on Issue Status
在这一点上,使用操场运行一个快速测试是很好的。您可以使用以下查询测试对状态为New的问题的问题列表的过滤:
{
issueList(status: New) {
id status title
}
}
您应该得到一个只包含新问题的响应。此外,您可以确保原始查询(没有任何过滤器)也能正常工作,返回数据库中的所有问题。
现在,让我们用三个超链接替换筛选器的占位符:所有问题、新问题和已分配问题。让我们使用一个名为status的查询字符串变量,其值指示要过滤的状态,并添加超链接,就像我们在导航栏中所做的那样。带有超链接而不是占位符的新组件如清单 9-10 所示,作为IssueFilter.js文件的全部内容。
/* eslint "react/prefer-stateless-function": "off" */
import React from 'react';
export default class IssueFilter extends React.Component {
render() {
return (
<div>This is a placeholder for the issue filter.</div>
<div>
<a href="/#/issues">All Issues</a>
{' | '}
<a href="/#/issues?status=New">New Issues</a>
{' | '}
<a href="/#/issues?status=Assigned">Assigned Issues</a>
</div>
);
}
}
Listing 9-10ui/src/IssueFilter.js: New Component with Filter Links
查询字符串需要由作为loadData()函数一部分的IssueList组件来处理。就像match属性一样,React 路由也提供一个名为location的对象作为 props 的一部分,该对象包括路径(在字段pathname中)和查询字符串(在字段search中)。React 路由不解析查询字符串,而是让应用决定如何解析这个字段。让我们遵循查询字符串的常规解释和解析,这可以通过 JavaScript API URLSearchParams()轻松完成,就像这样,在loadData()方法中:
...
const { location: { search } } = this.props;
const params = new URLSearchParams(search);
...
API URLSearchParams()可能需要一个针对旧浏览器的 polyfill,尤其是 Internet Explorer,如 https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams 的 MDN 文档中所述。既然我们也承诺支持 IE,让我们安装 polyfill。
$ cd ui
$ npm install url-search-params@1
要包含 polyfill,我们必须在IssueList.jsx中将其导入。
...
import URLSearchParams from 'url-search-params';
...
解析完查询字符串后,我们将使用URLSearchParams的get()方法访问status参数,就像params.get('status')一样。让我们创建一个变量,作为查询变量提供给 GraphQL。如果参数不存在,params.get()方法返回null,但是在这种情况下我们想跳过设置变量。因此,在将状态添加到变量之前,我们将添加一个检查来查看状态是否已定义。
...
const vars = {};
if (params.get('status')) vars.status = params.get('status');
...
让我们将简单的 GraphQL 查询修改为一个带有变量的命名操作:
...
const query = `query issueList($status: StatusType) {
issueList (status: $status) {
id title status owner
created effort due
}
}`;
...
现在,我们可以修改对graphQLFetch()的调用,以包含具有状态过滤器参数的查询变量:
...
const data = await graphQLFetch(query, vars);
...
此时,如果您尝试应用并通过单击每个过滤器超链接来应用过滤器,您会发现问题列表并没有改变。但是在 URL 中使用现有过滤器刷新浏览器时,它会显示正确的过滤问题列表。您还会发现,导航到另一个路径,例如报告页面或编辑页面,并使用 back 按钮会使过滤器生效。这表明在初始渲染时调用了loadData(),但是查询字符串的变化不会导致调用loadData()。
我在前面简单地谈到了组件生命周期方法。这些是 React 对组件所做的各种更改的挂钩。我们使用生命周期方法componentDidMount()来挂钩组件的初始就绪状态。类似地,我们需要挂钩一个方法,告诉我们查询字符串已经更改,这样我们就可以重新加载列表。这个图表很好地描述了一整套生命周期方法: http://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/ 。
从图中可以清楚地看出,生命周期方法componentDidUpdate()有利于在某些属性发生变化时采取行动。发生这种情况时会自动调用一个render(),但这还不够。我们还需要通过调用loadData()来刷新状态。
让我们实现生命周期挂钩componentDidUpdate(),并在必要时通过比较IssueList中的新旧查询字符串来重新加载数据。这个方法是通过了前面的道具。当前道具可以使用this.props来获得。我们可以通过比较先前和当前属性的location.search属性来检测过滤器中的变化,并在发生变化时重新加载数据:
...
componentDidUpdate(prevProps) {
const { location: { search: prevSearch } } = prevProps;
const { location: { search } } = this.props;
if (prevSearch !== search) {
this.loadData();
}
}
...
清单 9-11 显示了对IssueList组件的完整更改,包括最近的更改。
...
import React from 'react';
import URLSearchParams from 'url-search-params';
...
componentDidUpdate(prevProps) {
const { location: { search: prevSearch } } = prevProps;
const { location: { search } } = this.props;
if (prevSearch !== search) {
this.loadData();
}
}
...
async loadData() {
const { location: { search } } = this.props;
const params = new URLSearchParams(search);
const vars = {};
if (params.get('status')) vars.status = params.get('status');
const query = `query {
issueList {
const query = `query issueList($status: StatusType) {
issueList (status: $status) {
id title status owner
created effort due
}
}`;
const data = await graphQLFetch(query, vars);
if (data) {
...
}
...
Listing 9-11ui/src/IssueList.jsx: Changes for Handling a Query String Based Filter
现在,如果您在不同的过滤器超链接之间导航,您会发现问题列表会根据所选的过滤器进行刷新。
练习:查询参数
-
通过在 URL 栏中更改问题 ID 或为其中一个问题创建书签,在两个不同问题的编辑页面之间导航。正确显示了两个不同的页面。与问题列表页面相比,这里需要一个
componentDidUpdate()方法。为什么会这样?提示:思考属性的更改如何影响问题编辑页面中的问题列表。 -
在
componentDidUpdate()中,检查新旧属性是否相同。这需要吗?一个原因当然是,它避免了不必要的loadData()。但是为什么不在属性改变时重新加载数据呢?自己试试看。(记得在练习后恢复更改。) -
在
IssueAdd的render()方法中添加一个断点,在过滤器之间切换。你会发现当过滤器改变时,这个组件被再次渲染。对性能有什么影响?这怎么优化?提示:阅读关于在https://reactjs.org/docs/react-component.htmlReact 的文档中的“组件生命周期”部分。
本章末尾有答案。
链接
到目前为止,我们一直使用href来创建超链接。尽管这是可行的,但 React Router 提供了一种更好、更方便的方式来通过Link组件创建链接。这个组件很像一个href,但是它有以下不同之处:
-
Link中的路径总是绝对的;它不支持相对路径。 -
查询字符串和散列可以作为单独的属性提供给
Link。 -
Link、NavLink的一个变体能够判断当前 URL 是否与链接匹配,并向链接添加一个类以将其显示为活动的。 -
一个
Link在不同种类的路由之间工作是相同的,也就是说,指定路由的不同方式(使用#字符,或使用路径原样)对程序员是隐藏的。
让我们利用链接组件的这些属性,将所有的href更改为Link,这个组件有一个属性to,它可以是一个字符串(对于简单的目标)或一个对象(对于带有查询字符串的目标,等等)。).让我们从IssueRow组件的变化开始,这里的目标是一个简单的字符串。我们需要将组件的名称从<a>更改为<Link>,并将属性href更改为to,同时保持与目标相同的字符串。清单 9-12 中显示了IssueTable.jsx的变化。
...
import React from 'react';
import { Link } from 'react-router-dom';
function IssueRow({ issue }) {
...
<td>{issue.title}</td>
<td><a href={`/#/edit/${issue.id}`}>Edit</a></td>
<td><Link to={`/edit/${issue.id}`}>Edit</Link></td>
...
}
...
Listing 9-12ui/src/IssueTable.jsx: Changes to IssueRow to Use Link
接下来我们可以使用IssueFilter,这里有查询字符串。这一次,让我们提供一个分别包含路径和查询字符串的对象,而不是一个字符串。这些的对象属性分别是pathname和search。因此,对于新问题的链接,to属性将包含路径名为/issues,查询字符串为?status=New。
请注意,React Router 不会对查询字符串做出假设,正如您在上一节中看到的那样。本着同样的精神,它也要求将前缀?作为查询字符串的一部分。清单 9-13 中显示了对IssueFilter的这些更改。
...
import React from 'react';
import { Link } from 'react-router-dom';
export default class IssueFilter extends React.Component {
...
<a href="/#/issues">All Issues</a>
<Link to="/issues">All Issues</Link>
...
<a href="/#/issues?status=New">New Issues</a>
<Link to={{ pathname: '/issues', search: '?status=New' }}>
New Issues
</Link>
...
<a href="/#/issues?status=Assigned">Assigned Issues</a>
<Link to={{ pathname: '/issues', search: '?status=Assigned' }}>
Assigned Issues
</Link>
...
}
...
Listing 9-13ui/src/IssueFilter.jsx: Change to Convert hrefs to Links
至于Page.jsx中的导航栏,让我们用一个NavLink来代替,这将允许我们突出显示当前活动的导航链接。注意,NavLink将高亮显示任何部分匹配 URL 路径的路径,这些路径基于由/分隔的段。当导航路径的整个层次结构都可以突出显示时,或者当导航链接在页面中有更多变化时,这很有用。对于我们目前在应用中的导航栏来说,Home链接,它的目标只是/,将匹配浏览器 URL 中的任何路径。为了避免总是高亮显示,NavLink有一个exact属性,就像Route组件的属性一样,强制进行精确匹配而不是前缀匹配。让我们只对Home链接使用该属性,并像对IssueRow组件那样简单地转换其他属性。这些变化如清单 9-14 所示。
...
import React from 'react';
import { NavLink } from 'react-router-dom';
...
function NavBar() {
...
<a href="/">Home</a>
<NavLink exact to="/">Home</NavLink>
...
<a href="/#/issues">Issue List</a>
<NavLink to="/issues">Issue List</NavLink>
...
<a href="/#/report">Report</a>
<NavLink to="/report">Report</NavLink>
...
}
...
Listing 9-14ui/src/Page.jsx: Change to Replace hrefs with NavLinks
NavLink只在链接匹配 URL 时添加一个名为active的类。为了改变活动链接的外观,我们需要为这个类定义一个样式。让我们为样式规范中的活动链接使用浅蓝色背景。清单 9-15 显示了index.html的这一变化。
...
<style>
...
a.active {background-color: #D8D8F5;}
</style>
...
Listing 9-15ui/public/index.html: Style for Active NavLinks
现在,当您测试应用时,您不仅应该看到它像以前一样工作,还应该看到基于当前显示的页面突出显示的导航链接之一:问题列表页面或报告页面。然而,没有任何相应导航链接的编辑页面不会导致任何链接被突出显示。查看问题列表时的应用截图如图 9-4 所示。
图 9-4
查看列表时突出显示的问题列表链接
如果您两次单击同一个链接,您可能还会在开发人员工具控制台中看到一条警告,称“哈希历史不能推送相同的路径...”此消息仅在开发模式下可见,并且仅在我们以编程方式推入与之前相同的路由路径时才会显示。您可以放心地忽略此警告。在任何情况下,我们将很快过渡到浏览器历史路由,在那里将不会看到这个警告。
练习:链接
-
你可能已经注意到我们没有使用
NavLinks 来过滤链接。试着把这些也改成NavLinks。在过滤器之间导航时,您观察到了什么?你能解释这个吗?(记得在完成实验后恢复更改。) -
假设您使用的是第三方 CSS 库,使用该库突出显示链接的方式是添加
current类而不是active类。你会怎么做?提示:在https://reacttraining.com/react-router/web/api/NavLink查阅NavLink的文档。
本章末尾有答案。
程序导航
当变量值是动态的并且可能有许多无法预先确定的组合时,通常使用查询字符串。它们通常也是 HTML 表单的结果。一个表单需要动态地构造查询字符串,这与我们到目前为止在Link中使用的预先确定的字符串相反。
在后面的章节中,我们将创建一个更正式的表单,而不仅仅是将状态作为过滤器,但是在这一节中,我们将添加一个简单的下拉列表,并根据下拉列表的值设置查询字符串。我们可以通过传递一个来自IssueList的回调来直接重新加载列表,该回调接受新的过滤器作为参数。但是,URL 将不会反映页面的当前状态,这不是一个好主意,因为如果用户刷新浏览器,过滤器将被清除。建议保持数据流单向:当下拉列表值改变时,它会改变 URL 的查询字符串,进而应用过滤器。即使我们从中间开始也是一样的:直接改变 URL 的查询字符串,它将应用过滤器。
让我们首先创建这个简单的下拉列表,并用它替换IssueFilter中的链接。
...
<div>
Status:
{' '}
<select>
<option value="">(All)</option
<option value="New">New</option>
...
</select>
</div>
...
注意
编译器在元素边界处去除 JSX 中的所有空白,因此标签Status:后的空格将不起作用。在标签后添加空格的一种方法是使用 HTML 不间断空格。另一种插入元素的方法是将它作为 JavaScript 文本添加,这就是我们在本例中使用的方法。
接下来,让我们在 dropdown 值改变时捕获事件,并且可以预见的是,在onChange中捕获这个事件的属性。让我们添加这个属性,并将其设置为一个名为onChangeStatus的类方法。
...
<select onChange={this.onChangeStatus}>
...
在方法onChangeStatus的实现中,我们可以通过value属性,使用事件的目标(它将是下拉列表本身的句柄)获取下拉列表中所选项目的值:
...
onChangeStatus(e) {
const status = e.target.value;
}
...
就像 React Router 给IssueList组件增加的location属性一样,它还增加了一些更多的属性,其中一个是history.使用 this,location,query string 等。可以设置浏览器的 URL。但是,与IssueList不同,由于IssueFilter不直接是任何路由的一部分,React 路由不能自动使这些可用。为此,我们必须将这些附加属性显式地注入到IssueFilter组件中。这可以使用 React Router 提供的名为withRouter()的包装函数来完成。这个函数接受一个组件类作为参数,并返回一个新的组件类,它的history、location和match作为props的一部分。因此,我们不导出组件,而是像这样导出包装好的组件:
...
export default class IssueFilter extends React.Component {
...
}
...
export default withRouter(IssueFilter);
...
现在,在onChangeStatus()中,我们将可以访问this.props.history,它可以用于根据更改后的过滤器推送新位置。但是要访问处理程序中的this,我们必须确保处理程序被绑定到构造函数中的this。
...
constructor() {
super();
this.onChangeStatus = this.onChangeStatus.bind(this);
}
...
现在,在处理程序中,我们可以使用history的push()方法来推送新位置。这个方法接受一个对象,就像我们用于Link指定位置的对象一样,即一个pathname和一个search。让我们也处理一下空状态选项,我们将不会对其进行搜索。
...
onChangeStatus(e) {
...
const { history } = this.props;
history.push({
pathname: '/issues',
search: status ? `?status=${status}` : '',
});
}
...
清单 9-16 中显示了IssueFilter.jsx的完整源代码。删除的代码没有显示出来,因为几乎所有以前的代码都被删除了。
import React from 'react';
import { withRouter } from 'react-router-dom';
class IssueFilter extends React.Component {
constructor() {
super();
this.onChangeStatus = this.onChangeStatus.bind(this);
}
onChangeStatus(e) {
const status = e.target.value;
const { history } = this.props;
history.push({
pathname: '/issues',
search: status ? `?status=${status}` : '',
});
}
render() {
return (
<div>
Status:
{' '}
<select onChange={this.onChangeStatus}>
<option value="">(All)</option>
<option value="New">New</option>
<option value="Assigned">Assigned</option>
<option value="Fixed">Fixed</option>
<option value="Closed">Closed</option>
</select>
</div>
);
}
}
export default withRouter(IssueFilter);
Listing 9-16ui/src/IssueFilter.jsx: New Implementation of IssueFilter
如果您现在测试应用,您会发现当在下拉列表中选择不同的项目时,问题列表会发生变化。要查看它是否适用于除“新”和“已分配”之外的状态,您必须直接在 MongoDB 中或通过 Playground 添加更多关于其他状态的问题。图 9-5 中显示了应用的屏幕截图,其中问题列表已根据新问题进行了过滤。
图 9-5
使用下拉列表过滤的问题列表
练习:程序化导航
-
我们用的是
history的push()方法。还有什么方法可以使用,效果会有什么不同?提示:在https://reacttraining.com/react-router/web/api/history查阅history的文档。试试看。(记得在练习后恢复更改。) -
过滤问题列表,例如,新建。现在,保持开发人员控制台打开,并在浏览器中单击刷新。下拉菜单是否反映了过滤器的状态?再次在下拉列表中选择新建。你看到了什么?这是什么意思?
-
IssueList组件可以访问history对象。因此,不要在IssueFilter上使用withRouter,你可以将history对象从IssueList传递到IssueFilter,或者传递一个回调到IssueFilter,设置一个新的过滤器并从子组件调用它。比较这些选择。与使用withRouter的原始方法相比,有哪些优点和缺点?
本章末尾有答案。
嵌套路由
在显示对象列表的同时显示一个对象的细节的常见模式是使用 header-detail UI 模式。这与一些电子邮件客户端相同,特别是 Outlook 和 Gmail,它们可以纵向或横向拆分使用。对象列表显示了关于它们的简要信息(每封电子邮件的发件人和主题),选择其中一个对象后,所选对象(邮件本身)的更多详细信息将显示在详细信息区域。
问题跟踪器没有多大用处,除非它能够存储每个问题的详细描述和不同用户的评论。因此,与电子邮件客户端类似,让我们为问题添加一个描述字段,它可能是很长的文本,不适合显示在问题列表中。让我们也这样做,以便在选择一个问题时,页面的下半部分显示该问题的描述。
这需要嵌套路由,其中路径的开始部分描述了页面的一个部分,并且基于该页面内的交互,路径的后面部分描述了变化,或者对页面中额外显示的内容的进一步定义。在 Issue Tracker 应用的情况下,除了问题列表之外,我们将让/issues显示问题列表(没有详细信息),让/issues/1显示详细信息部分,其中包含对 ID 为 1 的问题的描述。
React Router 通过其动态路由理念使这一点变得容易。在组件层次结构中的任何一点,都可以添加一个Route组件,如果 URL 与 route 的路径匹配,就会呈现这个组件。在 Issue Tracker 应用中,我们可以定义这样一个Route,其实际组件是问题细节,在IssueList中,就在IssueAdd部分之后。路径可以是/issues/<id>的形式,类似于IssueEdit组件的路径匹配,如下所示:
...
<IssueAdd createIssue={this.createIssue} />
<hr />
<Route path="/issues/:id" component={IssueDetail} />
...
因此,与快速路由不同,React 路由的路由不需要全部预先声明;它们可以放置在任何级别,并在渲染过程中进行评估。
但是在我们做这个改变之前,让我们修改模式来添加一个描述字段。我们将在类型Issue和类型IssueInputs中这样做。我们还需要一个新的 API,它可以检索给定 ID 的单个问题。这个 API 是组件IssueDetail用来获取描述的,而IssueTable不会获取描述。让我们简单地称这个 API 为issue,它接受一个整数作为参数来指定要获取的问题的 ID。清单 9-17 中列出了schema.graphql的变更。
...
type Issue {
...
description: String
}
...
input IssueInputs {
...
description: String
}
...
type Query {
...
issue(id: Int!): Issue!
}
...
Listing 9-17api/schema.graphql: Changes for a New Field in Issue and a New Get API
接下来,让我们实现 API 来获得一个问题。这相当简单:我们需要做的就是使用id参数创建一个 MongoDB 过滤器,并使用这个过滤器在issues集合上调用findOne()。让我们调用这个函数get()并将它和其他从issue.js导出的函数一起导出。这组更改如清单 9-18 所示。
...
async function get(_, { id }) {
const db = getDb();
const issue = await db.collection('issues').findOne({ id });
return issue;
}
async function list(_, { status }) {
...
}
...
module.exports = { list, add, get };
...
Listing 9-18api/issue.js: Implementation of New Function get() to Fetch a Single Issue
最后,我们需要在提供给 Apollo 服务器的解析器中绑定新函数。清单 9-19 中显示了对api_handler.js的更改。
const resolvers = {
Query: {
...
issue: issue.get,
},
...
Listing 9-19api/api_handler.js
此时,您可以使用 Playground 测试新的 API。您可以创建一个带有描述字段的新问题,使用issue查询获取它,并查看描述是否被返回。为了方便起见,我们还可以修改模式初始化器脚本,为初始问题集添加一个描述字段。清单 9-20 中显示了对init.mongo.js的更改。
...
const issuesDB = [
{
...
description: 'Steps to recreate the problem:'
+ '\n1\. Refresh the browser.'
+ '\n2\. Select "New" in the filter'
+ '\n3\. Refresh the browser again. Note the warning in the console:'
+ '\n Warning: Hash history cannot PUSH the same path; a new entry'
+ '\n will not be added to the history stack'
+ '\n4\. Click on Add.'
+ '\n5\. There is an error in console, and add doesn\'t work.',
},
{
...
description: 'There needs to be a border in the bottom in the panel'
+ ' that appears when clicking on Add',
},
];
...
Listing 9-20api/scripts/init.mongo.js: Addition of Description to Sample Issues
您可以使用通常的命令运行这个脚本来初始化数据库,以便描述与您的测试和本章中的屏幕截图相匹配:
$ mongo issuetracker api/scripts/init.mongo.js
如果运行该脚本,您可能必须从主页链接开始,因为它可能已经删除了一些您手动创建的问题。否则,如果 UI 引用这些问题,您可能会得到一个 GraphQL 错误,大意是Query.issue不能为 null。
现在,我们可以实现IssueDetail组件了。作为该组件的一部分,我们将执行以下操作:
-
我们将维护状态,其中将包含一个问题对象。
-
像在
IssueEdit组件中一样,将从match.params.id中检索发布对象的 ID。 -
问题对象将通过使用
fetch()API 的issueGraphQL 查询以一种叫做loadData()的方法提取,并设置为状态。 -
方法
loadData()将在组件安装后(第一次)或 ID 改变时(在componentDidUpdate()中)被调用。 -
在
render()方法中,我们将使用<pre>标签显示描述,以便在显示中保持换行。
在一个名为IssueDetail.jsx的新文件中,组件的完整代码如清单 9-21 所示。
import React from 'react';
import graphQLFetch from './graphQLFetch.js';
export default class IssueDetail extends React.Component {
constructor() {
super();
this.state = { issue: {} };
}
componentDidMount() {
this.loadData();
}
componentDidUpdate(prevProps) {
const { match: { params: { id: prevId } } } = prevProps;
const { match: { params: { id } } } = this.props;
if (prevId !== id) {
this.loadData();
}
}
async loadData() {
const { match: { params: { id } } } = this.props;
const query = `query issue($id: Int!) {
issue (id: $id) {
id description
}
}`;
const data = await graphQLFetch(query, { id });
if (data) {
this.setState({ issue: data.issue });
} else {
this.setState({ issue: {} });
}
}
render() {
const { issue: { description } } = this.state;
return (
<div>
<h3>Description</h3>
<pre>{description}</pre>
</div>
);
}
}
Listing 9-21ui/src/IssueDetail.jsx: New Component to Show the Description of an Issue
为了将IssueDetail组件集成到IssueList组件中,我们需要添加一条路由,如本节开头所讨论的。但是,不要硬编码/issues,让我们使用父组件中匹配的路径,使用this.props.match.path。这样,即使父路径由于任何原因发生更改,更改也会被隔离到一个位置。
这一变化以及必要的导入如清单 9-22 所示。
...
import URLSearchParams from 'url-search-params';
import { Route } from 'react-router-dom';
...
import IssueAdd from './IssueAdd.jsx';
import IssueDetail from './IssueDetail.jsx';
...
render() {
const { issues } = this.state;
const { match } = this.props;
...
<IssueAdd createIssue={this.createIssue} />
<hr />
<Route path={`${match.path}/:id`} component={IssueDetail} />
...
}
...
Listing 9-22ui/src/IssueList.jsx: Changes for Including IssueDetail in a Route
要选择一个问题,让我们在问题列表中的“编辑”链接旁边创建另一个链接。这一次,让我们使用一个NavLink来突出显示所选的问题。理想情况下,我们应该能够通过单击行中的任何位置来进行选择,并且在选择时应该高亮显示整行。但是让我们留到后面的章节,在那里我们将有更好的工具来实现这个效果。NavLink将指向/issues/<id>,其中<id>是所选行中问题的 ID。
此外,为了不丢失 URL 的查询字符串部分,我们必须将当前查询字符串作为搜索属性添加到链接的目标中。但是,要访问当前的查询字符串,我们需要访问当前的位置,由于IssueRow没有显示为Route的一部分,我们必须通过用withRouter包装组件来注入位置。
对IssueTable.jsx文件的修改如清单 9-23 所示。
...
import React from 'react';
import { Link, NavLink, withRouter } from 'react-router-dom';
...
function IssueRow({ issue }) {
const IssueRow = withRouter(({ issue, location: { search } }) => {
const selectLocation = { pathname: `/issues/${issue.id}`, search };
...
<td>{issue.title}</td>
<td><Link to={`/edit/${issue.id}`}>Edit</Link></td>
<td>
<Link to={`/edit/${issue.id}`}>Edit</Link>
{' | '}
<NavLink to={selectLocation}>Select</NavLink>
</td>
</tr>
...
}
});
Listing 9-23ui/src/IssueTable.jsx: Addition of a Link to Select an Issue for Display in the Details Section
如果您现在测试这个应用,您会在每个问题的编辑链接旁边找到一个选择链接。单击此链接应该会更改 URL,以便将问题的 ID 附加到主路径,但在查询字符串(如果有)之前。您应该在有过滤器和没有过滤器的情况下进行尝试,以确保它在两种情况下都有效,并且刷新会继续显示所选问题的描述。
选中 ID 1 问题的页面截图如图 9-6 所示。
图 9-6
选定的问题和描述
练习:嵌套布线
- 在呈现
IssueList时,我们可以不使用Route,而是将问题列表的路由路径定义为/issues/:id,然后将传递 ID 的IssueDetail显示为 props 的一部分。比较获得相同结果的两种方法。有哪些利弊?
本章末尾有答案。
浏览器历史路由
在本章的开始,我们讨论了两种路由——基于散列的和基于浏览器历史的。如果我们自己来做的话,基于散列的路由很容易理解和实现:只需在转换时改变 URL 的锚部分就足够了。此外,服务器必须只返回对/的请求的index.html,而不返回其他的。
但是使用基于散列的路由的缺点是当服务器需要响应不同的 URL 路径时。想象一下在浏览器上点击刷新。当使用基于散列的路由时,浏览器从服务器向/发出请求,而不管#或实际路由路径之后是什么。如果我们必须让服务器以不同的方式处理这种刷新,更好的策略是对不同的路由使用不同的 URL 基础(也就是说,没有#和它后面的内容)。
当我们需要支持对搜索引擎爬虫的响应时,这种需求(从服务器本身对不同的路由做出不同的响应)就出现了。这是因为,对于爬虫找到的每个链接,如果基 URL 不同,就会产生一个新的请求。如果跟在#后面的是不同的,爬虫会认为它只是页面中的一个锚点,并且不管路径是什么,只对/发出请求。
为了使我们的应用搜索引擎友好,使用基于浏览器历史的路由是必要的。但这还不是全部,服务器还必须响应整个页面。相反,对于浏览器请求,页面将在浏览器上构建。我们还不会生成要显示的页面,因为实现它相当复杂,它应该有自己的一章。现在,我们将切换到基于浏览器历史的路由,但是假设页面是通过只操纵 DOM 来构造的。
切换到使用这种新路由就像改变import语句并使用BrowserRouter而不是HashRouter一样简单。该组件通过使用 HTML5 历史 API ( pushState、replaceState和popState)来保持 UI 与 URL 同步,从而实现路由。
这一变化显示在清单 9-24 的App.jsx中。
...
import ReactDOM from 'react-dom';
import { HashRouter BrowserRouter as Router } from 'react-router-dom';
...
Listing 9-24ui/src/App.jsx: Changes for Using Browser History Based Router
要测试这个变化,就得从原点位置开始,也就是http://localhost:8000。该应用将似乎工作,所有的链接将 Bootstrap 您到正确的页面和视图。此外,您会发现这些 URL 将没有一个#,而对于问题列表页面来说,它们只是简单的 URL,如http://localhost:8000/issues。
但是任何视图的刷新都将失败。例如,在“问题列表”页面中,如果刷新浏览器,您将在屏幕上看到以下消息:
Cannot GET /issues
这是因为浏览器中的 URL 当前指向/issues并且浏览器向服务器请求/issues,这不是由 UI 服务器处理的。为了解决这个问题,我们需要对 UI 服务器进行更改,它会为任何未被处理的URL 返回index.html。这可以通过在路径*的所有其他路由之后安装一个快速路由来实现,该路由读取index.html的内容并将其返回。
response对象有一个方便的方法叫做sendFile()。但是出于安全原因,必须指定文件的完整绝对路径——它不接受相对路径。让我们使用内置 Node.js 模块path中的path.resolve()将相对路径转换为绝对路径。对uiserver.js的更改如清单 9-25 所示。
...
require('dotenv').config();
const path = require('path');
...
app.get('/env.js', (req, res) => {
...
});
app.get('*', (req, res) => {
res.sendFile(path.resolve('public/index.html'));
});
...
Listing 9-25ui/uiserver.js: Respond with index.html for All Requests
如果您在做出这一更改后测试应用,您会发现任何页面上的刷新都可以像以前一样工作。测试公共目录中的其他文件是否得到了正确的服务也是一个好主意,特别是,app.bundle.js和vendor.bundle.js。
但是在正常的开发模式下,HMR 会提供这些包,而不是让 UI 服务器从公共目录中获取它们。因此,您需要禁用 HMR(通过将环境变量ENABLE_HMR设置为false),使用npm run compile手动编译包,然后启动 UI 服务器。然后,在刷新应用时,您应该看到这些文件被正确地从服务器中检索出来。完成测试后,不要忘记将更改恢复到 HMR。
仍有一项影响 HMR 运作的变革有待完成。Webpack 在output下有一个名为publicPath的配置选项。当使用按需加载或加载图像、文件等外部资源时,这是一个重要的选项。但是到目前为止我们还没有使用它们,没有将它们设置为任何值也不会影响应用的功能。该值默认为空字符串,这意味着与当前页面的位置相同。
原来,当模块发生变化并被 HMR 重新编译时,Webpack 使用publicPath的值来获取模块的更新信息。因此,如果您在某个位置(如/edit/1或/issues/1)更改源文件,您会发现 HMR 调用失败。如果你查看开发者工具的网络选项卡,你会发现这些请求返回的是index.html的内容,而不是模块更新信息。
当浏览器指向/issues和/issues/1时,您可以通过查看源文件改变时发生的情况来比较这两个请求和响应。在第一种情况下,您将看到对像/f3f397176a7b9c3237cf.hot-update.json这样的资源的请求,它成功了。而在第二种情况下,就会像/edit/f3f397176a7b9c3237cf.hot-update.json一样,失败。这是因为 Webpack 正在向当前位置发出请求相对于。这个请求不能被热的中间件匹配,所以它失败到 catch-all Express route,它返回index.html的内容。
我们在使用基于散列的路由时没有遇到这个问题,因为页面的位置总是/,路由受到 URL 的锚部分的影响。正确的请求应该没有前缀/edit。为了实现这一点,我们必须改变webpack.config.js来设置publicPath配置。对此的更改如清单 9-26 所示。
...
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'public'),
publicPath: '/',
},
...
Listing 9-26ui/webpack.config.js: Changes to Add publicPath
经过这次修改后,你会发现 HMR 在应用的任何页面上都能正常工作。
练习:浏览器历史路由
-
如果我们在应用中使用
hrefs 而不是Links 作为超链接,那么过渡到使用BrowserRouter会不会同样简单?还需要做哪些改变? -
现在让我们根据效果来比较使用
href和Link的情况。在Link之外增加一个href,用于在问题表中导航编辑。点击这两个链接,比较发生了什么。(提示:使用开发人员工具的“网络”选项卡来检查网络流量。)现在,用
HashHistory做同样的对比(注意:你得在href里用/#/edit,/edit不行。)现在,有区别吗?试着解释你所看到的。(记得在练习后还原实验变化。)
本章末尾有答案
摘要
在本章中,您学习了如何实现客户端路由,即根据菜单或导航栏中的链接显示不同的页面。React 路由库对此有所帮助。
您还了解了如何将浏览器中的 URL 与页面中显示的内容连接起来,以及如何使用参数和查询字符串来调整页面内容。正确实现路由是让用户在点击超链接和使用浏览器中的后退/前进按钮时有一种自然感觉的关键。此外,将浏览器中的 URL 连接到页面上的内容不仅有助于我们以有组织的方式思考不同的页面或视图,还可以帮助用户将链接添加到书签中并使用浏览器的刷新按钮。
在下一章,我们将探索如何处理企业应用中一个非常常见的事件:表单,React 方式。当我们这样做时,我们还将通过实现对问题的更新和删除操作来完成对问题对象的 CRUD。
练习答案
练习:简单路由
-
如果从
Redirect组件中删除了exact属性,您将看到内容部分是空白的,不管单击的是什么超链接。这是因为所有的URL 现在都匹配第一条路由。因为没有为路线定义组件,所以页面是空白的。此外,您还会在控制台中看到一个错误,提示您试图重定向到相同的路由。这是因为即使在导航中,相同的路线(??)也是匹配的。通过对路由重新排序,您几乎可以实现所需的行为:重定向可以放在两条路由之后、全部捕获之前。现在,
/issues和/report路径将与前两条路径匹配,并在那里停止。如果两者都不匹配,那么任何其他路由将匹配/,并将重定向到/issues。这与之前的行为并不完全相同,因为它将总是重定向到/issues,而不是显示未找到的页面。 -
如果您将
<Switch>替换为<div>,您会发现除了问题列表或报告占位符之外,始终会显示“未找到页面”消息。这是因为匹配不会在第一次匹配时停止,而是向显示所有匹配路线的组件。NotFound组件的路径(空)匹配任何路径,因此总是显示。 -
URL /
#/issues000显示未找到的页面,而/#/issues/000显示没有exact属性的问题列表,否则显示未找到的页面。这表明非精确路由匹配路径的完整段,每段由/分隔。这不是简单的前缀匹配。
练习:查询参数
-
当一个组件的属性改变时,React 会自动调用一个
render()。当属性的改变只影响渲染时,我们不需要做任何进一步的工作。问题列表中的不同之处在于属性的变化导致了状态的变化。这种变化必须在某个地方被触发,我们选择了生命周期方法
componentDidUpdate()来做这件事。最终,即使在问题编辑页面中,当我们在对服务器的异步调用中加载问题细节时,我们也必须实现componentDidUpdate()方法。 -
如果不检查新旧属性是否相同,就会导致无限循环。这是因为一个新的状态也被认为是对组件的更新,因此再次调用
componentDidUpdate(),这个循环将无休止地继续下去。 -
父组件中的任何更改都会触发子组件中的渲染,因为假设父组件的状态也会影响子组件。通常,这不是一个性能问题,因为重新计算的只是虚拟 DOM。由于新旧虚拟 DOM 将是相同的,所以实际的 DOM 将不会被更新。
对虚拟 DOM 的更新并不昂贵,因为它们只不过是内存中的数据结构。但是,在极少数情况下,特别是当组件层次非常深并且受影响的组件数量非常大时,更新虚拟 DOM 的行为可能需要一些时间。这可以通过挂钩生命周期方法
shouldComponentUpdate()并确定渲染是否有保证来优化。
练习:链接
-
如果你使用
NavLinks,你会发现所有的链接总是高亮显示。那是因为Link只匹配 URL 和链接的路径,并不认为查询字符串是路径的一部分。因为所有链接的路径都是/issues,所以它们总是匹配的。与路径参数相比,查询参数不是一个有限集,因此,不鼓励用于导航链接。如果过滤器是导航链接,我们应该像对待主导航栏一样使用路由参数。
-
NavLink组件的activeClassName属性决定了当链接活动时添加的类。您可以将该属性设置为current值,以获得想要的效果。
练习:程序化导航
-
可以使用
history.replace()方法,它替换当前的 URL,这样历史记录就没有旧的位置。另一方面,router.push()确保用户可以使用 back 按钮返回到之前的视图。当两条路线没有真正不同时,可以使用替换。它类似于 HTTP 重定向,其中请求的内容是相同的,但是在不同的位置可用。在这种情况下,记住第一个位置作为浏览器历史的一部分是没有用的。
-
刷新时,下拉菜单重置为默认值
All。但是列表是根据下拉列表的前一个选择进行过滤的,这反映在 URL 中作为查询字符串的一部分。我们将在下一章讨论表单时同步下拉列表值和查询字符串。如果下拉列表值更改为选择原始状态,开发人员控制台会显示一条警告:
Hash history cannot PUSH the same path; a new entry will not be added to the history stack.
由于路径相同,哈希历史拒绝推送路径,因此组件不会更新。
-
包装函数
withRouter有点难以理解。其他选项很容易理解,甚至看起来更简单。但是想象一个更加嵌套的层次结构,其中IssueFilter在IssueList中不止一层。在这种情况下,history对象必须通过所有中间组件,增加所有这些组件之间的耦合。让
IssueFilter直接操作 URL 减少了耦合,让每个组件处理一个单独的职责。对于IssueFilter,它是一个设置 URL 的任务,对于IssueList,它是一个使用来自 URL 的查询字符串的任务,不管它是如何设置的。
练习:嵌套布线
-
这两种方法之间的差别并不大,也可能是一致性的问题。无论如何,
Route所做的就是匹配 URL,如果匹配就显示一个组件。因为匹配是作为IssueList的一部分发生的,所以嵌套路由并没有增加多少,至少在这种情况下是这样。因此,显示包装在if条件中的IssueDetail组件(在存在 ID 的情况下)就可以了。另一个考虑因素是子组件在层次结构中的嵌套深度。在
IssueDetail的情况下,它只有一层深度,从IssueList到IssueDetail的 ID 传递非常简单。如果嵌套路由的组件嵌套很深,那么 ID 必须通过多个其他组件传递,所以对于IssueDetail来说,通过路由本身从 URL 获取这个参数可能更容易。
练习:浏览器历史路由
-
如果我们没有使用
Links,我们将不得不改变所有的hrefs 来删除#/前缀。这是使用Links 与普通hrefs 相比的一个优势 -
当使用
BrowserHistory时,href使浏览器导航到另一个 URL,从而向服务器发起请求,获取页面,然后呈现它。相比较而言,Link不会对服务器产生新的请求;它只是在浏览器中更改 URL,并通过替换需要为新路由显示的组件,以编程方式处理这一更改。当使用
HashHistory时,这两种方法没有明显的不同,因为基本 URL 总是相同的(/)。即使点击href,浏览器也不会向服务器发出新的请求,因为基本 URL 不会改变。*