在前一章中,我们开始通过改变架构和增加对编码标准和最佳实践的检查来进行组织。在这一章中,我们将进一步把代码分成多个文件,并添加工具来简化开发过程。我们将使用 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 服务器代码模块化之前一样工作。
在这一节中,我们将处理前端,或者 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,T6 又会导入其他三个组件。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小得多的增量文件。*

